Herramientas y rendimiento

Supérate en Compose

Bocinas

Acerca de

Acerca de la charla

Jetpack Compose facilita el diseño de interfaces de usuario de alta calidad, pero puede ser difícil detectar problemas que afecten el rendimiento de la IU. Estos tipos de problemas pueden provocar inconvenientes que los usuarios noten, como latencia de inicio y fotogramas perdidos que aparecen como jank de desplazamiento o animaciones entrecortadas.

En esta charla, presentaremos herramientas para descubrir qué afecta el rendimiento de tu IU, con un enfoque en Perfetto y el seguimiento de (re)composición. Luego, exploraremos antipatrones específicos que podrías estar usando y que te están costando dinero en secreto, cómo encontrarlos en tu código y con qué patrones positivos puedes reemplazarlos. Al final de esta charla, comprenderás cómo pensar en Compose con una mentalidad de rendimiento y cómo generar perfiles, analizar y verificar las optimizaciones de rendimiento en tu app.

Resumen

  • Los registros de Perfetto registran la actividad en el dispositivo y muestran porciones de Compose, como la composición y el diseño, en el subproceso principal. Úsalas para inspeccionar los cronogramas de fotogramas y hacer un seguimiento de las duraciones de las fases.
  • Cada operación dentro de los elementos componibles tiene un costo, incluido el inicio y la cancelación de corrutinas. Audita las APIs de efectos secundarios y considera combinar los efectos con claves idénticas para reducir la sobrecarga de composición.
  • El seguimiento de la composición anota las funciones de componibilidad, y el seguimiento de la recomposición (1.13 y versiones posteriores) hace un seguimiento de las lecturas y escrituras de estado que causan la recomposición. Ayudan a diagnosticar el trabajo inesperado directamente en los seguimientos de Perfetto.
  • ComputedState (Compose 1.13 y versiones posteriores) evita las invalidaciones no deseadas y, por lo general, es más rápido que DerivedState durante las invalidaciones de lectura después de escritura. DerivedState sigue siendo útil cuando los cálculos o las asignaciones costosos se benefician del almacenamiento en caché.
  • Omitir elementos componibles genera costos de mantenimiento y sobrecarga de comparación de parámetros. Confía en la omisión sólida y la igualdad de identidad de objetos, o evita las invalidaciones por completo, en lugar de forzar la estabilidad.

Habilidades

Recursos

Gemini Notebook

Temas

  • jetpack-compose
  • android-performance
  • perfetto
  • comparativas
  • corrutinas
  • composition-tracing
  • android-cli

Transcripción

Descargar transcripción

Introducción

  • Hola.

  • Hola a todos.

  • Espero que hayas disfrutado la pausa.

  • Espero que hayas tomado café, que te hayas hidratado y todo eso.

  • Hoy profundizaremos un poco más.

  • Bueno, sí, aquí vamos.

  • Hoy profundizaremos un poco más.

  • Soy Andrei Shikov y hoy comparto el escenario con Andrew Bailey.

  • Ambos trabajamos en un equipo de entorno de ejecución del compilador de Compose, pero también en muchas otras cosas, incluido el rendimiento.

  • Y el tema de la charla de hoy es responder tus preguntas, preguntas sobre el rendimiento, porque Jetpack Compose se estableció como un kit de herramientas de IU predeterminado para el desarrollo de Android.

  • Y, ahora, muchas personas preguntan cómo podemos asegurarnos de que nuestra IU de Compose tenga el mejor rendimiento posible.

  • Así que hoy, como ya dije, analizaremos en profundidad.

  • También presentaremos algunas herramientas para que te superes en Compose.

  • Primero, quiero hablar sobre por qué el rendimiento es importante para nosotros.

  • Cuando piensas en la calidad y en crear apps de calidad, sueles pensar en animaciones, en un conjunto de funciones enriquecido y, a veces, se te escapa el rendimiento, pero la percepción del usuario realmente depende del rendimiento de tu app.

  • Por lo tanto, si las animaciones se entrecortan, la percepción disminuye de inmediato.

  • Por lo tanto, todas las métricas y los números que mencionaremos hoy son, en realidad, métricas de proxy que seleccionamos para representar cómo se sienten los usuarios con respecto a la app, a todas esas animaciones y a todo lo que sucede en la pantalla.

  • Por lo tanto, optimizar tu app es, básicamente, optimizarla para ofrecer una mejor experiencia.

  • Por lo tanto, esta es una mentalidad que quiero que tengas mientras profundizamos en muchos temas hoy.

  • Ahora, cuando comiences a analizar el rendimiento de tu app, quiero que hagas tres cosas rápidas, tres cosas muy rápidas.

  • En primer lugar, si mides el rendimiento, lo haces en el modo de lanzamiento, por lo que no tenemos mucha información depurable y todo esto ralentiza nuestra app.

  • En segundo lugar, habilitas R8 o algún otro minificador.

  • Este año, presentamos una optimización que mejora el rendimiento de las corrutinas al doble, y también hacemos muchas otras cosas.

  • Nos apoyamos mucho en R8 cuando optimizamos Compose.

  • Por último, en especial para las métricas de inicio, debes asegurarte de usar el perfil de Baseline.

  • Por lo tanto, las clases que cargas en el inicio se precompilan y precargan cuando se inicia la app.

  • Como puedes ver, esto es como una diferencia de 6 veces con respecto a la compilación depurable habitual de la app.

  • Y, a medida que midas todos esos aspectos, se sumarán a los otros logros que obtengas en tu código.

  • Ahora bien, si hiciste todo eso y la app aún se retrasa un poco o hay otros problemas de rendimiento, aquí es donde debes entrar en este bucle de rendimiento.

  • Quieres comenzar a analizar algunas métricas en situaciones específicas.

  • Por ejemplo, supongamos que tu app tarda demasiado en cargarse, por lo que tal vez quieras restablecerla, iniciarla desde el selector habitual y verificar cuánto tarda en llegar a la primera pantalla de datos.

  • O tal vez la app se traba un poco, por lo que te desplazas por ella y te aseguras de que este desplazamiento sea repetible y estable.

  • Por lo tanto, el entorno estable que se muestra en esta diapositiva es muy importante, ya que, cuando realices optimizaciones más adelante, querrás asegurarte de ver mejoras en tus métricas y de confirmar que esas optimizaciones realmente funcionan según lo previsto.

  • Las comparativas son muy buenas para esto, ya que capturan muchos datos y realizan muchas iteraciones para asegurarse de que no haya tanto ruido en tu dispositivo. Además, realizan muchas configuraciones del dispositivo que te ayudan.

  • Además, las comparativas también son situaciones repetibles de forma predeterminada, y esto es lo que buscamos aquí.

  • Pero también puedes hacerlo con un montón de secuencias de comandos si lo deseas.

  • Y lo último, generas un perfil, mejoras algo en tu app, miras los datos y, luego, repites el proceso, y tal vez lo repites con diferentes métricas de situaciones en las que tu app tiene dificultades.

Generación de perfiles con seguimientos de Perfetto

  • Cuando analices los datos, la primera herramienta que presentamos hoy y con la que debes familiarizarte son los seguimientos de Perfetto.

  • Los registros de Perfetto registran todo lo que sucede en el dispositivo mientras se realiza el registro, por ejemplo, durante la ejecución de Macrobenchmark o tal vez alguna secuencia de comandos que estés registrando.

  • Y es de gran ayuda para todos los ingenieros que trabajan en el rendimiento de Android.

  • En mi caso, paso la mayor parte de mi día de trabajo mirando los registros de Perfetto.

  • Se ven más o menos así.

  • Son muchos datos.

  • Como dije, capturan todo lo que sucede en un dispositivo.

  • Por lo tanto, es posible que al principio te cueste encontrar dónde está exactamente tu app, por lo que te recomiendo que uses esta práctica barra de búsqueda.

  • Busca Compose, el paquete de tu app y cosas por el estilo.

  • Solo concéntrate en cosas familiares.

  • A medida que te familiarices con el proceso de tu app, verás las líneas de latencia de fotogramas y cuánto tardan muchas de las acciones de tu app.

  • Si aislamos uno de ellos, verías algo como esto.

  • El primero es el trabajo del subproceso principal.

  • Aquí es donde Compose hace la mayor parte de su trabajo.

  • Luego, está RenderThread.

  • Aquí es donde procesamos el comando de lienzo y lo codificamos en la GPU.

  • Luego, la GPU hace su trabajo y el sistema lo compone.

  • Hoy solo veremos el subproceso principal.

  • Porque aquí es donde Compose hace su trabajo.

  • Estamos hablando de Compose, por lo que es razonable enfocarse solo en eso.

  • Y, a medida que aislamos ese fotograma en el subproceso principal, comenzamos a ver porciones de Compose.

  • Seleccioné algunas para ti.

  • Es como composición, medida, diseño y dibujo.

  • Si ya escuchaste hablar de las fases de Compose y analizaste el rendimiento, es probable que las veas.

  • Por lo tanto, tienen porciones individuales en un registro de Perfetto, y es muy fácil averiguar cuánto tiempo tarda cada una de las fases y qué fases se ejecutan en cada fotograma en particular.

  • Hablamos mucho y en detalle sobre cómo optimizar parte de este trabajo, y debes asegurarte de realizar el trabajo correcto en la fase correcta.

  • Por ejemplo, si tienes una animación que actualiza el color de tu cuadro o algo similar, solo querrás invalidar la fase de dibujo, y no la composición ni el diseño, ni nada por el estilo.

  • Esta es la introducción al rendimiento, y hoy profundizaremos un poco más en algunos aspectos y nos enfocaremos en la composición, en particular.

  • Ahora, Andrew nos hablará sobre eso.

  • Gracias, Andrei.

  • Como mencionó Andrei, pasamos mucho tiempo hablando en el contexto del rendimiento sobre optimizaciones como evitar lecturas hacia atrás, asegurarte de aplazar las lecturas de estado a la fase correcta en el ciclo de vida de la composición y optimizar y asegurarte de que tus elementos componibles se invaliden como esperas, para que no hagas demasiado trabajo en cada fotograma.

  • Este es un muy buen punto de partida, pero ya tenemos mucho material al respecto y queremos avanzar un poco más en esa conversación.

  • Por lo tanto, hoy abordaremos lo que queda en la composición después de realizar esas optimizaciones y lo que hacen tus elementos componibles.

  • La desafortunada realidad de todo lo que se encuentra dentro de tu código de Compose y, en realidad, de todo lo que envías con tu app en lo que respecta al rendimiento, es que todo tiene un costo asociado.

  • Ninguna parte del código que envías en tu app es gratuita, y hasta las cosas más triviales, como leer el estado y componer elementos como un efecto secundario dentro de tu composición, tienen algunos costos tangibles asociados, y es importante tenerlos en cuenta, especialmente cuando comienzas a analizar lo que realmente hacen tus elementos componibles y a exprimir más rendimiento dentro de tu app.

  • Para mostrar cómo se ve eso en la escala más microscópica, estas son algunas de las operaciones más pequeñas que puedes realizar en cualquier parte de tu código y, en especial, dentro de tus elementos componibles. En esta resolución, hablamos de la escala de nanosegundos en términos de cuánto cuestan estas cosas aproximadamente como mentalidad.

  • Si quieres mejorar tu rendimiento, tienes dos opciones.

  • Puedes quitar una gran cantidad de operaciones económicas o una cantidad menor de operaciones más costosas.

  • En este gráfico, específicamente, las corrutinas son más costosas en lo que respecta a estas operaciones, y es un buen ejemplo de dónde empezar a buscar, porque, si quitamos algunas de estas instancias, podemos empezar a ver beneficios más tangibles en nuestra aplicación con mayor rapidez.

  • Otro aspecto que se debe tener en cuenta sobre las corrutinas específicamente es que, como se muestra en la diapositiva, no solo el inicio de una corrutina tiene un costo asociado, sino también su cancelación.

  • El costo de cancelación es especialmente importante porque se produce principalmente cuando se elimina contenido y, posiblemente, se pasa a otras áreas de la aplicación.

  • La navegación fuera de la pantalla es un caso extremadamente costoso en muchas aplicaciones en las que necesitas aprovechar al máximo la latencia de fotogramas y asegurarte de usar al máximo lo que tienes.

  • Por lo tanto, es importante tener en cuenta ambos costos.

  • Todas estas, sin importar lo que estemos viendo, se sumarán a esta escala de alrededor de 16 millones de nanosegundos para trabajar.

  • Tenemos un presupuesto que debemos reducir, y las corrutinas son un buen ejemplo para comenzar.

Control de los efectos secundarios

  • En Compose, las corrutinas aparecen con mayor frecuencia con las APIs de efectos.

  • Aquí hay un espectro de APIs que puedes usar con SideEffects, desde alta funcionalidad hasta baja latencia.

  • En el lado más funcional de este espectro, tenemos LaunchedEffect, que incluye todo lo relacionado con el trabajo asíncrono, en especial, las animaciones, todo lo que requiera un alcance de corrutina.

  • Sin embargo, si no necesitas eso y puedes realizar tu tarea con una API menos costosa, podría valer la pena considerar usarla en su lugar si se adapta a las necesidades de tu caso de uso.

  • Para mostrar cómo se ve en la práctica, aquí tengo un elemento componible de reproductor multimedia para demostrar todos los diferentes SideEffects y cuándo podrían aparecer.

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

  • En este ejemplo, se usa para cargar datos de forma asíncrona dentro del reproductor multimedia.

  • Si necesitas un alcance de corrutina, o si necesitas corrutinas en general o realizar una llamada de suspensión, usa LaunchedEffect.

  • Aunque sigue siendo un poco más costoso y hay un costo asociado con las corrutinas, siempre que lo aproveches y lo justifiques, usa la API adecuada para el trabajo.

  • Sin embargo, si te encuentras en una situación en la que usas un LaunchedEffect, pero no aprovechas la corrutina, puede ser un buen momento para considerar otras APIs que puedan hacer lo mismo, pero con un costo menor.

  • Para eso, si nos movemos hacia abajo en el espectro, podemos ver otros ejemplos de efectos secundarios, como 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 es ideal cuando tienes un proceso de configuración y eliminación asociado con tu efecto.

  • Aquí, en el mismo ejemplo del reproductor multimedia, tengo un ejemplo que bloquea la orientación de la actividad en modo horizontal cuando la función de componibilidad está presente y, luego, libera ese bloqueo de orientación cuando desaparece un tiempo después.

  • Si quieres el efecto único de LaunchedEffect y DisposableEffect, pero no necesitas corrutinas ni tienes un efecto de eliminación, también puedes usar la nueva API de SideEffect con claves que presentamos en Compose 1.12.

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val mediaPlayer = rememberMediaPlayer()
    
        // One-shot events
        SideEffect(Unit) { mediaPlayer.play() }
    }
  • Esta es una excelente alternativa para las otras dos APIs si las usaste, ya sea LaunchedEffect por conveniencia o DisposableEffect por necesidad de un efecto único. Navegar o migrar a las APIs de SideEffect con claves puede lograr lo mismo con menos sobrecarga y un mejor rendimiento, en especial si se migra varias veces durante el transcurso de una app completa.

  • Por último, la API menos costosa es la API de SideEffect estándar.

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val mediaPlayer = rememberMediaPlayer()
    
        // Synchronize the outside world to the state of composition
        SideEffect { mediaPlayer.playbackRate = playbackRate }
    }
  • Esto es más apropiado cuando quieres sincronizar el estado exterior del mundo con lo que sucede en la composición.

  • Aquí, de nuevo en el ejemplo del reproductor multimedia, tengo otro ejemplo que muestra la sincronización de un estado de reproducción con un parámetro que recibe la composición.

  • Un aspecto que debes tener en cuenta si decides auditar tus usos de efectos secundarios y migrar a uno menos costoso es que, además de los otros comportamientos y casos de uso de los que hablamos, también hay una diferencia en las garantías de orden con los efectos secundarios.

  • Primero, se ejecutan todos los efectos desechables, seguidos de los efectos secundarios normales, con clave o sin ella, y, luego, una vez que se completa el fotograma, se ejecutan todos los efectos iniciados porque hay un envío involucrado.

  • Si dependes de este tiempo para tus efectos iniciados, tenlo en cuenta si estás considerando migrar.

  • Y, para cualquier migración, asegúrate de probar tu código y de que no dependas de ninguna de estas garantías de tiempo, y de que tu código siga funcionando después.

  • Si no puedes reducir la complejidad del efecto secundario en sí, también puedes reducir la cantidad de efectos secundarios que tienes combinándolos.

    @Composable
    fun ShoppingCartScreen(
        viewModel: CartViewModel
    ) {
        LaunchedEffect(Unit) { viewModel.loadCart() }
        LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
        LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }
    
        CartContent(...)
    }
  • Esto es especialmente importante si tienes varios efectos que usan la misma clave.

  • Es un buen candidato para considerar fusionarlo, ya que se puede ejecutar al mismo tiempo.

  • En este ejemplo, tengo varios efectos de lanzamiento que realizan una llamada asíncrona a un modelo de vista.

  • Si los reemplazo por un solo LaunchedEffect y, luego, otras llamadas de inicio dentro de él, aunque todavía tenga la misma cantidad de corrutinas que se ejecutan aquí, esto tiene menos sobrecarga porque saca el trabajo de la composición y lo lleva a las corrutinas.

    @Composable
    fun ShoppingCartScreen(
        viewModel: CartViewModel
    ) {
        LaunchedEffect(Unit) {
            launch { viewModel.loadCart() }
            launch { viewModel.loadPaymentMethods() }
            launch { viewModel.loadDeliveryAddresses() }
        }
    
        CartContent(...)
    }
  • Por lo tanto, es una buena idea considerar la migración en situaciones como esta para reducir la cantidad de efectos cuando sea posible.

  • Los efectos y las corrutinas son solo un ejemplo de un lugar que podrías auditar dentro de tu aplicación, pero cada elemento componible y cada aplicación serán diferentes y tendrán distintas oportunidades dentro de ellos.

  • Para comprender qué es relevante para tu aplicación, debemos hablar sobre la próxima herramienta que presentará Andrei, que es el seguimiento de la composición.

Seguimiento de composición y recomposición

  • Bien.

  • Por lo tanto, el seguimiento de la composición, como si observaras esta recreación fiel del fotograma de Perfetto que vimos antes en otra diapositiva, verías que nos brinda información de tiempo con diferentes fases, pero no nos da muchos detalles sobre lo que está sucediendo.

  • ¿Qué?

  • Sí, lo que sucede dentro de la composición de las funciones de componibilidad que se ejecutan.

  • Y el seguimiento de la composición nos ayuda con eso.

  • Por lo tanto, introducimos algunos marcadores en el compilador de Compose que, básicamente, anotan cada función y la hacen visible en el seguimiento de Perfetto para que puedas verificar cuánto tiempo tarda cada función.

  • Puedes usar esa información para averiguar qué funciones exactas se están recomponiendo.

  • Sin embargo, esto genera una pequeña sobrecarga, por lo que, si quieres capturar los datos de rendimiento más precisos, debes usar Perfetto normal o simplemente ejecutar sin capturas de Perfetto y usar otras métricas.

  • Sin embargo, el seguimiento de la composición es muy útil si quieres averiguar cuánto tiempo tarda cada elemento componible individual.

  • Ahora, a veces también quieres averiguar por qué se recomponen las cosas, y eso es lo que intentamos resolver en la próxima versión del seguimiento del entorno de ejecución de Compose Tracing, es decir, la 1.13.

  • Ahora, presentamos el seguimiento de recomposición, que anota cada lectura y escritura de estado que dio como resultado una recomposición.

  • Por lo tanto, obtendrás algo como esto en tu registro de Perfetto, donde verás cada una de las escrituras de estado y las pilas de llamadas en las que se producen la escritura y la lectura, para que puedas relacionarlas con las líneas reales de tu código en las que se accedió a esos estados o se modificaron.

  • Si quieres obtener más información, consulta el vínculo que aparece aquí.

  • Tiene toda la documentación sobre el registro de composición y el registro de recomposición.

  • Ahora, Andrew hablará sobre cómo podemos usar esto de manera más eficiente para evitar las escrituras de estado.

Optimización del estado e invalidaciones

  • Gracias, Andrei.

  • Según los resultados que encuentres en esta investigación, es posible que descubras que hay invalidaciones en tu composición que no son útiles para tu aplicación y que podrían haberse evitado en primer lugar.

  • Aquí, por ejemplo, el ejemplo más común que tengo es un botón para desplazarse hacia arriba que depende de la visibilidad o de la progresión del desplazamiento para su visibilidad.

    @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")
            }
        }
    }
  • Aquí, como estoy leyendo el valor de un estado de desplazamiento dentro de la composición, este es un estado que cambia con mucha frecuencia, podría cambiar hasta una vez por fotograma, y esto va a causar muchas recomposiciones que podrían evitarse porque esta condición, aunque el estado de desplazamiento en sí está cambiando, la condición real que nos importa aquí no cambia con tanta frecuencia.

  • Este es un ejemplo en el que recomendamos usar un estado calculado, que puede evitar invalidaciones por cambios de estado que no son importantes para tu aplicación.

  • En este ejemplo, lo que hice fue unir el cálculo en sí dentro de un estado calculado y, cuando leemos este estado, en lugar de invalidar en función de la posición de desplazamiento, invalidará de forma condicional esta función de componibilidad en función de toda la condición y lo derivará del otro estado.

    @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 ya viste este patrón, es probable que lo hayas visto con el estado derivado.

  • ComputedState es nuevo en Compose 1.13 y, aunque ComputedState y DerivedState tienen un objetivo similar de invalidar de forma condicional cuando cambia su cálculo, tienen una diferencia muy sutil en cómo lo hacen.

  • DerivedState, que enviamos desde el principio de Compose, almacena en caché el valor que calcula y lo reutiliza hasta que cambian sus dependencias.

  • Con ComputedState, se calcula el valor cada vez que lo lees.

  • El almacenamiento en caché parece útil, pero, en realidad, en muchas situaciones, es más costoso que solo leer el resultado.

  • Para comprender cómo sucede esto, podemos ver un ejemplo de cómo se podrían usar estos estados dentro de tu aplicación y profundizar en lo que hace cada uno de ellos.

  • Aquí, tengo el mismo estado que refleja aproximadamente lo que estábamos haciendo en el botón para desplazarse hacia arriba, donde tiene alguna condición que se basa en algún otro estado, y lo tengo como DerivedState y 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
  • También leo el estado dos veces y obtendremos el mismo valor en ambas ocasiones, pero lo que sucede en segundo plano es diferente entre las dos.

  • Para simplificar un seguimiento de lo que realmente aparecería aquí en algo como Perfetto, podemos examinar lo que sucede cuando lees estos dos estados.

  • Aquí, comenzamos llamando al computedState.value.

  • Ejecutará la lambda de cálculo, que luego leerá el valor del estado, y también habrá un poco de mantenimiento para notificar que se produjo la lectura.

  • Esto se aplicará a todos los estados de instantáneas en Compose.

  • La segunda lectura se comporta exactamente igual que la primera y tiene las mismas características de rendimiento en todo momento.

  • En comparación, la primera lectura de DerivedState hace algo similar, en el que todavía tiene que llamar a la lambda de cálculo y aún hace la otra notificación, pero tiene un poco de sobrecarga de administración de caché, que se acorta un poco la primera vez porque no hay caché para verificar.

  • Por lo tanto, realiza un poco de mantenimiento para el almacenamiento en caché y, luego, logra lo mismo en un poco más de tiempo.

  • Sin embargo, la segunda lectura de DerivedState gana contra la segunda lectura de ComputedState, aunque ambos son estados bastante triviales.

  • Como dije en las diapositivas anteriores, el costo de una lectura de instantánea no es trivial en algunos casos, y leer desde la caché es más rápido que realizar la lectura en sí, por lo que vemos esta pequeña ventaja para la segunda lectura de DerivedState.

  • Sin embargo, la situación interesante es lo que sucede cuando se debe invalidar el estado.

  • Esto es lo que llamamos el escenario de lectura después de la escritura, en el que actualizas uno de los estados que se usan en el cálculo y este se debe volver a ejecutar.

  • En el código, esto se ve como si modificáramos a, tal vez incrementándolo, y luego volviéramos a leer el valor o tal vez recomponiéndolo.

    var a by mutableStateOf(0)
    
    val derivedState = derivedStateOf { a > 0 }
    val computedState = computedStateOf { a > 0 }
    
    ...
    
    // Read-after-write
    a++
    derivedState.value
    computedState.value
  • En el diagrama real, se verá, de nuevo, exactamente igual para ComputedState.

  • No tiene una caché para administrar, esto se ve exactamente igual, primera, segunda, tercera, cualquiera que sea la lectura.

  • Sin embargo, DerivedState tiene mucho trabajo por delante.

  • Sigue una ruta muy costosa para ver que la caché no es válida antes de que se actualice y llame al cálculo en sí, y luego tiene que hacer un seguimiento de las dependencias y administrar muchas de las observaciones para volver a escribir el valor de la caché y comprender que todos estos valores realmente cambiaron y administrar el mantenimiento en sí.

  • Todo esto se suma y hace que el costo de invalidar un DerivedState sea muy alto en comparación con un ComputedState.

  • Para poner números a todas estas afirmaciones, ComputedState suele ser más rápido en la primera lectura en unos 200 ns.

  • ComputedState pierde alrededor de 350 ns en un Pixel 6, específicamente, en condiciones de comparativas específicas.

  • Por lo tanto, en la segunda lectura, ComputedState perderá un poco en comparación con DerivedState.

  • Pero en el caso que realmente nos importa, en el que invalidamos y probablemente en el que realmente tienes trabajo que hacer en tu fotograma, ComputedState es más rápido en casi y, de hecho, más del doble en comparación con DerivedState.

  • Por lo tanto, según el uso que les des a tus estados, ComputedState puede ser una muy buena primera opción.

  • En general, todavía hay que tomar una decisión cuando se usan DerivedState y ComputedState.

  • No daremos de baja ComputedState, ya que todavía hay usos para ambos.

  • En general, si no sabes cuál elegir, el estado calculado es una buena opción predeterminada.

  • El estado derivado es más útil cuando realmente aprovechas el almacenamiento en caché, lo que significa que haces algo no trivial, como un algoritmo complicado, una asignación grande, muchas lecturas de estado dentro de ese estado que realmente se benefician del almacenamiento en caché aquí, o cualquier cosa no trivial o que dependa de cosas como la igualdad de objetos y la igualdad de identidad de objetos, donde compensas la diferencia y obtienes los beneficios del almacenamiento en caché que ofrece el estado derivado.

Omisión y estabilidad en Compose

  • El otro tema importante del que quiero hablar es que tanto el estado derivado como el estado calculado intentan evitar que se invalide el elemento componible en sí.

  • Esto es diferente de omitir un elemento componible, que es de lo que solemos hablar cuando pensamos en invalidar de forma condicional o no realizar trabajo dentro de un elemento componible.

  • La verdad que quiero exponer aquí es que, aunque se habla mucho de la estabilidad y la omisión dentro de Compose, esto no es lo más importante y no siempre es lo correcto para optimizar.

  • Como dijimos anteriormente, todo en la composición tiene algunos costos asociados, y eso incluye la omisión.

    // 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()
    }
  • Omitir no es necesariamente trivial.

  • Hay mucho mantenimiento que debemos hacer para habilitarlo en primer lugar.

  • Tenemos que hacer cosas como almacenar todos tus parámetros y encontrar espacio para ellos en la composición inicial y, luego, actualizar ese registro cuando vuelvas a componer.

  • También debemos comparar tus valores con los anteriores para comprender si tu elemento componible debe omitirse o no.

  • Y, luego, todo esto es solo el poder de una verificación if que omite el código o no.

  • Esto puede ser costoso según lo que esté sucediendo aquí.

  • Hay una optimización interna para el compilador, en la que, si pasas el mismo parámetro, podemos reutilizar las verificaciones de igualdad, pero esto no es todo, y es bastante fácil de superar.

  • Para ver un ejemplo de cómo omitir puede traicionarnos un poco, podemos ver un ejemplo muy clásico de una lista de desplazamiento que tiene, lo que es importante, un objeto masivo que contiene todos los datos que se compondrán en pantalla.

    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) { ... }
  • Aquí, el modelo de pantalla contiene algo como una pantalla de encabezado y, luego, toda la lista de contactos que pasa por un par de elementos componibles que, con el tiempo, llaman a una lista diferida y, luego, los muestran en pantalla con algunas celdas de elementos.

  • La jerarquía de composición de este código se ve de la siguiente manera, en la que, de nuevo, comenzamos con la pantalla de contacto, nos expandimos un poco y, luego, tenemos algunas otras funciones de componibilidad que llaman y presentan nuestros elementos.

  • Si observamos cómo se pasan nuestros datos, comenzamos con el modelo en la parte superior.

  • Lo desestructuramos en una cadena y, luego, en una lista. Esa lista se convierte en una expresión lambda de elementos para la lista diferida, y, luego, la lista diferida compondrá todos los elementos que aparecen en pantalla.

  • Veamos qué sucede cuando modificamos el último contacto de esta lista.

  • Lo que queremos que suceda es que solo se invalide el último elemento de la lista, pero eso no es lo que va a suceder aquí.

  • Si observamos un registro de composición para esta recomposición, se verá algo así, donde comenzamos en la pantalla de contacto y, luego, llegamos a este elemento de la lista.

  • Y esta es una forma bastante costosa de llegar hasta aquí.

  • También podemos ver que hay todas estas secciones en las que parece que no sucede nada dentro del registro.

  • Y, aunque no está claro aquí, en realidad es donde los elementos componibles deciden si están a punto de omitirse o no.

  • En este ejemplo, solo se omiten dos elementos componibles debido a la forma en que se recompone la lista diferida.

  • Cuando le asignas una nueva lambda de contenido, se invalidan todos los elementos que tiene.

  • Por lo tanto, invalidará los dos contactos que no cambiaron, además del que sí lo hizo, y esos dos son los únicos elementos que se omiten.

  • Todo lo demás realiza esta comparación de igualdad de omisión solo para darse cuenta de que, en realidad, tiene que ejecutarse.

  • Si tus objetos son estables, esta comparación puede ser bastante costosa.

  • Si nos obligas a revisar todas tus estructuras de datos, esto puede acumularse y ser un costo no trivial solo para saber si dos objetos son iguales.

  • Y lo que es peor, debido a que desestructuramos, debemos hacer la misma comparación en la lista dos veces.

  • Si, en cambio, dejas que el compilador se comporte con el comportamiento predeterminado y usas la omisión estricta, puedes obtener la omisión y las verificaciones para omitir de forma condicional tus elementos componibles, pero estos siguen la ruta mucho más rápida de usar la igualdad de identidad de objetos en lugar de la igualdad estructural.

  • Esto es mucho más rápido para nosotros, y aunque volvemos a componer los mismos elementos en este fotograma, donde realmente importa que entremos rápido, no perdemos demasiado tiempo descubriendo que no podíamos omitir estas funciones de componibilidad.

  • Por este motivo, también hacemos hincapié en el uso de herramientas como comparativas y seguimientos para comprender lo que sucede dentro de tu aplicación, en lugar de los recuentos de recomposición directamente.

  • Pueden ser un proxy útil, pero no es algo en lo que debas enfocarte demasiado ni sobreindexar durante la optimización de tus aplicaciones.

  • Recuerda que, en lugar de intentar omitir tu elemento componible, debes evitar invalidarlo por completo.

Analiza la ejecución con seguimientos de métodos

  • En este ejemplo, vimos que los seguimientos de composición pueden brindar mucha información sobre lo que está sucediendo, pero tampoco muestran necesariamente todo el panorama.

  • Para eso, Andrei nos hablará sobre los seguimientos de métodos.

  • Bien.

  • Como ya dijo Andrew, el seguimiento de composición no siempre te dice todo lo que está sucediendo, solo porque solo anota funciones de componibilidad, y debe haber algunos casos en los que solo veas un espacio en blanco en el seguimiento de composición que ya aumenta el seguimiento de Perfetto, pero aún no muestra todo lo que está sucediendo allí.

  • A veces, puede ser algo que hace Compose, a veces puede ser algo en tu código, es muy difícil de averiguar con solo mirar el seguimiento de la composición aquí.

  • Lo que debes hacer ahora es obtener el seguimiento de método.

  • Hay un elemento en el generador de perfiles de Android Studio, o también puedes usar comandos de ADB para obtener el registro, y lo que obtendrás es cada llamada a función que se produce en el mundo de Java.

  • Es todo lo que el compilador de Compose codifica allí, todo lo que hace el entorno de ejecución de Compose y todo lo que hace tu código, y es muy útil para hacer un seguimiento, investigar qué se está ejecutando exactamente y qué funciones se llamaron en ese espacio.

  • Una cosa, quiero que vean este elemento en el medio.

  • Quiero destacar que el seguimiento de métodos tiene una sobrecarga muy alta.

  • Tienes entre 10 y 20 veces más sobrecarga, según la complejidad del árbol de funciones, ya que cada llamada a función agrega un poco de sobrecarga.

  • Para ilustrar esto, podemos ver la captura de pantalla.

  • Así es como suele verse el seguimiento de métodos para el inicio de la app, y es posible que te preguntes: "¿Cómo puedo entender esto?".

  • Bueno, por lo general, comienzas con el seguimiento de la composición y observas las funciones que tienes aquí.

  • Este es un seguimiento de composición simplificado, en el que se muestra ScreenContent, un botón y algunas otras funciones.

  • Supongamos que queremos averiguar qué sucede en ScreenContent.

  • Perfetto tiene una barra de búsqueda.

  • Cargas el seguimiento de método en el visualizador de Perfetto, buscas la misma función que tienes aquí, ScreenContent, y llegarás a algo como esto, que es el seguimiento de método para la pantalla.

  • Como puedes ver, hay algunas llamadas de Composer.startGroup y de grupo.

  • Esto es lo que hacen los entornos de ejecución y el compilador en segundo plano para asegurarse de que la composición realmente haga su magia, pero la llamada interesante aquí es Composer.changed.

  • Así es como verificamos si los parámetros cambiaron entre las invocaciones anteriores y la invocación actual del elemento componible.

  • Y la mayoría de estos Composer.changed están dominados por este ScreenModel.equals.

  • Por lo tanto, tal vez ScreenModel sea una especie de clase de macrodatos y, tal vez, la hicimos estable para ayudar con el recuento de recomposiciones, por lo que tal vez sea algo que apareció en el informe del compilador.

  • Y tal vez quitar esta anotación estable ayude con el rendimiento en este caso en particular.

  • Por lo tanto, debes buscar eso, y los seguimientos de métodos de composición son la mejor herramienta para encontrarlo.

  • Bien.

  • Hoy vimos muchos temas, así que ahora le daré la palabra a Andrew para que haga un resumen.

Herramientas de diseño y rendimiento de Composable rápidas

  • Para concluir y dar un resumen general de todo lo que hablamos hoy, hay un par de cosas que debes tener en cuenta cuando diseñes elementos componibles y, luego, optimices las apps que ya tienes.

  • En primer lugar, recuerda que los elementos componibles más económicos y los que no invalidas son mejores que los que omites.

  • Recuerda que, cuando diseñas elementos componibles, es mejor considerar argumentos más pequeños y fáciles de comparar, y posiblemente reutilizar comparaciones con otras llamadas a funciones para aprovechar la optimización.

  • Cuando uses APIs dentro de tus funciones de componibilidad, ten en cuenta que el rendimiento o la latencia de fotogramas serán un producto del costo de las APIs en sí y de la frecuencia con la que las uses.

  • Ten en cuenta cómo se acumulan estos costos y que puede haber costos de eliminación asociados a ellos que podrían afectarte en el momento menos oportuno para tu aplicación.

  • Además, recuerda que es importante ser un buen ciudadano cuando realices optimizaciones dentro de tu app.

  • Comienza por realizar comparativas para tomar decisiones fundamentadas, genera perfiles de tu app para asegurarte de que estás orientando las áreas correctas de tu código y valida que estás realizando los cambios y viendo los resultados que esperas de las modificaciones que estás realizando como resultado de las comparativas y la generación de perfiles.

  • Además, recuerda que puede haber un equilibrio entre la capacidad de mantenimiento y el rendimiento dentro de tu aplicación.

  • Por lo tanto, haz lo que sea mejor para ti y tu equipo para asegurarte de no optimizar en exceso y arrinconarte con la mantenibilidad.

  • Además, para obtener más ayuda, tienes otra herramienta a tu disposición: el agente de IA que ya tienes.

  • Como parte de la CLI de Android y de las habilidades que enviamos allí, lanzamos una habilidad de rendimiento de Jetpack Compose que tiene mucha de la información de la que hablamos hoy y que puede revisar y auditar tu aplicación, y hacer sugerencias para este tipo de optimizaciones.

  • Puedes instalarlo en tu agente existente con la CLI de Android.

    android skills add jetpack-compose-performance
  • Para obtener más información sobre la CLI de Android, puedes consultar developer.android.com.

  • También puedes obtener más información sobre nuestras habilidades en nuestro repositorio de GitHub.

  • Y con eso, te deseamos una comparativa segura y una feliz composición.

Código

Fragmentos de código

Fragmento de Kotlin en el minuto 09:34 de "Administra efectos secundarios"
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val mediaPlayer = rememberMediaPlayer()

    // Asynchronous work and animations
    LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
}
Fragmento de Kotlin en el minuto 10:15 de "Administra efectos secundarios"
@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
        }
    }
}
Fragmento de Kotlin en el minuto 10:36 de "Administra efectos secundarios"
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val mediaPlayer = rememberMediaPlayer()

    // One-shot events
    SideEffect(Unit) { mediaPlayer.play() }
}
Fragmento de Kotlin en el minuto 11:12 de "Administra efectos secundarios"
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val mediaPlayer = rememberMediaPlayer()

    // Synchronize the outside world to the state of composition
    SideEffect { mediaPlayer.playbackRate = playbackRate }
}
Fragmento de Kotlin en el minuto 12:12 de "Administra efectos secundarios"
@Composable
fun ShoppingCartScreen(
    viewModel: CartViewModel
) {
    LaunchedEffect(Unit) { viewModel.loadCart() }
    LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
    LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }

    CartContent(...)
}
Fragmento de Kotlin en el minuto 12:36 de "Administra efectos secundarios"
@Composable
fun ShoppingCartScreen(
    viewModel: CartViewModel
) {
    LaunchedEffect(Unit) {
        launch { viewModel.loadCart() }
        launch { viewModel.loadPaymentMethods() }
        launch { viewModel.loadDeliveryAddresses() }
    }

    CartContent(...)
}
Fragmento de Kotlin en el minuto 15:12 de "State optimization and 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")
        }
    }
}
Fragmento de Kotlin en el minuto 15:51 de "Invalidaciones y optimización de estado"
@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")
        }
    }
}
Fragmento de Kotlin en el minuto 16:49 de "State optimization and 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
Fragmento de Kotlin en el minuto 18:35 de "Optimización de estado e invalidaciones"
var a by mutableStateOf(0)

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

...

// Read-after-write
a++
derivedState.value
computedState.value
Fragmento de Kotlin en el minuto 21:07 de "Omisión y estabilidad en 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()
}
Fragmento de Kotlin en el minuto 21:53 de "Omisión y estabilidad en 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) { ... }
Fragmento de shell en el minuto 29:40 de "Fast composable design and performance tools"
android skills add jetpack-compose-performance