保留規則用途和範例

以下範例是以常見情境為基礎,說明如何使用 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 規則檔案,並具備下列 Keep 規則:

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

在此情境中,程式庫應包含消費者 Keep 規則檔案,並具備下列 Keep 規則:

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

這些規則的設計宗旨是與這類反射完美搭配,盡可能進行最佳化,同時確保程式碼正常運作。如果應用程式從未使用 StartupTask 類別,R8 就能透過規則模糊處理類別名稱,並縮減或移除該類別的實作項目。不過,對於任何實作項目 (例如範例中使用的 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 { /* ... */ }
}

選用程式庫的開發人員有責任提供必要的消費者保留規則。這項保留規則可確保使用選用程式庫的任何應用程式,都會保留核心程式庫尋找程式碼時所需的程式碼。

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

如果沒有這項規則,R8 很可能會從選用程式庫中移除 VideoEventTracker,因為該模組中沒有任何項目直接使用它。保留規則會保留類別及其建構函式,讓核心程式庫成功例項化。

使用反射存取私有成員

使用反射存取不屬於程式庫公開 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 Native Interface (JNI)

從原生 (C/C++ 程式碼) 向上呼叫 Java 或 Kotlin 時,R8 的最佳化功能可能會發生問題。反向也是如此,從 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 類別本身,但如果從原生 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 註解的類別產生必要的保留規則。這項工具會為您處理複雜作業,確保產生的 CREATOR 和建構函式會保留給 Android 架構的反射呼叫。

如果您在 Kotlin 中手動編寫 Parcelable 類別,而未使用 @Parcelize,請自行負責保留 CREATOR 欄位和接受 Parcel 的建構函式。如果忘記這麼做,系統嘗試還原序列化物件時,應用程式就會異常終止。使用 @Parcelize 是標準做法,也更安全。

使用 kotlin-parcelize 外掛程式時,請注意下列事項:

  • 外掛程式會在編譯期間自動建立 CREATOR 欄位。
  • proguard-android-optimize.txt 檔案包含必要的 keep 規則,可保留這些欄位以確保功能正常運作。
  • 應用程式開發人員必須確認所有必要keep規則都存在, 尤其是任何自訂實作項目或第三方依附元件。