Rendimiento de Compose

Jetpack Compose tiene como objetivo ofrecer un rendimiento excelente listo para usar. En esta página, se muestra cómo escribir y configurar tu app para obtener el mejor rendimiento, y se indican algunos patrones que debes evitar.

Antes de leer esta página, te recomendamos que te familiarices con los conceptos fundamentales de Compose en Acerca de Compose.

Configura tu app de forma correcta

Si el rendimiento de tu app es deficiente, es posible que haya un problema de configuración. Un buen primer paso es revisar las siguientes opciones de configuración.

Compila en el modo de lanzamiento y usa R8

Si tienes problemas de rendimiento, asegúrate de ejecutar tu app en el modo de lanzamiento. El modo de depuración es útil para detectar muchos problemas, pero implica un costo de rendimiento significativo y puede dificultar la detección de otros problemas de código que podrían afectar el rendimiento. También debes usar el compilador R8 para quitar el código innecesario de tu app. De forma predeterminada, la compilación en modo de lanzamiento usa automáticamente el compilador R8.

Usa un perfil de Baseline

Compose se distribuye como biblioteca, en lugar de formar parte de la plataforma de Android. Este enfoque nos permite actualizar Compose con frecuencia y admitir versiones anteriores de Android. Sin embargo, la distribución de Compose como biblioteca implica un costo. El código de la plataforma de Android ya está compilado e instalado en el dispositivo. Por otro lado, las bibliotecas se deben cargar cuando se inicia la app y se deben interpretar en el momento en que se requiere la funcionalidad. Esto puede ralentizar la app al inicio y cada vez que use una función de la biblioteca por primera vez.

Para mejorar el rendimiento, define los perfiles de Baseline. Estos perfiles definen las clases y los métodos necesarios para los recorridos críticos del usuario y se distribuyen con el APK de tu app. Durante la instalación de la app, ART compila ese código crítico con anticipación para que esté listo para usarse cuando se inicie la app.

No siempre es fácil definir un buen perfil de Baseline y, por ello, Compose se envía con uno de forma predeterminada. Es posible que no tengas que realizar ninguna tarea para ver este beneficio. Sin embargo, si decides definir tu propio perfil, es posible que generes uno que, en realidad, no mejore el rendimiento de la app. Debes probar el perfil para verificar que te ayude. Una buena forma de hacerlo es escribir pruebas de macrocomparativas para tu app y verificar los resultados de estas mientras escribes y revisas tu perfil de Baseline. Si deseas obtener un ejemplo a fin de escribir pruebas de macrocomparativas para tu IU de Compose, consulta este ejemplo de Compose.

Si quieres obtener un desglose detallado de los efectos del modo de lanzamiento, R8 y los perfiles de referencia, consulta la entrada de blog en la que se explican lasrazones por las que siempre deberías probar el rendimiento de Compose en el lanzamiento.

Cómo afectan al rendimiento las tres fases de Compose

Como se explica en Fases de Jetpack Compose, cuando Compose actualiza un fotograma, atraviesa por tres fases:

  • Composición: Compose determina qué mostrar; ejecuta funciones que admiten composición y compila el árbol de IU.
  • Diseño: Compose determina el tamaño y la posición de cada elemento en el árbol de IU.
  • Dibujo: En realidad, Compose renderiza los elementos individuales de la IU.

Compose puede omitir de manera inteligente cualquiera de esas fases si no es necesaria. Por ejemplo, supongamos que un solo elemento gráfico cambia entre dos íconos del mismo tamaño. Como ese elemento no cambia de tamaño y no se agregan ni quitan elementos del árbol de IU, Compose puede omitir las fases de composición y diseño, y solo volver a dibujar ese elemento.

Sin embargo, algunos errores de codificación pueden dificultar que Compose sepa las fases que puede omitir con seguridad. Si hay dudas, Compose termina ejecutando las tres fases, lo que puede causar que tu IU sea más lenta de lo necesario. Por lo tanto, muchas de las prácticas recomendadas de rendimiento se relacionan con ayudar a Compose a omitir las fases que no necesita hacer.

Se deben seguir algunos principios generales que pueden mejorar el rendimiento en general.

Primero, cuando sea posible, quita los cálculos de las funciones que admiten composición. Es posible que se deban volver a ejecutar las funciones que admiten composición cada vez que cambia la IU; cualquier código que coloques en el elemento que admite composición se volverá a ejecutar, potencialmente, para cada fotograma de una animación. Por lo tanto, debes limitar el código del elemento que admite composición a lo que realmente necesita para compilar la IU.

Y, en segundo lugar, aplaza las lecturas de estado el mayor tiempo posible. Si mueves la lectura de estado a un elemento secundario que admite composición o a una fase posterior, puedes minimizar la recomposición, o bien omitir la fase de composición por completo. Para ello, puedes pasar las funciones lambda en lugar del valor de un estado que cambia con frecuencia y puedes priorizar los modificadores basados en lambda cuando pasas un estado de este tipo. Puedes ver un ejemplo de esta técnica en la sección Aplaza las lecturas el mayor tiempo posible.

En la siguiente sección, se describen algunos errores de código específicos que pueden causar este tipo de problemas. Esperamos que los ejemplos específicos que se abordan en esta sección también te ayuden a detectar otros errores similares en tu código.

Usa herramientas para encontrar problemas

Puede ser difícil saber dónde se encuentra un problema de rendimiento y qué código comenzar a optimizar. Comienza por usar herramientas que te permitan identificar tu problema.

Obtén recuentos de recomposiciones

Puedes usar el Inspector de diseño para consultar la frecuencia con la que se omite o se vuelve a componer un elemento que admite composición.

Recuentos de recomposición que se muestran en el Inspector de diseño

Para obtener más información, consulta la sección de herramientas.

Sigue las prácticas recomendadas

Es posible que encuentres algunos errores comunes de Compose. Estos errores pueden brindarte un código que parece ejecutarse lo suficientemente bien, pero que puede afectar el rendimiento de la IU. En esta sección, se incluyen algunas prácticas recomendadas para ayudarte a evitarlos.

Usa remember para minimizar cálculos costosos

Las funciones que admiten composición pueden ejecutarse con mucha frecuencia, con la misma que para cada fotograma de una animación. Por esta razón, debes hacer el menor cálculo posible en el cuerpo de tu elemento que admite composición.

Una técnica importante es almacenar los resultados de los cálculos con remember. De esa manera, el cálculo se ejecuta una vez, y los resultados se pueden recuperar siempre que sea necesario.

Por ejemplo, a continuación, te mostramos parte de un código que muestra una lista ordenada de nombres, pero que la ordena de forma simple y muy costosa:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // DON’T DO THIS
        items(contacts.sortedWith(comparator)) { contact ->
            // ...
        }
    }
}

El problema es que, cada vez que se vuelve a componer ContactsList, la lista de contactos completa se vuelve a ordenar por completo, aunque la lista no haya cambiado. Si el usuario se desplaza por la lista, el elemento que admite composición se vuelve a componer cada vez que aparece una fila nueva.

Para resolver este problema, ordena la lista fuera de LazyColumn y almacena la lista ordenada con remember:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    val sortedContacts = remember(contacts, sortComparator) {
        contacts.sortedWith(sortComparator)
    }

    LazyColumn(modifier) {
        items(sortedContacts) {
          // ...
        }
    }
}

Ahora, la lista se ordena una vez, cuando ContactList se compone por primera vez. Si los contactos o el comparador cambian, se vuelve a generar la lista ordenada. De lo contrario, el elemento que admite composición puede continuar usando la lista ordenada en caché.

Usa claves de diseño diferido

Los diseños diferidos hacen todo lo posible para volver a utilizar, de manera inteligente, los elementos, solo volviendo a generar o componer cuando sea necesario. Sin embargo, puedes ayudar a tomar las mejores decisiones.

Supongamos que una operación del usuario causa que un elemento se mueva en la lista. Por ejemplo, imagina que muestras una lista de notas ordenadas según la hora de modificación, con la última nota modificada en la parte superior.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes
        ) { note ->
            NoteRow(note)
        }
    }
}

Sin embargo, hay un problema con este código. Supongamos que se cambia la nota inferior. Ahora es la última nota modificada, por lo que va a la parte superior de la lista, y todas las demás notas se mueven un lugar hacia abajo.

El problema es que, sin tu ayuda, Compose no se da cuenta de que solo se mueven elementos sin cambios en la lista. En su lugar, Compose cree que se borró el "elemento 2" anterior y se creó uno nuevo para el elemento 3, el elemento 4 y todos los demás hasta el final de la lista. El resultado es que Compose vuelve a componer todos los elementos de la lista, aunque, en realidad, solo cambie uno de ellos.

La solución aquí es proporcionar claves de elemento. Proporcionar una clave estable para cada elemento permite que Compose evite recomposiciones innecesarias. En este caso, Compose puede ver que el elemento ahora en el punto 3 es el mismo que solía estar en el punto 2. Dado que ninguno de los datos de ese elemento cambió, Compose no tiene que volver a componerlo.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes,
             key = { note ->
                // Return a stable, unique key for the note
                note.id
            }
        ) { note ->
            NoteRow(note)
        }
    }
}

Usa derivedStateOf para limitar las recomposiciones

Un riesgo de uso del estado en tus composiciones es que, si el estado cambia con rapidez, es posible que la IU se vuelva a componer más de lo necesario. Por ejemplo, supongamos que muestras una lista en la que se puede hacer desplazamientos. Examinas el estado de la lista para ver qué elemento es el primero visible en esta:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

El problema es que, si el usuario se desplaza por la lista, listState cambia de forma constante, a medida que arrastra el dedo. Es decir, la lista se vuelve a componer de manera constante. Sin embargo, en realidad, no necesitas volver a componer con tanta frecuencia; no necesitas hacerlo hasta que un elemento nuevo se vuelva visible en la parte inferior. Por lo tanto, se realiza una gran cantidad de cálculos adicionales, lo que causa que tu IU tenga un rendimiento deficiente.

La solución es usar el estado derivado. El estado derivado te permite indicarle a Compose qué cambios de estado deberían activar la recomposición. En este caso, especifica que te interesa cuándo cambia el primer elemento visible. Cuando cambia ese valor de estado, la IU se debe volver a componer. Sin embargo, si el usuario aún no se desplazó lo suficiente para traer un nuevo elemento a la parte superior, no tiene que volver a componerse.

val listState = rememberLazyListState()

LazyColumn(state = listState) {
  // ...
  }

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

Aplaza las lecturas el mayor tiempo posible

Debes aplazar las lecturas de las variables de estado el mayor tiempo posible. Aplazar las lecturas de estado puede ayudar a garantizar que Compose vuelva a ejecutar el código mínimo posible en la recomposición. Por ejemplo, si la IU tiene un estado elevado en el árbol que admite composición y lees el estado en un elemento secundario que también la admite, puedes unir esa lectura de estado en una función lambda. De esta manera, la lectura ocurrirá solo cuando en realidad se necesite. Puedes consultar cómo aplicamos este enfoque a la app de ejemplo de Jetsnack. Jetsnack implementa un efecto similar a la barra de herramientas cuando se contrae en su pantalla de detalles.

Para lograr este efecto, el elemento Title que admite composición debe conocer la compensación de desplazamiento, de modo que se pueda desplazar por sí mismo con Modifier. A continuación, se muestra una versión simplificada del código de Jetsnack, antes de realizar la optimización:

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value)
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    // ...
    val offset = with(LocalDensity.current) { scroll.toDp() }

    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

Cuando cambia el estado de desplazamiento, Compose busca el alcance de recomposición superior más cercano y lo invalida. En este caso, el elemento superior más cercano es el elemento Box que admite composición. Por lo tanto, Compose vuelve a componer el objeto Box y también los elementos que admiten composición dentro de este. Si cambias tu código para que solo lea el estado en el que realmente lo usas, podrías reducir la cantidad de elementos que deben volver a componerse.

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) { scroll.value }
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    val offset = with(LocalDensity.current) { scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
    // ...
    }
}

El parámetro de desplazamiento ahora es lambda. Es decir, Title aún puede hacer referencia al estado elevado, pero el valor solo se lee dentro de Title, donde en realidad se necesita. Como resultado, cuando cambia el valor de desplazamiento, el alcance de la recomposición más cercano ahora es el elemento Title que admite composición. Compose ya no necesita volver a componer todo el elemento Box.

Esta es una gran cambio, pero puedes hacerlo mejor. Debes sospechar si generas la recomposición solo para volver a diseñar o dibujar un elemento que admite composición. En este caso, lo único que debes hacer es cambiar el desplazamiento del elemento Title que admite composición, lo que podría hacerse en la fase de diseño.

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    Column(
        modifier = Modifier
            .offset { IntOffset(y = scrollProvider()) }
    ) {
      // ...
    }
}

Anteriormente, el código usaba Modifier.offset(x: Dp, y: Dp), que toma el desplazamiento como parámetro. Si cambias a la versión lambda del modificador, puedes asegurarte de que la función lea el estado de desplazamiento en la fase de diseño. Como resultado, cuando cambia el estado de desplazamiento, Compose puede omitir la fase de composición por completo y pasar directamente a la fase de diseño. Cuando, con frecuencia, pasas variables de estado que cambian a modificadores, debes usar las versiones lambda de los modificadores siempre que sea posible.

Este es otro ejemplo de este enfoque. Este código aún no se optimizó:

// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)

Box(Modifier.fillMaxSize().background(color))

En este código, el color del fondo del cuadro cambia rápidamente entre dos colores. Por lo tanto, este estado cambia con mucha frecuencia. Luego, el elemento que admite composición lee este estado en el modificador en segundo plano. Como resultado, el cuadro debe volver a componerse en cada fotograma, ya que el color cambia en cada uno.

Para mejorar esto, podemos usar un modificador basado en lambda, en este caso, drawBehind. Es decir, el estado de color solo se lee durante la fase de dibujo. Como resultado, Compose puede omitir por completo las fases de composición y diseño; cuando cambia el color, Compose va directamente a la fase de dibujo.

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
   Modifier
      .fillMaxSize()
      .drawBehind {
         drawRect(color)
      }
)

Evita las escrituras hacia atrás

Compose tiene la suposición principal de que nunca escribirás en un estado que ya se haya leído. Cuando lo haces, se denomina escritura hacia atrás, y puede producirse una recomposición en todos los fotogramas, de forma infinita.

El siguiente elemento que admite composición muestra un ejemplo de este tipo de error.

@Composable
fun BadComposable() {
    var count by remember { mutableStateOf(0) }

    // Causes recomposition on click
    Button(onClick = { count++ }, Modifier.wrapContentSize()) {
        Text("Recompose")
    }

    Text("$count")
    count++ // Backwards write, writing to state after it has been read
}

Este código actualiza el recuento al final del elemento que admite composición después de leerlo en la línea anterior. Si ejecutas este código, verás que, después de hacer clic en el botón, lo que causa una recomposición, el contador aumenta con rapidez en un bucle infinito, ya que Compose vuelve a recomponer este elemento que admite composición, muestra una lectura de estado desactualizada, por lo que programa otra recomposición.

Puedes evitar la escritura hacia atrás por completo si nunca escribes en el estado en Composition. Si es posible, escribe siempre en el estado en respuesta a un evento y en una lambda, como en el ejemplo anterior de onClick.