Skip to content

Explore generated factories for NativeAOT activation of closed generic Java peers #12563

Description

@simonrozsival

Background

The trimmable typemap currently represents a generic Java peer with one proxy for its open generic definition. On CoreCLR, when the caller supplies a closed targetType, TrimmableTypeMap.CreateInstance() can construct it through reflection. NativeAOT uses CreateInstanceWithoutReflectionFallback(), so the generated open-generic proxy reaches the current unsupported path instead.

For example, given a peer such as:

public partial class GenericPeer<T> : Java.Lang.Object
{
    protected GenericPeer (IntPtr handle, JniHandleOwnership transfer)
        : base (handle, transfer)
    {
    }
}

the typemap companion conceptually contains one proxy for the open generic definition:

sealed class GenericPeer_1_Proxy : JavaPeerProxy
{
    public override Type TargetType => typeof (GenericPeer<>);

    public override IJavaPeerable CreateInstance (IntPtr handle, JniHandleOwnership transfer)
        => throw new NotSupportedException ("Cannot create instance of open generic type.");
}

CoreCLR handles a supplied closed target such as typeof (GenericPeer<string>) using the equivalent of:

var ctor = closedType.GetConstructor (
    BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
    binder: null,
    [typeof (IntPtr), typeof (JniHandleOwnership)],
    modifiers: null);
return (IJavaPeerable?) ctor?.Invoke ([handle, transfer]);

That fallback is intentionally unavailable to NativeAOT.

macOS precedent

The dotnet/macios registrar solves the equivalent construction problem by adding a static interface factory implementation to each eligible type. Its generated code is conceptually:

interface INSObjectFactory
{
    static virtual NSObject? _Xamarin_ConstructNSObject (NativeHandle handle) => null;
}

public partial class GenericObject<T> : NSObject, INSObjectFactory
{
    public static NSObject? _Xamarin_ConstructNSObject (NativeHandle handle)
        => new GenericObject<T> (handle);
}

A generic runtime helper can then make a statically resolved call:

static T? ConstructViaFactory<T> (NativeHandle handle)
    where T : NSObject, INSObjectFactory
    => T._Xamarin_ConstructNSObject (handle) as T;

This works because the registrar modifies the generic user type. Android's typemap generator currently emits only into a separate companion assembly, so it cannot add the required interface and static factory method to an already compiled type.

Possible Android-generated shape

Explore whether the Android source/binding generator can emit the factory implementation while it is still generating the generic peer. The contract must be non-generic:

internal interface IJavaPeerActivationFactory
{
    static abstract IJavaPeerable Create (IntPtr handle, JniHandleOwnership transfer);
}

The generated generic peer implements that contract and returns the non-generic interface type:

public partial class GenericPeer<T> : Java.Lang.Object, IJavaPeerActivationFactory
{
    public static IJavaPeerable Create (IntPtr handle, JniHandleOwnership transfer)
        => new GenericPeer<T> (handle, transfer);
}

A constrained generic runtime helper can then make a statically resolved call and cast the result back to the same T:

static T? Create<T> (IntPtr handle, JniHandleOwnership transfer)
    where T : class, IJavaPeerable, IJavaPeerActivationFactory
    => T.Create (handle, transfer) as T;

The interface cannot be generic. The closed peer type is the helper generic argument T; T.Create (handle, transfer) as T is what preserves the closed generic instantiation for NativeAOT. The implementation must be generated as part of the generic peer rather than emitted later into the typemap companion, because the companion cannot add the required static interface implementation to T.

This is illustrative, not a proposed final API. The remaining design problem is that today’s activation path has a Type targetType, not a generic T. The investigation must identify where the generic call site can be preserved or generated; resolving a factory from Type with MakeGenericType() would recreate the NativeAOT problem.

Questions to answer

  • Which generic peers have a meaningful closed-type activation path, given Java type erasure?
  • Where can the current Type targetType activation path retain or recover a statically known TPeer?
  • Can generated factories preserve the concrete generic arguments supplied by the managed caller without MakeGenericType()?
  • Should this be limited to source-generated binding types, or can user-defined generic peers participate?
  • What contract should connect the generated static factory implementation to the typemap and runtime activation paths?
  • Can the design preserve the current invariant that typemap-generated code never mutates user assemblies?
  • How should unsupported open-generic activation continue to fail?

Acceptance criteria for an eventual implementation

  • A closed generic Java peer that can be activated under CoreCLR can also be activated under NativeAOT without reflection.
  • Open-generic activation remains explicitly unsupported.
  • Generated code remains trim-safe and does not require runtime MakeGenericType(), ConstructorInfo.Invoke(), or Activator.CreateInstance().
  • CoreCLR and NativeAOT device tests cover the same closed-generic scenario.
  • Existing non-generic and interface/invoker activation paths are unchanged.

Related implementation points:

  • src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs
  • src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs
  • dotnet/macios/src/Foundation/NSObject2.cs
  • dotnet/macios/src/ObjCRuntime/Runtime.cs
  • dotnet/macios/tools/dotnet-linker/AppBundleRewriter.cs

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageIssues that need to be assigned.

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions