Gerar regras de manutenção com base em anotações

O Jetpack Keep permite declarar o uso de reflexão e da Java Native Interface (JNI) com anotações diretamente no código-fonte Kotlin ou Java, em vez de arquivos de regras de retenção separados. O plug-in do Gradle para Jetpack Keep lê essas anotações no momento do build e gera as regras de retenção necessárias para o R8.

O Jetpack Keep ajuda a otimização do R8 a permanecer eficaz à medida que a base de código muda:

  • As regras de retenção permanecem ao lado do código que precisa delas. Ao renomear ou excluir um código que usa reflexão, você atualiza a anotação no mesmo lugar. Não há um arquivo de regras separado para manter sincronizado.

  • As regras geradas são condicionais. O R8 mantém o destino de uma chamada reflexiva somente se também mantiver o código que faz a chamada. Se o código de chamada não for usado e o R8 o remover (geralmente o caso em recursos de biblioteca opcionais), o R8 também poderá remover o destino. Assim, as regras de recursos não utilizados não adicionam inchaço.

  • O lint encontra a reflexão para você. A biblioteca inclui verificações de lint que sinalizam chamadas reflexivas não anotadas e que podem adicionar muitas das anotações automaticamente.

Como o Jetpack Keep funciona

Considere uma função que instancia uma classe por nome. Se o R8 não puder informar que o código usa a classe, ele vai remover o construtor da classe, e o app vai falhar no momento da execução. Para evitar a falha, adicione uma regra como a seguinte ao arquivo de regras de retenção:

# Unconditional: R8 keeps this constructor even if it removes the reflection that invokes it.
-keep class com.example.ResourceLocatorImpl {
    <init>();
}

Com o Jetpack Keep, você anota a função que executa a reflexão:

// ResourceLocatorImpl isn't on this module's compile classpath, so both the
// annotation and the code refer to it by name.
@UsesReflectionToConstruct(
    className = "com.example.ResourceLocatorImpl",
    // Omitting parameterTypes keeps every constructor. An empty array keeps
    // only the no-argument constructor, which the code calls.
    parameterTypes = [],
)
fun loadResourceLocator(): ResourceLocator {
    val implClass = Class.forName("com.example.ResourceLocatorImpl")
    return implClass.getDeclaredConstructor().newInstance() as ResourceLocator
}

No momento do build, o plug-in do Gradle gera uma regra de retenção para o construtor. Ao contrário da regra escrita manualmente anterior, a regra gerada é condicional: o R8 mantém o construtor somente se loadResourceLocator permanecer no app otimizado.

Requisitos

Para usar o Jetpack Keep, seu projeto precisa do Plug-in do Android para Gradle (AGP, na sigla em inglês) 8.7 ou mais recente.

O plug-in do Gradle oferece suporte aos seguintes tipos de módulos:

Tipo de módulo Plug-in que o módulo aplica Configuração complementar
App Android com.android.application Nenhum
Biblioteca do Android com.android.library Nenhum
Biblioteca do Kotlin Multiplatform com um destino do Android com.android.kotlin.multiplatform.library Nenhum
Biblioteca JVM (Java ou Kotlin) java-library ou org.jetbrains.kotlin.jvm Registrar uma transformação JAR

Configurar o Jetpack Keep

Para usar o Jetpack Keep, adicione a biblioteca e o plug-in do Gradle a cada módulo que usa reflexão ou JNI. A biblioteca fornece as anotações e as verificações de lint, e o plug-in transforma as anotações em regras de retenção.

  1. No arquivo libs.versions.toml, adicione a biblioteca e o plug-in. A biblioteca e o plug-in compartilham a mesma versão:

    [versions]
    # The library and the plugin ship together. Use one version for both so
    # that they stay in sync when you upgrade.
    annotationKeep = "1.0.0-alpha01"
    
    [libraries]
    androidx-annotation-keep = { group = "androidx.annotation.keep", name = "keep", version.ref = "annotationKeep" }
    
    [plugins]
    androidx-annotation-keep = { id = "androidx.annotation.keep", version.ref = "annotationKeep" }
    

    O Gradle faz o download do plug-in do repositório Maven do Google. Portanto, verifique se os repositórios pluginManagement no arquivo settings.gradle.kts incluem google().

  2. No arquivo build.gradle.kts raiz, declare o plug-in ao lado do AGP sem aplicá-lo:

    plugins {
        alias(libs.plugins.android.application) apply false
        // Other plugins.
    
        // The plugin relies on AGP classes that it doesn't bundle, so declare
        // it next to AGP. Declaring it here also gives every module the same
        // plugin version.
        alias(libs.plugins.androidx.annotation.keep) apply false
    }
    
  3. No arquivo build.gradle.kts de cada módulo que usa reflexão ou JNI, aplique o plug-in e adicione a dependência da biblioteca:

    plugins {
        alias(libs.plugins.android.application)
        // The plugin reads annotations only from this module's own code, so
        // apply it in every module that uses Jetpack Keep.
        alias(libs.plugins.androidx.annotation.keep)
    }
    
    dependencies {
        implementation(libs.androidx.annotation.keep)
    }
    
  4. Sincronize o projeto com arquivos do Gradle.

Para cada variante de build de um módulo de app Android, o plug-in registra uma <variant>ExtractKeepRules tarefa, como releaseExtractKeepRules, e transmite as regras geradas para o R8 dessa variante. Para módulos de biblioteca, o plug-in empacota as regras geradas como regras de retenção do consumidor na saída da biblioteca. Os módulos de biblioteca JVM precisam de configuração adicional.

Encontrar reflexão com lint

O Jetpack Keep inclui as seguintes verificações de lint:

ReflectionWithoutKeepAnnotations
Sinaliza chamadas reflexivas, como Class.forName, getDeclaredConstructor, getDeclaredMethod e getDeclaredField, que não têm uma anotação de retenção correspondente.
MissingKeepAnnotationPlugin
Sinaliza módulos que dependem de androidx.annotation.keep:keep, mas não aplicam o plug-in do Gradle androidx.annotation.keep. Sem o plug-in, o build não gera regras de retenção das anotações.

Para usar o lint para anotar um módulo do Android, siga estas etapas:

  1. Execute o lint no módulo para encontrar chamadas reflexivas que precisam de anotações:

    ./gradlew :app:lint
    
  2. Aplique as correções que o lint pode fazer automaticamente:

    ./gradlew :app:lintFix
    

    O lint pode adicionar uma anotação automaticamente quando consegue determinar qual classe e membros o código reflete.

  3. Analise cada anotação adicionada para confirmar se ela corresponde ao que o código faz. Por exemplo, verifique se os tipos de parâmetros correspondem ao construtor ou método que o código chama.

  4. Resolva as descobertas restantes no Android Studio. Quando o lint não consegue determinar a classe de destino, a correção rápida insere a anotação com um marcador de posição TODO para você concluir. Também é possível adicionar a anotação manualmente.

    Por exemplo, a função a seguir recebe o nome da classe como um parâmetro. Portanto, o lint não consegue determinar qual classe ela constrói. A correção rápida adiciona uma anotação com um marcador de posição:

    @UsesReflectionToConstruct(
        className = TODO(),
        parameterTypes = []
    )
    fun createInstance(className: String): Any =
        Class.forName(className).getDeclaredConstructor().newInstance()
    

    Substitua o marcador de posição pela classe que o código constrói. Se a classe estiver disponível no tempo de compilação, use classConstant em vez de className. No Kotlin, o código não é compilado até que você substitua TODO().

  5. Verifique quais regras de retenção o plug-in gera das anotações antes de remover as regras atuais. Para um módulo de app, execute a tarefa de extração para a variante de lançamento:

    ./gradlew :app:releaseExtractKeepRules
    

    O plug-in grava as regras em app/build/generated/release/androidx.annotation.keep.rules.pro. Para módulos de biblioteca, consulte Verificar as regras geradas.

  6. Remova as regras de retenção que as novas anotações substituem dos arquivos de regras de retenção. Uma regra também pode abranger a reflexão que o lint não sinalizou, como a reflexão em outro módulo ou em uma biblioteca. Portanto, verifique cada regra antes de removê-la:

    • Para uma regra de pacote, como -keep class com.example.** { *; }, verifique se as anotações ou outras regras abrangem toda a reflexão nas classes do pacote.
    • Para uma regra que mantém classes ou membros específicos, verifique se ela corresponde a uma das regras geradas ou se nenhuma outra reflexão depende dela.
  7. Teste o build de lançamento.

Anotar o código

Adicione uma anotação do Jetpack Keep ao código que executa a reflexão: a função, o construtor, o campo ou a classe que contém a chamada reflexiva. A anotação descreve o destino da reflexão, e a regra gerada mantém esse destino somente se o R8 mantiver o código anotado.

Escolha uma anotação com base no que o código faz:

Anotação Use quando o código
@UsesReflectionToConstruct Chama um construtor por reflexão, por exemplo, com getDeclaredConstructor().newInstance().
@UsesReflectionToAccessMethod Chama um método por reflexão, por exemplo, com getMethod ou getDeclaredMethod.
@UsesReflectionToAccessField Lê ou grava um campo por reflexão, por exemplo, com getField ou getDeclaredField.
@UnconditionallyKeep É chamado de fora do código Kotlin ou Java do app, por exemplo, de código nativo por JNI. Use essa anotação somente quando não for possível usar as @UsesReflection* anotações, que geram regras condicionais.

Todas as anotações @UsesReflection* seguem estas convenções:

  • Identifique a classe de destino com classConstant ou className. Se a classe estiver disponível no tempo de compilação, use classConstant. O compilador verifica a referência, e as refatorações do ambiente de desenvolvimento integrado, como a renomeação, a atualizam. Use className somente quando a classe não estiver disponível no tempo de compilação, como uma classe em uma dependência opcional. Esses dois parâmetros são mutuamente exclusivos.
  • Direcione a classe mais específica. As regras geradas mantêm os membros correspondentes na classe especificada e nas subclasses ou implementações de interface. Quanto mais geral a classe, mais código o R8 mantém.
  • Especifique os tipos de parâmetros para manter uma única sobrecarga. Se você não definir parameterTypes ou parameterTypeNames, as regras vão manter todos os construtores ou todas as sobrecargas do método. Para manter apenas o construtor ou método sem argumentos, transmita uma matriz vazia.
  • Repita a anotação para vários destinos. Se uma função refletir em mais de uma classe ou membro, adicione uma anotação para cada destino.

Construir objetos com reflexão

Use @UsesReflectionToConstruct quando o código instanciar uma classe por reflexão. No exemplo a seguir, um executor de tarefas instancia implementações de uma interface StartupTask por nome. Como a anotação especifica a interface, as regras geradas mantêm o construtor sem argumentos de cada implementação StartupTask, desde que o R8 mantenha execute:

interface StartupTask {
    fun run()
}

object TaskRunner {
    // The task class is known only at runtime, so target the interface that
    // every task implements.
    @UsesReflectionToConstruct(classConstant = StartupTask::class, parameterTypes = [])
    fun execute(className: String) {
        val task = Class.forName(className)
            .getDeclaredConstructor()
            .newInstance() as StartupTask
        task.run()
    }
}

Chamar métodos com reflexão

Use @UsesReflectionToAccessMethod quando o código chamar um método por reflexão. Defina methodName como o nome do método. No exemplo a seguir, o código chama um método estático em uma dependência opcional. A classe não está disponível no tempo de compilação. Portanto, a anotação usa className em vez de classConstant:

object CrashReporting {
    @UsesReflectionToAccessMethod(
        className = "com.example.crashlib.CrashReporter",
        methodName = "log",
        // Omitting parameterTypes keeps every log overload. Keep only the one
        // that the code calls.
        parameterTypes = [String::class],
    )
    fun log(message: String) {
        val reporterClass = try {
            Class.forName("com.example.crashlib.CrashReporter")
        } catch (e: ClassNotFoundException) {
            // Crash reporting is optional, so skip it instead of failing.
            return
        }
        reporterClass.getMethod("log", String::class.java).invoke(null, message)
    }
}

Acessar campos com reflexão

Use @UsesReflectionToAccessField quando o código ler ou gravar um campo por reflexão. Defina fieldName como o nome do campo:

class UserProfileFetcher {
    // User doesn't expose internalId, so reading it requires reflection.
    @UsesReflectionToAccessField(classConstant = User::class, fieldName = "internalId")
    fun readInternalId(user: User): Any? {
        val field = User::class.java.getDeclaredField("internalId")
        field.isAccessible = true
        return field.get(user)
    }
}

Manter o código chamado de fora do app

Use @UnconditionallyKeep para códigos chamados de fora do código Kotlin ou Java do app, como um método que o código nativo chama por JNI. Anote o elemento chamado, não o código que o chama:

class NativeBridge {
    // Only native code calls this method, through a JNI lookup by name that
    // R8 can't see. Keep the method and its name unconditionally.
    @UnconditionallyKeep
    fun onNativeEvent(message: String) {
        Log.d("NativeBridge", "Received from native code: $message")
    }

    // The default proguard-android-optimize.txt file already keeps native
    // method names, so this method doesn't need an annotation.
    external fun startNativeProcess()
}

A regra gerada mantém o método anotado e a classe que o contém, com os nomes originais. Ela não mantém outras classes referenciadas pela assinatura do método. Se o código nativo depender dessas classes, anote-as também ou adicione regras de retenção para elas. Para mais informações sobre JNI e R8, consulte Interação com código nativo por JNI.

Por padrão, @UnconditionallyKeep preserva o nome do elemento. Se o autor da chamada externa não depender do nome, defina shouldPreserveName = false para que o R8 possa renomear o elemento.

Verificar as regras geradas

Esta seção explica como verificar as regras geradas para cada tipo de módulo. Para um módulo de app, o plug-in transmite as regras diretamente para o R8. Para um módulo de biblioteca, o plug-in empacota as regras como regras de retenção do consumidor na saída da biblioteca. Os apps que dependem da biblioteca aplicam essas regras automaticamente quando executam o R8, mesmo que não usem o Jetpack Keep. Para mais informações sobre regras de retenção do consumidor, consulte Otimização para autores de bibliotecas.

Apps Android

Para verificar quais regras de retenção o plug-in gera para um módulo de app, execute a tarefa de extração para uma variante de build:

./gradlew :app:releaseExtractKeepRules

O plug-in grava as regras de cada variante em app/build/generated/<variant>/androidx.annotation.keep.rules.pro. Um comentário # context: antes de cada regra identifica o código anotado que a produziu.

Para verificar como as regras geradas afetam a otimização junto com as outras regras de retenção, use o Analisador de configuração do R8.

Bibliotecas do Android

Os módulos de biblioteca do Android não precisam de configuração adicional. Ao criar o AAR, o plug-in adiciona as regras geradas ao arquivo proguard.txt do AAR, após as regras de retenção do consumidor que o AAR já contém. O plug-in registra uma tarefa <variant>KeepRulesTransformAar para cada variante de build.

Para verificar as regras, crie o AAR, por exemplo, com ./gradlew :mylibrary:assembleRelease, e abra proguard.txt no AAR gerado. As regras geradas aparecem em uma seção com o seguinte cabeçalho:

#################################################################
#   Rules generated from androidx.annotation.keep annotations   #
#################################################################

No macOS e no Linux, também é possível imprimir as regras na linha de comando:

unzip -p mylibrary/build/intermediates/aar/release/releaseKeepRulesTransformAar/mylibrary-release.aar proguard.txt

Bibliotecas do Kotlin Multiplatform

Os módulos de biblioteca do Kotlin Multiplatform com um destino do Android também não precisam de configuração adicional. Ao criar o AAR para o destino do Android, o plug-in adiciona as regras geradas ao arquivo proguard.txt do AAR da mesma forma que para bibliotecas do Android. O plug-in registra uma tarefa androidMainKeepRulesTransformAar para fazer isso.

Para verificar as regras, execute essa tarefa e abra proguard.txt no AAR criado. No macOS e no Linux, é possível imprimir as regras na linha de comando:

./gradlew :mylibrary:androidMainKeepRulesTransformAar
unzip -p mylibrary/build/intermediates/aar/androidMain/androidMainKeepRulesTransformAar/mylibrary.aar proguard.txt

Bibliotecas JVM

Antes de verificar as regras de um módulo de biblioteca JVM, configure o módulo para que o plug-in adicione as regras geradas a uma cópia do JAR. Em seguida, execute a tarefa de transformação registrada e verifique os arquivos de regras no JAR criado. Por exemplo, se você nomeou a tarefa keepRulesJar, é possível imprimir as regras no macOS e no Linux com os seguintes comandos:

./gradlew :mylibrary:keepRulesJar
unzip -p mylibrary/build/intermediates/annotation-keep/keepRulesJar/mylibrary-keep-rules.jar \
    "META-INF/com.android.tools/*" "META-INF/proguard/*"

Configurar bibliotecas JVM

Para módulos de biblioteca JVM que produzem um JAR, o plug-in não modifica o JAR automaticamente. Em vez disso, use a função annotationKeep.registerJavaArchiveTransform para registrar uma tarefa que cria uma cópia do JAR com as regras geradas e, em seguida, publique esse JAR. Adicione o seguinte ao arquivo build.gradle.kts do módulo da biblioteca:

plugins {
    id("java-library")
    alias(libs.plugins.jetbrains.kotlin.jvm)
    alias(libs.plugins.androidx.annotation.keep)
}

val jarTask = tasks.named<Jar>("jar")

// Apps run R8 on this library's code, so the generated rules must be inside
// the JAR. The plugin handles this automatically only for Android modules.
val keepRulesJarTask = annotationKeep.registerJavaArchiveTransform(
    taskName = "keepRulesJar",
    input = jarTask.flatMap { it.archiveFile },
    fileName = jarTask.flatMap { it.archiveFileName }.map { it.replace(".jar", "-keep-rules.jar") },
)

// Consumers of this library resolve apiElements and runtimeElements, so add
// the JAR that contains the generated rules to them.
configurations.configureEach {
    if (name == "apiElements" || name == "runtimeElements") {
        outgoing.artifact(keepRulesJarTask.flatMap { it.outputJar })
    }
}

dependencies {
    implementation(libs.androidx.annotation.keep)
}

A transformação adiciona as regras geradas ao local de regras do R8 de destino do JAR, META-INF/com.android.tools/r8/proguard.txt. Se o JAR já contiver regras em META-INF/com.android.tools/ ou META-INF/proguard/, a transformação vai anexar as regras geradas ao arquivo de regras atual. Para mais informações sobre esses locais, consulte Suporte a diferentes versões do R8.

Depois de registrar a transformação, verifique as regras no JAR.

Exemplo: carregar uma biblioteca de complementos opcionais

As bibliotecas geralmente usam reflexão para carregar complementos opcionais. Por exemplo, um SDK do Analytics pode enviar uma biblioteca principal e um complemento de vídeo. O complemento depende da biblioteca principal. Portanto, a biblioteca principal não pode se referir diretamente às classes do complemento. Em vez disso, a biblioteca principal procura a classe do complemento por nome no momento da execução e ativa o Google Analytics para vídeos somente se encontrar a classe.

Como a biblioteca principal usa a classe do complemento apenas por reflexão, o R8 pode remover a classe ou o construtor dela do build de lançamento de um app, o que faz com que a pesquisa falhe. A falha ocorre apenas em builds de lançamento de apps que incluem o complemento. Portanto, é difícil detectar no teste.

Sem o Jetpack Keep, o complemento envia uma regra de retenção do consumidor para a classe, conforme mostrado em Reflexão para oferecer suporte a dependências opcionais:

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

Essa regra está em uma biblioteca diferente da pesquisa que precisa dela. Portanto, as duas podem ficar dessincronizadas. Com o Jetpack Keep, você anota a pesquisa na biblioteca principal:

// In the core library.
interface AnalyticsPlugin {
    fun start()
}

object AnalyticsManager {
    // The annotation and the lookup share this constant so that they can't
    // refer to different classes.
    private const val VIDEO_TRACKER_CLASS =
        "com.example.analytics.video.VideoEventTracker"

    @UsesReflectionToConstruct(
        className = VIDEO_TRACKER_CLASS,
        parameterTypes = [],
    )
    fun initialize() {
        val tracker = try {
            Class.forName(VIDEO_TRACKER_CLASS)
                .getDeclaredConstructor()
                .newInstance() as AnalyticsPlugin
        } catch (e: ClassNotFoundException) {
            // Apps that don't play video leave out the add-on, so a missing
            // class isn't an error.
            return
        }
        // This is an ordinary interface call, not a reflective one, so R8
        // keeps start() without an annotation.
        tracker.start()
    }
}

A anotação segue estas diretrizes:

  • Anote o código que executa a reflexão. A anotação está em initialize, que chama Class.forName, não na classe do complemento.
  • Escolha a anotação com base no que o código faz. A pesquisa cria uma instância da classe. Portanto, o código usa @UsesReflectionToConstruct.
  • Use className para uma classe que não pode ser referenciada. A classe do complemento não está no caminho de classe de compilação da biblioteca principal. Portanto, a anotação não pode usar classConstant.
  • Mantenha apenas o construtor que o código chama. A pesquisa chama o construtor sem argumentos. Portanto, parameterTypes é uma matriz vazia.

Ao criar o AAR da biblioteca principal, o plug-in adiciona uma regra que mantém VideoEventTracker e o construtor sem argumentos sempre que o R8 mantém initialize. Os apps que usam o SDK aplicam essa regra automaticamente, mesmo que não usem o Jetpack Keep. Portanto, o complemento não precisa de uma regra própria. Em comparação com a regra do complemento, essa abordagem tem as seguintes vantagens:

  • A regra permanece ao lado da pesquisa. A biblioteca principal é proprietária da pesquisa e da regra. Portanto, você as atualiza juntas em vez de coordenar uma mudança em duas bibliotecas.
  • O R8 ainda pode remover códigos não utilizados. Se um app incluir o complemento, mas nunca chamar initialize, a regra gerada não será aplicada, e o R8 poderá remover VideoEventTracker. A regra do complemento o mantém em todos os apps que incluem o complemento.
  • O lint detecta pesquisas não anotadas. Se a biblioteca principal adicionar outra pesquisa reflexiva sem uma anotação, o lint vai sinalizá-la. Assim, você poderá corrigi-la antes de lançar a biblioteca.

Para confirmar o resultado, verifique se a regra gerada aparece no arquivo proguard.txt do AAR, conforme descrito em Bibliotecas do Android. Em seguida, teste um build de lançamento de um app que inclua o complemento, porque é aí que uma regra ausente quebraria o Google Analytics para vídeos.

Outros recursos