Études de cas

Comment les ingénieurs d'Instagram Direct ont créé une architecture d'UI native d'IA avec Jetpack Compose et réduit le coût des jetons par session d'agent de 33%

Temps de lecture : 11 min

Cet article de blog a été rédigé en collaboration avec l'équipe Meta.

Instagram Direct est l'une des principales surfaces d'Instagram, qui gère des milliards de messages d'utilisateurs chaque jour. Au fil des années, l'équipe a exploité toutes les micro-optimisations possibles du système Android View existant. Cependant, la maintenance et l'extension d'une surface héritée fortement optimisée créent une dette technique et une surcharge d'ingénierie importantes, d'autant plus que les équipes adoptent de plus en plus l'UI déclarative et les assistants de codage IA. 

L'adoption de Jetpack Compose pour Instagram Direct ne s'est pas limitée à une simple modernisation de l'UI. L'équipe a créé une codebase d'UI native de l'IA 50% plus petite que l'implémentation d'origine, tout en obtenant une réduction de 35% du temps d'exécution de l'agent IA, 32% d'échanges en moins entre les ingénieurs et les agents et une réduction de 33% du coût des jetons. En étroite collaboration avec Google, l'équipe a adopté Jetpack Compose tout en maintenant un niveau de performances élevé. Grâce aux optimisations des performances, Meta et Google ont amélioré Compose non seulement pour Instagram, mais aussi pour l'écosystème plus large des développeurs Android.

Moderniser le codebase à grande échelle

L'IA est rapidement devenue un compagnon quotidien pour les ingénieurs du secteur, et son application à une codebase à grande échelle comme Instagram génère déjà de réels gains de productivité. L'équipe Instagram Direct s'est fixé un objectif plus ambitieux. Plutôt que de simplement appliquer des outils d'IA au code existant, l'équipe a repensé la codebase et son architecture pour qu'elles soient AI-native, multipliant ainsi l'impact de l'IA bien au-delà de ce que la simple adaptation peut offrir.

L'équipe Instagram Direct a choisi Jetpack Compose comme composant clé pour créer une architecture d'UI basée sur l'IA. Sa nature déclarative garantit que le code est concis, prévisible et structurellement plus facile à analyser pour les modèles d'IA, avec moins d'effets secondaires, moins d'état implicite et des limites de composants plus claires.

La migration vers Jetpack Compose a nécessité une planification minutieuse. Des centaines de millions de personnes envoient des messages sur Instagram chaque jour. La migration devait donc être progressive et fluide, sans aucune interruption de l'expérience, pendant que l'équipe réorganisait l'infrastructure sous-jacente. Pour illustrer l'ampleur du défi, les composants d'UI individuels peuvent s'afficher dans plus de 160 permutations d'état distinctes, et un seul écran de conversation gère plus de 200 types de messages distincts.

Product Design 1.png

Lors de la migration d'un codebase de cette taille vers Compose, il est tentant de choisir la solution de facilité et d'intégrer des composants d'UI Compose dans la hiérarchie de vues existante. En tant qu'étape incrémentielle lors d'une migration progressive, c'est tout à fait valable. Toutefois, à long terme, l'intégration de Compose dans un codebase basé sur les vues pose un problème. Les outils d'IA choisissent souvent la voie de la facilité. Si vous mélangez du code d'UI déclaratif et impératif, l'IA risque de les combiner de manière incorrecte, ce qui peut entraîner des bugs subtils, une dette technique et des régressions de performances. 

Créer une architecture d'UI AI-native

À l'échelle d'Instagram, un certain degré d'abstraction architecturale est inévitable, et c'est ce qui permet de maintenir l'application à mesure qu'elle se développe. Prenons un modèle courant, où chaque type d'élément RecyclerView est modélisé en tant que descendant d'une classe de base RecyclerViewItem personnalisée qui expose les hooks de cycle de vie habituels tels que onBind.

Exemple 1

class ChatItem(
  val features: FeatureFlagProvider
) : RecyclerViewItem<ComposeViewHolder, ChatUiState> {

  // Imperative context:
  // AI could often take the path of least resistance and generate a mutable
  // state here, dispatched outside the ChatUiState. This class survives
  // re-bindings and is shared across multiple items, ultimately leading to
  // unexpected, hard-to-reproduce bugs.
  var isPinned: Boolean = false

  override fun onBind(holder: ComposeViewHolder, uiState: ChatUiState) {
      // Imperative context
      val isPinnedChatsEnabled = features.isEnabled("pinned_chats_feature")

      // Declarative context
      holder.composeView.setContent {

        // Blending imperative and declarative contexts
        if (isPinnedChatsEnabled) {
          Button(onClick = { isPinned = !isPinned }) {
            Text(if (isPinned) "Unpin" else "Pin")
          }
        }
        
        ...
      }
  }
}


Dans l'extrait ci-dessus, deux problèmes se posent. Tout d'abord, l'indicateur isPinnedChatsEnabled est lu dans le code impératif, puis capturé dans une lambda Compose, un couplage subtil entre les paradigmes. Deuxièmement, isPinned est un champ modifiable sur l'élément lui-même plutôt que dans ChatUiState. Il survit donc à la réassociation et au recyclage de RecyclerView sur les lignes, ce qui entraîne des fuites et des bugs difficiles à reproduire.

Même lorsque le code est nettoyé en attribuant à l'élément une fonction @Composable dédiée, les mêmes problèmes persistent.

Exemple 2

class ChatItem(
  val features: FeatureFlagProvider
) : ComposeRecyclerViewItem<ChatUiState> {

  // Imperative context
  val isPinnedChatsEnabled = features.isEnabled("pinned_chats_feature")
  var isPinned: Boolean = false

  // Declarative context
  @Composable
  override fun Content(uiState: ChatUiState) {

 
    // Blending imperative and declarative contexts
    if (isPinnedChatsEnabled) {
      Button(onClick = { isPinned = !isPinned }) {
        Text(if (isPinned) "Unpin" else "Pin")
      }
    }

    ...
  }
}

Il s'agit d'un exemple volontairement simple, mais il illustre un problème plus vaste : moins l'IA dispose de limites, plus la qualité du code qu'elle produit au fil du temps est faible. Les garde-fous et les compétences sont utiles, mais ils ne suffisent pas à eux seuls, car lorsque l'IA rencontre des frictions, elle les contourne souvent pour se débloquer.

Pour que le codebase soit adapté à l'IA, il doit respecter deux règles pratiques : 

  • Réduisez la dépendance au contexte personnalisé. Plus un agent IA a besoin de connaissances sur mesure et spécifiques au codebase pour effectuer une modification correcte, plus la qualité de son résultat est faible. Plus la codebase est proche des bonnes pratiques connues, meilleurs sont les résultats de l'IA.
  • Une codebase axée sur l'IA doit appliquer ses propres limites. Combler les lacunes de conception avec des compétences d'IA n'est pas évolutif, car chaque compétence chargée dans le contexte coûte des jetons et peut dégrader les performances de l'agent. C'est l'architecture elle-même qui doit porter ce poids. Les agents IA empruntent naturellement le chemin de moindre résistance. La conception doit donc faire en sorte que ce chemin mène à un code correct et de haute qualité, tout en rendant les mauvaises décisions de conception difficiles et coûteuses à exprimer.

Un élément de liste peut toujours être représenté par sa propre abstraction, mais dans ce cas, tout le code Compose se trouve dans le constructeur. Il n'a donc pas accès aux membres ou à l'état de la classe, et sa seule source d'arguments est le constructeur. Elle est ainsi équivalente à une fonction @Composable simple, tout en étant conforme à l'architecture existante.

Exemple 3

class ChatItem(
  val features: FeatureFlagProvider,
  val onPin: (Boolean) -> Unit,
) : ComposeItem<ChatUiState>(

 
   // Compose UI
   content = { uiState: ChatUiState ->
    val isPinnedChatsEnabled = features.isEnabled("pinned_chats_feature")

    if (isPinnedChatsEnabled) {
      Button(onClick = { onPin(!uiState.isPinned) }) {
        Text(if (uiState.isPinned) "Unpin" else "Pin")
      }
    }
    
    ...
  },
)

Migrer un codebase de cette taille est une tâche colossale. Pendant longtemps, les centaines de composants d'UI qui constituent la majeure partie de Direct UI ont dû coexister avec leurs équivalents d'ancienne génération, les deux étant gérés en parallèle. Les workflows d'IA ont permis de réaliser cette migration parallèle en accélérant le processus d'écriture de grandes quantités de code. Cette approche a permis à l'équipe Direct d'effectuer la migration en un temps record, sans perturber le reste de l'équipe, qui a continué à déployer les fonctionnalités qui améliorent l'expérience de millions de personnes chaque jour.

Plusieurs ingénieurs ont exécuté leurs propres agents IA sur une base de connaissances partagée de compétences et de conventions réutilisables créées lors de la migration. Cela a permis de synchroniser les workflows et les bonnes pratiques au sein de l'équipe, plutôt que de laisser chaque ingénieur les redécouvrir. Pour chaque surface, l'équipe a effectué la migration en plusieurs étapes :

  • Écrivez tout le code Compose avec l'IA.
  • Nous l'avons peaufinée, en gérant les cas particuliers et en comblant les écarts de performances, jusqu'à ce que l'UI soit déployée auprès d'utilisateurs réels dans un test public.

En divisant le travail en deux étapes par écran, un ingénieur peut parcourir rapidement l'ensemble de la surface, en définissant l'architecture et les cas spéciaux complexes dès le départ. Une fois ces bases posées, les autres membres de l'équipe peuvent se concentrer sur la préparation de l'UI pour la production sans avoir à prendre eux-mêmes ces décisions techniques, ce qui permet de maintenir une migration rapide.

Les résultats de la migration ont validé l'approche. Pour les surfaces Instagram Direct migrées, Jetpack Compose a permis à l'équipe de réduire de 50%la quantité totale de code d'UI. Moins l'IA doit générer de code, plus la qualité du résultat est élevée et plus le coût en jetons par tâche est faible. 

Quote-Pavlo-New.jpg

Une analyse de données interne de la codebase Android pour Instagram Direct a comparé les sessions d'agents IA travaillant sur l'UI Compose aux mêmes tâches utilisant les vues Android. Les gains d'efficacité ont été évidents sur deux plans :

  • Par caractère de code intégré : Compose a nécessité 32% d'échanges en moins entre l'ingénieur et l'agent et 35% de temps d'exécution en moins pour l'agent(le temps écoulé entre le moment où un agent commence à travailler sur la requête d'un ingénieur et celui où il renvoie une réponse).
  • Par session d'agent : le coût global des jetons a diminué de 33%  avec Compose par rapport à Views.

Nous indiquons à la fois l'efficacité de la sortie et le nombre de sessions types, car il s'agit de résultats utiles indépendamment l'un de l'autre. Les échanges ingénieur-agent et les chiffres du temps d'exécution comparent l'utilisation des ressources par unité de résultat, tandis que le chiffre des jetons compare le coût total d'une session d'agent standard.

Les données ont également révélé une différence constante dans la façon dont les deux frameworks gèrent le code complexe ou fragile. Meta suit cette métrique à l'aide d'un score de risque des modifications de code, qui évalue la qualité globale du code et la probabilité qu'une modification entraîne des incidents de production. L'analyse a mesuré l'efficacité des ressources d'un agent à l'aide d'un composite de la consommation de jetons, du temps d'exécution de l'agent et des interactions entre l'ingénieur et l'agent. À mesure que les fichiers accumulent un score de risque plus élevé, les sessions d'agent IA deviennent naturellement moins efficaces en termes de ressources. 

Lorsque le score de risque cumulé d'un fichier double, l'UI implémentée avec les vues Android réduit l'efficacité des ressources de l'agent de 30% (par caractère d'atterrissage). Dans les mêmes circonstances, la réduction de l'UI Jetpack Compose n'est que de 9%.

Grâce au partenariat entre Google et Meta, l'équipe Instagram Direct a adopté une nouvelle approche de Compose, en l'envisageant sous l'angle de la préparation de la codebase à l'IA, et pas seulement d'une réécriture de l'UI. Ce travail a révélé la force de Compose en tant que base pour créer des codebases et des architectures axés sur l'IA, en particulier lorsqu'il est appliqué à l'échelle d'applications comme Instagram.

Optimisations des performances 

Instagram Direct est l'une des surfaces les plus importantes de l'application, et les utilisateurs s'attendent à ce qu'elle soit rapide et réactive à tout moment. L'adoption de Jetpack Compose impliquait une réécriture substantielle de l'interface utilisateur, et l'objectif principal était de préserver une expérience de haute qualité sans régression.

Des années d'itération avaient déjà permis à l'ancienne implémentation basée sur les vues d'Instagram d'atteindre un niveau de performances exceptionnellement élevé, et l'équipe devait respecter ce même standard tout en passant à un framework d'interface utilisateur entièrement nouveau.

Instagram mesure des centaines, voire des milliers de métriques de performances. Pour l'adoption de Compose, les trois points suivants étaient les plus importants :

  • Time to Interactive : temps écoulé entre l'ouverture de l'écran et le moment où il est possible de l'utiliser.
  • Temps de chargement complet  : temps écoulé entre l'ouverture de l'écran et le chargement complet de tout le contenu (par exemple, les images).
  • Performances de défilement : fluidité du défilement de l'écran, sans perte d'images.

Ces métriques sont suivies au moment de l'exécution en production, ce qui permet d'effectuer des tests A/B comparant l'UI Compose migrée à l'ancienne UI et d'évaluer l'impact de cet effort sur les performances.

Une approche courante pour une telle migration consiste à commencer par de petits changements, en déplaçant quelques composants d'interface utilisateur, en collectant des données et en étudiant leur comportement. Bien qu'utiles, ces premiers résultats ne donnent qu'une image partielle, fournissant de faux négatifs par rapport à l'adoption de Compose, car :

  • Non représentatif : un composant d'interface utilisateur migré peut fournir des données utiles sur ses performances globales sur un écran particulier. Cependant, les différents composants se comportent différemment pour des raisons qui ne se généralisent pas. Vous ne pouvez donc pas toujours extrapoler à partir de ces résultats.
  • Coût d'interopérabilité : un petit élément Compose dans une grande codebase View entraîne un coût de transition imprévisible entre les deux systèmes. Ce surcoût fausse la mesure, de sorte que les premiers résultats à petite échelle ne reflètent pas ce à quoi ressemblerait réellement une migration complète.

Par conséquent, les petites migrations, bien qu'utiles, ne reflètent pas toujours l'impact total de Compose. Plus une surface est migrée de bout en bout sans interruption de pont, plus l'image devient claire et meilleure en termes de performances.

Les écrans principaux d'Instagram Direct sont construits autour de longues listes de types d'éléments variés, initialement implémentées avec RecyclerView. L'architecture repose sur des abstractions personnalisées pour l'évolutivité, mais elle reste liée au cycle de vie du système basé sur les vues.

Diagram 1.png

La principale tâche de l'équipe a consisté à migrer progressivement plusieurs centaines d'éléments de liste individuels vers Compose au sein de l'architecture existante basée sur RecyclerView, en les déployant en production dans de petits groupes indépendants dans le cadre de tests A/B, le tout sans modification visible de l'expérience de messagerie de l'utilisateur.

Le principal inconvénient d'une telle configuration est une dépendance importante vis-à-vis de l'ancien système de vues via une architecture RecyclerView de base, même après la migration complète de chaque élément de liste vers Compose. L'équipe a donc naturellement décidé d'investir dans le remplacement de l'architecture de base basée sur RecyclerView par l'alternative native de Compose, LazyColumn.

Cela signifie que les composants d'interface utilisateur Compose doivent être abstraits du framework dans lequel ils sont inclus, tout en restant compatibles avec RecyclerView et LazyColumn en même temps. Il est tout aussi important de pouvoir passer de l'un à l'autre au moment de l'exécution via des flags de fonctionnalité, afin d'activer les tests A/B.

Diagram 2.png

Bien que les nouveaux éléments Compose soient compatibles de manière native avec LazyColumn et puissent être intégrés à une arborescence de composition ininterrompue, une API d'interopérabilité a été créée pour les insérer également dans un RecyclerView. Cela a permis de déployer la configuration LazyColumn dans un test A/B en parallèle de RecyclerView, en réutilisant les mêmes éléments Compose et en améliorant les performances, sans perturber le reste de l'équipe qui crée et affine les fonctionnalités.

L'échelle, la complexité et la sensibilité d'Instagram aux moindres régressions ont posé un défi unique pour Jetpack Compose. Pour y parvenir, il a fallu adopter une approche itérative et collaborer étroitement. En travaillant en étroite collaboration, les ingénieurs de Google et de Meta ont analysé les métriques pour identifier et concevoir de nouvelles fonctionnalités Compose afin d'atteindre ou de dépasser les benchmarks basés sur les vues. Ce partenariat a permis d'ajouter des fonctionnalités notables à Jetpack Compose : la composition pouvant être mise en pause avec LazyLayoutCacheWindows et le suivi de la visibilité. 

Composition pouvant être mise en pause avec LazyLayoutCacheWindows

La composition pouvant être mise en pause (activée par défaut dans Compose 1.10) permet de composer de manière incrémentielle des éléments de liste différée coûteux sur plusieurs frames afin d'éviter les à-coups. Lorsqu'elle est associée à LazyLayoutCacheWindow (ajouté dans Compose 1.9), cette combinaison améliore considérablement la fluidité du défilement. Lors de tests internes récents chez Meta, la combinaison de la composition Pausable avec un LazyLayoutCacheWindow d'une fenêtre d'affichage a permis de réduire d'environ 13% les pertes d'images importantes par minute (LFD/m) par rapport à Compose standard. La fenêtre de cache seule l'a réduit d'environ 8% par rapport à la même référence. Les LFD/m sont une métrique interne que Meta utilise pour suivre les saccades notables lors du défilement.

Quote-Fabio.jpg


L'utilisation d'un LazyLayoutCacheWindow dans votre application prépare et conserve les éléments hors écran dans une bande basée sur les pixels autour de la fenêtre d'affichage pour permettre des défilements rapides. Pour profiter de LazyLayoutCacheWindows dans votre application, vous pouvez utiliser la dernière version de Compose 1.13.0-alpha03 et la configurer comme indiqué dans l'exemple ci-dessous :

val cacheWindow = LazyLayoutCacheWindow(ahead = 150.dp, behind = 100.dp)
// OR
val cacheWindow = LazyLayoutCacheWindow(aheadFraction = 0.5f, behindFraction = 0.3f)

LazyColumn(state = state, cacheWindow = cacheWindow) {
    ...
}

Il existe deux façons de configurer la fenêtre de cache. Les deux décrivent la même chose : la quantité de contenu hors écran à conserver, mais dans des unités différentes.

  • Dp : longueur absolue fixe. ahead = 150.dp conserve 150 dp de contenu composé au-delà du bord visible, quel que soit l'appareil.
  • Float : fraction de la fenêtre d'affichage. aheadFraction = 0.5f conserve une demi-page composée à l'avance, de sorte que la quantité absolue s'adapte à la hauteur de l'écran en fonction des différents facteurs de forme : plus sur une tablette ou un appareil pliable déplié, moins sur un téléphone compact. 

L'équipe Instagram a ajusté les fractions flottantes de la fenêtre de cache spécifiquement pour la structure de contenu et les tailles d'éléments de Direct. Comme les valeurs idéales varient en fonction des paramètres d'UI spécifiques, trouver le bon équilibre nécessite quelques tests.

Enregistrement des impressions avec onVisibilityChanged


L'API onVisibilityChanged (ajoutée dans Compose 1.9.0) est un autre résultat clé du partenariat technique entre Google et Meta. Il permet aux surfaces Jetpack Compose à grande échelle de savoir de manière cohérente quand un composable est réellement visible à l'écran, en remplaçant les implémentations personnalisées et manuelles utilisées par le passé. Rien que dans Instagram Direct, ces signaux de visibilité sont utilisés dans des centaines de fichiers pour prendre en charge les métriques de qualité des produits qui dépendent de l'affichage effectif des éléments d'interface utilisateur.

Performances de démarrage

L'adoption de Jetpack Compose pour Instagram Direct a entraîné des améliorations de performances inattendues sur d'autres surfaces de l'application. L'environnement d'exécution Jetpack Compose entraîne un coût de préchauffage que vous ne payez qu'une seule fois. Comme la messagerie est une surface à fort trafic souvent visitée au début d'une session utilisateur, d'autres surfaces d'Instagram qui s'appuient sur Compose ont bénéficié d'améliorations de performances notables.

Les performances de démarrage de l'UI Compose dans Instagram Direct ont été optimisées à l'aide de profils de référence, qui précompilent les chemins de code à chaud au moment de l'installation afin que Compose s'affiche rapidement dès le premier lancement.

Lessons from the Instagram Direct migration to Jetpack Compose 

  • Jetpack Compose offre un retour sur investissement immédiat : vous n'avez pas besoin d'utiliser des workflows d'IA avancés pour bénéficier de Compose. La réduction d'environ 50% du code signifie qu'il y a moins de code à gérer et moins de risques de bugs.
  • La conception d'une architecture AI-native a permis d'obtenir des résultats significatifs, notamment une réduction de 35% du temps d'exécution des agents IA, une diminution de 32% des échanges entre ingénieurs et agents, et une réduction de 33% du coût des jetons.
  • Bien qu'il existe de nombreuses API d'interopérabilité et que la combinaison de vues et de Compose soit possible, essayez de migrer des surfaces plus grandes plutôt que de petits composants individuels. Cela permet de conserver l'UI dans une hiérarchie de composition unique et ininterrompue, et de débloquer toutes les meilleures optimisations des performances natives de Compose.
  • Associez la composition Pausable à LazyLayoutCacheWindow : l'association de ces deux éléments donne de meilleurs résultats que les fenêtres de cache seules. Avec la fenêtre de cache uniquement, un élément lourd peut toujours essayer de composer en une seule passe, ce qui peut dépasser le budget de frame.
  • Contribuez à Compose. Meta s'est associé à l'équipe Jetpack Compose pour concrétiser ses commentaires et ses idées dans Compose. Le fait de travailler sur un kit d'outils Open Source signifie que nous bénéficions tous des corrections de bugs et des améliorations des performances effectuées de manière centralisée. Alors, n'hésitez pas à nous faire part de vos commentaires.

L'adoption de Jetpack Compose a permis d'améliorer considérablement le développement assisté par l'IA tout en simplifiant l'ingénierie quotidienne de l'UI chez Instagram. L'approche déclarative réduit le code récurrent, facilite la gestion de l'état et améliore la productivité globale des développeurs. L'équipe d'ingénieurs d'Instagram est impatiente d'intégrer Compose à d'autres surfaces de l'application, et de poursuivre la collaboration entre Google et Meta pour apporter d'autres améliorations aux utilisateurs d'Instagram et de Jetpack Compose. 

Si vous n'avez pas encore essayé Compose, qui propose désormais l'assistance IA, migrer vers Jetpack Compose est plus facile que jamais.

Remerciements. Merci à Michal Zielinski et Matthew Du de Meta, ainsi qu'à Andrei Shikov et George Mount de Google, pour leur travail visant à améliorer les performances de Compose grâce à la collaboration entre Meta et Google. Merci également à Gary Ye de Meta pour son aide à l'intégration de Compose dans Instagram Direct, et à Gopal Juneja de Meta pour son soutien à cet effort grâce à la data science.

Written by:
Continuer la lecture