Combine 和 SwiftUI 同年诞生于 WWDC 2019,从设计之初就深度绑定。SwiftUI 负责描述"是什么",Combine 负责管理"数据如何流动"------@Published 触发视图更新,ObservableObject 驱动状态变化,各种操作符链构建异步逻辑。这一章将带你彻底掌握 Combine 在 SwiftUI 中的实战用法,从基础属性包装器到复杂表单验证、搜索、倒计时,再到 iOS 17 新引入的 @Observable 宏,所有内容都配有可直接运行的代码。
1. Combine 与 SwiftUI 的核心纽带
SwiftUI 的视图是状态的函数。而 Combine 提供了一套机制,让状态的变化能够被自动发布、观察和组合。两者通过以下属性包装器紧密协作:
| 包装器 | 作用 | 适用场景 |
|---|---|---|
@Published |
自动为属性创建 Publisher,值变化时通知订阅者 | ViewModel 中的状态字段 |
@ObservedObject |
订阅一个外部传入的 ObservableObject,变化时刷新视图 | 子视图接收父视图传入的 ViewModel |
@StateObject |
创建并持有 ObservableObject 的生命周期 | 视图自己拥有 ViewModel |
@EnvironmentObject |
从环境中读取共享的 ObservableObject | 跨层级共享用户状态、主题等 |
@Environment |
直接读取环境值,如 colorScheme 等 | 不与 Combine 直接绑定,但可搭配使用 |
理解这些包装器的适用场景和生命周期,是写出稳定 SwiftUI 应用的基石。
2. @Published 与 ObservableObject
2.1 基本用法
@Published 将一个普通属性变为"可发布"属性,编译器会自动为其创建一个 Publisher,通过 $ 前缀访问。类必须遵循 ObservableObject 协议,其 objectWillChange 会在任何 @Published 属性变化前自动发出。
swift
import SwiftUI
import Combine
class CounterViewModel: ObservableObject {
@Published var count = 0
}
struct CounterView: View {
@StateObject private var viewModel = CounterViewModel()
var body: some View {
VStack(spacing: 16) {
Text("当前计数: \(viewModel.count)")
.font(.largeTitle)
Button("增加") {
viewModel.count += 1
}
.buttonStyle(.borderedProminent)
}
}
}
当点击按钮时,count 发生变化,视图自动重新渲染。
2.2 在 ViewModel 内部订阅 @Published
你可以通过 $property 拿到 Publisher,并在 ViewModel 内部构建 Combine 管道,例如进行防抖或验证:
swift
class SearchViewModel: ObservableObject {
@Published var query = ""
@Published var results: [String] = []
private var cancellables = Set<AnyCancellable>()
init() {
$query
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.removeDuplicates()
.sink { [weak self] text in
self?.search(text)
}
.store(in: &cancellables)
}
private func search(_ text: String) {
// 执行搜索逻辑
}
}
2.3 组合多个 @Published 属性
利用 combineLatest 或 CombineLatest3、CombineLatest4 可以联合验证多个字段:
swift
class RegistrationViewModel: ObservableObject {
@Published var username = ""
@Published var email = ""
@Published var password = ""
@Published var isFormValid = false
private var bag = Set<AnyCancellable>()
init() {
Publishers.CombineLatest3($username, $email, $password)
.map { username, email, password in
!username.isEmpty &&
email.contains("@") &&
password.count >= 6
}
.assign(to: \.isFormValid, on: self)
.store(in: &bag)
}
}
3. @StateObject 与 @ObservedObject 的抉择
这两个包装器最容易混淆。一句话总结:
@StateObject:视图拥有该对象,其生命周期与视图一致,不会在视图重建时被重新创建。@ObservedObject:视图不拥有该对象,只负责观察;对象的生命周期由外部管理。
3.1 典型场景
swift
class ParentViewModel: ObservableObject { ... }
struct ParentView: View {
@StateObject private var viewModel = ParentViewModel() // 拥有并负责生命周期
var body: some View {
ChildView(viewModel: viewModel) // 传入子视图
}
}
struct ChildView: View {
@ObservedObject var viewModel: ParentViewModel // 外部传入,仅观察
var body: some View {
Text(viewModel.someProperty)
}
}
3.2 常见陷阱
- 在
body或其他闭包中初始化@ObservedObject会导致对象在每次刷新时重建,界面可能闪烁或丢失状态。始终用@StateObject持有根源对象。 - 将
@StateObject用于需要外部控制生命周期的对象(如导航栈共享实例)则不恰当,应用@ObservedObject或@EnvironmentObject。
4. 跨视图共享:@EnvironmentObject
当对象需要跨越多个层级传递时,@EnvironmentObject 是最佳选择,它避免了逐层传递闭包或属性的麻烦。
swift
class UserSession: ObservableObject {
@Published var isLoggedIn = false
@Published var username = ""
}
@main
struct MyApp: App {
@StateObject private var session = UserSession()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(session) // 注入环境
}
}
}
struct HomeView: View {
@EnvironmentObject var session: UserSession
var body: some View {
if session.isLoggedIn {
Text("欢迎, \(session.username)")
} else {
Button("登录") {
session.isLoggedIn = true
session.username = "张三"
}
}
}
}
注意 :@EnvironmentObject 是隐式依赖,如果环境未提供对应类型,运行时会崩溃。务必在根视图注入,并在预览时提供模拟对象:
swift
#Preview {
HomeView()
.environmentObject(UserSession())
}
5. 异步数据加载:Combine 与 URLSession
Combine 为 URLSession 提供了原生扩展 dataTaskPublisher,使得网络请求可以直接融入管道链。
swift
struct User: Codable {
let id: Int
let name: String
}
class UserViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
@Published var errorMessage: String?
private var cancellable: AnyCancellable?
func loadUser(id: Int) {
isLoading = true
errorMessage = nil
guard let url = URL(string: "https://api.example.com/users/\(id)") else {
errorMessage = "URL 无效"
isLoading = false
return
}
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: User.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.errorMessage = error.localizedDescription
}
}, receiveValue: { [weak self] user in
self?.user = user
})
}
}
如果需要在 ViewModel 析构时自动取消,可将 AnyCancellable 存入 Set,也可以直接保持单个引用并在适当时机取消。
6. 完整的表单验证系统
表单验证是 Combine 的"杀手级应用"之一。我们可以为每个输入字段创建单独的验证管道,并用 CombineLatest 聚合整体的有效性。
swift
class RegistrationViewModel: ObservableObject {
@Published var username = ""
@Published var password = ""
@Published var confirmPassword = ""
@Published var usernameError = ""
@Published var passwordError = ""
@Published var confirmError = ""
@Published var isFormValid = false
private var bag = Set<AnyCancellable>()
init() {
// 用户名验证:3-12 字符
$username
.map { name -> String in
guard !name.isEmpty else { return "请输入用户名" }
guard name.count >= 3 && name.count <= 12 else { return "用户名长度需为 3-12" }
return ""
}
.sink { [weak self] error in self?.usernameError = error }
.store(in: &bag)
// 密码验证:至少 6 位
$password
.map { pass -> String in
guard !pass.isEmpty else { return "请输入密码" }
guard pass.count >= 6 else { return "密码至少 6 位" }
return ""
}
.sink { [weak self] error in self?.passwordError = error }
.store(in: &bag)
// 确认密码验证
$confirmPassword
.combineLatest($password)
.map { confirm, pass -> String in
guard !confirm.isEmpty else { return "请确认密码" }
guard confirm == pass else { return "两次密码不一致" }
return ""
}
.sink { [weak self] error in self?.confirmError = error }
.store(in: &bag)
// 表单总体验证
Publishers.CombineLatest3($usernameError, $passwordError, $confirmError)
.map { $0.isEmpty && $1.isEmpty && $2.isEmpty }
.assign(to: \.isFormValid, on: self)
.store(in: &bag)
}
}
对应的视图只需单向绑定显示错误信息和按钮状态,逻辑完全由 ViewModel 驱动,测试起来非常方便。
7. 搜索功能:防抖与去重
实时搜索需要精细控制请求频率。Combine 的 debounce 和 removeDuplicates 足以胜任。
swift
class SearchViewModel: ObservableObject {
@Published var searchText = ""
@Published var results: [String] = []
@Published var isSearching = false
private var bag = Set<AnyCancellable>()
init() {
$searchText
.debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)
.removeDuplicates()
.filter { !$0.isEmpty }
.flatMap { [weak self] query -> AnyPublisher<[String], Never> in
self?.isSearching = true
return self?.performSearch(query) ?? Just([]).eraseToAnyPublisher()
}
.receive(on: DispatchQueue.main)
.sink { [weak self] results in
self?.results = results
self?.isSearching = false
}
.store(in: &bag)
}
private func performSearch(_ query: String) -> AnyPublisher<[String], Never> {
// 模拟网络请求,实际可替换为 URLSession.dataTaskPublisher
return Just(["\(query) 结果1", "\(query) 结果2"])
.delay(for: .seconds(1), scheduler: DispatchQueue.global())
.eraseToAnyPublisher()
}
}
用 flatMap 包装每个搜索请求,并且内部 catch 降级,确保搜索流不会因为一次网络错误而终止。
8. 定时器与倒计时
SwiftUI 中直接使用 Timer.publish 非常便捷,但需注意内存管理。
swift
class TimerViewModel: ObservableObject {
@Published var secondsLeft = 60
@Published var isRunning = false
private var timer: AnyCancellable?
func start() {
guard !isRunning else { return }
isRunning = true
timer = Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
guard let self else { return }
if self.secondsLeft > 0 {
self.secondsLeft -= 1
} else {
self.stop()
}
}
}
func stop() {
isRunning = false
timer?.cancel()
timer = nil
}
func reset() {
stop()
secondsLeft = 60
}
}
如果需要避免 Timer 相关的强引用,切记在 stop() 或视图消失时 cancel() 并置空 timer。
9. iOS 17+ 新范式:@Observable 宏与 Combine 的融合
从 iOS 17 开始,@Observable 宏逐步替代了 ObservableObject 协议。它不再要求手动标记 @Published,而是自动跟踪属性访问。然而,@Observable 默认不提供 Combine Publisher,因此如果需要 Combine 管道,必须手动创建 CurrentValueSubject 或 PassthroughSubject 进行桥接。
swift
import SwiftUI
import Observation
import Combine
@Observable
class NewUserSettings {
var isDarkMode = false {
didSet {
isDarkModeSubject.send(isDarkMode)
}
}
// 手动提供 Combine Publisher
var isDarkModeSubject = CurrentValueSubject<Bool, Never>(false)
}
struct SettingsView: View {
@State private var settings = NewUserSettings()
private var bag = Set<AnyCancellable>()
var body: some View {
Toggle("深色模式", isOn: $settings.isDarkMode)
.onAppear {
settings.isDarkModeSubject
.sink { newValue in
print("深色模式变为: \(newValue)")
}
.store(in: &bag)
}
}
}
随着 SwiftUI 演化,许多原本依赖 Combine 的场景(如简单的 onChange 监听)可以直接用 Swift 并发或 View 修饰符替代,但 Combine 在复杂的数据流组合和高阶操作中仍有不可替代的优势。
10. 最佳实践
10.1 状态管理
- 明确所有权 :谁创建谁使用
@StateObject,谁接收谁使用@ObservedObject。 - 避免在 body 中初始化 ObservableObject:那会导致对象随视图重建反复创建。
10.2 内存管理
- 任何
sink或assign必须通过.store(in: &cancellables)存入Set<AnyCancellable>。 - 使用
[weak self]打破闭包循环引用。 Timer.publish必须在不需要时显式cancel()。
10.3 性能优化
- 将
debounce、removeDuplicates等过滤操作放在链的前端,减少后续运算。 - 避免在频繁更新的管道中做复杂计算,可借助
throttle限制频率。 - 利用
receive(on: DispatchQueue.main)将 UI 更新固定到主线程。
10.4 错误处理
- 异步操作(如
flatMap内的网络请求)应在内部 用catch捕获错误,防止外层订阅终止。 - 用户可看到的错误信息应区分"网络错误""服务端错误""输入无效"等,提供明确引导。
11. 总结
本章系统梳理了 Combine 与 SwiftUI 集成的方方面面:
@Published+ObservableObject构成了数据驱动的核心。@StateObject、@ObservedObject、@EnvironmentObject各司其职,管理不同的生命周期和可见范围。- 表单验证、搜索、定时器、网络请求等实战模式,可以开箱即用。
- iOS 17 的
@Observable带来了更简洁的观察模型,Combine 以桥接方式继续发挥作用。
掌握这些模式后,你将能够写出逻辑清晰、易于测试且界面流畅的 SwiftUI 应用。数据像水流一样在 Combine 管道中流转,而 SwiftUI 视图只是水面上最生动的倒影。