web-dev-qa-db-fra.com

Meilleure pratique de liaison de données dans Combine + SwiftUI?

Dans RxSwift, il est assez facile de lier un Driver ou un Observable dans un View Model à un observateur dans un ViewController (c'est-à-dire un UILabel).

Je préfère généralement construire un pipeline, avec des observables créés à partir d'autres observables, au lieu de pousser des valeurs "impérativement", disons via un PublishSubject).

Prenons cet exemple: mettre à jour un UILabel après avoir récupéré certaines données du résea


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

Exemple de combinaison + UIKit

Dans un projet basé sur UIKit, il semble que vous puissiez conserver le même schéma:

  • le modèle de vue expose les éditeurs
  • le contrôleur de vue lie ses éléments d'interface utilisateur à ces éditeurs
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)
    }
}

Et SwiftUI?

SwiftUI s'appuie sur des wrappers de propriétés comme @Published et des protocoles comme ObservableObject, ObservedObject pour gérer automatiquement les liaisons (à partir de Xcode 11b7 ).

Étant donné que les wrappers de propriété (AFAIK) ne peuvent pas être "créés à la volée", il est impossible de recréer l'exemple ci-dessus en utilisant le même modèle. Ce qui suit ne compile pas

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

Le plus proche que j'ai pu trouver est s'abonner dans votre modèle de vue (UGH!) et mettre à jour impérativement votre propriété, ce qui ne semble pas correct et réactif du tout.

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

Est-ce que "l'ancienne façon de faire les liaisons" doit être oubliée et remplacée, dans ce nouveau monde IViewController-less?

16
Enrico Querci

Après avoir posté la réponse précédente, lisez cet article: https://nalexn.github.io/swiftui-observableobject/

et décidez de faire de même. Utilisez @State et n'utilisez pas @Published

Protocole général ViewModel:

protocol ViewModelProtocol {
    associatedtype Output
    associatedtype Input

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

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

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

0
sergiy batsevych

Je me suis retrouvé avec un compromis. En utilisant @Published dans viewModel mais en s'abonnant dans SwiftUI View. Quelque chose comme ça:

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

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

0
sergiy batsevych