Casos de uso y ejemplos de reglas de conservación

Los siguientes ejemplos se basan en situaciones comunes en las que usas R8 para la optimización, pero necesitas orientación avanzada para redactar reglas de conservación.

Reflexión

En general, para obtener un rendimiento óptimo, no se recomienda usar la reflexión. Sin embargo, en ciertas situaciones, podría ser inevitable. En los siguientes ejemplos, se brinda orientación sobre las reglas de conservación en situaciones comunes que usan la reflexión.

Reflexión con clases cargadas por nombre

Las bibliotecas suelen cargar clases de forma dinámica usando el nombre de la clase como un String. Sin embargo, R8 no puede detectar las clases que se cargan de esta manera y podría quitar las clases que considera que no se usan.

Por ejemplo, considera la siguiente situación en la que tienes una biblioteca y una app que usa la biblioteca. El código muestra un cargador de biblioteca que crea una instancia de una interfaz StartupTask implementada por una app.

El código de la biblioteca es el siguiente:

// The interface for a task that runs once.
interface StartupTask {
    fun run()
}

// The library object that loads and executes the task.
object TaskRunner {
    fun execute(className: String) {
        // R8 won't retain classes specified by this string value at runtime
        val taskClass = Class.forName(className)
        val task = taskClass.getDeclaredConstructor().newInstance() as StartupTask
        task.run()
    }
}

La app que usa la biblioteca tiene el siguiente código:

// The app's task to pre-cache data.
// R8 will remove this class because it's only referenced by a string.
class PreCacheTask : StartupTask {
    override fun run() {
        // This log will never appear if the class is removed by R8.
        Log.d("AppTask", "Warming up the cache...")
    }
}

fun onCreate() {
    // The library is told to run the app's task by its name.
    TaskRunner.execute("com.example.app.PreCacheTask")
}

En esta situación, tu biblioteca debe incluir un archivo de reglas de conservación del consumidor con las siguientes reglas de conservación:

-keep class * implements com.example.library.StartupTask {
    <init>();
}

Sin esta regla, R8 quita PreCacheTask de la app porque esta no usa la clase directamente, lo que interrumpe la integración. La regla encuentra las clases que implementan la interfaz StartupTask de tu biblioteca y las conserva, junto con su constructor sin argumentos, lo que permite que la biblioteca cree instancias y ejecute PreCacheTask correctamente.

Reflexión con ::class.java

Las bibliotecas pueden cargar clases haciendo que la app pase el objeto Class directamente, lo que es un método más sólido que cargar clases por nombre. Esto crea una referencia sólida a la clase que R8 puede detectar. Sin embargo, si bien esto evita que R8 quite la clase, aún debes usar una regla de conservación para declarar que la clase se instancia de forma reflexiva y para proteger los miembros a los que se accede de forma reflexiva, como el constructor.

Por ejemplo, considera la siguiente situación en la que tienes una biblioteca y una app que la usa. El cargador de la biblioteca crea una instancia de una interfaz StartupTask pasando la referencia de clase directamente.

El código de la biblioteca es el siguiente:

// The interface for a task that runs once.
interface StartupTask {
    fun run()
}
// The library object that loads and executes the task.
object TaskRunner {
    fun execute(taskClass: Class<out StartupTask>) {
        // The class isn't removed, but its constructor might be.
        val task = taskClass.getDeclaredConstructor().newInstance()
        task.run()
    }
}

La app que usa la biblioteca tiene el siguiente código:

// The app's task is to pre-cache data.
class PreCacheTask : StartupTask {
    override fun run() {
        Log.d("AppTask", "Warming up the cache...")
    }
}

fun onCreate() {
    // The library is given a direct reference to the app's task class.
    TaskRunner.execute(PreCacheTask::class.java)
}

En esta situación, tu biblioteca debe incluir un archivo de reglas de conservación del consumidor con las siguientes reglas de conservación:

# Allow any implementation of StartupTask to be removed if unused.
-keep,allowobfuscation,allowshrinking class * implements com.example.library.StartupTask
# Keep the default constructor, which is called via reflection.
-keepclassmembers class * implements com.example.library.StartupTask {
    <init>();
}

Estas reglas están diseñadas para funcionar perfectamente con este tipo de reflexión, lo que permite una optimización máxima y, al mismo tiempo, garantiza que el código funcione correctamente. Las reglas permiten que R8 ofusque el nombre de la clase y reduzca o quite la implementación de la clase StartupTask si la app nunca la usa. Sin embargo, para cualquier implementación, como la PrecacheTask que se usa en el ejemplo, conservan el constructor predeterminado (<init>()) que tu biblioteca necesita llamar.

  • -keep,allowobfuscation,allowshrinking class * implements com.example.library.StartupTask: Esta regla se aplica a cualquier clase que implemente tu interfaz StartupTask.
    • -keep class * implements com.example.library.StartupTask: Esto conserva cualquier clase (*) que implemente tu interfaz.
    • ,allowobfuscation: Indica a R8 que, a pesar de mantener la clase, puede cambiarle el nombre o ofuscarla. Esto es seguro porque tu biblioteca no depende del nombre de la clase; obtiene el objeto Class directamente.
    • ,allowshrinking: Este modificador indica a R8 que puede quitar la clase si no se usa. Esto ayuda a R8 a borrar de forma segura una implementación de StartupTask que nunca se pasa a TaskRunner.execute(). En resumen, esta regla implica lo siguiente: Si una app usa una clase que implementa StartupTask, R8 conserva la clase. R8 puede cambiar el nombre de la clase para reducir su tamaño y puede borrarla si la app no la usa.
  • -keepclassmembers class * implements com.example.library.StartupTask { <init>(); }: Esta regla se dirige a miembros específicos de las clases que se identificaron en la primera regla, en este caso, el constructor.
    • -keepclassmembers class * implements com.example.library.StartupTask: Conserva miembros específicos (métodos, campos) de la clase que implementa la interfaz StartupTask, pero solo si se conserva la clase implementada.
    • { <init>(); }: Este es el selector de miembros. <init> es el nombre interno especial de un constructor en el código de bytes de Java. Esta parte se dirige específicamente al constructor predeterminado sin argumentos.
    • Esta regla es fundamental porque tu código llama a getDeclaredConstructor().newInstance() sin ningún argumento, lo que invoca de forma reflexiva al constructor predeterminado. Sin esta regla, R8 ve que ningún código llama directamente a new PreCacheTask(), supone que el constructor no se usa y lo quita. Esto provoca que tu app falle en el tiempo de ejecución con unInstantiationException.
run

Reflexión basada en la anotación del método

Las bibliotecas suelen definir anotaciones que los desarrolladores usan para etiquetar métodos o campos. Luego, la biblioteca usa la reflexión para encontrar estos miembros anotados en el tiempo de ejecución. Por ejemplo, la anotación @OnLifecycleEvent se usa para encontrar los métodos requeridos en el tiempo de ejecución.

Por ejemplo, considera la siguiente situación en la que tienes una biblioteca y una app que usa la biblioteca. El ejemplo muestra un bus de eventos que encuentra e invoca métodos anotados con @OnEvent.

El código de la biblioteca es el siguiente:

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class OnEvent

class EventBus {
    fun dispatch(listener: Any) {
        // Find all methods annotated with @OnEvent and invoke them
        listener::class.java.declaredMethods.forEach { method ->
            if (method.isAnnotationPresent(OnEvent::class.java)) {
                try {
                    method.invoke(listener)
                } catch (e: Exception) { /* ... */ }
            }
        }
    }
}

La app que usa la biblioteca tiene el siguiente código:

class MyEventListener {
    @OnEvent
    fun onSomethingHappened() {
        // This method will be removed by R8 without a keep rule
        Log.d(TAG, "Event received!")
    }
}

fun onCreate() {
    // Instantiate the listener and the event bus
    val listener = MyEventListener()
    val eventBus = EventBus()

    // Dispatch the listener to the event bus
    eventBus.dispatch(listener)
}

La biblioteca debe incluir un archivo de reglas de conservación del consumidor que conserve automáticamente cualquier método que use sus anotaciones:

-keepattributes RuntimeVisibleAnnotations
-keep @interface com.example.library.OnEvent;
-keepclassmembers class * {
    @com.example.library.OnEvent <methods>;
}
  • -keepattributes RuntimeVisibleAnnotations: Esta regla conserva las anotaciones que se deben leer durante el tiempo de ejecución.
  • -keep @interface com.example.library.OnEvent: Esta regla conserva la clase de anotación OnEvent en sí.
  • -keepclassmembers class * {@com.example.library.OnEvent <methods>;}: Esta regla conserva una clase y miembros específicos solo si se usa la clase y esta contiene esos miembros.
    • -keepclassmembers: Esta regla conserva una clase y miembros específicos solo si se usa la clase y esta contiene esos miembros.
    • class *: La regla se aplica a cualquier clase.
    • @com.example.library.OnEvent <methods>;: Conserva cualquier clase que tenga uno o más métodos (<methods>) anotados con @com.example.library.OnEvent y también conserva los métodos anotados.

Reflexión basada en las anotaciones de la clase

Las bibliotecas pueden usar la reflexión para buscar clases que tengan una anotación específica. En este caso, la clase del ejecutor de tareas encuentra todas las clases anotadas con ReflectiveExecutor usando la reflexión y ejecuta el método execute.

Por ejemplo, considera la siguiente situación en la que tienes una biblioteca y una app que la usa.

La biblioteca tiene el siguiente código:

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
annotation class ReflectiveExecutor

class TaskRunner {
    fun process(task: Any) {
        val taskClass = task::class.java
        if (taskClass.isAnnotationPresent(ReflectiveExecutor::class.java)) {
            val methodToCall = taskClass.getMethod("execute")
            methodToCall.invoke(task)
        }
    }
}

La app que usa la biblioteca tiene el siguiente código:

// In consumer app

@ReflectiveExecutor
class ImportantBackgroundTask {
    fun execute() {
        // This class will be removed by R8 without a keep rule
        Log.e("ImportantBackgroundTask", "Executing the important background task...")
    }
}

// Usage of ImportantBackgroundTask

fun onCreate(){
    val task = ImportantBackgroundTask()
    val runner = TaskRunner()
    runner.process(task)
}

Dado que la biblioteca usa la reflexión de forma reflexiva para obtener clases específicas, debe incluir un archivo de reglas de conservación del consumidor con las siguientes reglas de conservación:

# Retain annotation metadata for runtime reflection.
-keepattributes RuntimeVisibleAnnotations

# Keep the annotation interface itself.
-keep @interface com.example.library.ReflectiveExecutor

# Keep the execute method in the classes which are being used
-keepclassmembers @com.example.library.ReflectiveExecutor class * {
   public void execute();
}

Esta configuración es muy eficiente porque le indica a R8 exactamente qué conservar.

Reflexión para admitir dependencias opcionales

Un caso de uso común de la reflexión es crear una dependencia flexible entre una biblioteca principal y una biblioteca complementaria opcional. La biblioteca principal puede verificar si el complemento está incluido en la app y, si lo está, puede habilitar funciones adicionales. Esto te permite enviar módulos de complementos sin obligar a la biblioteca principal a tener una dependencia directa de ellos.

La biblioteca principal usa la reflexión (Class.forName) para buscar una clase específica por su nombre. Si se encuentra la clase, se habilita la función. De lo contrario, falla de forma correcta.

Por ejemplo, considera el siguiente código en el que un AnalyticsManager principal verifica si hay una clase VideoEventTracker opcional para habilitar las estadísticas de video.

La biblioteca principal tiene el siguiente código:

object AnalyticsManager {
    private const val VIDEO_TRACKER_CLASS = "com.example.analytics.video.VideoEventTracker"

    fun initialize() {
        try {
            // Attempt to load the optional module's class using reflection
            Class.forName(VIDEO_TRACKER_CLASS).getDeclaredConstructor().newInstance()
            Log.d(TAG, "Video tracking enabled.")
        } catch (e: ClassNotFoundException) {
            Log.d(TAG,"Video tracking module not found. Skipping.")
        } catch (e: Exception) {
            Log.e(TAG, e.printStackTrace())
        }
    }
}

La biblioteca de videos opcional tiene el siguiente código:

package com.example.analytics.video

class VideoEventTracker {
    // This constructor must be kept for the reflection call to succeed.
    init { /* ... */ }
}

El desarrollador de la biblioteca opcional es responsable de proporcionar la regla de conservación del consumidor necesaria. Esta regla de conservación garantiza que cualquier app que use la biblioteca opcional conserve el código que necesita la biblioteca principal para encontrar.

# In the video library's consumer keep rules file
-keep class com.example.analytics.video.VideoEventTracker {
    <init>();
}

Sin esta regla, es probable que R8 quite VideoEventTracker de la biblioteca opcional, ya que nada en ese módulo la usa directamente. La regla de conservación preserva la clase y su constructor, lo que permite que la biblioteca principal la cree una instancia correctamente.

Reflexión para acceder a miembros privados

Usar la reflexión para acceder a código privado o protegido que no forma parte de la API pública de una biblioteca puede generar problemas significativos. Este código está sujeto a cambios sin previo aviso, lo que puede provocar comportamientos inesperados o fallas en tu aplicación.

Cuando dependes de la reflexión para las APIs no públicas, es posible que surjan los siguientes problemas:

  • Actualizaciones bloqueadas: Los cambios en el código privado o protegido pueden impedir que actualices a versiones superiores de la biblioteca.
  • Beneficios perdidos: Es posible que te pierdas nuevas funciones, correcciones importantes de fallas o actualizaciones de seguridad esenciales.

Optimizaciones de R8 y reflexión

Si debes realizar una reflexión en el código privado o protegido de una biblioteca, presta mucha atención a las optimizaciones de R8. Si no hay referencias directas a estos miembros, es posible que R8 suponga que no se usan y, posteriormente, los quite o les cambie el nombre. Esto puede provocar fallas durante el tiempo de ejecución, a menudo con mensajes de error engañosos, como NoSuchMethodException o NoSuchFieldException.

Por ejemplo, considera la siguiente situación que demuestra cómo podrías acceder a un campo privado desde una clase de biblioteca.

Una biblioteca que no es tuya tiene el siguiente código:

class LibraryClass {
    private val secretMessage = "R8 will remove me"
}

Tu app tiene el siguiente código:

fun accessSecretMessage(instance: LibraryClass) {
    // Use Java reflection from Kotlin to access the private field
    val secretField = instance::class.java.getDeclaredField("secretMessage")
    secretField.isAccessible = true
    // This will crash at runtime with R8 enabled
    val message = secretField.get(instance) as String
}

Agrega una regla -keep en tu app para evitar que R8 quite el campo privado:

-keepclassmembers class com.example.LibraryClass {
    private java.lang.String secretMessage;
}
  • -keepclassmembers: Esto conserva miembros específicos de una clase solo si la clase en sí se retiene.
  • class com.example.LibraryClass: Segmenta la clase exacta que contiene el campo.
  • private java.lang.String secretMessage;: Identifica el campo privado específico por su nombre y tipo.

Interfaz nativa de Java (JNI)

Las optimizaciones de R8 pueden tener problemas cuando se trabaja con llamadas ascendentes desde código nativo (C/C++) a Java o Kotlin. Si bien lo contrario también es cierto (las llamadas descendentes de Java o Kotlin al código nativo pueden tener problemas), el archivo predeterminado proguard-android-optimize.txt incluye la siguiente regla para que las llamadas descendentes sigan funcionando. Esta regla protege contra el recorte de métodos nativos.

-keepclasseswithmembernames,includedescriptorclasses class * {
  native <methods>;
}

Interacción con código nativo a través de la interfaz nativa de Java (JNI)

Cuando tu app usa JNI para realizar llamadas ascendentes desde código nativo (C/C++) a Java o Kotlin, R8 no puede ver qué métodos se llaman desde tu código nativo. Si no hay referencias directas a estos métodos en tu app, R8 supone incorrectamente que no se usan y los quita, lo que provoca que la app falle.

En el siguiente ejemplo, se muestra una clase de Kotlin con un método que se puede llamar desde una biblioteca nativa. La biblioteca nativa crea una instancia de un tipo de aplicación y pasa datos del código nativo al código de Kotlin.

package com.example.models

// This class is used in the JNI bridge method signature
data class NativeData(val id: Int, val payload: String)
package com.example.app
// In package com.example.app
class JniBridge {
    /**
     *   This method is called from the native side.
     *   R8 will remove it if it's not kept.
     */
    fun onNativeEvent(data: NativeData) {
        Log.d(TAG, "Received event from native code: $data")
    }
    // Use 'external' to declare a native method
    external fun startNativeProcess()

    companion object {
        init {
            // Load the native library
            System.loadLibrary("my-native-lib")
        }
    }
}

En este caso, debes informar a R8 para evitar que se optimice el tipo de aplicación. Además, si los métodos llamados desde el código nativo usan tus propias clases en sus firmas como parámetros o tipos de devolución, también debes verificar que no se cambie el nombre de esas clases.

Agrega las siguientes reglas de conservación a tu app:

-keepclassmembers,includedescriptorclasses class com.example.JniBridge {
    public void onNativeEvent(com.example.model.NativeData);
}

-keep class NativeData{
        <init>(java.lang.Integer, java.lang.String);
}

Estas reglas de conservación evitan que R8 quite o cambie el nombre del método onNativeEvent y, lo que es fundamental, su tipo de parámetro.

  • -keepclassmembers,includedescriptorclasses class com.example.JniBridge{ public void onNativeEvent(com.example.model.NativeData);}: Conserva miembros específicos de una clase solo si la clase se instancia primero en código Kotlin o Java. Le indica a R8 que la app usa la clase y que debe conservar miembros específicos de la clase.
    • -keepclassmembers: Conserva miembros específicos de una clase solo si la clase se instancia primero en código Kotlin o Java. Le indica a R8 que la app usa la clase y que debe conservar miembros específicos de la clase.
    • class com.example.JniBridge: Segmenta la clase exacta que contiene el campo.
    • includedescriptorclasses: Este modificador también conserva las clases que se encuentran en la firma o el descriptor del método. En este caso, evita que R8 cambie el nombre o quite la clase com.example.models.NativeData, que se usa como parámetro. Si se cambiara el nombre de NativeData (por ejemplo, a a.a), la firma del método ya no coincidiría con lo que espera el código nativo, lo que provocaría una falla.
    • public void onNativeEvent(com.example.models.NativeData);: Especifica la firma exacta de Java del método que se debe conservar.
  • -keep class NativeData{<init>(java.lang.Integer, java.lang.String);}: Si bien includedescriptorclasses garantiza que la clase NativeData en sí se conserve, todos los miembros (campos o métodos) dentro de NativeData a los que se accede directamente desde tu código JNI nativo necesitan sus propias reglas de conservación.
    • -keep class NativeData: Este destino se dirige a la clase llamada NativeData y el bloque especifica qué miembros dentro de la clase NativeData se deben conservar.
    • <init>(java.lang.Integer, java.lang.String): Esta es la firma del constructor. Identifica de forma única el constructor que toma dos parámetros: el primero es un Integer y el segundo es un String.

Llamadas indirectas a la plataforma

Transfiere datos con una implementación de Parcelable

El framework de Android usa la reflexión para crear instancias de tus objetos Parcelable. En el desarrollo moderno de Kotlin, debes usar el complemento kotlin-parcelize, que genera automáticamente la implementación de Parcelable necesaria, incluidos el campo CREATOR y los métodos que necesita el framework.

Por ejemplo, considera el siguiente ejemplo en el que se usa el complemento kotlin-parcelize para crear una clase Parcelable:

import android.os.Parcelable
import kotlinx.parcelize.Parcelize

// Add the @Parcelize annotation to your data class
@Parcelize
data class UserData(
    val name: String,
    val age: Int
) : Parcelable

En este caso, no hay una regla de conservación recomendada. El complemento de Gradle kotlin-parcelize genera automáticamente las reglas de conservación requeridas para las clases que anotas con @Parcelize. Maneja la complejidad por ti y se asegura de que los constructores y CREATOR generados se conserven para las llamadas de reflexión del framework de Android.

Si escribes una clase Parcelable de forma manual en Kotlin sin usar @Parcelize, eres responsable de mantener el campo CREATOR y el constructor que acepta un Parcel. Si olvidas hacerlo, la app fallará cuando el sistema intente deserializar tu objeto. Usar @Parcelize es la práctica estándar y más segura.

Cuando uses el complemento kotlin-parcelize, ten en cuenta lo siguiente:

  • El complemento crea automáticamente campos CREATOR durante la compilación.
  • El archivo proguard-android-optimize.txt contiene las reglas keep necesarias para conservar estos campos y garantizar la funcionalidad adecuada.
  • Los desarrolladores de aplicaciones deben verificar que estén presentes todas las reglas de keep requeridas, en especial para las implementaciones personalizadas o las dependencias de terceros.