Problem
The trimmable typemap currently generates one _Foo.TypeMap.dll per source assembly, but the generation is not truly incremental because of cross-assembly aliases.
The TypeMapping.GetOrCreateExternalTypeMapping<T>() API from dotnet/runtime collects [assembly: TypeMap<T>(key, type)] attributes across all assemblies into a single flat IReadOnlyDictionary<string, Type>. Duplicate keys are not allowed. When multiple assemblies bind different C# types to the same Java class (e.g., Java.Lang.Object in Mono.Android and JavaObject in Java.Interop both map to java/lang/Object), we must avoid key collisions.
Today this is handled by MergeCrossAssemblyAliases (in #11177), which:
- Scans all assemblies globally to find JNI name collisions
- Moves peers from one assembly's group into another's
- Generates alias holders with indexed keys (
java/lang/Object[0], java/lang/Object[1])
- Creates cross-assembly type references between typemap DLLs
This means any assembly change can potentially trigger regeneration of other assemblies' typemap DLLs, defeating incrementality. Cross-assembly aliases are the common case (not the exception), so this affects most builds.
Proposed solution
Debug builds: Per-assembly typemap universes — each assembly gets its own isolated dictionary, fully incremental.
Release builds: Single merged typemap universe — optimal runtime performance, no iteration overhead.
Both modes are abstracted behind the same ITypeMapWithAliasing interface, so the runtime's TrimmableTypeMap consumes them uniformly.
Build time: per-assembly universes (Debug)
Each typemap DLL generates an anchor type and uses it as the TypeMap<T> group parameter:
// _Mono.Android.TypeMap.dll
internal class __TypeMapAnchor {}
[assembly: TypeMap<__TypeMapAnchor>("java/lang/Object", typeof(Java_Lang_Object_Proxy))]
// _Java.Interop.TypeMap.dll
internal class __TypeMapAnchor {} // different assembly, different type
[assembly: TypeMap<__TypeMapAnchor>("java/lang/Object", typeof(JavaObject_Proxy))]
Same key in different dictionaries = no collision. Each assembly is self-contained. Changing one assembly never invalidates another's typemap DLL.
Build time: merged universe (Release)
For Release builds, the generator runs the full merge and emits all mappings under a single generated anchor type in the root assembly:
// All typemap DLLs share the root assembly's anchor
[assembly: TypeMap<_Microsoft_Android_TypeMaps.__TypeMapAnchor>("java/lang/Object", typeof(Java_Lang_Object_Proxy))]
[assembly: TypeMap<_Microsoft_Android_TypeMaps.__TypeMapAnchor>("java/lang/Object[0]", typeof(JavaObject_Proxy))] // alias
Each individual typemap assembly does not know whether it's part of a single-universe or multi-universe build. It always emits its own __TypeMapAnchor type and uses it as the group parameter. In Release mode, the generator consolidates all mappings under the root assembly's anchor after merging cross-assembly aliases.
Root assembly as a startup hook
_Microsoft.Android.TypeMaps.dll acts as a startup hook. The build sets DOTNET_STARTUP_HOOKS=_Microsoft.Android.TypeMaps.
Initialization ordering: JNIEnvInit must call RunStartupHooksIfNeeded() before TrimmableTypeMap.Initialize(). Today the order is reversed — TrimmableTypeMap.Initialize() runs first (line 166), then startup hooks (line 186). We must swap them so the startup hook can register the ITypeMapWithAliasing instance before TrimmableTypeMap.Initialize() creates the singleton and calls RegisterNatives.
The root assembly's Initialize() method constructs the appropriate ITypeMapWithAliasing and registers it:
// Generated _Microsoft.Android.TypeMaps.dll
internal class __TypeMapAnchor {} // root assembly's own anchor for Release merged universe
internal static class StartupHook
{
static void Initialize ()
{
// Debug: per-assembly universes — each assembly has its own anchor
var universes = new SingleUniverseTypeMap[] {
new (TypeMapping.GetOrCreateExternalTypeMapping<_Mono_Android_TypeMap.__TypeMapAnchor> (),
TypeMapping.GetOrCreateProxyTypeMapping<_Mono_Android_TypeMap.__TypeMapAnchor> ()),
new (TypeMapping.GetOrCreateExternalTypeMapping<_Java_Interop_TypeMap.__TypeMapAnchor> (),
TypeMapping.GetOrCreateProxyTypeMapping<_Java_Interop_TypeMap.__TypeMapAnchor> ()),
// ... one per assembly
};
TrimmableTypeMap.SetTypeMap (new AggregateTypeMap (universes));
// Release: single merged universe — root assembly's anchor
// TrimmableTypeMap.SetTypeMap (new SingleUniverseTypeMap (
// TypeMapping.GetOrCreateExternalTypeMapping<__TypeMapAnchor> (),
// TypeMapping.GetOrCreateProxyTypeMapping<__TypeMapAnchor> ()));
}
}
MSBuild enables startup hooks automatically when _AndroidTypeMapImplementation=trimmable:
- Sets
StartupHookSupport=true
- Adds
_Microsoft.Android.TypeMaps to DOTNET_STARTUP_HOOKS / STARTUP_HOOKS
Alias-aware type map abstraction
Both Debug and Release builds need alias resolution (intra-assembly in Debug, cross-assembly in Release). Instead of leaking alias logic into TrimmableTypeMap, we introduce a shared abstraction:
/// <summary>
/// Resolves types mapped to a JNI name (including aliases) and proxy types
/// for managed types. Abstracts single-universe and multi-universe topologies.
/// </summary>
interface ITypeMapWithAliasing
{
/// <summary>
/// Returns all types mapped to a JNI name, resolving alias holders.
/// For non-alias entries, returns a single type. For alias groups,
/// returns all aliased types. Across universes, flattens results.
/// </summary>
IEnumerable<Type> GetTypes (string jniName);
/// <summary>
/// Tries to get the proxy type for a managed type.
/// First-wins across universes (each managed type exists in one assembly).
/// </summary>
bool TryGetProxyType (Type managedType, [NotNullWhen (true)] out Type? proxyType);
}
SingleUniverseTypeMap — wraps one IReadOnlyDictionary<string, Type> (one TypeMapLazyDictionary) and handles alias resolution within that universe:
/// <summary>
/// Used in Release for the single merged universe, and in Debug for each
/// per-assembly universe. Resolves direct entries and alias holders.
/// </summary>
class SingleUniverseTypeMap : ITypeMapWithAliasing
{
readonly IReadOnlyDictionary<string, Type> _typeMap;
readonly IReadOnlyDictionary<Type, Type> _proxyTypeMap;
public IEnumerable<Type> GetTypes (string jniName)
{
if (!_typeMap.TryGetValue (jniName, out var mappedType))
yield break;
// Direct proxy entry
var proxy = mappedType.GetCustomAttribute<JavaPeerProxy> (inherit: false);
if (proxy is not null) {
yield return mappedType;
yield break;
}
// Alias holder — resolve each alias key within this universe
var aliases = mappedType.GetCustomAttribute<JavaPeerAliasesAttribute> (inherit: false);
if (aliases is null)
yield break;
foreach (var key in aliases.Aliases) {
if (_typeMap.TryGetValue (key, out var aliasEntryType))
yield return aliasEntryType;
}
}
public bool TryGetProxyType (Type managedType, [NotNullWhen (true)] out Type? proxyType)
{
if (_proxyTypeMap.TryGetValue (managedType, out proxyType))
return true;
proxyType = null;
return false;
}
}
AggregateTypeMap (Debug only) — wraps N SingleUniverseTypeMap instances and flattens results:
/// <summary>
/// Wraps N per-assembly SingleUniverseTypeMaps and flattens their results.
/// Each universe is self-contained (no cross-assembly aliases), but the same
/// JNI name can exist in multiple universes.
/// </summary>
class AggregateTypeMap : ITypeMapWithAliasing
{
readonly SingleUniverseTypeMap[] _universes;
public IEnumerable<Type> GetTypes (string jniName)
{
foreach (var universe in _universes) {
foreach (var type in universe.GetTypes (jniName))
yield return type;
}
}
public bool TryGetProxyType (Type managedType, [NotNullWhen (true)] out Type? proxyType)
{
// First-wins: each managed type exists in exactly one assembly
foreach (var universe in _universes) {
if (universe.TryGetProxyType (managedType, out proxyType))
return true;
}
proxyType = null;
return false;
}
}
TrimmableTypeMap consumes ITypeMapWithAliasing uniformly — no conditional logic:
readonly ITypeMapWithAliasing _typeMap;
// Release: SingleUniverseTypeMap (single merged dict)
// Debug: AggregateTypeMap (N per-assembly dicts)
Key simplification: TrimmableTypeMap no longer needs to know about aliasing at all. Today, GetProxiesForJniName manually checks for JavaPeerAliasesAttribute, resolves alias keys, and iterates indexed entries. With ITypeMapWithAliasing, all alias resolution is encapsulated in SingleUniverseTypeMap.GetTypes(). TrimmableTypeMap.GetProxiesForJniName becomes a simple loop:
JavaPeerProxy[] GetProxiesForJniName (string jniName)
{
return _jniProxyCache.GetOrAdd (jniName, static (name, self) => {
var result = new List<JavaPeerProxy> ();
foreach (var type in self._typeMap.GetTypes (name)) {
var proxy = type.GetCustomAttribute<JavaPeerProxy> (inherit: false);
if (proxy is not null)
result.Add (proxy);
}
return result.Count > 0 ? result.ToArray () : [];
}, this);
}
Similarly, GetProxyForManagedType simplifies to just calling _typeMap.TryGetProxyType() — no more JavaPeerAliasesAttribute fallback path, no GetProxyFromAliases helper.
The existing _jniProxyCache and _proxyCache in TrimmableTypeMap continue to cache resolved results, so ITypeMapWithAliasing methods are only called once per key.
crossgen2 / ReadyToRun opportunity
Individual per-assembly typemap DLLs are excellent candidates for ahead-of-time compilation via crossgen2. Since each universe is self-contained, it can be independently precompiled — the TypeMapLazyDictionary backing each universe becomes a fast R2R lookup even in Debug builds.
This aligns with and supersedes #10792 (precompiled typemap assemblies). The per-assembly split naturally creates small, stable compilation units that benefit from R2R. Combined with dotnet/runtime#124352 (TypeMap / assembly-level metadata R2R improvements), Debug-build typemap lookups can approach Release-build performance.
What changes
| Component |
Debug |
Release |
MergeCrossAssemblyAliases |
Not used — each assembly is self-contained |
Used (as today) |
| Per-assembly codegen |
Fully incremental — own anchor type, no cross-assembly refs |
Global merge, then split |
| Root assembly |
Startup hook → AggregateTypeMap from N universes |
Startup hook → SingleUniverseTypeMap from merged dict |
Runtime (TrimmableTypeMap) |
Receives ITypeMapWithAliasing via SetTypeMap() |
Receives ITypeMapWithAliasing via SetTypeMap() |
| Cross-assembly alias holders |
Not needed |
Used for collisions |
| Intra-assembly aliases |
Unchanged (handled by ModelBuilder) |
Unchanged |
What stays the same
- Proxy types, UCO wrappers, RegisterNatives — already per-assembly
- Intra-assembly alias holders — already handled by
ModelBuilder
- Trimming — each universe is independently trimmable
Files to update
src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs — conditional merge (Release only), per-assembly anchors (Debug)
src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs — emit anchor type per assembly
src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/RootTypeMapAssemblyGenerator.cs — emit startup hook Initialize() method that constructs AggregateTypeMap (Debug) or SingleUniverseTypeMap (Release) and calls TrimmableTypeMap.SetTypeMap()
src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs — add SetTypeMap(ITypeMapWithAliasing) static method; consume ITypeMapWithAliasing instead of raw IReadOnlyDictionary; alias resolution moves into SingleUniverseTypeMap
src/Mono.Android/Microsoft.Android.Runtime/ITypeMapWithAliasing.cs — new: interface + SingleUniverseTypeMap + AggregateTypeMap
src/Mono.Android/Android.Runtime/JNIEnvInit.cs — swap initialization order: RunStartupHooksIfNeeded() must run before TrimmableTypeMap.Initialize()
- MSBuild targets — pass configuration to generator, control merge vs per-assembly mode, enable
StartupHookSupport=true and set DOTNET_STARTUP_HOOKS=_Microsoft.Android.TypeMaps for trimmable typemap
Related issues
Open questions
- Anchor type naming: Generate
__TypeMapAnchor in each typemap DLL? Or use a convention based on source assembly name?
- Incremental Release builds: Could we detect "no cross-assembly collisions changed" and skip the merge? Or is the merge cheap enough that it doesn't matter for Release?
Part of #10788
Problem
The trimmable typemap currently generates one
_Foo.TypeMap.dllper source assembly, but the generation is not truly incremental because of cross-assembly aliases.The
TypeMapping.GetOrCreateExternalTypeMapping<T>()API from dotnet/runtime collects[assembly: TypeMap<T>(key, type)]attributes across all assemblies into a single flatIReadOnlyDictionary<string, Type>. Duplicate keys are not allowed. When multiple assemblies bind different C# types to the same Java class (e.g.,Java.Lang.Objectin Mono.Android andJavaObjectin Java.Interop both map tojava/lang/Object), we must avoid key collisions.Today this is handled by
MergeCrossAssemblyAliases(in #11177), which:java/lang/Object[0],java/lang/Object[1])This means any assembly change can potentially trigger regeneration of other assemblies' typemap DLLs, defeating incrementality. Cross-assembly aliases are the common case (not the exception), so this affects most builds.
Proposed solution
Debug builds: Per-assembly typemap universes — each assembly gets its own isolated dictionary, fully incremental.
Release builds: Single merged typemap universe — optimal runtime performance, no iteration overhead.
Both modes are abstracted behind the same
ITypeMapWithAliasinginterface, so the runtime'sTrimmableTypeMapconsumes them uniformly.Build time: per-assembly universes (Debug)
Each typemap DLL generates an anchor type and uses it as the
TypeMap<T>group parameter:Same key in different dictionaries = no collision. Each assembly is self-contained. Changing one assembly never invalidates another's typemap DLL.
Build time: merged universe (Release)
For Release builds, the generator runs the full merge and emits all mappings under a single generated anchor type in the root assembly:
Each individual typemap assembly does not know whether it's part of a single-universe or multi-universe build. It always emits its own
__TypeMapAnchortype and uses it as the group parameter. In Release mode, the generator consolidates all mappings under the root assembly's anchor after merging cross-assembly aliases.Root assembly as a startup hook
_Microsoft.Android.TypeMaps.dllacts as a startup hook. The build setsDOTNET_STARTUP_HOOKS=_Microsoft.Android.TypeMaps.Initialization ordering:
JNIEnvInitmust callRunStartupHooksIfNeeded()beforeTrimmableTypeMap.Initialize(). Today the order is reversed —TrimmableTypeMap.Initialize()runs first (line 166), then startup hooks (line 186). We must swap them so the startup hook can register theITypeMapWithAliasinginstance beforeTrimmableTypeMap.Initialize()creates the singleton and callsRegisterNatives.The root assembly's
Initialize()method constructs the appropriateITypeMapWithAliasingand registers it:MSBuild enables startup hooks automatically when
_AndroidTypeMapImplementation=trimmable:StartupHookSupport=true_Microsoft.Android.TypeMapstoDOTNET_STARTUP_HOOKS/STARTUP_HOOKSAlias-aware type map abstraction
Both Debug and Release builds need alias resolution (intra-assembly in Debug, cross-assembly in Release). Instead of leaking alias logic into
TrimmableTypeMap, we introduce a shared abstraction:SingleUniverseTypeMap— wraps oneIReadOnlyDictionary<string, Type>(oneTypeMapLazyDictionary) and handles alias resolution within that universe:AggregateTypeMap(Debug only) — wraps NSingleUniverseTypeMapinstances and flattens results:TrimmableTypeMapconsumesITypeMapWithAliasinguniformly — no conditional logic:Key simplification:
TrimmableTypeMapno longer needs to know about aliasing at all. Today,GetProxiesForJniNamemanually checks forJavaPeerAliasesAttribute, resolves alias keys, and iterates indexed entries. WithITypeMapWithAliasing, all alias resolution is encapsulated inSingleUniverseTypeMap.GetTypes().TrimmableTypeMap.GetProxiesForJniNamebecomes a simple loop:Similarly,
GetProxyForManagedTypesimplifies to just calling_typeMap.TryGetProxyType()— no moreJavaPeerAliasesAttributefallback path, noGetProxyFromAliaseshelper.The existing
_jniProxyCacheand_proxyCacheinTrimmableTypeMapcontinue to cache resolved results, soITypeMapWithAliasingmethods are only called once per key.crossgen2 / ReadyToRun opportunity
Individual per-assembly typemap DLLs are excellent candidates for ahead-of-time compilation via crossgen2. Since each universe is self-contained, it can be independently precompiled — the
TypeMapLazyDictionarybacking each universe becomes a fast R2R lookup even in Debug builds.This aligns with and supersedes #10792 (precompiled typemap assemblies). The per-assembly split naturally creates small, stable compilation units that benefit from R2R. Combined with dotnet/runtime#124352 (TypeMap / assembly-level metadata R2R improvements), Debug-build typemap lookups can approach Release-build performance.
What changes
MergeCrossAssemblyAliasesAggregateTypeMapfrom N universesSingleUniverseTypeMapfrom merged dictTrimmableTypeMap)ITypeMapWithAliasingviaSetTypeMap()ITypeMapWithAliasingviaSetTypeMap()ModelBuilder)What stays the same
ModelBuilderFiles to update
src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs— conditional merge (Release only), per-assembly anchors (Debug)src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs— emit anchor type per assemblysrc/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/RootTypeMapAssemblyGenerator.cs— emit startup hookInitialize()method that constructsAggregateTypeMap(Debug) orSingleUniverseTypeMap(Release) and callsTrimmableTypeMap.SetTypeMap()src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs— addSetTypeMap(ITypeMapWithAliasing)static method; consumeITypeMapWithAliasinginstead of rawIReadOnlyDictionary; alias resolution moves intoSingleUniverseTypeMapsrc/Mono.Android/Microsoft.Android.Runtime/ITypeMapWithAliasing.cs— new: interface +SingleUniverseTypeMap+AggregateTypeMapsrc/Mono.Android/Android.Runtime/JNIEnvInit.cs— swap initialization order:RunStartupHooksIfNeeded()must run beforeTrimmableTypeMap.Initialize()StartupHookSupport=trueand setDOTNET_STARTUP_HOOKS=_Microsoft.Android.TypeMapsfor trimmable typemapRelated issues
TypeMappingAPI forTypeMap<T>/TypeMapAssociation<T>attribute-based dictionariesOpen questions
__TypeMapAnchorin each typemap DLL? Or use a convention based on source assembly name?Part of #10788