Combine + SwiftUI 中的最佳数据绑定实践?

Posted

技术标签:

【中文标题】Combine + SwiftUI 中的最佳数据绑定实践?【英文标题】:Best data-binding practice in Combine + SwiftUI? 【发布时间】:2020-01-10 13:08:34 【问题描述】:

在 RxSwift 中,很容易将 DriverView Model 中的 Observable 绑定到 ViewController 中的某个观察者(即 UILabel)。

我通常更喜欢构建一个管道,使用 从其他可观察对象创建的可观察对象,而不是“强制”推送值,例如通过 PublishSubject)。

让我们使用这个例子:从网络获取一些数据后更新UILabel


RxSwift + RxCocoa 示例

final class RxViewModel 
    private var dataObservable: Observable<Data>

    let stringDriver: Driver<String>

    init() 
        let request = URLRequest(url: URL(string:"https://www.google.com")!)

        self.dataObservable = URLSession.shared
            .rx.data(request: request).asObservable()

        self.stringDriver = dataObservable
            .asDriver(onErrorJustReturn: Data())
            .map  _ in return "Network data received!" 
    

final class RxViewController: UIViewController 
    private let disposeBag = DisposeBag()
    let rxViewModel = RxViewModel()

    @IBOutlet weak var rxLabel: UILabel!

    override func viewDidLoad() 
        super.viewDidLoad()

        rxViewModel.stringDriver.drive(rxLabel.rx.text).disposed(by: disposeBag)
    


结合 + UIKit 示例

在基于 UIKit 的项目中,您似乎可以保持相同的模式:

视图模型公开发布者 视图控制器将其 UI 元素绑定到这些发布者
final class CombineViewModel: ObservableObject 
    private var dataPublisher: AnyPublisher<URLSession.DataTaskPublisher.Output, URLSession.DataTaskPublisher.Failure>
    var stringPublisher: AnyPublisher<String, Never>

    init() 
        self.dataPublisher = URLSession.shared
            .dataTaskPublisher(for: URL(string: "https://www.google.it")!)
            .eraseToAnyPublisher()

        self.stringPublisher = dataPublisher
            .map  (_, _) in return "Network data received!" 
            .replaceError(with: "Oh no, error!")
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    

final class CombineViewController: UIViewController 
    private var cancellableBag = Set<AnyCancellable>()
    let combineViewModel = CombineViewModel()

    @IBOutlet weak var label: UILabel!

    override func viewDidLoad() 
        super.viewDidLoad()

        combineViewModel.stringPublisher
            .flatMap  Just($0) 
            .assign(to: \.text, on: self.label)
            .store(in: &cancellableBag)
    


SwiftUI 呢?

SwiftUI 依赖于像 @Published 这样的属性包装器和像 ObservableObjectObservedObject 这样的协议来自动处理绑定(截至 Xcode 11b7)。

由于 (AFAIK) 属性包装器不能“动态创建”,因此您无法使用相同的模式重新创建上面的示例。 以下无法编译

final class WrongViewModel: ObservableObject 
    private var dataPublisher: AnyPublisher<URLSession.DataTaskPublisher.Output, URLSession.DataTaskPublisher.Failure>
    @Published var stringValue: String

    init() 
        self.dataPublisher = URLSession.shared
            .dataTaskPublisher(for: URL(string: "https://www.google.it")!)
            .eraseToAnyPublisher()

        self.stringValue = dataPublisher.map  ... . ??? <--- WRONG!
    

我能想到的最接近的方法是在您的视图模型中订阅(UGH!)立即更新您的属性,这感觉完全不正确和被动。

final class SwiftUIViewModel: ObservableObject 
    private var cancellableBag = Set<AnyCancellable>()
    private var dataPublisher: AnyPublisher<URLSession.DataTaskPublisher.Output, URLSession.DataTaskPublisher.Failure>

    @Published var stringValue: String = ""

    init() 
        self.dataPublisher = URLSession.shared
            .dataTaskPublisher(for: URL(string: "https://www.google.it")!)
            .eraseToAnyPublisher()

        dataPublisher
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: _ in )  (_, _) in
            self.stringValue = "Network data received!"
        .store(in: &cancellableBag)
    

struct ContentView: View 
    @ObservedObject var viewModel = SwiftUIViewModel()

    var body: some View 
        Text(viewModel.stringValue)
    

在这个新的 UIViewController-less 世界中,“旧的绑定方式”会被遗忘和取代吗?

【问题讨论】:

我不认为有任何内置的方法可以做你想做的事。 This 是某人制作的辅助函数,您可能会觉得有趣。 在 SwiftUI 中有两种处理异步数据的方法,或者可能有两种变体。您可以按照 Benjamin 的建议使用 onReceive 或将数据保存在类中并发送 objectWillChange 消息。我都用过,它们都很容易使用。我见过的 onReceive 的最大缺点是,由于视图的状态发生变化,它可能会受到正文被重新读取的影响,请参阅***.com/questions/57796877/…,如果两个计时器都是 1 秒,则会出现问题- 【参考方案1】:

我发现一种优雅的方法是将发布者上的错误替换为Never,然后使用assignassign 仅在Failure == Never 时有效)。

你的情况……

dataPublisher
    .receive(on: DispatchQueue.main)
    .map  _ in "Data received"  //for the sake of the demo
    .replaceError(with: "An error occurred") //this sets Failure to Never
    .assign(to: \.stringValue, on: self)
    .store(in: &cancellableBag)

【讨论】:

【参考方案2】:

我认为这里缺少的部分是您忘记了您的 SwiftUI 代码功能。在 MVVM 范式中,我们将功能部分拆分为视图模型,并将副作用保留在视图控制器中。使用 SwiftUI,副作用被推到了 UI 引擎本身的更高位置。

我还没有对 SwiftUI 搞砸太多,所以我不能说我理解所有的后果,但与 UIKit 不同的是,SwiftUI 代码不直接操作屏幕对象,而是创建一个结构来执行操作传递给 UI 引擎。

【讨论】:

【参考方案3】:

在发布上一个答案后阅读这篇文章:https://nalexn.github.io/swiftui-observableobject/

并决定采取同样的方式。使用@State,不要使用@Published

通用 ViewModel 协议:

protocol ViewModelProtocol 
    associatedtype Output
    associatedtype Input

    func bind(_ input: Input) -> Output

ViewModel 类:

final class SwiftUIViewModel: ViewModelProtocol 
    struct Output 
        var dataPublisher: AnyPublisher<String, Never>
    

    typealias Input = Void

    func bind(_ input: Void) -> Output 
        let dataPublisher = URLSession.shared.dataTaskPublisher(for: URL(string: "https://www.google.it")!)
        .map "Just for testing - \($0)"
        .replaceError(with: "An error occurred")
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()

        return Output(dataPublisher: dataPublisher)
    

SwiftUI 视图:

struct ContentView: View 

    @State private var dataPublisher: String = "ggg"

    let viewModel: SwiftUIViewModel
    let output: SwiftUIViewModel.Output

    init(viewModel: SwiftUIViewModel) 
        self.viewModel = viewModel
        self.output = viewModel.bind(())
    

    var body: some View 
        VStack 
            Text(self.dataPublisher)
        
        .onReceive(output.dataPublisher)  value in
            self.dataPublisher = value
        
    


【讨论】:

检查 github.com/serbats/Reactive-Combine-MVVM-Templates 以获得一些 MVVM Xcode 模板【参考方案4】:

我最终做出了一些妥协。在 viewModel 中使用 @Published,但在 SwiftUI 视图中订阅。 像这样:

final class SwiftUIViewModel: ObservableObject 
    struct Output 
        var dataPublisher: AnyPublisher<String, Never>
    

    @Published var dataPublisher : String = "ggg"

    func bind() -> Output 
        let dataPublisher = URLSession.shared.dataTaskPublisher(for: URL(string: "https://www.google.it")!)
        .map "Just for testing - \($0)"
        .replaceError(with: "An error occurred")
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()

        return Output(dataPublisher: dataPublisher)
    

和 SwiftUI:

struct ContentView: View 
    private var cancellableBag = Set<AnyCancellable>()

    @ObservedObject var viewModel: SwiftUIViewModel

    init(viewModel: SwiftUIViewModel) 
        self.viewModel = viewModel

        let bindStruct = viewModel.bind()
        bindStruct.dataPublisher
            .assign(to: \.dataPublisher, on: viewModel)
            .store(in: &cancellableBag)
    

    var body: some View 
        VStack 
            Text(self.viewModel.dataPublisher)
        
    


【讨论】:

以上是关于Combine + SwiftUI 中的最佳数据绑定实践?的主要内容,如果未能解决你的问题,请参考以下文章

SwiftUI + Combine:如何将数据分配给带有动画的模型

SwiftUI ObjectBinding 不会使用 combine 接收来自可绑定对象的 didchange 更新

将 AppKit/UIKit 中的 Key-Value Observation 转换为 Combine 和 SwiftUI

如何使用 Combine 框架和 SwiftUI 发布网络请求的数据

使用 SwiftUI+Combine 的数据库延迟加载

swiftui combine 无法获取数据 [关闭]