保持ルールのユースケースと例

次の例は、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 がクラスを削除することはなくなりますが、クラスがリフレクションによってインスタンス化されることを宣言し、コンストラクタなど、リフレクションによってアクセスされるメンバーを保護するには、キープルールを使用する必要があります。

たとえば、ライブラリと、そのライブラリを使用するアプリがあるシナリオを考えてみましょう。ライブラリ ローダーは、クラス参照を直接渡すことで 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>();
}

これらのルールは、このタイプのリフレクションと完全に連携するように設計されており、コードが正しく動作することを保証しながら、最大限の最適化を実現します。このルールにより、アプリで StartupTask クラスが使用されない場合、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>;: 1 つ以上のメソッド(<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 を削除する可能性があります。これは、そのモジュールで 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 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);
}

これらの keep ルールにより、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);}: includedescriptorclassesNativeData クラス自体が保持されるようにしますが、ネイティブ JNI コードから直接アクセスされる NativeData 内のメンバー(フィールドまたはメソッド)には、独自の保持ルールが必要です。
    • -keep class NativeData: NativeData という名前のクラスを対象とし、ブロックは NativeData クラス内のどのメンバーを保持するかを指定します。
    • <init>(java.lang.Integer, java.lang.String): これはコンストラクタのシグネチャです。これは、2 つのパラメータ(1 つ目は Integer、2 つ目は String)を受け取るコンストラクタを一意に識別します。

間接プラットフォーム呼び出し

Parcelable の実装を使用してデータを転送する

Android フレームワークは、リフレクションを使用して Parcelable オブジェクトのインスタンスを作成します。最新の Kotlin 開発では、kotlin-parcelize プラグインを使用する必要があります。このプラグインは、フレームワークが必要とする CREATOR フィールドやメソッドなど、必要な Parcelable 実装を自動的に生成します。

たとえば、次の例では、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 フレームワークのリフレクション呼び出し用に保持されるように、複雑な処理を自動的に行います。

@Parcelize を使用せずに Kotlin で Parcelable クラスを手動で記述する場合は、CREATOR フィールドと Parcel を受け入れるコンストラクタを保持する必要があります。この処理を忘れると、システムがオブジェクトの逆シリアル化を試みたときにアプリがクラッシュします。@Parcelize を使用することは、標準的で安全な方法です。

kotlin-parcelize プラグインを使用する場合は、次の点に注意してください。

  • このプラグインは、コンパイル時に CREATOR フィールドを自動的に作成します。
  • proguard-android-optimize.txt ファイルには、これらのフィールドを保持して機能を適切に動作させるために必要な keep ルールが含まれています。
  • アプリ デベロッパーは、必要な keep ルールがすべて存在することを確認する必要があります。特に、カスタム実装やサードパーティの依存関係については確認が必要です。