Published by

Il y a 3 semaines -

Temps de lecture 11 minutes

SDUI ❤️ SwiftUI

Dans une ou plusieurs de vos applications mobiles vous avez probablement eu besoin que l’interface utilisateur de vos écrans soit définie de façon précise par votre back-end. Ce dernier se chargerait de définir quels composants (boutons, libellés, mais aussi barre de navigation) s’afficheraient et comment (couleurs, styles et positionnement) ; un simple changement côté serveur permettrait de déployer immédiatement l’affichage des apps clientes et ce, possiblement, sur l’intégralité des plateformes iOS et Android.

Cette approche prend souvent le nom de Server-Driven UI (ou Server-Driven Rendering) et s’applique parfaitement à des cas d’usage typique des clients légers. Pages de consultation (les immanquables CGUs, « à propos », FAQ, Credits, etc), écrans d’on-boarding, formulaires mais aussi des applications entières peuvent bénéficier grandement de ce paradigme et réduire de façon conséquente la charge de travail demandée par des implémentations souvent répétitives et inintéressantes.

Implémenter sa stack SDUI

Si vous souhaitez vous doter d’une interface SDUI pour votre application vous pouvez choisir d’opter pour des solutions Open Source telles que Lona. Développé par AirBnb, Lona est actuellement en phase de Developer Preview et, même avec une documentation très limitée, supporte des cas d’usage très avancés, incluant même un outil GUI pour simplifier la réalisation des composants visuels. Lona supporte actuellement les applications iOS/macOS écrites en Swift et React Native, avec une compatibilité Kotlin qui est seulement « planned ».

Et si on voulait le faire nous-mêmes ?

En effet, implémenter une stack Server-Driven UI from scratch est un travail qui peut être mené correctement par une équipe de taille moyenne. C’est d’ailleurs la route parcourue par de nombreuses applications mobiles, comme par exemple SoundCloud, Zalando et, dans l’Héxagone, Canal+.

Mais aujourd’hui en particulier, mettre en place un système SDUI est significativement plus intéressant : l’arrivée de SwiftUI (sur iOS) et Jetpack Compose (sur Android) rendent ce développement plus simple – et beaucoup plus amusant !

La raison va au delà du hype et de la facilité d’usage de ces frameworks et dérive du caractère déclaratif de SwiftUI et Jetpack Compose. Ce caractère déclaratif est le même que l’on retrouve dans les formats d’échange de la donnée tels que JSON (mais pas que) et nous permet d’avoir un modèle de donnée qui sera conceptuellement très proche de celui des composants visuels.

Puisque quelques lignes de code valent plus que mille mots : l’objet

{
	"type": "list",
	"data": {
		"elements": [{
			"type": "text",
			"data": {
				"text": "Hi!"
			}
		}]
	}
}

correspondra au bloc Swift suivant :

List(
	content: {
		Text(
			"Hi!"
		)
	}
)

Les deux versions sont facilement comparables car on y retrouve le même formalisme dans la structuration de la donnée. À l’inverse, avec UIKit, nous aurions retrouvé des méthodes « impératives » telles que addSubview(...) qui n’auraient pas pu avoir une représentation immédiate en JSON.

Panoramique du système

Le fonctionnement du système est simple.

Le serveur fournit au client une représentation des vues à afficher. Si on adhère complètement à l’approche SDUI, cela implique la mise à disposition d’une source de données (dans notre cas un JSON) contenant l’intégralité des informations à présenter à l’écran : barres de navigation, boutons, libellés et tout autre élément visible par l’utilisateur final. Dans la modélisation choisie, chaque élément contenu dans la source de données est un Component.

Le client s’occupe de télécharger le JSON et de traduire chaque Component en une vue SwiftUI, qui sera ensuite rendue à l’écran.

Dans notre première itération, le serveur fournit au client chaque page dans une source JSON séparée. Par exemple, l’intégralité de la page d’accueil sera renvoyée en appelant le service https://mysduiservice.xyz/home, tandis que l’intégralité de la page de détail d’un produit « foo » sera contenue par la ressource https://mysduiservice.xyz/product/foo.

Nous allons découvrir par la suite les détails d’implémentation de chacune des deux couches.

Le début : la source de données

Cela est certainement une évidence, mais dans la SDUI le modèle de données exposées par le serveur est central à la réussite du projet. Nous allons partir sur une modélisation classique, qui est d’ailleurs  recommandée par plusieurs  autres références sur le sujet.

Notre entité de base, le composant, va être représenté en JSON comme suit :

{
  "id": "my-id",
  "type": "text",
  "data": {
     "text": "my-label"
  }
}

Les champs principaux seront les suivants :

  • id : définit l’identifiant du composant et devra être unique pour chaque page
  • type : le type du composant. Par exemple « button », « text », « top-bar », etc.
  • data : toutes les informations spécifiques au composant (title pour un bouton, source pour une image, etc)

La représentation côté SwiftUI

Côté client, notre application téléchargera la source JSON et convertira les composants en vues SwiftUI.

Modélisation en Swift

Pour commencer, nous devons transformer le JSON en modèles Swift. Commençons par le Component, qui peut être représenté à l’aide d’un enum :

enum Component {
    case page
    case column
    case text
}

Chaque composant devra avoir des valeurs associées, et notamment identifiant et data :

enum Component {
    case page(id: String, data: PageData)
    case column(id: String, data: ColumnData)
    case text(id: String, data: TextData)
}

Avec la conformité de l’enum Component à Decodable nous pouvons transformer la source de données, une fois le téléchargement complété, en un objet natif.

enum TypeName: String, Decodable {
    case page
    case column
    case text
}

enum Component: Decodable { 
    enum CodingKeys: String, CodingKey {
        case id
        case typeName = "type"
        case data
    }
    case page(id: String, data: PageData)
    case column(id: String, data: ColumnData)
    case text(id: String, data: TextData)

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        let id = try values.decode(String.self, forKey: .id)
        let type = try values.decode(TypeName.self, forKey: .type)

        switch (type) {
        case .page:
            let data = try values.decode(PageData.self, forKey: .data)
            self = .page(id: id, data: data)
        case .column:
            let data = try values.decode(ColumnData.self, forKey: .data)
            self = .column(id: id, data: data)
        case .text:
            let data = try values.decode(TextData.self, forKey: .data)
            self = .text(id: id, data: data)
        }
    }
}

Afin d’éviter de répéter les paramètres id et data dans chaque case, nous pouvons les encapsuler dans une struct (que nous appellerons Definition), comme suit :

enum Component: Decodable {
    enum CodingKeys: String, CodingKey {
        case typeName = "type"
    }

    struct Definition<DataType: Decodable>: Decodable {
        let id: String
        let data: DataType
    }

    case page(Definition<PageData>)
    case column(Definition<ColumnData>)
    case text(Definition<TextData>)

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        let typeName = try values.decode(TypeName.self, forKey: .typeName)

        switch (typeName) {
        case .page:
            self = .page(try Definition<PageData>(from: decoder))
        case .column:
            self = .column(try Definition<ColumnData>(from: decoder))
        case .text:
            self = .text(try Definition<TextData>(from: decoder))
        }
    }
}

En ce qui concerne les champs Data, vous l’aurez imaginé, ils sont modélisés via des objets struct séparés. Par exemple :

struct PageData: Decodable {
    let children: [Component]
}

struct ColumnData: Decodable {
    let elements: [Component]
}

struct TextData: Decodable {
    let text: String
}

Génération des vues SwiftUI

Nous allons désormais attaquer la partie la plus intéressante de notre projet : la création de vues SwiftUI.

Pour ce faire, nous allons introduire une fonction render sur Component. Cette fonction renverra un objet AnyView. Chaque Component aura donc une passe de rendering propre à son type : par exemple, le component .text renverra une vue Text, tandis que la page produira un VStack de Components qui seront rendus de façon récursive.

extension Component {
    func render() -> AnyView {
        switch self {
        case .page(let definition): return AnyView(
            VStack {
                ForEach(definition.data.children, content: { $0.render() })
            }
        )
        case .column(let definition): return AnyView(
            VStack {
                ForEach(definition.data.elements, content: { $0.render() })
            }
        )
        case .text(let definition): return AnyView(
            Text(definition.data.text)
        )
        }
    }
}

Bien sûr, afin de réduire la longueur de la méthode render, et de mieux séparer les responsabilités, nous pouvons subdiviser le corps en plusieurs fonctions, comme dans l’exemple suivant. Dans cette implémentation, notamment, les opérations de rendering des composants peuvent être écrites à l’intérieur de fichiers distincts.

extension Component {
    func render() -> AnyView {
        switch self {
        case .page(let definition): return AnyView(definition.render())
        case .column(let definition): return AnyView(definition.render())
        case .text(let definition): return AnyView(definition.render())
        }
    }
}

// ComponentDefinition+PageData.swift
extension Component.Definition where DataType == PageData {
    func render() -> some View {
        VStack {
            ForEach(data.children, content: { $0.render() })
        }
    }
}

// ComponentDefinition+ColumnData.swift
extension Component.Definition where DataType == ColumnData {
    func render() -> some View {
        VStack {
            ForEach(data.elements, content: { $0.render() })
        }
    }
}

// ComponentDefinition+TextData.swift
extension Component.Definition where DataType == TextData {
    func render() -> some View {
        Text(data.text)
    }
}

Petit détail d’implémentation, pour rendre possible le ForEach sur des collections de Component, ce dernier devra être conforme au protocole SwiftUI Identifiable :

extension Component: Identifiable {
    var id: String {
        switch self {
        case .page(let definition): return definition.id
        case .column(let definition): return definition.id
        case .text(let definition): return definition.id
        }
    }
}

Il ne reste plus qu’à afficher la vue dans une application. Rien de plus simple :

struct MySDUIView: View {
    @State var component: Component
    var body: some View {
        component.render()
    }
}

Bonus : aperçus instantanés avec Xcode

Une des fonctionnalités de SwiftUI les plus appréciées est certainement la possibilité d’afficher un aperçu instantané (preview) de la vue que nous sommes en train de construire. Dans le contexte de la Server-Driven UI, ce comportement est d’autant plus précieux car ça nous permet (et permet à nos collègues) de tester rapidement le rendu visuel d’une source JSON.

Voici un exemple :

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        let json = """
        ...
        """.data(using: .utf8)!
        let component = try! JSONDecoder().decode(Component.self, from: json)
        return MySDUIView(component: component)
    }
}

Que des forces ?

Si nous avons déjà décrit plus tôt les atouts de cette approche, cette technique n’est pas sans complexités, dont certaines peuvent être rédhibitoires selon votre cas d’usage.

En particulier, bien qu’il est possible de déployer à chaud de nouvelles versions d’interfaces sans redéploiement de l’application client, cela reste vrai seulement dans la mesure où les composants renvoyés par le serveur sont intégralement reconnus par le client. Par exemple, lors de l’intégration d’un composant pas initialement prévu par l’application client, nous devrons nous attendre à ce qu’une partie de nos clients ne soient pas en mesure d’afficher la vue. Il sera donc impératif de configurer, côté client, un mécanisme de gestion d’obsolescence des versions et cela depuis la première version de l’application. À la différence d’une application traditionnelle, où l’API d’un service peut changer sans impacter la totalité de l’écran, la modification du contrat de l’API SDUI entre serveur et client peut rendre l’intégralité de la page, voire de l’application, indisponible.

Aussi, comparé à une application traditionnelle, le payload d’une page Server-Driven est en moyenne plus volumineux : cela s’explique par le besoin de représenter à la fois contenant et contenu, tandis que dans une application classique le premier n’est téléchargé qu’au moment de l’installation.

D’un point de vue organisationnel, aussi, face à la simplification du travail des équipes mobile sur le long terme, la SDUI donne inévitablement plus de responsabilités aux équipes back-end : ces dernières devront possiblement posséder une appétence pour les thématiques côté front et, plus spécifiquement, dans le domaine des Back-For-Front (BFF), avec une attention particulière à dédier aux problématiques de disponibilité et performances.

Cela semble cependant un prix correct à payer, surtout en raison du fait qu’un projet SDUI permet d’atteindre plus facilement la parité de fonctionnalité entre plates-formes iOS et Android mais aussi de mettre en place aisément des stratégies de A/B Testing ou de Canary Release en utilisant les mêmes technologies qu’un projet Web traditionnel.

Un outillage déjà existant

Bien que la rédaction de la source de données en format JSON puisse sembler une tâche manuelle et ennuyante, le marché est déjà riche en solutions capables de répondre à cette problématique. Il s’agit notamment de la multitude de CMS Headless (comme Contentful ou Kontent) qui sont en train de s’affirmer aujourd’hui en tant que companions des sites JAMstack et qui permettent d’écrire le contenu à l’aide d’interfaces haut niveau et de l’exposer via API dans un format exploitable par la machine. Un autre avantage notable est la familiarité d’une grande partie des équipes “contenus” à ces outils, qui peuvent donc être utilisés pour alimenter l’intégralité des front-ends (Web et applications mobiles).

Ensuite, afin de tester le résultat, les développeurs pourront – comme d’habitude – mettre à disposition une application spécifique visant un environnement maitrisé par les créateurs de contenus ou, encore, dans le cas des plates-formes Apple, une application client macOS dont le développement serait extrêmement rapide en vertu de l’utilisation de SwiftUI et, si besoin, de la technologie Catalyst.

Conclusion

Le pilotage de l’interface depuis Web Services est depuis des années une solution efficace et puissante pour le développement d’applications mobiles. L’outillage récent, basé sur SwiftUI, rend ce développement encore plus intéressant et rapide, donc une solution encore plus viable pour votre prochain projet.

Published by

Publié par Simone Civetta

Simone est responsable technique des équipes mobilités chez Publicis Sapient Engineering.

Commentaire

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.