¿Qué hace que un RecyclerView o un LazyColumn de Compose sean rápidos (o lentos)?
Ambos widgets prometen un scroll infinito fluido al no materializar nunca por completo un ítem fuera de pantalla — pero esa promesa se rompe en cuanto te saltas las keys estables, el diffing granular o el alcance de recomposición por ítem.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Qué hace que un RecyclerView o un LazyColumn de Compose sean rápidos (o lentos)?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#La idea compartida: nunca pagar por lo que no está en pantalla
RecyclerView y LazyColumn existen para resolver el mismo problema que un ScrollView ingenuo lleno de hijos no puede resolver: no infles, midas, dispongas, compongas ni mantengas en memoria nada que el usuario no esté viendo en ese momento (más un pequeño buffer). Todo lo que cualquiera de los dos widgets hace bien o mal se reduce a qué tan fielmente respeta esa idea — y las dos operaciones más costosas en cualquiera de las dos plataformas son, en orden, inflar/componer un ítem completamente nuevo desde cero y ejecutar una actualización de lista completa que toca cada fila. Las listas rápidas minimizan ambas; las listas lentas las disparan constantemente sin que nadie lo note.
#RecyclerView: los tres niveles del Recycler
El Recycler de RecyclerView mantiene las vistas fuera de pantalla en tres niveles progresivamente más "fríos". El scrap adjunto (attached scrap) contiene vistas separadas a mitad de un paso de layout que están por reasignarse a una nueva posición — no necesitan rebind, es la reutilización más barata posible, y en su mayoría es invisible para ti como desarrollador. El caché (dimensionado con setItemViewCacheSize, 2 por defecto) contiene vistas recién salidas de pantalla que aún recuerdan su posición y datos exactos — si vuelves a hacer scroll hacia arriba rápido, estas regresan sin ningún rebind. Solo cuando una vista sale del caché termina en el RecycledViewPool, agrupada por viewType, despojada de cualquier identidad posicional — reutilizar una de estas siempre cuesta una llamada completa a onBindViewHolder(), aunque se salta la mucho más cara onCreateViewHolder()/inflado. Una pantalla de feed con cinco formas de fila distintas y solo tres instancias pooleadas por tipo (el máximo por defecto) va a agotar el pool constantemente si no se aumenta su tamaño con setMaxRecycledViews; un RecyclerView horizontal anidado (un carrusel de "historias" dentro de un feed vertical) debería compartir un único RecycledViewPool entre todas las instancias de fila mediante setRecycledViewPool(), o cada fila construirá y descartará su propio pool cada vez que salga de pantalla.
Lo que hace que un RecyclerView pase de rápido a lento es casi siempre uno de tres hábitos. Primero, llamar a notifyDataSetChanged() para cualquier cambio, por pequeño que sea — descarta todo el conocimiento posicional y fuerza un rebind y relayout completo de cada fila visible, matando las animaciones de ítems en el proceso. DiffUtil (usado directamente, o de forma transparente a través de ListAdapter) calcula el conjunto mínimo de operaciones de inserción/eliminación/movimiento/cambio en un hilo de fondo y despacha las llamadas granulares notifyItem* por ti. Segundo, ignorar getChangePayload() — incluso con DiffUtil, un cambio de contenido completo vuelve a enlazar toda la fila; un payload (digamos, solo un delta en el conteo de likes) permite que onBindViewHolder(holder, position, payloads) actualice un solo TextView y se salte por completo volver a decodificar la imagen de la fila. Tercero, no configurar setHasStableIds(true) con IDs reales derivados del contenido — sin IDs estables, RecyclerView no puede distinguir claramente "este es el mismo ítem lógico que se movió" de "esta posición ahora tiene contenido distinto", lo que se manifiesta como animaciones de ítem defectuosas y rebinds innecesarios al reordenar.
#Compose: sin ViewHolder, pero el mismo instinto
LazyColumn no poolea un objeto de vista real como lo hace RecyclerView, pero implementa el mismo instinto a través de la composición. Los ítems que salen de pantalla no necesariamente se descartan de inmediato — Compose mantiene un pequeño pool de slots de nodos previamente compuestos pero ahora fuera de pantalla, y cuando un nuevo ítem entra en pantalla, intenta reutilizar uno de esos slots (sus nodos de layout, su estado interno recordado donde la forma lo permite) en lugar de componer desde cero. Dos parámetros de items() deciden qué tan bien funciona esto: key, que le da a cada ítem una identidad estable independiente de su posición para que Compose pueda rastrear "esta es la misma fila lógica" a través de un cambio de datos (si falta, Compose recurre a identidad basada en el índice, así que insertar una fila al principio hace que, para Compose, cada fila debajo parezca haber cambiado); y contentType, que — de forma muy similar a viewType en RecyclerView — segrega el pool de reutilización para que el slot de un encabezado nunca se le ofrezca inútilmente a la forma de una fila de foto.
El otro modo de falla específico de Compose es un alcance de recomposición demasiado amplio. Cada lambda pasada a items { post -> ... } es su propio alcance reiniciable: leer post.likeCount dentro de ese lambda significa que solo esa fila se recompone cuando cambia el conteo de likes. Leer estado derivado de toda la lista — como listState.firstVisibleItemIndex — directamente dentro de un composable que también renderiza cada fila acopla la recomposición de cada fila a que ese valor cambie en cada píxel de scroll; envolverlo en derivedStateOf colapsa "cambia en cada píxel" en "cambia solo cuando el booleano derivado realmente cambia de valor", y leer el valor derivado solo donde se necesita mantiene acotado el radio de impacto. La misma disciplina que hizo rápido a onBindViewHolder — hacer el mínimo trabajo, con el alcance más acotado posible — aplica aquí como "hacer la mínima recomposición, con el alcance más acotado posible".
class FeedDiffCallback : DiffUtil.ItemCallback<FeedPost>() {override fun areItemsTheSame(old: FeedPost, new: FeedPost) = old.id == new.idoverride fun areContentsTheSame(old: FeedPost, new: FeedPost) = old == new// Un payload permite que onBindViewHolder se salte las partes costosas de un rebind.override fun getChangePayload(old: FeedPost, new: FeedPost): Any? =if (old.likeCount != new.likeCount && old.copy(likeCount = new.likeCount) == new) {LikeCountPayload(new.likeCount)} else null}class FeedAdapter : ListAdapter<FeedPost, FeedAdapter.PostViewHolder>(FeedDiffCallback()) {init { setHasStableIds(true) }override fun getItemId(position: Int): Long = getItem(position).idoverride fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PostViewHolder {val binding = ItemFeedPostBinding.inflate(LayoutInflater.from(parent.context), parent, false)return PostViewHolder(binding)}override fun onBindViewHolder(holder: PostViewHolder, position: Int) {holder.bind(getItem(position))}override fun onBindViewHolder(holder: PostViewHolder, position: Int, payloads: MutableList<Any>) {val likePayload = payloads.filterIsInstance<LikeCountPayload>().firstOrNull()if (likePayload != null) {holder.bindLikeCountOnly(likePayload.count) // se salta volver a decodificar la imagen de la fila} else {super.onBindViewHolder(holder, position, payloads)}}class PostViewHolder(private val binding: ItemFeedPostBinding) : RecyclerView.ViewHolder(binding.root) {fun bind(post: FeedPost) { /* bind completo: imagen, autor, caption, conteo de likes */ }fun bindLikeCountOnly(count: Int) {binding.likeCountText.text = count.toString()}}data class LikeCountPayload(val count: Int)}// Un RecyclerView anidado de "historias" comparte un único pool entre todas las instancias de fila.val sharedStoriesPool = RecyclerView.RecycledViewPool()outerFeedRecyclerView.setRecycledViewPool(sharedStoriesPool)
@Composablefun FeedList(posts: List<FeedPost>, onLike: (Long) -> Unit) {val listState = rememberLazyListState()// isPastFold solo cambia — y solo recompone a quien lo lee — cuando el// booleano realmente cambia de valor, no en cada píxel de scroll.val isPastFold by remember {derivedStateOf { listState.firstVisibleItemIndex > 2 }}LazyColumn(state = listState) {items(items = posts,key = { post -> post.id }, // identidad estable a través de cambios de datoscontentType = { post -> post.type }, // HEADER, IMAGE_POST, AD — refleja viewType) { post ->FeedRow(post = post, onLike = onLike)}}if (isPastFold) {JumpToTopButton(onClick = { /* scroll hacia arriba */ })}}@Composableprivate fun FeedRow(post: FeedPost, onLike: (Long) -> Unit) {// Leer post.likeCount aquí acota la recomposición a ESTA fila únicamente,// no a todo el LazyColumn, cuando se actualiza un solo post.Row(Modifier.fillMaxWidth().padding(12.dp)) {AsyncImage(model = post.thumbnailUrl, contentDescription = null)Text(text = "${post.likeCount} likes")}}