Сохраните правила использования и примеры

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

Отражение

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

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

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

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

Код библиотеки следующий:

// 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 удалить класс, вам всё равно необходимо использовать правило Keep, чтобы объявить, что экземпляр класса создаётся рефлексивно, и защитить элементы, к которым осуществляется рефлексивный доступ, например, конструктор.

Например, рассмотрим следующий сценарий, в котором у вас есть библиотека и приложение, использующее библиотеку. Загрузчик библиотеки создает экземпляр интерфейса 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 (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 (например, в aa ), сигнатура метода перестанет соответствовать ожиданиям машинного кода, что приведёт к сбою.
    • 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 .

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

Передача данных с реализацией 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

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

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

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

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