ツールとパフォーマンス

型安全な保持ルールと DEX の最適化

スピーカー

概要

講演について

Android アプリは、サードパーティ ライブラリの過度に保守的な保持ルールが原因で、最適化が不十分な状態でリリースされることがよくあります。これにより、APK の肥大化、コールド スタートの遅延、ランタイム パフォーマンスの低下につながります。

Google Play の DEX 最適化に関する新しいパフォーマンスしきい値により、アプリの表示は最適化の進捗状況に直接左右されるようになりました。

この講演では、ルールの肥大化に対処するための最新の R8 ワークフローを紹介します。学習内容は次のとおりです。

  • R8 構成アナライザとスキルを使用して、以前のルールのオーバーヘッドを特定します。
  • 広範なワイルドカードの keep ルールから、正確でタイプセーフな生成されたアノテーションに移行します。
  • 新しい Android CLI ツールで最適化の回帰を防ぐ。

概要

  • Google Play では、25% 以上のコード最適化を必要とするアプリ品質のしきい値が適用されます。これらの要件を満たすことで、メモリ使用量が削減され、起動速度と ANR 発生率が改善されます。
  • R8 Configuration Analyzer は、どの keep ルールがコードを保持しているかを示す HTML レポートを生成します。包含されたルールと未使用のルールをハイライト表示し、コンパイル前に最適化の可能性を測定します。
  • リフレクション クラッシュを防ぐために導入されたり、ライブラリから継承されたりすることが多い、過度に広範な keep ルールは、アプリの最適化を損ないます。パッケージ全体のワイルドカード ルールが 1 つあるだけで、未使用のクラスが数十個保持される可能性があります。
  • Jetpack Keep は、lint を使用してリフレクションを検出し、コードから直接、正確なアノテーションと条件付き保持ルールを生成します。古い R8 バージョンと互換性のある標準のコンシューマー ルールをエクスポートします。
  • Android CLI は、Google Play の正確なロジックを使用して、アップロードせずに App Bundle と AAR を検査します。問題のあるルールにフラグを付け、自動回帰テスト用の JSON レポートをエクスポートします。

リソース

Gemini Notebook

トピック

  • dex-optimization
  • r8
  • keep-rules
  • jetpack-keep
  • android-cli
  • app-performance

文字起こし

DEX の最適化の概要

  • 皆さん、こんにちは。

  • Android DevRel チームの Breana です。

  • Android Performance チームの Chris Craik です。

  • タイプセーフな保持ルールと DEX の最適化へようこそ。

  • 本日は、DEX の最適化とは何か、なぜ重要なのか、そして最近の興味深いアップデートについてお話しします。

  • 長い間、DEX の最適化は、あれば便利という程度のものとして扱われてきました。

  • そのため、一度設定したらそのままにしておくか、専用の時間を確保して設定することになるかもしれません。

  • しかし、モバイル業界はデバイスのメモリ可用性に影響を与える重大なハードウェア供給制約に直面しており、アプリのメモリ使用量の削減、起動の高速化、ANR の削減、レンダリングとランタイムのパフォーマンスの全体的な向上を実現するには、適切に最適化された DEX が不可欠です。

  • 健全な Android エコシステムを維持し、アプリがプレミアムなエクスペリエンスを提供し続けられるように、Google Play は、2 月に施行されるパフォーマンスしきい値を含む、新しいアプリ品質要件を発表しました。

  • これには、DEX コードの最適化のしきい値が含まれます。

  • アプリは、圧縮、最適化、難読化を 25% 以上行う必要があります。

  • では、Android デベロッパーにとって、これはどのような意味があるのでしょうか。

  • 最適化されていない DEX コードは、ユーザー エクスペリエンスに悪影響を及ぼすだけでなく、Google Play ストアでのアプリの表示や公開機能にも影響する可能性があります。

  • しかし、このセッションを最後まで聞いていただければ、これらの要件を満たすだけでなく、それを超えるためのツールを手に入れることができます。

  • DEX コードの最適化は、Android のアプリ最適化ツールである R8 で実現できます。

  • 別のアプリ オプティマイザーでアプリをこれらのしきい値以上にできる場合、R8 は厳密には必須ではありませんが、Android のツールチェーンに直接組み込まれている推奨パスです。

  • 適切に最適化された DEX と App Bundle はメモリ使用量を削減しますが、主なメリットはパフォーマンスに関連するもので、ANR の削減、起動の高速化、ランタイム パフォーマンスの向上です。

  • アプリを最適化すると、アプリとユーザーに実際にプラスの効果がもたらされることが実証されています。

  • たとえば、Grab は R8 フルモードを採用してキープルールを改良することで、アプリの最適化を 78%、圧縮を 79%、難読化を 82% 向上させることができました。

  • このスライドを見て、「数字はかなり高いが、実際にはどういうことなのか」と思われるかもしれません。

  • これらの改善とベースライン プロファイルの組み合わせにより、ANR が 32% 減少し、起動時間が 35% 改善しました。

  • 上位のアプリが R8 を初めて導入したとき、DEX サイズが平均 38% 減少し、ユーザーが認識した ANR が 16% 減少しました。

  • すでに R8 を使用している場合は、

  • すでに R8 を有効にしているアプリで、最適化、圧縮、難読化のレベルを 50% 以上に引き上げる手順を実施すると、平均して、DEX サイズがさらに 25% 削減され、起動時間が 4% 短縮され、ユーザーが認識した ANR がさらに 17% 削減されます。これは、前のスライドで示した 16% に上乗せされるものです。

  • そこで本日は、このような数字を達成する方法についてご説明します。

  • DEX コードの最適化に役立つツールと、過度に広範で不適切に構成された keep ルールから、具体的で狭いルールに移行する方法について説明します。

  • まず、R8 は何年も前から存在していることをお伝えしておきます。

  • また、この機能によって効果の高い最適化が可能になった一方で、構成には依然として細心の注意が必要であることも認識しています。

  • リフレクション呼び出しは、入力が予測不能であることを意味し、実行時のクラッシュにつながる可能性があります。

  • このため、過去にはアプリチームとライブラリ チームの両方が、安全を期すために過度に広範な keep ルールを追加する傾向がありました。

  • 上位アプリの 41% は、Google Play が定義する 25% の最適化レベルを下回っていますが、上位アプリの 13% はすでにその基準値の 3 倍を達成しています。

  • この数字を達成しているのは、さまざまな業界やユーザー ジャーニーのアプリです。

  • また、独自の保持ルールとライブラリから継承された保持ルールの両方を管理するための適切なツールと適切な可視性があれば、アプリの最適化スコアを高くすることができます。

  • この最適化を支援する新しいツールと改善点について説明する前に、この最適化を少し容易にする、過去 1 年間の R8 のその他の改善点についてもいくつか紹介します。

  • まず、新しいアプリ テンプレートにはパッケージ スコープが含まれています。これは、最初から最低限の最適化を確保するのに役立ちます。

  • R8 Configuration Analyzer(これについては後ほど説明します)が、古いバージョンの AGP でサポートされるようになりました。

  • また、Bazel でも非公式にサポートされています。

  • AGP 9.2 では、コルーチン プリミティブが 2 倍高速になりました。

  • AGP 9.2 で行われた R8 の改善の結果、Tinder はバージョン 9.0.1 からアップグレードした後、ANR が 13% 減少しました。

  • また、AGP 9.3 では、R8 API が再設計され、命名がより明確になり、デフォルト ルールとリソース圧縮のデフォルトがよりシンプルになりました。さらに、キープルールの AGP ソース ディレクトリがサポートされるようになりました。

    android {
        buildTypes {
            release {
                optimization {
                    // no need to specify default
                    // rules and resource shrinking
                    enabled = true
                }
            }
        }
        ...
    }
    android {
        buildTypes {
            release {
                // Enables code optimizations.
                isMinifyEnabled = true
    
                // Enables resource shrinking.
                isShrinkResources = true
    
                proguardFiles(
                    // Default rules.
                    getDefaultProguardFile(
                        "proguard-android-optimize.txt"
                    ),
                    ... // list out each file with keep rules
                )
                ...
            }
        }
        ...
    }
  • 本日ご紹介するアップデートも、デベロッパー エクスペリエンスの向上を目的としたものです。

  • まず、R8 構成アナライザと R8 スキルを活用して、以前のルールのオーバーヘッドを特定し、改善への道筋を決定する方法から説明します。

  • 次に、Chris が、ワイルドカードを使用した広範な keep ルールから、新しい正確で型安全な自動生成アノテーションに移行する方法について説明します。

  • 最後に、新しい CLI 検証ツールを使用して、クラッシュを発生させずに積極的な最適化を行う方法について説明します。

R8 構成アナライザの概要

  • R8 構成アナライザは、DEX の最適化を調査するのに最適なツールです。

  • ツールをまだ使用していない場合は、次のように表示されます。

  • これは、R8 構成に関する情報を可視化する HTML レポートです。

  • このレポートは、Analytics Bloat というライブラリを含むアプリに関するものです。

  • ご想像のとおり、これは実際のアプリやライブラリではありませんが、上部に青色でハイライト表示されています。

  • Config Analyzer は、次の項目を測定します。縮小: R8 が keep ルールによって縮小を妨げられないクラス、メソッド、フィールドの割合。

  • これは、R8 が最初の圧縮パスを実行した後に計算されます。

  • また、最適化も測定します。これは、メソッドのインライン化やクラスの統合など、プログラム全体の最適化の対象となるコードの割合です。

  • 難読化。これは、難読化または短い識別子に変更できるメンバーの割合です。

  • また、R8 Config Analyzer は最終的な最適化と DEX コンパイルの前にこれらのスコアを計算するため、このレポートに表示される最適化、圧縮、難読化の値は、Google Play Console に表示される値と正確には一致しません。

  • これは、Google Play が最終的な DEX 出力を測定するためです。

  • この不一致の理由は、開発時に高速なローカル フィードバック ループにアクセスできるようにするためです。

  • ただし、R8 構成アナライザに表示されるスコアは、Google Play ストアにアップロードする前に DEX の最適化の可能性を検証するうえで非常に役立ちます。

  • このレポートには、レポートを開いたときに表示されるメインビューである、保持ルール分析テーブルも含まれています。

  • そこから、任意のルールをクリックすると、特定のルールが保持しているクラス、メソッド、フィールドの正確な数を確認できます。これにより、どのルールが保持している数が多すぎるかがわかります。

  • また、包含ルールもあります。ここでは、包含ルールを拡大して表示しています。

  • これらは、より広範なルールと重複するルールです。

  • これらはハイライト表示されるため、広範なキーワードを削除して、より絞り込まれたキーワードのみを保持できます。

  • また、未使用のルールも表示されます。これは、アプリの安定性にリスクをもたらすことなく削除できるルールです。

  • そこで、Config Analyzer は、レガシールールの蓄積から始まるいくつかの問題に対処するように設計されました。

  • 以前は、ルールごとの可視性がなかったため、古いルールを削除するのはリスクがありました。

  • そのため、チームはそれらを放置するか、より広範なワイルドカードを追加するだけでした。

  • 2 つ目は、ライブラリ ルールの盲点を見つけるのに役立つことです。

  • そのため、従来は SDK を追加する際に、そのライブラリにバンドルされているルールが過度に広範なものかどうか、あるいは 10 バージョンほど後でも必要かどうかを判断するのは困難でした。

  • ライブラリについては、後ほど詳しく説明します。

  • 3 つ目は、最適化の効果を評価できることです。

  • これまでは、ルールが 2 つのクラスを保持しているのか、2,000 のクラスを保持しているのかを簡単に確認する方法がありませんでした。

  • R8 がアプリと先ほど確認したライブラリのコンテキストでどのように機能するかを見てみましょう。

  • この例では、アプリ モジュールと 3 つのライブラリがあります。

  • 右側のライブラリ 3 に注目してください。

  • これは Analytics Bloat ライブラリで、パッケージ全体の保持ルールが含まれています。

  • まず、R8 は、統合されたすべての keep ルールからルート エントリ ポイントを特定します。ここでは黄色でハイライト表示されています。

  • 通常、これらはマニフェスト内のクラスなど、アプリのメインのエントリ ポイントです。

  • また、Analytics Bloat ライブラリのワイルドカード保持ルールにより、ライブラリ内のすべてのクラスがエントリ ポイントとして扱われます。

  • 次に、R8 は、それらのルートからコールグラフ全体の到達可能性をトレースします。

  • ルートから到達可能なクラスは、保持対象として緑色でマークされます。一方、参照されていないクラスはデッドコードとして分類され、赤色でマークされます。

  • その後、R8 はツリー シェイキングを実行し、参照されていないデッドコードを削除します。

  • このツリー シェイキング(最初の圧縮パス)の後、残ったのはこれだけです。

  • アプリの縮小スコアは 42.5% で、Google Play のしきい値である 25% を上回っていますが、まだ望ましいレベルには達していません。

  • これは主に、アナリティクス ライブラリのパッケージ全体の keep ルールが原因です。

  • そのため、残りの 40 クラスのうち 17 クラスしかさらに縮小できません。

  • ここで強調したいのは、不適切な keep ルールが 1 つあるだけで、アプリの最適化に大きな問題が生じる可能性があるということです。

  • これを念頭に置いて、ライブラリについて簡単に説明します。

  • この 1 年間、Google のチームは、ライブラリ コンシューマーの保持ルールがエコシステムの最適化にどのように影響しているかを理解することに多くのリソースを投資してきました。

  • 多くのライブラリは、安定性の名の下に、過度に広範なルールを適用してリリースされます。

  • また、アプリ デベロッパーがアプリに自分で追加できるように、ドキュメントに大まかなルールを記載している場合もあります。

  • しかし、安定性のために広範なルールを適用する必要はなく、パフォーマンスに悪影響を及ぼします。そのコストは、主にアプリ デベロッパーが負担することになります。

  • Analytics Bloat ライブラリのように、パッケージ全体の保持ルールによってライブラリ全体が保持されるケースが見られます。このライブラリの場合、アプリの最適化の可能性に大きな影響を及ぼしていました。

  • Google 独自の SDK も例外ではなく、過去 1 年間に 12 個以上の Google ライブラリで、過度に広範な保持ルールが修正されました。

  • 構成アナライザは AGP 9.3 に直接統合されていますが、R8 のバージョンをアップグレードすれば、古いバージョンでも使用できます。

  • このコマンドを実行すると、先ほどお見せした HTML レポートが生成されます。このレポートには、キープルール、個々のルールとアプリ全体に対する圧縮、最適化、難読化の影響が表示されます。

  • 完全な R8 リリースビルドを実行している場合、レポートは自動的に生成され、このファイルパスに保存されます。

  • このスタンドアロン タスクのメリットは、フルビルドよりも生成にかかる時間が短いことです。

ケーススタディ: WHOOP の最適化

  • WHOOP のチーム(この会場のどこかにいるかもしれません)が構成アナライザを実行したところ、圧縮が 74%、難読化が 74%、最適化が 0% という結果になりました。

  • ご想像のとおり、チームはこの最適化値を見て本当に驚きました。

  • 設定アナライザの可視性により、ビルドで最適化を無効にする古い ProGuard ファイルが使用されていることが判明し、更新されたバージョンに切り替えることで、アプリの最適化を有効にすることができました。

  • これにより、55% の最適化と 4% の APK サイズ削減が実現しました。

  • ベースライン プロファイルと組み合わせることで、コールド スタート時間が 38% 改善され、過剰なフリーズ フレームが 28% 減少しました。

  • 左の画面はベースライン プロファイルなし、右の画面はベースライン プロファイルありです。ご覧のとおり、ベースライン プロファイルを追加するとアプリの起動が速くなります。

  • もう一度再生してみましょう。

  • いいですね

  • このリンクから構成アナライザのガイドにアクセスできます。レポートを手動で確認するのは良いことですが、ご想像のとおり、アプリの R8 構成の状態によっては、多くの時間がかかる可能性があります。

  • そこで、この作業をスピードアップするために、R8 Analyzer スキルを用意しています。

    $ android skills list
    $ android skills add r8-analyzer
  • エージェントに R8 構成の分析を依頼すると、このスキルは R8 構成アナライザ レポートを生成するだけでなく、最も効果的なルールを提示します。

  • 出力例をいくつかご紹介します。最適化スコアは 42% です。つまり、コードベースの 52%(58%)は R8 で最適化できません。

  • keep ルールのセクションを詳しく見ると、分析用の肥大化したライブラリに対するパッケージ全体の keep ルールによって、コードベースの 22% が最適化されないことがわかります。また、この keep ルールは、すでにわかっているように範囲が広すぎるため、改良することが推奨されています。

  • これで、R8 構成アナライザーとスキルを使用して、アプリの肥大化の原因と DEX の最適化を妨げている要因を診断できるようになりました。

DEX の最適化におけるリフレクションの課題

  • しかし、過度に広範な keep ルールを見つけた場合、予期しないクラッシュを誤って引き起こすことなく、安全に置き換えるにはどうすればよいでしょうか。

  • ここからは Chris にバトンタッチして、以前の keep ルールから、新しい型安全性のある自動生成された keep アノテーションに移行する方法を説明してもらいます。

  • Breana さん、ありがとうございました。

  • では、ここに至るまでの経緯についてお話ししましょう。

  • この肥大化した分析ライブラリと、基本的にすべてを保持するこの keep ルールで、私たちが直面している根本的な問題はリフレクションです。

  • では、リフレクション コードを少し見てみましょう。

    fun createWithReflection(className: String): MyObject {
       return Class.forName(className)
           .getDeclaredConstructor()
           .newInstance() as MyObject
    }
  • これは、特定のオブジェクトをリフレクションによってインフレートするだけのオブジェクト インスタンス化関数です。

  • DEX の最適化という観点から見ると、この方法のデメリットは、すべての静的呼び出しが実行時まで遅延されることです。

  • このクラス名の取得元によっては、コードを見るだけでは、R8 オプティマイザーはここで何が起きているかを把握できません。

  • これらは、デフォルトで Dex の最適化を中断する状況であるため、通常は Keep ルールを追加する必要があります。

  • 理想的には、これを回避することです。

  • 理想的には、リフレクションを完全に回避することです。特に 2026 年の現在では、シリアル化や依存関係注入のための優れたコード生成ツールがあり、この種の問題を完全に排除できます。

  • でも、それだけではないですよね?

  • 場合によっては、リフレクションがまだ必要です。

  • では、この肥大化したライブラリで実際に何が起こったのかを詳しく見ていきましょう。

  • 一般的な例として、オプションの依存関係があります。

    // Problem 1 - optional dependency
    fun tryInitCrashReporter(context: Context): EventTransport? {
       return runCatching {
           Class.forName("com.example.analytics.bloat.crash.CrashReporterTransport")
               .getDeclaredConstructor(Context::class.java)
               .newInstance(context) as? EventTransport
       }.getOrNull()
    }
    
    // Problem 2 - dynamically instantiate subclass
    fun createEventFilter(className: String): EventFilter {
       return Class.forName(className)
           .getDeclaredConstructor()
           .newInstance() as? EventFilter
    }
    
    // Problem 3 - serialization
    fun serializeEvent(event: UserActionEvent): JSONObject {
       val json = JSONObject()
       for (field in event.javaClass.declaredFields) {
           field.isAccessible = true
           json.put(field.name, field.get(event))
       }
       return json
    }
  • ここでは、ライブラリのクラスパスでアクセスできない特定の型をインフレートしているため、文字列を使用して行う必要があります。

  • このライブラリは、ライブラリのビルド時に存在する可能性がある特定のクラスを参照しているため、リフレクションを使用して関連ライブラリ内のメンバーを検索しています。

  • もう 1 つのケースは、サブクラスを動的にインスタンス化することです。

  • 特に、マニフェスト ファイルから取得されるものや、サーバーサイドから送信されるものについては、ローカルアプリに「このオブジェクトをインフレートして、後で作成してください」と通知します。

  • そして、3 つ目のカテゴリは、恥ずかしながらシリアル化です。

  • つまり、すべてのメンバーを反復処理することで JSON シリアル化が行われます。

  • これは本当に不快なことですが、このようなことが起こることもあります。

  • 必要なときに必ず修正されるとは限りません。

  • では、この結果がどうなるかについてお話ししましょう。

  • このようなリフレクション呼び出しが積み重なると、簡単な方法を選択して、ユーザー エクスペリエンスよりもデベロッパー エクスペリエンスを優先するようになります。

  • これはあまり良くないので、ToDo を追加します。これで大丈夫です。

    # TODO: clean this up
    -keep com.example.analytics.bloat.** { * };
  • いつか、いつか、私が、私が、戻ってきて、直す、でしょ?

  • では、本来行うべきこと、少なくとも今日より前に行うべきだったこと、つまり、ここで理想的な保持ルールを作成する方法について説明します。

    fun createEventFilter(className: String): EventFilter {
       return Class.forName(className)
           .getDeclaredConstructor()
           .newInstance() as? EventFilter
    }
  • では、始めましょう。

  • 最初の部分は、このリフレクション関数から返される特定のクラスを保持することです。

    ### Keep the EventFilter
    -keep class com.example.analytics.bloat.EventFilter {
        *
    }
  • いや、待って。

  • あのさ

  • これは正しくありません。

  • これは Kotlin クラスです。文字列型であることを確認する必要があります。KT を追加しましょう。

    ### Keep the actual EventFilter
    -keep class com.example.analytics.bloat.EventFilterKt {
        *
    }
  • 実際、ここで重要なのは、EventFilter を返すのではなく、EventFilter のサブクラスを返すということです。そのため、これを正しくインフレートできるように、EventFilter を拡張するクラスを保持する必要があります。

    ### Keep the EventFilter subclasses
    -keep class ** extends com.example.analytics.bloat.EventFilterKt {
        *
    }
  • この方法でも問題ありませんが、さらに効率を高めるには、優れたライブラリ デベロッパーとして、さらに何をする必要があるでしょうか。

  • -keepclasseswithmembers を指定して、オブジェクトのインフレートに使用している特定の <init> コンストラクタを持つクラスのみを保持する必要があります。

    ### Keep the EventFilter subclasses, if they have this constructor
    -keepclasseswithmembers class ** extends com.example.analytics.bloat.EventFilterKt {
       void <init>();
    }
  • さらに、allowaccessmodification を追加して、この Keep ルールの対象となるすべての API でさらに最適化が行われるようにします。R8 による最適化を妨げないようにするためです。

    ### Keep the EventFilter subclasses, if they have this constructor, allow more optimization
    -keepclasseswithmembers,allowaccessmodification class ** extends com.example.analytics.bloat.EventFilterKt {
       void <init>();
    }
  • 最後のステップは、追加の作業が必要になるため、ほとんどの人が行っていませんが、ライブラリにとって非常に重要な、条件付きにすることです。

    ### -if makes this conditional, critical for libraries
    -if class com.example.analytics.bloat.EventToolsKt {
        class com.example.analytics.bloat.EventFilterKt createEventFilter(java.lang.String);
    }
    ### Keep the EventFilter subclasses, if they have this constructor, allow more optimization
    -keepclasseswithmembers,allowaccessmodification class ** extends com.example.analytics.bloat.EventFilterKt {
        void <init>();
    }
  • 概念的には、この関数を実際に呼び出す場合を除き、これらのサブクラスをすべて保持する必要はありません。これは、Keep ルールの先頭にある if ステートメントが行うことです。

  • 挙手をお願いしても、ほとんど誰も手を挙げないでしょう。

  • 実際に記述して、条件付きにするなどです。

  • では、これを少し改善できるとしたらどうでしょうか?

  • まず、

  • 別の方法があるかもしれません。

  • そこで、手動で作成した Keep ルールを狭く設定した場合について説明しました。

  • 技術的には可能ですが、記述や読み取りが難しく、メンテナンスも非常に困難です。なぜなら、内部で何かが発生したときに簡単に壊れてしまうような、文字列型の小さなビットが多数存在するからです。

  • その下にあるものが変化します。

  • 代替手段もあります。

  • @Keep などのアノテーションがあります。

  • @Keep は AndroidX アノテーション パッケージに長い間含まれていましたが、最近はあまり推奨されていません。

    @androidx.annotation.Keep
    class EventFilter { … }
    
    @androidx.annotation.Keep
    class SizeBasedEventFilter { … }
    
    @androidx.annotation.Keep
    class DropLogsEventFilter { … }
  • これはライブラリ デベロッパーにとって優れたソリューションではなく、実際、内部的には禁止されています。メンテナンスは簡単ですが、柔軟性に欠け、前述の最適化の一部が機能しないためです。

  • それはできません。

  • コールサイトの分析はできません。

  • 先ほどの例に戻りましょう。「EventFilter に @Keep を追加すればいい」と思うかもしれません。

    @Keep
    class EventFilter { … }
  • いいえ

  • @Keep は実際にはサブクラスをターゲットにせず、そのクラスのみをターゲットにします。

    @Keep
    class EventFilter { … }
    
    @Keep
    class SizeBasedEventFilter { … }
    
    @Keep
    class DropLogsEventFilter { … }
  • 実際には、すべてのサブクラスをターゲットにする必要があります。そのため、記述が難しい 1 つの Keep ルールを使用する場合よりも、多くの @Keep アノテーションを記述することになります。

  • どちらにも問題があります。

  • 忘れやすく、検出が遅れ、デフォルトでは呼び出し元に条件付けされません。

  • 特にライブラリの場合は、より良い方法が必要です。

  • すぐに使えるようにするための、より簡単な方法が必要だと考えています。

  • ここで見られる大きな問題の 1 つは、これらのものが、実際に保護している反射から遠く離れた場所に存在していることです。

  • 最終的に、私たちが求めているソリューションは、ターゲットとなるものを実際に膨らませている、ここにあるいたずらなリフレクション コードに直接対抗するものです。

Jetpack Keep の概要

  • そこで、この問題を解決するために設計された AndroidX の新しいライブラリとプラグインである Jetpack Keep について説明します。

  • Jetpack Keep の最終的な目標は、まずアノテーションから keep ルールを生成し、さらに、コードベースにすでに存在するリフレクションからアノテーションを直接生成することです。

  • セットアップは非常に簡単です。

    plugins {
       id("com.android.library")
       id("androidx.annotation.keep")
    }
    
    dependencies {
        implementation("androidx.annotation.keep:keep")
        ...
    }
  • Gradle プラグインであり、アノテーション ライブラリでもあります。

  • では、ビルド時に実際にどのように実行されるかを見てみましょう。

  • たとえば、先ほどの例のように、既知の型のサブクラスをインフレートする場合は、どうすればよいでしょうか。

  • lintFix を実行するだけです。

    ./gradlew analyticsbloat:lintFix
  • Lint は、このメソッド内で発生しているすべてのリフレクションを分析し、このアノテーションが必要であることを示します。

    @UsesReflectionToConstruct(
       classConstant = EventFilter::class,
       parameterTypes = []
    )
    fun createEventFilter(className: String): EventFilter {
       return Class.forName(className)
           .getDeclaredConstructor()
           .newInstance() as? EventFilter
    }
  • このアノテーションは、この呼び出し内のリフレクションが何を行っているかを正確に表しています。

  • これは、「このタイプのクラスをインフレートしている」ことを示します。

  • この場合、EventFilter は既知のクラスです。

  • 文字列を使用して参照する必要がないため、文字列は使用していません。

  • これは追加の安全性であり、parameterTypes をエンコードして、どのコンストラクタが保持されているかを正確に示しています。

  • では、これは何をするのでしょうか?

  • たとえば、これが最終的な R8 ビルドにどのように影響するのか?

  • このツールは keep ルールを生成します。ご覧のとおり、これは先ほどと同じ理想的な keep ルールです。

    # Generated Keep Rule
    
    -if class com.example.analytics.bloat.EventTools {
        class com.example.analytics.bloat.EventFilter createEventFilter(java.lang.String);
    }
    -keepclasseswithmembers,allowaccessmodification class ** extends class com.example.analytics.bloat.EventFilter {
        void <init>();
    }
  • まったく同じクラス、サブクラスをターゲットにしており、このリフレクション ポイントを条件としています。

  • また、すべてのサブクラスにアノテーションを付ける必要もありません。

  • 別の場所に保持ルールファイルを別途保存する必要はありません。

  • これは、保持ルールを管理するはるかに簡単な方法です。

  • オプションの依存関係の例をもう一度見てみましょう。

    @UsesReflectionToConstruct(
       className = "com.example.analytics.bloat.crash.CrashReporterTransport",
       parameterTypes = [Context::class]
    )
    fun tryInitCrashReporter(context: Context): EventTransport? {
       return runCatching {
           Class.forName("com.example.analytics.bloat.crash.CrashReporterTransport")
               .getDeclaredConstructor(Context::class.java)
               .newInstance(context) as? EventTransport
       }.getOrNull()
    }
  • このクラスはクラスパスにありませんが、参照できます。

  • 文字列を使用しているのはそのためです。それ以外のパラメータ タイプはクラス参照で参照できるため、そうしています。

  • この場合も、理想的な keep ルールが生成されます。

    # Generated Keep Rule
    
    -if class com.example.analytics.bloat.CrashConnection {
        class com.example.analytics.bloat.EventTransport tryInitCrashReporter(android.content.Context);
    }
    -keepclasseswithmembers,allowaccessmodification class com.example.analytics.bloat.crash.CrashReporterTransport {
        void <init>(android.content.Context);
    }
  • これは条件付きで、具体的で、ターゲットを絞ったものであり、ターゲットとなるクラスで可能な限り最適化を可能にします。

  • これはフィールドのリフレクションです。この例では、オブジェクト内のすべてのフィールドを反復処理していますが、ここでも、このスター演算子ですべてのフィールドをターゲットにすることで、問題なく処理できます。

    // TODO: migrate to Kotlinx Serialization or Moshi codegen.
    @UsesReflectionToAccessField(
       classConstant = UserActionEvent::class,
       fieldName = "*"
    )
    fun serializeEvent(event: UserActionEvent): JSONObject {
       val json = JSONObject()
       for (field in event.javaClass.declaredFields) {
           field.isAccessible = true
           json.put(field.name, field.get(event))
       }
       return json
    }
  • これにより、すべてのフィールドをターゲットに設定できます。ご覧のとおり、この非常に残念なリフレクション(アプリに含めるべきではないものですが、後で戻ってクリーンアップすることを完全に約束します)に対して、期待どおりの keep ルールが生成されます。

    # Generated Keep Rule
    
    -if class com.example.analytics.bloat.serialization.EventSerializer {
        class org.json.JSONObject serializeEvent(com.example.analytics.bloat.model.UserActionEvent);
    }
    -keepclasseswithmembers,allowaccessmodification class com.example.analytics.bloat.model.UserActionEvent {
        <fields>;
    }
  • では、Jetpack Keep は何をするのでしょうか。

  • ルールを維持するためのメンテナンスを大幅に削減できます。

  • 自動生成できるものはすべて、保持ルールの維持に関する懸念事項やリスク領域から除外されます。

  • また、lint の決定論も得られます。

  • これは、反復処理や移行の試行中に問題を修正するだけでなく、コードが変更されるたびに新しいリフレクションをキャッチし、リフレクションの変更をキャッチします。この安全性は非常に重要です。

  • この機能は使用できますか?

  • では、ここでの依存関係は何でしょうか。

  • プラグインと lint には AGP 8.7 が必要です。

  • ほぼ全員が該当するはずです。

  • また、使用できる場所についても非常に柔軟です。

  • これはアプリ内で使用できます。

  • Android ライブラリ、AAR、KMP ライブラリ、JAR の内部で使用できます。その場合は、手動で少し接続する必要があります。

  • しかし、ここで重要なのは、ライブラリ デベロッパーとして安全に使用できるということです。

  • 誰かが古いバージョンの R8 や、ライブラリを使用するサードパーティ ツールを使用している場合でも、実際の標準のコンシューマー ルールをエクスポートするため、誰でもライブラリを使用でき、互換性のない新しい形式を使用する心配はありません。

Android CLI を使用したアプリの分析

  • ここまで、Jetpack Keep が最適化にどのように役立つか、リフレクション コード呼び出しの保護について心配する必要がなくなることについて説明してきました。ここからは、最後のリリース ステージでダブルチェックを行うために、最近 Android CLI でリリースされた新機能について説明します。

  • 先ほど Breana が、Play の調査結果でサイズ情報、DEX サイズ、最適化率に関する情報がわかるツールがあると言っていました。

  • 現在、Android CLI でも利用できるようになっています。

  • Google Play ストアとまったく同じロジックを使用するため、Google Play ストアに App Bundle をアップロードしなくても、CLI で App Bundle を分析できます。

    $ android analyze <mybundle.aab>
    $ android analyze <mybundle.apk>
  • また、ローカル分析では、まったく同じ情報が直接かつ即座に報告されます。

  • この例では、アプリの最適化率が非常に低いという警告が表示されています。また、Google Play ストアと同様に、DEX サイズと最適化率も表示されています。

  • また、JSON 出力も用意されています。これらの値のいずれかが大幅に低下した場合に回帰テストを自動化するスクリプトを作成したい場合、これらは Google Play が適用する数値であるため、追跡する必要があります。この JSON 値、つまり JSON レポートを使用すると、非常に簡単に追跡できます。

    {
      "optimization": {
        "bundleAnalysisResult": {
          "agpVersion": "9.3.2",
          "r8Json": {
            "version": "9.3.16",
            "options": {
              "isRepackageClassesEnabled": true,
              "isProguardCompatibilityModeEnabled": false
            },
            "stats": {
              "shrinkingRatio": 0.2306,
              "optimizationRatio": 0.2351,
              "obfuscationRatio": 0.2322
            },
            ...
          }
        "mappingObfuscationRatio": 0.21631891,
        "dexHeuristicObfuscationRatio": 0.25118626,
        "uncompressedDexSizeBytes": "16198152"
      }
  • ここでも、まったく同じ数字が表示されています。

  • android analyze の機能はこれだけではありません。

  • android analyze はライブラリでも実行できます。

    $ android analyze <library.aar>
    $ android analyze <library.jar>
  • Breana が言ったように、ライブラリに不適切な keep ルールが含まれていると、多くの問題が発生します。

  • では、これを使って、実際にいくつかの問題を見てみましょう。

  • このツールを特定の AAR で実行すると、このライブラリにはいくつかの問題があることが一番上に表示されます。

  • このライブラリの保持率は高くなっています。これは、無制限の保持ルールで保持しすぎていることを意味します。

  • このファイルには、最適化を妨げる広範な keep ルールが含まれています。その影響の大きさは、下に示されている数字からわかります。また、このファイルには、おそらく最も恐ろしいことに、何もターゲットにしていない keep ルールが含まれています。これは、何らかの無制限な方法で他の誰かをターゲットにしていることを意味している可能性があります。

  • 非常に危険なコードスメル。

  • AAR では、これらすべてを確認できます。特にライブラリのパブリッシャーにとっては、不適切な keep ルールをリリースしないようにするうえで、また、keep ルールの作成にあまり詳しくない人がライブラリを操作しているときに、不適切な keep ルールが漏洩しないようにするうえで、これは優れた方法です。

  • ここでは、保持率も出力されていることがわかります。

  • これは、ライブラリの全体的な品質を考えるうえで有効な方法です。

  • 個々のライブラリを完全にスタンドアロンで、参照なしで、R8 で最適化すると、0 バイトになるはずですよね?

  • 内部に保持されているものがないため、基本的には空になっているはずです。

  • それが理想的です。

  • この場合は、そうではありません。

  • R8 と D8 のサイズを直接確認できます。

  • 実際、最も不適切な保持ルールの一部をハイライト表示して、ユーザーが少しでも見つけやすくしています。

  • これも JSON 形式です。アプリ デベロッパーがライブラリの更新を制限する場合でも、ライブラリ デベロッパーがライブラリの品質の大幅な低下を防ぐ場合でも、同じ分析を行い、Android CLI をラップするシンプルなスクリプトでこれらの問題の発生を防ぐことができます。

    {
      "optimization": {
        "libraryAnalysisResult": {
          "dex": {
            "d8CompileResult": {
              "dexSizeBytes": 4523352
            },
            "r8CompileResult": {
              "dexSizeBytes": 3266596,
              "configuration": {
                "usedPackageWideKeepRules": [
                  {
                    "source": "-keep class com.example.analytics.bloat.** { *; }",
                    "keptItemCount": 125579,
                  },
                  ...
                ],
                "unusedPackageWideKeepRules": [
                  {
                    "source": "-keep class some.other.library.** { *; }",
                  },
                  ...
                ]
              },
              ...
    }
  • ライブラリの問題が見つかった場合は、まず更新を試してください。

  • Breana が先ほど述べたように、Google は 10 以上の Google ライブラリを修正して、非常に不適切な keep ルールを修正しました。他にも修正されたライブラリはありますが、それらを使用するアプリによって更新されていません。

  • これは素晴らしい第一歩です。

  • 次に、問題を探してライブラリ デベロッパーに連絡し、アナライザー レポートを添付して、「保持しているデータが多すぎます。

  • アプリの最適化をサポートしてください。」

重要ポイントとリソース

  • ここで、これまでの内容を振り返ってみましょう。R8 Config Analyzer について説明しました。これは、R8 ビルドで発生しているすべてのこと、最適化可能なものとそうでないものを通知するすべてのことを確認するのに役立ちます。

  • Jetpack Keep について説明しました。これは、何もしなくても keep ルールを自動的に作成できる新しいライブラリです。

  • そして、Android CLI は、確認と検証、最後のステップの再確認、そして、あなた、エージェント、チームのジュニア エンジニアが、ユーザーに不利益をもたらしていないことを確認するための新しいツールです。

  • 詳細については、R8、構成アナライザ、Jetpack Keep のドキュメントをご覧ください。

  • また、Android CLI のドキュメントには、analyze コマンドとその使用方法が記載されています。

  • ありがとうございました

コード

コード スニペット

「DEX 最適化の概要」の 5 分 15 秒の Kotlin スクリプト スニペット
android {
    buildTypes {
        release {
            optimization {
                // no need to specify default
                // rules and resource shrinking
                enabled = true
            }
        }
    }
    ...
}
「DEX の最適化の概要」の 5 分 15 秒の Kotlin スクリプト スニペット
android {
    buildTypes {
        release {
            // Enables code optimizations.
            isMinifyEnabled = true

            // Enables resource shrinking.
            isShrinkResources = true

            proguardFiles(
                // Default rules.
                getDefaultProguardFile(
                    "proguard-android-optimize.txt"
                ),
                ... // list out each file with keep rules
            )
            ...
        }
    }
    ...
}
「ケーススタディ:WHOOP の最適化」の 13: 19 のシェル スニペット
$ android skills list
$ android skills add r8-analyzer
「DEX の最適化におけるリフレクションの課題」の 14:41 の Kotlin スニペット
fun createWithReflection(className: String): MyObject {
   return Class.forName(className)
       .getDeclaredConstructor()
       .newInstance() as MyObject
}
「DEX の最適化におけるリフレクションの課題」の 15:40 の Kotlin スニペット
// Problem 1 - optional dependency
fun tryInitCrashReporter(context: Context): EventTransport? {
   return runCatching {
       Class.forName("com.example.analytics.bloat.crash.CrashReporterTransport")
           .getDeclaredConstructor(Context::class.java)
           .newInstance(context) as? EventTransport
   }.getOrNull()
}

// Problem 2 - dynamically instantiate subclass
fun createEventFilter(className: String): EventFilter {
   return Class.forName(className)
       .getDeclaredConstructor()
       .newInstance() as? EventFilter
}

// Problem 3 - serialization
fun serializeEvent(event: UserActionEvent): JSONObject {
   val json = JSONObject()
   for (field in event.javaClass.declaredFields) {
       field.isAccessible = true
       json.put(field.name, field.get(event))
   }
   return json
}
「DEX の最適化におけるリフレクションの課題」の 16:52 のシェル スニペット
# TODO: clean this up
-keep com.example.analytics.bloat.** { * };
「DEX の最適化におけるリフレクションの課題」の 16:59 の Kotlin スニペット
fun createEventFilter(className: String): EventFilter {
   return Class.forName(className)
       .getDeclaredConstructor()
       .newInstance() as? EventFilter
}
「DEX 最適化におけるリフレクションの課題」の 17:07 のシェル スニペット
### Keep the EventFilter
-keep class com.example.analytics.bloat.EventFilter {
    *
}
「DEX 最適化におけるリフレクションの課題」の 17:15 のシェル スニペット
### Keep the actual EventFilter
-keep class com.example.analytics.bloat.EventFilterKt {
    *
}
「DEX 最適化におけるリフレクションの課題」の 17:19 のシェル スニペット
### Keep the EventFilter subclasses
-keep class ** extends com.example.analytics.bloat.EventFilterKt {
    *
}
「DEX 最適化におけるリフレクションの課題」の 17:40 のシェル スニペット
### Keep the EventFilter subclasses, if they have this constructor
-keepclasseswithmembers class ** extends com.example.analytics.bloat.EventFilterKt {
   void <init>();
}
「DEX 最適化におけるリフレクションの課題」の 17:50 のシェル スニペット
### Keep the EventFilter subclasses, if they have this constructor, allow more optimization
-keepclasseswithmembers,allowaccessmodification class ** extends com.example.analytics.bloat.EventFilterKt {
   void <init>();
}
「DEX 最適化におけるリフレクションの課題」の 18:03 のシェル スニペット
### -if makes this conditional, critical for libraries
-if class com.example.analytics.bloat.EventToolsKt {
    class com.example.analytics.bloat.EventFilterKt createEventFilter(java.lang.String);
}
### Keep the EventFilter subclasses, if they have this constructor, allow more optimization
-keepclasseswithmembers,allowaccessmodification class ** extends com.example.analytics.bloat.EventFilterKt {
    void <init>();
}
「DEX の最適化におけるリフレクションの課題」の 18:57 の Kotlin スニペット
@androidx.annotation.Keep
class EventFilter { … }

@androidx.annotation.Keep
class SizeBasedEventFilter { … }

@androidx.annotation.Keep
class DropLogsEventFilter { … }
「DEX 最適化におけるリフレクションの課題」の 19:22 の Kotlin スニペット
@Keep
class EventFilter { … }
「DEX の最適化におけるリフレクションの課題」の 19:29 の Kotlin スニペット
@Keep
class EventFilter { … }

@Keep
class SizeBasedEventFilter { … }

@Keep
class DropLogsEventFilter { … }
「Introduction to Jetpack Keep」の 20:38 の Kotlin スクリプト スニペット
plugins {
   id("com.android.library")
   id("androidx.annotation.keep")
}

dependencies {
    implementation("androidx.annotation.keep:keep")
    ...
}
「Introduction to Jetpack Keep」の 20:54 のシェル スニペット
./gradlew analyticsbloat:lintFix
「Introduction to Jetpack Keep」の 20:55 の Kotlin スニペット
@UsesReflectionToConstruct(
   classConstant = EventFilter::class,
   parameterTypes = []
)
fun createEventFilter(className: String): EventFilter {
   return Class.forName(className)
       .getDeclaredConstructor()
       .newInstance() as? EventFilter
}
「Introduction to Jetpack Keep」の 21:25 のシェル スニペット
# Generated Keep Rule

-if class com.example.analytics.bloat.EventTools {
    class com.example.analytics.bloat.EventFilter createEventFilter(java.lang.String);
}
-keepclasseswithmembers,allowaccessmodification class ** extends class com.example.analytics.bloat.EventFilter {
    void <init>();
}
「Introduction to Jetpack Keep」の 21:52 の Kotlin スニペット
@UsesReflectionToConstruct(
   className = "com.example.analytics.bloat.crash.CrashReporterTransport",
   parameterTypes = [Context::class]
)
fun tryInitCrashReporter(context: Context): EventTransport? {
   return runCatching {
       Class.forName("com.example.analytics.bloat.crash.CrashReporterTransport")
           .getDeclaredConstructor(Context::class.java)
           .newInstance(context) as? EventTransport
   }.getOrNull()
}
「Introduction to Jetpack Keep」の 22:05 のシェル スニペット
# Generated Keep Rule

-if class com.example.analytics.bloat.CrashConnection {
    class com.example.analytics.bloat.EventTransport tryInitCrashReporter(android.content.Context);
}
-keepclasseswithmembers,allowaccessmodification class com.example.analytics.bloat.crash.CrashReporterTransport {
    void <init>(android.content.Context);
}
「Introduction to Jetpack Keep」の 22:16 の Kotlin スニペット
// TODO: migrate to Kotlinx Serialization or Moshi codegen.
@UsesReflectionToAccessField(
   classConstant = UserActionEvent::class,
   fieldName = "*"
)
fun serializeEvent(event: UserActionEvent): JSONObject {
   val json = JSONObject()
   for (field in event.javaClass.declaredFields) {
       field.isAccessible = true
       json.put(field.name, field.get(event))
   }
   return json
}
「Introduction to Jetpack Keep」の 22:27 のシェル スニペット
# Generated Keep Rule

-if class com.example.analytics.bloat.serialization.EventSerializer {
    class org.json.JSONObject serializeEvent(com.example.analytics.bloat.model.UserActionEvent);
}
-keepclasseswithmembers,allowaccessmodification class com.example.analytics.bloat.model.UserActionEvent {
    <fields>;
}
「Analyzing apps with Android CLI」の 24:22 のシェル スニペット
$ android analyze <mybundle.aab>
$ android analyze <mybundle.apk>
「Analyzing apps with Android CLI」の 24:52 の JSON スニペット
{
  "optimization": {
    "bundleAnalysisResult": {
      "agpVersion": "9.3.2",
      "r8Json": {
        "version": "9.3.16",
        "options": {
          "isRepackageClassesEnabled": true,
          "isProguardCompatibilityModeEnabled": false
        },
        "stats": {
          "shrinkingRatio": 0.2306,
          "optimizationRatio": 0.2351,
          "obfuscationRatio": 0.2322
        },
        ...
      }
    "mappingObfuscationRatio": 0.21631891,
    "dexHeuristicObfuscationRatio": 0.25118626,
    "uncompressedDexSizeBytes": "16198152"
  }
「Android CLI を使用したアプリの分析」の 25:17 のシェル スニペット
$ android analyze <library.aar>
$ android analyze <library.jar>
「Analyzing apps with Android CLI」の 26:46 の JSON スニペット
{
  "optimization": {
    "libraryAnalysisResult": {
      "dex": {
        "d8CompileResult": {
          "dexSizeBytes": 4523352
        },
        "r8CompileResult": {
          "dexSizeBytes": 3266596,
          "configuration": {
            "usedPackageWideKeepRules": [
              {
                "source": "-keep class com.example.analytics.bloat.** { *; }",
                "keptItemCount": 125579,
              },
              ...
            ],
            "unusedPackageWideKeepRules": [
              {
                "source": "-keep class some.other.library.** { *; }",
              },
              ...
            ]
          },
          ...
}