Case study

Come gli ingegneri di Instagram Direct hanno creato un'architettura UI nativa dell'AI con Jetpack Compose e ridotto il costo dei token per sessione dell'agente del 33%

Tempo di lettura: 11 minuti

Questo post del blog è stato scritto in collaborazione con il team di Meta

Instagram Direct è una delle piattaforme principali di Instagram, che gestisce miliardi di messaggi degli utenti ogni giorno. Nel corso di anni di iterazioni, il team ha sfruttato ogni micro-ottimizzazione possibile del sistema Android View legacy. Tuttavia, mantenere ed espandere una superficie legacy fortemente ottimizzata crea un debito tecnico e un overhead di ingegneria significativi, soprattutto man mano che i team adottano sempre più UI dichiarative e assistenti alla codifica AI. 

L'adozione di Jetpack Compose per Instagram Direct è andata oltre la tipica modernizzazione della UI. Il team ha creato un codebase dell'interfaccia utente nativa dell'AI che è più piccolo del 50% rispetto all'implementazione originale, ottenendo una riduzione del 35% del tempo di esecuzione dell'agente AI, il 32% in meno di scambi tra ingegnere e agente e una riduzione del 33% del costo dei token. In stretta collaborazione con Google, il team ha adottato Jetpack Compose mantenendo un elevato livello di prestazioni. Grazie alle ottimizzazioni delle prestazioni, Meta e Google hanno migliorato Compose non solo per Instagram, ma anche per l'ecosistema più ampio degli sviluppatori Android.

Modernizzazione del codebase su larga scala

L'AI è diventata rapidamente un compagno quotidiano per gli ingegneri del settore e la sua applicazione a una base di codice su larga scala come Instagram produce già reali aumenti di produttività. Il team di Instagram Direct ha fissato un obiettivo più ambizioso. Anziché utilizzare semplicemente gli strumenti di AI sul codice esistente, il team ha riprogettato la base di codice e la sua architettura in modo che fossero AI-native per progettazione, moltiplicando l'impatto dell'AI ben oltre ciò che il semplice adattamento può offrire.

Il team di Instagram Direct ha scelto Jetpack Compose come componente chiave per la creazione di un'architettura UI basata sull'AI. La sua natura dichiarativa garantisce che il codice sia conciso, prevedibile e strutturalmente più facile da analizzare per i modelli di AI, con meno effetti collaterali, meno stato implicito e limiti dei componenti più chiari.

La migrazione a Jetpack Compose ha richiesto un'attenta pianificazione. Centinaia di milioni di persone inviano messaggi su Instagram ogni giorno, quindi la migrazione doveva essere graduale, fluida e senza interruzioni dell'esperienza mentre il team riprogettava le fondamenta. Per illustrare la portata della sfida: i singoli componenti dell'interfaccia utente possono essere visualizzati in oltre 160 permutazioni di stato distinte e una singola schermata di conversazione gestisce più di 200 tipi di messaggi distinti.

Product Design 1.png

Quando si esegue la migrazione di una codebase di queste dimensioni a Compose, è allettante scegliere la soluzione più semplice e incorporare i componenti UI di Compose all'interno della gerarchia di oggetti View esistente. Come passaggio incrementale durante una migrazione graduale, è perfettamente valido. A lungo termine, tuttavia, l'integrazione di Compose all'interno di un codebase basato su visualizzazioni rappresenta una sfida. Gli strumenti di AI spesso seguono il percorso che presenta minori resistenze. Se combini codice UI dichiarativo e imperativo, è probabile che l'AI li combini in modo errato, introducendo bug sottili, debito tecnico e regressioni delle prestazioni. 

Creare un'architettura UI nativa per l'AI

Alla scala di Instagram, un certo grado di astrazione architettonica è inevitabile ed è ciò che mantiene l'app gestibile man mano che cresce. Considera un pattern comune, in cui ogni tipo di articolo RecyclerView viene modellato come discendente di una classe base personalizzata RecyclerViewItem che espone i normali hook del ciclo di vita, ad esempio onBind.

Esempio 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")
          }
        }
        
        ...
      }
  }
}


Nello snippet riportato sopra, si insinuano due problemi. Innanzitutto, il flag isPinnedChatsEnabled viene letto nel codice imperativo e poi acquisito all'interno di una lambda Compose, un accoppiamento sottile tra paradigmi. In secondo luogo, isPinned esiste come campo modificabile nell'elemento stesso anziché in ChatUiState, quindi sopravvive al ricollegamento e al riciclo di RecyclerView tra le righe, causando perdite e bug difficili da riprodurre.

Anche quando il codice viene pulito assegnando all'elemento una funzione @Composable dedicata, i problemi rimangono gli stessi.

Esempio 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")
      }
    }

    ...
  }
}

Questo è volutamente un esempio semplice, ma illustra un problema più ampio: meno limiti vengono dati all'AI, minore è la qualità del codice che produce nel tempo. Le barriere e le competenze aiutano, ma non sono sufficienti da sole, perché quando l'AI incontra difficoltà, spesso le aggira per sbloccarsi.

Per rendere il codebase compatibile con l'AI, è necessario seguire due regole pratiche: 

  • Ridurre al minimo la dipendenza dal contesto personalizzato. Più l'agente AI ha bisogno di conoscenze specifiche del codebase per apportare una modifica corretta, minore è la qualità del suo output. Più il codebase è vicino alle best practice note, migliori saranno i risultati dell'AI.
  • Un codebase AI-first deve imporre i propri limiti. Colmare le lacune di progettazione con le competenze di AI non è scalabile, poiché ogni competenza caricata nel contesto costa token e può peggiorare le prestazioni dell'agente. Invece, l'architettura stessa dovrebbe sostenere questo peso. Gli agenti AI seguono naturalmente il percorso di minore resistenza, quindi la progettazione deve fare in modo che questo percorso porti a un codice corretto e di alta qualità, rendendo difficile e costoso esprimere decisioni di progettazione scadenti.

Un elemento di elenco può comunque essere rappresentato dalla propria astrazione, ma in questo caso tutto il codice Compose si trova nel costruttore, quindi non ha accesso ai membri o allo stato della classe e la sua unica fonte di argomenti è il costruttore. In questo modo, è equivalente a una semplice funzione @Composable, pur rispettando l'architettura esistente.

Esempio 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")
      }
    }
    
    ...
  },
)

La migrazione di un codebase di queste dimensioni è un'impresa enorme. Per molto tempo, le centinaia di componenti UI che costituiscono la maggior parte della UI diretta hanno dovuto coesistere con le loro controparti legacy, con entrambe mantenute in parallelo. I workflow AI hanno reso possibile questa migrazione parallela accelerando il processo di scrittura di grandi quantità di codice. Questo approccio ha consentito al team Direct di eseguire la migrazione in tempi record, senza interrompere il lavoro del resto del team, che ha continuato a rilasciare le funzionalità che migliorano l'esperienza di milioni di persone ogni giorno.

Più ingegneri hanno eseguito i propri agenti AI su una knowledge base condivisa di competenze e convenzioni riutilizzabili create durante la migrazione. In questo modo, i workflow e le best practice sono rimasti sincronizzati in tutto il team, anziché essere riscoperti da ogni ingegnere. All'interno di ogni superficie, il team ha eseguito la migrazione nelle seguenti fasi:

  • Scrivi tutto il codice Compose con l'AI.
  • Perfezionarla, gestendo i casi limite e colmando le lacune di prestazioni, fino a quando l'interfaccia utente non è stata implementata per gli utenti reali in un test pubblico.

Dividere il lavoro in due fasi per schermo consente a un ingegnere di muoversi rapidamente su tutta la superficie, definendo l'architettura e i casi limite più complessi in anticipo. Con queste basi, gli altri possono concentrarsi sulla preparazione dell'interfaccia utente pronto per la produzione senza doversi fermare per prendere decisioni tecniche, mantenendo la migrazione complessiva rapida.

I risultati della migrazione hanno convalidato l'approccio. Per le superfici di Instagram Direct di cui è stata eseguita la migrazione, Jetpack Compose ha consentito al team di ridurre la quantità totale di codice UI del 50%. Meno codice da generare per l'AI è associato a un output di qualità superiore e a un costo dei token inferiore per attività. 

Quote-Pavlo-New.jpg

Un'analisi interna dei dati del codebase Android per Instagram Direct ha confrontato le sessioni di agenti AI che lavorano sull'interfaccia utente Compose con le stesse attività che utilizzano Android Views. I miglioramenti dell'efficienza sono stati evidenti in due dimensioni:

  • Per carattere del codice di destinazione:componi il 32% in meno di scambi tra ingegneri e agenti e il 35% in meno di tempo di esecuzione dell'agente(il tempo trascorso da quando un agente inizia a lavorare alla richiesta di un ingegnere fino a quando non restituisce una risposta).
  • Per sessione dell'agente:il costo dei token è diminuito del 33% con Crea rispetto a Visualizzazioni.

Riportiamo sia l'efficienza dell'output sia il numero di sessioni tipico perché sono risultati utili indipendenti. Le cifre relative agli scambi tra ingegnere e agente e al tempo di esecuzione confrontano l'utilizzo delle risorse per unità di output ottenuto, mentre la cifra dei token confronta il costo totale per una sessione tipica dell'agente.

I dati hanno anche rivelato una differenza costante nel modo in cui i due framework gestiscono il codice complesso o fragile. Meta tiene traccia di questo valore utilizzando un punteggio di rischio delle modifiche al codice, che valuta la qualità complessiva del codice e la probabilità che una modifica causi incidenti di produzione. L'analisi ha misurato l'efficienza delle risorse di un agente utilizzando una combinazione di consumo di token, tempo di esecuzione dell'agente e interazioni tra l'ingegnere e l'agente. Man mano che i file accumulano un punteggio di rischio più elevato, le sessioni dell'agente AI diventano naturalmente meno efficienti in termini di risorse. 

Quando il punteggio di rischio accumulato di un file raddoppia, la UI implementata con Android Views riduce l'efficienza delle risorse dell'agente del 30% (per carattere di atterraggio). Nelle stesse circostanze, la riduzione dell'interfaccia utente di Jetpack Compose è solo del 9%.

Grazie alla partnership tra Google e Meta, il team di Instagram Direct ha portato una nuova prospettiva all'adozione di Compose, affrontandola dal punto di vista della preparazione del codebase all'AI, non solo di una riscrittura della UI. Questo lavoro ha rivelato la forza di Compose nel fungere da base per la creazione di codebase e architetture basate sull'AI, soprattutto se applicato alla scala di app come Instagram.

Ottimizzazioni del rendimento 

Instagram Direct è una delle superfici più integrate dell'app e gli utenti si aspettano che sia sempre veloce e reattiva. L'adozione di Jetpack Compose ha comportato una riscrittura sostanziale dell'interfaccia utente e l'obiettivo principale era quello di preservare l'esperienza di alta qualità senza regressioni.

Anni di iterazioni avevano già portato l'implementazione basata sulla visualizzazione legacy su Instagram a un livello di prestazioni eccezionalmente elevato e il team doveva soddisfare lo stesso standard durante il passaggio a un framework UI completamente nuovo.

Instagram misura centinaia, se non migliaia, di metriche sul rendimento. Per l'adozione di Compose, i tre aspetti più importanti sono stati:

  • Tempo all'interattività: il tempo che intercorre tra l'apertura della schermata e la possibilità di utilizzarla.
  • Tempo di caricamento completo : il tempo che intercorre tra l'apertura della schermata e il caricamento completo di tutti i contenuti (ad es. le immagini).
  • Prestazioni di scorrimento: la fluidità dello scorrimento dello schermo, senza perdita di frame.

Queste metriche vengono monitorate in fase di runtime in produzione, il che consente di eseguire test A/B confrontando la UI di Compose di cui è stata eseguita la migrazione con la UI legacy e valutare l'impatto sul rendimento di questo lavoro.

Un modo comune per affrontare una migrazione come questa è iniziare in piccolo, spostando una manciata di componenti UI, raccogliendo dati e studiandone il comportamento. Sebbene utili, questi primi risultati forniscono solo un quadro parziale, fornendo falsi negativi rispetto all'adozione di Compose perché:

  • Non rappresentativo: un componente UI migrato può fornire dati utili sul suo rendimento complessivo in una determinata schermata. Tuttavia, i diversi componenti si comportano in modo diverso per motivi che non sono generalizzabili, quindi non è sempre possibile estrapolare informazioni.
  • Costo di interoperabilità: un piccolo componente Compose all'interno di un grande codebase View paga un costo di bridging imprevedibile tra i due sistemi. Questo sovraccarico distorce la misurazione, quindi i risultati iniziali su piccola scala non riflettono l'aspetto effettivo della migrazione completa.

Il risultato è che le piccole migrazioni, sebbene utili, non riflettono sempre l'impatto completo di Compose. Più una superficie viene migrata end-to-end senza interruzioni del bridging, più chiara e migliore diventa l'immagine in termini di prestazioni.

Le schermate principali di Instagram Direct sono basate su lunghe liste di vari tipi di elementi, originariamente implementate con RecyclerView. L'architettura si basa su astrazioni personalizzate per la scalabilità, ma rimane vincolata al ciclo di vita del sistema basato su View.

Diagramma 1.png

L'attività principale del team è stata la migrazione graduale di diverse centinaia di elementi di elenco individuali a Compose all'interno dell'architettura esistente basata su RecyclerView, implementandoli in produzione in piccoli gruppi indipendenti nell'ambito di test A/B, il tutto senza modifiche visibili all'esperienza di messaggistica dell'utente.

Il principale svantaggio di questa configurazione è una dipendenza significativa dal sistema View legacy tramite un'architettura RecyclerView di base, anche dopo la migrazione completa di ogni elemento dell'elenco a Compose. Come passo successivo naturale, il team ha deciso di investire nella sostituzione dell'architettura di base basata su RecyclerView con l'alternativa nativa di Compose, LazyColumn.

Ciò significa che i componenti della UI di Compose devono essere astratti dal framework in cui sono inclusi, pur rimanendo compatibili sia con RecyclerView che con LazyColumn contemporaneamente. Altrettanto importante è la possibilità di passare da una all'altra in fase di runtime tramite i flag delle funzionalità, per consentire i test A/B.

Diagram 2.png

Mentre i nuovi elementi di Compose sono compatibili in modo nativo con LazyColumn e possono essere inseriti in un albero di composizione ininterrotto, è stata creata un'API di interoperabilità per inserirli anche in un RecyclerView. Ciò ha reso possibile l'implementazione della configurazione di LazyColumn in un test A/B fianco a fianco con RecyclerView, riutilizzando gli stessi elementi di Composizione e migliorando il rendimento, senza interrompere il lavoro del resto del team che crea e perfeziona le funzionalità.

La scala, la complessità e la sensibilità di Instagram anche alle regressioni più piccole hanno rappresentato una sfida unica per Jetpack Compose. Per risolvere questi problemi è stata necessaria una partnership iterativa e pratica. Lavorando a stretto contatto, gli ingegneri di Google e Meta hanno analizzato le metriche per individuare e progettare nuove funzionalità di Compose che soddisfino o superino i benchmark basati sulle visualizzazioni. Grazie a questa partnership, spiccano le seguenti aggiunte a Jetpack Compose: composizione sospendibile con LazyLayoutCacheWindows e monitoraggio della visibilità. 

Composizione sospendibile con LazyLayoutCacheWindows

La composizione sospendibile (attivata per impostazione predefinita in Compose 1.10) consente di comporre in modo incrementale gli elementi di elenchi pigri costosi nei vari frame per evitare scatti. Se combinata con LazyLayoutCacheWindow (aggiunta in Compose 1.9), la combinazione migliora notevolmente la fluidità dello scorrimento. Nei recenti test interni di Meta, la combinazione di Pausable composition con una viewport LazyLayoutCacheWindow ha ridotto i cali di frame di grandi dimensioni al minuto (LFD/m) di circa il 13% rispetto a Compose standard. Cache Window da solo ha ridotto il consumo di energia di circa l'8% rispetto allo stesso valore di riferimento. LFD/m è una metrica interna utilizzata da Meta per monitorare i rallentamenti evidenti durante lo scorrimento.

Quote-Fabio.jpg


L'utilizzo di un LazyLayoutCacheWindow nell'app prepara e conserva gli elementi fuori schermo all'interno di una banda basata su pixel intorno alla finestra, per consentire scorrimenti rapidi. Per sfruttare LazyLayoutCacheWindows nella tua app, puoi utilizzare l'ultima versione di Compose 1.13.0-alpha03 e configurarla come mostrato nell'esempio seguente:

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

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

Esistono due modi per configurare la finestra della cache. Entrambi descrivono la stessa cosa: la quantità di contenuti fuori schermo da mantenere composti, ma in unità diverse.

  • Dp: lunghezza assoluta fissa. ahead = 150.dp mantiene 150 dp di contenuti composti oltre il bordo visibile, indipendentemente dal dispositivo.
  • Float: frazione dell'area visibile. aheadFraction = 0.5f mantiene la composizione di metà schermo in anticipo, quindi la quantità assoluta viene scalata in base all'altezza dello schermo supportando vari fattori di forma: più su un tablet o un pieghevole aperto, meno su uno smartphone compatto. 

Il team di Instagram ha messo a punto le frazioni in virgola mobile della finestra della cache in modo specifico per la struttura dei contenuti e le dimensioni degli elementi di Direct. Poiché i valori ideali variano a seconda dei parametri specifici della UI, trovare il giusto equilibrio richiede un po' di sperimentazione.

Registrazione delle impressioni con onVisibilityChanged


L'API onVisibilityChanged (aggiunta in Compose 1.9.0) è stato un altro risultato chiave della partnership tecnica tra Google e Meta. Offre alle superfici Jetpack Compose su larga scala un modo coerente per sapere quando un componibile è effettivamente visibile sullo schermo, sostituendo le implementazioni personalizzate e create manualmente utilizzate in passato. Solo in Instagram Direct, questi indicatori di visibilità vengono utilizzati in centinaia di file per supportare le metriche di qualità del prodotto che dipendono dal fatto che gli elementi dell'interfaccia utente siano stati effettivamente mostrati agli utenti.

Rendimento all'avvio

L'adozione di Jetpack Compose per Instagram Direct ha portato a miglioramenti inattesi delle prestazioni in altre sezioni dell'app. Il runtime di Jetpack Compose comporta un costo di riscaldamento che paghi una sola volta e, poiché la messaggistica è una sezione ad alto traffico spesso visitata all'inizio di una sessione utente, altre sezioni di Instagram che si basano su Compose hanno registrato miglioramenti notevoli delle prestazioni.

Le prestazioni di avvio della UI di Compose all'interno di Instagram Direct sono state ottimizzate utilizzando i profili di base, che precompilano i percorsi di codice attivi al momento dell'installazione, in modo che Compose venga visualizzato rapidamente fin dal primo avvio.

Lezioni apprese dalla migrazione di Instagram Direct a Jetpack Compose 

  • Jetpack Compose ha un ritorno sull'investimento immediato:non è necessario utilizzare workflow AI avanzati per trarre vantaggio da Compose. Con la riduzione del codice di circa il 50%, è necessario gestire meno codice e la superficie di attacco per i bug è ridotta.
  • La progettazione di un'architettura nativa dell'AI ha portato a risultati significativi, tra cui una riduzione del 35% del tempo di esecuzione dell'agente AI, il 32% in meno di scambi tra ingegneri e agenti e una riduzione del 33% del costo dei token.
  • Sebbene siano disponibili molte API di interoperabilità e il supporto per combinare Views e Compose, cerca di eseguire la migrazione di superfici più grandi rispetto a singoli componenti piccoli. In questo modo, la UI rimane all'interno di una singola gerarchia di composizione ininterrotta e vengono sbloccate tutte le migliori ottimizzazioni delle prestazioni native di Compose.
  • Abbina la composizione sospendibile a LazyLayoutCacheWindow: la combinazione di queste due funzionalità produce risultati migliori rispetto alle sole finestre della cache. Con la sola finestra della cache, un elemento pesante potrebbe comunque tentare di comporsi in un solo passaggio, superando potenzialmente il budget dei frame.
  • Contribuisci a Compose. Meta ha collaborato con il team di Jetpack Compose per dare vita ai suoi feedback e alle sue idee all'interno di Compose. Lavorare su un toolkit open source significa che tutti traggono vantaggio quando vengono apportati centralmente correzioni di bug e miglioramenti delle prestazioni. Quindi, fai sentire la tua voce.

L'adozione di Jetpack Compose ha consentito di ottenere notevoli vantaggi nello sviluppo assistito dall'AI, semplificando al contempo l'ingegneria dell'UI quotidiana su Instagram. L'approccio dichiarativo riduce il boilerplate, semplifica la gestione dello stato e migliora la produttività complessiva degli sviluppatori. Il team di ingegneria di Instagram non vede l'ora di portare Compose su altre piattaforme dell'app e di continuare la collaborazione tra Google e Meta per apportare ulteriori miglioramenti sia agli utenti di Instagram sia a quelli di Jetpack Compose. 

Se non hai ancora provato Compose, ora con l'assistenza dell'AI, la migrazione a Jetpack Compose è più facile che mai.

Riconoscimenti. Grazie a Michal Zielinski e Matthew Du di Meta e ad Andrei Shikov e George Mount di Google per il loro lavoro che ha portato a miglioramenti delle prestazioni di Compose grazie alla collaborazione tra Meta e Google. Grazie anche a Gary Ye di Meta per aver contribuito a portare Scrivi su Instagram Direct e a Gopal Juneja di Meta per aver supportato questo progetto attraverso la scienza dei dati.

Continua a leggere