Background
The trimmable typemap currently relies on JavaPeerContainerFactory<T> to provide AOT-safe creation of JavaList<T>, JavaCollection<T>, JavaDictionary<K, V>, and T[] instances. The factory is auto-instantiated by every non-generic peer's proxy via the JavaPeerProxy<T>.GetContainerFactory() base method:
// src/Mono.Android/Java.Interop/JavaPeerProxy.cs
public abstract class JavaPeerProxy<T> : JavaPeerProxy where T : class, IJavaPeerable
{
public override JavaPeerContainerFactory GetContainerFactory ()
=> JavaPeerContainerFactory<T>.Instance;
}
This forces a unique JavaPeerContainerFactory<T> instantiation per peer type T, even for peer types never used as collection element types. Each instantiation also pulls in:
new T[length], new T[length][], new T[length][][] (ranks 1-3)
new JavaList<T>(...)
new JavaCollection<T>(...)
new JavaDictionary<T, *>(...)
For a typical .NET MAUI app with ~2000 binding types, this is ~2000 instantiations of the factory plus the cascading container generic instantiations — substantial NativeAOT app size impact.
Active call sites (3)
JavaConvert.TryGetFactoryBasedConverter — marshaling IList<T> / ICollection<T> / IDictionary<K, V> from JNI handles
JNIEnv.ArrayCreateInstance — creating T[] arrays for JNI marshaling
JavaPeerProxy<T>.GetContainerFactory() — root cause of bloat (auto-implementation in base class)
Removal plan
Phase 1: Replace CreateList/CreateCollection/CreateDictionary with ActivateUsingReflection
The ActivateUsingReflection helper added in #11225 already handles closed generic activation via reflection with [DynamicallyAccessedMembers(Constructors)] flowing to the closed types. Use it for the 3 collection methods.
Caveat: MakeGenericType(typeof(JavaList<>), elementType) is not AOT-safe.
Solution: Use the trimmable typemap to look up the closed JavaList<T> type for known element types, then call ActivateUsingReflection on it.
Removes: CreateList, CreateCollection, CreateDictionary, CreateDictionaryWithValueFactory from JavaPeerContainerFactory<T>. Eliminates ~75% of the per-type bloat.
Phase 2: Array types — runtime fork between CoreCLR and NativeAOT
Tracking PR: #11238 (currently in scaffolding state pending the .NET SDK bump that brings dotnet/runtime#126380 — ships in 11.0.100-preview.5.26228.123).
Design
JNIEnv.ArrayCreateInstance branches on RuntimeFeature.IsDynamicCodeSupported:
static Array ArrayCreateInstance(Type elementType, int length)
{
if (!RuntimeFeature.TrimmableTypeMap)
return Array.CreateInstance(elementType, length); // legacy fallback unchanged
if (RuntimeFeature.IsDynamicCodeSupported) {
// CoreCLR / Mono — runtime type loader can construct any T[] dynamically.
// No typemap roundtrip; unlimited array rank.
return Array.CreateInstance(elementType, length);
}
// NativeAOT — opaque from the trimmer's POV, so we resolve the closed array
// type through the typemap and use AOT-safe Array.CreateInstanceFromArrayType.
if (TrimmableTypeMap.Instance.TryGetArrayType(elementType, out var arrayType))
return Array.CreateInstanceFromArrayType(arrayType, length);
throw new NotSupportedException(
$"No TrimmableTypeMap array entry for element type '{elementType}'. " +
"Add an [assembly: TypeMap] entry or report an issue.");
}
Why a runtime fork. The two trimmers have very different array-shape semantics:
| Trimmer |
Array-shape tracking |
Implication for typemap design |
| ILC (NativeAOT) |
Per-TypeDesc — string[], string[][] are distinct nodes. Per-shape conditional dependencies via NecessaryTypeSymbol. |
Speculative emission works correctly: T[] entry is dropped iff T[] is never constructed in IL. |
| ILLink (CoreCLR PublishTrimmed) |
Per-TypeDefinition. UnwrapToResolvableType strips array wrappers down to the element TypeDef before tracking. |
Speculative T[] entry is alive whenever T is reachable, regardless of whether T[] is constructed. 3× per-peer attribute cost with no opportunity to cull. |
CoreCLR has a runtime type loader that can construct arbitrary T[] dynamically, so the typemap roundtrip is unnecessary work. NativeAOT cannot construct arbitrary T[] for value-type elements (and the JNI-marshaling call sites are opaque to the trimmer either way), so the typemap is the only AOT-safe route.
Generator changes
Emit array-shape entries only when the build targets NativeAOT ($(PublishAot) == true). Use a dedicated TypeMap<TGroup> per rank, keyed by the element JNI name (no [L<jni>; prefix construction at runtime):
// In _<App>.TypeMap.dll, only when PublishAot=true:
internal sealed class __ArrayMapRank1 { }
internal sealed class __ArrayMapRank2 { }
internal sealed class __ArrayMapRank3 { }
// One trio per non-aliased, non-generic peer:
[assembly: TypeMap<__ArrayMapRank1>("java/lang/String", typeof(string[]), typeof(string[]))]
[assembly: TypeMap<__ArrayMapRank2>("java/lang/String", typeof(string[][]), typeof(string[][]))]
[assembly: TypeMap<__ArrayMapRank3>("java/lang/String", typeof(string[][][]), typeof(string[][][]))]
-
Trim target = the closed array type itself (3rd ctor arg). ILC drops the entry when that array shape is never constructed — exact per-shape trimming, validated with disjoint type sets at ~/Projects/playground/TestTypeMapArrays:
| Element-type usage in app |
T[] typemap entry under NativeAOT |
Reference type used scalar only (new T()) |
dropped ✅ |
Reference type used as new T[…] |
kept |
| Value type used scalar only |
dropped ✅ |
Value type used as new T[…] |
kept |
| Type never referenced anywhere |
dropped |
Per-shape trimming works for both reference and value-type elements — the test had to use disjoint type sets per group to get clean results (sharing types across multiple groups can cross-pollinate the trim graph and inflate kept-set).
-
Element-only JNI keys avoid the "[" + "L" + jni + ";" runtime concat. The JNI name is already cached by the existing _proxyCache lookup.
-
Three separate groups per rank instead of a single composite key. Lookup is a switch on rank inside TryGetArrayType; rank is recursion depth from elementType.IsArray walk.
-
Skip rules unchanged from the speculative-emission proposal: open generics (typeof(T<>[]) is invalid), JNI primitive-keyword keys (Z, B, …; primitives go through the legacy GetPrimitiveArrayTypesForSimpleReference path), alias groups (would produce duplicate keys; deferred).
CoreCLR-targeted builds emit no array entries at all — saves ~900 KB of attribute metadata in the typemap assembly for a typical MAUI app.
Runtime helper
TrimmableTypeMap.TryGetArrayType(Type elementType, out Type? arrayType):
- Walk down
elementType.IsArray / GetElementType() to find the leaf element type and the array depth (byte[][] → leaf byte, depth 2; depth + 1 = total rank requested).
- Resolve the leaf JNI element name:
- Primitive → static dictionary (
bool→"Z", byte→"B", char→"C", short→"S", int→"I", long→"J", float→"F", double→"D").
- Reference →
TryGetJniNameForManagedType(leaf, out var jni).
- Switch on rank to pick the right
__ArrayMapRank{N} dictionary.
dict.TryGetValue(elementJni, out arrayType).
PR #11238 already ships scaffolding for this (the new ITypeMapWithAliasing.TryGetType, TrimmableTypeMap.TryGetArrayType shell, primitive-encoding helper) so the eventual full implementation only needs to flip a few switches.
Considered alternatives
Proxy map (TypeMapAssociationAttribute<TGroup>) keyed by element Type → array Type. Cleaner runtime API (Type → Type direct, no TryGetJniNameForManagedType indirection) and the generator wouldn't need to compute element JNI names for these entries. Rejected because it under-trims: ProxyTypeMapNode.GetConditionalStaticDependencies conditions on MaximallyConstructableType(key) (the element type), so the array entry is alive whenever the element type is reachable. Disjoint-set NativeAOT test:
Usage of element T |
External map (TypeMap<G>, trim target = T[]) |
Proxy map (TypeMapAssociation<G>, trim trigger = T) |
| Ref scalar only |
T[] dropped |
T[] kept |
| Ref scalar + array |
T[] kept |
T[] kept |
| Struct scalar only |
T[] dropped |
T[] kept |
| Struct scalar + array |
T[] kept |
T[] kept |
| Never referenced |
T[] dropped |
T[] dropped (refs) / kept (structs — typeof in attribute marks MaximallyConstructable for value types) |
For Mono.Android peer types specifically (all reference types, many constructed scalarly without ever appearing as T[]), the proxy-map approach would keep ~3× more entries alive than the external map. The runtime API simplification is real but doesn't justify the size regression.
Single composite-key external map ("[L<jni>;", "[[L<jni>;", …) in one group. Rejected — same trimming behaviour as the per-rank groups, but pays a string.Concat at every JNIEnv.ArrayCreateInstance call site. Per-rank groups eliminate the concat by making the rank a switch dispatch instead of part of the key.
Scanner-based emission (only emit array entries for peers actually used as T[] in binding signatures). Tighter result than speculative emission. Deferred — requires the metadata signature walker we skipped earlier (rubber-duck noted in the original PR planning). Layerable on top of the speculative design without breaking the runtime contract; planned as a follow-up if app-size measurements show the speculative cost is too high.
Why this is unblocked from the recent ILLink fix
dotnet/runtime#126380 (closing dotnet/runtime#126177) fixes TypeMapHandler.RecordTypeMapEntry / MarkTypeMapAttribute so they no longer crash on Mono.Cecil.ArrayType. Without this fix, ILLink throws NotSupportedException: TypeDefinition cannot be resolved from 'Mono.Cecil.ArrayType' type and the build fails. The fix ships in .NET 11 nightly 11.0.100-preview.5.26228.123 and later. PR #11238 is gated on dotnet/android picking up an SDK with this fix.
Note: even with #126380, ILLink still trims at element-type granularity (per the design choice in that PR). That's why we use a runtime fork and emit array entries only for NativeAOT builds — there's no benefit to paying the attribute cost when the consumer (CoreCLR) can do Array.CreateInstance natively anyway.
Removed by Phase 2
JavaPeerContainerFactory<T>.CreateArray and CreateHigherRankArray (and the abstract base method). The CoreCLR branch (Array.CreateInstance) replaces both — and additionally supports unlimited rank because the runtime type loader can construct any T[N] dynamically. NativeAOT remains capped at the emitted ranks (1–3).
Phase 3: Delete JavaPeerContainerFactory
Once Phase 1 and Phase 2 are done:
- Delete
JavaPeerContainerFactory and JavaPeerContainerFactory<T>
- Delete
JavaPeerProxy.GetContainerFactory() virtual + JavaPeerProxy<T> override
- Generated proxies no longer instantiate
JavaPeerContainerFactory<T>
- All per-type bloat eliminated
Action items
Related
Background
The trimmable typemap currently relies on
JavaPeerContainerFactory<T>to provide AOT-safe creation ofJavaList<T>,JavaCollection<T>,JavaDictionary<K, V>, andT[]instances. The factory is auto-instantiated by every non-generic peer's proxy via theJavaPeerProxy<T>.GetContainerFactory()base method:This forces a unique
JavaPeerContainerFactory<T>instantiation per peer type T, even for peer types never used as collection element types. Each instantiation also pulls in:new T[length],new T[length][],new T[length][][](ranks 1-3)new JavaList<T>(...)new JavaCollection<T>(...)new JavaDictionary<T, *>(...)For a typical .NET MAUI app with ~2000 binding types, this is ~2000 instantiations of the factory plus the cascading container generic instantiations — substantial NativeAOT app size impact.
Active call sites (3)
JavaConvert.TryGetFactoryBasedConverter— marshalingIList<T>/ICollection<T>/IDictionary<K, V>from JNI handlesJNIEnv.ArrayCreateInstance— creatingT[]arrays for JNI marshalingJavaPeerProxy<T>.GetContainerFactory()— root cause of bloat (auto-implementation in base class)Removal plan
Phase 1: Replace
CreateList/CreateCollection/CreateDictionarywithActivateUsingReflectionThe
ActivateUsingReflectionhelper added in #11225 already handles closed generic activation via reflection with[DynamicallyAccessedMembers(Constructors)]flowing to the closed types. Use it for the 3 collection methods.Caveat:
MakeGenericType(typeof(JavaList<>), elementType)is not AOT-safe.Solution: Use the trimmable typemap to look up the closed
JavaList<T>type for known element types, then callActivateUsingReflectionon it.Removes:
CreateList,CreateCollection,CreateDictionary,CreateDictionaryWithValueFactoryfromJavaPeerContainerFactory<T>. Eliminates ~75% of the per-type bloat.Phase 2: Array types — runtime fork between CoreCLR and NativeAOT
Tracking PR: #11238 (currently in scaffolding state pending the .NET SDK bump that brings dotnet/runtime#126380 — ships in
11.0.100-preview.5.26228.123).Design
JNIEnv.ArrayCreateInstancebranches onRuntimeFeature.IsDynamicCodeSupported:Why a runtime fork. The two trimmers have very different array-shape semantics:
TypeDesc—string[],string[][]are distinct nodes. Per-shape conditional dependencies viaNecessaryTypeSymbol.T[]entry is dropped iffT[]is never constructed in IL.TypeDefinition.UnwrapToResolvableTypestrips array wrappers down to the element TypeDef before tracking.T[]entry is alive wheneverTis reachable, regardless of whetherT[]is constructed. 3× per-peer attribute cost with no opportunity to cull.CoreCLR has a runtime type loader that can construct arbitrary
T[]dynamically, so the typemap roundtrip is unnecessary work. NativeAOT cannot construct arbitraryT[]for value-type elements (and the JNI-marshaling call sites are opaque to the trimmer either way), so the typemap is the only AOT-safe route.Generator changes
Emit array-shape entries only when the build targets NativeAOT (
$(PublishAot) == true). Use a dedicatedTypeMap<TGroup>per rank, keyed by the element JNI name (no[L<jni>;prefix construction at runtime):Trim target = the closed array type itself (3rd ctor arg). ILC drops the entry when that array shape is never constructed — exact per-shape trimming, validated with disjoint type sets at
~/Projects/playground/TestTypeMapArrays:T[]typemap entry under NativeAOTnew T())new T[…]new T[…]Per-shape trimming works for both reference and value-type elements — the test had to use disjoint type sets per group to get clean results (sharing types across multiple groups can cross-pollinate the trim graph and inflate kept-set).
Element-only JNI keys avoid the
"[" + "L" + jni + ";"runtime concat. The JNI name is already cached by the existing_proxyCachelookup.Three separate groups per rank instead of a single composite key. Lookup is a switch on rank inside
TryGetArrayType; rank is recursion depth fromelementType.IsArraywalk.Skip rules unchanged from the speculative-emission proposal: open generics (
typeof(T<>[])is invalid), JNI primitive-keyword keys (Z,B, …; primitives go through the legacyGetPrimitiveArrayTypesForSimpleReferencepath), alias groups (would produce duplicate keys; deferred).CoreCLR-targeted builds emit no array entries at all — saves ~900 KB of attribute metadata in the typemap assembly for a typical MAUI app.
Runtime helper
TrimmableTypeMap.TryGetArrayType(Type elementType, out Type? arrayType):elementType.IsArray/GetElementType()to find the leaf element type and the array depth (byte[][]→ leafbyte, depth 2; depth + 1 = total rank requested).bool→"Z",byte→"B",char→"C",short→"S",int→"I",long→"J",float→"F",double→"D").TryGetJniNameForManagedType(leaf, out var jni).__ArrayMapRank{N}dictionary.dict.TryGetValue(elementJni, out arrayType).PR #11238 already ships scaffolding for this (the new
ITypeMapWithAliasing.TryGetType,TrimmableTypeMap.TryGetArrayTypeshell, primitive-encoding helper) so the eventual full implementation only needs to flip a few switches.Considered alternatives
Proxy map (
TypeMapAssociationAttribute<TGroup>) keyed by elementType→ arrayType. Cleaner runtime API (Type → Typedirect, noTryGetJniNameForManagedTypeindirection) and the generator wouldn't need to compute element JNI names for these entries. Rejected because it under-trims:ProxyTypeMapNode.GetConditionalStaticDependenciesconditions onMaximallyConstructableType(key)(the element type), so the array entry is alive whenever the element type is reachable. Disjoint-set NativeAOT test:TTypeMap<G>, trim target =T[])TypeMapAssociation<G>, trim trigger =T)T[]droppedT[]keptT[]keptT[]keptT[]droppedT[]keptT[]keptT[]keptT[]droppedT[]dropped (refs) / kept (structs — typeof in attribute marksMaximallyConstructablefor value types)For Mono.Android peer types specifically (all reference types, many constructed scalarly without ever appearing as
T[]), the proxy-map approach would keep ~3× more entries alive than the external map. The runtime API simplification is real but doesn't justify the size regression.Single composite-key external map (
"[L<jni>;","[[L<jni>;", …) in one group. Rejected — same trimming behaviour as the per-rank groups, but pays astring.Concatat everyJNIEnv.ArrayCreateInstancecall site. Per-rank groups eliminate the concat by making the rank a switch dispatch instead of part of the key.Scanner-based emission (only emit array entries for peers actually used as
T[]in binding signatures). Tighter result than speculative emission. Deferred — requires the metadata signature walker we skipped earlier (rubber-duck noted in the original PR planning). Layerable on top of the speculative design without breaking the runtime contract; planned as a follow-up if app-size measurements show the speculative cost is too high.Why this is unblocked from the recent ILLink fix
dotnet/runtime#126380 (closing dotnet/runtime#126177) fixes
TypeMapHandler.RecordTypeMapEntry/MarkTypeMapAttributeso they no longer crash onMono.Cecil.ArrayType. Without this fix, ILLink throwsNotSupportedException: TypeDefinition cannot be resolved from 'Mono.Cecil.ArrayType' typeand the build fails. The fix ships in .NET 11 nightly11.0.100-preview.5.26228.123and later. PR #11238 is gated on dotnet/android picking up an SDK with this fix.Note: even with #126380, ILLink still trims at element-type granularity (per the design choice in that PR). That's why we use a runtime fork and emit array entries only for NativeAOT builds — there's no benefit to paying the attribute cost when the consumer (CoreCLR) can do
Array.CreateInstancenatively anyway.Removed by Phase 2
JavaPeerContainerFactory<T>.CreateArrayandCreateHigherRankArray(and the abstract base method). The CoreCLR branch (Array.CreateInstance) replaces both — and additionally supports unlimited rank because the runtime type loader can construct anyT[N]dynamically. NativeAOT remains capped at the emitted ranks (1–3).Phase 3: Delete
JavaPeerContainerFactoryOnce Phase 1 and Phase 2 are done:
JavaPeerContainerFactoryandJavaPeerContainerFactory<T>JavaPeerProxy.GetContainerFactory()virtual +JavaPeerProxy<T>overrideJavaPeerContainerFactory<T>Action items
CreateList/CreateCollection/CreateDictionarywith typemap lookup +ActivateUsingReflection$(PublishAot), runtime fork onIsDynamicCodeSupported, factory deletion) lands once dotnet/android picks up an SDK with dotnet/runtime#126380JavaPeerContainerFactoryand related virtual methodsRelated
ActivateUsingReflectionfor closed generic activation, which Phase 1 builds onJavaPeerContainerFactory