前言
系列: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 本质就是一个可以接收事件的协议,定义了接收Event<Element>的能力。subscribe(onNext:onError:onCompleted:onDisposed:)底层,就是把你传入的闭包打包成一个 Observer。
// 一个最简单的Observer let observer = AnyObserver<String> { 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绑定场景。
二、Binder:UI绑定专用Observer(RxCocoa核心)
Binder是继承自Observer 的UI专用订阅者,自带两条强保障:
强制主线程执行:不管事件从哪个线程过来,Binder内部自动切到主线程回调,不用手动写
observeOn(MainScheduler.instance)不接受error事件:如果上游抛出error,Binder直接断言崩溃。
设计思想:UI绑定逻辑,不应该让上游error直接把UI订阅干掉。UI状态流不允许因为一次网络错误直接终结。
Binder 基础示例
let label = UILabel() // 创建Binder,专门设置label文本 let labelTextBinder = Binder<String>(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 = PublishRelay<String>() 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 = BehaviorRelay<String>(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:带初始值,保存最新值,新订阅能拿到当前状态;适合状态存储
四、实战Demo
1.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类型是Observable<String?>,可选字符串;orEmpty转为非空String- 输入框每输入一个字符,就发射next事件
- 直接bind到label.rx.text(Binder),自动主线程更新UI
- 页面pop,disposeBag释放,订阅自动销毁
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 = BehaviorRelay<String>(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 = PublishRelay<Void>() /// 登录失败事件(附带错误信息) private let loginFailure = PublishRelay<String>() /// 忘记密码事件 private let forgotPasswordTapped = PublishRelay<Void>() 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,这里一次性讲清楚。 Subject:PublishSubject/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强行发送error:Relay没有onError方法,编译层面直接阻止
本篇小结
- Observer 是事件接收者,可以接收next/error/completed,不保证线程。
- Binder 是RxCocoa提供UI专用Observer:自动主线程、不允许error,UI绑定使用
bind(to:)。 - PublishRelay:热信号,无缓存,订阅之后接收事件,适合一次性事件。
- BehaviorRelay:热信号,保存最新值,新订阅可以拿到当前状态,MVVM状态首选。
- Relay不能发送error,相比Subject更加安全,业务开发优先Relay。
思考题
textField.rx.text属于冷信号还是热信号?- 如果把BehaviorRelay交给bind(to:)绑定UI,上游会不会出现序列中断?
- 什么时候你会选择Subject而不是Relay?