1. Antes de comenzar
En este codelab, aprenderás a mejorar el rendimiento del tiempo de ejecución de una app de Compose. Seguirás un enfoque científico para medir, depurar y optimizar el rendimiento. También investigarás varios problemas de rendimiento con el registro del sistema y modificarás el código del tiempo de ejecución que no cumple con los requisitos en una app de muestra, que contiene varias pantallas que representan diferentes tareas. Cada pantalla está creada de diferente manera e incluye lo siguiente:
- La primera pantalla es una lista de dos columnas con imágenes y algunas etiquetas en la parte superior. Aquí es donde optimizas los elementos componibles pesados.
- La segunda y la tercera pantalla contienen un estado de recomposición frecuente. Aquí, quitas las recomposiciones innecesarias para optimizar el rendimiento.
- La última pantalla contiene elementos inestables. Aquí, estabilizas los elementos con diversas técnicas.
Requisitos previos
- Saber cómo compilar apps Compose
- Conocimientos básicos acerca de cómo probar o ejecutar macrobenchmarks
Qué aprenderás
- Cómo detectar problemas de rendimiento con registros del sistema y registros de composición
- Cómo programar apps Compose que cumplen con los requisitos y renderizan sin inconvenientes
Requisitos
- La versión estable más reciente de Android Studio
- Un dispositivo Android físico con Android 6 (nivel de API 23) o una versión posterior
2. Prepárate
Para comenzar, sigue estos pasos:
- Clona el repositorio desde GitHub
$ git clone https://github.com/android/codelab-android-compose.git
También tienes la opción de descargar el repositorio como archivo ZIP:
- Abre el proyecto
PerformanceCodelab
, que contiene las siguientes ramas:
main
: Contiene el código de partida para este proyecto, en el que realizarás cambios para completar el codelab.end
: Contiene el código de solución para este codelab.
Te recomendamos que comiences con la rama main
y sigas el codelab paso a paso a tu propio ritmo.
- Si quieres ver el código de solución, ejecuta este comando:
$ git clone -b end https://github.com/android/codelab-android-compose.git
También puedes descargar el código de solución:
Opcional: Registros del sistema usados en este codelab
Durante el codelab, ejecutarás varias comparativas que capturan los registros del sistema.
Si no puedes ejecutarlas, esta es una lista de registros del sistema que puedes descargar:
3. Enfoque de corrección de problemas de rendimiento
Detectar UIs lentas y que no cumplen con los requisitos de rendimiento es posible a simple vista y con solo explorar la app. Pero antes de empezar de lleno y comenzar a corregir código según suposiciones, deberías medir su rendimiento para comprender si los cambios marcarán una diferencia.
Durante el desarrollo, con una compilación debuggable
de la app, es probable que notes cierto incumplimiento en el rendimiento, y comenzar a abordar el problema podría ser una tentación. Sin embargo, un valor debuggable
en el rendimiento de la app no representa lo que los usuarios verán, así que es importante verificar con una app non-debuggable
que efectivamente se trata de un problema. En una app debuggable
, la totalidad del código se tiene que interpretar en función del tiempo de ejecución.
Cuando se considera el rendimiento en Compose, no hay ninguna regla fija que se deba seguir para implementar una funcionalidad específica. No deberías precipitarte en realizar las siguientes acciones:
- Buscar y corregir cada parámetro inestable que se filtre en el código
- Quitar animaciones que provoquen la recomposición de elementos componibles
- Llevar a cabo optimizaciones difíciles de leer basadas en presentimientos
Todas estas modificaciones se deben realizar de manera sensata y con las herramientas disponibles para asegurarse de tratar el problema de rendimiento.
Cuando se abordan los problemas de rendimiento, debes seguir este enfoque científico:
- Establecer el rendimiento inicial con una medición
- Observar la causa del problema
- Modificar el código según las observaciones
- Medir el rendimiento y compararlo con el valor inicial
- Repetir
Si no sigues ningún método estructurado, algunos de los cambios podrían mejorar el rendimiento, pero otros podrían disminuirlo, y podrías obtener el mismo resultado.
Te recomendamos ver el siguiente video para mejorar el rendimiento de la app con Compose que detalla el recorrido para corregir los problemas de rendimiento, además de ofrecer algunas sugerencias para mejorarlo.
Cómo generar perfiles de Baseline
Antes de comenzar a investigar los problemas de rendimiento, genera un Perfil de Baseline para tu app. En Android 6 (nivel de API 23) y versiones posteriores, las apps ejecutan código que se interpreta en el tiempo de ejecución y se compila justo a tiempo (JIT) y con anticipación (AOT) en el momento de la instalación. El código interpretado y compilado de forma JIT se ejecuta más lentamente que cuando se compila de forma AOT, pero ocupa menos espacio en el disco y en la memoria, y es por eso que la compilación AOT no debería aplicarse a todo el código.
Cuando implementas perfiles de Baseline, puedes mejorar el inicio de la app en un 30% y reducir la ejecución de código en modo JIT en el tiempo de ejecución en ocho veces, como se muestra en la siguiente imagen basada en la app de ejemplo Now in Android:
Para obtener más información sobre los perfiles de Baseline, consulta los siguientes recursos:
- Documentación de perfiles de Baseline
- Codelab para mejorar el rendimiento de la app con perfiles de Baseline
Cómo medir el rendimiento
Para medir el rendimiento, recomendamos configurar y escribir comparativas con Jetpack Macrobenchmark. Las comparativas de Macrobenchmark son pruebas de instrumentación que interaccionan con la app como lo haría un usuario, a la vez que se supervisa su rendimiento. Esto significa que no contaminan el código de la app con código de pruebas y, por lo tanto, proporcionan información de rendimiento confiable.
En este codelab, ya configuramos la base de código y escribimos las comparativas para enfocarnos directamente en corregir los problemas de rendimiento. Si no sabes con seguridad cómo configurar y usar Macrobenchmark en tu proyecto, consulta los siguientes recursos:
- Cómo inspeccionar el rendimiento de una app con Macrobenchmark (codelab)
- Cómo inspeccionar el rendimiento: MAD Skills
- Cómo escribir un documento de Macrobenchmark
Con las comparativas de Macrobenchmark, puedes elegir uno de los siguientes modos de compilación:
None
: Restablece el estado de la compilación y ejecuta todo en modo JIT.Partial
: Compila previamente la app con perfiles de Baseline o iteraciones de calentamiento, y ejecuta en modo JIT.Full
: Compila previamente todo el código de la app, de modo que no haya código en ejecución en el modo JIT.
En este codelab, únicamente usarás el modo CompilationMode.Full()
para las comparativas porque solo te ocuparás de los cambios que hagas en el código, no del estado de compilación de la app. Este enfoque te permite reducir la varianza que se originaría a partir del código en ejecución en modo JIT, lo que se reduciría al implementar perfiles de Baseline personalizados. Ten en cuenta que el modo Full
puede tener un efecto negativo en el inicio de la app, así que no lo uses para las comparativas que miden el inicio de la app, sino que únicamente para las que miden las mejoras de rendimiento del tiempo de ejecución.
Una vez que hayas completado las mejoras de rendimiento y quieras verificar la ejecución cuando los usuarios instalan la app, usa el modo CompilationMode.Partial()
que usa los perfiles de Baseline.
En la siguiente sección, aprenderás cómo leer los registros para detectar los problemas de rendimiento.
4. Analiza el rendimiento con el registro del sistema
Con una compilación debuggable
de la app, puedes usar el Inspector de diseño con el recuento de composiciones para comprender rápidamente cuándo algo se recompone con demasiada frecuencia.
Sin embargo, se trata solo de una parte de la investigación general sobre el rendimiento porque solo obtienes mediciones de proxy y no el tiempo real que implicó la renderización de esos elementos componibles. Probablemente no tenga importancia si algo se recompone N
veces, si la duración total es de menos de un milisegundo. Pero, por otro lado, es importante si algo se compone solo una vez o dos, y hacerlo le lleva 100 milisegundos. Con frecuencia, un elemento componible podría componerse solo una vez y, aun así, requerir demasiado tiempo para hacerlo y ralentizar la pantalla.
Para investigar los problemas de rendimiento de manera confiable y obtener información valiosa sobre lo que hace la app y si le lleva más tiempo del que debería, puedes usar el registro del sistema con registro de composición.
El registro del sistema te brinda información sobre el tiempo de todos los procesos que suceden en la app. No le agrega una sobrecarga y, por ende, puedes mantenerlo en la app de productividad sin tener que preocuparte por efectos negativos en el rendimiento.
Cómo configurar el seguimiento de composición
La función de composición propaga de forma automática información en sus fases de tiempo de ejecución, como cuando un elemento se recompone o cuando un diseño diferido carga previamente elementos. Sin embargo, la información no es suficiente para determinar con efectividad cuál podría ser la sección problemática. Para mejorar la cantidad de información, puedes configurar el registro de composición, que te indica el nombre de cada elemento componible que se compuso durante el registro. Esto te permite comenzar a investigar los problemas de rendimiento sin tener que agregar varias secciones trace("label")
personalizadas.
Para habilitar el registro de composición, sigue estos pasos:
- Agrega la dependencia
runtime-tracing
a tu módulo:app
:
implementation("androidx.compose.runtime:runtime-tracing:1.0.0-beta01")
En este punto, podrías llevar a cabo un registro del sistema con el generador de perfiles de Android Studio que incluyera toda la información, pero usaremos las comparativas de Macrobenchmark para las mediciones del rendimiento y el registro del sistema.
- Agrega dependencias adicionales al módulo
:measure
para habilitar el registro de composición con Macrobenchmark:
implementation("androidx.tracing:tracing-perfetto:1.0.0")
implementation("androidx.tracing:tracing-perfetto-binary:1.0.0")
- Agrega el aumento de instrumentación
androidx.benchmark.fullTracing.enable=true
al archivobuild.gradle
del módulo:measure
:
defaultConfig {
// ...
testInstrumentationRunnerArguments["androidx.benchmark.fullTracing.enable"] = "true"
}
Para saber cómo configurar un registro de composición para, por ejemplo, usarlo desde la terminal, consulta la documentación.
Cómo capturar el rendimiento inicial con Macrobenchmark
Existen varias formas de recuperar un archivo del registro del sistema. Por ejemplo, podrías registrarte en el generador de perfiles de Android Studio, capturarlo en el dispositivo o recuperar un registro del sistema registrado con Macrobenchmark. En este codelab, usarás los registros de la biblioteca de Macrobenchmark.
Este proyecto contiene comparativas en el módulo :measure
que puedes ejecutar para tomar las mediciones del rendimiento. En este proyecto, las comparativas están configuradas para ejecutar solo una iteración con el objetivo de ahorrar tiempo durante este codelab. En la app real, si la varianza resultante es alta, se recomienda tener al menos diez iteraciones.
Para capturar el rendimiento inicial, usa la prueba AccelerateHeavyScreenBenchmark
que desplaza la pantalla de la primera tarea, sigue estos pasos:
- Abre el archivo
AccelerateHeavyScreenBenchmark.kt
. - Ejecuta la comparativa con la acción del margen junto a la clase de comparativas:
Esta comparativa desplaza la pantalla Task 1 y captura la latencia de fotogramas y las secciones de registro
personalizadas.
Una vez que se complete la comparativa, deberías ver los resultados en el panel de resultados de Android Studio:
AccelerateHeavyScreenBenchmark_accelerateHeavyScreenCompilationFull
ImagePlaceholderCount min 20.0, median 20.0, max 20.0
ImagePlaceholderMs min 22.9, median 22.9, max 22.9
ItemTagCount min 80.0, median 80.0, max 80.0
ItemTagMs min 3.2, median 3.2, max 3.2
PublishDate.registerReceiverCount min 1.0, median 1.0, max 1.0
PublishDate.registerReceiverMs min 1.9, median 1.9, max 1.9
frameDurationCpuMs P50 5.4, P90 9.0, P95 10.5, P99 57.5
frameOverrunMs P50 -4.2, P90 -3.5, P95 -3.2, P99 74.9
Traces: Iteration 0
Las métricas importantes en el resultado son las siguientes:
frameDurationCpuMs
: Indica el tiempo que llevó renderizar fotogramas. Cuanto más breve, mejor.frameOverrunMs
: Indica el tiempo excedente del límite para fotogramas, incluido el funcionamiento en la GPU. Un número negativo es un buen resultado porque implica que sobró tiempo.
Las otras métricas, como la métrica ImagePlaceholderMs
, usan secciones de registro personalizadas y calculan la duración total de todas esas secciones en el archivo de registro, además de la cantidad de veces que se produjo con la métrica ImagePlaceholderCount
.
Todas estas métricas pueden ayudarnos a comprender si los cambios que hacemos en nuestra base de código mejoran el rendimiento.
Cómo leer el archivo de registro
Puedes leer el archivo de registro desde Android Studio o con Perfetto, la herramienta basada en la Web.
Si bien el generador de perfiles de Android Studio es una buena manera de abrir rápidamente un registro y mostrar el proceso de la app, Perfetto proporciona capacidades de investigación más detalladas para todos los procesos que se ejecutan en un sistema con consultas en SQL eficaces y mucho más. En este codelab, usarás Perfetto para analizar registros del sistema.
- Abre el sitio web de Perfetto, que carga el panel de la herramienta.
- Ubica los registros del sistema que capturó Macrobenchmark en el sistema de archivos del hosting, que se guardan en la carpeta
[module]/outputs/connected_android_test_additional_output/benchmarkRelease/connected/[device]/
. Cada iteración de comparativa procesa un archivo de registro separado, y cada archivo contiene las mismas interacciones con la app.
- Arrastra el archivo
AccelerateHeavyScreenBenchmark_...iter000...perfetto-trace
a la IU de Perfetto y espera hasta que cargue el archivo de registro. - Opcional: Si no puedes ejecutar la comparativa y generar el archivo de registro, descarga el nuestro y arrástralo a Perfetto:
- Busca el proceso com.compose.performance en tu app. Por lo general, la app en primer plano está por debajo de los carriles de información del hardware y de un par de carriles del sistema.
- Abre el menú desplegable con el nombre del proceso de la app. Verás la lista de subprocesos que se ejecutan en la app. Mantén el archivo de registro abierto porque lo necesitarás en el paso siguiente.
Si quieres buscar un problema de rendimiento en la app, puedes aprovechar los cronogramas esperado y real en la parte superior de la lista de subprocesos de la app:
El cronograma esperado indica cuándo el sistema espera que la app genere los fotogramas para mostrar UI fluida y de cumplimiento que, en este caso, es de 16 ms y 600 µs (1000 ms/60). El cronograma real muestra la duración real de los fotogramas que genera la app, incluido el funcionamiento de la GPU.
Es probable que veas diferentes colores, y esto indica lo siguiente:
- Fotograma verde: El fotograma se produjo puntualmente.
- Fotograma rojo: El fotograma con bloqueos llevó más tiempo de lo esperado. Deberías investigar el trabajo realizado en estos fotogramas para evitar problemas de rendimiento.
- Fotograma verde claro: El fotograma se produjo conforme el plazo, pero se presentó tardíamente, lo que generó una latencia de entrada aumentada.
- Fotograma amarillo: El fotograma se bloqueó, pero la app no fue el motivo.
Cuando la IU se renderiza en la pantalla, los cambios deben ser más rápidos que el tiempo de creación de un fotograma que espera el dispositivo. Históricamente, esto fue de casi 16.6 ms dado que la frecuencia de actualización de la pantalla fue de 60 Hz. Sin embargo, para los dispositivos Android modernos, puede ser de casi 11 ms o menos porque la frecuencia de actualización de la pantalla es de 90 Hz o más rápida. También puede ser diferente para cada fotograma debido a frecuencias de actualización variables.
Por ejemplo, si tu IU se compone de 16 elementos, entonces cada elemento se debe crear en apenas 1 ms para evitar que se omitan fotogramas. Por otro lado, si solo tienes un elemento, como un reproductor de video, la composición sin bloqueos puede llevar hasta 16 ms.
Cómo comprender el gráfico de llamadas del registro del sistema
En la siguiente imagen, verás un ejemplo de una versión simplificada de un registro del sistema que muestra una recomposición.
Cada barra, de un extremo a otro, muestra en la parte inferior el tiempo total. Las barras también corresponden a las secciones de código de las funciones llamadas. Las llamadas de composición se recomponen en la jerarquía de composición. El primer elemento componible es MaterialTheme
. Dentro de MaterialTheme
hay datos locales de composición que proporcionan información sobre asociación de temas. Desde allí, se llama al elemento HomeScreen
componible. El elemento componible de la pantalla principal llama a los elementos MyImage
y MyButton
componibles como parte de su composición.
Las brechas en los registros del sistema provienen de código sin registrar que se ejecuta porque los registros del sistema solo muestran código que se marca para el registro. El código que se ejecuta se procesa después de la llamada de MyImage
pero antes de la llamada de MyButton
, y lleva una cantidad de tiempo equivalente al tamaño de la brecha.
En el paso siguiente, analizarás el registro que tomaste en el paso anterior.
5. Acelera elementos componibles pesados
Como primera tarea cuando intentas optimizar el rendimiento de la app, deberías buscar elementos componibles pesados o una tarea de ejecución prolongada en el subproceso principal. El trabajo de ejecución prolongada podría tener diferentes significados, según qué tan complicada es tu IU y cuánto tiempo tienes para componerla.
Entonces, si se omite un fotograma, tienes que buscar los elementos componibles que tardan demasiado tiempo en procesarse y acelerarlos. Para ello, debes descargar el subproceso principal, o bien omitir parte del trabajo que estos elementos hacen en el subproceso principal.
Para analizar el registro tomado de la prueba AccelerateHeavyScreenBenchmark
, sigue estos pasos:
- Abre el registro del sistema que tomaste en el paso anterior.
- Acerca el primer fotograma largo, que contiene la inicialización de IU después de la carga de los datos. El contenido del fotograma es similar a la siguiente imagen:
En el registro, puedes ver que suceden muchas cosas dentro de un fotograma, que se puede encontrar en la sección Choreographer#doFrame
. Puedes ver en la imagen que la mayor parte del trabajo proviene del elemento componible que contiene la sección ImagePlaceholder
, que carga una imagen grande.
Cómo evitar cargar imágenes grandes en el subproceso principal
Es probable que resulte evidente cargar imágenes de manera asíncrona de una red que usa una de las bibliotecas convenientes, como Coil o Glide, pero, ¿qué sucede si tienes que mostrar una imagen grande que tienes localmente en la app?
La función de componibilidad común painterResource
que carga una imagen de los recursos carga la imagen en el subproceso principal durante la composición. Esto significa que si la imagen es grande, puede bloquear el subproceso principal con algo de trabajo.
En tu caso, puedes ver el problema como parte del marcador de posición de la imagen asíncrona. La función de componibilidad painterResource
carga una imagen de marcador de posición que demora unos 23 ms en cargar.
Existen varias formas de mejorar este problema, incluidas las siguientes opciones:
- Carga la imagen de manera asíncrona.
- Achica la imagen de modo que se cargue más rápidamente.
- Usa un elemento de diseño vectorial que se escale según el tamaño requerido.
Para corregir este problema de rendimiento, sigue estos pasos:
- Navega al archivo
AccelerateHeavyScreen.kt
. - Ubica la función de componibilidad
imagePlaceholder()
que carga la imagen. La imagen del marcador de posición tiene dimensiones de 1,600 x 1,600 px, que es claramente demasiado grande para lo que muestra.
- Cambia el elemento de diseño a
R.drawable.placeholder_vector
:
@Composable
fun imagePlaceholder() =
trace("ImagePlaceholder") { painterResource(R.drawable.placeholder_vector) }
- Vuelve a ejecutar la prueba
AccelerateHeavyScreenBenchmark
, que recompila la app y vuelve a tomar el registro del sistema. - Arrastra el registro del sistema al panel de Perfetto.
Como alternativa, puedes descargar el registro:
- Busca la sección del registro
ImagePlaceholder
, que te muestra directamente la parte mejorada.
- Observa que la función
ImagePlaceholder
ya no bloquea tanto el subproceso principal.
Como una solución alternativa en la app real, es probable que una imagen del marcador de posición no sea lo que genera complicaciones, pero que material gráfico sí lo haga. En este caso, podrías usar la función de componibilidad rememberAsyncImage
de Coil, que carga el elemento componible de manera asíncrona. Esta solución mostrará un espacio vacío hasta que se cargue el marcador de posición, así que ten en cuenta que podrías necesitar tener un marcador de posición para estos tipos de imágenes.
Aún hay otros aspectos que no se ejecutan bien, de los que te ocuparás en el paso siguiente.
6. Descarga una operación pesada en un subproceso en segundo plano
Si seguimos investigando el mismo elemento en busca de problemas adicionales, verás que hay secciones con el nombre binder transaction
a los que la descarga le lleva a cada uno aproximadamente 1 ms.
Las secciones con el nombre binder transaction
muestran que hubo una comunicación entre tu proceso y el proceso del sistema. Se trata de una forma normal de recuperar información del sistema, como recuperar un servicio del sistema.
Estas transacciones se incluyen en muchas de las APIs que se comunican con el sistema. Por ejemplo, cuando se recupera un servicio del sistema con getSystemService
, se registra un receptor de transmisiones o se solicita un ConnectivityManager
.
Lamentablemente, estas transacciones no proporcionan mucha información sobre lo que solicitan, así que tienes que analizar el código según los usos de API mencionados y, luego, agregar una sección trace
personalizada para asegurarte de que se trata de la parte problemática.
Para mejorar las transacciones de Binder, sigue estos pasos:
- Abre el archivo
AccelerateHeavyScreen.kt
. - Ubica el elemento
PublishedText
componible. Este elemento formatea una fecha y una hora según la zona horaria actual, y registra un objetoBroadcastReceiver
que mantiene un registro de cambios de zona horaria. Contiene una variable de estadocurrentTimeZone
con la zona horaria predeterminada del sistema como valor inicial y, luego, unDisposableEffect
que registra un receptor de transmisiones para los cambios de zona horaria. Por último, este elemento componible muestra una fecha y hora formateada conText
.DisposableEffect
, que es una buena opción en esta situación porque necesitas contar con una manera de cancelar el registro del receptor de transmisiones, que se hace en la expresión lambdaonDispose
. La parte problemática, sin embargo, es que el código dentro deDisposableEffect
bloquea el subproceso principal:
@Composable
fun PublishedText(published: Instant, modifier: Modifier = Modifier) {
val context = LocalContext.current
var currentTimeZone: TimeZone by remember { mutableStateOf(TimeZone.currentSystemDefault()) }
DisposableEffect(Unit) {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
currentTimeZone = TimeZone.currentSystemDefault()
}
}
// TODO Codelab task: Wrap with a custom trace section
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
onDispose { context.unregisterReceiver(receiver) }
}
Text(
text = published.format(currentTimeZone),
style = MaterialTheme.typography.labelMedium,
modifier = modifier
)
}
- Une el
context.registerReceiver
con una llamadatrace
para asegurarte de que es esto lo que efectivamente causa todo elbinder transactions
:
trace("PublishDate.registerReceiver") {
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
}
En general, un código que se ejecuta durante tanto tiempo en el subproceso principal podría no causar muchos inconvenientes, pero el hecho de que esta transacción se ejecute para cada elemento visible en la pantalla sí podría ser la causa de problemas. Si se contempla que hay seis elementos visibles en la pantalla, estos se tienen que componer con el primer fotograma. Estas llamadas por sí solas pueden llevar 12 ms, que es casi la totalidad del tiempo límite para un fotograma.
Para corregir esto, tienes que transferir el registro de transmisión en un subproceso diferente. Puedes hacerlo con corrutinas.
- Obtén un alcance que esté vinculado al ciclo de vida del elemento componible
val scope = rememberCoroutineScope()
. - Dentro del efecto, lanza una corrutina en un despachador que no sea
Dispatchers.Main
. Por ejemplo, en este caso,Dispatchers.IO
. De este modo, el registro de transmisión no bloquea el subproceso principal, pero el estado realcurrentTimeZone
se mantiene en el subproceso principal.
val scope = rememberCoroutineScope()
DisposableEffect(Unit) {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
currentTimeZone = TimeZone.currentSystemDefault()
}
}
// launch the coroutine on Dispatchers.IO
scope.launch(Dispatchers.IO) {
trace("PublishDate.registerReceiver") {
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
}
}
onDispose { context.unregisterReceiver(receiver) }
}
Hay un paso más para la optimización. No necesitas un receptor de transmisiones para cada elemento de la lista, sino que necesitas uno solo y deberías elevarlo.
Puedes elevarlo y pasar el parámetro de zona horaria al árbol de elementos componibles o, dado que no se usa en muchas partes en tu IU, puedes usar datos locales de composición.
Para los fines de este codelab, conserva el receptor de transmisiones como parte del árbol de elementos componibles. Sin embargo, en la app real, podría ser beneficioso separarlo en una capa de datos para evitar contaminar el código de la IU.
- Define los datos locales de composición con la zona horaria predeterminada del sistema:
val LocalTimeZone = compositionLocalOf { TimeZone.currentSystemDefault() }
- Actualiza el elemento
ProvideCurrentTimeZone
componible que lleva una expresión lambdacontent
para proporcionar la zona horaria actual:
@Composable
fun ProvideCurrentTimeZone(content: @Composable () -> Unit) {
var currentTimeZone = TODO()
CompositionLocalProvider(
value = LocalTimeZone provides currentTimeZone,
content = content,
)
}
- Quita
DisposableEffect
del elementoPublishedText
componible y colócalo en el nuevo para elevarlo ahí, y reemplaza elcurrentTimeZone
con el estado y el efecto secundario:
@Composable
fun ProvideCurrentTimeZone(content: @Composable () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var currentTimeZone: TimeZone by remember { mutableStateOf(TimeZone.currentSystemDefault()) }
DisposableEffect(Unit) {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
currentTimeZone = TimeZone.currentSystemDefault()
}
}
scope.launch(Dispatchers.IO) {
trace("PublishDate.registerReceiver") {
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
}
}
onDispose { context.unregisterReceiver(receiver) }
}
CompositionLocalProvider(
value = LocalTimeZone provides currentTimeZone,
content = content,
)
}
- Une un elemento componible en el que desees que datos locales de composición sean válidos con el
ProvideCurrentTimeZone
. Puedes unir elAccelerateHeavyScreen
entero, según se muestra en el siguiente fragmento:
@Composable
fun AccelerateHeavyScreen(items: List<HeavyItem>, modifier: Modifier = Modifier) {
// TODO: Codelab task: Wrap this with timezone provider
ProvideCurrentTimeZone {
Box(
modifier = modifier
.fillMaxSize()
.padding(24.dp)
) {
ScreenContent(items = items)
if (items.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
}
- Cambia el elemento
PublishedText
componible para que solo contenga la funcionalidad de formateo básico y lea el valor actual de los datos locales de composición a través deLocalTimeZone.current
:
@Composable
fun PublishedText(published: Instant, modifier: Modifier = Modifier) {
Text(
text = published.format(LocalTimeZone.current),
style = MaterialTheme.typography.labelMedium,
modifier = modifier
)
}
- Vuelve a ejecutar la comparativa, que compila la app.
Como alternativa, puedes descargar el registro del sistema con el código corregido:
- Arrastra el archivo de registro al panel de Perfetto. Todas las secciones del
binder transactions
desaparecieron del subproceso principal. - Busca el nombre de la sección que es similar al paso anterior. Puedes buscarlo en uno de los otros subprocesos que crearon las corrutinas (
DefaultDispatch
):
7. Quita las subcomposiciones innecesarias
Quitaste el código pesado del subproceso principal, así que eso ya no bloquea la composición. Todavía hay aspectos por mejorar. Puedes quitar la sobrecarga innecesaria en forma de un elemento LazyRow
componible en cada elemento.
En el ejemplo, cada uno de los elementos contiene una fila de rastreadores, según se destaca en la siguiente imagen:
Esta fila está implementada con un elemento LazyRow
componible porque es sencillo programarlo de esta manera. Pasa los elementos al elemento LazyRow
componible, y este se ocupará del resto:
@Composable
fun ItemTags(tags: List<String>, modifier: Modifier = Modifier) {
// TODO: remove unnecessary lazy layout
LazyRow(
modifier = modifier
.padding(4.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
items(tags) { ItemTag(it) }
}
}
El problema es que, mientras los diseños de Lazy
se destacan entre diseños donde tienes muchos más elementos que el tamaño restringido, estos generan costos adicionales, algo que es innecesario cuando no se requiere la composición diferida.
Dada la naturaleza de los elementos Lazy
componibles, que usan un elemento SubcomposeLayout
componible, siempre se muestran como varios fragmentos de trabajo; primero, el contenedor y, luego, los elementos que se pueden ver en la pantalla en ese momento, que es el segundo fragmento de trabajo. También puedes buscar un registro compose:lazylist:prefetch
en el registro del sistema, que indica que elementos adicionales se incorporan en el viewport y, por lo tanto, se cargan previamente para estar listos con anticipación.
Para determinar de forma aproximada el tiempo que lleva en tu caso, abre el mismo archivo de registro. Puedes ver que hay secciones desconectadas del elemento principal. Cada elemento consta del elemento real que se compone y de los elementos rastreadores. De esta manera, cada elemento se compone en unos 2.5 ms, y si multiplicas este tiempo por la cantidad de elementos visibles, se obtiene otra buena parte de trabajo.
Para corregir esto, sigue estos pasos:
- Navega al archivo
AccelerateHeavyScreen.kt
y ubica el elementoItemTags
componible. - Cambia la implementación
LazyRow
por un elementoRow
componible que se itere en la listatags
, como se muestra en el siguiente fragmento:
@Composable
fun ItemTags(tags: List<String>, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.padding(4.dp)
.fillMaxWidth()
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
tags.forEach { ItemTag(it) }
}
}
- Vuelve a ejecutar la comparativa, que también compilará la app.
- Opcional: Descarga el registro del sistema con el código corregido:
- Busca las secciones de
ItemTag
, observa que lleva menos tiempo y usa la misma sección raíz deCompose:recompose
.
Una situación similar podría ocurrir con otros contenedores que usan un elemento SubcomposeLayout
componible, por ejemplo, un elemento BoxWithConstraints
componible. Puede abarcar la creación de los elementos en las secciones de Compose:recompose
, que podría no mostrarse directamente como un fotograma bloqueado, pero que puede ser visible para el usuario. Si puedes, intenta evitar un elemento BoxWithConstraints
componible en cada elemento, ya que solo podría ser necesario cuando compones una IU diferente según el espacio disponible.
En esta sección, aprendiste a corregir composiciones que llevan demasiado tiempo.
8. Compara los resultados con el análisis comparativo inicial
Ahora que terminaste de optimizar el rendimiento de la pantalla, deberías comparar los resultados de la comparación con el análisis inicial.
- Abre Test History en el panel de ejecución de Android Studio
- Selecciona la ejecución más antigua que se relaciona con la comparativa inicial sin ningún cambio y compara las métricas de
frameDurationCpuMs
yframeOverrunMs
. Deberías ver resultados similares a los de la siguiente tabla:
Antes
AccelerateHeavyScreenBenchmark_accelerateHeavyScreenCompilationFull
ImagePlaceholderCount min 20.0, median 20.0, max 20.0
ImagePlaceholderMs min 22.9, median 22.9, max 22.9
ItemTagCount min 80.0, median 80.0, max 80.0
ItemTagMs min 3.2, median 3.2, max 3.2
PublishDate.registerReceiverCount min 1.0, median 1.0, max 1.0
PublishDate.registerReceiverMs min 1.9, median 1.9, max 1.9
frameDurationCpuMs P50 5.4, P90 9.0, P95 10.5, P99 57.5
frameOverrunMs P50 -4.2, P90 -3.5, P95 -3.2, P99 74.9
Traces: Iteration 0
- Selecciona la ejecución más reciente que se relaciona con la comparativa con todas las optimizaciones. Deberías ver resultados similares a los de la siguiente tabla:
Después
AccelerateHeavyScreenBenchmark_accelerateHeavyScreenCompilationFull
ImagePlaceholderCount min 20.0, median 20.0, max 20.0
ImagePlaceholderMs min 2.9, median 2.9, max 2.9
ItemTagCount min 80.0, median 80.0, max 80.0
ItemTagMs min 3.4, median 3.4, max 3.4
PublishDate.registerReceiverCount min 1.0, median 1.0, max 1.0
PublishDate.registerReceiverMs min 1.1, median 1.1, max 1.1
frameDurationCpuMs P50 4.3, P90 7.7, P95 8.8, P99 33.1
frameOverrunMs P50 -11.4, P90 -8.3, P95 -7.3, P99 41.8
Traces: Iteration 0
Si verificas especialmente la fila frameOverrunMs
, puedes ver todos los percentiles mejorados:
P50 | P90 | P95 | P99 | |
antes | -4.2 | -3.5 | -3.2 | 74.9 |
después | -11.4 | -8.3 | -7.3 | 41.8 |
mejora | 171% | 137% | 128% | 44% |
En la sección siguiente, aprenderás a corregir una composición que se da con demasiada frecuencia.
9. Evita recomposiciones innecesarias
La composición tiene las siguientes 3 fases:
- La composición determina lo que se mostrará al compilar un árbol de elementos componibles.
- El diseño toma ese árbol y determina dónde aparecerán los elementos componibles en la pantalla.
- El dibujo plasma los elementos componibles en la pantalla.
Por lo general, el orden de estas fases es el mismo, y esto permite que los datos fluyan en una dirección desde la composición hasta el diseño y el dibujo, hasta producir un fotograma de IU.
BoxWithConstraints
, los diseños diferidos (por ejemplo, LazyColumn
o LazyVerticalGrid
) y todos los diseños basados en SubcomposeLayout
elementos componibles son excepciones notables, en las que la composición de los elementos secundarios depende de las fases de diseño de los elementos superiores.
Por lo general, la composición es la fase más costosa de ejecutar, ya que es la fase que implica la mayor cantidad de trabajo, y también puede que origines que otros elementos componibles no relacionados se recompongan.
La mayoría de los fotogramas contiene las tres fases, pero la de composición puede efectivamente omitir una fase por completo si no hay trabajo que hacer. Para incrementar el rendimiento de tu app, puedes aprovechar esta capacidad.
Cómo aplazar fases de composición con modificadores lambda
Las funciones de componibilidad se ejecutan en la fase de composición. Para permitir que el código se ejecute en otro momento, puedes proporcionarlo como una función lambda.
Para hacerlo, sigue estos pasos:
- Abre el archivo
PhasesComposeLogo.kt
. - Navega a la pantalla Task 2 de la app. Verás un logotipo que rebota en el borde de la pantalla.
- Abre el Inspector de diseño y, luego, inspecciona los recuentos de recomposición. Verás una cantidad de recomposiciones de rápido crecimiento.
- Opcional: Ubica el archivo
PhasesComposeLogoBenchmark.kt
y ejecútalo para recuperar el registro del sistema y ver la composición de la sección del registroPhasesComposeLogo
que se produce en cada fotograma. Las recomposiciones se muestran en un registro como secciones que se repiten con el mismo nombre.
- Si es necesario, cierra el generador de perfiles del Inspector de diseño y, luego, regresa al código. Verás el elemento
PhaseComposeLogo
componible que se parece a lo siguiente:
@Composable
fun PhasesComposeLogo() = trace("PhasesComposeLogo") {
val logo = painterResource(id = R.drawable.compose_logo)
var size by remember { mutableStateOf(IntSize.Zero) }
val logoPosition by logoPosition(size = size, logoSize = logo.intrinsicSize)
Box(
modifier = Modifier
.fillMaxSize()
.onPlaced {
size = it.size
}
) {
with(LocalDensity.current) {
Image(
painter = logo,
contentDescription = "logo",
modifier = Modifier.offset(logoPosition.x.toDp(), logoPosition.y.toDp())
)
}
}
}
El elemento logoPosition
componible contiene una lógica que cambia su estado con cada fotograma y se parece a lo siguiente:
@Composable
fun logoPosition(size: IntSize, logoSize: Size): State<IntOffset> =
produceState(initialValue = IntOffset.Zero, size, logoSize) {
if (size == IntSize.Zero) {
this.value = IntOffset.Zero
return@produceState
}
var xDirection = 1
var yDirection = 1
while (true) {
withFrameMillis {
value += IntOffset(x = MOVE_SPEED * xDirection, y = MOVE_SPEED * yDirection)
if (value.x <= 0 || value.x >= size.width - logoSize.width) {
xDirection *= -1
}
if (value.y <= 0 || value.y >= size.height - logoSize.height) {
yDirection *= -1
}
}
}
}
El estado se lee en el elemento PhasesComposeLogo
componible con el modificador Modifier.offset(x.dp, y.dp)
, que significa que lo lee en la composición.
Este modificador es el motivo por el que la app se recompone en cada fotograma de esta animación. En este caso, hay una alternativa sencilla: el modificador Offset
basado en lambda.
- Actualiza el elemento
Image
componible para que use el modificadorModifier.offset
, que acepta una expresión lambda que devuelve un objetoIntOffset
, como en el siguiente fragmento:
Image(
painter = logo,
contentDescription = "logo",
modifier = Modifier.offset { IntOffset(logoPosition.x, logoPosition.y) }
)
- Vuelve a ejecutar la app y verifica el inspector de diseño. Verás que la animación ya no genera ninguna recomposición.
Recuerda que no deberías tener que recomponer solo para ajustar el diseño de una pantalla, sobre todo durante el desplazamiento, lo que genera que se bloqueen fotogramas. La recomposición que se produce durante el desplazamiento casi siempre es innecesaria y se debería evitar.
Otros modificadores de lambda
El modificador Modifier.offset
no es el único modificador con la versión lambda. En la siguiente tabla, verás los modificadores comunes que se recompondrían cada vez, algo que se puede reemplazar por sus alternativas aplazadas cuando pasan un valor de estado que cambia frecuentemente:
Modificador común | Alternativa aplazada |
|
|
|
|
|
|
10. Aplaza fases de composición con diseño personalizado
El uso de un modificador basado en lambda es, a menudo, la manera más sencilla de evitar invalidar la composición, pero a veces no hay un modificador basado en lambda que haga lo que necesitas. En estos casos, puedes implementar directamente un diseño personalizado o, incluso, un elemento Canvas
componible para pasar de forma directa a la fase de dibujo. Las lecturas de estado de composición completadas dentro de un diseño personalizado solo invalidan el diseño y omiten la recomposición. Como lineamiento general, si solo quieres ajustar el diseño o el tamaño, pero no quieres agregar o quitar elementos componibles, en algunos casos, puedes lograr el efecto sin invalidar la composición en absoluto.
Para ello, sigue estos pasos:
- Abre el archivo
PhasesAnimatedShape.kt
y, luego, ejecuta la app. - Navega a la pantalla Task 3. Esta pantalla contiene una forma que cambia de tamaño cuando haces clic en un botón. El valor de tamaño se anima con la API de animación de Compose
animateDpAsState
.
- Abre el Inspector de diseño.
- Haz clic en Toggle size.
- Observa que la forma se recompone en cada fotograma de la animación.
El elemento MyShape
componible toma el objeto size
como un parámetro, que es una lectura del estado. Esto significa que cuando el objeto size
cambia, el elemento PhasesAnimatedShape
componible (el alcance de recomposición más cercano) se recompone y, subsecuentemente, el elemento MyShape
componible también se recompone porque sus entradas cambiaron.
Para omitir la recomposición, sigue estos pasos:
- Cambia el parámetro
size
a una función lambda para que los cambios de tamaño no recompongan directamente el elementoMyShape
componible:
@Composable
fun MyShape(
size: () -> Dp,
modifier: Modifier = Modifier
) {
// ...
- Actualiza el sitio de llamada en el elemento
PhasesAnimatedShape
componible para usar la función lambda:
MyShape(size = { size }, modifier = Modifier.align(Alignment.Center))
Cambiar el parámetro size
a una expresión lambda demora la lectura del estado. Ahora, eso ocurre cuando se invoca la función lambda.
- Cambia el cuerpo del elemento
MyShape
componible por la siguiente expresión:
Box(
modifier = modifier
.background(color = Purple80, shape = CircleShape)
.layout { measurable, _ ->
val sizePx = size()
.roundToPx()
.coerceAtLeast(0)
val constraints = Constraints.fixed(
width = sizePx,
height = sizePx,
)
val placeable = measurable.measure(constraints)
layout(sizePx, sizePx) {
placeable.place(0, 0)
}
}
)
En la primera línea de la expresión lambda de medida de modificador layout
, se invoca la función lambda size
. Esto ocurre dentro del modificador layout
, de modo que solo invalida el diseño, no la composición.
- Vuelve a ejecutar la app, navega hasta la pantalla Task 3 y, luego, abre el Inspector de diseño.
- Haz clic en Toggle Size y, a continuación, observa que el tamaño de la forma se anima igual que antes, pero el elemento
MyShape
componible no se recompone.
11. Evita recomposiciones con clases estables
La composición genera código que puede omitir la ejecución de un elemento componible si todos los parámetros de entrada son estables y no cambiaron desde la composición anterior. Un tipo es estable si es inmutable o si es posible que el motor de Compose sepa si su valor cambió entre recomposiciones.
Si el motor de Compose no tiene la certeza de que un elemento componible es estable, lo considerará inestable y no generará la lógica de código para omitir la recomposición, y esto significa que el elemento componible se recompondrá cada vez. Esto se puede producir cuando una clase no es de un tipo primitivo y ocurre una de las situaciones siguientes:
- Es una clase mutable. Por ejemplo, contiene una propiedad mutable.
- Es una clase que se define en un módulo Gradle que no usa Compose. No tiene una dependencia en el compilador de Compose.
- Se trata de una clase que contiene una propiedad inestable.
Este comportamiento puede ser indeseable en algunos casos, donde ocasiona problemas de rendimiento y se puede cambiar cuando haces lo siguiente:
- Cómo habilitar el modo de omisión avanzada
- Anota el parámetro con una anotación
@Immutable
o@Stable
. - Agrega la clase al archivo de configuración de estabilidad.
Para obtener más ayuda sobre la estabilidad, lee la documentación.
En esta tarea, tienes una lista de elementos que se pueden agregar, quitar o verificar, y tienes que asegurarte de que no se recomponga ningún elemento si no es necesario hacerlo. Existen dos tipos de elementos que se alternan entre los que se recrean cada vez y los que no lo hacen.
Los elementos que se recrean cada vez están aquí como simulación de un caso de uso real donde los datos provienen de una base de datos local (por ejemplo, Room o sqlDelight) o una fuente de datos remota (como solicitudes a la API o entidades de Firestore), y devuelven una nueva instancia del objeto cada vez que hay un cambio.
Varios elementos componibles tienen un modificador Modifier.recomposeHighlighter()
adjunto, que puedes encontrar en nuestro repositorio de GitHub. Este modificador muestra un borde siempre que se recompone un elemento componible y puede servir como solución temporaria alternativa frente al Inspector de diseño.
Cómo habilitar el modo de omisión avanzada
El compilador 1.5.4 y versiones posteriores de Jetpack Compose vienen con una opción para habilitar el modo de omisión avanzado, lo que significa que incluso los elementos componibles con parámetros inestables pueden generar código de omisión. Se espera que este modo reduzca de manera radical la cantidad de elementos componibles que no se pueden omitir en el proyecto, lo que mejorará el rendimiento sin cambios en el código.
Para los parámetros inestables, se compara la lógica de omisión respecto de la igualdad de instancias, y esto significa que el parámetro se omitirá si se pasa la misma instancia al elemento componible que en el caso anterior. Por el contrario, los parámetros estables usan la igualdad estructural (al llamar el método Object.equals()
) para determinar la lógica de omisión.
Además de la lógica de omisión, el modo de omisión avanzada también recuerda automáticamente las expresiones lambda que se incluyen en una función de componibilidad. Este hecho implica que no necesitas una llamada remember
para unir una función lambda, por ejemplo, una que llama un método ViewModel
.
El modo de omisión avanzada se puede habilitar en un módulo Gradle.
Para hacerlo, sigue estos pasos:
- Abre el archivo
build.gradle.kts
de la app. - Actualiza el bloque
composeCompiler
con el siguiente fragmento:
composeCompiler {
// Not required in Kotlin 2.0 final release
suppressKotlinVersionCompatibilityCheck = "2.0.0-RC1"
// This settings enables strong-skipping mode for all module in this project.
// As an effect, Compose can skip a composable even if it's unstable by comparing it's instance equality (===).
enableExperimentalStrongSkippingMode = true
}
Esto agrega el argumento de compilador experimentalStrongSkipping
al módulo Gradle.
- Haz clic en Sync project with Gradle files.
- Vuelve a compilar el proyecto.
- Abre la pantalla Task 5 y, luego, observa que los elementos que usan igualdad estructural están marcados con un ícono
EQU
y no se recomponen cuando interaccionas con la lista de elementos.
Sin embargo, otros tipos de elementos aún se recomponen. Los corregirás en el paso siguiente.
Cómo corregir la estabilidad con anotaciones
Como se mencionó anteriormente, con el módulo de omisión avanzada habilitado, un elemento componible omitirá su ejecución cuando el parámetro tenga la misma instancia que en la composición anterior. Sin embargo, este esquema no se aplica en situaciones en las que se proporciona una nueva instancia de la clase inestable con cada cambio.
En tu caso, la clase StabilityItem
es inestable porque contiene una propiedad LocalDateTime
inestable.
Para corregir la estabilidad de esta clase, sigue estos pasos:
- Navega al archivo
StabilityViewModel.kt
. - Ubica la clase
StabilityItem
y agrégale la anotación@Immutable
:
// TODO Codelab task: make this class Stable
@Immutable
data class StabilityItem(
val id: Int,
val type: StabilityItemType,
val name: String,
val checked: Boolean,
val created: LocalDateTime
)
- Vuelve a compilar la app.
- Navega a la pantalla Task 5 y observa que no se recomponga ninguno de los elementos de la lista.
Esta clase ahora usa la igualdad estructural para verificar si hubo cambios desde la composición anterior y, por ende, no hay recomposición.
Aún está el elemento componible que refiere a la fecha del cambio más reciente, que continúa con la recomposición, independientemente de lo que hayas hecho hasta ahora.
Cómo corregir la estabilidad con el archivo de configuración
El enfoque anterior funciona bien para las clases que forman parte de la base de código. Sin embargo, las clases que están fuera de tu alcance, como las clases de bibliotecas externas o las de bibliotecas estándar, no se pueden editar.
Puedes habilitar un archivo de configuración de estabilidad que tome las clases (con los posibles comodines) que se tratarán como estables.
Para hacerlo, sigue estos pasos:
- Navega al archivo
build.gradle.kts
de la app. - Agrega la opción
stabilityConfigurationFile
al bloquecomposeCompiler
:
composeCompiler {
...
stabilityConfigurationFile = project.rootDir.resolve("stability_config.conf")
}
- Sincroniza el proyecto con los archivos de Gradle.
- Abre el archivo
stability_config.conf
en la carpeta raíz de este proyecto, junto al archivoREADME.md
. - Agrega lo siguiente:
// TODO Codelab task: Make a java.time.LocalDate class stable.
java.time.LocalDate
- Vuelve a compilar la app. Si la fecha es la misma, la clase
LocalDateTime
no hará que el elemento Latest change was YYYY-MM-DD componible se vuelva a componer.
En tu app, puedes extender el archivo que contiene los patrones, de modo que no tendrás que escribir todas las clases que se deben considerar como estables. Así que, en tu caso, puedes usar el comodín java.time.*
, que considerará estables a todas las clases incluidas en el paquete, como Instant
, LocalDateTime
, ZoneId
y otras clases de java.time.
Con estos los pasos, no se recompone nada en esta pantalla, excepto el elemento que se agregó o con el que se interaccionó, que es el comportamiento esperado.
12. Felicitaciones
¡Felicitaciones! Optimizaste el rendimiento de una app de Compose. Si bien solo mostramos una pequeña parte de los problemas de rendimiento con los que te puedes encontrar en tu app, aprendiste a detectar otros problemas potenciales y a corregirlos.
¿Qué sigue?
Si no generaste un perfil de Baseline para tu app, te recomendamos que lo hagas.
Puedes seguir el codelab Cómo mejorar el rendimiento de la app con perfiles de Baseline. Si quieres obtener más información sobre la configuración de comparativas, consulta este Cómo inspeccionar el rendimiento de la app con Macrobenchmark.