Best data-binding practice in Combine + SwiftUI?

2020-06-07 21:58发布

问题:

In RxSwift it's pretty easy to bind a Driver or an Observable in a View Model to some observer in a ViewController (i.e. a UILabel).

I usually prefer to build a pipeline, with observables created from other observables, instead of "imperatively" pushing values, say via a PublishSubject).

Let's use this example: update a UILabel after fetching some data from the network


RxSwift + RxCocoa example

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)
    }
}

Combine + UIKit example

In a UIKit-based project it seems like you can keep the same pattern:

  • view model exposes publishers
  • view controller binds its UI elements to those publishers
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)
    }
}

What about SwiftUI?

SwiftUI relies on property wrappers like @Published and protocols like ObservableObject, ObservedObject to automagically take care of bindings (As of Xcode 11b7).

Since (AFAIK) property wrappers cannot be "created on the fly", there's no way you can re-create the example above using to the same pattern. The following does not compile

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!
    }
}

The closest I could come up with is subscribing in your view model (UGH!) and imperatively update your property, which does not feel right and reactive at all.

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)
    }
}

Is the "old way of doing bindings" to be forgotten and replaced, in this new UIViewController-less world?

回答1:

An elegant way I found is to replace the error on the publisher with Never and to then use assign (assign only works if Failure == Never).

In your case...

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:

I think the missing piece here is that you are forgetting that your SwiftUI code is functional. In the MVVM paradigm, we split the functional part into the view model and keep the side effects in the view controller. With SwiftUI, the side effects are pushed even higher into the UI engine itself.

I haven't messed much with SwiftUI yet so I can't say I understand all the ramifications yet, but unlike UIKit, SwiftUI code doesn't directly manipulate screen objects, instead it creates a structure that will do the manipulation when passed to the UI engine.



回答3:

I ended up with some compromise. Using @Published in viewModel but subscribing in SwiftUI View. Something like this:

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)
    }
}

and 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)
        }
    }
}



回答4:

After posting previous answer read this article: https://nalexn.github.io/swiftui-observableobject/

and decide to do same way. Use @State and don't use @Published

General ViewModel protocol:

protocol ViewModelProtocol {
    associatedtype Output
    associatedtype Input

    func bind(_ input: Input) -> Output
}

ViewModel class:

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 View:

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
        }
    }
}