Skip to content

Blog


Adopter SwiftUI avec une approche ascendante pour minimiser les risques

13 septembre 2022

|
Terry Latanville

Terry Latanville

Md Al Mamun

Md Al Mamun

La question de savoir si SwiftUI est prêt pour l'entreprise est un sujet qui fait l'objet d'un grand débat parmi de nombreux ingénieurs. Ce n'est pas un secret que DoorDash l'a pleinement adopté dans son application grand public, puisque nous avons récemment organisé une réunion technique pour partager les nombreux défis que nous avons surmontés. Notre plus grand défi, cependant, a été de consacrer suffisamment de temps à la réécriture de plusieurs applications en parallèle.

À partir d'iOS 13, Apple a proposé un paradigme d'interface utilisateur radicalement différent sous la forme de SwiftUI. Il s'agissait d'un changement majeur par rapport à UIKit, avec des pipelines de données réactifs et une syntaxe déclarative. De nombreux codes d'entreprise sont pilotés par un flux de données unidirectionnel (par exemple VIPER, MVVM-C), alors que SwiftUI tire parti d'un flux de données bidirectionnel sous la forme de liaisons et d'observables.

Notre équipe a vu une opportunité unique de construire de nouvelles fonctionnalités avec SwiftUI, tout en les isolant dans notre architecture VIPER existante. Cette approche ascendante permettrait des améliorations incrémentales et s'avérerait moins perturbante pour les autres développeurs. Elle présentait également l'avantage d'être complètement agnostique par rapport au reste de la base de code ; d'autres fonctionnalités continueraient à utiliser l'interface VIPER établie, sans jamais avoir besoin de connaître les détails d'implémentation de la couche View.

Pourquoi adopter SwiftUI avec une approche ascendante ?

L'adoption de SwiftUI selon une approche ascendante est préférable car elle minimise les risques en étant moins perturbatrice, tout en permettant des améliorations progressives.

L'introduction d'un nouveau paradigme d'interface utilisateur représenterait un risque considérable pour une application établie comme Dasher, qui est utilisée par les chauffeurs-livreurs pour accepter et exécuter des offres de commande de nourriture. Obliger les autres équipes à modifier leur façon de travailler allongerait considérablement les délais de développement. Il était donc hors de question d'adopter SwiftUI avec une approche descendante. DoorDash a choisi de réécrire l'application consommateurs l'année dernière, ce qui, selon nos estimations, nécessiterait des milliers d'heures d'ingénierie. Notre approche ascendante devait atténuer l'impact sur les autres équipes en travaillant dans les limites des modules existants. Le respect de ces contraintes nous a permis d'utiliser SwiftUI pour de nouveaux modules sans avoir à modifier le code existant.

Restez informé grâce aux mises à jour hebdomadaires

Abonnez-vous à notre blog d'ingénierie pour recevoir régulièrement des informations sur les projets les plus intéressants sur lesquels notre équipe travaille.

Atténuer les difficultés liées à l'adoption de nouvelles technologies

L'adoption de fonctionnalités de pointe n'est pas un problème nouveau. Il y a toujours de nouvelles tendances et de nouvelles fonctionnalités qui sont introduites, et les ingénieurs cherchent toujours à les essayer. Les entreprises adoptent généralement l'une des deux approches suivantes.

Descendante

Une approche descendante consiste à jeter ou à réécrire l'ensemble de l'application en faveur d'un nouveau code utilisant les technologies les plus récentes. Il y a de nombreuses bonnes raisons de choisir cette voie. L'une des plus importantes est qu'elle permet d'éviter la migration du code existant. Souvent, ce code a été écrit par des personnes qui ont quitté l'entreprise, ou a nécessité des connaissances particulières sur le fonctionnement de certaines parties de l'application. Plusieurs centaines d'heures d'ingénierie sont souvent consacrées à la maintenance du code existant. Cet effort n'est plus nécessaire lors d'une réécriture. Le fait de partir d'une page blanche permet d'avoir des modèles et des références cohérents dans la base de code, plutôt que d'avoir plusieurs façons d'obtenir le même résultat.

Les nouveaux développeurs chercheront souvent des exemples lorsqu'ils créeront de nouvelles fonctionnalités. Si une technologie est partiellement adoptée, comme ce serait le cas dans une approche ascendante, il peut y avoir plus d'une façon d'obtenir le même résultat, ce qui peut être source d'incertitude. Il y a aussi l'avantage supplémentaire de faire appel à l'amour des ingénieurs pour le "nouveau et le brillant", ce qui peut être excellent pour l'embauche et le moral. Il peut être décourageant d'entendre que les entreprises utilisent des technologies plus anciennes au cours du processus d'entretien.

L'adoption d'une approche descendante présente plusieurs inconvénients, le plus important étant l'importance de l'investissement initial : les fonctionnalités qui fonctionnent aujourd'hui prendront du temps à être reconstruites selon la nouvelle approche. La réimplémentation des fonctionnalités existantes est une tâche difficile, car le système présente souvent des particularités très spécifiques qui ont été intégrées au fil des itérations du logiciel. Certaines de ces particularités peuvent même constituer le flux de travail préféré des utilisateurs. La reconstruction d'une application entière entraînera toujours des défis et des résultats inattendus. De grandes entreprises comme Uber ont développé leur architecture RIBS au cours d'une réécriture bien documentée. Pinterest a exploré une architecture entièrement nouvelle en partant de zéro. Avec autant de risques différents, il est souvent difficile d'estimer le temps que prendra une approche descendante. Une approche descendante augmente le risque d'instabilité dans un projet existant car il y a beaucoup de pièces mobiles : tout est en train d'être réécrit, les ingénieurs peuvent avoir besoin d'accéder aux mêmes segments de code, ou la chaîne de dépendances peut être non triviale.

Bottom-up (du bas vers le haut)

Une approche ascendante, parfois appelée " Strangler Fig Pattern", du nom du figuier de Strangler, permet d'adopter les nouvelles technologies à un rythme mesuré, plutôt que de perturber l'ensemble de l'application. De nouvelles fonctionnalités peuvent être créées en exploitant les interfaces existantes ou en en introduisant de nouvelles, sans avoir d'impact sur les fonctionnalités en amont ou en aval. Les fonctionnalités héritées peuvent être reconstruites en utilisant la nouvelle technologie de manière incrémentale, plutôt qu'en une seule fois. Les nouveaux chemins de code peuvent être validés à l'aide de tests AB avant de retirer l'ancienne implémentation.

L'introduction de la nouvelle approche à un rythme mesuré donne aux ingénieurs plus de temps pour se familiariser avec la nouvelle technologie, car les fonctionnalités peuvent être mises en œuvre en utilisant l'ancienne ou la nouvelle approche, jusqu'à ce que la nouvelle approche soit éprouvée.

Comme pour toute approche alternative, il y a des coûts associés. Les premières fonctionnalités tendent à être indépendantes du code existant, mais il arrivera un moment où les deux approches devront échanger des informations. En fonction de l'architecture et des choix effectués, il peut être nécessaire de créer un code de transition ou de traduction. Cela demande du temps supplémentaire aux ingénieurs qui connaissent les deux modèles. La construction de ces passerelles a tendance à permettre au code existant de rester dans le système beaucoup plus longtemps qu'une réécriture descendante. Le système développera de multiples modèles pour résoudre des problèmes similaires, ce qui rendra beaucoup plus difficile l'intégration des nouveaux ingénieurs et l'adoption de la "bonne approche" lors de la mise en œuvre d'une nouvelle fonctionnalité. Dans les cas extrêmes, si la nouvelle approche n'est pas pleinement adoptée, voire abandonnée, l'approche ascendante aura ajouté un montant significatif de dette technologique, annulant de fait les gains censés être apportés par la nouvelle technologie.

Un bref rappel de l'utilisation de l'architecture VIPER par DoorDash

DoorDash utilise l'architecture VIPER à bon escient, permettant d'isoler les fonctionnalités au fur et à mesure de leur développement. Cette isolation a permis de créer des fonctionnalités beaucoup plus rapidement, compte tenu du niveau d'expérience de l'équipe avec SwiftUI. De nombreux articles fournissent des détails supplémentaires sur l'architecture VIPER. Nous recommandons celui publié par l'équipe d'Objc.io : Architecting iOS Apps with VIPER.

L'application Dasher de DoorDash est construite à partir d'une collection de modules. En général, chaque module représente une fonction spécifique, comme la réception d'une commande ou la consultation de vos évaluations personnelles. Les modules sont autonomes et utilisent toujours une vue, un interacteur et un présentateur pour assurer un flux d'informations unidirectionnel.

Les vues adressent des demandes aux interacteurs qui, à leur tour, récupèrent des entités auprès de services distants. Une fois la requête terminée, l'interacteur transmet les données au présentateur, qui les traduit pour que la vue puisse être affichée. Les routeurs sont utilisés dans des cas d'utilisation plus complexes pour gérer comment et quand une nouvelle vue doit être présentée.

Toute personne familière avec SwiftUI remarquera probablement un obstacle immédiat à l'adoption. SwiftUI préfère les flux de données bidirectionnels fournis par les enveloppes de propriétés telles que @State et @Binding. L'équipe a été confrontée à un choix :

  • Poursuivre l'implémentation de la couche Vue avec UIKit, construire une architecture réactive à l'intérieur de notre application, et passer en gros à SwiftUI et Combine.
  • Trouver un équilibre et développer une solution qui permette aux modules individuels de décider s'ils utilisent SwiftUI ou UIKit.

Comment DoorDash a intégré SwiftUI dans une application basée sur VIPER avec une approche ascendante

Notre équipe a choisi d'introduire un moyen d'utiliser SwiftUI dans les limites de notre architecture VIP. Cette approche ne serait appliquée qu'aux nouvelles fonctionnalités que nous construisions afin de prouver que SwiftUI pouvait être intégrée de manière fiable dans l'application Dasher aux côtés de nos fonctionnalités UIKit existantes. Notre approche éviterait l'investissement en temps considérable que représente la réécriture des fonctionnalités existantes et permettrait à la couche View de devenir un détail d'implémentation qui varierait entre les modules.

Notre approche de l'intégration de SwiftUI dans une architecture VIP a nécessité quelques éléments supplémentaires :

  1. State:
    1. Un type de référence au niveau du module
    2. sert de source unique de vérité pour les données du module
    3. Récepteur des données transmises par le présentateur
    4. Un conteneur pour divers éditeurs de combinaisons, qui produisent des données et des états en aval (à consommer par une ou plusieurs instances de ViewModel).
  2. ViewModel:
    1. Un type de référence au niveau de la vue
    2. Un observable qui calcule les deltas ViewState
  3. ViewState:
    1. Un type de valeur au niveau de la vue
    2. Encapsule les données reçues par le biais de changements d'état, sous l'impulsion du présentateur.
Figure 1 : VIP réactif

La figure 1 illustre le flux de données unidirectionnel de Reactive VIP. La sous-classe typique UIViewController est remplacée par un UIHostingController. Le modèle de vue adopte le chemin de communication vers l'interacteur qui appartenait auparavant à la vue. Le contrôleur d'hébergement est responsable de la construction et de l'affichage de la vue SwiftUI de niveau racine pour le module. Le contrôleur hôte possède également l'état du module, agissant comme une source de vérité pour toutes les données du module. La construction de la vue SwiftUI de niveau supérieur dans la fonction init du contrôleur hôte permettrait au ViewModel racine de référencer les valeurs publiées à partir de l'objet State du module. Les données reçues du présentateur seraient reçues dans le contrôleur hôte, comme c'est généralement le cas dans VIP, mais les données seraient transmises telles quelles à la partie appropriée de l'objet State du module. Les ViewModels exposent alors un ViewState à leurs vues SwiftUI respectives qui agissent comme un instantané dans le temps.

Ce n'est pas un secret que la navigation dans SwiftUI est un sujet de grand débat. Étant donné que nous construisions des fonctionnalités de manière contenue avec SwiftUI à l'intérieur de VIP, nous avons complètement délégué la navigation à VIP via les Routers. Cela a permis de s'assurer que la navigation dans l'application était toujours gérée par une interface cohérente. 

Démarrer avec Reactive VIP

Prenons l'exemple d'une fonctionnalité dans laquelle nous voulons connaître le nombre de fois qu'un utilisateur a appuyé sur un bouton. Pour garder la trace du nombre d'appuis, nous pourrions avoir besoin d'appeler le backend pour enregistrer le nombre, le backend renverra ensuite les données mises à jour et nous devrions alors afficher les dernières informations à l'utilisateur. Décomposons la fonctionnalité en fonction de l'architecture ci-dessus.

Éléments VIP traditionnels

Module
  • La navigation et la communication externe passent par cet élément
  • Réception et gestion des événements du cycle de vie de l'application (si nécessaire)
  • Construit les éléments VIP, en conservant la propriété de la vue.

struct Entity {
    let name: String
    let count: Int
}
 
class Module {
    let view: ViewProtocol
 
    init() {
        let presenter = MyPresenter()
        let interactor = MyInteractor(presenter: presenter)
        self.view = MyHostingController(interactor: interactor)
        presenter.view = view
    }
}
Vue (contrôleur d'hébergement)
  • UIHostingController agit comme un pont entre UIKit et SwiftUI
  • Le cycle de vie d'UIKit se termine ici et SwiftUI commence ici
  • Les rootView n'est pas recréé à chaque fois que les données changent

protocol ViewProtocol: UIViewController {
    func showUpdatedContent(
        updatedName: String,
        updatedCount: Int
    )
}
 
class MyHostingController: UIHostingController<MyView> {
    private let state = MyState()
 
    private let interactor: Interactor
 
    init(interactor: Interactor) {
        self.interactor = interactor
 
        // In some cases, the hosting controller can create an
        // intermediate type to publish property changes to the
        // ViewModel all at once.
        // e.g.
        //
        // let viewStoreSubject = PassthroughSubject<ViewStore, Never>
        //
        // struct ViewStore {
        //    let updatedName: String
        //     let updatedCount :Int
        // }
        //
        let namePublisher = state.$name.eraseToAnyPublisher()
        let countPublisher = state.$count.eraseToAnyPublisher()
        let myView = MyView(viewModel: .init(
            namePublisher: namePublisher,
            countPublisher: countPublisher,
            interactor: interactor
        ))
        super.init(rootView: myView)
    }
 
    @MainActor required dynamic init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
 
extension MyHostingController: ViewProtocol {
    func showUpdatedContent(updatedName: String, updatedCount: Int) {
        DispatchQueue.main.async { [weak self] in
            self?.state.name = updatedName
            self?.state.count = updatedCount
        }
    }
}
Interacteur
  • Gère les actions reçues de la part du View couche
  • Communique avec divers services, notamment des services d'analyse, d'enregistrement ou des effets secondaires produits par les actions de l'utilisateur.
  • Des dépendances peuvent être injectées ici pour accéder à des services fournis par d'autres parties du système.

protocol Interactor {
    func didTap()
}
 
class MyInteractor: Interactor {
    private let presenter: Presenter
    private var entity = Entity(
        name: "Mr. SwiftUI",
        count: 0
    )
 
    init(presenter: Presenter) {
        self.presenter = presenter
    }
 
    func didTap() {
        // Sample business logic
        entity = Entity(
            name: entity.name,
            count: entity.count + 1
        )
        DispatchQueue.global().async { [weak presenter, entity] in
            presenter?.update(from: entity)
        }
    }
}
Présentateur
  • Résumés View de la couche Interactor
  • Transforme les données reçues des services pour le View couche 
  • Très léger, moins de responsabilités de tous les éléments

protocol Presenter: AnyObject {
    func update(from entity: Entity)
}
 
class MyPresenter: Presenter {
    weak var view: ViewProtocol?
 
    func update(from entity: Entity) {
        view?.showUpdatedContent(
            updatedName: entity.name,
            updatedCount: entity.count
        )
    }
}

Composants réactifs

State
  • sert de source unique de vérité pour le module
  • Transmet les données à SwiftUI via les changements publiés
  • Vit dans le même fichier que le contrôleur d'hébergement en tant qu'élément privé

private class MyState: ObservableObject {
    @Published var name = ""
    @Published var count = 0
}
Vue (SwiftUI)
  • ViewState établit une relation univoque entre l'interface SwiftUI View et la source de la vérité
  • Cette approche garantit une seule direction de communication
  • La logique d'entreprise est transférée hors du View ce qui facilite grandement les simulations et les tests.

struct MyView: View {
    struct ViewState {
        var name: String
        var totalTaps: Int
    }
 
    @ObservedObject var viewModel: MyViewModel
 
    var body: some View {
        VStack {
            Text("Hello \(viewModel.viewState.name)")
            Text("You pressed the button \(viewModel.viewState.totalTaps) time(s)")
            Button {
                viewModel.buttonTapped()
            } label: {
                Text("Tap Me")
            }
        }
    }
}
 
struct MyView_Previews: PreviewProvider {
    static var previews: some View {
        MyView(viewModel: .init(
            namePublisher: Just("Name").eraseToAnyPublisher(),
            countPublisher: Just(0).eraseToAnyPublisher(),
            interactor: MyInteractor(presenter: MyPresenter())
        ))
    }
}

Modèle de vue

  • Les ViewModel agit comme un pont de données
  • Accepte les données via les propriétés publiées par l'État
  • Signale les actions de l'utilisateur à l'interacteur
  • L'abstraction de ces dépendances facilite les tests unitaires. ViewModels

class MyViewModel: ObservableObject {
    @Published var viewState = MyView.ViewState(
        name: "",
        totalTaps: 0
    )
    private let interactor: Interactor
    private var namePublisherCancellable: AnyCancellable?
    private var countPublisherCancellable: AnyCancellable?
 
    init(
        namePublisher: AnyPublisher<String, Never>,
        countPublisher: AnyPublisher<Int, Never>,
        interactor: Interactor
    ) {
        self.interactor = interactor
        self.namePublisherCancellable = namePublisher
            .sink { [weak self] newName in
                self?.viewState.name = newName
            }
        self.countPublisherCancellable = countPublisher
            .sink { [weak self] newCount in
                self?.viewState.totalTaps = newCount
            }
    }
 
    func buttonTapped() {
        interactor.didTap()
    }
}

Livrer des fonctionnalités plus rapidement, avec une meilleure testabilité grâce à SwiftUI

L'adoption de SwiftUI avec une approche ascendante a été une énorme victoire pour notre projet, et ce de plusieurs façons. L'équipe a été en mesure de réaliser des tâches basées sur des vues 50 % plus rapidement qu'auparavant, en grande partie grâce à la réduction du temps d'itération grâce aux aperçus de SwiftUI et à la diminution des lignes de code nécessaires par rapport à une vue UIKit.

L'ajout de ces éléments supplémentaires à notre architecture a représenté une petite quantité d'overhead, mais cela a été compensé par une réduction des couches Interactor et Presenter. Les itérations futures de notre architecture pourraient inclure la suppression de ces couches, mais nos objectifs initiaux étaient de modifier la couche View et de maintenir les modèles d'architecture VIP pour assurer la compatibilité avec d'autres modules basés sur UIKit.

Une autre grande victoire a été l'amélioration de la testabilité grâce à la relation 1:1 entre les ViewModels et les Views. Les ViewModels étaient suffisamment simples pour pouvoir être simulés, ce qui nous a permis de mettre en œuvre des tests instantanés avec une petite quantité de travail supplémentaire, ce qui nous a permis de gagner du temps grâce à la réduction des délais de mise en œuvre des vues.

Cette victoire est importante car l'adoption de SwiftUI a été limitée. iOS 13 a introduit SwiftUI, mais à une échelle limitée et avec un nombre important de problèmes. De nombreux ingénieurs hésitent à adopter SwiftUI car ils estiment qu'elle n'est pas "prête pour l'entreprise". Cette expérience suggère aux ingénieurs de revoir les statistiques de leur base d'installation, car les mêmes appareils peuvent fonctionner sous iOS 13, 14 et 15. Profitez des améliorations offertes par les versions ultérieures de SwiftUI et passez à la version minimale dès aujourd'hui - nous l'avons fait !

Notre prochain objectif est d'élargir l'adoption de notre solution et d'aider d'autres ingénieurs à intégrer SwiftUI à leurs fonctionnalités.

Comment adopter SwiftUI dans votre projet d'entreprise dès aujourd'hui

Comme le savent de nombreux ingénieurs, la mise en œuvre de fonctionnalités avec une grande rapidité est un facteur clé dans l'économie actuelle axée sur les produits. Il peut être très difficile de vendre aux parties prenantes d'un produit un plan de réécriture de l'ensemble de l'application. L'effort à fournir est toujours considérable et il peut être difficile d'atteindre la parité des fonctionnalités en peu de temps, sans parler d'ajouter quelque chose de nouveau. Cela peut s'avérer encore plus difficile s'il y a des contraintes au niveau des testeurs ou si le système ne dispose pas d'un ensemble complet de tests au niveau des fonctionnalités.

L'adoption de SwiftUI dans une application d'entreprise peut également s'avérer difficile, de nombreuses architectures étant construites autour du modèle procédural d'UIKit. Ces facteurs font qu'il est crucial d'identifier une tranche suffisamment petite de votre architecture pour supporter SwiftUI. En travaillant dans ces limites, adoptez SwiftUI avec une approche ascendante pour atténuer les inconvénients d'une réécriture et profiter de la vitesse et de la testabilité offertes par SwiftUI dans vos cycles de développement. Les nouvelles fonctionnalités peuvent être écrites en même temps que les fonctionnalités existantes sans introduire d'obstacles supplémentaires pour les autres personnes travaillant sur votre produit. Les fonctionnalités existantes peuvent être laissées telles quelles, ou un plan peut être établi pour les réécrire à un rythme acceptable, mais elles n'entraveront pas le système dans son ensemble.

About the Authors

Emplois connexes

Localisation
Seattle, WA; Sunnyvale, CA; San francisco, CA
Département
Ingénierie
Localisation
San Francisco, CA ; Sunnyvale, CA ; Los Angeles, CA ; Seattle, WA ; New York, NY
Département
Ingénierie
Localisation
San Francisco, CA ; Sunnyvale, CA ; Los Angeles, CA ; Seattle, WA ; New York, NY
Département
Ingénierie
Localisation
New York, NY; San Francisco, CA; Los Angeles, CA; Seattle, WA; Sunnyvale, CA
Département
Ingénierie
Localisation
Toronto, ON
Département
Ingénierie