Start Debugging

.NET MAUI Android のリリースビルドで R8 の縮小と難読化を有効にする方法

AndroidLinkTool を r8 に設定し、トリミングを有効のままにして、ProguardConfiguration ファイルを追加します。.NET 10 と .NET 11 RC 1 がいまだに難読化されていない Java を出力する理由、新しい AndroidR8ObfuscationMode プロパティがそれをどう変えるか、そして R8 が実際に実行されたことを確認する方法を解説します。

結論: MAUI の .csproj にある Release 専用の PropertyGroup<AndroidLinkTool>r8</AndroidLinkTool> を追加し、トリミングは有効のままにして (Release ではデフォルトで有効です)、keep ルールがあれば ProguardConfiguration ビルドアクションを付けた proguard.cfg ファイルに記述します。これでアプリの Java 側に対する R8 の縮小と最適化が有効になります。ただし、現在出荷されている SDK では何も難読化されません。.NET for Android 36.1.69 (.NET 10) と 37.0.0-rc.1.2257 (.NET 11 RC 1) は、どちらも R8 の構成に -dontobfuscate を注入します。本当の難読化は新しい AndroidR8ObfuscationMode プロパティとともに登場します。このプロパティは .NET 11 では RC 1 より後のリリースで private-members がデフォルトになり、次回の .NET 10 サービスリリースにはオプトインのバックポートとして入ります。

この最後の点は、以前よりも重要になっています。Google は 2026-08-26 に、2027 年 2 月から Google Play のアプリバンドルには DEX コードの最適化、縮小、難読化のカバレッジが少なくとも 25% 必要になると発表しました (Android vitals がアラートを出すのは、バンドルに含まれる DEX がアプリで 10 MB、ゲームで 50 MB に達した場合のみです)。MAUI アプリは AndroidX と Google Play services の Java を大量に取り込むため、DEX 側は決して小さくありません。

以下の内容はすべて、上記のリリースタグ時点の dotnet/android ソースを追跡して確認したものです。そのため、各主張を MSBuild のターゲットと照らし合わせてご自身で検証できます。

MAUI アプリで R8 が扱うもの、扱わないもの

MAUI の Android パッケージには 2 種類のコードが含まれており、それぞれ別のツールで縮小されます。

Google Play のパーセンテージは DEX に対して計測されるので、まさに R8 が担当する側が対象です。つまり “MAUI で R8 を有効にする” と言うとき、それはこの Java 側を小さくし、最終的には名前を変更することを意味します。

縮小だけでも十分に価値があります。dotnet/android #12535 では、ある開発者が 36.1.69 上の .NET 10 アプリを計測し、非圧縮の DEX が D8 では 20.18 MB、R8 と SDK のデフォルトルールでは 11.43 MB になりました。手書きの keep ルールなしで、Java コードの半分近くが削減されたことになります。

最小限のプロジェクト変更

.NET 10 と .NET 11 をターゲットとする MAUI アプリの構成は、これですべてです。

<!-- MyApp.csproj, .NET 10 (Microsoft.Android.Sdk 36.1.x) and .NET 11 RC 1 (37.0.0-rc.1) -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net11.0-android;net11.0-ios</TargetFrameworks>
    <OutputType>Exe</OutputType>
    <UseMaui>true</UseMaui>
  </PropertyGroup>

  <PropertyGroup Condition="'$(Configuration)' == 'Release' And $(TargetFramework.Contains('-android'))">
    <AndroidLinkTool>r8</AndroidLinkTool>
    <!-- Default in Release already. Written out because R8 without trimming strips Java types your C# still uses. -->
    <PublishTrimmed>true</PublishTrimmed>
  </PropertyGroup>

  <ItemGroup Condition="$(TargetFramework.Contains('-android'))">
    <ProguardConfiguration Include="Platforms/Android/proguard.cfg" />
  </ItemGroup>
</Project>

あとはいつもどおり発行します。

dotnet publish -f net11.0-android -c Release

proguard.cfg は空の状態から始めてかまいません。ルールを追加する必要があるのは、リフレクション経由で到達されるものを R8 が削除した場合だけです。これについては後述します。

AndroidLinkTool を設定したときに SDK が行うこと

Xamarin.Android.Common.targets が残りの設定を AndroidLinkTool から導出するため、必要なスイッチはこれだけです。.NET 10 と .NET 11 のターゲットから要点を抜き出すと、次のようになります。

<!-- Xamarin.Android.Common.targets (dotnet/android 36.1.69 and 37.0.0-rc.1.2257), condensed -->
<AndroidDexTool   Condition=" '$(AndroidLinkTool)' == 'r8' ">d8</AndroidDexTool>
<AndroidLinkTool  Condition=" '$(AndroidLinkTool)' == 'proguard' And '$(AndroidEnableDesugar)' == 'True' ">r8</AndroidLinkTool>
<AndroidEnableProguard Condition=" '$(AndroidLinkTool)' != '' ">True</AndroidEnableProguard>
<AndroidCreateProguardMappingFile Condition="'$(AndroidCreateProguardMappingFile)' == '' And '$(AndroidLinkTool)' == 'r8'">True</AndroidCreateProguardMappingFile>
<AndroidProguardMappingFile Condition=" '$(AndroidLinkTool)' == 'r8' And '$(AndroidCreateProguardMappingFile)' == 'True' ">$(OutputPath)mapping.txt</AndroidProguardMappingFile>

ここからいくつかの帰結が導かれます。

R8 にトリミングの有効化が必要な理由

R8 は、C# がまだ使っている Java 型を自力で判断できません。そのリストは .NET のトリマーから得られます。ILLink の実行後、カスタムステップが proguard_project_references.cfg を書き出し、残ったマネージド型がバインドしているすべての Java 型に対する keep ルールを記録します。これを生成するターゲットは、トリミングを条件としています。

<!-- Microsoft.Android.Sdk.TypeMap.LlvmIr.targets, 37.0.0-rc.1.2257 -->
<Target Name="_GenerateProguardConfiguration"
    AfterTargets="_PrepareLinkedAssembliesForProguard"
    Condition=" '$(PublishTrimmed)' == 'true' and '$(_ProguardProjectConfiguration)' != '' "

ところが、R8 を実行するかどうかの判定はトリミングを確認しません。Xamarin.Android.D8.targets はパスのプロパティが設定されていることしか要求せず、_ResolveAssembliesAndroidLinkTool が空でないすべてのビルドでそれを設定します。

<!-- Xamarin.Android.D8.targets, same in 36.1.69 and 37.0.0-rc.1.2257 -->
<_UseR8 Condition=" ('$(AndroidLinkTool)' == 'r8' And '$(_ProguardProjectConfiguration)' != '') Or '$(AndroidEnableMultiDex)' == 'True' ">True</_UseR8>

そのため PublishTrimmed=false (あるいは、リフレクションの問題に対するよくある回避策である Release での AndroidLinkMode=None) の場合でも R8 は実行されますが、バインディングを保護するファイルがない状態で実行されます。ビルドは XA4304 (“ProGuard configuration file ‘…proguard_project_references.cfg’ was not found”) をログに出すだけで、アプリは R8 が削除した Java 型に初めて触れた時点で java.lang.ClassNotFoundException によって落ちます。まさにこの流れが dotnet/android #6612 であり、メンテナーは R8 が .NET リンカーの有効化に依存していることを認めています。

プロジェクトファイルの Release 条件が見た目だけのものではない理由もここにあります。Debug ビルドはトリミングを行わないため、無条件の AndroidLinkTool=r8 は Debug でも参照ファイルなしで R8 を実行させてしまいます。さらに高速デプロイが有効だと XA0119: “Using fast deployment and a code shrinker at the same time is not recommended” も発生します。

R8 が実際に受け取る構成ファイル

R8 の実行時、SDK は --pg-conf の入力を次の順序で組み立てます (Xamarin.Android.Common.targets_ProguardConfiguration アイテム)。

  1. $(ProguardConfigFiles) (このプロパティを設定している場合)。
  2. Android SDK の proguard-android.txt (最適化なしのベースライン)。AndroidR8ObfuscationMode=private-members を備えた新しい SDK では、これが proguard-android-optimize.txt になります。
  3. obj/.../proguard/proguard_xamarin.cfg: mono.android.**net.dot.jni.** などのランタイム用 keep ルールです。現在出荷されている SDK では、このファイルは -dontobfuscate で始まります。
  4. proguard_project_references.cfg: 残ったマネージド型がバインドしているすべての Java 型に対する keep ルールで、ILLink の後に生成されます。
  5. proguard_project_primary.cfg: ACW マップに含まれる Java Callable Wrapper ごとに 1 つの -keep class X { *; } ルールがあり、C# で定義したすべての ActivityService、カスタム View が残るようにします。
  6. ご自身の @(ProguardConfiguration) アイテム。
  7. 参照している .aar ファイルから抽出されたコンシューマールール (proguard.txt)。

MAUI アプリが自前の型のために手書きの keep ルールをほとんど必要としないのは、4 と 5 があるからです。ビルドは、マネージド側から到達できる Java クラスをすでに把握しています。把握できないのは、Java コードがリフレクション経由で到達するものです。

現時点で縮小はされても難読化されない理由

ProGuard のオプションはグローバルです。いずれかの構成ファイルに -dontobfuscate があれば、R8 の実行全体で難読化が無効になり、ご自身の proguard.cfg に追加してそれを再び有効にできる逆のフラグは存在しません。36.1.69 と 37.0.0-rc.1.2257 の proguard_xamarin.cfg にはその行が含まれているため、どちらの SDK でも R8 を有効にした MAUI ビルドは縮小と最適化を行いますが、Java の名前はすべてそのまま残ります。書き出される mapping.txt には削除されたメンバーや行番号の変更は記録されますが、名前の変更は現れません。

#12535 の計測結果もこれと一致しています。あるアプリの mapping.txt では 15,235 クラス中 34 クラス (0.2%) しか名前が変更されておらず、別のアプリでは Play Console が難読化 1% と報告していました。一部の回答で勧められている AndroidCreateProguardMappingFile=true の設定は、ここでは何も変えません。これはマッピングファイルを書き出すかどうかを制御するだけです。

一律の -dontobfuscate は安全策でした。JNI はマネージドのピアを名前で Java クラスにバインドするため、Java Callable Wrapper やバインドされた AndroidX のメソッドの名前を変えると、実行時に JNIEnv の検索が壊れてしまいます。同じスレッドでは、この行を手作業で削除するだけでも不十分であることがわかりました。生成された keep ルールは、バインディングが JNI 経由で名前で読み取るフィールドを保護しておらず、アプリが起動時にクラッシュしたのです。SDK の構成にパッチを当てるのではなく、以下で紹介するサポートされたスイッチを待ってください。

AndroidR8ObfuscationMode で本当の難読化を有効にする

2026-09-10 にマージされた dotnet/android #12668 は、一律のルールを選択的なルールに置き換え、公開プロパティを追加しています。

AndroidR8ObfuscationMode難読化最適化のベースラインデフォルト
disabledなし、Java の名前はすべて保持proguard-android.txt.NET 10 サービスリリース
private-membersprivate および package-private メンバーの名前を変更proguard-android-optimize.txt.NET 11 (RC 1 より後)

同じ日に #12752disabled をデフォルトとして release/10.0.1xx にバックポートしたため、サービス更新によって既存アプリの挙動が変わることはありません。これを含むリリース済みタグはまだありません (36.1.69 はこれより前で、release/11.0.1xx-rc1 はマージ前に切り出されました)。そのため、.NET 11 RC 2 と次回の .NET 10 サービス更新で入ると考えてください。

private-members モードでは、R8 タスクが -dontobfuscate の代わりに次のルールを書き出します。

# Generated by the R8 task in dotnet/android main (post .NET 11 RC 1)
-keep,allowshrinking,allowoptimization class **
-keepclassmembers,allowshrinking,allowoptimization class ** {
   public protected *;
}
-keep,allowoptimization interface ** {
   public protected *;
}
-keep,allowshrinking class * implements **

これは次のように読めます。すべてのクラスは名前を保持し、すべての public および protected メンバーも名前を保持し、private または package-private のものは名前を変更される可能性があります。使われていないコードは引き続き削除できます。インターフェースのルールがあるのは、マネージドのプロキシ選択が R8 からは見えない Class.getInterfaces() を呼び出すためです。これがないと、クラスのマージによってインターフェースの関係が失われ、マネージドコードに誤ったプロキシが渡される可能性があります。

サービスリリースが出た後の .NET 10 でオプトインする場合、または .NET 11 でオプトアウトする場合は次のようにします。

<!-- .NET 10 servicing (opt in) or .NET 11 (opt out with "disabled") -->
<PropertyGroup Condition="'$(Configuration)' == 'Release' And $(TargetFramework.Contains('-android'))">
  <AndroidLinkTool>r8</AndroidLinkTool>
  <AndroidR8ObfuscationMode>private-members</AndroidR8ObfuscationMode>
</PropertyGroup>

それ以外の値を指定すると、ビルドは XA1050: “The ‘AndroidR8ObfuscationMode’ MSBuild property has an invalid value” で失敗します。この PR では、文書化されていなかった _AndroidR8DontObfuscate_AndroidR8DontOptimize スイッチも削除されているため、issue のスレッドからコピーしたものがあればプロジェクトから削除してください。

期待値は適切に保ってください。PR の報告によると、Google Play は dotnet new maui -sc テンプレートを最適化 62%、縮小 65%、難読化 28% と計測しました。25% の基準はクリアしていますが、JNI から見える名前は変更できないため、難読化が最も余裕のない項目です。private-members は “Play の要件を満たすのに十分なもの” として扱い、C# のロジックを保護するものとは考えないでください。

本当に意味のある keep ルールの書き方

R8 が削除するのは、到達不能だと証明できる Java コードだけです。ルールが必要なのは、R8 から見えない方法で到達されるコードです。

# Platforms/Android/proguard.cfg  (.NET 10 / .NET 11, R8 via AndroidLinkTool=r8)

# A Java SDK that loads its own classes with Class.forName and ships no consumer rules
-keep class com.example.vendorsdk.** { *; }

# Classes you look up by string from C#, e.g. Java.Lang.Class.ForName("com.example.Probe")
-keep class com.example.Probe { *; }

# JSON models serialized by a Java library (Gson, Moshi) that uses reflection
-keepattributes Signature,*Annotation*
-keep class com.example.api.models.** { <fields>; }

# Silence a known-harmless missing optional class instead of ignoring all warnings
-dontwarn androidx.window.extensions.**

調整中は、診断用のルールを 2 つ一時的に追加しておく価値があります。ご自身の ProguardConfiguration ファイルはアプリケーションの構成として扱われるため、グローバルオプションを使用できるからです。

# Temporary: dump what R8 removed and the fully merged configuration
-printusage r8-usage.txt
-printconfiguration r8-merged.txt

R8 はこれらの相対パスを構成ファイルのフォルダーを基準に解決するため、どちらも proguard.cfg の隣に出力されます。r8-merged.txt-dontobfuscate を検索すると、SDK が適用した難読化の挙動を確認できます。また、ルールを書く前に r8-usage.txt でクラス名を検索して、R8 がそれを削除したことを確かめてください。どちらの行も Release ビルドのたびに時間がかかるので、コミット前に削除してください。

実際にハマりやすい注意点

R8 が本当に実行されたかの確認

プロパティだけを信用せず、ビルド出力で確認してください。

  1. バイナリログ付きでビルドします: dotnet publish -f net11.0-android -c Release -bl。MSBuild Structured Log Viewer で msbuild.binlog を開き、_CompileToDalvik の下にある R8 タスクを検索します。D8 しか見つからない場合、プロパティが Android のビルドに届いていません。たいていは、その条件が TargetFramework に一致していないことが原因です。R8 は実行されたもののログに proguard_project_references.cfg に対する XA4304 がある場合は、トリミングが無効になっており、アプリは実行時にクラッシュします。
  2. bin/Release/net11.0-android/mapping.txt が存在し、タイムスタンプが新しいことを確認します。
  3. obj/Release/net11.0-android/android-arm64/proguard/proguard_xamarin.cfg を開きます (正確な RID フォルダーは RuntimeIdentifiers によって異なります)。36.1.69 または 37.0.0-rc.1.2257 では -dontobfuscate で始まります。AndroidR8ObfuscationMode=private-members を備えた SDK では、代わりに -keep,allowshrinking,allowoptimization class ** のブロックで始まります。
  4. .aab を内部テストトラックにアップロードし、Play Console のアプリバンドル エクスプローラーで最適化、縮小、難読化のパーセンテージを確認します。Google が適用するのはこの数値なので、注視すべきはこれです。Play は、バンドルに r8.json ビルドメタデータファイルがあればそこからパーセンテージを読み取り、なければ mapping.txt から推定します。SDK が r8.json をパッケージに含めるようになるのは dotnet/android #12646 からで、これは release/10.0.1xxmain には入っていますが、37.0.0-rc.1.2257 には含まれていません。

関連記事

参考資料

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る