Outils et performances

Surpassez-vous dans Compose

Haut-parleurs

À propos

À propos de l'intervention

Jetpack Compose facilite la conception d'interfaces utilisateur de haute qualité, mais il peut être difficile de détecter les problèmes qui affectent les performances de l'UI. Ces types de problèmes peuvent entraîner des problèmes que vos utilisateurs remarquent, comme une latence au démarrage et des pertes d'images qui se traduisent par des saccades lors du défilement ou des animations saccadées.

Dans cette présentation, nous allons vous présenter des outils permettant de découvrir ce qui affecte les performances de votre UI, en nous concentrant sur Perfetto et le traçage de la (re)composition. Nous explorerons ensuite des anti-modèles spécifiques que vous utilisez peut-être et qui vous coûtent de l'argent sans que vous le sachiez, comment les trouver dans votre code et par quels modèles positifs les remplacer. À la fin de cette présentation, vous saurez comment penser en Compose en gardant à l'esprit les performances, et comment profiler, analyser et vérifier les optimisations des performances dans votre application.

Résumé

  • Les traces Perfetto enregistrent l'activité sur l'appareil et affichent les segments Compose tels que la composition et la mise en page le long du thread principal. Utilisez-les pour inspecter les chronologies des frames et suivre la durée des phases.
  • Chaque opération dans les composables a un coût, y compris le lancement et l'annulation de coroutines. Auditez les API d'effets secondaires et envisagez de fusionner les effets avec des clés identiques pour réduire la surcharge de composition.
  • Le traçage de la composition annote les fonctions composables, et le traçage de la recomposition (1.13+) suit les lectures et écritures d'état qui provoquent la recomposition. Ils permettent de diagnostiquer les tâches inattendues directement dans les traces Perfetto.
  • ComputedState (Compose 1.13 ou version ultérieure) évite les invalidations indésirables et est généralement plus rapide que DerivedState lors des invalidations de lecture après écriture. DerivedState reste utile lorsque des calculs ou des allocations coûteux bénéficient de la mise en cache.
  • Ignorer des composables entraîne des coûts de maintenance et une surcharge liée à la comparaison des paramètres. Fiez-vous à l'égalité d'identité des objets et à l'omission forcée, ou évitez complètement les invalidations, plutôt que de forcer la stabilité.

Compétences

Ressources

Gemini Notebook

Thèmes

  • jetpack-compose
  • android-performance
  • perfetto
  • benchmarking
  • coroutines
  • composition-tracing
  • android-cli

Transcription

Introduction

  • Bonjour.

  • Bonjour !

  • J'espère que vous avez passé de bonnes vacances.

  • J'espère que vous avez pris votre café, que vous êtes bien hydraté, etc.

  • Aujourd'hui, nous allons nous pencher un peu plus sur le sujet.

  • Eh bien, oui, c'est parti.

  • Nous allons approfondir un peu le sujet aujourd'hui.

  • Je m'appelle Andrei Shikov et je suis accompagné aujourd'hui d'Andrew Bailey.

  • Nous travaillons tous les deux dans l'équipe d'exécution du compilateur Compose, mais aussi sur beaucoup d'autres choses, y compris les performances.

  • Le thème de la discussion d'aujourd'hui est de répondre à vos questions sur les performances, car Jetpack Compose s'est imposé comme le kit d'outils d'interface utilisateur par défaut pour le développement Android.

  • Et maintenant, beaucoup de gens se demandent comment s'assurer que leur UI Compose est aussi performante que possible.

  • Aujourd'hui, nous allons donc nous pencher en détail sur le sujet, comme je l'ai déjà dit.

  • Nous allons également vous présenter des outils pour vous aider à vous surpasser dans Compose.

  • Pour commencer, j'aimerais expliquer pourquoi les performances sont importantes pour nous.

  • Quand on pense à la qualité et à la création d'applications de qualité, on pense généralement aux animations et à un ensemble de fonctionnalités riches. On oublie parfois les performances, mais la perception de l'utilisateur dépend vraiment des performances de votre application.

  • Si les animations sont saccadées, la perception de l'utilisateur se dégrade immédiatement.

  • Toutes les métriques et tous les chiffres que nous allons mentionner aujourd'hui sont en fait des métriques proxy que nous avons sélectionnées pour représenter ce que les utilisateurs pensent de l'application, de toutes ces animations et de tout ce qui se passe à l'écran.

  • En fin de compte, optimiser votre application revient à optimiser l'expérience utilisateur.

  • C'est un état d'esprit que je souhaite que vous adoptiez, car nous allons aborder de nombreux sujets aujourd'hui.

  • Maintenant que vous commencez à examiner les performances de votre application, je vous invite à effectuer trois actions rapides.

  • Tout d'abord, si vous mesurez les performances, vous le faites en mode release. Nous n'avons donc pas beaucoup d'informations de débogage, et tout cela ralentit notre application.

  • Deuxièmement, vous activez R8 ou d'autres minificateurs.

  • Cette année, nous avons introduit une optimisation qui double les performances des coroutines, et nous faisons également beaucoup d'autres choses.

  • Nous nous appuyons beaucoup sur R8 pour optimiser Compose.

  • Enfin, en particulier pour les métriques de démarrage, vous devez vous assurer d'utiliser un profil de référence.

  • Les classes que vous chargez au démarrage sont donc précompilées et préchargées au démarrage de l'application.

  • Comme vous pouvez le voir, il y a une différence d'un facteur 6 par rapport à votre build débogable habituel de l'application.

  • Et, à mesure que vous mesurez tous ces éléments, ils s'ajouteront aux autres gains que vous réalisez dans votre code.

  • Maintenant, vous avez fait tout cela, mais l'application est toujours un peu lente ou il y a d'autres problèmes de performances. C'est là que vous devez entrer dans cette boucle de performances.

  • Vous souhaitez commencer à examiner certaines métriques pour des scénarios spécifiques.

  • Par exemple, si votre application met trop de temps à se charger, vous pouvez la réinitialiser, la lancer comme d'habitude, puis vérifier le temps qu'elle met à afficher le premier écran de données.

  • Ou peut-être que l'application saccade un peu, alors vous faites défiler l'écran et vous vous assurez que ce défilement est reproductible et stable.

  • L'environnement stable présenté sur cette diapositive est donc très important, car lorsque vous effectuerez des optimisations par la suite, vous voudrez vous assurer que vos métriques s'améliorent et que ces optimisations fonctionnent comme prévu.

  • Les benchmarks sont très utiles pour cela, car ils capturent déjà beaucoup de données, effectuent de nombreuses itérations pour s'assurer qu'il n'y a pas trop de bruit sur votre appareil et effectuent également de nombreuses configurations d'appareil qui vous aident.

  • De plus, les benchmarks sont également des scénarios reproductibles par défaut, et c'est ce que nous recherchons ici.

  • Mais vous pouvez également le faire avec un ensemble de scripts si vous le souhaitez.

  • Enfin, vous profilez, vous améliorez un élément de votre application, vous examinez les données, puis vous répétez l'opération, peut-être avec d'autres métriques de scénario où votre application rencontre des difficultés.

Profilage avec des traces Perfetto

  • Lorsque vous examinez les données, le premier outil que nous présentons aujourd'hui et que vous devez bien connaître est les traces Perfetto.

  • Les traces Perfetto enregistrent tout ce qui se passe sur l'appareil pendant l'enregistrement de la trace, par exemple lors de l'exécution d'un macrobenchmark ou d'un script que vous enregistrez.

  • Il est d'une grande aide pour tous les ingénieurs qui travaillent sur les performances d'Android.

  • En ce qui me concerne, je passe la majeure partie de ma journée de travail à examiner les traces Perfetto.

  • Ils ressemblent à ceci.

  • Cela représente beaucoup de données.

  • Comme je l'ai dit, ils capturent tout ce qui se passe sur un appareil.

  • Vous aurez peut-être du mal à trouver votre application au début. Je vous recommande donc d'utiliser cette barre de recherche pratique.

  • Recherchez Compose, recherchez votre package d'application, etc.

  • Concentrez-vous sur des choses familières.

  • Une fois que vous avez trouvé le processus de votre application, vous verrez les lignes de temps de rendu et le temps que prennent de nombreux éléments de votre application.

  • Si nous en isolons un, vous verrez quelque chose comme ceci.

  • Le premier est le travail du thread principal.

  • C'est là que Compose effectue la plupart de ses opérations.

  • Ensuite, il y a RenderThread.

  • C'est là que nous traitons la commande de canevas et que nous l'encodons pour le GPU.

  • Ensuite, le GPU fait son travail, puis le système compose le résultat.

  • Aujourd'hui, nous allons nous concentrer uniquement sur le thread principal.

  • En effet, c'est là que Compose effectue son travail.

  • Nous parlons de Compose, il est donc logique de nous concentrer uniquement sur ce point.

  • En isolant cette frame sur le thread principal, nous commençons à voir des tranches Compose.

  • J'en ai sélectionné quelques-uns pour vous.

  • C'est comme la composition, la mesure, la mise en page et le dessin.

  • Si vous avez déjà entendu parler des phases de Compose, vous les verrez probablement en examinant les performances.

  • Chacune de ces phases est représentée par une tranche individuelle dans une trace Perfetto. Il est donc très facile de savoir combien de temps prend chaque phase et quelles phases sont exécutées dans chaque frame.

  • Nous avons beaucoup parlé de l'optimisation de certaines de ces tâches, et vous devez vous assurer d'effectuer les bonnes tâches au bon moment.

  • Par exemple, si vous avez une animation qui met à jour la couleur de votre boîte ou quelque chose de similaire, vous ne voulez invalider que la phase de dessin, et non la composition, la mise en page, etc.

  • Il s'agit d'un cours d'introduction aux performances. Aujourd'hui, nous allons approfondir certains aspects, en nous concentrant en particulier sur la composition.

  • Je laisse maintenant la parole à Andrew, qui va nous expliquer tout cela.

  • Merci, Andrei.

  • Comme l'a mentionné Andrei, nous avons déjà beaucoup parlé des optimisations de performances, comme éviter les lectures vers l'arrière, s'assurer de différer les lectures d'état à la bonne phase du cycle de vie de la composition, et optimiser et s'assurer que vos composables sont invalidés comme prévu, afin de ne pas trop travailler sur chaque frame.

  • C'est un très bon point de départ, mais nous avons déjà beaucoup de documentation à ce sujet, et nous voulons aller un peu plus loin.

  • Aujourd'hui, nous allons nous intéresser à ce qu'il reste de la composition après ces optimisations, et à ce que font vos composables eux-mêmes.

  • Malheureusement, tout ce qui se trouve dans votre code Compose, et tout ce que vous fournissez avec votre application en termes de performances, a un coût.

  • Aucun code que vous intégrez à votre application n'est sans frais. Même des opérations aussi simples que la lecture d'un état et la composition d'un effet secondaire dans votre composition ont un coût tangible. Il est important d'en être conscient, en particulier lorsque vous commencez à analyser ce que font réellement vos composables et à optimiser les performances de votre application.

  • Pour vous montrer à quoi cela ressemble à l'échelle microscopique, voici quelques-unes des plus petites opérations que vous pouvez effectuer n'importe où dans votre code, et en particulier dans vos composables. À cette résolution, nous parlons de l'échelle de la nanoseconde en termes de coût approximatif de ces éléments.

  • Si vous souhaitez améliorer vos performances, deux options s'offrent à vous.

  • Vous pouvez supprimer un très grand nombre d'opérations peu coûteuses ou un plus petit nombre d'opérations plus coûteuses.

  • Sur ce graphique en particulier, les coroutines sont plus coûteuses que les autres opérations. C'est un bon exemple de point de départ, car si nous supprimons quelques-unes de ces instances, nous pouvons commencer à voir des avantages plus tangibles dans notre application plus rapidement.

  • Autre point à garder à l'esprit concernant les coroutines : comme indiqué sur la diapositive, le lancement d'une coroutine a un coût, mais l'annulation d'une coroutine en a un aussi.

  • Le coût d'annulation est particulièrement important, car il se produit principalement lorsque vous supprimez du contenu et que vous passez éventuellement à d'autres zones de votre application.

  • La navigation est un cas extrêmement coûteux dans de nombreuses applications où vous devez tirer le meilleur parti du temps de rendu et vous assurer que vous utilisez au mieux ce dont vous disposez.

  • Il est donc important de tenir compte de ces deux coûts.

  • Tous ces éléments, quel que soit l'aspect que nous examinons, vont s'additionner pour atteindre cette échelle d'environ 16 millions de nanosecondes.

  • Nous avons donc un budget à respecter, et les coroutines sont un bon exemple pour commencer.

Gérer les effets secondaires

  • Dans Compose, les coroutines apparaissent le plus souvent avec les API d'effets.

  • Il existe un éventail d'API que vous pouvez utiliser avec SideEffects, de la haute fonctionnalité à la faible latence.

  • Du côté le plus fonctionnel de ce spectre, nous avons LaunchedEffect, qui inclut tout ce qui est nécessaire au travail asynchrone, en particulier les animations, tout ce pour quoi vous avez besoin d'un champ d'application de coroutine.

  • Toutefois, si vous n'en avez pas besoin et que vous pouvez accomplir votre tâche avec une API moins coûteuse, il peut être intéressant d'envisager d'utiliser cette dernière si elle répond aux besoins de votre cas d'utilisation.

  • Pour vous montrer ce que cela donne en pratique, j'ai ici un composable de lecteur multimédia qui illustre tous les différents SideEffects et quand ils peuvent apparaître.

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val mediaPlayer = rememberMediaPlayer()
    
        // Asynchronous work and animations
        LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
    }
  • Commençons par un LaunchedEffect.

  • Dans cet exemple, il est utilisé pour charger des données de manière asynchrone dans le lecteur multimédia.

  • Si vous avez besoin d'un champ d'application de coroutine, ou si vous avez besoin de coroutines en général ou pour effectuer un appel de suspension, utilisez LaunchedEffect.

  • Même si elle reste plus coûteuse et qu'un coût est associé aux coroutines, tant que vous l'exploitez et que vous le justifiez, utilisez l'API adaptée à la tâche.

  • Toutefois, si vous vous trouvez dans une situation où vous utilisez un LaunchedEffect, mais que vous n'exploitez pas la coroutine, il peut être judicieux d'envisager d'autres API capables de faire la même chose, mais à moindre coût.

  • Pour cela, en descendant dans le spectre, nous pouvons voir d'autres exemples d'effets secondaires comme DisposableEffect.

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val activity = LocalActivity.current
    
        // Setup and teardown
        DisposableEffect(Unit) {
            activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
            onDispose {
                activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
            }
        }
    }
  • DisposableEffect est idéal lorsque vous avez un processus de configuration et de suppression associé à votre effet.

  • Ici, dans le même exemple de lecteur multimédia, j'ai un exemple qui verrouille l'orientation de l'activité en mode Paysage lorsque le composable est présent, puis qui déverrouille cette orientation lorsqu'il disparaît un peu plus tard.

  • Si vous souhaitez obtenir l'effet ponctuel de LaunchedEffect et DisposableEffect, mais que vous n'avez pas besoin de coroutines et que vous n'avez pas d'effet de suppression, vous pouvez également utiliser la nouvelle API SideEffect avec des clés que nous avons introduite dans Compose 1.12.

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val mediaPlayer = rememberMediaPlayer()
    
        // One-shot events
        SideEffect(Unit) { mediaPlayer.play() }
    }
  • Il s'agit d'une excellente alternative aux deux autres API si vous les avez utilisées, soit LaunchedEffect par commodité, soit DisposableEffect par nécessité pour un effet ponctuel. La navigation ou la migration vers les API SideEffect avec des clés peut accomplir la même chose avec moins de frais généraux et de meilleures performances, en particulier si la migration est effectuée plusieurs fois sur l'ensemble d'une application.

  • Enfin, l'API la moins chère est l'API SideEffect standard.

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val mediaPlayer = rememberMediaPlayer()
    
        // Synchronize the outside world to the state of composition
        SideEffect { mediaPlayer.playbackRate = playbackRate }
    }
  • Cette approche est particulièrement adaptée lorsque vous souhaitez synchroniser l'état extérieur du monde avec ce qui se passe dans la composition.

  • Ici, toujours dans l'exemple du lecteur multimédia, j'ai un autre exemple qui montre la synchronisation d'un état de lecture avec un paramètre reçu par composition.

  • Si vous décidez d'auditer vos utilisations d'effets secondaires et de migrer vers un effet secondaire moins coûteux, n'oubliez pas qu'en plus des autres comportements et cas d'utilisation dont nous avons parlé, il existe également une différence dans les garanties de classement avec les effets secondaires.

  • Tous les effets jetables sont exécutés en premier, suivis des effets secondaires standards, avec ou sans clé. Une fois l'image terminée, tous les effets lancés sont exécutés, car une distribution est impliquée.

  • Si vous dépendez de ce timing pour vos effets lancés, gardez cela à l'esprit si vous envisagez de migrer.

  • Pour toute migration, veillez à tester votre code et à vous assurer que vous ne dépendez d'aucune de ces garanties de délai, et que votre code fonctionne toujours après la migration.

  • Si vous ne pouvez pas réduire la complexité de l'effet secondaire lui-même, vous pouvez également réduire le nombre d'effets secondaires en les fusionnant.

    @Composable
    fun ShoppingCartScreen(
        viewModel: CartViewModel
    ) {
        LaunchedEffect(Unit) { viewModel.loadCart() }
        LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
        LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }
    
        CartContent(...)
    }
  • C'est particulièrement utile si vous avez plusieurs effets qui utilisent la même clé.

  • Il est judicieux d'envisager de les fusionner, car ils peuvent s'exécuter en même temps.

  • Dans cet exemple, j'ai plusieurs effets lancés qui effectuent chacun un appel asynchrone dans un modèle de vue.

  • Si je les remplace par un seul LaunchedEffect, puis par d'autres appels de lancement, même si j'ai toujours le même nombre de coroutines en cours d'exécution, la surcharge est moindre, car le travail est effectué dans des coroutines plutôt que dans la composition.

    @Composable
    fun ShoppingCartScreen(
        viewModel: CartViewModel
    ) {
        LaunchedEffect(Unit) {
            launch { viewModel.loadCart() }
            launch { viewModel.loadPaymentMethods() }
            launch { viewModel.loadDeliveryAddresses() }
        }
    
        CartContent(...)
    }
  • Il est donc judicieux d'envisager de migrer dans de telles situations pour réduire le nombre d'effets lorsque cela est possible.

  • Les effets et les coroutines ne sont qu'un exemple d'élément que vous pouvez auditer dans votre application. Chaque composable et chaque application sont différents et offrent des opportunités différentes.

  • Pour comprendre ce qui est pertinent pour votre application, nous devons parler du prochain outil qu'Andrei va présenter, à savoir le traçage de la composition.

Traçage de la composition et de la recomposition

  • Très bien.

  • Si vous regardez cette reproduction fidèle du frame Perfetto que nous avons vu précédemment sur une autre diapositive, vous constaterez que le traçage de la composition nous fournit des informations temporelles avec différentes phases, mais pas vraiment beaucoup de détails sur ce qui se passe.

  • Quoi ?

  • Oui, ce qui se passe dans la composition des fonctions composables exécutées.

  • Le traçage de la composition nous aide à le faire.

  • Nous introduisons donc des marqueurs dans le compilateur Compose qui annotent chaque fonction et la rendent visible dans la trace Perfetto. Vous pouvez ainsi vérifier le temps d'exécution de chaque fonction.

  • Vous pouvez l'utiliser pour déterminer exactement quelles fonctions sont recomposées.

  • Toutefois, cela entraîne un certain surcoût. Par conséquent, si vous souhaitez capturer les données sur les performances les plus précises, vous devez utiliser Perfetto standard ou simplement l'exécuter sans les captures Perfetto et utiliser d'autres métriques.

  • Mais le traçage de composition est vraiment utile si vous souhaitez déterminer le temps nécessaire à chaque composable individuel.

  • Parfois, vous voulez aussi comprendre pourquoi les éléments sont recomposés. C'est ce que nous essayons de résoudre dans la prochaine version du traçage d'exécution Compose Tracing, ou plutôt la version 1.13.

  • Nous lançons maintenant le traçage de la recomposition, qui annote chaque lecture et écriture d'état ayant entraîné une recomposition.

  • Vous obtiendrez donc quelque chose comme ceci dans votre trace Perfetto, où vous verrez chacune des écritures d'état et les piles d'appels où l'écriture et la lecture se produisent. Vous pourrez ainsi les faire correspondre aux lignes réelles de votre code où ces états ont été consultés ou modifiés.

  • Pour en savoir plus, consultez le lien suivant.

  • Elle contient toute la documentation sur le traçage de composition et de recomposition.

  • Je laisse maintenant la parole à Andrew, qui va vous expliquer comment utiliser cette fonctionnalité plus efficacement pour éviter les écritures d'état.

Optimisation de l'état et invalidations

  • Merci, Andrei.

  • En fonction des résultats de cette investigation, vous découvrirez peut-être que votre composition contient des invalidations qui ne sont pas utiles pour votre application et qui auraient pu être évitées dès le départ.

  • Par exemple, l'exemple le plus courant que j'ai est un bouton de défilement vers le haut dont la visibilité dépend de la visibilité ou de la progression du défilement.

    @Composable
    fun ScrollToTopButton(
        scrollState: ScrollState
    ) {
        val showScrollToTopButton = scrollState.value > 0
        val coroutineScope = rememberCoroutineScope()
    
        if (showScrollToTopButton) {
            FloatingActionButton(
                onClick = {
                    coroutineScope.launch { scrollState.animateScrollTo(0) }
                }
            ) {
                Icon(Icons.Default.ArrowUpward, "Scroll to Top")
            }
        }
    }
  • Ici, comme je lis la valeur d'un état de défilement dans la composition, il s'agit d'un état qui change très fréquemment, potentiellement une fois par frame. Cela va entraîner de nombreuses recompositions qui pourraient être évitées, car même si l'état de défilement lui-même change, la condition réelle qui nous intéresse ici ne change pas si souvent.

  • Dans ce cas, nous vous recommandons d'utiliser un état calculé, qui peut éviter les invalidations pour les changements d'état qui n'ont pas d'importance pour votre application.

  • Dans cet exemple, j'ai encapsulé le calcul lui-même dans un état calculé. Lorsque nous lisons cet état, au lieu d'invalider en fonction de la position de défilement elle-même, il invalide ce composable de manière conditionnelle en fonction de l'ensemble de la condition et le dérive de l'autre état lui-même.

    @Composable
    fun ScrollToTopButton(
        scrollState: ScrollState
    ) {
        val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
        val coroutineScope = rememberCoroutineScope()
    
        if (showScrollToTopButton) {
            FloatingActionButton(
                onClick = {
                    coroutineScope.launch { scrollState.animateScrollTo(0) }
                }
            ) {
                Icon(Icons.Default.ArrowUpward, "Scroll to Top")
            }
        }
    }
  • Si vous avez déjà vu ce modèle, il est probable que vous l'ayez vu utilisé avec un état dérivé.

  • ComputedState est une nouveauté de Compose 1.13. Bien que ComputedState et DerivedState aient tous deux un objectif similaire (invalider de manière conditionnelle lorsque leur calcul change), ils présentent une différence très subtile dans la façon dont ils le font.

  • DerivedState, que nous proposons depuis le lancement de Compose, met en cache la valeur qu'il calcule et la réutilise jusqu'à ce que ses dépendances changent.

  • Avec ComputedState, la valeur est calculée chaque fois que vous la lisez.

  • La mise en cache semble utile, mais dans de nombreuses situations, elle est en réalité plus coûteuse que la simple lecture du résultat.

  • Pour comprendre comment cela se passe, nous pouvons examiner un exemple d'utilisation de ces états dans votre application et analyser ce que chacun d'eux fait réellement.

  • Ici, j'ai le même état qui reflète à peu près ce que nous faisions dans le bouton de défilement vers le haut, où il y a une condition basée sur un autre état, et je l'ai à la fois comme DerivedState et ComputedState.

    var a by mutableStateOf(0)
    
    val derivedState = derivedStateOf { a > 0 }
    val computedState = computedStateOf { a > 0 }
    
    // First read
    derivedState.value
    computedState.value
    
    // Second read
    derivedState.value
    computedState.value
  • Je lis également l'état deux fois et j'obtiens la même valeur les deux fois, mais ce qui se passe en arrière-plan est différent entre les deux.

  • Pour simplifier une trace de ce qui s'afficherait réellement ici dans un outil comme Perfetto, nous pouvons examiner ce qui se passe réellement lorsque vous lisez ces deux états.

  • Ici, nous commençons par appeler computedState.value.

  • Il exécute le lambda de calcul, qui lit ensuite la valeur de l'état. Il y a également un peu de maintenance pour simplement notifier que la lecture a eu lieu.

  • Cela s'applique à tous les états d'instantané dans Compose.

  • La deuxième lecture se comporte exactement de la même manière que la première et présente les mêmes caractéristiques de performances.

  • En comparaison, la toute première lecture de DerivedState fait quelque chose de similaire, où il doit toujours appeler le lambda de calcul et effectue toujours l'autre notification, mais il y a un peu de surcharge de gestion du cache, qui est un peu court-circuitée la première fois, car il n'y a pas de cache à vérifier.

  • Il effectue donc un peu de maintenance pour la mise en cache elle-même, puis accomplit la même chose en un peu plus de temps.

  • En revanche, la deuxième lecture de DerivedState est plus rapide que la deuxième lecture de ComputedState, même s'il s'agit de deux états assez simples.

  • Comme je l'ai dit dans les diapositives précédentes, le coût d'une lecture d'instantané n'est pas négligeable dans certains cas, et la lecture à partir du cache est plus rapide que la lecture elle-même. C'est pourquoi nous constatons ce très léger gain pour la deuxième lecture elle-même pour DerivedState.

  • Mais le scénario le plus intéressant est celui où l'état doit être invalidé.

  • C'est ce que nous appelons le scénario de lecture après écriture, dans lequel vous mettez à jour l'un des états utilisés dans le calcul, et le calcul doit s'exécuter à nouveau.

  • Dans le code, cela signifie que nous modifions "a", par exemple en l'incrémentant, puis que nous lisons à nouveau la valeur ou que nous recomposons.

    var a by mutableStateOf(0)
    
    val derivedState = derivedStateOf { a > 0 }
    val computedState = computedStateOf { a > 0 }
    
    ...
    
    // Read-after-write
    a++
    derivedState.value
    computedState.value
  • Dans le diagramme, cela se présente exactement de la même manière pour ComputedState.

  • Il n'a pas de cache à gérer. Le résultat est exactement le même, qu'il s'agisse de la première, de la deuxième, de la troisième ou de n'importe quelle autre lecture.

  • DerivedState, en revanche, a du pain sur la planche.

  • Il passe par un chemin très coûteux pour voir que le cache n'est pas valide avant même de mettre à jour et d'appeler le calcul lui-même. Il doit ensuite suivre les dépendances et gérer de nombreuses observations pour réécrire la valeur du cache, comprendre que toutes ces valeurs ont réellement changé et gérer la maintenance elle-même.

  • Tout cela s'additionne et rend le coût d'invalidation d'un DerivedState très élevé par rapport à un ComputedState.

  • Pour illustrer ces affirmations par des chiffres, ComputedState est généralement plus rapide d'environ 200 ns lors de la première lecture.

  • ComputedState perd environ 350 ns sur un Pixel 6, en particulier dans des conditions d'analyse comparative spécifiques.

  • Ainsi, lors de la deuxième lecture, ComputedState sera légèrement moins performant que DerivedState.

  • Mais dans le cas qui nous intéresse, celui où nous invalidons et probablement celui où vous avez réellement du travail à faire sur votre frame, ComputedState est presque deux fois plus rapide que DerivedState.

  • Ainsi, selon l'utilisation que vous faites de vos états, ComputedState peut être un très bon premier choix.

  • En général, il faut toujours faire un choix entre DerivedState et ComputedState.

  • Nous ne supprimons pas ComputedState, car les deux ont encore des cas d'utilisation.

  • En général, si vous ne savez pas lequel choisir, l'état calculé est une bonne option par défaut.

  • L'état dérivé est particulièrement utile lorsque vous profitez de la mise en cache, c'est-à-dire lorsque vous effectuez une opération non triviale, comme un algorithme complexe, une allocation importante, de nombreuses lectures d'état qui bénéficient de la mise en cache, ou toute opération non triviale ou qui dépend d'éléments tels que l'égalité des objets et l'égalité de l'identité des objets, où vous compensez la différence et bénéficiez des avantages de la mise en cache qu'offre l'état dérivé.

Ignorer et stabilité dans Compose

  • L'autre sujet important que je souhaite aborder est que l'état dérivé et l'état calculé essaient tous deux d'éviter d'invalider le composable lui-même.

  • C'est différent d'ignorer un composable, ce dont nous parlons normalement lorsque nous pensons à l'invalidation conditionnelle ou à l'absence de travail dans un composable.

  • La vérité que je souhaite exposer ici est que, même si l'on parle beaucoup de stabilité et d'omission dans Compose, ce n'est pas la solution ultime et ce n'est pas toujours la bonne chose à optimiser.

  • Comme nous l'avons déjà dit, tout ce qui se trouve dans la composition est associé à des coûts, y compris l'omission.

    // What you write
    @Composable fun ContactListItem(
        contact: Contact
    ) { ... }
    
    // What you get
    @Composable fun ContactListItem(
        contact: Contact,
        $composer: Composer?
        $changed: Int
    ) {
        val $dirty = $changed or
            $composer.changed(contact)
    
        if ($dirty) { /* Your function body */ }
        else $composer.skipToGroupEnd()
    }
  • Ignorer n'est pas forcément simple.

  • Il y a beaucoup de maintenance à faire pour l'activer.

  • Nous devons stocker tous vos paramètres et leur trouver de l'espace lors de la composition initiale, puis mettre à jour cet enregistrement lorsque vous recomposez.

  • Nous devons également comparer vos valeurs à celles d'avant pour déterminer si votre composable doit être ignoré ou non.

  • Ensuite, tout cela n'est que la puissance d'une vérification "if" qui ignore ou non le code.

  • Cela peut s'avérer coûteux selon ce qui se passe ici.

  • Le compilateur est optimisé en arrière-plan. Si vous transmettez le même paramètre, nous pouvons réutiliser les vérifications d'égalité, mais ce n'est pas une solution miracle et il est assez facile de la contourner.

  • Pour voir un exemple où l'omission nous trahit un peu, nous pouvons examiner un exemple très classique de liste déroulante qui contient, et c'est important, un objet volumineux contenant toutes les données à composer à l'écran.

    data class ContactsModel(
        val header: String,
        val contacts: List<Contact>
    )
    
    data class Contact(...)
    
    @Composable
    fun ContactsScreen(model: ContactsModel) {
        Column {
            TopAppBar(title = { Text(model.header) })
            ContactsList(model.contacts)
        }
    }
    
    @Composable
    fun ContactsList(contacts: List<Contact>) {
        LazyColumn {
            items(contacts) { ContactListItem(it) }
        }
    }
    
    @Composable
    fun ContactListItem(contact: Contact) { ... }
  • Ici, le modèle d'écran contient un écran d'en-tête et la liste des contacts, qu'il transmet ensuite à quelques composables qui appellent une liste différée, puis les affichent à l'écran avec des cellules d'éléments.

  • La hiérarchie de composition de ce code ressemble à ceci : nous commençons à nouveau par l'écran de contact, nous l'étendons un peu, puis nous avons d'autres composables qui appellent et présentent nos éléments.

  • Si nous examinons la façon dont nos données sont transmises, nous commençons par le modèle en haut.

  • Nous la déstructurons en une chaîne, puis en une liste. Cette liste est ensuite convertie en lambda d'éléments pour la liste différée, qui composera tous les éléments qui s'affichent à l'écran.

  • Voyons ce qui se passe lorsque nous modifions le tout dernier contact de cette liste.

  • Nous voulons simplement invalider le dernier élément de la liste, mais ce n'est pas ce qui va se passer ici.

  • Si nous examinons une trace de composition pour cette recomposition, elle ressemblera à ceci : nous commençons par l'écran de contact, puis nous descendons jusqu'à cet élément de liste.

  • Et c'est un moyen assez coûteux de descendre ici.

  • Nous pouvons également voir qu'il y a toutes ces sections où il semble que rien ne se passe dans la trace.

  • Même si ce n'est pas évident ici, c'est à ce moment-là que les composables décident s'ils vont être ignorés ou non.

  • Dans cet exemple, seuls deux composables sont ignorés en raison de la façon dont la liste différée se recompose.

  • Lorsque vous lui attribuez un nouveau lambda de contenu, il invalide tous les éléments dont il dispose.

  • Il invalidera donc les deux contacts qui n'ont pas changé, en plus de celui qui a changé. Ces deux contacts sont les seuls éléments qui seront ignorés.

  • Tout le reste effectue cette comparaison d'égalité ignorée, pour se rendre compte qu'il doit en fait s'exécuter.

  • Si vos objets sont stables, cette comparaison peut s'avérer assez coûteuse.

  • Si vous nous forcez à parcourir toutes vos structures de données, cela peut s'accumuler et représenter un coût non négligeable, juste pour déterminer si deux objets sont égaux.

  • Pire encore, comme nous avons déstructuré, nous devons effectuer deux fois la même comparaison sur la liste.

  • Si vous laissez le compilateur se comporter par défaut et que vous utilisez le mode de saut renforcé, vous pouvez toujours ignorer des éléments et vérifier si vous pouvez ignorer vos composables de manière conditionnelle, mais vous empruntez un chemin beaucoup plus rapide en utilisant l'égalité d'identité d'objet au lieu de l'égalité structurelle.

  • Le calcul est beaucoup plus rapide. Même si nous recomposons les mêmes éléments sur ce frame, où il est important d'accéder rapidement, nous ne perdons pas trop de temps à déterminer que nous ne pouvons pas ignorer ces composables.

  • C'est également pourquoi nous insistons sur l'utilisation d'outils tels que les benchmarks et les traces pour comprendre ce qui se passe dans votre application, plutôt que de compter directement les recompositions.

  • Ils peuvent être utiles, mais vous ne devez pas nécessairement vous focaliser dessus ni les surindexer lors de l'optimisation de vos applications.

  • N'oubliez pas qu'au lieu d'essayer d'ignorer votre composable, vous devez éviter de l'invalider.

Analyser l'exécution avec des traces de méthode

  • Dans cet exemple, nous avons vu que les traces de composition peuvent fournir de nombreuses informations sur ce qui se passe, mais qu'elles ne montrent pas nécessairement l'ensemble de la situation.

  • Pour cela, Andrei va nous parler des traces de méthode.

  • Très bien.

  • Comme Andrew l'a déjà dit, le traçage de composition ne vous dit pas toujours tout ce qui se passe, car il n'annote que les fonctions composables. Il doit y avoir des cas où vous ne voyez qu'un espace vide dans la trace de composition qui augmente déjà la trace Perfetto, mais elle ne montre toujours pas tout ce qui s'y passe.

  • Parfois, il peut s'agir d'une action de Compose, parfois d'un élément de votre code. Il est très difficile de le savoir en examinant simplement la trace de composition ici.

  • Vous devez maintenant récupérer la trace de méthode.

  • Il existe un élément dans le profileur d'Android Studio, mais vous pouvez également utiliser des commandes ADB pour récupérer la trace. Vous obtiendrez ainsi chaque appel de fonction qui se produit dans l'environnement Java.

  • Il s'agit de tout ce que le compilateur Compose encode, de tout ce que l'environnement d'exécution Compose fait et de tout ce que votre code fait. C'est très utile pour suivre et examiner ce qui s'exécute exactement, et quelles fonctions ont été appelées dans cet espace.

  • Une chose, je veux... voir cet élément au milieu.

  • Je tiens à souligner que le traçage de méthode entraîne une surcharge très élevée.

  • Vous avez 10 à 20 fois plus de frais généraux, selon la complexité de l'arborescence des fonctions, car chaque appel de fonction ajoute un peu de complexité.

  • Pour illustrer cela, nous pouvons regarder la capture d'écran.

  • Voici à quoi ressemble généralement la trace de méthode pour le démarrage de l'application. Vous vous demandez peut-être comment l'interpréter.

  • Pour commencer, vous examinez généralement la trace de composition et les fonctions qui s'y trouvent.

  • Il s'agit d'une trace de composition simplifiée, avec ScreenContent, un bouton et d'autres fonctions.

  • Imaginons que nous voulons comprendre ce qui se passe dans ScreenContent.

  • Eh bien, Perfetto dispose d'une barre de recherche.

  • Vous chargez votre trace de méthode dans le lecteur Perfetto, vous recherchez la même fonction que celle que vous avez ici, ScreenContent, et vous obtenez quelque chose comme ça, qui est la trace de méthode pour l'écran.

  • Comme vous pouvez le voir, il y a des appels Composer.startGroup et des appels de groupe.

  • C'est ce que les environnements d'exécution et le compilateur font pour vous en arrière-plan pour s'assurer que votre composition fonctionne, mais l'appel intéressant ici est Composer.changed.

  • C'est ainsi que nous vérifions si les paramètres ont changé entre les appels précédents et l'appel actuel du composable.

  • La majorité de ces Composer.changed est dominée par ce ScreenModel.equals.

  • Peut-être que ScreenModel est une sorte de classe de big data, et que nous l'avons rendu stable pour faciliter le décompte des recompositions, donc peut-être qu'il est apparu dans le rapport du compilateur.

  • Supprimer cette annotation stable pourrait améliorer les performances dans ce cas particulier.

  • Vous devez donc rechercher cela, et les traces de méthode de composition sont le meilleur outil pour y parvenir.

  • Très bien.

  • Nous avons vu beaucoup de choses aujourd'hui. Je laisse maintenant la parole à Andrew pour conclure.

Outils de conception et de performances rapides pour Compose

  • Pour conclure et résumer tout ce dont nous avons parlé aujourd'hui, voici quelques points à garder à l'esprit lorsque vous concevez des composables et optimisez vos applications existantes.

  • Tout d'abord, n'oubliez pas que les composables moins coûteux et ceux que vous n'invalidez pas sont préférables à ceux que vous ignorez.

  • N'oubliez pas que lorsque vous concevez des composables, il est préférable d'envisager des arguments plus petits et plus faciles à comparer, et éventuellement de réutiliser les comparaisons avec d'autres appels de fonction pour profiter de l'optimisation.

  • Lorsque vous utilisez des API dans vos composables, n'oubliez pas que les performances ou le temps de rendu dépendent du coût des API elles-mêmes et de la fréquence à laquelle vous les utilisez.

  • Soyez attentif à la façon dont ces coûts s'additionnent, et gardez à l'esprit qu'ils peuvent être associés à des coûts de suppression que vous pourriez rencontrer au moment le moins opportun pour votre application.

  • N'oubliez pas non plus qu'il est important d'être un bon citoyen lorsque vous effectuez des optimisations dans votre application.

  • Commencez par effectuer des analyses comparatives pour prendre des décisions éclairées, profilez votre application pour vous assurer que vous ciblez les bonnes zones de votre code, et vérifiez que vous apportez les modifications et que vous obtenez les résultats attendus à partir des modifications que vous apportez à la suite des analyses comparatives et du profilage.

  • N'oubliez pas non plus qu'il peut y avoir un compromis à faire entre la facilité de maintenance et les performances de votre application.

  • Faites donc ce qui est le mieux pour vous et votre équipe afin de ne pas trop optimiser et de ne pas vous retrouver dans une impasse en termes de facilité de maintenance.

  • Pour obtenir de l'aide supplémentaire, vous disposez d'un autre outil : l'agent IA que vous avez déjà.

  • Nous avons lancé, dans le cadre de la CLI Android et des compétences que nous y proposons, une compétence de performances Jetpack Compose qui contient une grande partie des informations dont nous avons parlé aujourd'hui. Elle peut examiner et auditer votre application, et faire des suggestions pour ce type d'optimisations.

  • Vous pouvez l'installer dans votre agent existant à l'aide de la CLI Android.

    android skills add jetpack-compose-performance
  • Pour en savoir plus sur la CLI Android, consultez developer.android.com.

  • Vous pouvez également en savoir plus sur nos compétences sur notre dépôt GitHub.

  • Et voilà ! Bon benchmarking et bonne composition !

Code

Extraits de code

Extrait de code Kotlin à 09:34 dans "Managing side effects"
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val mediaPlayer = rememberMediaPlayer()

    // Asynchronous work and animations
    LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
}
Extrait de code Kotlin à 10:15 dans "Gérer les effets secondaires"
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val activity = LocalActivity.current

    // Setup and teardown
    DisposableEffect(Unit) {
        activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
        onDispose {
            activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
        }
    }
}
Extrait de code Kotlin à 10:36 dans "Gérer les effets secondaires"
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val mediaPlayer = rememberMediaPlayer()

    // One-shot events
    SideEffect(Unit) { mediaPlayer.play() }
}
Extrait Kotlin à 11:12 dans "Gérer les effets secondaires"
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val mediaPlayer = rememberMediaPlayer()

    // Synchronize the outside world to the state of composition
    SideEffect { mediaPlayer.playbackRate = playbackRate }
}
Extrait Kotlin à 12:12 dans "Gérer les effets secondaires"
@Composable
fun ShoppingCartScreen(
    viewModel: CartViewModel
) {
    LaunchedEffect(Unit) { viewModel.loadCart() }
    LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
    LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }

    CartContent(...)
}
Extrait Kotlin à 12:36 dans "Gérer les effets secondaires"
@Composable
fun ShoppingCartScreen(
    viewModel: CartViewModel
) {
    LaunchedEffect(Unit) {
        launch { viewModel.loadCart() }
        launch { viewModel.loadPaymentMethods() }
        launch { viewModel.loadDeliveryAddresses() }
    }

    CartContent(...)
}
Extrait de code Kotlin à 15:12 dans "Optimisation de l'état et invalidations"
@Composable
fun ScrollToTopButton(
    scrollState: ScrollState
) {
    val showScrollToTopButton = scrollState.value > 0
    val coroutineScope = rememberCoroutineScope()

    if (showScrollToTopButton) {
        FloatingActionButton(
            onClick = {
                coroutineScope.launch { scrollState.animateScrollTo(0) }
            }
        ) {
            Icon(Icons.Default.ArrowUpward, "Scroll to Top")
        }
    }
}
Extrait de code Kotlin à 15:51 dans "State optimization and invalidations"
@Composable
fun ScrollToTopButton(
    scrollState: ScrollState
) {
    val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
    val coroutineScope = rememberCoroutineScope()

    if (showScrollToTopButton) {
        FloatingActionButton(
            onClick = {
                coroutineScope.launch { scrollState.animateScrollTo(0) }
            }
        ) {
            Icon(Icons.Default.ArrowUpward, "Scroll to Top")
        }
    }
}
Extrait Kotlin à 16:49 dans "Optimisation de l'état et invalidations"
var a by mutableStateOf(0)

val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }

// First read
derivedState.value
computedState.value

// Second read
derivedState.value
computedState.value
Extrait Kotlin à 18:35 dans "State optimization and invalidations"
var a by mutableStateOf(0)

val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }

...

// Read-after-write
a++
derivedState.value
computedState.value
Extrait de code Kotlin à 21:07 dans "Skipping and stability in Compose"
// What you write
@Composable fun ContactListItem(
    contact: Contact
) { ... }

// What you get
@Composable fun ContactListItem(
    contact: Contact,
    $composer: Composer?
    $changed: Int
) {
    val $dirty = $changed or
        $composer.changed(contact)

    if ($dirty) { /* Your function body */ }
    else $composer.skipToGroupEnd()
}
Extrait de code Kotlin à 21:53 dans "Skipping and stability in Compose"
data class ContactsModel(
    val header: String,
    val contacts: List<Contact>
)

data class Contact(...)

@Composable
fun ContactsScreen(model: ContactsModel) {
    Column {
        TopAppBar(title = { Text(model.header) })
        ContactsList(model.contacts)
    }
}

@Composable
fun ContactsList(contacts: List<Contact>) {
    LazyColumn {
        items(contacts) { ContactListItem(it) }
    }
}

@Composable
fun ContactListItem(contact: Contact) { ... }
Extrait de code shell à 29:40 dans "Fast composable design and performance tools"
android skills add jetpack-compose-performance