Примеры использования правил Keep

Приведенные ниже примеры основаны на распространенных сценариях, в которых вы используете R8 для оптимизации, но вам нужны дополнительные инструкции по составлению правил сохранения.

Отражение

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

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

Рефлексия с классами, загруженными по имени

Библиотеки часто загружают классы динамически, используя название класса в качестве String. Однако R8 не может обнаружить классы, загруженные таким образом, и может удалить классы, которые считает неиспользуемыми.

Рассмотрим сценарий, в котором у вас есть библиотека и приложение, использующее ее. В коде ниже показан загрузчик библиотеки, который создает экземпляр интерфейса StartupTask, реализованного приложением.

Код библиотеки выглядит следующим образом:

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

В приложении, использующем библиотеку, есть следующий код:

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

В этом случае в библиотеке должен быть файл правил хранения для потребителей со следующими правилами:

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

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

Размышления с ::class.java

Библиотеки могут загружать классы, передавая объект Class напрямую. Это более надежный способ, чем загрузка классов по имени. Это создает строгую ссылку на класс, которую может обнаружить R8. Однако, хотя это и не позволит R8 удалить класс, вам все равно нужно будет использовать правило сохранения, чтобы указать, что класс создается рефлексивно, и защитить члены, к которым осуществляется рефлексивный доступ, например конструктор.

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

Код библиотеки выглядит следующим образом:

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

В приложении, использующем библиотеку, есть следующий код:

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

В этом случае библиотека должна включать файл правил сохранения для потребителей со следующими правилами:

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

Эти правила идеально подходят для такого типа рефлексии, поскольку позволяют максимально оптимизировать код и при этом обеспечить его корректную работу. Правила позволяют R8 обфусцировать название класса и сжимать или удалять реализацию класса StartupTask, если приложение никогда его не использует. Однако для любой реализации, например PrecacheTask, которая используется в примере, сохраняется конструктор по умолчанию (<init>()), который должна вызывать ваша библиотека.

  • -keep,allowobfuscation,allowshrinking class * implements com.example.library.StartupTask – это правило применяется к любому классу, который реализует интерфейс StartupTask.
    • -keep class * implements com.example.library.StartupTask: этот атрибут сохраняет любой класс (*), реализующий ваш интерфейс.
    • ,allowobfuscation: эта команда указывает R8, что, несмотря на сохранение класса, его можно переименовать или обфусцировать. Это безопасно, поскольку ваша библиотека не полагается на название класса, а получает объект Class напрямую.
    • ,allowshrinking – этот модификатор указывает R8, что он может удалить класс, если он не используется. Это позволяет R8 безопасно удалить реализацию StartupTask, которая никогда не передается в TaskRunner.execute(). Если приложение использует класс, который реализует StartupTask, R8 сохраняет этот класс. R8 может переименовать класс, чтобы уменьшить его размер, и удалить его, если он не используется в приложении.
  • -keepclassmembers class * implements com.example.library.StartupTask { <init>(); }. Это правило применяется к определенным членам классов, которые были идентифицированы в первом правиле (в данном случае – к конструктору).
    • -keepclassmembers class * implements com.example.library.StartupTask – сохраняет определенные элементы (методы, поля) класса, реализующего интерфейс StartupTask, но только если сам реализованный класс сохраняется.
    • { <init>(); } – селектор участников. <init> – это специальное внутреннее название конструктора в байт-коде Java. Эта часть нацелена на конструктор по умолчанию без аргументов.
    • Это правило критически важно, поскольку ваш код вызывает getDeclaredConstructor().newInstance() без аргументов, что рефлексивно вызывает конструктор по умолчанию. Без этого правила R8 видит, что ни один код не вызывает new PreCacheTask() напрямую, предполагает, что конструктор не используется, и удаляет его. Из-за этого во время выполнения приложения происходит сбой с ошибкойInstantiationException.

Рефлексия на основе аннотации метода

В библиотеках часто определяются аннотации, которые разработчики используют для пометки методов или полей. Затем библиотека использует рефлексию, чтобы найти эти аннотированные элементы во время выполнения. Например, аннотация @OnLifecycleEvent используется для поиска нужных методов во время выполнения.

Рассмотрим следующий сценарий, в котором у вас есть библиотека и приложение, использующее ее. В примере показана шина событий, которая находит и вызывает методы, аннотированные с помощью @OnEvent.

Код библиотеки выглядит следующим образом:

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

В приложении, использующем библиотеку, есть следующий код:

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

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

-keepattributes RuntimeVisibleAnnotations
-keep @interface com.example.library.OnEvent;
-keepclassmembers class * {
    @com.example.library.OnEvent <methods>;
}
  • -keepattributes RuntimeVisibleAnnotations – это правило сохраняет аннотации, которые должны считываться во время выполнения.
  • -keep @interface com.example.library.OnEvent: это правило сохраняет класс аннотаций OnEvent.
  • -keepclassmembers class * {@com.example.library.OnEvent <methods>;}: Это правило сохраняет класс и его элементы, только если класс используется и содержит эти элементы.
    • -keepclassmembers – это правило сохраняет класс и определенные элементы, только если класс используется и содержит эти элементы.
    • class * – правило применяется к любому классу.
    • @com.example.library.OnEvent <methods>;: сохраняет любой класс, в котором есть один или несколько методов (<methods>), аннотированных с помощью @com.example.library.OnEvent, а также сами аннотированные методы.

Размышления на основе аннотаций к уроку

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

Рассмотрим следующий сценарий, в котором у вас есть библиотека и приложение, которое ее использует.

Библиотека содержит следующий код:

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

В приложении, использующем библиотеку, есть следующий код:

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

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

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

Такая конфигурация очень эффективна, поскольку R8 точно знает, что нужно сохранить.

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

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

Основная библиотека использует рефлексию (Class.forName), чтобы найти определенный класс по его названию. Если курс найден, функция включается. Если нет, то он просто не сработает.

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

Основная библиотека содержит следующий код:

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

Необязательная библиотека видео содержит следующий код:

package com.example.analytics.video

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

Разработчик необязательной библиотеки должен предоставить необходимое правило сохранения для потребителя. Это правило гарантирует, что любое приложение, использующее дополнительную библиотеку, сохранит код, который должна найти основная библиотека.

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

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

Рефлексия для доступа к закрытым членам

Использование рефлексии для доступа к закрытому или защищенному коду, который не является частью общедоступного API библиотеки, может привести к серьезным проблемам. Такой код может быть изменен без уведомления, что может привести к неожиданному поведению или сбоям в вашем приложении.

Если вы используете рефлексию для закрытых API, могут возникнуть следующие проблемы:

  • Блокировка обновлений. Изменения в частном или защищенном коде могут помешать вам обновить библиотеку до более новой версии.
  • Упущенные преимущества. Вы можете пропустить новые функции, важные исправления ошибок и обновления системы безопасности.

Оптимизация R8 и рефлексия

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

Рассмотрим следующий сценарий, который демонстрирует, как можно получить доступ к закрытому полю из класса библиотеки.

В библиотеке, которая вам не принадлежит, есть следующий код:

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

В вашем приложении есть следующий код:

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
}

Добавьте в приложение правило -keep, чтобы R8 не удалял частное поле:

-keepclassmembers class com.example.LibraryClass {
    private java.lang.String secretMessage;
}
  • -keepclassmembers: сохраняются только определенные участники курса, если сам курс сохранен.
  • class com.example.LibraryClass: этот тег относится к определенному классу, содержащему поле.
  • private java.lang.String secretMessage; – название и тип частного поля.

Java Native Interface (JNI)

Оптимизация R8 может вызывать проблемы при работе с вызовами из нативного кода (C/C++) в Java или Kotlin. Обратные вызовы из кода Java или Kotlin в нативный код также могут вызывать проблемы, но в файле proguard-android-optimize.txt по умолчанию есть следующее правило, которое позволяет им работать. Это правило защищает встроенные методы от удаления.

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

Взаимодействие с нативным кодом через Java Native Interface (JNI).

Если ваше приложение использует JNI для вызовов из нативного кода (C/C++) в Java или Kotlin, R8 не может определить, какие методы вызываются из нативного кода. Если в вашем приложении нет прямых ссылок на эти методы, R8 ошибочно предполагает, что они не используются, и удаляет их, что приводит к сбою приложения.

В следующем примере показан класс Kotlin с методом, который должен вызываться из нативной библиотеки. Нативная библиотека создает экземпляр типа приложения и передает данные из нативного кода в код 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")
        }
    }
}

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

Добавьте в приложение следующие правила:

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

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

Эти правила запрещают R8 удалять или переименовывать метод onNativeEvent и, что важно, тип его параметра.

  • -keepclassmembers,includedescriptorclasses class com.example.JniBridge{ public void onNativeEvent(com.example.model.NativeData);}. Сохраняет определенные элементы класса, только если экземпляр класса сначала создается в коде Kotlin или Java. Это сообщает R8, что приложение использует класс и что он должен сохранить определенные элементы класса.
    • -keepclassmembers. Сохраняет определенные элементы класса, только если экземпляр класса сначала создается в коде Kotlin или Java. Это сообщает R8, что приложение использует класс и что он должен сохранить определенные элементы класса.
    • class com.example.JniBridge – таргетинг на точный класс, содержащий поле.
    • includedescriptorclasses: этот модификатор также сохраняет все классы, найденные в сигнатуре или дескрипторе метода. В данном случае это не позволяет R8 переименовать или удалить класс com.example.models.NativeData, который используется в качестве параметра. Если бы метод NativeData был переименован (например, в a.a), сигнатура метода больше не соответствовала бы ожидаемой нативным кодом, что привело бы к сбою.
    • public void onNativeEvent(com.example.models.NativeData); – точная сигнатура Java метода, который нужно сохранить.
  • -keep class NativeData{<init>(java.lang.Integer, java.lang.String);}. Хотя includedescriptorclasses гарантирует, что сам класс NativeData будет сохранен, для всех элементов (полей или методов) в NativeData, доступ к которым осуществляется непосредственно из нативного кода JNI, требуются собственные правила сохранения.
    • -keep class NativeData: этот код предназначен для класса с названием NativeData, а блок указывает, какие элементы класса NativeData нужно сохранить.
    • <init>(java.lang.Integer, java.lang.String) – сигнатура конструктора. Он однозначно идентифицирует конструктор, который принимает два параметра: первый – Integer, а второй – String.

Вы также можете добавить аннотацию @UnconditionallyKeep из Jetpack Keep к методам, которые вызываются нативным кодом, вместо того чтобы писать для них правила сохранения. Аннотация не сохраняет классы в сигнатуре метода, например NativeData, поэтому вам все равно нужно сохранять эти классы отдельно.

Косвенные звонки на платформе

Перенос данных с помощью реализации Parcelable

Платформа Android использует рефлексию для создания экземпляров объектов Parcelable. В современной разработке на Kotlin следует использовать плагин kotlin-parcelize, который автоматически генерирует необходимую реализацию Parcelable, включая поле CREATOR и методы, необходимые фреймворку.

Например, в следующем примере плагин kotlin-parcelize используется для создания класса 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

В этом случае рекомендуемое правило хранения отсутствует. Плагин kotlin-parcelize Gradle автоматически генерирует необходимые правила сохранения для классов, которые вы аннотируете с помощью @Parcelize. Он берет на себя всю сложную работу, гарантируя, что сгенерированные CREATOR и конструкторы будут сохранены для вызовов рефлексии фреймворка Android.

Если вы вручную создаете класс Parcelable на языке Kotlin без использования @Parcelize, вам необходимо поддерживать поле CREATOR и конструктор, принимающий объект Parcel. Если вы этого не сделаете, то при попытке десериализации объекта произойдет сбой приложения. Стандартным и более безопасным вариантом является использование значения @Parcelize.

При использовании плагина kotlin-parcelize учитывайте следующее:

  • Плагин автоматически создает поля CREATOR во время компиляции.
  • Файл proguard-android-optimize.txt содержит правила keep, необходимые для сохранения этих полей и обеспечения правильной работы.
  • Разработчики приложений должны убедиться, что все необходимые правила keep присутствуют, особенно если используются специальные реализации или сторонние зависимости.

Библиотеки, использующие рефлексию или преобразование байт-кода, получают доступ к коду динамически во время выполнения. Если R8 удалит или переименует классы, поля или методы, к которым осуществляется доступ таким образом, приложение может аварийно завершить работу.

Однако популярные сторонние библиотеки (такие как Gson, Retrofit и Kotlinx Serialization) автоматически включают собственные правила R8 для сохранения потребителей. При использовании последних версий этих библиотек вам не нужно добавлять в проект правила сохранения вручную.

Gson

Gson – это библиотека сериализации и десериализации JSON, которая в значительной степени зависит от рефлексии. Если вы используете полный режим для оптимизации приложения, он удаляет сигнатуры универсальных типов, конструкторы по умолчанию и неаннотированные поля, если явно не указано иное.

Чтобы Gson работал правильно, добавьте специальные правила, которые сохранят непереходные поля в классах модели данных и иерархию 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

Поля, помеченные модификатором transient, игнорируются Gson во время сериализации и десериализации, поэтому правило сохранения нацелено именно на непереходные поля (!transient).

Модернизация

Retrofit – это сетевая библиотека, которая проверяет методы интерфейса сервиса, аннотированные с помощью HTTP-аннотаций (например, @GET или @POST), используя отражение для создания сетевых запросов и преобразования ответов.

Retrofit динамически генерирует реализации ваших интерфейсов API во время выполнения с помощью Proxy.newProxyInstance(). Поскольку R8 не видит класс, статически реализующий эти интерфейсы, он может удалить методы или их общие типы возвращаемых значений.

Правила хранения пакетов

Retrofit использует рефлексию во время выполнения для проверки общих параметров, аннотаций методов и аннотаций параметров. Без правильной конфигурации R8 в полном режиме может полностью удалять общие сигнатуры из типов возвращаемых значений, продолжений Kotlin и классов ответов или даже заменять значения интерфейса на null, поскольку интерфейсы Retrofit динамически создаются с помощью прокси.

Начиная с версии 2.10.0 библиотека автоматически включает официальные правила сохранения, необходимые для сохранения значений по умолчанию для аннотаций, параметров методов сервиса и метаданных классов. Подробнее о правилах, используемых Retrofit…

Сохранять общие типы возвращаемых значений

Retrofit проверяет общую сигнатуру типа возвращаемого значения (например, Observable<Data>), чтобы правильно десериализовать ответ сети. Если R8 удалит общую сигнатуру, Retrofit заменит экземпляр объекта на null.

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

# 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>

Класс модели данных, который возвращается (например, Data в Observable<Data>), также должен быть сохранен, поскольку он будет создан конвертером (например, Gson) с помощью рефлексии.

Сопрограммы

Когда вы используете сопрограммы Kotlin, компилятор Kotlin преобразует функции suspend, добавляя параметр Continuation к сигнатуре скомпилированного метода.

Когда библиотеки, такие как Retrofit, рефлексивно считывают общую сигнатуру функции suspend, они полагаются на параметр Continuation. В полном режиме атрибут Signature сохраняется только для классов, которые явно сохраняются. Поскольку Continuation – синтетический параметр, R8 по умолчанию удаляет его сигнатуру, нарушая рефлексию.

Чтобы предотвратить удаление подписи и обеспечить совместимость во время выполнения в полном режиме, добавьте следующее правило:

# Keep the signature attribute globally
-keepattributes Signature

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