Start Debugging

Cómo habilitar la reducción y ofuscación con R8 en una compilación Release de .NET MAUI para Android

Establece AndroidLinkTool en r8, mantén el recorte activado y agrega un archivo ProguardConfiguration. Por qué .NET 10 y .NET 11 RC 1 todavía generan Java sin ofuscar, cómo lo cambia la nueva propiedad AndroidR8ObfuscationMode y cómo verificar que R8 realmente se ejecutó.

Respuesta corta: agrega <AndroidLinkTool>r8</AndroidLinkTool> a un PropertyGroup exclusivo de Release en el .csproj de tu app MAUI, deja el recorte activado (en Release viene activado por defecto) y coloca las reglas keep en un archivo proguard.cfg con la acción de compilación ProguardConfiguration. Eso activa la reducción y optimización con R8 de la parte Java de tu app. No ofusca nada en los SDK que se distribuyen hoy: .NET for Android 36.1.69 (.NET 10) y 37.0.0-rc.1.2257 (.NET 11 RC 1) inyectan -dontobfuscate en la configuración de R8. La ofuscación real llega con la nueva propiedad AndroidR8ObfuscationMode, cuyo valor por defecto es private-members en .NET 11 después de RC 1 y que es un backport opcional para la próxima versión de servicio de .NET 10.

Ese último detalle importa más que antes. Google anunció el 26 de agosto de 2026 que, a partir de febrero de 2027, los app bundles en Google Play necesitan al menos un 25% de cobertura de optimización, reducción y ofuscación de su código DEX (Android vitals solo emite alertas cuando un bundle contiene 10 MB de DEX en apps y 50 MB en juegos). Una app MAUI incorpora mucho Java de AndroidX y de los servicios de Google Play, así que la parte DEX no es pequeña.

Todo lo que sigue se rastreó en el código fuente de dotnet/android en las etiquetas de versión mencionadas arriba, así que puedes comprobar cada afirmación contra los targets de MSBuild por tu cuenta.

Qué toca R8 en una app MAUI y qué no

Un paquete Android de MAUI contiene dos tipos de código, y cada uno lo reduce una herramienta distinta:

Los porcentajes de Google Play se miden sobre el DEX, que es exactamente la mitad de R8. Así que cuando se habla de “habilitar R8 en MAUI”, se refiere a hacer más pequeña esa mitad Java y, eventualmente, renombrarla.

La reducción por sí sola ya vale la pena. En dotnet/android #12535, un desarrollador midió una app .NET 10 en 36.1.69 con 20.18 MB de DEX sin comprimir usando D8 y 11.43 MB con R8 y las reglas por defecto del SDK. Eso es casi la mitad del código Java eliminado, sin escribir reglas keep a mano.

El cambio mínimo en el proyecto

Esta es toda la configuración para una app MAUI que apunta a .NET 10 y .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>

Luego publica como siempre:

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

El proguard.cfg puede empezar vacío. Solo le agregas reglas cuando R8 elimina algo al que se accede por reflexión, lo cual se trata más adelante.

Qué hace el SDK cuando estableces AndroidLinkTool

AndroidLinkTool es el único interruptor que necesitas, porque Xamarin.Android.Common.targets deriva el resto a partir de él. Resumido de los targets de .NET 10 y .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>

De ahí se desprenden algunas consecuencias:

Por qué R8 necesita el recorte activado

R8 no puede averiguar por sí mismo qué tipos Java sigue usando tu C#. Esa lista viene del recortador de .NET: después de que se ejecuta ILLink, un paso personalizado escribe proguard_project_references.cfg con una regla keep para cada tipo Java al que se enlaza un tipo administrado que sobrevivió. El target que lo genera está condicionado al recorte:

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

Sin embargo, la decisión de ejecutar R8 no comprueba el recorte. Xamarin.Android.D8.targets solo necesita que la propiedad de ruta esté establecida, y _ResolveAssemblies la establece en cada compilación donde AndroidLinkTool no está vacío:

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

Así que con PublishTrimmed=false (o AndroidLinkMode=None en Release, una solución alternativa común para problemas de reflexión), R8 se ejecuta igual, pero sin el archivo que protege tus enlaces. La compilación solo registra XA4304 (“ProGuard configuration file ‘…proguard_project_references.cfg’ was not found”), y luego la app muere con java.lang.ClassNotFoundException la primera vez que toca un tipo Java que R8 eliminó. Esa secuencia exacta es dotnet/android #6612, donde los mantenedores confirmaron que R8 depende de que el enlazador de .NET esté habilitado.

Por eso también la condición de Release en el archivo de proyecto no es cosmética. Las compilaciones Debug no recortan, así que un AndroidLinkTool=r8 sin condición hace que Debug también ejecute R8 sin el archivo de referencias, y con la implementación rápida activada además obtienes XA0119: “Using fast deployment and a code shrinker at the same time is not recommended”.

Qué archivos de configuración recibe realmente R8

Cuando R8 se ejecuta, el SDK ensambla sus entradas --pg-conf en este orden (elementos _ProguardConfiguration en Xamarin.Android.Common.targets):

  1. $(ProguardConfigFiles), si estableces esa propiedad.
  2. El proguard-android.txt del SDK de Android (base sin optimización). En los SDK más nuevos con AndroidR8ObfuscationMode=private-members, se convierte en proguard-android-optimize.txt.
  3. obj/.../proguard/proguard_xamarin.cfg: reglas keep del runtime para mono.android.**, net.dot.jni.** y similares. En los SDK que se distribuyen hoy, este archivo empieza con -dontobfuscate.
  4. proguard_project_references.cfg: reglas keep para cada tipo Java al que se enlaza un tipo administrado que sobrevivió, generadas después de ILLink.
  5. proguard_project_primary.cfg: una regla -keep class X { *; } por cada Java Callable Wrapper del mapa ACW, de modo que cada Activity, Service y View personalizado que define tu C# sobrevive.
  6. Tus elementos @(ProguardConfiguration).
  7. Las reglas de consumidor (proguard.txt) extraídas de los archivos .aar referenciados.

Los elementos 4 y 5 son la razón por la que una app MAUI rara vez necesita reglas keep escritas a mano para sus propios tipos: la compilación ya sabe a qué clases Java puede llegar el lado administrado. Lo que no puede saber es a qué código llega Java por reflexión.

Por qué hoy obtienes reducción pero no ofuscación

Las opciones de ProGuard son globales. Si cualquier archivo de configuración dice -dontobfuscate, la ofuscación queda desactivada para toda la ejecución de R8, y no existe un flag opuesto que puedas agregar en tu propio proguard.cfg para volver a activarla. Como proguard_xamarin.cfg en 36.1.69 y 37.0.0-rc.1.2257 contiene esa línea, una compilación MAUI con R8 habilitado en cualquiera de los dos SDK reduce y optimiza, pero mantiene intactos todos los nombres Java. El mapping.txt que escribe sigue registrando los miembros eliminados y los cambios de número de línea, pero no mostrará renombrados.

Las mediciones en #12535 coinciden: 34 de 15 235 clases renombradas en el mapping.txt de una app (0.2%), y Play Console reportando un 1% de ofuscación para otra. Establecer AndroidCreateProguardMappingFile=true, como sugieren algunas respuestas, no cambia nada aquí; solo controla si se escribe el archivo de mapeo.

El -dontobfuscate general fue la opción segura. JNI enlaza los pares administrados con las clases Java por nombre, así que renombrar un Java Callable Wrapper o un método de AndroidX enlazado rompería las búsquedas de JNIEnv en tiempo de ejecución. El mismo hilo encontró que eliminar la línea a mano tampoco basta: las reglas keep generadas no protegían los campos que los enlaces leen por nombre a través de JNI, así que las apps fallaban al iniciar. Espera el interruptor soportado que se describe abajo en lugar de parchear la configuración del SDK.

Activar la ofuscación real con AndroidR8ObfuscationMode

dotnet/android #12668, fusionado el 10 de septiembre de 2026, reemplaza la regla general por una selectiva y agrega una propiedad pública:

AndroidR8ObfuscationModeOfuscaciónBase de optimizaciónValor por defecto
disabledninguna, se conservan todos los nombres Javaproguard-android.txtServicio de .NET 10
private-membersse renombran los miembros privados y de paqueteproguard-android-optimize.txt.NET 11 después de RC 1

El mismo día, #12752 lo portó a release/10.0.1xx con disabled como valor por defecto, para que una actualización de servicio no cambie las apps existentes. Ninguna etiqueta publicada lo contiene todavía (36.1.69 es anterior, y release/11.0.1xx-rc1 se creó antes de la fusión), así que espéralo en .NET 11 RC 2 y en la próxima actualización de servicio de .NET 10.

En el modo private-members, la tarea de R8 escribe estas reglas en 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 **

Léelo así: cada clase conserva su nombre, cada miembro público y protegido conserva su nombre, y todo lo privado o de paquete puede renombrarse. El código no usado todavía puede eliminarse. Las reglas de interfaces existen porque la selección de proxies administrados llama a Class.getInterfaces(), algo que R8 no puede ver; sin ellas, la fusión de clases podría descartar una relación de interfaz y entregarle al código administrado el proxy equivocado.

Para activarlo en .NET 10 cuando llegue la versión de servicio, o para desactivarlo en .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>

Cualquier otro valor hace fallar la compilación con XA1050: “The ‘AndroidR8ObfuscationMode’ MSBuild property has an invalid value”. El PR también elimina los interruptores no documentados _AndroidR8DontObfuscate y _AndroidR8DontOptimize, así que quítalos de tu proyecto si los copiaste de algún hilo de issues.

Mantén tus expectativas calibradas. El PR informa que Google Play midió una plantilla dotnet new maui -sc con un 62% de optimización, 65% de reducción y 28% de ofuscación. Eso supera el umbral del 25%, pero la ofuscación es la más ajustada, porque los nombres visibles para JNI no pueden renombrarse. Trata private-members como “suficiente para el requisito de Play”, no como protección para tu lógica en C#.

Escribir reglas keep que realmente importan

R8 solo elimina el código Java que puede demostrar que es inalcanzable. El código para el que necesitas reglas es el código al que se llega de formas que R8 no puede ver:

# 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 la pena agregar temporalmente dos reglas de diagnóstico mientras ajustas esto, porque tu propio archivo ProguardConfiguration se trata como configuración de la aplicación y puede usar opciones globales:

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

R8 resuelve esas rutas relativas respecto de la carpeta del archivo de configuración, así que ambos quedan junto a proguard.cfg. Busca -dontobfuscate en r8-merged.txt para confirmar qué comportamiento de ofuscación aplicó tu SDK, y busca el nombre de una clase en r8-usage.txt para comprobar que R8 la eliminó antes de escribirle una regla. Quita ambas líneas antes de hacer commit, porque agregan tiempo a cada compilación Release.

Trampas que muerden en la práctica

Comprobar que R8 realmente se ejecutó

No confíes solo en la propiedad; confírmalo con la salida de la compilación:

  1. Compila con un registro binario: dotnet publish -f net11.0-android -c Release -bl. Abre msbuild.binlog en el MSBuild Structured Log Viewer y busca la tarea R8 bajo _CompileToDalvik. Si solo encuentras D8, la propiedad nunca llegó a la compilación de Android, normalmente porque su condición no coincide con tu TargetFramework. Si R8 se ejecutó pero el registro contiene XA4304 para proguard_project_references.cfg, el recorte está desactivado y la app fallará en tiempo de ejecución.
  2. Comprueba que bin/Release/net11.0-android/mapping.txt existe y tiene una marca de tiempo reciente.
  3. Abre obj/Release/net11.0-android/android-arm64/proguard/proguard_xamarin.cfg (la carpeta RID exacta depende de tus RuntimeIdentifiers). En 36.1.69 o 37.0.0-rc.1.2257 empieza con -dontobfuscate. En un SDK con AndroidR8ObfuscationMode=private-members, empieza en cambio con el bloque -keep,allowshrinking,allowoptimization class **.
  4. Sube el .aab a un canal de pruebas internas y lee los porcentajes de optimización, reducción y ofuscación en el explorador de app bundles de Play Console. Ese es el número que Google exige, así que es el que debes vigilar. Play lee los porcentajes de un archivo de metadatos de compilación r8.json cuando el bundle lo tiene, y si no, los estima a partir de mapping.txt. El SDK empieza a empaquetar r8.json con dotnet/android #12646, que está en release/10.0.1xx y main pero no en 37.0.0-rc.1.2257.

Lecturas relacionadas

Fuentes

Comments

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

< Volver