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ções avançadas para criar regras keep.

Reflexo

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

Reflexão com classes carregadas por nome

As bibliotecas costumam carregar classes dinamicamente usando o nome da classe como uma 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 cenário a seguir em que você tem uma biblioteca e um app que a usa. O código demonstra um carregador de biblioteca que instancia uma interface StartupTask implementada por um app.

O código da biblioteca é o seguinte:

// 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, a biblioteca precisa incluir um arquivo de regras keep do consumidor com as seguintes regras keep:

-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, interrompendo 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 instancie e execute PreCacheTask.

Reflexão com ::class.java

As bibliotecas podem carregar classes fazendo com que o app transmita o objeto Class diretamente, 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 keep 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 cenário a seguir em que você tem uma biblioteca e um app que a usa. O carregador de biblioteca instancia uma StartupTask interface transmitindo a referência de classe diretamente.

O código da biblioteca é o seguinte:

// 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, a biblioteca precisa incluir um arquivo de regras keep do consumidor com as seguintes regras keep:

# 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 a PrecacheTask usada no exemplo, elas preservam o construtor padrão (<init>()) que a biblioteca precisa chamar.

  • -keep,allowobfuscation,allowshrinking class * implements com.example.library.StartupTask: essa regra segmenta qualquer classe que implemente a interface StartupTask.
    • -keep class * implements com.example.library.StartupTask: Isto preserva qualquer classe (*) que implemente a interface.
    • ,allowobfuscation: instrui o R8 a renomear ou ofuscar a classe, apesar de mantê-la. Isso é seguro porque a 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 pode excluí-la se o app não a usar.
  • -keepclassmembers class * implements com.example.library.StartupTask { <init>(); }: Essa regra segmenta membros específicos das classes identificadas na primeira regra. Nesse caso, o construtor.
    • -keepclassmembers class * implements com.example.library.StartupTask: preserva membros específicos (métodos, campos) da classe que implementa StartupTask interface, mas somente se a classe implementada estiver sendo mantida.
    • { <init>(); }: esse é o seletor de membros. <init> é o nome interno especial de um construtor no bytecode Java. Essa parte segmenta especificamente o construtor padrão sem argumentos.
    • Essa regra é essencial porque o código chama getDeclaredConstructor().newInstance() sem argumentos, que invoca de forma reflexiva 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 durante a execução com uma InstantiationException.

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

As bibliotecas costumam definir anotações que os desenvolvedores usam para marcar métodos ou campos. 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 cenário a seguir em que você tem uma biblioteca e um app que a usa. O exemplo demonstra um barramento de eventos que encontra e invoca métodos anotados com @OnEvent.

O código da biblioteca é o seguinte:

@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 keep do consumidor que preserva automaticamente todos os métodos que usam as anotações:

-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 durante a 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>;: preserva qualquer classe que tenha um ou mais métodos (<methods>) anotados com @com.example.library.OnEvent e também preserva os próprios métodos anotados.

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

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

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

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 a reflexão de forma reflexiva para receber classes específicas, ela precisa incluir um arquivo de regras keep do consumidor com as seguintes regras keep:

# 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 é muito eficiente porque informa ao R8 exatamente o que preservar.

Reflexão para oferecer suporte a dependências opcionais

Um caso de uso comum para 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 a reflexão (Class.forName) para procurar uma classe específica pelo nome. Se a classe for encontrada, o recurso será ativado. Caso contrário, ele falhará normalmente.

Por exemplo, considere o código a seguir em que um AnalyticsManager principal verifica uma classe VideoEventTracker opcional para ativar o Google Analytics 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 keep do consumidor necessária. Essa regra keep 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 keep preserva 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 particular ou protegido que não faz parte da API pública de uma biblioteca pode introduzir problemas significativos. Esse código está sujeito a mudanças sem aviso prévio, o que pode levar a comportamentos inesperados ou falhas no aplicativo.

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

  • Atualizações bloqueadas:as 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 do R8 e reflexão

Se você precisar refletir no código particular ou protegido de uma biblioteca, preste 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 são usados e, posteriormente, removê-los ou renomeá-los. Isso pode levar a falhas 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"
}

Seu 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 ao 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: segmenta a classe exata que contém o campo.
  • private java.lang.String secretMessage;: identifica o campo particular específico pelo nome e tipo.

Interface Java nativa (JNI)

As otimizações do R8 podem ter problemas ao trabalhar com chamadas de retorno do código nativo (C/C++) para Java ou Kotlin. Embora o inverso também seja verdadeiro (as chamadas de retorno do Java ou Kotlin para o código nativo podem ter problemas), o arquivo padrão proguard-android-optimize.txt inclui a seguinte regra para manter as chamadas de retorno funcionando. Essa regra protege contra o corte de métodos nativos.

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

Interação com código nativo pela interface Java nativa (JNI)

Quando o app usa a JNI para fazer chamadas de retorno do código nativo (C/C++) para Java ou Kotlin, o R8 não consegue identificar quais métodos são chamados do código nativo. Se não houver referências diretas a esses métodos no app, o R8 vai presumir incorretamente que esses métodos não são usados e os removerá, causando 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 instancia 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, você precisa informar o R8 para impedir 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, também será necessário verificar se essas classes não foram renomeadas.

Adicione as seguintes regras keep ao 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 keep 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);}: preserva membros específicos de uma classe somente se a classe for instanciada primeiro no código Kotlin ou Java. Ela informa ao R8 que o app está usando a classe e que membros específicos dela precisam ser preservados.
    • -keepclassmembers: preserva membros específicos de uma classe somente se a classe for instanciada primeiro no código Kotlin ou Java. Ela informa ao R8 que o app está usando a classe e que membros específicos dela precisam ser preservados.
    • class com.example.JniBridge: segmenta a classe exata que contém o campo.
    • includedescriptorclasses: esse modificador também preserva todas 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 renomeada (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 Java exata do método a ser preservado.
  • -keep class NativeData{<init>(java.lang.Integer, java.lang.String);}: Embora includedescriptorclasses garanta que a classe NativeData seja preservada, todos os membros (campos ou métodos) em NativeData que são acessados diretamente do código JNI nativo precisam das próprias regras keep.
    • -keep class NativeData: segmenta a classe chamada NativeData e o bloco especifica quais membros dentro da classe NativeData devem ser mantidos.
    • <init>(java.lang.Integer, java.lang.String): essa é a assinatura do construtor. Ela identifica exclusivamente o construtor que usa dois parâmetros: o primeiro é um Integer e o segundo é uma String.

Chamadas indiretas de plataforma

Transferir dados com uma implementação de Parcelable

O framework do Android usa a reflexão para criar instâncias dos objetos Parcelable. No desenvolvimento moderno do 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 keep recomendada. O plug-in do Gradle kotlin-parcelize gera automaticamente as regras keep necessárias para as classes anotadas com @Parcelize. Ele processa a complexidade para você, garantindo que o CREATOR e os construtores gerados sejam preservados para as chamadas de reflexão do framework do Android.

Se você programar uma classe Parcelable manualmente no Kotlin sem usar @Parcelize, será responsável por manter o campo CREATOR e o construtor que aceita um Parcel. Esquecer de fazer isso causa falha no app quando o sistema tenta 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 campos CREATOR automaticamente durante a compilação.
  • O arquivo proguard-android-optimize.txt contém as regras keep necessárias para manter esses campos para a funcionalidade adequada.
  • Os desenvolvedores de apps precisam verificar se todas as regras keep necessárias estão presentes, especialmente para implementações personalizadas ou dependências de terceiros.

As bibliotecas que usam transformações de reflexão ou bytecode acessam o código dinamicamente durante a execução. Se o R8 remover ou renomear classes, campos ou métodos acessados dessa forma, o app poderá falhar.

No entanto, bibliotecas de terceiros populares (como Gson, Retrofit e Kotlinx Serialization) agrupam automaticamente as próprias regras keep do consumidor do R8. Ao usar versões recentes dessas bibliotecas, não é necessário adicionar regras keep manuais ao projeto.

Gson

Gson (link em inglês) é uma biblioteca de serialização e desserialização JSON que depende muito da reflexão. Quando você usa o modo completo para otimizar o app, ele remove assinaturas de tipo genérico, construtores padrão e campos não anotados, a menos que seja instruído explicitamente de outra forma.

Para garantir que o Gson funcione corretamente, adicione regras específicas para manter campos não temporários nas classes de modelo de dados e preservar a hierarquia TypeToken:

# Preserve generic type information required for deserialization
-keepattributes Signature

# Keep all non-transient fields in your data model classes for reflection
-keepclassmembers class com.example.models.** {
    !transient <fields>;
}

# Keep TypeToken itself and any anonymous classes extending it
-keep,allowobfuscation,allowshrinking,allowoptimization class com.google.gson.reflect.TypeToken { *; }
-keep,allowobfuscation,allowshrinking,allowoptimization class * extends com.google.gson.reflect.TypeToken

Os campos marcados com o modificador transient são ignorados pelo Gson durante a serialização e desserialização. É por isso que a regra keep segmenta especificamente campos não temporários (!transient).

Retrofit

Retrofit (link em inglês) é uma biblioteca de rede que inspeciona métodos de interface de serviço anotados com anotações HTTP (como @GET ou @POST) usando reflexão para construir solicitações de rede e converter respostas.

O Retrofit gera implementações dinamicamente das interfaces de API durante a execução usando Proxy.newProxyInstance(). Como o R8 não vê nenhuma classe implementando essas interfaces de forma estática, ele pode remover os métodos ou os tipos de retorno genéricos.

Regras keep agrupadas

O Retrofit depende da reflexão de execução para inspecionar parâmetros genéricos, anotações de método e anotações de parâmetro. Sem a configuração adequada, o modo completo do R8 pode remover completamente assinaturas genéricas de tipos de retorno, continuações do Kotlin e classes de resposta ou até mesmo substituir valores de interface por nulos, já que as interfaces do Retrofit são instanciadas dinamicamente com um proxy.

A partir do Retrofit 2.10.0, a biblioteca agrupa automaticamente as regras keep oficiais necessárias para preservar os padrões de anotação, os parâmetros do método de serviço e os metadados de classe necessários. Para mais informações, consulte Regras usadas pelo Retrofit (link em inglês).

Preservar tipos de retorno genéricos

O Retrofit inspeciona a assinatura genérica do tipo de retorno (por exemplo, Observable<Data>) para desserializar corretamente a resposta da rede. Se o R8 remover a assinatura genérica, o Retrofit vai substituir o objeto instanciado por null.

Para impedir que o modo completo do R8 remova a assinatura genérica dos tipos de retorno, use a seguinte regra condicional:

# Preserve generic type information for Call/Observable return types
-keepattributes Signature

# If an interface has a Retrofit HTTP annotation, keep its return type (class <3>)
-if interface * {
    @retrofit2.http.* public *** *(...);
}
-keep,allowoptimization,allowshrinking,allowobfuscation class <3>

A classe de modelo de dados real que está sendo retornada (por exemplo, Data em Observable<Data>) também precisa ser mantida, já que ela será construída de forma reflexiva pelo conversor (como Gson).

Corrotinas

Quando você usa corrotinas do Kotlin, o compilador do Kotlin transforma funções suspend anexando um parâmetro Continuation à assinatura do método compilado.

Quando bibliotecas como o Retrofit leem de forma reflexiva a assinatura genérica de uma função suspend, elas dependem desse parâmetro Continuation. Ao usar o modo completo, o atributo Signature é mantido apenas para classes que são mantidas explicitamente. Como Continuation é um parâmetro sintético, o R8 remove a assinatura dele por padrão, interrompendo a reflexão.

Para evitar a remoção de assinaturas e garantir a compatibilidade de execução no modo completo, inclua a seguinte regra:

# Keep the signature attribute globally
-keepattributes Signature

# Explicitly keep the Continuation class so its signature is not stripped
-keep class kotlin.coroutines.Continuation