Après mon premier petit TP autour de SwiftUI et Combine pour générer mes attestations de déplacement ~à la barbe de la maréchaussée~ à la volée voire en retard, j’ai profité de l’adaptation au second format d’attestation pour faire des explorations un peu plus poussées de mon architecture autour de Combine.
J’en sors une petite liste de considérations techniques que j’espère d’intérêt, et voici les premières !
Architecture: MVVM+
Pour une petite app comme celle-ci, je m’autorise des entorses à nombre de principes stricts overkill que je n’estime pas pertinents ici, avec des gains principalement en concision et lisibilité. Le MVVM est tout à fait indiqué pour un cloisonnement minimal, et en l’occurence ça ressemblait à ça
Avec des injection par construction et donc cette instanciation initiale :
let store = Store(context: moContext)
let model = MainViewModel(store: store, router: Router())
return MainView(model: model)
On peut remarquer le petit Router
qui s’est avéré fort utile pour éviter trop de plomberie. Son rôle est juste de publier des propriétés destinées à contrôler et rendre compte de la navigation. Il ne fait donc clairement pas partie du modèle ni des vues, et j’ai du mal à le considérer comme un modèle de vue étant donnée sa nature transverse.
Tant qu’il ne dépasse pas ce rôle de navigation, ne stocke qu’un minimum de données transitoires au besoin (immuables de préférence, la struct d’une personne à éditer par exemple), ça reste très lisible et on évite les chaînages de @Published
orthodoxes.
enum ActiveSheet {
case attestationPresentation(person: PersonStruct), addPerson, edit(person: PersonStruct)
}
enum ActiveAlert {
case confirmAttestation, detail(reason: Reason)
}
class Router: ObservableObject {
@Published
var showSheet = false
@Published
var showAlert = false
private(set) var activeSheet = ActiveSheet.addPerson
private(set) var activeAlert = ActiveAlert.confirmAttestation
func showAttestationCreationAlert() {
activeAlert = .confirmAttestation
showAlert = true
}
func startAddPerson() {
activeSheet = .addPerson
showSheet = true
}
func startEdit(person: PersonStruct) {
activeSheet = .edit(person: person)
showSheet = true
}
func showReasonDetail(_ reason: Reason) {
activeAlert = .detail(reason: reason)
showAlert = true
}
func showAttestationView(person: PersonStruct) {
activeSheet = .attestationPresentation(person: person)
showSheet = true
}
func closeSheet() {
showSheet = false
}
}
Plutôt concis, ça vaut clairement le coup plutôt que de perdre ces quelques variables dans des chemins trop tortueux.
Tous mes proches le savent, les enums Swift c’est ma grande passion. Et ceux du petit routeur ci-dessus me permettent de faire des fonctions SwiftUI bien compactes :
func alert() -> Alert {
switch vm.router.activeAlert {
case .confirmAttestation:
return Alert(title: coldFeetTitle,
message: coldFeetMessage,
primaryButton: .default(Text("Je certifie")) {
vm.generateNewAttestation()
}, secondaryButton: .cancel(Text("Annuler")))
case .detail(reason: let reason):
return Alert(title: Text(reason.niceString),
message: Text(reason.detail),
dismissButton: .default(Text("Ok")))
}
}
func sheet() -> AnyView {
switch vm.router.activeSheet {
case .attestationPresentation(let person):
return AnyView(AttestationView(vm: vm.attestationViewModel(person: person)))
case .addPerson:
return AnyView(AddOrEditPersonSheet(vm: vm.addPersonViewModel))
case .edit(let person):
return AnyView(AddOrEditPersonSheet(vm: vm.editPersonViewModel(person: person)))
}
}
Et enfin, le body
de ma View
principale sera très concis :
var body: some View {
NavigationView {
VStack(alignment: .center, spacing: 5) {
MainListsView(model: vm.mainListsViewModel).environment(\.editMode, $editMode)
BottomMenu(model: vm.bottomMenuViewModel)
}
.sheet(isPresented: $vm.router.showSheet, onDismiss: {
vm.checkShouldShowPinnedAttestation()
}, content: sheet)
.navigationBarTitle("", displayMode: .inline)
.navigationBarItems(leading: navigationBarLeadingItem, trailing: EditButton(editMode: $editMode))
}
.alert(isPresented: $vm.router.showAlert, content: alert)
}
(on peut comprendre d’ailleurs pourquoi je sépare le showSheet
et showAlert
des enums, au lieu de déclarer des .none
)
La vue principale est la principale consommatrice du routeur, cependant de multiples vues viennent agir dessus.
Evidemment, cette architecture est adaptée à ce projet particulier, ne cherchez pas à reproduire ça à la maison.
Les petits trucs pénibles
Type erasure
Ci-dessus, vous pouvez voir que j’utilise AnyView(...)
pour renvoyer un type consistant de View
. Pour tous ceux qui ont joué un peu en profondeur avec les protocoles et génériques en Swift, on atteint vite des obstacles mystérieux particulièrement brainboiling.
Heureusement on observe un effort de type erasure dans les bibliothèques système avec ces AnyView
, AnyCancellable
…
Ainsi que de nouveaux mots clé mystérieux comme some
qui est la réponse directe à la sentence :
Protocols ‘WouldBeSoNice’ can only be used as a generic constraint because it has Self or associated type requirements
Si ça vous intéresse je vous conseille ce petit article.
Ceci-dit, même si ça disparaît vite, je pense que c’est un frein assez considérable notamment pour des débutants.
Observer des objets imbriqués
Un ViewModel
en mode Combine doit avoir cette allure :
class MyViewModel : ObservableObject {
@Published
var someProperty = "Coucou copaing !"
}
Le wrapper @Published
est tout à fait adaptée aux structs puisque toute mutation d’une struct est un changement de valeur. Mais les classes si elles sont faites pour être mutées ne remonteront point l’évènement au wrapper.
Or pour observer une propriété imbriquée au deuxième niveau dans SwiftUI, comme .sheet(isPresented: $vm.router.showSheet) {…}
, on peut essayer :
- d’observer le routeur qui serait une struct et de prendre sa valeur
showSheet
- avec le routeur en
ObservableObject
, observer directementshowSheet
Eh bien aucune des deux options ne fonctionne directement depuis une vue SwiftUI.
Ce petit $
qui désigne la projectedValue
d’une propriété encapsulée par un @Published
ou un @State
n’est pas magique, et ça ne fonctionne qu’au premier niveau, c’est à dire un @Published
propriété d’un ObservableObject
.
Et la feinte officielle n’est pas bien glorieuse :
class MyViewModel : ObservableObject {
@Published
var router = Router
private var cancellables = Set<AnyCancellable>()
init(router: Router) {
self.router = router
router.objectWillChange.sink { [weak self] _ in
self?.objectWillChange.send()
}.store(in: &cancellables)
}
deinit {
cancellables.forEach({ $0.cancel() })
}
}
Il-y-a des variantes plus concises au prix de sacrifices discutables, mais voilà le principe. A chaque fois que le routeur va changer, on va propager l’évènement pour indiquer à l’UI de se rafraîchir. C’est un peu large, un peu “Mario fait du Combine”, mais ne soyons pas obtus, si ça roule après tout…
Une bonne alternative est de mettre tout ça à plat dans la vue :
struct MyView: View {
@ObservedObject
private var vm: MainViewModel
var body: some View {...}
}
deviendrait
struct MyView: View {
@ObservedObject
private var vm: MyViewModel
@ObservedObject
private var router: Router
var body: some View {...}
}
C’est plus élégant je trouve, mais imaginons que j’ai 8 entités un peu complexes à embarquer dans mon VM, ça commence alors à foisonner plus que de raison.
Autre point en passant, impossible de sécuriser l’instance embarquée du routeur dans le view-model avec un private(set)
modifier dans la première version. C’est de l’ordre du TOC - c’est bien d’en être conscient - mais ça me gène 😅
Propager des @Published, Model -> VM -> View
class Store : ObservableObject {
@Published
private(set) var someUrl: URL?
}
class MyViewModel : ObservableObject {
@Published
private(set) var someUrl: URL?
init(store: Store) {
store.$someUrl.assign(to: &$someUrl)
}
}
Mais c’est très raisonnable, super ! Oui mais iOS14+ seulement.
Et voici la version iOS 13 :
class Store : ObservableObject {
@Published
private(set) var someUrl: URL?
}
class MyViewModel : ObservableObject {
@Published
private(set) var someUrl: URL?
private var cancellables = Set<AnyCancellable>()
init(store: Store) {
store.$someUrl.sink { [weak self] url in
self?.someUrl = url
}.store(in: &cancellables)
}
deinit {
cancellables.forEach({ $0.cancel() })
}
}
* whip sound *
Pas mal hein ?
* whip sound *
Sink twice
silence génant
Quand on observe un sujet avec sink
, sachez que la valeur qui vous est passée en closure est celle qui va être attribuée, comme lorsqu’on utilise willSet
sur une propriété (quelques détails ici).
Pas de problème pour l’update d’UI, c’est fait pour. Mais pour les autres besoins, comme par exemple quand on a des mécanismes complexes intermédiaires qui ne se résument pas à fusionner deux valeurs publiées, la meilleure chose à faire est encore de créer son publisher.
Et même si j’aurais encore beaucoup à dire autour de ce sujet, je réserve à un futur article une petite contribution autour de ce sujet, des property wrappers et consorts.
N’hésitez pas à m’écrire si vous avez un avis quelconque sur ce que j’ai écrit, mon email est (?) dans le footer ;)