第2篇|Observer、Binder、Relay,RxCocoa UI 绑定核心
前言系列RxSwift 从入门到精通RxSwift6 RxCocoa 前置第1篇掌握Observable、订阅、DisposeBag、冷序列 本文目标搞懂Observer的本质理解Binder为什么是UI绑定首选吃透PublishRelay / BehaviorRelay完成UITextField实时双向联动Demo分清Relay和Subject选型差异。阅读提示本篇开始大量对接UIKit是日常业务写Rx代码最高频的知识点。上一篇我们学习了 Observable事件生产者负责往外推送.next/.error/.completed。 有生产者就一定有消费者Observer订阅者。 Observable 推事件Observer 收事件subscribe就是把二者连接起来。Observable生产事件 → Observer消费事件一、Observer 是什么Observer 本质就是一个可以接收事件的协议定义了接收EventElement的能力。subscribe(onNext:onError:onCompleted:onDisposed:)底层就是把你传入的闭包打包成一个 Observer。// 一个最简单的Observer let observer AnyObserverString { event in switch event { case .next(let str): print(收到数据\(str)) case .error(let err): print(收到错误\(err)) case .completed: print(序列完成) } } let observable Observable.just(Hello Observer) observable.subscribe(observer)Observable 调用subscribe(observer)把 observer 传入序列内部当事件产生时回调给这个 observer。Observer 的特点Observer 可以接收三类事件next / error / completed一旦收到 error序列终止Observer不再接收后续事件Observer 不绑定线程事件在事件产生的线程回调问题来了UI更新必须在主线程。如果后台线程的事件直接给到普通Observer去更新UI就会触发UI线程崩溃。 于是RxCocoa提供了Binder专门解决UI绑定场景。二、BinderUI绑定专用ObserverRxCocoa核心Binder是继承自Observer 的UI专用订阅者自带两条强保障强制主线程执行不管事件从哪个线程过来Binder内部自动切到主线程回调不用手动写observeOn(MainScheduler.instance)不接受error事件如果上游抛出errorBinder直接断言崩溃。设计思想UI绑定逻辑不应该让上游error直接把UI订阅干掉。UI状态流不允许因为一次网络错误直接终结。Binder 基础示例let label UILabel() // 创建Binder专门设置label文本 let labelTextBinder BinderString(label) { label, text in label.text text } Observable.just(来自Binder的文本) .bind(to: labelTextBinder) .disposed(by: disposeBag)bind(to:)是RxCocoa专门给Binder设计的订阅方法。 和subscribe的区别subscribe普通订阅可以接收error线程不保证bind(to:)只能绑定Binder不处理error自动主线程我们平时写的label.rx.text本质就是RxCocoa预先封装好的Binder// 等价于上面示例RxCocoa已经帮我们封装好Binder Observable.just(Hello RxCocoa) .bind(to: label.rx.text) .disposed(by: disposeBag)黄金准则UI视图绑定一律优先使用 bind(to:) Binder不要用subscribe。三、RxRelay业务状态首选热信号重点前面讲的Observable大多是冷信号。但是业务开发里我们经常需要保存状态、随时推送新值并且不能因为error导致整个状态流挂掉。 RxRelay 提供两个核心类PublishRelay、BehaviorRelay属于热信号永远不会发送error事件。重要Relay 只有一个方法接收事件.accept(value)没有onError Relay 内部不允许发送error所以Relay序列永远不会因为error终止。1. PublishRelay订阅之后才能收到后续新事件订阅前发出的事件丢失。 类比直播开播之后才进来的观众看不到开播前的消息。let relay PublishRelayString() print( 第一个订阅者进来) relay.subscribe(onNext: { print(sub1: \($0)) }) relay.accept(消息A) print( 第二个订阅者进来) relay.subscribe(onNext: { print(sub2: \($0)) }) relay.accept(消息B)输出 第一个订阅者进来 sub1: 消息A 第二个订阅者进来 sub1: 消息B sub2: 消息B消息A在第二个订阅订阅之前发出sub2收不到。适用场景一次性事件、消息通知弹窗弹出、页面跳转信号不需要保存当前值。2. BehaviorRelay项目最常用保存当前最新值新订阅者一订阅立刻收到当前存储的值后续继续接收新事件。 类比直播间有弹幕缓存新人进来先看到最新一条弹幕之后继续接收新弹幕。初始化必须给一个默认值。// 初始化必须传入默认值 let relay BehaviorRelayString(value: 初始文本) print( 第一个订阅) relay.subscribe(onNext: { print(sub1: \($0)) }) relay.accept(修改文本A) print( 第二个订阅) relay.subscribe(onNext: { print(sub2: \($0)) }) relay.accept(修改文本B)输出 第一个订阅 sub1: 初始文本 sub1: 修改文本A 第二个订阅 sub2: 修改文本A sub1: 修改文本B sub2: 修改文本B第二个订阅进来立刻拿到当前最新值修改文本A。读取当前值BehaviorRelay 可以通过.value直接同步读取当前保存的值let currentText relay.value print(currentText)✅ 适用场景页面状态、表单输入值、全局状态MVVM中ViewModel的输出状态首选。对比一句话记忆 PublishRelay无初始值订阅前消息丢掉适合事件信号 BehaviorRelay带初始值保存最新值新订阅能拿到当前状态适合状态存储四、实战Demo1.Binder的例子最经典RxCocoa场景输入框实时输入Label同步展示文字。具体的UIxi效果图如下图1.UITextField和UILabel绑定// // RxSwiftUIBindDemosVC.swift // RxSwiftExamples // // Created by iFlyCai on 2026/10/20. // import UIKit import SnapKit import RxSwift import RxCocoa class RxSwiftUIBindDemosVC: UIViewController { private let disposeBag DisposeBag() // MARK: - UI 控件 private let cardView UIView() // 文本输入区 private let textField UITextField() private let echoLabel UILabel() private let countLabel UILabel() override func viewDidLoad() { super.viewDidLoad() title Binder UI 绑定 view.backgroundColor .white setupCard() bindUI() } // MARK: - 布局 private func setupCard() { view.backgroundColor .white let lineView UIView() lineView.backgroundColor UIColor.separator let textSection makeSectionTitle(文本输入绑定) textField.placeholder 输入内容... textField.borderStyle .roundedRect textField.font .systemFont(ofSize: 12) echoLabel.font .systemFont(ofSize: 14) echoLabel.textColor .darkGray echoLabel.text 回显 countLabel.font .systemFont(ofSize: 13) countLabel.textColor .secondaryLabel countLabel.text 字符数0 view.addSubview(lineView) view.addSubview(textSection) view.addSubview(textField) view.addSubview(echoLabel) lineView.snp.makeConstraints { make in make.leading.trailing.equalToSuperview() make.top.equalTo(view.safeAreaLayoutGuide) make.height.equalTo(1.0 / traitCollection.displayScale) } textSection.snp.makeConstraints { make in make.top.equalTo(lineView.snp.bottom).offset(20) make.leading.trailing.equalToSuperview().offset(20) } textField.snp.makeConstraints { make in make.top.equalTo(textSection.snp.bottom).offset(20) make.leading.trailing.equalToSuperview().offset(20) } echoLabel.snp.makeConstraints { make in make.top.equalTo(textField.snp.bottom).offset(20) make.leading.trailing.equalTo(textField) } } private func makeSectionTitle(_ text: String) - UILabel { let label UILabel() label.text text label.font .systemFont(ofSize: 12, weight: .semibold) label.textColor .secondaryLabel return label } // MARK: - 绑定 private func bindUI() { textField.rx.text.bind(to: echoLabel.rx.text).disposed(by: disposeBag) } }textField.rx.text类型是ObservableString?可选字符串orEmpty转为非空String输入框每输入一个字符就发射next事件直接bind到label.rx.textBinder自动主线程更新UI页面popdisposeBag释放订阅自动销毁2.BehaviorRelay的例子我们以需改用户昵称的demo为例UI效果图如下图2.修改用户昵称初始状态下用户昵称为“游客”点击修改昵称按钮我们修改用户的昵称这个时候我们BehaviorRelay给昵称赋一个初始值点击按钮之后获取最新的值。完整代码如下import UIKit import RxSwift import RxCocoa import SnapKit final class BehaviorRelayDemosVC: UIViewController { private let disposeBag DisposeBag() // MARK: - 容器 private let scrollView UIScrollView() private let contentStack UIStackView() // MARK: - UI 控件 private let avatarLabel UILabel() private let nameLabel UILabel() private let valueLabel UILabel() private let changeNameBtn UIButton(type: .system) // MARK: - 状态 /// 状态容器当前昵称BehaviorRelay 必须有初始值 private let userName BehaviorRelayString(value: 游客) // MARK: - 生命周期 override func viewDidLoad() { super.viewDidLoad() setupUI() bindUI() } // MARK: - 布局 private func setupUI() { title BehaviorRelay 用法 view.backgroundColor .systemGroupedBackground scrollView.alwaysBounceVertical true scrollView.showsVerticalScrollIndicator false view.addSubview(scrollView) contentStack.axis .vertical contentStack.spacing 16 scrollView.addSubview(contentStack) scrollView.snp.makeConstraints { $0.edges.equalTo(view.safeAreaLayoutGuide) } contentStack.snp.makeConstraints { $0.top.bottom.equalTo(scrollView.contentLayoutGuide).inset(20) $0.leading.trailing.equalTo(scrollView.frameLayoutGuide).inset(20) } contentStack.addArrangedSubview(makeIntroCard()) contentStack.addArrangedSubview(makeProfileCard()) } /// 顶部说明卡片 private func makeIntroCard() - UIView { let card makeCard() let titleLabel UILabel() titleLabel.text BehaviorRelay titleLabel.font .systemFont(ofSize: 22, weight: .bold) titleLabel.textColor .label let subtitleLabel UILabel() subtitleLabel.text 自带初始值、可随时直读 value、只会发出 next 的 UI 状态容器。 subtitleLabel.font .systemFont(ofSize: 13) subtitleLabel.textColor .secondaryLabel subtitleLabel.numberOfLines 0 card.addSubview(titleLabel) card.addSubview(subtitleLabel) titleLabel.snp.makeConstraints { $0.top.leading.trailing.equalToSuperview().inset(20) } subtitleLabel.snp.makeConstraints { $0.top.equalTo(titleLabel.snp.bottom).offset(6) $0.leading.trailing.bottom.equalToSuperview().inset(20) } return card } /// 昵称展示卡片 private func makeProfileCard() - UIView { let card makeCard() // 头像取昵称首字 avatarLabel.textAlignment .center avatarLabel.font .systemFont(ofSize: 28, weight: .semibold) avatarLabel.textColor .white avatarLabel.backgroundColor .systemBlue avatarLabel.layer.cornerRadius 32 avatarLabel.layer.masksToBounds true nameLabel.font .systemFont(ofSize: 20, weight: .semibold) nameLabel.textColor .label nameLabel.textAlignment .center let captionLabel UILabel() captionLabel.text 当前昵称 captionLabel.font .systemFont(ofSize: 12) captionLabel.textColor .secondaryLabel captionLabel.textAlignment .center changeNameBtn.setTitle(修改昵称, for: .normal) changeNameBtn.titleLabel?.font .systemFont(ofSize: 15, weight: .semibold) changeNameBtn.setTitleColor(.white, for: .normal) changeNameBtn.backgroundColor .systemBlue changeNameBtn.layer.cornerRadius 12 let divider UIView() divider.backgroundColor .separator let valueRow makeRow(caption: userName.value, valueLabel: valueLabel) [avatarLabel, nameLabel, captionLabel, changeNameBtn, divider, valueRow] .forEach { card.addSubview($0) } avatarLabel.snp.makeConstraints { $0.top.equalToSuperview().inset(24) $0.centerX.equalToSuperview() $0.size.equalTo(64) } nameLabel.snp.makeConstraints { $0.top.equalTo(avatarLabel.snp.bottom).offset(12) $0.leading.trailing.equalToSuperview().inset(20) } captionLabel.snp.makeConstraints { $0.top.equalTo(nameLabel.snp.bottom).offset(4) $0.leading.trailing.equalToSuperview().inset(20) } changeNameBtn.snp.makeConstraints { $0.top.equalTo(captionLabel.snp.bottom).offset(20) $0.leading.trailing.equalToSuperview().inset(20) $0.height.equalTo(46) } divider.snp.makeConstraints { $0.top.equalTo(changeNameBtn.snp.bottom).offset(20) $0.leading.trailing.equalToSuperview().inset(20) $0.height.equalTo(1.0 / traitCollection.displayScale) } valueRow.snp.makeConstraints { $0.top.equalTo(divider.snp.bottom).offset(14) $0.leading.trailing.bottom.equalToSuperview().inset(20) } return card } /// 左标题 右数值的键值行 private func makeRow(caption: String, valueLabel: UILabel) - UIView { let row UIView() let captionLabel UILabel() captionLabel.text caption captionLabel.font .monospacedSystemFont(ofSize: 13, weight: .regular) captionLabel.textColor .secondaryLabel valueLabel.font .systemFont(ofSize: 14, weight: .medium) valueLabel.textColor .label valueLabel.textAlignment .right valueLabel.text — valueLabel.setContentHuggingPriority(.required, for: .horizontal) valueLabel.setContentCompressionResistancePriority(.required, for: .horizontal) row.addSubview(captionLabel) row.addSubview(valueLabel) captionLabel.snp.makeConstraints { $0.leading.centerY.equalToSuperview() } valueLabel.snp.makeConstraints { $0.trailing.centerY.equalToSuperview() $0.leading.greaterThanOrEqualTo(captionLabel.snp.trailing).offset(16) } row.snp.makeConstraints { $0.height.equalTo(22) } return row } /// 统一卡片样式 private func makeCard() - UIView { let card UIView() card.backgroundColor .secondarySystemGroupedBackground card.layer.cornerRadius 16 card.layer.shadowColor UIColor.black.cgColor card.layer.shadowOpacity 0.05 card.layer.shadowOffset CGSize(width: 0, height: 2) card.layer.shadowRadius 8 return card } // MARK: - 绑定 private func bindUI() { // 昵称 头像首字 直读 value三处同步 userName .bind(to: nameLabel.rx.text) .disposed(by: disposeBag) userName .map { String($0.prefix(1)) } .bind(to: avatarLabel.rx.text) .disposed(by: disposeBag) userName .bind(to: valueLabel.rx.text) .disposed(by: disposeBag) // 状态变更随机换一个昵称 changeNameBtn.rx.tap .subscribe(onNext: { [weak self] in guard let self else { return } let names [张三, 李四, 王五, Ada] self.userName.accept(names.randomElement() ?? 游客) }) .disposed(by: disposeBag) } }3.PublishRelay的例子PublishRelay的特点如下只管“往后发”不管“以前发生过啥”。我们以登陆的场景为例写了一个简单的登陆demo,示例代码如下我们使用PublishRelay表示登陆成功的状态登陆成功进表示点击登陆按钮之后的一个瞬间并不需要持续保存状态。import UIKit import RxSwift import RxCocoa class PublishRelayDemosVC: UIViewController { // MARK: - UI private let usernameField UITextField() private let passwordField UITextField() private let loginButton UIButton(type: .system) private let forgotButton UIButton(type: .system) // MARK: - PublishRelay事件输出 /// 登录成功事件 private let loginSuccess PublishRelayVoid() /// 登录失败事件附带错误信息 private let loginFailure PublishRelayString() /// 忘记密码事件 private let forgotPasswordTapped PublishRelayVoid() private let disposeBag DisposeBag() // MARK: - Life Cycle override func viewDidLoad() { super.viewDidLoad() view.backgroundColor .white setupUI() bindUI() bindRelay() } // MARK: - UI 绑定 private func bindUI() { loginButton.rx.tap .subscribe(onNext: { [weak self] in self?.doLogin() }) .disposed(by: disposeBag) forgotButton.rx.tap .bind(to: forgotPasswordTapped) .disposed(by: disposeBag) } // MARK: - PublishRelay 订阅VC 内部 / 或给 VM 用 private func bindRelay() { // 登录成功 → 跳转首页 loginSuccess .subscribe(onNext: { [weak self] in self?.navigateToHome() }) .disposed(by: disposeBag) // 登录失败 → 弹 Toast loginFailure .subscribe(onNext: { [weak self] error in self?.showToast(error) }) .disposed(by: disposeBag) // 忘记密码 forgotPasswordTapped .subscribe(onNext: { [weak self] in self?.navigateToForgotPassword() }) .disposed(by: disposeBag) } // MARK: - 登录逻辑模拟 private func doLogin() { let username usernameField.text ?? let password passwordField.text ?? if username.isEmpty || password.isEmpty { // 失败事件 loginFailure.accept(用户名或密码不能为空) return } if password.count 6 { loginFailure.accept(密码长度不能少于6位) return } // 成功事件 loginSuccess.accept(()) } // MARK: - 页面跳转模拟 private func navigateToHome() { print( 跳转首页) showToast(登录成功 ) } private func navigateToForgotPassword() { print( 跳转忘记密码页) } // MARK: - Toast private func showToast(_ text: String) { print( Toast:, text) } // MARK: - UI 布局 private func setupUI() { title PublishRelay用法 usernameField.placeholder 请输入用户名 usernameField.borderStyle .roundedRect passwordField.placeholder 请输入密码 passwordField.borderStyle .roundedRect passwordField.isSecureTextEntry true loginButton.setTitle(登录, for: .normal) loginButton.backgroundColor .systemBlue loginButton.setTitleColor(.white, for: .normal) loginButton.layer.cornerRadius 8 forgotButton.setTitle(忘记密码, for: .normal) let stack UIStackView(arrangedSubviews: [ usernameField, passwordField, loginButton, forgotButton ]) stack.axis .vertical stack.spacing 16 stack.translatesAutoresizingMaskIntoConstraints false view.addSubview(stack) NSLayoutConstraint.activate([ stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 40), stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), usernameField.heightAnchor.constraint(equalToConstant: 44), passwordField.heightAnchor.constraint(equalToConstant: 44), loginButton.heightAnchor.constraint(equalToConstant: 48) ]) } }五、Relay vs Subject项目选型对比很多新手混淆Relay和Subject这里一次性讲清楚。 SubjectPublishSubject/BehaviorSubject同时是Observable Observer可以调用onNext / onError / onCompleted。 ⚠️ Subject坑一旦调用onError序列永久终结后续onNext全部无效。类型是否可发error新订阅能否拿到旧值推荐场景PublishRelay❌ 不能❌一次性事件、通知信号BehaviorRelay❌ 不能✅状态存储MVVM首选PublishSubject✅ 可以❌老项目兼容谨慎使用BehaviorSubject✅ 可以✅老项目兼容谨慎使用项目开发规范建议 ✅业务状态优先 Relay规避error导致流中断风险 ❌ 除非你明确需要主动发送error否则不要使用Subject六、常见新手坑❌ 把Relay用subscribe来更新UI不使用bind(to:) ✅ UI绑定尽量bind(to:)自带主线程保护❌ 忘记.orEmpty直接把String?绑定到只接受String的Binder类型报错❌ 用PublishRelay保存表单状态新页面订阅拿不到已有输入内容 ✅ 表单状态一定要BehaviorRelay❌ 给Relay强行发送errorRelay没有onError方法编译层面直接阻止本篇小结Observer 是事件接收者可以接收next/error/completed不保证线程。Binder 是RxCocoa提供UI专用Observer自动主线程、不允许errorUI绑定使用bind(to:)。PublishRelay热信号无缓存订阅之后接收事件适合一次性事件。BehaviorRelay热信号保存最新值新订阅可以拿到当前状态MVVM状态首选。Relay不能发送error相比Subject更加安全业务开发优先Relay。思考题textField.rx.text属于冷信号还是热信号如果把BehaviorRelay交给bind(to:)绑定UI上游会不会出现序列中断什么时候你会选择Subject而不是Relay