Как создавать правила хранения на основе аннотаций

Jetpack Keep позволяет объявлять использование отражения и интерфейса Java Native Interface (JNI) с помощью аннотаций непосредственно в исходном коде Kotlin или Java, а не в отдельных файлах правил Keep. Плагин Jetpack Keep Gradle считывает эти аннотации во время сборки и генерирует правила сохранения, необходимые для R8.

Jetpack Keep помогает R8 эффективно оптимизировать код по мере изменения базы кода:

  • Правила хранения находятся рядом с кодом, к которому они относятся. При переименовании или удалении кода, использующего рефлексию, вы обновляете аннотацию в том же месте. Нет отдельного файла правил, который нужно синхронизировать.

  • Сгенерированные правила являются условными. R8 сохраняет цель рефлексивного вызова, только если сохраняет код, который его выполняет. Если вызывающий код не используется и R8 удаляет его (что часто происходит с необязательными функциями библиотеки), R8 также может удалить целевой объект, чтобы правила из неиспользуемых функций не увеличивали размер приложения.

  • Lint находит рефлексию за вас. Библиотека включает проверки lint, которые отмечают рефлексивные вызовы без аннотаций и могут автоматически добавлять многие аннотации.

Как работает Jetpack Keep

Рассмотрим функцию, которая создает экземпляр класса по имени. Если R8 не сможет определить, что ваш код использует класс, он удалит конструктор класса, и приложение аварийно завершит работу во время выполнения. Чтобы предотвратить сбой, добавьте в файл правил сохранения следующее правило:

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

С помощью Jetpack Keep вы можете аннотировать функцию, которая выполняет рефлексию:

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

Во время сборки плагин Gradle генерирует правило сохранения для конструктора. В отличие от предыдущего правила, написанного вручную, сгенерированное правило является условным: R8 сохраняет конструктор, только если loadResourceLocator остается в оптимизированном приложении.

Требования

Чтобы использовать Jetpack Keep, в проекте должен быть установлен плагин Android Gradle (AGP) версии 8.7 или более поздней.

Плагин Gradle поддерживает следующие типы модулей:

Тип модуля Плагин, который применяется модулем Дополнительная настройка
В приложении для Android com.android.application Нет
Библиотека Android com.android.library Нет
Кроссплатформенная библиотека Kotlin с целевой платформой Android com.android.kotlin.multiplatform.library Нет
Библиотека JVM (Java или Kotlin) java-library или org.jetbrains.kotlin.jvm Как зарегистрировать преобразование JAR

Настроить Jetpack Keep

Чтобы использовать Jetpack Keep, добавьте библиотеку и плагин Gradle в каждый модуль, в котором используется отражение или JNI. Библиотека предоставляет аннотации и проверки lint, а плагин преобразует аннотации в правила сохранения.

  1. Добавьте библиотеку и плагин в файл libs.versions.toml. Библиотека и плагин имеют одну и ту же версию:

    [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" }
    

    Gradle скачивает плагин из репозитория Maven Google, поэтому убедитесь, что в хранилищах pluginManagement в файле settings.gradle.kts есть google().

  2. В корневом файле build.gradle.kts объявите плагин вместе с AGP, не применяя его:

    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. В файле build.gradle.kts каждого модуля, использующего рефлексию или JNI, примените плагин и добавьте зависимость библиотеки:

    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. Синхронизируйте проект с файлами Gradle.

Для каждого варианта сборки модуля приложения Android плагин регистрирует задачу <variant>ExtractKeepRules, например releaseExtractKeepRules, и передает сгенерированные правила в R8 для этого варианта. Для модулей библиотек плагин упаковывает сгенерированные правила как правила сохранения потребителей в выходных данных библиотеки. Для модулей библиотеки JVM требуется дополнительная настройка.

Найти отражение с ворсом

Jetpack Keep включает следующие проверки lint:

ReflectionWithoutKeepAnnotations
Помечает рефлексивные вызовы, такие как Class.forName, getDeclaredConstructor, getDeclaredMethod и getDeclaredField, у которых нет соответствующей аннотации keep.
MissingKeepAnnotationPlugin
Помечает модули, которые зависят от androidx.annotation.keep:keep, но не применяют плагин Gradle androidx.annotation.keep. Без плагина сборка не генерирует правила сохранения из аннотаций.

Чтобы использовать lint для аннотирования модуля Android, выполните следующие действия:

  1. Запустите lint в модуле, чтобы найти вызовы, требующие аннотаций:

    ./gradlew :app:lint
    
  2. Чтобы применить исправления, которые может выполнить инструмент lint, сделайте следующее:

    ./gradlew :app:lintFix
    

    Lint может добавить аннотацию автоматически, если сможет определить, какой класс и какие элементы отражены в коде.

  3. Проверьте каждую добавленную аннотацию, чтобы убедиться, что она соответствует действиям вашего кода. Например, убедитесь, что типы параметров соответствуют конструктору или методу, который вызывает ваш код.

  4. Устраните оставшиеся проблемы в Android Studio. Если инструменту lint не удается определить целевой класс, функция быстрого исправления вставляет аннотацию с плейсхолдером TODO, который вам нужно заполнить. Вы также можете добавить аннотацию вручную.

    Например, следующая функция получает название класса в качестве параметра, поэтому lint не может определить, какой класс она создает. Быстрое исправление добавляет аннотацию с плейсхолдером:

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

    Замените плейсхолдер классом, который создает ваш код. Если класс доступен во время компиляции, используйте classConstant вместо className. В Kotlin код не компилируется, пока вы не замените TODO().

  5. Прежде чем удалять существующие правила, проверьте, какие правила сохранения создает плагин на основе ваших аннотаций. Для модуля приложения запустите задачу извлечения для варианта выпуска:

    ./gradlew :app:releaseExtractKeepRules
    

    Плагин записывает правила в файл app/build/generated/release/androidx.annotation.keep.rules.pro. Для модулей библиотеки ознакомьтесь с разделом Проверка сгенерированных правил.

  6. Удалите из файлов правил сохранения правила, которые заменяются новыми аннотациями. Правило также может охватывать рефлексию, которую lint не пометил, например рефлексию в другом модуле или библиотеке, поэтому проверяйте каждое правило, прежде чем удалять его.

    • Для правила на уровне пакета, например -keep class com.example.** { *; }, убедитесь, что аннотации или другие правила охватывают все отражения в классах пакета.
    • Если правило сохраняет определенные классы или элементы, убедитесь, что оно точно соответствует одному из сгенерированных правил или что от него не зависит никакое другое отражение.
  7. Протестируйте конечную сборку.

Добавьте аннотации в код

Добавьте аннотацию Jetpack Keep в код, который выполняет рефлексию: функцию, конструктор, поле или класс, содержащий рефлексивный вызов. Аннотация описывает цель рефлексии, а сгенерированное правило сохраняет эту цель, только если R8 сохраняет аннотированный код.

Выберите аннотацию в зависимости от того, что делает ваш код:

Аннотация Используйте, если ваш код
@UsesReflectionToConstruct Вызывает конструктор через рефлексию, например с помощью getDeclaredConstructor().newInstance().
@UsesReflectionToAccessMethod Вызывает метод с помощью отражения, например с помощью getMethod или getDeclaredMethod.
@UsesReflectionToAccessField Считывает или записывает поле с помощью отражения, например с помощью getField или getDeclaredField.
@UnconditionallyKeep Вызывается извне кода Kotlin или Java вашего приложения, например из нативного кода через JNI. Используйте эту аннотацию только в том случае, если вы не можете использовать аннотации @UsesReflection*, которые создают условные правила.

Все аннотации @UsesReflection* соответствуют следующим соглашениям:

  • Определите целевой класс с помощью classConstant или className. Если класс доступен во время компиляции, используйте classConstant. Компилятор проверяет ссылку, а IDE обновляет ее при рефакторинге, например при переименовании. Используйте className, только если класс недоступен во время компиляции, например если он находится в необязательной зависимости. Эти два параметра являются взаимоисключающими.
  • Выбирайте наиболее подходящий класс. Сгенерированные правила продолжают сопоставлять элементы в указанном классе и его подклассах или реализациях интерфейса. Чем более общий класс, тем больше кода сохраняет R8.
  • Укажите типы параметров, чтобы сохранить одну перегрузку. Если не задать parameterTypes или parameterTypeNames, правила сохранят все конструкторы или все перегрузки метода. Чтобы оставить только конструктор или метод без аргументов, передайте пустой массив.
  • Повторите аннотацию для нескольких целей. Если функция относится к нескольким классам или членам, добавьте по одной аннотации для каждого целевого объекта.

Создание объектов с помощью рефлексии

Используйте @UsesReflectionToConstruct, если ваш код создает экземпляр класса с помощью рефлексии. В примере ниже исполнитель задач создает экземпляры реализаций интерфейса StartupTask по имени. Поскольку аннотация указывает на интерфейс, сгенерированные правила сохраняют конструктор без аргументов для каждой реализации StartupTask, если R8 сохраняет 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()
    }
}

Вызов методов с помощью рефлексии

Используйте @UsesReflectionToAccessMethod, когда ваш код вызывает метод с помощью рефлексии. В качестве значения methodName укажите название метода. В примере ниже код вызывает статический метод в необязательной зависимости. Класс недоступен во время компиляции, поэтому в аннотации используется className вместо 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)
    }
}

Доступ к полям с помощью рефлексии

Используйте @UsesReflectionToAccessField, когда ваш код считывает или записывает поле через рефлексию. Задайте для параметра fieldName название поля:

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

Как сохранить код, вызываемый извне приложения

Используйте @UnconditionallyKeep для кода, который вызывается извне кода Kotlin или Java вашего приложения, например метода, который нативный код вызывает через JNI. Добавляйте аннотации к вызываемому элементу, а не к коду, который его вызывает.

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

Сгенерированное правило сохраняет аннотированный метод и содержащий его класс с их исходными именами. Другие классы, на которые ссылается сигнатура метода, не сохраняются. Если нативный код зависит от этих классов, добавьте аннотации и для них или добавьте правила keep. Подробнее о JNI и R8 рассказывается в статье Взаимодействие с нативным кодом через JNI.

По умолчанию @UnconditionallyKeep сохраняет название элемента. Если внешний вызывающий объект не зависит от имени, задайте значение shouldPreserveName = false, чтобы R8 мог переименовать элемент.

Проверьте сгенерированные правила

В этом разделе рассказывается, как проверить сгенерированные правила для каждого типа модуля. Для модуля приложения плагин передает правила напрямую в R8. Для модуля библиотеки плагин упаковывает правила как правила сохранения потребителя в выходных данных библиотеки. Приложения, которые зависят от вашей библиотеки, автоматически применяют эти правила при запуске R8, даже если они не используют Jetpack Keep. Подробную информацию о правилах сохранения для потребителей можно найти в разделе Оптимизация для авторов библиотек.

Приложения Android

Чтобы проверить, какие правила сохранения плагин генерирует для модуля приложения, запустите задачу извлечения для варианта сборки:

./gradlew :app:releaseExtractKeepRules

Плагин записывает правила для каждого варианта в файл app/build/generated/<variant>/androidx.annotation.keep.rules.pro. Комментарий # context: перед каждым правилом указывает на аннотированный код, который его создал.

Чтобы проверить, как сгенерированные правила влияют на оптимизацию вместе с другими правилами сохранения, используйте анализатор конфигурации R8.

Библиотеки Android

Модули библиотеки Android не требуют дополнительной настройки. При сборке AAR плагин добавляет сгенерированные правила в файл proguard.txt пакета AAR после всех правил сохранения, которые уже есть в пакете. Плагин регистрирует задачу <variant>KeepRulesTransformAar для каждого варианта сборки.

Чтобы проверить правила, создайте AAR, например с помощью ./gradlew :mylibrary:assembleRelease, и откройте proguard.txt в созданном AAR. Сгенерированные правила будут показаны в разделе с заголовком:

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

В macOS и Linux правила можно также распечатать из командной строки:

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

Мультиплатформенные библиотеки Kotlin

Модули библиотеки Kotlin Multiplatform с целевым объектом Android также не требуют дополнительной настройки. При создании AAR для целевой платформы Android плагин добавляет сгенерированные правила в файл proguard.txt AAR так же, как и для библиотек Android. Для этого плагин регистрирует задачу androidMainKeepRulesTransformAar.

Чтобы проверить правила, запустите эту задачу и откройте файл proguard.txt в созданном ею архиве Android. В macOS и Linux правила можно распечатать из командной строки:

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

Библиотеки JVM

Прежде чем проверять правила для модуля библиотеки JVM, настройте модуль так, чтобы плагин добавлял сгенерированные правила в копию своего JAR-файла. Затем запустите зарегистрированную задачу преобразования и проверьте файлы правил в созданном ею JAR-файле. Например, если вы назвали задачу keepRulesJar, то можете распечатать правила в macOS и Linux с помощью следующих команд:

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

Как настроить библиотеки JVM

Для модулей библиотеки JVM, которые создают JAR, плагин не изменяет JAR автоматически. Вместо этого используйте функцию annotationKeep.registerJavaArchiveTransform, чтобы зарегистрировать задачу, которая создает копию JAR с созданными правилами, а затем публикует этот JAR. Добавьте в файл build.gradle.kts модуля библиотеки следующее:

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

Преобразование добавляет сгенерированные правила в целевое местоположение правил R8 JAR-файла: META-INF/com.android.tools/r8/proguard.txt. Если в JAR-файле уже есть правила в META-INF/com.android.tools/ или META-INF/proguard/, приложение для преобразования добавит сгенерированные правила в существующий файл правил. Подробнее о том, как поддерживать разные версии R8…

После регистрации преобразования проверьте правила в JAR-файле.

Пример. Загрузка дополнительной библиотеки

Библиотеки часто используют рефлексию для загрузки дополнительных дополнений. Например, Analytics SDK может поставляться с основной библиотекой и дополнением для видео. Дополнение зависит от основной библиотеки, поэтому основная библиотека не может ссылаться на классы дополнения напрямую. Вместо этого основная библиотека ищет класс дополнения по названию во время выполнения и включает видеоаналитику, только если находит его.

Поскольку основная библиотека использует класс дополнения только через рефлексию, R8 может удалить класс или его конструктор из конечной сборки приложения, что приведет к сбою поиска. Сбой происходит только в конечных сборках приложений, в которые включено дополнение, поэтому его трудно обнаружить при тестировании.

Без Jetpack Keep дополнение поставляет правило сохранения для своего класса, как показано в разделе Рефлексия для поддержки необязательных зависимостей:

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

Это правило находится в другой библиотеке, нежели таблица, которая его использует, поэтому они могут быть не синхронизированы. С Jetpack Keep вы можете аннотировать поиск в основной библиотеке:

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

Аннотация соответствует следующим рекомендациям:

  • Добавьте аннотацию к коду, который выполняет рефлексию. Аннотация находится в initialize, который вызывает Class.forName, а не в классе дополнения.
  • Выберите аннотацию в зависимости от того, что делает код. Поиск создает экземпляр класса, поэтому в коде используется @UsesReflectionToConstruct.
  • Используйте className для класса, на который нельзя сослаться. Класс дополнения отсутствует в пути компиляции основной библиотеки, поэтому аннотация не может использовать classConstant.
  • Оставьте только конструктор, который вызывается кодом. При вызове поиска вызывается конструктор без аргументов, поэтому parameterTypes является пустым массивом.

Когда вы создаете AAR-файл основной библиотеки, плагин добавляет правило, которое сохраняет VideoEventTracker и его конструктор без аргументов, когда R8 сохраняет initialize. Приложения, использующие SDK, применяют это правило автоматически, даже если они не используют Jetpack Keep, поэтому дополнению не нужно собственное правило. По сравнению с правилом дополнения этот подход имеет следующие преимущества:

  • Правило остается рядом с поиском. Поскольку и поиск, и правило относятся к основной библиотеке, вы можете обновить их одновременно, а не координировать изменения в двух библиотеках.
  • R8 по-прежнему может удалять неиспользуемый код. Если приложение включает дополнение, но никогда не вызывает initialize, сгенерированное правило не применяется и R8 может удалить VideoEventTracker. Правило дополнения сохраняет его во всех приложениях, в которые оно включено.
  • Lint обнаруживает неаннотированные поисковые запросы. Если в основную библиотеку позже будет добавлен ещё один рефлексивный поиск без аннотации, lint пометит его, чтобы вы могли исправить это до выпуска библиотеки.

Чтобы проверить результат, убедитесь, что сгенерированное правило появилось в файле proguard.txt AAR, как описано в разделе Библиотеки Android. Затем протестируйте конечную сборку приложения, в которое включено дополнение, поскольку именно в ней отсутствие правила может нарушить работу видеоаналитики.

Дополнительные ресурсы