Casi d'uso ed esempi di regole

Gli esempi seguenti si basano su scenari comuni in cui utilizzi R8 per l'ottimizzazione, ma hai bisogno di indicazioni avanzate per creare regole di conservazione.

Riflesso

In generale, per prestazioni ottimali, non è consigliabile utilizzare la reflection. Tuttavia, in alcuni scenari, potrebbe essere inevitabile. Gli esempi seguenti forniscono indicazioni per le regole di conservazione in scenari comuni che utilizzano la reflection.

Reflection con classi caricate per nome

Le librerie spesso caricano le classi in modo dinamico utilizzando il nome della classe come String. Tuttavia, R8 non è in grado di rilevare le classi caricate in questo modo e potrebbe rimuovere le classi che considera inutilizzate.

Ad esempio, considera lo scenario seguente in cui hai una libreria e un'app che utilizza la libreria. Il codice mostra un caricatore di librerie che crea un'istanza di un'interfaccia StartupTask implementata da un'app.

Il codice della libreria è il seguente:

// 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()
    }
}

L'app che utilizza la libreria ha il seguente codice:

// 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")
}

In questo scenario, la tua raccolta deve includere un file di regole di conservazione dei consumatori con le seguenti regole di conservazione:

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

Senza questa regola, R8 rimuove PreCacheTask dall'app perché l'app non utilizza la classe direttamente, interrompendo l'integrazione. La regola trova le classi che implementano l'interfaccia StartupTask della tua libreria e le conserva, insieme al relativo costruttore senza argomenti, consentendo alla libreria di istanziare ed eseguire correttamente PreCacheTask.

Riflessione con ::class.java

Le librerie possono caricare le classi facendo passare direttamente l'oggetto Class all'app, un metodo più affidabile rispetto al caricamento delle classi per nome. In questo modo viene creata un riferimento forte alla classe che R8 può rilevare. Tuttavia, anche se ciò impedisce a R8 di rimuovere la classe, devi comunque utilizzare una regola di conservazione per dichiarare che la classe viene istanziata in modo riflessivo e per proteggere i membri a cui si accede in modo riflessivo, come il costruttore.

Ad esempio, considera lo scenario seguente in cui hai una libreria e un'app che la utilizza: il caricatore della libreria crea un'interfaccia StartupTask passando direttamente il riferimento alla classe.

Il codice della libreria è il seguente:

// 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()
    }
}

L'app che utilizza la libreria ha il seguente codice:

// 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)
}

In questo scenario, la tua raccolta deve includere un file di regole di conservazione dei consumatori con le seguenti regole di conservazione:

# 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>();
}

Queste regole sono progettate per funzionare perfettamente con questo tipo di riflessione, consentendo la massima ottimizzazione e assicurando che il codice funzioni correttamente. Le regole consentono a R8 di offuscare il nome della classe e di ridurre o rimuovere l'implementazione della classe StartupTask se l'app non la utilizza mai. Tuttavia, per qualsiasi implementazione, ad esempio PrecacheTask utilizzato nell'esempio, viene conservato il costruttore predefinito (<init>()) che la libreria deve chiamare.

  • -keep,allowobfuscation,allowshrinking class * implements com.example.library.StartupTask: questa regola ha come target qualsiasi classe che implementa l'interfaccia StartupTask.
    • -keep class * implements com.example.library.StartupTask: in questo modo viene mantenuta qualsiasi classe (*) che implementa l'interfaccia.
    • ,allowobfuscation: indica a R8 che, nonostante la classe venga mantenuta, può rinominarla o offuscarla. Questo è sicuro perché la tua libreria non si basa sul nome della classe, ma ottiene l'oggetto Class direttamente.
    • ,allowshrinking: questo modificatore indica a R8 che può rimuovere la classe se non viene utilizzata. In questo modo R8 può eliminare in sicurezza un'implementazione di StartupTask che non viene mai passata a TaskRunner.execute(). In breve, questa regola implica quanto segue: se un'app utilizza una classe che implementa StartupTask, R8 mantiene la classe. R8 può rinominare la classe per ridurne le dimensioni ed eliminarla se l'app non la utilizza.
  • -keepclassmembers class * implements com.example.library.StartupTask { <init>(); }: Questa regola ha come target membri specifici delle classi identificate nella prima regola, in questo caso il costruttore.
    • -keepclassmembers class * implements com.example.library.StartupTask: conserva membri specifici (metodi, campi) della classe che implementa l'interfaccia StartupTask, ma solo se la classe implementata stessa viene conservata.
    • { <init>(); }: questo è il selettore dei membri. <init> è il nome interno speciale di un costruttore nel bytecode Java. Questa parte ha come target specifico il costruttore predefinito senza argomenti.
    • Questa regola è fondamentale perché il codice chiama getDeclaredConstructor().newInstance() senza argomenti, il che richiama in modo riflessivo il costruttore predefinito. Senza questa regola, R8 vede che nessun codice chiama direttamente new PreCacheTask(), presuppone che il costruttore non venga utilizzato e lo rimuove. Ciò causa l'arresto anomalo dell'app in fase di esecuzione con unInstantiationException.

Riflessione basata sull'annotazione del metodo

Le librerie spesso definiscono annotazioni che gli sviluppatori utilizzano per taggare metodi o campi. La libreria utilizza quindi la reflection per trovare questi membri annotati in fase di runtime. Ad esempio, l'annotazione @OnLifecycleEvent viene utilizzata per trovare i metodi richiesti in fase di runtime.

Ad esempio, considera lo scenario seguente in cui hai una libreria e un'app che la utilizza. L'esempio mostra un bus di eventi che trova e richiama i metodi annotati con @OnEvent.

Il codice della libreria è il seguente:

@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) { /* ... */ }
            }
        }
    }
}

L'app che utilizza la libreria ha il seguente codice:

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 libreria deve includere un file di regole di conservazione per i consumatori che conservi automaticamente tutti i metodi che utilizzano le relative annotazioni:

-keepattributes RuntimeVisibleAnnotations
-keep @interface com.example.library.OnEvent;
-keepclassmembers class * {
    @com.example.library.OnEvent <methods>;
}
  • -keepattributes RuntimeVisibleAnnotations: questa regola conserva le annotazioni che devono essere lette in fase di runtime.
  • -keep @interface com.example.library.OnEvent: questa regola conserva la classe di annotazione OnEvent stessa.
  • -keepclassmembers class * {@com.example.library.OnEvent <methods>;}: Questa regola conserva una classe e membri specifici solo se la classe viene utilizzata e contiene questi membri.
    • -keepclassmembers: questa regola conserva una classe e membri specifici solo se la classe viene utilizzata e contiene questi membri.
    • class *: la regola si applica a qualsiasi classe.
    • @com.example.library.OnEvent <methods>;: in questo modo viene mantenuta qualsiasi classe che abbia uno o più metodi (<methods>) annotati con @com.example.library.OnEvent e vengono mantenuti anche i metodi annotati.

Riflessione basata sulle annotazioni della classe

Le librerie possono utilizzare la reflection per cercare classi con un'annotazione specifica. In questo caso, la classe di esecuzione delle attività trova tutte le classi annotate con ReflectiveExecutor utilizzando la reflection ed esegue il metodo execute.

Ad esempio, considera il seguente scenario in cui hai una libreria e un'app che utilizza la libreria.

La libreria contiene il seguente codice:

@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)
        }
    }
}

L'app che utilizza la libreria ha il seguente codice:

// 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)
}

Poiché la libreria utilizza la reflection per ottenere classi specifiche, la libreria deve includere un file di regole di conservazione per i consumatori con le seguenti regole di conservazione:

# 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();
}

Questa configurazione è molto efficiente perché indica a R8 esattamente cosa conservare.

Reflection per supportare le dipendenze facoltative

Un caso d'uso comune per la reflection è la creazione di una dipendenza debole tra una libreria di base e una libreria aggiuntiva facoltativa. La libreria principale può verificare se il componente aggiuntivo è incluso nell'app e, in caso affermativo, può attivare funzionalità aggiuntive. In questo modo puoi spedire moduli aggiuntivi senza forzare la libreria principale ad avere una dipendenza diretta da questi.

La libreria principale utilizza la reflection (Class.forName) per cercare una classe specifica in base al nome. Se il corso viene trovato, la funzionalità viene attivata. In caso contrario, non va a buon fine.

Ad esempio, considera il seguente codice in cui un controllo AnalyticsManager di base verifica la presenza di una classe VideoEventTracker facoltativa per attivare l'analisi video.

La libreria principale contiene il seguente codice:

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 libreria video facoltativa contiene il seguente codice:

package com.example.analytics.video

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

Lo sviluppatore della libreria facoltativa è responsabile della fornitura della regola di conservazione necessaria per i consumatori. Questa regola di conservazione assicura che qualsiasi app che utilizza la libreria opzionale conservi il codice necessario alla libreria principale per essere trovato.

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

Senza questa regola, R8 probabilmente rimuove VideoEventTracker dalla libreria facoltativa poiché nulla in questo modulo lo utilizza direttamente. La regola keep conserva la classe e il suo costruttore, consentendo alla libreria principale di istanziarla correttamente.

Riflessione per accedere ai membri privati

L'utilizzo della reflection per accedere a codice privato o protetto che non fa parte dell'API pubblica di una libreria può causare problemi significativi. Questo codice è soggetto a modifiche senza preavviso, il che può portare a comportamenti imprevisti o arresti anomali nell'applicazione.

Quando utilizzi la reflection per le API non pubbliche, potresti riscontrare i seguenti problemi:

  • Aggiornamenti bloccati:le modifiche al codice privato o protetto possono impedire l'aggiornamento a versioni successive della libreria.
  • Vantaggi mancati:potresti perdere nuove funzionalità, importanti correzioni di arresti anomali o aggiornamenti della sicurezza essenziali.

Ottimizzazioni e riflessioni di R8

Se devi eseguire la reflection nel codice privato o protetto di una libreria, presta molta attenzione alle ottimizzazioni di R8. Se non sono presenti riferimenti diretti a questi membri, R8 potrebbe presupporre che non vengano utilizzati e quindi rimuoverli o rinominarli. Ciò può causare arresti anomali del runtime, spesso con messaggi di errore fuorvianti come NoSuchMethodException o NoSuchFieldException.

Ad esempio, considera lo scenario seguente che mostra come potresti accedere a un campo privato da una classe di libreria.

Una libreria che non è di tua proprietà contiene il seguente codice:

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

La tua app ha il seguente codice:

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
}

Aggiungi una regola -keep nella tua app per impedire a R8 di rimuovere il campo privato:

-keepclassmembers class com.example.LibraryClass {
    private java.lang.String secretMessage;
}
  • -keepclassmembers: vengono conservati membri specifici di un corso solo se il corso stesso viene conservato.
  • class com.example.LibraryClass: questo ha come target la classe esatta contenente il campo.
  • private java.lang.String secretMessage;: identifica il campo privato specifico in base al nome e al tipo.

Java Native Interface (JNI)

Le ottimizzazioni di R8 possono presentare problemi quando si lavora con chiamate upcall dal codice nativo (C/C++) a Java o Kotlin. Sebbene sia vero anche il contrario, ovvero che le chiamate inverse da Java o Kotlin al codice nativo possono presentare problemi, il file proguard-android-optimize.txt predefinito include la seguente regola per mantenere il funzionamento delle chiamate inverse. Questa regola impedisce il taglio dei metodi nativi.

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

Interazione con il codice nativo tramite Java Native Interface (JNI)

Quando la tua app utilizza JNI per effettuare chiamate di ritorno dal codice nativo (C/C++) a Java o Kotlin, R8 non riesce a vedere quali metodi vengono chiamati dal tuo codice nativo. Se non sono presenti riferimenti diretti a questi metodi nella tua app, R8 presuppone erroneamente che questi metodi non vengano utilizzati e li rimuove, causando l'arresto anomalo dell'app.

L'esempio seguente mostra una classe Kotlin con un metodo destinato a essere chiamato da una libreria nativa. La libreria nativa crea un'istanza di un tipo di applicazione e trasferisce i dati dal codice nativo al codice 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")
        }
    }
}

In questo caso, devi informare R8 per impedire l'ottimizzazione del tipo di applicazione. Inoltre, se i metodi chiamati dal codice nativo utilizzano le tue classi nelle loro firme come parametri o tipi restituiti, devi anche verificare che queste classi non vengano rinominate.

Aggiungi le seguenti regole di conservazione alla tua app:

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

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

Queste regole di conservazione impediscono a R8 di rimuovere o rinominare il metodo onNativeEvent e, cosa fondamentale, il suo tipo di parametro.

  • -keepclassmembers,includedescriptorclasses class com.example.JniBridge{ public void onNativeEvent(com.example.model.NativeData);}: Conserva membri specifici di una classe solo se la classe viene istanziata prima nel codice Kotlin o Java. Indica a R8 che l'app utilizza la classe e che deve conservare membri specifici della classe.
    • -keepclassmembers: questo conserva membri specifici di una classe solo se la classe viene istanziata prima nel codice Kotlin o Java. Indica a R8 che l'app utilizza la classe e che deve conservare membri specifici della classe.
    • class com.example.JniBridge: questo ha come target la classe esatta contenente il campo.
    • includedescriptorclasses: questo modificatore conserva anche le classi trovate nella firma o nel descrittore del metodo. In questo caso, impedisce a R8 di rinominare o rimuovere la classe com.example.models.NativeData, che viene utilizzata come parametro. Se NativeData è stato rinominato (ad esempio in a.a), la firma del metodo non corrisponderebbe più a ciò che si aspetta il codice nativo, causando un arresto anomalo.
    • public void onNativeEvent(com.example.models.NativeData);: questo specifica la firma Java esatta del metodo da conservare.
  • -keep class NativeData{<init>(java.lang.Integer, java.lang.String);}: Sebbene includedescriptorclasses si assicuri che la classe NativeData stessa venga conservata, tutti i membri (campi o metodi) all'interno di NativeData a cui si accede direttamente dal codice JNI nativo richiedono regole di conservazione proprie.
    • -keep class NativeData: questo ha come target la classe denominata NativeData e il blocco specifica quali membri all'interno della classe NativeData mantenere.
    • <init>(java.lang.Integer, java.lang.String): questa è la firma del costruttore. Identifica in modo univoco il costruttore che accetta due parametri: il primo è un Integer e il secondo è un String.

Chiamate di piattaforma indirette

Trasferire i dati con un'implementazione di Parcelable

Il framework Android utilizza la reflection per creare istanze dei tuoi oggetti Parcelable. Nello sviluppo Kotlin moderno, devi utilizzare il plug-in kotlin-parcelize, che genera automaticamente l'implementazione Parcelable necessaria, inclusi il campo CREATOR e i metodi necessari al framework.

Ad esempio, considera il seguente esempio in cui il plug-in kotlin-parcelize viene utilizzato per creare una classe 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

In questo scenario, non esiste una regola di conservazione consigliata. Il plug-in Gradle genera automaticamente le regole di conservazione richieste per le classi annotate con @Parcelize.kotlin-parcelize Gestisce la complessità per te, assicurandosi che i CREATOR e i costruttori generati vengano conservati per le chiamate di reflection del framework Android.

Se scrivi manualmente una classe Parcelable in Kotlin senza utilizzare @Parcelize, sei responsabile della gestione del campo CREATOR e del costruttore che accetta un Parcel. Se dimentichi di farlo, l'app si arresta in modo anomalo quando il sistema tenta di deserializzare l'oggetto. L'utilizzo di @Parcelize è la pratica standard e più sicura.

Quando utilizzi il plug-in kotlin-parcelize, tieni presente quanto segue:

  • Il plug-in crea automaticamente i campi CREATOR durante la compilazione.
  • Il file proguard-android-optimize.txt contiene le regole keep necessarie per conservare questi campi per il corretto funzionamento.
  • Gli sviluppatori di app devono verificare che siano presenti tutte le regole keep richieste, soprattutto per implementazioni personalizzate o dipendenze di terze parti.