Start Debugging

Como habilitar a redução e a ofuscação do R8 em um build de release Android do .NET MAUI

Defina AndroidLinkTool como r8, mantenha o trimming ligado e adicione um arquivo ProguardConfiguration. Por que o .NET 10 e o .NET 11 RC 1 ainda entregam Java sem ofuscação, como a nova propriedade AndroidR8ObfuscationMode muda isso e como verificar se o R8 realmente rodou.

Resposta curta: adicione <AndroidLinkTool>r8</AndroidLinkTool> a um PropertyGroup exclusivo de Release no .csproj do seu MAUI, deixe o trimming ligado (ele já vem ligado por padrão em Release) e coloque as regras de keep em um arquivo proguard.cfg com a build action ProguardConfiguration. Isso liga a redução e a otimização do R8 no lado Java do seu app. Isso não ofusca nada nos SDKs disponíveis hoje: o .NET for Android 36.1.69 (.NET 10) e o 37.0.0-rc.1.2257 (.NET 11 RC 1) injetam -dontobfuscate na configuração do R8. A ofuscação de verdade chega com a nova propriedade AndroidR8ObfuscationMode, cujo padrão é private-members no .NET 11 depois do RC 1 e que é um backport opcional para a próxima versão de manutenção do .NET 10.

Esse último detalhe importa mais do que antes. O Google anunciou em 26 de agosto de 2026 que, a partir de fevereiro de 2027, os app bundles no Google Play vão precisar de pelo menos 25% de cobertura de otimização, redução e ofuscação do código DEX (o Android vitals só emite alerta quando um bundle carrega 10 MB de DEX, no caso de apps, ou 50 MB, no caso de jogos). Um app MAUI puxa muito Java do AndroidX e do Google Play services, então o lado DEX não é pequeno.

Tudo o que vem abaixo foi rastreado no código-fonte do dotnet/android nas tags de release citadas acima, então você mesmo pode conferir cada afirmação nos targets do MSBuild.

O que o R8 toca em um app MAUI, e o que ele não toca

Um pacote Android do MAUI carrega dois tipos de código, e eles são reduzidos por ferramentas diferentes:

As porcentagens do Google Play são medidas sobre o DEX, que é exatamente a metade do R8. Então, quando alguém diz “habilite o R8 no MAUI”, está falando de deixar essa metade Java menor e, com o tempo, renomeada.

Só a redução já vale a pena. Na dotnet/android #12535, um desenvolvedor mediu um app .NET 10 no 36.1.69 com 20,18 MB de DEX descomprimido usando D8 e 11,43 MB usando R8 com as regras padrão do SDK. É quase metade do código Java removida, sem nenhuma regra de keep escrita à mão.

A alteração mínima no projeto

Esta é a configuração completa para um app MAUI voltado para .NET 10 e .NET 11:

<!-- 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>

Depois, publique como de costume:

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

O proguard.cfg pode começar vazio. Você só adiciona regras a ele quando o R8 remove algo que é acessado por reflexão, o que é tratado mais adiante.

O que o SDK faz quando você define AndroidLinkTool

AndroidLinkTool é a única chave de que você precisa, porque o Xamarin.Android.Common.targets deriva o resto a partir dela. Resumido dos targets do .NET 10 e do .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>

Algumas consequências decorrem disso:

Por que o R8 precisa do trimming ligado

O R8 não consegue descobrir sozinho quais tipos Java o seu C# ainda usa. Essa lista vem do trimmer do .NET: depois que o ILLink roda, uma etapa customizada escreve proguard_project_references.cfg com uma regra de keep para cada tipo Java ao qual um tipo gerenciado sobrevivente faz binding. O target que o gera é condicionado ao trimming:

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

A decisão de rodar o R8, porém, não verifica o trimming. O Xamarin.Android.D8.targets só precisa que a propriedade de caminho esteja definida, e o _ResolveAssemblies a define em todo build em que AndroidLinkTool não está vazio:

<!-- 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>

Então, com PublishTrimmed=false (ou AndroidLinkMode=None em Release, um contorno comum para problemas de reflexão), o R8 ainda roda, mas sem o arquivo que protege seus bindings. O build apenas registra XA4304 (“ProGuard configuration file ‘…proguard_project_references.cfg’ was not found”), e o app morre com java.lang.ClassNotFoundException na primeira vez que toca um tipo Java que o R8 removeu. Essa sequência exata é a dotnet/android #6612, em que os mantenedores confirmaram que o R8 depende do linker do .NET estar habilitado.

É também por isso que a condição de Release no arquivo de projeto não é cosmética. Builds de Debug não fazem trimming, então um AndroidLinkTool=r8 incondicional faz o Debug rodar o R8 sem o arquivo de referências também, e com o fast deployment ligado você ainda recebe XA0119: “Using fast deployment and a code shrinker at the same time is not recommended”.

Quais arquivos de configuração o R8 realmente recebe

Quando o R8 roda, o SDK monta as entradas --pg-conf nesta ordem (itens _ProguardConfiguration em Xamarin.Android.Common.targets):

  1. $(ProguardConfigFiles), se você definir essa propriedade.
  2. O proguard-android.txt do Android SDK (base sem otimização). Em SDKs mais novos com AndroidR8ObfuscationMode=private-members, ele passa a ser proguard-android-optimize.txt.
  3. obj/.../proguard/proguard_xamarin.cfg: regras de keep do runtime para mono.android.**, net.dot.jni.** e afins. Nos SDKs disponíveis hoje, esse arquivo começa com -dontobfuscate.
  4. proguard_project_references.cfg: regras de keep para cada tipo Java ao qual um tipo gerenciado sobrevivente faz binding, geradas depois do ILLink.
  5. proguard_project_primary.cfg: uma regra -keep class X { *; } por Java Callable Wrapper do mapa de ACW, para que toda Activity, Service e View customizada que o seu C# define sobreviva.
  6. Seus itens @(ProguardConfiguration).
  7. Regras de consumidor (proguard.txt) extraídas dos arquivos .aar referenciados.

Os itens 4 e 5 explicam por que um app MAUI raramente precisa de regras de keep escritas à mão para os próprios tipos: o build já sabe quais classes Java o lado gerenciado consegue alcançar. O que ele não sabe é o que o código Java alcança por reflexão.

Por que hoje você ganha redução, mas não ofuscação

As opções do ProGuard são globais. Se qualquer arquivo de configuração disser -dontobfuscate, a ofuscação fica desligada em toda a execução do R8, e não existe flag oposta que você possa adicionar no seu próprio proguard.cfg para religá-la. Como o proguard_xamarin.cfg no 36.1.69 e no 37.0.0-rc.1.2257 contém essa linha, um build MAUI com R8 em qualquer um dos dois SDKs reduz e otimiza, mas mantém todos os nomes Java intactos. O mapping.txt gerado ainda registra membros removidos e mudanças de número de linha, mas não vai mostrar renomeações.

As medições da #12535 batem com isso: 34 de 15.235 classes renomeadas no mapping.txt de um app (0,2%), e o Play Console reportando 1% de ofuscação para outro. Definir AndroidCreateProguardMappingFile=true, como algumas respostas sugerem, não muda nada aqui; isso só controla se o arquivo de mapeamento é gerado.

O -dontobfuscate geral foi a escolha segura. O JNI associa peers gerenciados a classes Java pelo nome, então renomear um Java Callable Wrapper ou um método do AndroidX com binding quebraria as buscas do JNIEnv em runtime. A mesma thread descobriu que remover a linha à mão também não basta: as regras de keep geradas não protegiam campos que os bindings leem pelo nome via JNI, então os apps travavam na inicialização. Espere pela chave suportada descrita abaixo em vez de remendar a configuração do SDK.

Ligando a ofuscação de verdade com AndroidR8ObfuscationMode

O dotnet/android #12668, mesclado em 10 de setembro de 2026, substitui a regra geral por uma seletiva e adiciona uma propriedade pública:

AndroidR8ObfuscationModeOfuscaçãoBase de otimizaçãoPadrão
disablednenhuma, todos os nomes Java preservadosproguard-android.txtmanutenção do .NET 10
private-membersmembros private e package-private renomeadosproguard-android-optimize.txt.NET 11 depois do RC 1

No mesmo dia, o #12752 fez o backport para release/10.0.1xx com disabled como padrão, para que uma atualização de manutenção não altere apps existentes. Nenhuma tag lançada contém isso ainda (o 36.1.69 é anterior, e o release/11.0.1xx-rc1 foi criado antes do merge), então espere por isso no .NET 11 RC 2 e na próxima atualização de manutenção do .NET 10.

No modo private-members, a task do R8 escreve estas regras no lugar de -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 **

Leia assim: toda classe mantém o nome, todo membro public e protected mantém o nome, e qualquer coisa private ou package-private pode ser renomeada. Código não usado ainda pode ser removido. As regras de interface existem porque a seleção de proxy gerenciado chama Class.getInterfaces(), que o R8 não enxerga; sem elas, a fusão de classes poderia descartar uma relação de interface e entregar o proxy errado ao código gerenciado.

Para habilitar no .NET 10 quando a versão de manutenção sair, ou para desabilitar no .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>

Qualquer outro valor faz o build falhar com XA1050: “The ‘AndroidR8ObfuscationMode’ MSBuild property has an invalid value”. O PR também remove as chaves não documentadas _AndroidR8DontObfuscate e _AndroidR8DontOptimize, então tire-as do seu projeto se você as copiou de alguma thread de issue.

Mantenha as expectativas calibradas. O PR relata que o Google Play mediu um template dotnet new maui -sc com 62% de otimização, 65% de redução e 28% de ofuscação. Isso passa da barra de 25%, mas a ofuscação é a mais apertada, porque nomes visíveis ao JNI não podem ser renomeados. Trate private-members como “suficiente para o requisito do Play”, não como proteção para a lógica do seu C#.

Escrevendo regras de keep que realmente importam

O R8 só remove código Java que ele consegue provar que é inalcançável. O código que precisa de regras é aquele alcançado de formas que o R8 não enxerga:

# 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.**

Vale a pena adicionar duas regras de diagnóstico temporariamente enquanto você faz esse ajuste, porque o seu próprio arquivo ProguardConfiguration é tratado como configuração da aplicação e pode usar opções globais:

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

O R8 resolve esses caminhos relativos a partir da pasta do arquivo de configuração, então os dois vão parar ao lado do proguard.cfg. Procure -dontobfuscate em r8-merged.txt para confirmar qual comportamento de ofuscação o seu SDK aplicou, e procure um nome de classe em r8-usage.txt para provar que o R8 a removeu antes de escrever uma regra para ela. Remova as duas linhas antes de fazer commit, porque elas aumentam o tempo de todo build de Release.

Armadilhas que pegam na prática

Verificando se o R8 realmente rodou

Não confie só na propriedade; confirme pela saída do build:

  1. Compile com um log binário: dotnet publish -f net11.0-android -c Release -bl. Abra o msbuild.binlog no MSBuild Structured Log Viewer e procure a task R8 sob _CompileToDalvik. Se você só encontrar D8, a propriedade nunca chegou ao build Android, geralmente porque a condição dela não bate com o seu TargetFramework. Se o R8 rodou, mas o log contém XA4304 para proguard_project_references.cfg, o trimming está desligado e o app vai travar em runtime.
  2. Verifique se bin/Release/net11.0-android/mapping.txt existe e tem um timestamp recente.
  3. Abra obj/Release/net11.0-android/android-arm64/proguard/proguard_xamarin.cfg (a pasta de RID exata depende dos seus RuntimeIdentifiers). No 36.1.69 ou no 37.0.0-rc.1.2257, ele começa com -dontobfuscate. Em um SDK com AndroidR8ObfuscationMode=private-members, ele começa com o bloco -keep,allowshrinking,allowoptimization class **.
  4. Envie o .aab para uma faixa de teste interno e leia as porcentagens de otimização, redução e ofuscação no explorador de app bundles do Play Console. Esse é o número que o Google exige, então é ele que você deve acompanhar. O Play lê as porcentagens de um arquivo de metadados de build r8.json quando o bundle tem um e, caso contrário, as estima a partir do mapping.txt. O SDK passa a empacotar o r8.json com o dotnet/android #12646, que está em release/10.0.1xx e main, mas não no 37.0.0-rc.1.2257.

Leitura relacionada

Fontes

Comments

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

< Voltar