问题
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
}
}
}
来源:https://stackoverflow.com/questions/57842609/best-data-binding-practice-in-combine-swiftui