Pérdidas de memoria en la programación reactiva de Android: cómo detectarlas, depurarlas y corregirlas.
Patrones RxJava y LiveData que provocan fugas de memoria inesperadas
Es difícil imaginar crear una aplicación de Android en 2022 en lugar de adoptar RxJava, Datos en tiempo realo proceso de KotlinTan poderosas como son estas bibliotecas, también pueden ser una fuente de fugas de memoria inesperadas y difíciles de encontrar. Recientemente descubrimos dos vulnerabilidades muy sutiles que han estado presentes en nuestra aplicación durante meses. En este artículo, exploraremos por qué y resumiremos brevemente las herramientas disponibles para depurar pérdidas de memoria.
Hace aproximadamente un año, refactorizamos la mayor parte del código base heredado restante para seguir nuestro moderno MVVM arquitectura. Siempre estamos muy emocionados cuando limpiamos el código, implementamos los cambios. Pero… nuestra emoción se calmó después de que comenzamos a recibir informes de bloqueos aleatorios en varias áreas de la aplicación.
Los registros de Crashlytics no son útiles.vemos ilocalizable tío Errores en todas las pantallas de la aplicación. Literalmente vimos cientos de bloqueos en diferentes partes del código base, cada uno con un seguimiento de pila diferente.
Entonces, ¿cómo depurar esta situación? Buscas patrones.
Después de investigar los registros, encontramos algunos puntos en común. ¡Todos los usuarios que se bloquean deslizan mucho!
Lo siguiente que vamos a hacer es tratar de replicar esa escena.abrí perfilador de memoria en Android Studio y comienza a deslizar el perfil. Luego tuve un momento de «¡oh, oh!». La memoria asignada sigue aumentando cuando deslizo sin signos de disminución:
¿Qué sucedió? ¿No es el trabajo del recolector de basura (GC) limpiar objetos que ya no se necesitan y liberar memoria?sí, pero el objeto debe ser Ya no es necesario!
Ciertamente no necesito un perfil que haya deslizado 50 veces antes en la memoria.Por eso uso un ViewPager
en el interior ProfileViewActivity
Puede contener hasta tres ProfileViewFragment
una vez (actualmente visible, anterior, siguiente)Pero… aparentemente nuestro código dice lo contrario.
Lo siguiente que hice fue capturar un volcado de pila para analizar los objetos asignados en la memoria, y esto es lo que obtuve:
⚠️ 33 fugas! ️33 instancias ProfileViewFragment
Actualmente en la memoria! No es de extrañar que la memoria asignada siga aumentando a medida que deslizo.
Luego revisé el código en ese fragmento y vi esta cosita aterradora:
Resulta que registramos el oyente del fragmento para el resto del código base heredado, ¡pero nunca cancelamos el registro! (Otón es una biblioteca de bus de eventos que era muy popular antes de que RxJava se convirtiera en la corriente principal. )
el oyente sostiene un fuerte referencia al fragmento, el detector aún está activo incluso después de que se destruye el fragmento, y el GC asume que necesitamos el objeto aunque no sea así. En otras palabras, el oyente filtra el fragmento. Esta es la pérdida de memoria:
Un objeto en la memoria que ya no se usa es referenciado por otro objeto que está en uso, lo que hace que el GC no lo borre y libere la memoria.
notas: En los lenguajes que utilizan el conteo automático de referencias (ARC) como Swift, si tenemos dos objetos que tienen fuertes referencias entre sí, incluso sin ninguna referencia externa, se producirá una fuga de memoria.Se llama periodo de retenciónEsto no suele ser un problema en Android porque Java GC usa un algoritmo de marca y barrido.mira esto entrada en el blog para obtener información interesante sobre la simetría entre los dos métodos de GC.
¿Qué tiene que ver con los errores OOM? Android establece un límite estricto en el tamaño del almacenamiento dinámico asignado a cada aplicación, según el dispositivo y la memoria RAM disponible.Cuantas más fugas tengamos, más rápido se alcanzará la capacidad del montón, por lo que el sistema no podrá asignar más memoria para los nuevos objetos que intentaremos crear, lanzará un OutOfMemoryError
.
Esta es exactamente la razón por la cual los errores OOM pueden ocurrir en cualquier parte de su código, incluso en clases que no causan pérdidas de memoria, lo que hace que los seguimientos de la pila sean inútiles y que los problemas sean difíciles de depurar.
Desde entonces, nuestra conciencia sobre las fugas de memoria ha aumentado y realizamos comprobaciones periódicas para confirmar que no estamos perdiendo memoria.
Echemos un vistazo a otros casos del mundo real que hemos encontrado en nuestra aplicación que provocan pérdidas de memoria.
recuerdas esos RecyclerView.Adapter
¿Antes de migrar a Jetpack Compose? Bueno, estoy bastante seguro de que muchas bases de código aún dependen de ellos porque Compose es bastante nuevo.
Digamos que tienes un fragmento que representa RecyclerView
a través de un Adapter
¿Cómo observará la lista de elementos y actualizará Adapter
Por lo general, tendrá un LiveData
en tu proyecto ViewModel
te suscribes y luego usas DiffUtil
.
Pero, ¿dónde definirías esa suscripción?Hay dos opciones posibles: crear una suscripción en el fragmento y enviar la lista actualizada de elementos a Adapter
o puede pasar su ViewModel
llegar Adapter
y tiene Adapter
Observa los cambios directamente.
En el último caso, tiene muchas posibilidades de crear una pérdida de memoria.
Primero, veamos este código:
queremos init
cuadra Adapter
El primer argumento de LiveData
observe()
método es un LifecycleOwner
, que puede ser cualquier clase con un ciclo de vida de Android.Si iniciamos la suscripción en una actividad o fragmento entonces usaremos this
Vincule la suscripción al ciclo de vida de la actividad o al propio fragmento.
pero.. this
no funciona en el alcance Adapter
porque Adapter
No relacionado con el ciclo de vida de Android.hmm… pero en nuestro ejemplo sí pasamos Context
al constructor Adapter
De esta forma podemos acceder al recurso y cargar la imagen.Esto probablemente se puede usar como LifecycleOwner
¿correcto? Después de todo, es el contexto del fragmento, por lo que está ligado al ciclo de vida.
Intentemos esto:
¿Está compilando? ✅
¿No hay quejas de linter? ✅
¿Funciona como se esperaba cuando ejecutamos la aplicación? ✅
Pero como habrás adivinado, ¡acabamos de crear una pérdida de memoria!Aunque usamos fragmentos Context
en el interior Adapter
Que Context
es en realidad la actividad que alberga el fragmento.Los fragmentos no pueden tener Context
Haz una actividad.
¿Qué significa esto para nuestras suscripciones? Esto significa que nuestra suscripción está ligada al ciclo de vida de la actividad. Las suscripciones permanecen activas mientras la actividad esté activa. Entonces, si retiramos el fragmento, incluso si ya no lo necesitamos, el GC no puede liberar esa memoria porque hay una suscripción activa en el adaptador del fragmento.
Tenemos un fragmento en la memoria que ya no se necesita y no se puede limpiar. Esto es especialmente problemático en una arquitectura de actividad única, donde el fragmento se recrea constantemente cuando el usuario ingresa a la pantalla correspondiente, pero nunca se borra hasta que el usuario sale de la aplicación.
Para arreglar esto, en lugar de lanzar Context
a un LifecycleOwner
Podemos pasar explícitamente el ciclo de vida de la vista del fragmento a nuestro constructor Adapter
y vincular nuestra suscripción a ella:
De esta manera, la suscripción se libera automáticamente una vez que se destruye la vista del Fragmento.
Observar un flujo RxJava es más simple que un LiveData
Suscripciones, principalmente por la flexibilidad y variedad de operadores que brinda RxJava.
Además, RxJava nos permite usar PublishSubject
Esto es imposible LiveData
No hay una solución alternativa.
Sin embargo, una cosa importante que perdimos fue la capacidad de vincular las suscripciones al ciclo de vida de Android.Como mencionamos en el ejemplo anterior, cuando observe()
Una especie de LiveData
Necesitamos especificar un LifecycleOwner
Esto permitirá que la suscripción se procese una vez automáticamente LifecycleOwner
Se destruido. De esta manera, no tenemos que preocuparnos por borrar la suscripción cuando se destruye la Actividad o el Fragmento.
Veamos la siguiente secuencia de RxJava:
Empecemos a suscribirnos a nuestro ViewModel
Observe los mensajes de chat mientras chateamos con los usuarios y actualice el estado cuando se actualice la lista de mensajes.
Pero la suscripción nunca se desecha, por lo que incluso si ViewModel
se borra y permanecerá activo. Esto significa que la actividad o el fragmento asociado no se liberará de la memoria incluso después de que el usuario salga de esa pantalla. En otras palabras, tenemos una pérdida de memoria.
Para protegernos de las fugas de memoria relacionadas con las suscripciones de RxJava en nuestra aplicación, establecimos los siguientes dos patrones:
Definir un RxViewModel
nuestro ViewModel
Heredar de la base RxViewModel
Esto eliminará automáticamente la suscripción. onCleared()
Se llama cuando se destruye la actividad o el fragmento asociado:
Lo único que debemos hacer es agregar cada suscripción a nuestro disposables
De esta forma se limpiarán automáticamente:
Limpie la actividad de MVVM y el ciclo de vida de los fragmentos
Hemos desarrollado nuestros propios métodos de ciclo de vida para actividades y fragmentos que incluyen (como protegernos de El proceso de Android muere), también nos protegen del mal uso de las suscripciones RxJava. Puede leer más en la siguiente serie de publicaciones de blog, que describe nuestro enfoque en detalle:
Esta fuga no tiene nada que ver con la programación reactiva y puede ser una falla en el diseño o la implementación. ConnectivityManager
Este servicio nos permite comprobar si los usuarios tienen una conexión a Internet activa y si están utilizando Wi-Fi o datos móviles. Descubrimos esta fuga mientras depurábamos una de las fugas de memoria anteriores.
Para obtener la instancia actual ConnectivityManager
nosotros podemos usar getSystemService()
Ocupaciones:
Resulta que aunque ConnectivityManager
mantener par Context
fue provistoEn nuestro caso proporcionamos implícitamente Context
actividades, porque ConnectivityManager
Manteniendo una fuerte referencia a ella, nuestra actividad no puede borrarse de la memoria incluso después de haber sido destruida.
La solución fue simple, aunque definitivamente no está documentada.en lugar de llamar getSystemService()
En nuestra actividad utilizaremos getSystemService()
Contexto de aplicación:
Por lo tanto, ConnectivityManager
Mantendrá una fuerte referencia al contexto de la aplicación, que es exactamente lo que queremos para los singletons, ya que están vinculados a la vida útil de la aplicación.
Las herramientas más populares que tenemos para detectar y depurar fugas de memoria son Perfilador de Android y fuga canario.
Perfilador de Android
Esta documento El análisis del uso de la memoria en Android Studio es bastante extenso, pero en general comenzamos abriendo Profiler (Ver > Ventanas de herramientas > Perfilador), inicie una nueva sesión y seleccione el dispositivo y el proceso que queremos depurar:
Entonces tenemos que elegir memoria Opciones Y comience a interactuar con la aplicación, principalmente ingresando y saliendo de actividades y fragmentos sospechosos.Finalmente, hacemos clic captura de volcado de pila opciones y seleccione Registro.
En el volcado de almacenamiento dinámico capturado, podemos ver todos los objetos de la aplicación almacenados en la memoria en ese momento, incluidas las fugas de memoria. Si seleccionamos el objeto que se está filtrando, podemos ver todas sus instancias y todas las referencias a él. Esto puede ayudarnos a identificar la fuente de la fuga.
fuga canario
fuga canario es una biblioteca de código abierto que se ejecuta junto con la aplicación, observando onDestroy()
Eventos de actividad y fragmentos, y registro de objetos retenidos. Después de alcanzar un cierto umbral de objetos retenidos, capturará un volcado de almacenamiento dinámico y notificará a nuestro dispositivo sobre posibles pérdidas de memoria. La desventaja de este enfoque es que congela la aplicación cada vez que se vuelca el montón, porque el volcado del montón ocurre en el mismo proceso que la aplicación.
Nuestra experiencia reciente con estas fugas de memoria muestra que son difíciles de inferir o predecir solo a partir de la revisión del código. Cualquier aplicación que se actualice regularmente también se debe considerar como una mejor práctica para ejecutar pruebas ad hoc utilizando las herramientas anteriores varias veces al año, y definitivamente después de una refactorización importante.
Stelios Frantzeskakis es ingeniero de software sénior Software de la calle PerryEditor de aplicaciones de citas LGBTQ+ nuca y Jackcon más de 30 millones de miembros en todo el mundo.