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

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

Отражение

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

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

Библиотеки часто загружают классы динамически, используя имя класса в виде 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, вам все равно необходимо использовать правило 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 из необязательной библиотеки, поскольку ничто в этом модуле напрямую его не использует. Правило keep сохраняет класс и его конструктор, позволяя основной библиотеке успешно его инициализировать.

Возможность доступа для закрытых участников.

Использование рефлексии для доступа к закрытому или защищенному коду, не являющемуся частью публичного 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);
}

Эти правила сохранения предотвращают удаление или переименование метода onNativeEvent и — что особенно важно — типа его параметра в R8.

  • -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 : Этот модификатор также сохраняет любые классы, найденные в сигнатуре метода или дескрипторе. В данном случае он предотвращает переименование или удаление класса com.example.models.NativeData , используемого в качестве параметра, со стороны R8. Если бы 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 и методы, необходимые фреймворку.

Например, рассмотрим следующий пример, где для создания класса Parcelable используется плагин kotlin-parcelize :

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