Nozioni di base sulle app

Scala la tua app per ogni schermo con Compose

Altoparlanti

Informazioni

Informazioni sulla presentazione

L'ecosistema Android è vasto e gli utenti passano senza problemi da smartphone, tablet, pieghevoli e ambienti desktop. Ora che abbiamo adottato l'approccio Compose First, questa sessione esplora come progettare l'architettura della tua app per finestre ridimensionabili e diversi formati, dove le ipotesi di layout tradizionali non funzionano, portando a UI allungate e perdita di stato.

Scopri come le funzionalità più recenti di Jetpack Compose, Navigation 3 e WindowManager possono aiutarti a creare layout dinamici che si adattano in modo naturale da schermi compatti a display a doppio riquadro. Scopri le strategie per accelerare il tuo workflow di sviluppo utilizzando Gemini e le nostre skill, come la skill adattiva, per rifattorizzare automaticamente i codebase legacy per schermi più grandi.

Riepilogo

  • Le piattaforme Android richiedono app adattive perché il sistema ignora i blocchi di orientamento e proporzioni sugli schermi più grandi a partire da Android 16, con la rimozione delle disattivazioni del manifest in Android 17.
  • L'API sperimentale MediaQuery monitora in modo dichiarativo le funzionalità attive della UI, come le dimensioni della finestra e le posizioni di piegatura. Sostituisce l'unione di più gestori di piattaforme legacy nello stato di Compose.
  • Le primitive Grid e FlexBox stabili forniscono componenti di base per il layout adattivo. Grid consente disposizioni multidimensionali parametrizzate all'interno di un singolo composable, mentre FlexBox fornisce la distribuzione e l'allineamento dello spazio unidimensionale.
  • Jetpack Navigation 3 struttura i flussi multipannello a livello di app in base allo stato e alle scene. Le strategie di scena personalizzate valutano le funzionalità della UI e le classi di dimensioni delle finestre per passare dinamicamente dalla presentazione a riquadro singolo a quella a più riquadri.
  • Le skill Compose Adaptive per Gemini automatizzano le migrazioni delle app a componenti adattivi moderni. La skill aggiornata rifattorizza i layout, risolve i bug di perdita di stato e configura gli input desktop, come le scorciatoie da tastiera e i cursori.

Risorse

Gemini Notebook

Argomenti

  • jetpack-compose
  • adaptive-layouts
  • mediaquery
  • grid-layout
  • flexbox
  • navigation-3
  • android-skills

Trascrizione

Scarica la trascrizione

Introduzione alla scalabilità delle app su più schermi

  • Francesco Romano

    Ciao,

  • Oggi approfondiremo gli aspetti tecnici su come scalare la tua app su ogni schermo con Jetpack Compose.

  • Perché adattare i contenuti a ogni schermo?

  • Perché l'ecosistema Android si è espanso in molti fattori di forma diversi.

  • L'APK può essere eseguito su smartphone, pieghevoli, tablet, Chromebook e Googlebook, TV, sistemi di infotainment per auto, dispositivi XR e così via.

  • Innanzitutto, raggiungi i tuoi utenti ovunque si trovino.

  • In secondo luogo, il passaggio della piattaforma Android alle app adattive non è transitorio.

  • Non è che torneremo a un mondo di soli telefoni, l'IA è qui per restare.

  • Ecco una breve cronologia.

  • Nel 2022 abbiamo iniziato a gettare le basi.

  • Abbiamo avuto Android 12L, la prima release di Jetpack WindowManager, abbiamo fornito API come le classi di dimensioni della finestra e abbiamo pubblicato la prima versione delle norme sulla qualità delle app adattive.

  • Inoltre, abbiamo aggiornato Google Play in modo che le valutazioni siano suddivise per tipo di dispositivo e abbiamo iniziato a includere le norme sulla qualità delle app adattive nelle decisioni relative al ranking.

  • In Android 15, abbiamo anche introdotto le finestre delle app in AOSP e Compose Material 3 Adaptive, che ha raggiunto la versione stabile.

  • A partire da Android 16, SDK 36, il sistema ignora semplicemente i blocchi dell'orientamento, i blocchi delle proporzioni e i flag di ridimensionamento sugli schermi più grandi.

  • Insieme al rilascio dei display connessi, ora il tuo APK può essere eseguito praticamente in qualsiasi dimensione della finestra.

  • L'SDK 36 ha fornito un flag manifest temporaneo per disattivare l'override dell'orientamento e delle proporzioni.

  • In Android 17, questa disattivazione non è più disponibile.

  • Pertanto, la creazione di un'app adattiva è un requisito di base della piattaforma.

  • Negli ultimi dieci anni, abbiamo lavorato a stretto contatto con i team di tutto l'ecosistema e abbiamo imparato in prima persona perché la creazione di app adattive può sembrare difficile.

  • Si verificano alcune perdite di stato, API di finestre frammentate, indicatori poco chiari su quando cambiare layout e alcune vecchie ipotesi radicate nella codebase.

  • Abbiamo quindi preso tutto ciò che abbiamo imparato da questo decennio di sfide per gli sviluppatori e lo abbiamo integrato direttamente in due punti: innanzitutto, nel toolkit dell'interfaccia utente stesso.

  • Oggi esploreremo alcune nuove funzionalità principali di Jetpack Compose: query supporti, griglia, FlexBox e Navigation 3.

  • Ma anche in strumenti agentici che scrivono il codice insieme a te tramite le skill Android ufficiali per Gemini in Android Studio e la CLI Android.

  • Ad esempio, vedremo le competenze adattive di Compose rilasciate durante la conferenza Google I/O all'inizio di quest'anno.

  • Era già in grado di eseguire la migrazione dei layout degli smartphone a finestre di dimensioni maggiori.

  • Ora può anche correggere la perdita di stato, implementare esperienze uniche per i pieghevoli, come la modalità da tavolo, e migliorare il supporto degli input scoprendo e implementando scorciatoie da tastiera, cursori personalizzati, descrizioni comando e così via.

  • Bene, iniziamo con Jetpack Compose, che è la base di tutto questo.

  • Jetpack Compose è diventato stabile nel 2021, quindi quest'anno compie cinque anni.

  • In questi cinque anni, abbiamo visto in migliaia di app di produzione quanto sia più facile e affidabile creare app adattive con Compose.

  • Lo sviluppo per Android è ora ufficialmente incentrato su Compose.

  • Ciò significa che tutti i nuovi layout adattivi, gli stili e i componenti di navigazione moderni sono creati per Compose.

  • Dal punto di vista dell'architettura di sistema, il vantaggio principale di Compose per le app adattive rispetto al sistema di visualizzazione precedente è il modo in cui lo stato e la UI interagiscono.

  • Per spiegarlo, voglio fare un rapido confronto con il sistema di visualizzazione.

  • Pensa a come funzionava il sistema di visualizzazione.

  • La UI era un albero di oggetti modificabili che venivano espansi una volta da file XML statici.

  • Avevi diversi qualificatori delle risorse e iniziamo con il problema principale: la duplicazione tra diversi qualificatori delle risorse.

  • Quando crei una UI adattiva in XML, non hai alcuna logica condizionale all'interno del file di layout.

  • Invece, devi solo duplicare l'intera gerarchia nelle cartelle delle risorse, come layout, layout smallest window 600 dp, layout land e così via.

  • Ok, guarda l'esempio EditText a destra.

    <LinearLayout android:orientation="vertical">
      <EditText
          android:id="@+id/text_msg"
          android:inputType="textCapCharacters"
          android:maxLength="12" />
    </LinearLayout>
    <ConstraintLayout>
      <TextInputEditText
        android:id="@+id/text_input"
        android:inputType="text" />
    </ConstraintLayout>
  • Si tratta dello stesso componente logico che gestisce l'input di testo ed è sparso in diversi file XML.

  • Se aggiorni un attributo come inputType o maxLength nel layout per smartphone, il compilatore non ti obbliga ad aggiornarlo anche nel layout per tablet.

  • Inoltre, poiché i componenti sono disaccoppiati in file diversi, la deviazione strutturale è quasi garantita nel tempo.

  • Inoltre, questa duplicazione è collegata a un altro problema, ovvero la perdita di stato durante le modifiche alla configurazione.

  • Ricorda che nel sistema View i componenti dell'interfaccia utente sono stateful.

  • Un EditText possiede il proprio buffer Editable interno, la posizione del cursore personalizzata, lo stato attivo e un altro stato.

  • Quindi, quando un utente, ad esempio, apre il dispositivo o ridimensiona la finestra oltre una determinata soglia, Android distrugge l'intera gerarchia di oggetti View e gonfia i nuovi file XML da zero.

  • Per salvare l'input dell'utente, il framework si basa su onSaveInstanceState, che raggruppa lo stato collegato all'ID della visualizzazione.

  • Ma questo crea tre possibili modalità di errore.

  • Innanzitutto, se qualcuno ha accidentalmente denominato l'ID text_input anziché text_msg nell'altro XML, il ripristino dello stato elimina silenziosamente il testo di input dell'utente senza alcun avviso.

  • In secondo luogo, onRestoreInstanceState viene eseguito dopo onViewCreated, il che può spesso entrare in conflitto con gli osservatori di ViewModel e causare loop di callback.

  • Terzo, se scambi il tipo di widget tra i qualificatori, ad esempio su uno schermo compatto vuoi uno spinner e su uno schermo più ampio vuoi un RadioGroup, se condividono lo stesso ID, si verifica un arresto anomalo in fase di runtime con una ClassCastException.

  • In Jetpack Compose, invece, la UI non è un oggetto che crei una volta.

  • La tua UI è una funzione pura dello stato.

  • E questo cambia completamente il concetto stesso di modifica della configurazione.

  • Che cos'è una modifica della configurazione in Compose?

  • Ho un suggerimento.

Gestione delle modifiche alla configurazione e delle funzionalità della UI

  • Francesco Romano

    Sì.

  • In Compose, una modifica della configurazione è solo un'altra ricomposizione e le ricomposizioni avvengono continuamente.

  • Quando la finestra si allarga, ad esempio perché l'utente ha aperto il dispositivo, i composabili leggono le nuove dimensioni e quelli il cui output dipende da queste vengono semplicemente eseguiti di nuovo.

  • Questo è l'intero evento.

  • Ma ora vediamo cosa significa scrivere app adattive moderne utilizzando gli strumenti e i componenti di Compose più recenti.

  • Quindi, il mondo XML ti faceva porre domande come "Sto eseguendo l'app su uno smartphone, un tablet o un computer?" e poi ramificava la UI in base alla categoria del dispositivo.

  • Ma c'è un motivo fondamentale per evitare di farlo.

  • Anche se l'app viene eseguita esattamente sullo stesso dispositivo fisico, il contesto dell'applicazione e le funzionalità della UI possono cambiare dinamicamente in fase di runtime.

  • Quindi, il messaggio che voglio lasciare è che le decisioni relative alla UI dovrebbero essere guidate dalle funzionalità attive correnti: dimensioni della finestra corrente, postura, alcune caratteristiche hardware.

  • Vediamo un esempio classico: le finestre delle app su un monitor esterno.

  • Se colleghi lo smartphone Pixel a un monitor esterno sulla scrivania, lo stesso processo dell'app che era in esecuzione su uno schermo verticale da 6 pollici ora è in esecuzione su un monitor da 32 pollici con tastiera fisica e mouse collegati.

  • Pertanto, il controllo del tipo di dispositivo su cui è in esecuzione l'app non garantisce nulla sullo schermo su cui viene effettivamente visualizzata o sulle modalità di input.

  • Inoltre, Android supporta la modalità Multi-finestra, quindi le finestre possono essere ridimensionate.

  • Anche se lo schermo di un Googlebook è più grande di quello dello smartphone, l'app potrebbe essere eseguita in una finestra larga 400 dp, insieme ad altre app.

  • Pertanto, anche l'esecuzione di query sulle metriche statiche del display fisico può essere priva di significato.

  • Quindi, per progettare app per Compose, dobbiamo prima capire come eseguire query sull'ambiente.

  • Vorrei iniziare cercando di formalizzare il concetto di funzionalità della UI.

  • Le funzionalità della UI includono non solo la larghezza e l'altezza della finestra o la postura del pieghevole, ma anche le modalità di input e output attualmente attive.

  • Abbiamo notato che nell'ambiente desktop gli utenti non utilizzano sempre il touch screen.

  • Possono usare la tastiera e il mouse.

  • Nel sistema dell'auto, alcune vetture hanno una manopola o un input diverso, un modo diverso per inserire i segnali.

  • Inoltre, oltre alle dimensioni dello schermo e all'input, la distanza dell'utente dal display, ad esempio la distanza di visualizzazione, o se una videocamera o un microfono hardware sono attualmente collegati.

  • Queste sono tutte le funzionalità della UI.

Rilevamento delle posture con l'API mediaQuery

  • Francesco Romano

    Ora, se volevi recuperare tutti gli indicatori, dovevi unire 5 o 6 API diverse provenienti da fonti diverse.

  • Avevi bisogno dell'oggetto di configurazione, di WindowMetricsCalculator, di WindowInfoTracker, di InputManager, di PackageManager e poi, manualmente, di convertire ciascuno di questi in uno stato, uno stato di Compose.

  • Ti è piaciuto?

  • No.

  • Per risolvere questo problema, abbiamo introdotto l'API MediaQuery in androidx.compose.ui.

    mediaQuery { ... }
    derivedMediaQuery { ... }
  • Si ispira alle query supporti CSS.

  • Inoltre, il termine "media" non si riferisce alla riproduzione di audio o video.

  • Si riferisce al mezzo, ovvero l'ambiente in cui viene eseguito il rendering della UI.

  • MediaQuery ti offre un unico modo dichiarativo nativo di Compose per eseguire query sulle funzionalità della UI.

  • Diamo un'occhiata alle proprietà esposte in UiMediaScope.

    sealed interface UiMediaScope {
        @get:FrequentlyChangingValue val windowWidth: Dp
        @get:FrequentlyChangingValue val windowHeight: Dp
    
        val windowPosture: Posture // Flat, Tabletop, Book
        val pointerPrecision: PointerPrecision // Fine, Coarse, Blunt, None
        val keyboardKind: KeyboardKind // Physical, Virtual, None
    
        val hasCamera: Boolean
        val hasMicrophone: Boolean
        val viewingDistance: ViewingDistance // Near, Medium, Far
    }
  • windowWidth e windowHeight in dp.

  • windowPosture, che al momento è destinato ai pieghevoli e può essere Flat, Tabletop o Book.

  • pointerPrecision indica la precisione del dispositivo di puntamento principale: Fine per mouse, stilo o trackpad; Approssimativa per il tocco; Nessuna per joystick, controller o Nessuno.

  • keyboardKind può essere fisico, virtuale o nessuno.

  • hasCamera e hasMicrophone monitorano dinamicamente la disponibilità di questo hardware.

  • Pensa a una fotocamera esterna che può essere collegata.

  • viewingDistance segnala Vicino, Medio o Lontano.

  • Ma penso che sia più facile se guardiamo un esempio reale, ovvero il rilevamento della modalità da tavolo del dispositivo.

  • La modalità da tavolo è una posizione che consente agli utenti di piegare a metà il dispositivo e, di solito, di appoggiarlo su una superficie.

  • È ideale per la riproduzione di video prolungata, per una videochiamata e per la fotografia time-lapse.

  • È anche molto utile per seguire una ricetta o guardare una presentazione.

  • In precedenza, per rilevare la postura Tabletop prima di MediaQuery, dovevi scrivere tutto questo codice.

    @Composable
    fun currentVideoPlayerLayout(): VideoPlayerLayout {
       val context = LocalContext.current
       val windowLayoutInfo by WindowInfoTracker.getOrCreate(context).windowLayoutInfo(context)
           .collectAsStateWithLifecycle(null)
       val isTabletopPosture = windowLayoutInfo?.displayFeatures?.any { displayFeature ->
           displayFeature is FoldingFeature &&
                   displayFeature.state == FoldingFeature.State.HALF_OPENED &&
                   displayFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
       } ?: false
       return if (isTabletopPosture) {
           VideoPlayerLayout.Tabletop
       } else {
           VideoPlayerLayout.Default
       }
    }
  • Dovevi ottenere il contesto, un'istanza di WindowInfoTracker, raccogliere windowLayoutInfo, che è un flusso, uno stato di Compose sensibile al ciclo di vita, quindi eseguire l'iterazione di displayFeatures per verificare se ce n'è uno che è un FoldingFeature, è semiaperto ed è orizzontale.

  • E questo solo per rilevare la postura.

  • Si tratta di un boilerplate di grandi dimensioni.

  • Con una nuova API MediaQuery, tutto questo si riduce a poche righe di codice.

    @Composable
    fun currentVideoPlayerLayout(): VideoPlayerLayout {
       return mediaQuery {
           when(windowPosture) {
               UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
               else -> VideoPlayerLayout.Default
           }
       }
    }
  • La funzione mediaQuery accetta un singolo parametro lambda e, all'interno dell'ambito del ricevitore di quel lambda, espone l'insieme di proprietà che abbiamo appena visto.

  • In questo esempio, controllando semplicemente windowPosture in un'espressione when, possiamo restituire immediatamente il composable corretto.

  • Ecco altri due esempi di utilizzo dell'API.

    @Composable
    fun AdaptiveChatControls(
        onStartVideoCall: () -> Unit,
        onSendMessage: () -> Unit,
    ) {
        // 1. Return a Dp value directly based on pointer precision
        val chipPadding = mediaQuery {
            if (pointerPrecision == PointerPrecision.Fine) 2.dp else 8.dp
        }
    
        // 2. Return a Boolean combining hardware capabilities
        val canVideoCall = mediaQuery { hasCamera && hasMicrophone }
        val hasPhysicalKeyboard = mediaQuery {
            keyboardKind == KeyboardKind.Physical
        }
    
        Row(modifier = Modifier.padding(chipPadding)) {
            if (canVideoCall) {
                IconButton(onClick = onStartVideoCall) {
                    Icon(Icons.Default.Videocam, contentDescription = "Video call")
                }
            }
            Button(onClick = onSendMessage) {
                Text(if (hasPhysicalKeyboard) "Send (Ctrl+Enter)" else "Send")
            }
        }
    }
  • La prima è la densità delle informazioni.

  • Quindi, quando un utente utilizza un laptop, un monitor desktop con un mouse e la precisione del puntatore è Fine, possiamo ridurre il padding sui componenti della UI da, in questo esempio, 8 Dp a 2 Dp per mostrare più contenuti in modo confortevole, poiché gli utenti non utilizzano le dita, i touch target possono essere più piccoli.

  • Inoltre, azioni basate sulle funzionalità.

  • Supponiamo di avere una barra di inserimento della chat, possiamo controllare hasCamera e hasMicrophone per mostrare o nascondere dinamicamente il pulsante di azione della videochiamata oppure possiamo combinare il tipo di tastiera fisica per mostrare o meno alcuni suggerimenti per le scorciatoie da tastiera, ad esempio premi Ctrl+Invio per inviare.

  • Tutti questi dati vengono recuperati in fase di esecuzione.

  • MediaQuery è ottimo anche per i test e la generazione di anteprime.

    @Preview(name = "Tabletop + Fine Pointer", widthDp = 840, heightDp = 700)
    @Composable
    fun TabletopPlayerPreview() {
        val simulatedMediaScope = object : UiMediaScope {
            override val windowWidth = 840.dp
            override val windowHeight = 700.dp
            override val windowPosture = UiMediaScope.Posture.Tabletop
            override val pointerPrecision = UiMediaScope.PointerPrecision.Fine
            override val keyboardKind = UiMediaScope.KeyboardKind.Physical
            override val hasCamera = true
            override val hasMicrophone = true
            override val viewingDistance = UiMediaScope.ViewingDistance.Medium
        }
    
        CompositionLocalProvider(LocalUiMediaScope provides simulatedMediaScope) {
            VideoPlayerScreen()
        }
    }
  • In pratica, MediaQuery risolve il suo ricevitore tramite un elemento CompositionLocal chiamato LocalUiMediaScope.

  • Quindi, puoi semplicemente fornire un UiMediaScope fittizio tramite CompositionLocalProvider nell'anteprima e non è necessario avviare dispositivi pieghevoli e impostarli a 90° o collegare un mouse solo per eseguire un test della UI di Compose.

Layout dello schermo adattabili con Grid e FlexBox

  • Francesco Romano

    Ora sappiamo come eseguire query sulle funzionalità della UI.

  • Ora vediamo come creare il layout effettivo.

  • Per questo, voglio presentare Grid, una nuova primitiva di layout in androidx.compose.foundation.layout, che ora è stabile.

    Grid(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        config = {
            val maxWidthDp = constraints.maxWidth.toDp()
            val columnCount = if (maxWidthDp < 600.dp) 2 else 4
    
            repeat(columnCount) {
                column(1.fr)
            }
            row(GridTrackSize.Auto)
            row(1.fr)
    
            gap(row = 16.dp, column = 8.dp)
            flow = GridFlow.Row
        }
    ) {
        // GridScope children...
    }
  • La griglia consente di implementare un layout bidimensionale visualizzando gli elementi in colonne e righe che si adattano alle dimensioni del contenitore disponibili.

  • Vediamo alcune cose che puoi fare nel blocco di configurazione.

  • Puoi aggiungere nuovi elementi o tracce chiamando column o row e puoi utilizzare i loop Kotlin standard come repeat.

  • Puoi impostare i canali tra le tracce.

  • Puoi controllare la direzione del posizionamento automatico tramite flow-- GridFlow.Row o GridFlow.Column.

  • Ecco un dettaglio chiave dell'architettura.

  • Il blocco di configurazione viene eseguito durante la fase di misurazione e l'ambito di configurazione della griglia implementa la densità ed espone i vincoli.

  • Ciò significa che puoi ispezionare constraints.maxWidth.toDp() direttamente all'interno della configurazione per calcolare le tracce senza passaggi di ricomposizione aggiuntivi.

  • In Grid, quando aggiungi un elemento secondario senza modificatore, questo viene inserito automaticamente nella cella successiva.

    Grid(
        config = {
            repeat(3) { column(1.fr) }
            repeat(3) { row(100.dp) }
            gap(8.dp)
        }
    ) {
        // Spans 2x2 area (rows 1..2, columns 1..2)
        HeroCard(modifier = Modifier.gridItem(rows = 1..2, columns = 1..2))
    
        // Overlaps on top of HeroCard in cell (1, 1) aligned to TopStart!
        LiveBadge(
            modifier = Modifier.gridItem(
                row = 1,
                column = 1,
                alignment = Alignment.TopStart
            )
        )
    
        // Negative index: placed in last row (-1), spanning all 3 columns
        FooterBar(modifier = Modifier.gridItem(row = -1, column = 1, columnSpan = 3))
    }
  • Quando vuoi un controllo esplicito, puoi utilizzare Modifier.gridItem e hai coordinate basate su uno.

  • Quindi, la riga 1, colonna 1 è la cella in alto a sinistra.

  • Supporta un'indicizzazione negativa, per cui la riga -1 è l'ultima riga e così via.

  • Puoi utilizzare rowSpan per estendere un elemento su un'area 2D, oltre alla sovrapposizione.

  • Pertanto, se due elementi vengono posizionati in celle sovrapposte, Grid li impila uno sopra l'altro all'interno di questi limiti e l'ordine Z è determinato dall'ordine di composizione.

  • Quindi, gli elementi successivi verranno disegnati sopra gli altri, proprio come Box.

  • Ora conosciamo le nozioni di base di Grid.

  • Vediamo l'esempio del video player.

  • A seconda che il dispositivo sia piatto o semiaperto, dobbiamo passare dal layout predefinito al layout da tavolo.

  • Prima di Grid, dovevamo creare due diversi componibili, DefaultLayout e TabletopLayout, e poi creare un ramo del codice su un'espressione when.

    @Composable
    fun VideoPlayerLayout(
       player: @Composable () -> Unit,
       controller: @Composable () -> Unit,
       modifier: Modifier = Modifier
    ) {
       when(currentVideoPlayerLayout()) {
           VideoPlayerLayout.Default -> DefaultLayout(player, controller, modifier)
           VideoPlayerLayout.Tabletop -> TabletopLayout(player, controller, modifier)
       }
    }
  • E potresti pensare: "Cosa c'è di male?"

  • Se guardi questi layout, i loro contenuti sono identici.

  • Entrambi i display mostrano esattamente lo stesso player e lo stesso controller.

  • L'unica differenza tra i due è il modo in cui questi due elementi sono disposti sullo schermo.

  • Finora, non avendo un modo per passare la disposizione del layout stessa come parametro, eravamo costretti a definire due composabili separati.

  • Ora possiamo parametrizzare la configurazione del layout e gestire tutto all'interno di una singola funzione componibile.

  • Vedremo come.

  • Quindi, con l'API Grid, possiamo definire le aree denominate come enum.

    enum class VideoPlayerArea { Player, Controller }
    
    Grid(
        config = { ... },
        modifier = modifier
    ) {
        Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
            player()
        }
        Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
           controller()
       }
    }
  • Poi, possiamo specificare i composabili secondari, player e controller, da inserire nella griglia e possiamo associare ogni elemento secondario a un'area denominata chiamando Modifier.gridItem e passando areaId.

  • In questo caso, indichiamo a Grid di posizionare il player nell'area denominata VideoPlayerArea.Player e il controller nell'area VideoPlayerArea.Controller.

  • Qui definiamo le due funzioni lambda di configurazione in un VideoPlayerGridConfiguration.

    object VideoPlayerGridConfiguration {
       val Default: GridConfigurationScope.() -> Unit = {
           row(1f)
           column(1f)
           area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
           area(areaId = VideoPlayerArea.Controller, row = 1, column = 1)
       }
    
       val Tabletop: GridConfigurationScope.() -> Unit = {
           row(0.5f)
           row(0.5f)
           column(1f)
           area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
           area(areaId = VideoPlayerArea.Controller, row = 2, column = 1)
       }
    }
  • Per il layout predefinito del video player, configuriamo una griglia di una riga e una colonna, ma tieni presente che, anche se esiste una sola cella, possiamo comunque assegnare due nomi di area alla stessa cella.

  • In questo caso, come abbiamo visto in precedenza, il controller è sovrapposto al player.

  • Per la modalità da tavolo, definiamo semplicemente due righe e una colonna, posizionando il player nella metà superiore e il controller nella metà inferiore, in pratica, in questo modo.

  • Una volta definite queste lambda di configurazione, possiamo utilizzare MediaQuery direttamente nella configurazione della griglia.

    @Composable
    fun VideoPlayerLayout(
       player: @Composable () -> Unit,
       controller: @Composable () -> Unit,
       modifier: Modifier = Modifier
    ) {
       Grid(
           config = { mediaQuery {
           if (isTabletopPosture) { VideoPlayerGridConfiguration.Tabletop }
     else { VideoPlayerGridConfiguration.Default }
    }
       }
           modifier = modifier
       ) {
           Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
               player()
           }
           Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
               controller()
           }
       }
    }
  • Ora possiamo avere un singolo composable di layout del video player con l'UI che passa dinamicamente da una configurazione all'altra.

  • La prossima primitiva di cui voglio parlare è FlexBox.

    @Composable
    fun AdaptiveActionHeader(tags: List<String>) {
            FlexBox(
            config = {
                direction( if (isCompact)
    FlexDirection.Column else FlexDirection.Row )
                wrap(FlexWrap.Wrap)
                alignItems(FlexAlignItems.Center)
                gap(row = 8.dp, column = 12.dp)
            },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text(
                text = "Home",
                modifier = Modifier.flex {
                    basis(FlexBasis.Auto)
                    grow(1f)
                    shrink(1f)
                }
            )
            tags.forEach { tag ->
                FilterChipItem(
                    label = tag,
                    modifier = Modifier.flex { shrink(0f) }
                )
            }
        }
    }
  • Quindi, mentre Grid gestisce i layout di righe e colonne 2D, FlexBox, che ora è anche stabile, gestisce i layout monodimensionali che richiedono comunque una distribuzione intelligente dello spazio, la riduzione e l'allineamento su più righe.

  • Inoltre, è un layout comodo e adattabile che puoi utilizzare al posto di due componibili separati, come FlowRow e FlowColumn, perché è un singolo contenitore la cui direzione può cambiare dinamicamente all'interno del suo blocco di configurazione senza ricreare i componibili secondari.

  • Inoltre, implementa l'algoritmo CSS FlexBox completo, quindi hai wrap, justify-content, align-items, gap.

  • E su ogni elemento secondario, hai Modifier.flex che ti fornisce basis, fattori di crescita e riduzione relativi, alignSelf e così via.

Architettura del layout a livello di app e Navigation 3

  • Francesco Romano

    Ok, ora passiamo dalla definizione a livello di schermo ai layout a livello di app, ovvero come strutturiamo la navigazione di primo livello e le disposizioni degli schermi con più riquadri nell'intera applicazione, non solo nella singola schermata.

  • Sebbene MediaQuery fornisca la larghezza e l'altezza esatte della finestra in dp, per l'architettura a livello di app, è necessario utilizzare i breakpoint della finestra di visualizzazione standardizzati e predefiniti, ovvero le classi di dimensioni della finestra.

  • In pratica, vuoi che ogni schermo sia reattivo all'interno di ciascuno di questi bucket, mentre puoi avere più modifiche, aggiornamenti e differenze strutturali tra i diversi bucket.

  • Ad esempio, mostrando solo un elenco su uno schermo compatto o sia l'elenco che i dettagli in una finestra espansa.

  • Puoi creare classi di dimensioni della finestra direttamente su UiMediaScope in modo molto pulito, ovvero definendo una proprietà di estensione su UiMediaScope.

    val UiMediaScope.windowSizeClass: WindowSizeClass
        @Composable get() = derivedMediaQuery {
            WindowSizeClass.BREAKPOINTS_V2.computeWindowSizeClass(
                widthDp = windowWidth.value,
                heightDp = windowHeight.value
            )
        }
    
    @Composable
    fun AppRoot() {
        val sizeClass = mediaQuery { windowSizeClass }
        val isExpandedWidth = sizeClass.isWidthAtLeastBreakpoint(
            WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND // 600dp
        )
        // Drive app-level navigation and scene strategies...
    }
  • Una cosa da notare è che utilizza derivedMediaQuery, non la classe MediaQuery standard.

  • Perché?

  • Perché la larghezza e l'altezza della finestra sono valori che cambiano di frequente.

  • Quando gli utenti ridimensionano l'app, ad esempio su un computer, questi valori possono cambiare a ogni frame.

  • Inoltre, probabilmente non vuoi ricomporre lo scaffold a livello di app in ogni frame, ma solo quando la finestra supera effettivamente un breakpoint.

  • E una volta ottenute le classi di dimensioni della finestra, come facciamo a passare da un flusso a riquadro singolo a layout a più riquadri quando abbiamo più spazio?

  • È qui che Jetpack Navigation 3 dà il meglio di sé, perché Nav 3 è stato progettato da zero attorno allo stato e alle scene di Compose.

  • Innanzitutto, il back stack è di tua proprietà ed è semplicemente un elenco di NavKey, quindi è molto flessibile ed è facile da salvare come stato.

  • Ogni chiave nello stack si risolve in un NavEntry contenente il relativo composable e i metadati.

  • Inoltre, NavDisplay non hardcoded il modo in cui vengono disegnate le voci, ma valuta un elenco di strategie di scena.

  • All'interno della strategia, puoi esaminare le funzionalità della UI, ad esempio le classi di dimensioni della finestra.

    if (isExpandedWidth) {
        /* return a multi-pane scene */
    }
    else {
        /* return a single pane scene */
    }
  • Puoi ispezionare il back stack e poi decidere se eseguire il rendering di una NavEntry in un singolo riquadro, di più elementi contemporaneamente o di un elemento sopra l'altro.

  • È molto flessibile.

  • Oltre alla strategia multipannello, le classi di dimensioni della finestra determinano anche i componenti di navigazione da utilizzare per massimizzare l'ergonomia e l'utilizzo dello spazio sullo schermo.

  • Ad esempio, su larghezze compatte, vuoi una barra di navigazione in basso, su larghezze medie, una barra di navigazione, su larghezze espanse, un riquadro di navigazione a scomparsa completo.

  • Quando combini Nav3 per le decisioni di layout a livello di app e i componenti adattivi per la definizione a livello di schermo, ottieni app come questa, che si distinguono praticamente in ogni dimensione e ambiente della finestra.

  • Per Nav 3 in particolare, se vuoi saperne di più sugli ultimi aggiornamenti, incluso il supporto per i deep link, dai un'occhiata al talk Building Type-Safe Deep Links with Jetpack Navigation 3.

Demo di sviluppo e automazione agentici

  • Francesco Romano

    Ok, oggi abbiamo parlato di molte nuove API: MediaQuery, Grid, FlexBox, Nav3.

  • Questo è tutto su come Jetpack Compose ti aiuta a creare app adattive.

  • Tuttavia, nell'era dello sviluppo agentico, l'utilizzo di Jetpack Compose offre un altro vantaggio estremamente importante: è molto più efficace.

  • Probabilmente l'avrai sentito durante il keynote: abbiamo visto app che utilizzano oltre il 30% di token in meno dopo la migrazione a Compose e stiamo lavorando per ottimizzare ulteriormente l'Agentic Workflow per le app Compose.

  • Oggi voglio evidenziare gli aggiornamenti principali della skill Compose Adaptive.

    android skills list
    android skills add adaptive
  • Come ho detto, oltre a eseguire la migrazione dei layout a Grid, Flexbox e MediaQuery, ora gestisce esplicitamente la gestione dello stato end-to-end nell'app e testiamo la competenza rispetto a bug di perdita di stato reali: preservando la navigazione, la posizione di scorrimento, la riproduzione di contenuti multimediali attivi, gli input di testo digitati a metà e così via.

  • La skill ora include anche l'ottimizzazione della postura dei pieghevoli, come il refactoring automatico delle schermate dei contenuti multimediali e della fotocamera in modalità da tavolo, ma anche quando l'app viene eseguita su un Googlebook o su un monitor esterno con mouse e tastiera, il layout è solo una parte della storia.

  • La skill Adattabilità ora può controllare la tua UI per trovare e implementare scorciatoie da tastiera, cursori del puntatore personalizzati, stati di passaggio del mouse, tooltip e così via.

  • Vediamo come funziona.

  • Ho registrato una demo di questo.

  • Quindi abbiamo la nostra app.

  • Possiamo guardare i nostri animali domestici.

  • Possiamo scambiare messaggi con loro.

  • Possiamo guardare un video.

  • Sì, possiamo vedere gli animali domestici più popolari.

  • Ma vedi già che non è un granché.

  • Ora vediamo, voglio solo guardare un'immagine con un po' di drammaticità e dei gatti.

  • Ok, voglio vederle più grandi.

  • Dah, torna alla home page.

  • Ok, lo dico al mio amico.

  • Sì, quindi, "Amico", hm.

  • L'app non è un granché.

  • È anche troppo ampio.

  • Noti che è troppo largo.

  • Proviamo a ridimensionarla.

  • Cosa?

  • Eh, sì, ok.

  • Il testo che ho digitato è andato perso.

  • Ok, è il momento di chiedere a Gemini di risolvere il problema.

  • Quindi, grazie alla skill Adaptive, il prompt può essere semplice come "rendi l'app adattiva".

  • Come puoi vedere, sto utilizzando Gemini 3.0 Flash che, insieme alla skill, è in grado di farlo.

  • Ha acquisito la skill e ora sta iniziando a eseguire le modifiche.

  • Ti farà alcune domande, ad esempio: "Oh, MediaQuery è sperimentale.

  • Vuoi davvero usarlo?"

  • E continua così.

  • Ho dovuto accelerare per tagliare alcune parti, ma se vieni nell'area demo, puoi provare le skill in prima persona.

  • E questa è solo una delle skill che abbiamo rilasciato.

  • Ad esempio, abbiamo anche una nuova competenza che ti aiuterà a creare esperienze straordinarie con lo stilo.

  • Quindi, vieni nell'area per provarlo.

  • Quindi, è fatta.

  • Ha modificato 11 file.

  • Vediamolo all'opera.

  • Ok.

  • Grid sta già facendo il suo lavoro.

  • La navigazione è stata spostata sul lato.

  • Proviamo i filtri.

  • Penso che ora funzioneranno.

  • Sì, lo fanno.

  • Ok, inviamo un messaggio al mio amico per dirgli che l'app ora è fantastica.

  • Vediamo come appare con più spazio.

  • Sì, è stata eseguita la migrazione al layout elenco-dettagli.

  • Questa esecuzione ha richiesto circa 20 minuti e circa 200.000 token.

  • Ok.

Il ROI delle app adattive e i concetti chiave

  • Francesco Romano

    Di solito inizio un discorso con metriche aziendali e casi di successo, ma per ADS volevo passare direttamente al codice.

  • Tuttavia, voglio dedicare qualche minuto a condividere il ritorno sull'investimento in termini di progettazione.

  • Oggi, la tua app per Android può potenzialmente essere eseguita su oltre 580 milioni di dispositivi con schermi di grandi dimensioni attivi e sappiamo anche che gli utenti di dispositivi pieghevoli e multi-dispositivo sono tra i segmenti più coinvolti su Android.

  • Diamo un'occhiata a due case study concreti che dimostrano cosa succede quando si distribuisce una build adattiva.

  • La prima è Gratitude, una famosa app per il benessere personale e il diario giornaliero.

  • Adottando design di schermi adattivi, Gratitude ha reso i tablet e i pieghevoli un canale di acquisizione in forte crescita.

  • Innanzitutto, hanno raddoppiato la velocità di acquisizione sugli schermi di grandi dimensioni.

  • Le installazioni giornaliere provenienti specificamente da tablet e pieghevoli sono aumentate del 100%.

  • Hanno ampliato la quota di schermi di grandi dimensioni della loro base utenti complessiva dal 7% al 10% delle installazioni totali.

  • La seconda è BoxBoxClub, un'app complementare per la Formula 1 e gli sport motoristici.Dopo aver aggiornato la build per i pieghevoli e gli schermi più grandi, la telemetria ha mostrato enormi guadagni: un aumento del 7, 6% degli utenti attivi complessivi e un aumento del 54% del tempo di coinvolgimento.

  • Ok.

  • Siamo alla fine del discorso e voglio darti tre concetti fondamentali.

  • La creazione di app adattive consente alla tua app di supportare più dispositivi e più utenti, garantendo al contempo la compatibilità futura con questo ecosistema in espansione.

  • Lo sviluppo Android è incentrato su Compose.

  • Tutti i nuovi strumenti, le librerie e le competenze sono creati per Compose.

  • Compose è ideale per i layout adattivi e per lo sviluppo agentico.

  • Le codebase sono più piccole e facili da comprendere, gli agenti utilizzano meno token quando gestiscono il codice di Compose.

  • Quindi, prova tutti i nuovi strumenti, le skill e le API e divertiti.

  • Grazie.

Codice

Snippet di codice

Snippet XML al minuto 05:17 in "Introduzione alla scalabilità delle app su più schermi"
<LinearLayout android:orientation="vertical">
  <EditText
      android:id="@+id/text_msg"
      android:inputType="textCapCharacters"
      android:maxLength="12" />
</LinearLayout>
Snippet XML al minuto 05:17 in "Introduction to scaling apps across screens"
<ConstraintLayout>
  <TextInputEditText
    android:id="@+id/text_input"
    android:inputType="text" />
</ConstraintLayout>
Snippet di Kotlin al minuto 11:31 in "Detecting postures with the mediaQuery API"
mediaQuery { ... }
derivedMediaQuery { ... }
Snippet Kotlin al minuto 11:57 in "Detecting postures with the mediaQuery API"
sealed interface UiMediaScope {
    @get:FrequentlyChangingValue val windowWidth: Dp
    @get:FrequentlyChangingValue val windowHeight: Dp

    val windowPosture: Posture // Flat, Tabletop, Book
    val pointerPrecision: PointerPrecision // Fine, Coarse, Blunt, None
    val keyboardKind: KeyboardKind // Physical, Virtual, None

    val hasCamera: Boolean
    val hasMicrophone: Boolean
    val viewingDistance: ViewingDistance // Near, Medium, Far
}
Snippet Kotlin al minuto 13:11 in "Detecting postures with the mediaQuery API"
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
   val context = LocalContext.current
   val windowLayoutInfo by WindowInfoTracker.getOrCreate(context).windowLayoutInfo(context)
       .collectAsStateWithLifecycle(null)
   val isTabletopPosture = windowLayoutInfo?.displayFeatures?.any { displayFeature ->
       displayFeature is FoldingFeature &&
               displayFeature.state == FoldingFeature.State.HALF_OPENED &&
               displayFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
   } ?: false
   return if (isTabletopPosture) {
       VideoPlayerLayout.Tabletop
   } else {
       VideoPlayerLayout.Default
   }
}
Snippet Kotlin al minuto 13:42 in "Rilevamento delle posture con l'API mediaQuery"
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
   return mediaQuery {
       when(windowPosture) {
           UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
           else -> VideoPlayerLayout.Default
       }
   }
}
Snippet Kotlin al minuto 14:07 in "Detecting postures with the mediaQuery API"
@Composable
fun AdaptiveChatControls(
    onStartVideoCall: () -> Unit,
    onSendMessage: () -> Unit,
) {
    // 1. Return a Dp value directly based on pointer precision
    val chipPadding = mediaQuery {
        if (pointerPrecision == PointerPrecision.Fine) 2.dp else 8.dp
    }

    // 2. Return a Boolean combining hardware capabilities
    val canVideoCall = mediaQuery { hasCamera && hasMicrophone }
    val hasPhysicalKeyboard = mediaQuery {
        keyboardKind == KeyboardKind.Physical
    }

    Row(modifier = Modifier.padding(chipPadding)) {
        if (canVideoCall) {
            IconButton(onClick = onStartVideoCall) {
                Icon(Icons.Default.Videocam, contentDescription = "Video call")
            }
        }
        Button(onClick = onSendMessage) {
            Text(if (hasPhysicalKeyboard) "Send (Ctrl+Enter)" else "Send")
        }
    }
}
Snippet Kotlin al minuto 14:59 in "Detecting postures with the mediaQuery API"
@Preview(name = "Tabletop + Fine Pointer", widthDp = 840, heightDp = 700)
@Composable
fun TabletopPlayerPreview() {
    val simulatedMediaScope = object : UiMediaScope {
        override val windowWidth = 840.dp
        override val windowHeight = 700.dp
        override val windowPosture = UiMediaScope.Posture.Tabletop
        override val pointerPrecision = UiMediaScope.PointerPrecision.Fine
        override val keyboardKind = UiMediaScope.KeyboardKind.Physical
        override val hasCamera = true
        override val hasMicrophone = true
        override val viewingDistance = UiMediaScope.ViewingDistance.Medium
    }

    CompositionLocalProvider(LocalUiMediaScope provides simulatedMediaScope) {
        VideoPlayerScreen()
    }
}
Snippet Kotlin al minuto 15:40 in "Responsive screen layouts with Grid and FlexBox"
Grid(
    modifier = Modifier.fillMaxSize().padding(16.dp),
    config = {
        val maxWidthDp = constraints.maxWidth.toDp()
        val columnCount = if (maxWidthDp < 600.dp) 2 else 4

        repeat(columnCount) {
            column(1.fr)
        }
        row(GridTrackSize.Auto)
        row(1.fr)

        gap(row = 16.dp, column = 8.dp)
        flow = GridFlow.Row
    }
) {
    // GridScope children...
}
Snippet Kotlin alle 16:50 in "Responsive screen layouts with Grid and FlexBox"
Grid(
    config = {
        repeat(3) { column(1.fr) }
        repeat(3) { row(100.dp) }
        gap(8.dp)
    }
) {
    // Spans 2x2 area (rows 1..2, columns 1..2)
    HeroCard(modifier = Modifier.gridItem(rows = 1..2, columns = 1..2))

    // Overlaps on top of HeroCard in cell (1, 1) aligned to TopStart!
    LiveBadge(
        modifier = Modifier.gridItem(
            row = 1,
            column = 1,
            alignment = Alignment.TopStart
        )
    )

    // Negative index: placed in last row (-1), spanning all 3 columns
    FooterBar(modifier = Modifier.gridItem(row = -1, column = 1, columnSpan = 3))
}
Snippet Kotlin al minuto 17:59 in "Responsive screen layouts with Grid and FlexBox"
@Composable
fun VideoPlayerLayout(
   player: @Composable () -> Unit,
   controller: @Composable () -> Unit,
   modifier: Modifier = Modifier
) {
   when(currentVideoPlayerLayout()) {
       VideoPlayerLayout.Default -> DefaultLayout(player, controller, modifier)
       VideoPlayerLayout.Tabletop -> TabletopLayout(player, controller, modifier)
   }
}
Snippet Kotlin al minuto 18:51 in "Responsive screen layouts with Grid and FlexBox"
enum class VideoPlayerArea { Player, Controller }

Grid(
    config = { ... },
    modifier = modifier
) {
    Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
        player()
    }
    Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
       controller()
   }
}
Snippet Kotlin al minuto 19:20 in "Responsive screen layouts with Grid and FlexBox"
object VideoPlayerGridConfiguration {
   val Default: GridConfigurationScope.() -> Unit = {
       row(1f)
       column(1f)
       area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
       area(areaId = VideoPlayerArea.Controller, row = 1, column = 1)
   }

   val Tabletop: GridConfigurationScope.() -> Unit = {
       row(0.5f)
       row(0.5f)
       column(1f)
       area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
       area(areaId = VideoPlayerArea.Controller, row = 2, column = 1)
   }
}
Snippet Kotlin al minuto 20:00 in "Responsive screen layouts with Grid and FlexBox"
@Composable
fun VideoPlayerLayout(
   player: @Composable () -> Unit,
   controller: @Composable () -> Unit,
   modifier: Modifier = Modifier
) {
   Grid(
       config = { mediaQuery {
       if (isTabletopPosture) { VideoPlayerGridConfiguration.Tabletop }
 else { VideoPlayerGridConfiguration.Default }
}
   }
       modifier = modifier
   ) {
       Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
           player()
       }
       Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
           controller()
       }
   }
}
Snippet di Kotlin al minuto 20:16 in "Responsive screen layouts with Grid and FlexBox"
@Composable
fun AdaptiveActionHeader(tags: List<String>) {
        FlexBox(
        config = {
            direction( if (isCompact)
FlexDirection.Column else FlexDirection.Row )
            wrap(FlexWrap.Wrap)
            alignItems(FlexAlignItems.Center)
            gap(row = 8.dp, column = 12.dp)
        },
        modifier = Modifier.fillMaxWidth()
    ) {
        Text(
            text = "Home",
            modifier = Modifier.flex {
                basis(FlexBasis.Auto)
                grow(1f)
                shrink(1f)
            }
        )
        tags.forEach { tag ->
            FilterChipItem(
                label = tag,
                modifier = Modifier.flex { shrink(0f) }
            )
        }
    }
}
Snippet di Kotlin al minuto 22:09 in "App-level layout architecture and Navigation 3"
val UiMediaScope.windowSizeClass: WindowSizeClass
    @Composable get() = derivedMediaQuery {
        WindowSizeClass.BREAKPOINTS_V2.computeWindowSizeClass(
            widthDp = windowWidth.value,
            heightDp = windowHeight.value
        )
    }

@Composable
fun AppRoot() {
    val sizeClass = mediaQuery { windowSizeClass }
    val isExpandedWidth = sizeClass.isWidthAtLeastBreakpoint(
        WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND // 600dp
    )
    // Drive app-level navigation and scene strategies...
}
Snippet di Kotlin al minuto 23:33 in "App-level layout architecture and Navigation 3"
if (isExpandedWidth) {
    /* return a multi-pane scene */
}
else {
    /* return a single pane scene */
}
Snippet di shell al minuto 25:18 in "Demo di sviluppo e automazione basati su agenti"
android skills list
android skills add adaptive