Casos de uso e exemplos de regras de retenção

Os exemplos a seguir são baseados em cenários comuns em que você usa o R8 para otimização, mas precisa de orientação avançada para criar regras de manutenção.

Reflexão

Em geral, para um desempenho ideal, não é recomendável usar a reflexão. No entanto, em determinadas situações, isso pode ser inevitável. Os exemplos a seguir fornecem orientações para regras de manutenção em cenários comuns que usam reflexão.

Reflexão com classes carregadas por nome

As bibliotecas geralmente carregam classes dinamicamente usando o nome da classe como um String. No entanto, o R8 não consegue detectar classes carregadas dessa maneira e pode remover as classes que considera não utilizadas.

Por exemplo, considere o seguinte cenário em que você tem uma biblioteca e um app que usa a biblioteca. O código demonstra um carregador de biblioteca que instancia uma interface StartupTask implementada por um app.

O código da biblioteca é este:

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

O app que usa a biblioteca tem o seguinte 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")
}

Nesse cenário, sua biblioteca precisa incluir um arquivo de regras de manutenção do consumidor com as seguintes regras:

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

Sem essa regra, o R8 remove PreCacheTask do app porque ele não usa a classe diretamente, o que prejudica a integração. A regra encontra as classes que implementam a interface StartupTask da biblioteca e as preserva, junto com o construtor sem argumentos, permitindo que a biblioteca crie instâncias e execute PreCacheTask com sucesso.

Reflexão com ::class.java

As bibliotecas podem carregar classes fazendo com que o app transmita o objeto Class diretamente, o que é um método mais robusto do que carregar classes por nome. Isso cria uma referência forte à classe que o R8 pode detectar. No entanto, embora isso impeça o R8 de remover a classe, ainda é necessário usar uma regra de preservação para declarar que a classe é instanciada de forma reflexiva e para proteger os membros que são acessados de forma reflexiva, como o construtor.

Por exemplo, considere o seguinte cenário em que você tem uma biblioteca e um app que usa a biblioteca. O carregador de biblioteca cria uma instância de uma interface StartupTask transmitindo a referência de classe diretamente.

O código da biblioteca é este:

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

O app que usa a biblioteca tem o seguinte 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)
}

Nesse cenário, sua biblioteca precisa incluir um arquivo de regras de manutenção do consumidor com as seguintes regras:

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

Essas regras foram projetadas para funcionar perfeitamente com esse tipo de reflexão, permitindo a otimização máxima e garantindo que o código funcione corretamente. As regras permitem que o R8 ofusque o nome da classe e reduza ou remova a implementação da classe StartupTask se o app nunca a usar. No entanto, para qualquer implementação, como o PrecacheTask usado no exemplo, eles preservam o construtor padrão (<init>()) que sua biblioteca precisa chamar.

  • -keep,allowobfuscation,allowshrinking class * implements com.example.library.StartupTask: essa regra tem como destino qualquer classe que implemente sua interface StartupTask.
    • -keep class * implements com.example.library.StartupTask: isso preserva qualquer classe (*) que implemente sua interface.
    • ,allowobfuscation: instrui o R8 a renomear ou ofuscar a classe, mesmo que ela seja mantida. Isso é seguro porque sua biblioteca não depende do nome da classe. Ela recebe o objeto Class diretamente.
    • ,allowshrinking: esse modificador instrui o R8 a remover a classe se ela não for usada. Isso ajuda o R8 a excluir com segurança uma implementação de StartupTask que nunca é transmitida para TaskRunner.execute(). Em resumo, essa regra implica o seguinte: se um app usar uma classe que implementa StartupTask, o R8 vai manter a classe. O R8 pode renomear a classe para reduzir o tamanho dela e excluí-la se o app não a usar.
  • -keepclassmembers class * implements com.example.library.StartupTask { <init>(); }: Essa regra tem como destino membros específicos das classes identificadas na primeira regra, neste caso, o construtor.
    • -keepclassmembers class * implements com.example.library.StartupTask: preserva membros específicos (métodos, campos) da classe que implementa a interface StartupTask, mas apenas se a própria classe implementada for mantida.
    • { <init>(); }: este é o seletor de membros. <init> é o nome interno especial de um construtor em bytecode Java. Esta parte é especificamente destinada ao construtor padrão sem argumentos.
    • Essa regra é essencial porque seu código chama getDeclaredConstructor().newInstance() sem argumentos, o que invoca reflexivamente o construtor padrão. Sem essa regra, o R8 percebe que nenhum código chama new PreCacheTask() diretamente, presume que o construtor não é usado e o remove. Isso faz com que o app falhe no tempo de execução com umInstantiationException.
run

Reflexão com base na anotação do método

As bibliotecas geralmente definem anotações que os desenvolvedores usam para marcar métodos ou campos. Em seguida, a biblioteca usa a reflexão para encontrar esses membros anotados durante a execução. Por exemplo, a anotação @OnLifecycleEvent é usada para encontrar os métodos necessários durante a execução.

Por exemplo, considere o seguinte cenário em que você tem uma biblioteca e um app que usa a biblioteca. O exemplo demonstra um barramento de eventos que encontra e invoca métodos anotados com @OnEvent.

O código da biblioteca é este:

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

O app que usa a biblioteca tem o seguinte 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)
}

A biblioteca precisa incluir um arquivo de regras de manutenção do consumidor que preserve automaticamente todos os métodos usando as anotações dela:

-keepattributes RuntimeVisibleAnnotations
-keep @interface com.example.library.OnEvent;
-keepclassmembers class * {
    @com.example.library.OnEvent <methods>;
}
  • -keepattributes RuntimeVisibleAnnotations: essa regra preserva anotações que devem ser lidas no tempo de execução.
  • -keep @interface com.example.library.OnEvent: essa regra preserva a própria classe de anotação OnEvent.
  • -keepclassmembers class * {@com.example.library.OnEvent <methods>;}: essa regra preserva uma classe e membros específicos somente se a classe estiver sendo usada e contiver esses membros.
    • -keepclassmembers: essa regra preserva uma classe e membros específicos somente se a classe estiver sendo usada e contiver esses membros.
    • class *: a regra se aplica a qualquer classe.
    • @com.example.library.OnEvent <methods>;: isso preserva qualquer classe que tenha um ou mais métodos (<methods>) anotados com @com.example.library.OnEvent, além de preservar os próprios métodos anotados.

Reflexão com base em anotações de classe

As bibliotecas podem usar a reflexão para verificar classes que têm uma anotação específica. Nesse caso, a classe de execução de tarefas encontra todas as classes anotadas com ReflectiveExecutor usando reflexão e executa o método execute.

Por exemplo, considere o seguinte cenário em que você tem uma biblioteca e um app que usa essa biblioteca.

A biblioteca tem o seguinte 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)
        }
    }
}

O app que usa a biblioteca tem o seguinte 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)
}

Como a biblioteca usa reflexão para receber classes específicas, ela precisa incluir um arquivo de regras de manutenção do consumidor com as seguintes regras:

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

Essa configuração é altamente eficiente porque informa ao R8 exatamente o que preservar.

Reflexão para oferecer suporte a dependências opcionais

Um caso de uso comum da reflexão é criar uma dependência flexível entre uma biblioteca principal e uma biblioteca complementar opcional. A biblioteca principal pode verificar se o complemento está incluído no app e, se estiver, pode ativar recursos extras. Isso permite enviar módulos complementares sem forçar a biblioteca principal a ter uma dependência direta deles.

A biblioteca principal usa reflexão (Class.forName) para procurar uma classe específica pelo nome. Se a classe for encontrada, o recurso será ativado. Caso contrário, ela falha de maneira normal.

Por exemplo, considere o código a seguir, em que um AnalyticsManager principal verifica uma classe VideoEventTracker opcional para ativar a análise de vídeo.

A biblioteca principal tem o seguinte 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())
        }
    }
}

A biblioteca de vídeo opcional tem o seguinte código:

package com.example.analytics.video

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

O desenvolvedor da biblioteca opcional é responsável por fornecer a regra de preservação do consumidor necessária. Essa regra de preservação garante que qualquer app que use a biblioteca opcional preserve o código que a biblioteca principal precisa encontrar.

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

Sem essa regra, o R8 provavelmente remove VideoEventTracker da biblioteca opcional, já que nada nesse módulo a usa diretamente. A regra de preservação mantém a classe e o construtor dela, permitindo que a biblioteca principal a instancie.

Reflexão para acessar membros particulares

Usar a reflexão para acessar código privado ou protegido que não faz parte de uma API pública de biblioteca pode causar problemas significativos. Esse código está sujeito a mudanças sem aviso prévio, o que pode levar a comportamentos inesperados ou falhas no seu aplicativo.

Ao usar a reflexão para APIs não públicas, você pode encontrar os seguintes problemas:

  • Atualizações bloqueadas:mudanças no código particular ou protegido podem impedir que você atualize para versões mais recentes da biblioteca.
  • Benefícios perdidos:você pode perder novas funcionalidades, correções importantes de falhas ou atualizações de segurança essenciais.

Otimizações e reflexão do R8

Se você precisar refletir no código privado ou protegido de uma biblioteca, preste muita atenção às otimizações do R8. Se não houver referências diretas a esses membros, o R8 poderá presumir que eles não estão sendo usados e, posteriormente, removê-los ou renomeá-los. Isso pode levar a falhas de tempo de execução, geralmente com mensagens de erro enganosas, como NoSuchMethodException ou NoSuchFieldException.

Por exemplo, considere o cenário a seguir, que demonstra como você pode acessar um campo particular de uma classe de biblioteca.

Uma biblioteca que não é sua tem o seguinte código:

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

O app tem o seguinte 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
}

Adicione uma regra -keep no app para impedir que o R8 remova o campo particular:

-keepclassmembers class com.example.LibraryClass {
    private java.lang.String secretMessage;
}
  • -keepclassmembers: preserva membros específicos de uma classe somente se a própria classe for mantida.
  • class com.example.LibraryClass: isso tem como destino a classe exata que contém o campo.
  • private java.lang.String secretMessage;: identifica o campo privado específico pelo nome e tipo.

Java Native Interface (JNI)

As otimizações do R8 podem ter problemas ao trabalhar com upcalls de código nativo (C/C++) para Java ou Kotlin. Embora o contrário também seja verdadeiro (as chamadas de Java ou Kotlin para código nativo podem ter problemas), o arquivo padrão proguard-android-optimize.txt inclui a seguinte regra para manter as chamadas funcionando. Essa regra evita que os métodos nativos sejam cortados.

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

Interação com código nativo usando a Java Native Interface (JNI)

Quando seu app usa JNI para fazer upcalls do código nativo (C/C++) para Java ou Kotlin, o R8 não consegue ver quais métodos são chamados do seu código nativo. Se não houver referências diretas a esses métodos no app, o R8 vai presumir incorretamente que eles não estão sendo usados e os removerá, causando uma falha no app.

O exemplo a seguir mostra uma classe Kotlin com um método destinado a ser chamado de uma biblioteca nativa. A biblioteca nativa cria uma instância de um tipo de aplicativo e transmite dados do código nativo para o código 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")
        }
    }
}

Nesse caso, informe ao R8 para evitar que o tipo de aplicativo seja otimizado. Além disso, se os métodos chamados do código nativo usarem suas próprias classes nas assinaturas como parâmetros ou tipos de retorno, verifique também se essas classes não foram renomeadas.

Adicione as seguintes regras keep ao seu app:

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

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

Essas regras impedem que o R8 remova ou renomeie o método onNativeEvent e, principalmente, o tipo de parâmetro dele.

  • -keepclassmembers,includedescriptorclasses class com.example.JniBridge{ public void onNativeEvent(com.example.model.NativeData);}: isso preserva membros específicos de uma classe somente se ela for instanciada primeiro em código Kotlin ou Java. Isso informa ao R8 que o app está usando a classe e que ele precisa preservar membros específicos dela.
    • -keepclassmembers: preserva membros específicos de uma classe somente se a classe for instanciada primeiro em código Kotlin ou Java. Isso informa ao R8 que o app está usando a classe e que ele precisa preservar membros específicos da classe.
    • class com.example.JniBridge: isso tem como destino a classe exata que contém o campo.
    • includedescriptorclasses: esse modificador também preserva as classes encontradas na assinatura ou no descritor do método. Nesse caso, ele impede que o R8 renomeie ou remova a classe com.example.models.NativeData, que é usada como um parâmetro. Se NativeData fosse renomeado (por exemplo, para a.a), a assinatura do método não corresponderia mais ao que o código nativo espera, causando uma falha.
    • public void onNativeEvent(com.example.models.NativeData);: especifica a assinatura exata do método Java a ser preservado.
  • -keep class NativeData{<init>(java.lang.Integer, java.lang.String);}: Embora includedescriptorclasses garanta que a própria classe NativeData seja preservada, todos os membros (campos ou métodos) em NativeData que são acessados diretamente do seu código JNI nativo precisam ter regras de preservação próprias.
    • -keep class NativeData: isso tem como destino a classe chamada NativeData e o bloco especifica quais membros dentro da classe NativeData manter.
    • <init>(java.lang.Integer, java.lang.String): esta é a assinatura do construtor. Ele identifica de maneira exclusiva o construtor que usa dois parâmetros: o primeiro é um Integer e o segundo é um String.

Chamadas indiretas da plataforma

Transferir dados com uma implementação de Parcelable

O framework Android usa reflexão para criar instâncias dos objetos Parcelable. No desenvolvimento moderno em Kotlin, use o plug-in kotlin-parcelize, que gera automaticamente a implementação Parcelable necessária, incluindo o campo CREATOR e os métodos de que o framework precisa.

Por exemplo, considere o exemplo a seguir, em que o plug-in kotlin-parcelize é usado para criar uma 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

Nesse cenário, não há uma regra de retenção recomendada. O plug-in do Gradle kotlin-parcelize gera automaticamente as regras de manutenção necessárias para as classes que você anota com @Parcelize. Ele lida com a complexidade para você, garantindo que o CREATOR e os construtores gerados sejam preservados para as chamadas de reflexão do framework Android.

Se você escrever uma classe Parcelable manualmente em Kotlin sem usar @Parcelize, será responsável por manter o campo CREATOR e o construtor que aceita um Parcel. Se você se esquecer de fazer isso, o app vai falhar quando o sistema tentar desserializar o objeto. Usar @Parcelize é a prática padrão e mais segura.

Ao usar o plug-in kotlin-parcelize, esteja ciente do seguinte:

  • O plug-in cria automaticamente campos CREATOR durante a compilação.
  • O arquivo proguard-android-optimize.txt contém as regras keep necessárias para reter esses campos e garantir a funcionalidade adequada.
  • Os desenvolvedores de apps precisam verificar se todas as regras keep necessárias estão presentes, principalmente para implementações personalizadas ou dependências de terceiros.