保留规则应用场景和示例

以下示例基于您使用 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 安全地删除从未传递给 TaskRunner.execute()StartupTask 实现。简而言之,此规则意味着:如果应用使用的类实现了 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 { /* ... */ }
}

可选库的开发者负责提供必要的消费者保留规则。此 keep 规则可确保使用可选库的任何应用保留核心库需要查找的代码。

# 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 可能会认为它们未被使用,并随后移除或重命名它们。这可能会导致运行时崩溃,并经常出现误导性错误消息,例如 NoSuchMethodExceptionNoSuchFieldException

例如,请考虑以下场景,该场景演示了如何从库类访问私有字段。

您不拥有的某个库包含以下代码:

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)

当从原生 (C/C++) 代码到 Java 或 Kotlin 进行上调时,R8 的优化可能会出现问题。虽然反过来也是如此,即从 Java 或 Kotlin 到原生代码的下调也可能会出现问题,但默认文件 proguard-android-optimize.txt 包含以下规则,以确保下调正常运行。此规则可防止原生方法被剪裁。

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

通过 Java 原生接口 (JNI) 与原生代码互动

当您的应用使用 JNI 从原生 (C/C++) 代码向 Java 或 Kotlin 进行 upcall 时,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 防止优化应用类型。此外,如果从原生代码调用的方法在其签名中将您自己的类用作参数或返回类型,您还必须验证这些类是否未被重命名。

向应用添加以下 keep 规则:

-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 类本身,但从原生 JNI 代码直接访问的 NativeData 中的任何成员(字段或方法)都需要有自己的保留规则。
    • -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

在这种情况下,没有建议的保留规则。kotlin-parcelize Gradle 插件会自动为使用 @Parcelize 注释的类生成所需的 keep 规则。它会为您处理复杂性,确保生成的 CREATOR 和构造函数保留下来,以供 Android 框架的反射调用使用。

如果您在 Kotlin 中手动编写 Parcelable 类而不使用 @Parcelize,则需要负责维护 CREATOR 字段和接受 Parcel 的构造函数。如果忘记这样做,当系统尝试反序列化您的对象时,应用会崩溃。使用 @Parcelize 是标准且更安全的做法。

使用 kotlin-parcelize 插件时,请注意以下事项:

  • 插件会在编译期间自动创建 CREATOR 字段。
  • proguard-android-optimize.txt 文件包含必要的 keep 规则,以保留这些字段,确保功能正常运行。
  • 应用开发者必须验证所有必需的 keep 规则是否都存在,尤其是对于任何自定义实现或第三方依赖项。