Alors qu'UIKit a été le framework de référence pour les ingénieurs iOS pour construire des interfaces utilisateur dans leurs applications au fil des ans, SwiftUI a gagné régulièrement du terrain en tant que framework alternatif qui corrige de nombreux inconvénients d'UIKit. Par exemple, SwiftUI nécessite beaucoup moins de code pour construire la même interface utilisateur, et produit toujours une mise en page valide. Les développeurs n'ont plus besoin de passer des heures à déboguer les problèmes de mise en page automatique. Dans cet article, nous allons d'abord comparer l'approche événementielle d'UIKit à l'approche orientée données de SwiftUI, puis nous plongerons dans le cycle de vue, l'identité et le processus de rendu de SwiftUI afin de mieux comprendre comment écrire du code performant dans SwiftUI.
Fonctionnement d'un cadre événementiel
UIKit fournit une interface utilisateur événementielle par nature, où les vues sont créées par une séquence d'événements qui effectuent des opérations et finissent par former ce qui est vu à l'écran. Dans un cadre événementiel, il doit y avoir un contrôleur qui colle la vue et les événements. Cette colle est appelée contrôleur de vue.
Fonctionnement du contrôleur de vue
Le contrôleur de vue est essentiellement un centre de contrôle qui décide de ce qui se passe en fonction d'événements particuliers. Par exemple, si un contenu doit être affiché à l'écran lors du chargement d'une page, le contrôleur de vue écoute l'événement de chargement de la page et exécute la logique métier nécessaire pour charger et afficher le contenu. Examinons un exemple plus spécifique :
Supposons qu'il y ait un bouton qui, lorsqu'on clique dessus, affiche une image d'un type de fruit aléatoire sur l'écran. Après chaque nouveau clic sur le bouton, une nouvelle sorte de fruit est affichée. Examinons une représentation du flux si cela était construit avec UIKit dans la figure 1 ci-dessous.
Dans ce flux, le contrôleur de vue conserve une référence au bouton et à la vue. Lorsqu'un utilisateur clique sur le bouton, le contrôleur de vue prend cela comme un signal pour calculer un nouveau type de fruit. Une fois que le nouveau fruit est renvoyé, le contrôleur de vue demande à la vue de mettre à jour l'interface utilisateur. Dans ce cas, l'événement de clic sur le bouton commande la logique qui modifie l'interface utilisateur.
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.
Veuillez saisir une adresse électronique valide.
Merci de vous être abonné !
Les défis de l'utilisation d'UIKit et des contrôleurs de vue
Même s'il s'agit d'un exemple très simple, nous pouvons voir que le contrôleur de vue a plusieurs responsabilités. Avec des vues plus complexes dans une application de production, ces responsabilités signifient que le contrôleur de vue peut devenir massif et difficile à gérer. Nous devons écrire le code et dicter la logique de l'interaction entre le contrôleur de vue, la vue et chaque événement, ce qui peut être source d'erreurs et difficile à lire.
Bien sûr, une grande partie de la douleur liée au traitement du contrôleur de vue peut être atténuée par une bonne architecture de code et une séparation des préoccupations. L'architecture VIP que notre application iOS DoorDash utilise permet d'extraire la logique métier et la logique de présentation, de sorte que le contrôleur de vue n'a pas besoin de connaître cette logique et peut simplement se concentrer sur l'affichage de la vue à l'écran en fonction des données.
Mais toute architecture ne peut éviter le contrôleur de vue, car son rôle de ciment entre les événements et la vue est irremplaçable dans un cadre axé sur les événements.
Fonctionnement d'un cadre axé sur les données
Alors qu'UIKit utilise un framework piloté par les événements, SwiftUI est basé sur un framework piloté par les données. Dans SwiftUI, les vues sont une fonction de l'état, et non une séquence d'événements(WWDC 2019). Une vue est liée à certaines données (ou état) en tant que source de vérité, et se met automatiquement à jour chaque fois que l'état change. Ceci est réalisé en définissant les vues comme des fonctions qui prennent la liaison de données comme argument.
Ce cadre axé sur les données élimine complètement le contrôleur de vue en tant qu'intermédiaire. Ce que l'utilisateur voit à l'écran est directement contrôlé par un état, qui peut être n'importe quel type de données. En utilisant le même exemple d'application de fruits que nous avons utilisé ci-dessus avec UIKit, nous pouvons voir une illustration de ce concept ci-dessous dans la Figure 2.
The fruit type is a state that is bound to the view, which means whenever the fruit is updated, it will automatically be reflected on the view. This means that when a user clicks the button, we just need to update the state, and the view will update to show the new fruit, without needing a controller to tell it to do so. Hence the term “data-driven” - the UI is a direct representation of data.
Les avantages d'un cadre fondé sur les données
Travailler avec un cadre axé sur les données signifie qu'il n'y a plus de contrôleurs de vue massifs et qu'il n'est plus nécessaire de définir la logique des événements pour effectuer les mises à jour de la vue. L'interface est couplée aux données, ce qui réduit le nombre de lignes de code et améliore la lisibilité. Nous pouvons facilement comprendre que le fruit que la vue affiche est déterminé par l'état du fruit, contrairement à UIKit, où nous devrions fouiller dans le code pour voir comment le fruit est contrôlé.
Les défis de l'utilisation de SwiftUI
Tout nouveau framework ou technologie présente des compromis. En se basant uniquement sur les comparaisons de frameworks basés sur les événements et les données mentionnées ci-dessus, SwiftUI peut toujours apparaître comme l'option supérieure, mais ce n'est pas tout à fait vrai.
Les inconvénients de SwiftUI sont principalement liés au fait qu'il n'a été publié qu'il y a trois ans. SwiftUI est un nouveau framework, il faudra donc du temps pour que les développeurs l'adoptent et l'apprennent. Compte tenu de l'adoption en cours, il y a moins d'architectures de code établies basées sur SwiftUI. Nous avons également rencontré des problèmes de rétrocompatibilité, lorsque le même code SwiftUI fonctionne différemment dans iOS 14 et 15, ce qui rend le débogage très difficile.
Maintenant que nous avons une compréhension de base des avantages et des inconvénients des deux types de frameworks, nous allons nous plonger dans les défis spécifiques que nous avons rencontrés avec SwiftUI et son processus de rendu de vue, et comment écrire un code efficace pour préserver l'identité d'une vue afin de créer une interface utilisateur fluide et optimale.
Voir dans SwiftUI
Certains concepts principaux méritent d'être mentionnés lorsque l'on travaille avec SwiftUI :
- Vue en fonction de l'état
- Identité de la vue
- Durée de vie de la vue
Tout d'abord, les données sont la source de vérité pour la vue. Lorsque les données changent, nous recevons les mises à jour sur une vue. Nous savons donc déjà que les vues dans SwiftUI sont fonction d'un état (Figure 3). Mais quel est cet état dans le monde de SwiftUI ?
Lorsque l'on passe d'une architecture événementielle à un framework déclaratif, on peut se poser quelques questions. Il n'est pas difficile de comprendre les bases de SwiftUI, mais ce qui se passe sous le capot n'est pas très clair. Nous savons que lorsque l'état de la vue change, la vue est mise à jour, mais certaines questions se posent naturellement :
- Comment les données sont-elles mises à jour ?
- Comment le point de vue comprend-il ce qui doit exactement changer ?
- Crée-t-il une nouvelle vue à chaque fois qu'un petit élément de données est modifié ?
- Quelle est l'efficacité et le coût des mises à jour des données ?
Il est essentiel de comprendre le fonctionnement interne du framework. Les réponses à ces questions et à d'autres peuvent aider à résoudre certains comportements indésirables dans nos applications, comme des performances médiocres, des bogues aléatoires ou des animations inattendues. Cela permettra de développer des applications bien optimisées et sans bogues.
A propos de la hiérarchie des vues de SwiftUI.
L'élément principal de l'interface utilisateur dans SwiftUI est une vue. Les performances et la qualité de la partie visuelle de l'application dépendent de l'efficacité de sa définition et de ses manipulations d'état. Jetons un coup d'œil à la vue par défaut qui a été créée pour un modèle SwiftUI dans Xcode :
struct ContentView: View {
var body: some View {
Text("Hello, world!")
.padding()
}
}
Il existe une structure ContentView conforme au protocole View:
public protocol View {
associatedtype Body : View
@ViewBuilder var body: Self.Body { get }
}
Une propriété de corps calculée définit le contenu de la vue. La composition des vues SwiftUI forme une hiérarchie de vues. Le protocole View a un type associé, qui est également une View. À un moment donné, SwiftUI essaiera de rendre le ContentView, et demandera simplement le corps du ContentView. Cependant, si la vue de contenu ne contient pas une vue Text primitive, mais une autre vue personnalisée, SwiftUI devra demander à toutes les vues personnalisées imbriquées leur corps afin de les afficher. Jetons un coup d'œil à cet exemple :
struct FruitsView: View {
var body: some View {
BananaView()
}
}
struct BananaView: View {
var body: some View {
Text("I am banana!")
.padding()
}
}
Dans ce cas, FruitsView demandera son corps à BananaView, car il doit savoir ce qu'il doit afficher. BananaView demande son corps au Text. Il s'agit d'une série d'appels récursifs, comme le montre la figure 4, car chaque vue a un corps, et le corps renvoie une vue.
SwiftUI, pour avoir de bonnes performances, doit couper court et briser la récursion d'une manière ou d'une autre. Dans notre cas, la récursion se terminera lorsque SwiftUI tentera de demander au texte son corps, car le texte, ainsi que d'autres composants de SwiftUI, est un type primitif. Il peut être dessiné sans demander son corps. Ceci est réalisé avec un type Never:
extension Text : View {
public typealias Body = Never
}
extension Never : View {
public typealias Body = Never
public var body: Never { get }
}
De plus, Never est conforme au protocole View. Ainsi, notre récursivité s'arrêtera lorsque nous atteindrons le type primitif, comme le montre la figure 5, car SwiftUI traitera les types primitifs d'une manière spéciale.
Les types primitifs constituent la base de toute hiérarchie de vues. Le texte est l'un des types de vues primitives, mais il en existe d'autres :
- Texte
- Image
- Entretoise
- ZStack
- VStack
- HStack
- Liste
- Etc.
Système de gestion de l'État
Chaque vue a un état, qui peut être modifié au cours de l'exécution de notre application. L'état est une source unique de vérité pour cette vue. La vue et son état ont des mécanismes qui pilotent les mises à jour du corps, donc à chaque fois que l'état de la vue change, le corps est demandé. Dans SwiftUI, l'état peut être créé de différentes manières, par exemple :
- @State
- @StateObject
- @Binding
- @ObservedObject
- @EnvironmentObject.
@State
L'état est une source de vérité pour la vue et il est utilisé lorsque la portée des changements est limitée à la vue uniquement. En enveloppant les types de valeurs dans des propriétés d'état transitoires, le cadre alloue un stockage persistant pour ce type de valeur et en fait une dépendance, de sorte que les modifications de l'état seront automatiquement reflétées dans la vue. C'est une bonne pratique d'utiliser un mot-clé private lors de la déclaration de State, parce qu'il est conçu pour être utilisé par la vue en interne.
@StateObject
Ce property wrapper doit être appliqué au type qui se conforme au protocole ObservedObject et permet de suivre les changements de cet objet et de le traiter comme un état. SwiftUI crée une nouvelle instance de l'objet une seule fois pour chaque instance de la structure qui déclare l'objet. Lorsque les propriétés publiées de l'objet observable changent, SwiftUI met à jour les parties de la vue qui dépendent de ces propriétés.
@ObservedObject
Il s'agit d'un type d'enveloppeur de propriété qui s'abonne à un objet observable et invalide une vue chaque fois que l'objet observable change. Cette enveloppe de propriété est très similaire à @StateObject ; la principale différence est que @StateObject est utilisé pour créer initialement la valeur et que nous pouvons ensuite la transmettre en tant que dépendance aux autres vues à l'aide de @ObservedObject.
@ObservedObject est utilisé pour garder la trace d'un objet qui a déjà été créé.
@Binding
Ce wrapper de propriété est utile dans presque toutes les vues SwiftUI. Le binding est un wrapper de propriété qui peut lire et écrire une valeur appartenant à une source de vérité, par exemple un @State ou l'une des propriétés de @StateObject. Le signe du dollar ($) est utilisé pour préfixer la variable de propriété @State afin d'obtenir la valeur projetée, et cette valeur projetée est un binding. Vous pouvez ensuite transmettre une liaison à un niveau plus bas dans la hiérarchie de la vue et la modifier. Les modifications seront répercutées sur toutes les vues qui l'utilisent comme source de vérité.
struct BananaView: View {
@State private var isPeeled: Bool = false
var body: some View {
Text(isPeeled ? "Peeled banana!" : "Banana!")
.background(.yellow)
PeelBananaButton(isPeeled: $isPeeled)
}
}
struct PeelBananaButton: View {
@Binding var isPeeled: Bool
var body: some View {
Button("Peel Banana") {
isPeeled = true
}
}
}
@EnvironmentObject
Ce wrapper de propriété ne crée ni n'alloue l'objet lui-même. Au lieu de cela, il fournit un mécanisme permettant de surveiller l'environnement de la hiérarchie des vues. Par exemple, la vue parentale, qui contient la source de vérité (par exemple, StateObject), comporte plusieurs couches de vues secondaires (figure 6).
Les vues C et D dépendent des données. La transmission des données peut être réalisée en injectant continuellement l'objet observé à plusieurs reprises, jusqu'à ce que ces vues disposent d'une référence à cet objet. Les vues A et B n'ont pas vraiment besoin de connaître cet objet, puisque seules les vues C et D ont besoin des données. Cette approche peut créer du code de type "boilerplate" et apporter des dépendances supplémentaires aux vues qui n'en ont pas besoin.
Un objet d'environnement est très utile dans ce cas. Il est défini dans une vue de niveau supérieur et toute vue enfant dans une hiérarchie de vues peut accéder à l'objet et obtenir les bonnes mises à jour de données, comme le montre la figure 7 ci-dessous. Il est possible d'accéder à l'objet observé sur une vue ancêtre à condition que l'un de ses ancêtres l'ajoute à la hiérarchie à l'aide du modificateur environmentObject(_ :):
These are the instruments we can use to update the data and have the view reflect the updates. Every small change to the data flow might cause multiple view’s body computations. These computations can potentially affect the performance, for example in case of using unoptimized computed variables. SwiftUI is smart enough to detect the changes and can only redraw the parts of the view which have been actually affected by a data update. This redrawing is done with the help of AttributeGraph - an internal component used by SwiftUI to build and analyze the dependency graph for the data and its related views.
Identité d'une vue
Dans UIKit, les vues sont des classes et les classes ont des pointeurs qui identifient leurs vues. En revanche, dans SwiftUI, les vues sont des structs et n'ont pas de pointeurs. Pour être efficace et optimisée, SwiftUI doit savoir si les vues sont identiques ou distinctes. Il est également important pour le framework d'identifier les vues afin d'effectuer une transition correcte et de rendre la vue correctement une fois que certaines valeurs de la vue ont changé.
View’s identity is a concept that brings some light to SwiftUI rendering magic. There might be thousands of updates across your app, and some body properties are recomputed again and again. However it doesn’t always lead to the full re-rendering of the affected view. And the view’s identity is a key to understanding this. There are two ways of identifying the view in SwiftUI, through explicit identity or structural identity. Let's take a deep dive into both.
Identité explicite
Views can be identified using custom or data-driven identifiers. The pointer identity which is used in UIKit is an example of the explicit identity, since the pointers are being used to identify the view. You probably have seen the examples of it while iterating over your views in a for each loop. Explicit identity can be provided by using the identifier directly: .id(...) . It binds a view's identity to the given value, which needs to be hashable:
extension View {
@inlinable public func id<ID>(_ id: ID) -> some View where ID : Hashable
}
Supposons que nous ayons un ensemble de fruits. Chaque fruit a un nom unique et une couleur :
struct Fruit {
let name: String
let color: Color
}
Pour afficher une liste déroulante de fruits, la structure ForEach peut être utilisée :
struct FruitListView: View {
let fruits = [Fruit(name: "Banana", color: .yellow),
Fruit(name: "Cherry", color: .red)]
var body: some View {
ScrollView {
ForEach(fruits) { fruit in
FruitView(fruit: fruit)
}
}
}
}
struct FruitView: View {
let fruit: Fruit
var body: some View {
Text("\(fruit.name)!")
.foregroundColor(fruit.color)
.padding()
}
}
However, this will not compile and there will be an error: Referencing initializer 'init(_:content:)' on 'ForEach' requires that 'Fruit' conform to 'Identifiable'
Ce problème peut être résolu soit en implémentant le protocole Identifiable dans la structure Fruit, soit en fournissant un keypath. Dans les deux cas, cela permettra à l'interface SwiftUI de savoir quelle identité explicite le FruitView devrait avoir :
struct FruitListView: View {
let fruits = [Fruit(name: "Banana", color: .yellow),
Fruit(name: "Cherry", color: .red)]
var body: some View {
ScrollView {
ForEach(fruits, id: \.name) { fruit in
FruitView(fruit: fruit)
}
}
}
}
Ce nouveau code sera compilé et FruitView sera identifié par son nom, puisque le nom du fruit est conçu pour être unique.
Un autre cas d'utilisation où l'identité explicite est régulièrement utilisée est la possibilité d'effectuer un défilement manuel vers l'une des sections de la vue de défilement.
struct ContentView: View {
let headerID = "header"
let fruits = [Fruit(name: "Banana", color: .yellow),
Fruit(name: "Cherry", color: .red)]
var body: some View {
ScrollView {
ScrollViewReader { proxy in
Text("Fruits")
.id(headerID)
ForEach(fruits, id: \.name) { fruit in
FruitView(fruit: fruit)
}
Button("Scroll to top") {
proxy.scrollTo(headerID)
}
}
}
}
}
Dans cet exemple, le fait d'appuyer sur un bouton fait défiler la vue jusqu'en haut. L'extension .id() est utilisée pour fournir des identifiants personnalisés à nos vues, en leur donnant une identité explicite.
Identité structurelle
Chaque vue SwiftUI doit avoir une identité. Si la vue n'a pas d'identité explicite, elle a une identité structurelle. On parle d'identité structurelle lorsque la vue est identifiée à l'aide de son type et de sa position dans une hiérarchie de vues. SwiftUI utilise la hiérarchie des vues pour générer l'identité implicite des vues.
Prenons l'exemple suivant :
struct ContentView: View {
@State var isRounded: Bool = false
var body: some View {
if isRounded {
PizzaView()
.cornerRadius(25)
} else {
PizzaView()
.cornerRadius(0)
}
PizzaView()
.cornerRadius(isRounded ? 25 : 0)
Toggle("Round", isOn: $isRounded.animation())
.fixedSize()
}
}
Comme nous l'avons vu dans l'exemple ci-dessus, il existe deux approches différentes pour mettre en œuvre le changement de rayon d'angle animé pour le PizzaView.
La première approche crée deux vues complètement distinctes, en fonction de l'état booléen. En fait, SwiftUI crée une instance de la vue ConditionalContent dans les coulisses. Cette vue ConditionalContent est chargée de présenter l'une ou l'autre vue en fonction de la condition. Et ces vues de pizzas ont des identités de vues différentes, en raison de la condition utilisée. Dans ce cas, SwiftUI redessinera la vue une fois que la bascule aura changé, et appliquera la transition de fondu entrant/sortant pour le changement, comme on peut le voir dans la Figure 8 ci-dessous. Il est important de comprendre qu'il ne s'agit pas de la même PizzaView, il s'agit de deux vues différentes et elles ont leurs propres identités structurelles. Il peut également être mis en œuvre à l'aide du modificateur de vue :
PizzaView()
.cornerRadius(isRounded ? 25 : 0)
L'identité structurelle de la vue reste ainsi inchangée et SwiftUI n'applique pas la transition de fondu. Elle animera le changement du rayon de l'angle, comme le montre la figure 8 ci-dessous, car pour le cadre, il s'agit de la même vue, mais avec des valeurs de propriétés différentes.
Dans ce cas, l'identité structurelle de la vue ne change pas. Apple recommande de préserver l'identité de la vue en plaçant des conditionnelles dans le modificateur de vue plutôt que d'utiliser des instructions if/else.
L'identité structurelle et sa compréhension sont la clé d'une application mieux optimisée avec moins de bogues. Il explique également pourquoi l'utilisation d'un modificateur de vue conditionnel peut être une mauvaise idée.
Il y a quelques points à garder à l'esprit pour obtenir de meilleures performances :
- Maintenez l'identité de la vue. Si vous le pouvez, n'utilisez pas d'instructions conditionnelles pour préserver l'identité.
- Utilisez des identifiants stables pour votre vue s'ils sont explicitement fournis.
- Évitez d'utiliser AnyView si possible
Un exemple concret d'identité de vue chez DoorDash
Prenons un exemple dans l'application DoorDash iOS. La vue des contacts affiche la liste des contacts et permet à l'utilisateur de choisir un ou plusieurs contacts, comme le montre la figure 9 ci-dessous. Le composant liste de contacts est utilisé dans DoorDash aujourd'hui lors de l'envoi d'un cadeau.
Cette vue utilise le framework Contacts pour récupérer les contacts sur l'appareil et les transformer en sections avec des titres à afficher dans le composant `List` de SwiftUI.
Pour ce faire, nous itérons sur notre liste de sections à l'aide d'un `ForEach` et nous les affichons dans la liste avec pour clé l'identifiant unique de la section.
```
List {
ForEach(listSections, id: \.id) { contactsSection in
// Display the contact section header & rows
}
}
```
La section `ContactSection` est responsable de l'encapsulation des propriétés nécessaires à l'affichage de la liste des contacts dans la vue. Elle contient 3 propriétés :
- Un identifiant unique pour la section
- Le titre de la section
- La liste des contacts de la section
```
struct ContactSection {
let id: String = UUID().uuidString
let title: String
let contacts: [Contacts]
init(title: String, contacts: [Contacts]) {
self.title = title
self.contacts = contacts
}
}
Nos contacts s'affichent désormais dans la liste, mais nous rencontrons un problème : lorsqu'un numéro de téléphone non valide est sélectionné dans la liste, un message de toast s'anime dans la vue pour alerter le client. Lorsque le toast apparaît, la liste entière se modifie (Figure 10) comme s'il y avait de nouvelles données à présenter - ce qui n'est pas une expérience idéale pour l'utilisateur.
Au fur et à mesure que la vue est animée, Swift redessine la vue et donc notre liste. Chaque fois que nous accédons à la variable calculée qui génère les sections, la structure `ContactSection` est initialisée avec un nouvel identifiant différent pour la même section.
Dans ce cas, le titre de nos sections est la première initiale du nom du contact, ce qui rend chaque titre unique. Nous pouvons donc supprimer la propriété `id` de notre structure `ContactSection` et classer la liste par le titre au lieu de l'identifiant incohérent.
List {
ForEach(listSections, id: \.title) { contactsSection in
// Display the contact section header & rows
}
}
Maintenant, comme le montre la figure 11, l'animation est superbe !
Lorsque l'on utilise le composant `List` dans SwiftUI, il faut se souvenir d'utiliser un identifiant persistant pour donner une clé à la liste ; cela améliore nos animations et nos performances.
Conclusion
From the above, we can clearly see the advantages in terms of user experience and performance when we preserve a view’s identity and manage the dependencies correctly and efficiently. These concepts are essential to write better optimized, smooth, and effective iOS applications with SwiftUI. The framework uses a type-based diffing algorithm to determine what views to redraw for each change of the state, and it does it’s best to ensure that our user interface remains performant and optimized.However, it's important to understand that it’s not pure magic. We still need to write efficient code, and understand how the body invocations work, how the dependencies are managed, and how to preserve the view’s identity.