Come licenziare un editore dopo l'aggiornamento asincrono in SwiftUI?

Aug 23 2020

Problema

Quando il mio modello ospitato all'interno di una classe negozio viene aggiornato sul thread principale in modo asincrono, una vista SwiftUI non esegue automaticamente il rendering della fetta del modello fornita da ViewModel.

Soluzione presunta

È necessario un publisher / promessa personalizzato per collegare insieme View / ViewModel / Factory / Store, poiché un riferimento non attiva gli aggiornamenti sugli aggiornamenti asincroni.

Bloccare

Come lo scrivo? Ho provato ad aggiungere una promessa alla classe Store, ma Xcode avvisa che il risultato della chiamata a sink (receiveValue :) non è utilizzato. Chiaramente non capisco le promesse / gli editori e la maggior parte dei tutorial utilizza URLSession e il suo dataTaskPublisher (non il mio caso).

Ho provato l'esempio in questo thread di Apple Dev Forum sulle classi factory e store, ma senza dadi. Chiaramente non lo capisco. In questa risposta, @Asperi ha suggerito di ascoltare la vista a un editore e aggiornare una variabile @State, ma poiché il mio modello è privato mi manca un target per quell'approccio.

Codice abbreviato

  • Una factory crea un'istanza di una classe ContactStore con dipendenze; un riferimento viene passato a ViewModels.
  • Le VM aprono l'accesso all'archivio privato con variabili calcolate. La visualizzazione chiama le funzioni nel ViewModel che modificano lo stato, che funziona bene se sincrono.

Factory.swift


import SwiftUI
import Combine

class MainFactory {

    init() {
        ...
        self.contactStore = ContactStore()
    }

    private var preferences: Preferences
    private var contactStore: ContactStore

 ...

    func makeOnboardingVM() -> OnboardingVM {
        OnboardingVM(preferences: preferences, contactStore: contactStore)
    }

}

ContactStore.swift

final class ContactStore {

    private(set) var authorizationStatus: CNAuthorizationStatus = .notDetermined
    private(set) var contacts: [Contact] = [Contact]()
    private(set) var errors: [Error] = [Error]()

    private lazy var initialImporter = CNContactImporterForiOS14(converter: CNContactConverterForiOS14(),
                                                                 predictor: UnidentifiedSelfContactFavoritesPredictor())
}

// MARK: - IMPORT

extension ContactStore {
    /// Full import during app onboarding. Work conducted on background thread.
    func requestAccessAndImportPhoneContacts(completion: @escaping (Bool) -> Void) {
        CNContactStore().requestAccess(for: .contacts) { [weak self] (didAllow, possibleError) in
            guard didAllow else {
                DispatchQueue.main.async { completion(didAllow) }
                return
            }
            DispatchQueue.main.async { completion(didAllow) }
            self?.importContacts()
        }
    }

    private func importContacts() {
        initialImporter.importAllContactsOnUserInitiatedThread { [weak self] importResult in
            DispatchQueue.main.async {
               switch importResult {
               case .success(let importedContacts):
                
                   self?.contacts = importedContacts
               case .failure(let error):
                   self?.errors.append(error)
               }
            }
        }
    }
}

OnboardingViewModel.swift

import SwiftUI
import Contacts

class OnboardingVM: ObservableObject {

    init(preferences: Preferences, contactStore: ContactStore) {
        self.preferences = preferences
        self.contactStore = contactStore
    }

    @Published private var preferences: Preferences
    @Published private var contactStore: ContactStore

    var contactsAllImported: [Contact] { contactStore.contacts }

    func processAddressBookAndGoToNextScreen() {
        contactStore.requestAccessAndImportContacts() { didAllow in
            DispatchQueue.main.async {
                if didAllow {
                    self.go(to: .relevantNewScreen)
                else { self.go(to: .relevantOtherScreen) }
            }
        }
    }

    ...
}
       

View.swift

struct SelectEasiestToCall: View {
    @EnvironmentObject var onboarding: OnboardingVM

    var body: some View {
        VStack {
            ForEach(onboarding.allContactsImported) { contact in 
                SomeView(for: contact)
            }
        }

Risposte

1 NewDev Aug 23 2020 at 08:08

Presumo che ciò che intendi per non funzionante è che il ForEachnon visualizza i contatti importati.

Il problema è che mentre hai assegnato nuovi valori a ContactStore.contacts, questo non viene rilevato come una modifica in OnboardingVM. La @Publishedproprietà contactStorenon è cambiata perché è un classtipo di riferimento.

Quello che devi fare è scrivere il codice per reagire manualmente a questa modifica.

Una cosa che potresti fare è avere un altro gestore che venga chiamato quando vengono aggiunti nuovi contatti, e dopo aver ricevuto la notizia, invocare objectWillChange.send, che farebbe sapere alla vista osservante che questo oggetto cambierà (e quindi ricalcolerebbe il suo corpo)

func processAddressBookAndGoToNextScreen() {
   contactStore.requestAccessAndImportContacts(
      onAccessResponse: { didAllow in
        ...
      }, 
      onContactsImported: { 
         self.objectWillChange.send() // <- manually notify of change
      }
   )
}

(Ovviamente dovresti apportare alcune modifiche requestAccessAndImportPhoneContactse importContactsrichiamare effettivamente il onContactsImportedgestore che ho aggiunto)

Esistono altri approcci per la notifica (ad esempio utilizzando un delegato).

Si potrebbe usare un editore per notificare, ma non mi sembra utile qui, dal momento che si tratta di un'importazione di una volta, e un editore / sottoscrittore sembra un eccessivo.