アノテーションから保持ルールを生成する

Jetpack Keep を使用すると、リフレクションと Java Native Interface(JNI)の使用を、個別の keep ルール ファイルではなく、Kotlin または Java のソースコードに直接アノテーションで宣言できます。Jetpack Keep Gradle プラグインは、ビルド時にこれらのアノテーションを読み取り、R8 に必要な保持ルールを生成します。

Jetpack Keep は、コードベースが変更されても R8 の最適化が有効な状態を維持できるようにします。

  • 保持ルールを、そのルールを必要とするコードの近くに配置する。リフレクションを使用するコードの名前を変更または削除する場合は、同じ場所でアノテーションを更新します。同期を維持するための個別のルールファイルはありません。

  • 生成されたルールは条件付きです。R8 は、リフレクション呼び出しを行うコードも保持する場合にのみ、リフレクション呼び出しのターゲットを保持します。thought 呼び出し元のコードが使用されておらず、R8 がそれを削除する場合(オプションのライブラリ機能でよくあるケース)、R8 はターゲットも削除できるため、未使用の機能のルールによって肥大化することはありません。

  • Lint がリフレクションを検出します。このライブラリには、アノテーションが付加されていないリフレクション呼び出しにフラグを立てる lint チェックが含まれており、多くのアノテーションを自動的に追加できます。

Jetpack Keep の仕組み

名前でクラスをインスタンス化する関数について考えてみましょう。コードがクラスを使用していることを R8 が認識できない場合、クラスのコンストラクタが削除され、実行時にアプリがクラッシュします。このクラッシュを防ぐには、次のようなルールを keep ルールファイルに追加します。

# Unconditional: R8 keeps this constructor even if it removes the reflection that invokes it.
-keep class com.example.ResourceLocatorImpl {
    <init>();
}

Jetpack Keep を使用する場合は、代わりにリフレクションを実行する関数にアノテーションを付けます。

// ResourceLocatorImpl isn't on this module's compile classpath, so both the
// annotation and the code refer to it by name.
@UsesReflectionToConstruct(
    className = "com.example.ResourceLocatorImpl",
    // Omitting parameterTypes keeps every constructor. An empty array keeps
    // only the no-argument constructor, which the code calls.
    parameterTypes = [],
)
fun loadResourceLocator(): ResourceLocator {
    val implClass = Class.forName("com.example.ResourceLocatorImpl")
    return implClass.getDeclaredConstructor().newInstance() as ResourceLocator
}

ビルド時に、Gradle プラグインはコンストラクタの keep ルールを生成します。前述の手動で記述したルールとは異なり、生成されたルールは条件付きです。R8 は、loadResourceLocator が最適化されたアプリに残っている場合にのみコンストラクタを保持します。

要件

Jetpack Keep を使用するには、プロジェクトに Android Gradle プラグイン(AGP)8.7 以降が必要です。

Gradle プラグインは、次のモジュール タイプをサポートしています。

モジュール タイプ モジュールが適用するプラグイン 追加の設定
Android アプリ com.android.application なし
Android ライブラリ com.android.library なし
Android ターゲットを含む Kotlin マルチプラットフォーム ライブラリ com.android.kotlin.multiplatform.library なし
JVM ライブラリ(Java または Kotlin) java-library または org.jetbrains.kotlin.jvm JAR 変換を登録する

Jetpack Keep を設定する

Jetpack Keep を使用するには、リフレクションまたは JNI を使用する各モジュールに、ライブラリと Gradle プラグインの両方を追加します。ライブラリはアノテーションと lint チェックを提供し、プラグインはアノテーションを keep ルールに変換します。

  1. libs.versions.toml ファイルにライブラリとプラグインを追加します。ライブラリとプラグインは同じバージョンを共有します。

    [versions]
    # The library and the plugin ship together. Use one version for both so
    # that they stay in sync when you upgrade.
    annotationKeep = "1.0.0-alpha01"
    
    [libraries]
    androidx-annotation-keep = { group = "androidx.annotation.keep", name = "keep", version.ref = "annotationKeep" }
    
    [plugins]
    androidx-annotation-keep = { id = "androidx.annotation.keep", version.ref = "annotationKeep" }
    

    Gradle は Google の Maven リポジトリからプラグインをダウンロードするため、settings.gradle.kts ファイルの pluginManagement リポジトリに google() が含まれていることを確認してください。

  2. ルート build.gradle.kts ファイルで、プラグインを適用せずに AGP と一緒に宣言します。

    plugins {
        alias(libs.plugins.android.application) apply false
        // Other plugins.
    
        // The plugin relies on AGP classes that it doesn't bundle, so declare
        // it next to AGP. Declaring it here also gives every module the same
        // plugin version.
        alias(libs.plugins.androidx.annotation.keep) apply false
    }
    
  3. リフレクションまたは JNI を使用する各モジュールの build.gradle.kts ファイルで、プラグインを適用し、ライブラリの依存関係を追加します。

    plugins {
        alias(libs.plugins.android.application)
        // The plugin reads annotations only from this module's own code, so
        // apply it in every module that uses Jetpack Keep.
        alias(libs.plugins.androidx.annotation.keep)
    }
    
    dependencies {
        implementation(libs.androidx.annotation.keep)
    }
    
  4. プロジェクトを Gradle ファイルと同期します。

Android アプリ モジュールのビルド バリアントごとに、プラグインは <variant>ExtractKeepRules タスク(releaseExtractKeepRules など)を登録し、生成されたルールをそのバリアントの R8 に渡します。ライブラリ モジュールの場合、プラグインは生成されたルールをコンシューマーの保持ルールとしてライブラリの出力にパッケージ化します。JVM ライブラリ モジュールには追加の設定が必要です。

lint を使用してリフレクションを見つける

Jetpack Keep には、次の lint チェックが含まれています。

ReflectionWithoutKeepAnnotations
一致する keep アノテーションがないリフレクション呼び出し(Class.forName、getDeclaredConstructor、getDeclaredMethod、getDeclaredField など)にフラグを付けます。
MissingKeepAnnotationPlugin
androidx.annotation.keep:keep に依存しているが、androidx.annotation.keep Gradle プラグインを適用していないモジュールにフラグを付けます。このプラグインがないと、ビルドでアノテーションから保持ルールが生成されません。

lint を使用して Android モジュールにアノテーションを付ける手順は次のとおりです。

  1. モジュールで lint を実行して、アノテーションが必要なリフレクション呼び出しを見つけます。

    ./gradlew :app:lint
    
  2. lint が自動的に行う修正を適用します。

    ./gradlew :app:lintFix
    

    Lint は、コードがどのクラスとメンバーを反映しているかを判断できる場合、アノテーションを自動的に追加できます。

  3. 追加した各アノテーションが、コードの動作と一致していることを確認します。たとえば、パラメータの型が、コードが呼び出すコンストラクタまたはメソッドと一致していることを確認します。

  4. Android Studio で残りの検出結果を解決します。lint がターゲット クラスを特定できない場合、クイックフィックスは TODO プレースホルダを含むアノテーションを挿入します。アノテーションを手動で追加することもできます。

    たとえば、次の関数はクラス名をパラメータとして受け取るため、lint はどのクラスが構築されるかを判断できません。クイックフィックスにより、プレースホルダを含むアノテーションが追加されます。

    @UsesReflectionToConstruct(
        className = TODO(),
        parameterTypes = []
    )
    fun createInstance(className: String): Any =
        Class.forName(className).getDeclaredConstructor().newInstance()
    

    プレースホルダは、コードが作成するクラスに置き換えます。コンパイル時にクラスが利用可能な場合は、className ではなく classConstant を使用します。Kotlin では、TODO() を置き換えるまでコードはコンパイルされません。

  5. 既存のルールを削除する前に、プラグインがアノテーションから生成する保持ルールを確認してください。アプリ モジュールの場合は、リリース バリアントの抽出タスクを実行します。

    ./gradlew :app:releaseExtractKeepRules
    

    プラグインはルールを app/build/generated/release/androidx.annotation.keep.rules.pro に書き込みます。ライブラリ モジュールについては、生成されたルールを検証するをご覧ください。

  6. 新しいアノテーションに置き換えられる keep ルールを keep ルール ファイルから削除します。ルールには、別のモジュールやライブラリでのリフレクションなど、lint でフラグが設定されなかったリフレクションも含まれている可能性があるため、削除する前に各ルールを確認してください。

    • -keep class com.example.** { *; } などのパッケージ全体のルールの場合、アノテーションまたは他のルールがパッケージのクラスに対するすべてのリフレクションをカバーしていることを確認します。
    • 特定のクラスまたはメンバーを保持するルールについては、生成されたルールのいずれかと厳密に一致していること、または他のリフレクションがそれに依存していないことを確認してください。
  7. リリースビルドをテストします。

コードにアノテーションを付ける

リフレクションを実行するコード(リフレクション呼び出しを含む関数、コンストラクタ、フィールド、クラス)に Jetpack Keep アノテーションを追加します。アノテーションはリフレクションのターゲットを記述し、生成されたルールは、R8 がアノテーション付きコードを保持する場合にのみ、そのターゲットを保持します。

コードの動作に基づいてアノテーションを選択します。

Annotation コードが次の場合に使用します。
@UsesReflectionToConstruct リフレクションを介してコンストラクタを呼び出す(getDeclaredConstructor().newInstance() など)。
@UsesReflectionToAccessMethod リフレクション(getMethod や getDeclaredMethod など)を介してメソッドを呼び出します。
@UsesReflectionToAccessField リフレクション(getField や getDeclaredField など)を介してフィールドを読み書きする。
@UnconditionallyKeep アプリの Kotlin コードまたは Java コードの外部から呼び出される(JNI を介してネイティブ コードから呼び出されるなど)。このアノテーションは、条件付きルールを生成する @UsesReflection* アノテーションを使用できない場合にのみ使用します。

すべての @UsesReflection* アノテーションは、次の規則に従います。

  • classConstant または className でターゲット クラスを特定します。コンパイル時にクラスが利用可能な場合は、classConstant を使用します。コンパイラが参照をチェックし、名前変更などの IDE リファクタリングによって参照が更新されます。className は、オプションの依存関係のクラスなど、コンパイル時にクラスを使用できない場合にのみ使用します。これら 2 つのパラメータは相互に排他的です。
  • できるだけ詳細なクラスをターゲットにする。生成されたルールは、指定したクラスとそのサブクラスまたはインターフェース実装で一致するメンバーを保持します。クラスが一般的であるほど、R8 が保持するコードは多くなります。
  • パラメータの型を指定して、単一のオーバーロードを保持します。parameterTypes または parameterTypeNames を設定しない場合、ルールはメソッドのすべてのコンストラクタまたはすべてのオーバーロードを保持します。引数なしのコンストラクタまたはメソッドのみを保持するには、空の配列を渡します。
  • 複数のターゲットに対してアノテーションを繰り返します。1 つの関数が複数のクラスまたはメンバーに反映される場合は、ターゲットごとに 1 つのアノテーションを追加します。

リフレクションを使用してオブジェクトを構築する

コードがリフレクションを介してクラスをインスタンス化する場合は、@UsesReflectionToConstruct を使用します。次の例では、タスクランナーが StartupTask インターフェースの実装を名前でインスタンス化します。アノテーションでインターフェースを指定しているため、生成されたルールは、R8 が execute を保持している限り、すべての StartupTask 実装の引数なしコンストラクタを保持します。

interface StartupTask {
    fun run()
}

object TaskRunner {
    // The task class is known only at runtime, so target the interface that
    // every task implements.
    @UsesReflectionToConstruct(classConstant = StartupTask::class, parameterTypes = [])
    fun execute(className: String) {
        val task = Class.forName(className)
            .getDeclaredConstructor()
            .newInstance() as StartupTask
        task.run()
    }
}

リフレクションを使用してメソッドを呼び出す

コードがリフレクションを介してメソッドを呼び出す場合は、@UsesReflectionToAccessMethod を使用します。methodName は、メソッドの名前に設定します。次の例では、コードはオプションの依存関係で静的メソッドを呼び出します。このクラスはコンパイル時に使用できないため、アノテーションでは classConstant ではなく className を使用します。

object CrashReporting {
    @UsesReflectionToAccessMethod(
        className = "com.example.crashlib.CrashReporter",
        methodName = "log",
        // Omitting parameterTypes keeps every log overload. Keep only the one
        // that the code calls.
        parameterTypes = [String::class],
    )
    fun log(message: String) {
        val reporterClass = try {
            Class.forName("com.example.crashlib.CrashReporter")
        } catch (e: ClassNotFoundException) {
            // Crash reporting is optional, so skip it instead of failing.
            return
        }
        reporterClass.getMethod("log", String::class.java).invoke(null, message)
    }
}

リフレクションを使用してフィールドにアクセスする

コードがリフレクションを介してフィールドの読み取りまたは書き込みを行う場合は、@UsesReflectionToAccessField を使用します。fieldName をフィールドの名前に設定します。

class UserProfileFetcher {
    // User doesn't expose internalId, so reading it requires reflection.
    @UsesReflectionToAccessField(classConstant = User::class, fieldName = "internalId")
    fun readInternalId(user: User): Any? {
        val field = User::class.java.getDeclaredField("internalId")
        field.isAccessible = true
        return field.get(user)
    }
}

アプリの外部から呼び出されるコードを保持する

ネイティブ コードが JNI を介して呼び出すメソッドなど、アプリの Kotlin コードまたは Java コードの外部から呼び出されるコードには、@UnconditionallyKeep を使用します。呼び出される要素にアノテーションを付けます。呼び出すコードには付けません。

class NativeBridge {
    // Only native code calls this method, through a JNI lookup by name that
    // R8 can't see. Keep the method and its name unconditionally.
    @UnconditionallyKeep
    fun onNativeEvent(message: String) {
        Log.d("NativeBridge", "Received from native code: $message")
    }

    // The default proguard-android-optimize.txt file already keeps native
    // method names, so this method doesn't need an annotation.
    external fun startNativeProcess()
}

生成されたルールは、アノテーション付きメソッドとそのメソッドを含むクラスを元の名前で保持します。メソッドのシグネチャが参照する他のクラスは保持されません。ネイティブ コードがこれらのクラスに依存している場合は、それらのクラスにもアノテーションを付けるか、それらのクラスの keep ルールを追加します。JNI と R8 の詳細については、JNI を介したネイティブ コードとのやり取りをご覧ください。

デフォルトでは、@UnconditionallyKeep は要素の名前を保持します。外部呼び出し元が名前に依存しない場合は、R8 が要素の名前を変更できるように shouldPreserveName = false を設定します。

生成されたルールを確認する

このセクションでは、モジュールのタイプごとに生成されたルールを確認する方法について説明します。アプリ モジュールの場合、プラグインはルールを R8 に直接渡します。ライブラリ モジュールの場合、プラグインはルールをコンシューマーの keep ルールとしてライブラリの出力にパッケージ化します。ライブラリに依存するアプリは、Jetpack Keep を使用していなくても、R8 を実行するときにこれらのルールを自動的に適用します。コンシューマー保持ルールの詳細については、ライブラリ作成者向けの最適化をご覧ください。

Android アプリ

プラグインがアプリ モジュール用に生成する keep ルールを確認するには、ビルド バリアントの抽出タスクを実行します。

./gradlew :app:releaseExtractKeepRules

このプラグインは、各バリアントのルールを app/build/generated/<variant>/androidx.annotation.keep.rules.pro に書き込みます。各ルールの前にある # context: コメントは、そのルールを生成したアノテーション付きコードを示します。

生成されたルールが他の保持ルールと組み合わさって最適化にどのように影響するかを確認するには、R8 構成アナライザを使用します。

Android ライブラリ

Android ライブラリ モジュールでは、追加の設定は必要ありません。AAR をビルドすると、プラグインは生成されたルールを AAR の proguard.txt ファイルに追加します。これは、AAR にすでに含まれているコンシューマー保持ルールの後に追加されます。この処理を行うため、プラグインは各ビルド バリアントに <variant>KeepRulesTransformAar タスクを登録します。

ルールを検証するには、./gradlew :mylibrary:assembleRelease などを使用して AAR をビルドし、生成された AAR で proguard.txt を開きます。生成されたルールは、次のヘッダーを含むセクションに表示されます。

#################################################################
#   Rules generated from androidx.annotation.keep annotations   #
#################################################################

macOS と Linux では、コマンドラインからルールを出力することもできます。

unzip -p mylibrary/build/intermediates/aar/release/releaseKeepRulesTransformAar/mylibrary-release.aar proguard.txt

Kotlin Multiplatform ライブラリ

Android ターゲットを含む Kotlin マルチプラットフォーム ライブラリ モジュールでも、追加の設定は必要ありません。Android ターゲットの AAR をビルドすると、プラグインは Android ライブラリの場合と同じように、生成されたルールを AAR の proguard.txt ファイルに追加します。プラグインは、この処理を行う androidMainKeepRulesTransformAar タスクを登録します。

ルールを検証するには、そのタスクを実行し、作成された AAR で proguard.txt を開きます。macOS と Linux では、コマンドラインからルールを出力できます。

./gradlew :mylibrary:androidMainKeepRulesTransformAar
unzip -p mylibrary/build/intermediates/aar/androidMain/androidMainKeepRulesTransformAar/mylibrary.aar proguard.txt

JVM ライブラリ

JVM ライブラリ モジュールのルールを検証する前に、プラグインが生成されたルールを JAR のコピーに追加するようにモジュールを構成します。次に、登録した変換タスクを実行し、作成された JAR 内のルールファイルを確認します。たとえば、タスクに keepRulesJar という名前を付けた場合、macOS と Linux で次のコマンドを使用してルールを出力できます。

./gradlew :mylibrary:keepRulesJar
unzip -p mylibrary/build/intermediates/annotation-keep/keepRulesJar/mylibrary-keep-rules.jar \
    "META-INF/com.android.tools/*" "META-INF/proguard/*"

JVM ライブラリを構成する

JAR を生成する JVM ライブラリ モジュールの場合、プラグインは JAR を自動的に変更しません。代わりに、annotationKeep.registerJavaArchiveTransform 関数を使用して、生成されたルールを含む JAR のコピーを作成するタスクを登録し、その JAR を公開します。ライブラリ モジュールの build.gradle.kts ファイルに次のコードを追加します。

plugins {
    id("java-library")
    alias(libs.plugins.jetbrains.kotlin.jvm)
    alias(libs.plugins.androidx.annotation.keep)
}

val jarTask = tasks.named<Jar>("jar")

// Apps run R8 on this library's code, so the generated rules must be inside
// the JAR. The plugin handles this automatically only for Android modules.
val keepRulesJarTask = annotationKeep.registerJavaArchiveTransform(
    taskName = "keepRulesJar",
    input = jarTask.flatMap { it.archiveFile },
    fileName = jarTask.flatMap { it.archiveFileName }.map { it.replace(".jar", "-keep-rules.jar") },
)

// Consumers of this library resolve apiElements and runtimeElements, so add
// the JAR that contains the generated rules to them.
configurations.configureEach {
    if (name == "apiElements" || name == "runtimeElements") {
        outgoing.artifact(keepRulesJarTask.flatMap { it.outputJar })
    }
}

dependencies {
    implementation(libs.androidx.annotation.keep)
}

この変換により、生成されたルールが JAR のターゲット R8 ルールの場所 META-INF/com.android.tools/r8/proguard.txt に追加されます。JAR に META-INF/com.android.tools/ または META-INF/proguard/ のルールがすでに含まれている場合、変換では、生成されたルールが既存のルールファイルに追加されます。これらの場所の詳細については、さまざまな R8 バージョンをサポートするをご覧ください。

変換を登録したら、JAR のルールを確認します。

例: オプションのアドオン ライブラリを読み込む

ライブラリは、リフレクションを使用してオプションのアドオンを読み込むことがよくあります。たとえば、アナリティクス SDK は、コア ライブラリと動画アドオンを配布する場合があります。アドオンはコア ライブラリに依存しているため、コア ライブラリはアドオンのクラスを直接参照できません。代わりに、コア ライブラリは実行時にアドオンのクラスを名前で検索し、クラスが見つかった場合にのみ動画分析を有効にします。

コア ライブラリはリフレクションを介してのみアドオンのクラスを使用するため、R8 がアプリのリリースビルドからクラスまたはそのコンストラクタを削除し、ルックアップが失敗する可能性があります。このエラーは、アドオンを含むアプリのリリースビルドでのみ発生するため、テストで検出するのは困難です。

Jetpack Keep を使用しない場合、アドオンは、オプションの依存関係をサポートするリフレクションに示すように、そのクラスのコンシューマー保持ルールを配布します。

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

このルールは、このルールを必要とするルックアップとは異なるライブラリにあるため、2 つのルールが同期しなくなる可能性があります。Jetpack Keep を使用すると、代わりにコア ライブラリのルックアップにアノテーションを付けることができます。

// In the core library.
interface AnalyticsPlugin {
    fun start()
}

object AnalyticsManager {
    // The annotation and the lookup share this constant so that they can't
    // refer to different classes.
    private const val VIDEO_TRACKER_CLASS =
        "com.example.analytics.video.VideoEventTracker"

    @UsesReflectionToConstruct(
        className = VIDEO_TRACKER_CLASS,
        parameterTypes = [],
    )
    fun initialize() {
        val tracker = try {
            Class.forName(VIDEO_TRACKER_CLASS)
                .getDeclaredConstructor()
                .newInstance() as AnalyticsPlugin
        } catch (e: ClassNotFoundException) {
            // Apps that don't play video leave out the add-on, so a missing
            // class isn't an error.
            return
        }
        // This is an ordinary interface call, not a reflective one, so R8
        // keeps start() without an annotation.
        tracker.start()
    }
}

アノテーションは次のガイドラインに沿って行います。

  • リフレクションを実行するコードにアノテーションを付けます。アノテーションは、Class.forName を呼び出す initialize にあり、アドオンのクラスにはありません。
  • コードの機能に基づいてアノテーションを選択します。ルックアップによってクラスのインスタンスが作成されるため、コードでは @UsesReflectionToConstruct が使用されます。
  • 参照できないクラスには className を使用します。アドオンのクラスはコアライブラリのコンパイル クラスパスにないため、アノテーションは classConstant を使用できません。
  • コードが呼び出すコンストラクタのみを保持します。ルックアップは引数なしのコンストラクタを呼び出すため、parameterTypes は空の配列になります。

コア ライブラリの AAR をビルドすると、R8 が initialize を保持するたびに VideoEventTracker とその引数なしのコンストラクタを保持するルールがプラグインによって追加されます。SDK を使用するアプリは、Jetpack Keep を使用していなくても、このルールを自動的に適用するため、アドオンに独自のルールは必要ありません。アドオンのルールと比較すると、このアプローチには次の利点があります。

  • ルールはルックアップの横に表示されます。コア ライブラリがルックアップとルールの両方を所有しているため、2 つのライブラリ間で変更を調整するのではなく、両方を一緒に更新します。
  • R8 は未使用のコードを削除できます。アプリにアドオンが含まれているものの、initialize を呼び出さない場合、生成されたルールは適用されず、R8 は VideoEventTracker を削除できます。アドオンのルールにより、アドオンを含むすべてのアプリにアドオンが保持されます。
  • Lint は、アノテーションのないルックアップを検出します。コアライブラリに、アノテーションのない別のリフレクション ルックアップが後で追加された場合、lint がフラグを付けるため、ライブラリをリリースする前に修正できます。

結果を確認するには、Android ライブラリの説明に沿って、生成されたルールが AAR の proguard.txt ファイルに表示されていることを確認します。次に、アドオンを含むアプリのリリースビルドをテストします。ルールが不足していると、動画分析が中断されるためです。

制限事項

Jetpack Keep は、アノテーションによってクラスとメンバーを検出するリフレクションを検出したり、そのキープルールを生成したりすることはまだできません。この機能は計画中です。

それまでは、この種のリフレクションには keep ルールを使用してください。たとえば、次のルールは、R8 が保持するクラスで @SomeAnnotation アノテーションが付けられたすべてのメンバーを保持します。

-keepclassmembers class * {
    @com.example.annotation.SomeAnnotation <methods>;
    @com.example.annotation.SomeAnnotation <fields>;
}

Jetpack Keep がユースケースで機能しない場合や、その他のフィードバックがある場合は、問題を報告してください。

参考情報