iOS 工程

为什么在 iOS 中使用 RxSwift·Combine?响应式编程解决的问题

学习 iOS 开发时,迟早会遇到 RxSwift 或 Combine 这道墙。招聘信息里几乎必然出现它们,但打开代码后,看到 map、flatMap、sink 连成一串的陌生语法,很容易感到困惑。“明明用闭包和代理也能正常运行,真的有必要学这个吗?”

5 分钟阅读
为什么在 iOS 中使用 RxSwift·Combine?响应式编程解决的问题 封面图

学习 iOS 开发时,迟早会遇到 RxSwift 或 Combine 这道墙。招聘信息里几乎必然出现它们,但打开代码后,看到 mapflatMapsink连成一串的陌生语法,很容易感到困惑。“明明用闭包和代理也能正常运行,真的有必要学这个吗?”产生这种疑问很自然。

今天先不讨论语法,而是从 为什么需要这类工具开始梳理。理解原因后,语法自然就能跟上。


iOS 应用其实是“事件处理机器”

拆解应用的工作内容后会发现,大多数事情都是响应事件。

  • 用户点击按钮 → 切换界面
  • 网络响应到达 → 刷新列表
  • 键盘弹出 → 将输入框向上推
  • 文本框值发生变化 → 重新请求搜索结果

问题在于,UIKit 会以 各不相同的方式传递这些事件。

事件源 传递方式
按钮点击 target-action
表格视图滚动 delegate
网络响应 completion handler(闭包)
键盘出现 NotificationCenter
对象属性变化 KVO(Key-Value Observing)

在同一个界面同时使用这五种方式,在 iOS 中非常常见。同样是“响应事件”,代码却分散成五种形式,因此要理解一个界面的行为,就必须翻查视图控制器的各个位置。

RxSwift 和 Combine 要解决的第一个问题就是这个。它们将 所有事件统一为“随时间流动的值流”。按钮点击、网络响应和键盘通知都可以通过同一个接口处理。


依靠回调硬撑时会遇到的两道墙

墙 1:组合异步任务

常见需求是:“同时请求个人信息和最近文章,等两者都到达后再绘制界面”。如果用 completion handler 编写,会变成这样。

var user: User?
var posts: [Post]?

func loadProfile() {
    let group = DispatchGroup()
    group.enter()
    api.fetchUser { result in
        user = try? result.get()
        group.leave()
    }
    group.enter()
    api.fetchPosts { result in
        posts = try? result.get()
        group.leave()
    }
    group.notify(queue: .main) {
        guard let user, let posts else { /* 错误处理又该放在哪里? */ return }
        render(user, posts)
    }
}

需要动用 DispatchGroup、临时存储变量和分散的错误处理。当请求增加到三个、四个,并混入“ A 完成后用结果请求 B”这样的依赖关系时,嵌套会变得无法控制。这就是所谓的回调地狱。

在 Combine 中,同样的需求可以这样表达。

api.fetchUser()
    .zip(api.fetchPosts())
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { completion in
        if case .failure(let error) = completion { showError(error) }
    }, receiveValue: { user, posts in
        render(user, posts)
    })
    .store(in: &cancellables)

“把两者 zip 起来,在主线程接收,成功时通知,失败时也通知。”代码几乎可以直接读出需求,错误处理也集中在一处。

墙 2:控制连续事件

想想搜索框。如果每输入一个字符就调用 API,输入“Swift”五个字符就会发出五次请求。因此通常会加上这些条件。

  • 仅在停止输入 0.3 秒后发起请求(debounce)
  • 如果与上一次搜索词相同,则不发起请求(去重)
  • 发起新请求后,取消尚未完成的旧请求

如果用 Timer 和标志变量直接实现,计时器失效时机与请求取消时机相互交织,很容易产生 bug。在 Combine 中,只需串联经过验证的运算符即可。

searchTextSubject
    .debounce(for: .seconds(0.3), scheduler: DispatchQueue.main)
    .removeDuplicates()
    .map { api.search(query: $0) }
    .switchToLatest()   // 收到新请求时自动取消上一个请求
    .sink { results in render(results) }
    .store(in: &cancellables)

关键在于:**不自行实现涉及时间的事件控制逻辑,而是组装经过验证的组件来解决。**这正是响应式编程价值最明显的地方。

不自行实现时间控制逻辑,而是组装经过验证的运算符
不自行实现时间控制逻辑,而是组装经过验证的运算符

MVVM 绑定的标准组件

如上一篇文章所述,MVVM(Model-View-ViewModel)必须具备“ViewModel 变化时 View 自动跟着变化”的绑定才算完整。但 UIKit 没有内置绑定。填补这一空缺的是 RxSwift(RxCocoa),从 iOS 13 开始则由 Combine 接替这一角色。

viewModel.$isLoading
    .sink { [weak self] in self?.spinner.isAnimating = $0 }
    .store(in: &cancellables)

这就是实际工作中大量招聘信息要求 RxSwift 或 Combine 的原因。在基于 MVVM 的代码库中,绑定层实际上就是用这两个框架之一构建的。


RxSwift 和 Combine 有什么不同?

从概念上看,两者都是响应式编程工具。可以认为 Observable 变成了 Publisher,而 subscribe变成了sink,基本只是名称改变。学会一个后,借助术语对应表就能掌握另一个。不过,选择标准仍然很明确。

  • RxSwift:第三方库。没有 iOS 版本限制,借助 RxCocoa 提供丰富的 UIKit 绑定,长期积累的资料和社区是优势。代价是增加外部依赖,并延长构建时间。
  • Combine:Apple 的第一方框架。iOS 13 及以上无需添加依赖即可使用,并能自然衔接 SwiftUI 的 @PublishedObservableObject。不过,它对 UIKit 绑定的支持不如 RxCocoa 丰富。

新项目通常可以默认选择 Combine;加入现有代码库已经使用 RxSwift 的团队时,则往往需要学习 RxSwift。

RxSwift 和 Combine 是完成同一工作的两个工具箱,选择取决于团队情况
RxSwift 和 Combine 是完成同一工作的两个工具箱,选择取决于团队情况

有了 async/await 之后还需要它吗?

Swift 5.5 引入 async/await 后,人们经常问:“现在是不是不用学 Combine 了?”这话只对了一半。对于以 **“一次请求、一次响应”**结束的单次异步任务,async/await 更易读。上面的个人资料示例只需 async let两行。

但像搜索框这样 持续流入的值流,情况就不同了。如果要对文本输入、位置更新、WebSocket 消息或 ViewModel 状态变化等持续事件施加 debounce、combineLatest 等基于时间的控制,仍然属于响应式工具的领域。Apple 也在通过 AsyncSequence 扩展这一领域,但运算符生态目前仍然是 Combine 更成熟。

总结如下。

  • 单次异步任务(如网络请求)→ async/await
  • 持续事件流 + 基于时间的控制、UI 绑定 → Combine(或 RxSwift)

与其说两者是竞争关系,不如说是负责不同领域的工具。


总结

用一句话概括使用 RxSwift 和 Combine 的原因,就是:将各不相同的异步事件统一为一个流接口,并在其上以声明式方式处理组合、时间控制和绑定

  • 将分散在代理、闭包和通知中的事件处理统一为一种方式
  • 无需嵌套回调即可表达多个异步任务的组合
  • 用经过验证的运算符解决 debounce、去重和请求取消等时间控制
  • MVVM 绑定的事实标准组件

语法初看似乎门槛很高,但只要掌握“把事件看作值流”这一视角,运算符不过是将数组的 mapfilter沿时间轴扩展而已。下一篇将深入拆解 Combine 的 Publisher 和 Subscriber 实际如何配合运行。

延伸阅读