有了 Observation,SwiftUI 还需要 Combine 吗

Published on
Authors
Table of Contents

Xcode 15b6 macOS 14

Combine 初体验

SwiftUI 和 Combine 是在 WWDC 19 上同时发布的,它们非常利于构建声明式和响应式应用。对于简单的应用,如果不需要处理复杂的数据流,我们并不需要直接使用 Combine。SwiftUI 目前已经内置了一些能够使用 Combine 特性的 API,比如 Timer、URLSession、NotificationCenter 等,我们可以通过 onReceive 来订阅事件。

比如这里有个简单的秒钟计时器:

ContentView.swift
struct ContentView: View {
  private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
  @State private var count = 0

  var body: some View {
    Text(count.formatted())
      .onReceive(timer) { _ in
        count += 1
      }
  }
}

这样写虽然能够实现功能,但是 UI 和业务逻辑放在一个文件中,不利于单元测试和代码复用。虽然 Apple 没有为 SwiftUI 应用钦定一个架构,笔者还是倾向于选择 MVVM 来进行实际开发。

将上面的代码稍作改动:

ContentView.swift
struct ContentView: View {
  private let vm = ContentViewModel()

  var body: some View {
    Text(vm.count.formatted())
  }
}
ContentViewModel.swift
import Combine

final class ContentViewModel: ObservableObject {
  @Published var count = 0

  private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
  private var bag = Set<AnyCancellable>()

  init() {
    timer.sink { [weak self] _ in
      guard let self else { return }
      count += 1
    }
    .store(in: &bag)
  }
}

代码拆分后,View 只需要负责视图的布局和数据展示,ViewModel 处理业务逻辑和事件,各司其职,一目了然。

Observation 发布之后

WWDC 23 上发布了一个新的框架:Observation。它极大地简化了 SwiftUI 的状态管理,同时对性能也有一定的提升。

@Observable
final class Book: Identifiable {
  var title = "Sample Book Title"
  var author = Author()
  var isAvailable = true
}

struct BookView: View {
  @State private var book: Book

  var body: some View {
    Text(book.title)
  }
}

@Observable 其实是一个宏,宏(Macros)也是 WWDC 23 加入 Swift 标准库的新特性,它可以减少样板代码,提高编码效率。

在 Observation 发布之前,如果要熟练地对 SwiftUI 进行状态管理,需要学习各种各样的属性包装器并了解它们的区别和使用场景。而现在不需要了,如上代码所示,我们只需要使用宏进行声明,然后在视图中调用相应的属性即可,如果某个属性不想被观察,只需要使用宏 @ObservationIgnored 进行标记即可。

这个简单的示例并不足以充分展示 Observation 对 SwiftUI 状态管理的提升作用,如需深入了解 Observation,可以阅读以下文档或观看 Session:

回到本文要探讨的问题:有了 Observation,SwiftUI 还需要 Combine 吗?答案是肯定的。因为虽然我们不再需要 Combine 提供的 ObservableObject 以及各种 SwiftUI 提供的属性包装器了,但 Observation 不具备 Combine 处理复杂的数据流的能力。

我们来看一个非常实用的例子:输入框的防抖。代码如下:

SampleView.swift
struct SampleView: View {
  @State private var vm = SampleViewModel()

  var body: some View {
    Form {
      Section {
        TextField("Input something", text: $vm.searchText)
      } header: {
        Text("Debounce")
      } footer: {
        Text("Searching **\(vm.searchKeywords)** ...")
          .opacity(vm.isSearching ? 1 : 0)
      }
      .textCase(nil)
    }
  }
}
SampleViewModel.swift
import SwiftUI
import Observation
import Combine

/// The ViewModel responsible for managing the state and logic for ``SampleView``.
/// It performs a debounced search based on user input.
@Observable
final class SampleViewModel {

  /// The text entered by the user in the search text field.
  var searchText: String = "" {
    didSet {
      // Send the updated text to the Combine pipeline
      searchTextPub.send(searchText)
    }
  }

  /// The processed keywords used for searching.
  var searchKeywords = ""

  /// A flag indicating whether a search is currently in progress.
  var isSearching = false

  /// A Combine PassthroughSubject to publish updates to `searchText`.
  private let searchTextPub = PassthroughSubject<String, Never>()

  /// A collection of AnyCancellable to store Combine subscriptions.
  private var bag = Set<AnyCancellable>()

  init() {
    searchTextPub
      .dropFirst() // Ignore the initial value
      .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } // Trim whitespaces and new lines
      .filter { !$0.isEmpty } // Filter out empty strings
      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) // Debounce for 0.5 seconds
      .sink { [weak self] value in // Subscribe to updates
        guard let self else { return }
        if searchKeywords != value {
          searchKeywords = value
          mockRequest()
        }
      }
      .store(in: &bag)
  }

  /// Simulates a search request with a random delay.
  func mockRequest() {
    isSearching = true
    Task { @MainActor in
      try? await Task.sleep(for: .seconds(.random(in: 1...2)))
      isSearching = false
    }
  }
}

可以看到,视图层的代码非常简单,只需要从 ViewModel 取数据即可,但 ViewModel 不少工作来实现输入的防抖和对无效数据的过滤。

由于使用的 Observation,所以在 ViewModel 中无法像之前使用 Combine 那样直接从使用 @Published 声明的属性中方便地使用 publisher 实例,这里需要多写一些代码来向订阅者发送事件。

从这个实例可以看出,对于复杂的视图和数据流,Combine 仍然是不可或缺的。

总结

  1. Combine 和 Observation 并不是对立关系,二者可以共存,使用场景不同。
  2. Observation 天生就是为视图服务的,主要用于状态管理。
  3. Combine 有许多的操作符,非常适合处理业务层复杂的数据流。
twitterDiscuss on Twitter