dotnet / android

.NET for Android provides open-source bindings of the Android SDK for use with .NET managed languages such as C#
MIT License
1.92k stars 526 forks source link

[Mono.Android] Extend `JNINativeWrapper.CreateBuiltInDelegate` exploration #9309

Open jpobst opened 3 days ago

jpobst commented 3 days ago

Note: This is just a POC exploration for discussion, it is not intended to be committed. Note: All performance numbers mentioned are run on Android Emulator on a DevBox, so they are somewhat inflated.

Run a Release version of the dotnet new android template and it takes 1.385s to start up. Now add the following code to MainActivity.cs:

protected override void OnApplyThemeResource (Resources.Theme? theme, int resId, bool first)
{
    base.OnApplyThemeResource (theme, resId, first);
}

Compile and run the app again, now it takes 1.608s to start up, an increase of 223ms. What gives?

It turns out that part of https://github.com/dotnet/android/pull/6657 is a "marketing" performance optimization. By ensuring every delegate needed by our template is "built-in" we never hit a delegate that needs System.Runtime.Emit. However once a user adds most any other code they will hit a delegate that isn't "built-in" which needs SRE and they will take the considerable perf hit of initializing SRE and generating the first delegate.

To avoid this, what if we took the concept of "built-in" delegates and formalized and expanded it? That is, each version of netX.0-android would contain a known set of built-in delegates that libraries could depend on.

If we scan Mono.Android plus the ~630 AndroidX/etc. libraries we currently bind, we find that there are 1037 unique delegates in our "ecosystem". This PR adds all of them to JNINativeWrapper.CreateBuiltInDelegate, thus avoiding initializing SRE and taking the performance hit.

Tradeoffs

Per our apk-diff unit test on CI (BuildReleaseArm64), adding an additional 1000 delegates and wrapper functions comes at a cost in .apk size of ~61 KB:

61,440 Package size difference 0.58% (of 10,579,211)

Unfortunately, JNINativeWrapper.CreateBuiltInDelegate acts as a "choke-point" method in that it references every built-in delegate and wrapper, so unused ones cannot be removed by the trimmer.

Enhancements

In the long run, we can avoid the .apk size increase by making this process trimmer friendly. In addition to JNINativeWrapper.CreateDelegate we could expose the individual delegate creation methods:

public class JNINativeWrapper {
  public _Jni_Marshal_PP_V CreateBuiltInDelegate_PP_V (_Jni_Marshal_PP_V dlg) { ... }
  public _Jni_Marshal_PP_J CreateBuiltInDelegate_PP_J (_Jni_Marshal_PP_J dlg) { ... }
  public _Jni_Marshal_PP_Z CreateBuiltInDelegate_PP_Z (_Jni_Marshal_PP_Z dlg) { ... }
  etc...
}

Because the list of built-in delegates is a per-.NET level contract, a binding library targeting net10.0-android knows that it can replace calls to JNINativeWrapper.CreateDelegate with calls to explicit delegate creator methods:

// net9.0-android
JNINativeWrapper.CreateDelegate (new _JniMarshal_PP_L (n_GetText1));

// net10.0-android
JNINativeWrapper.CreateDelegate_PP_L (new _JniMarshal_PP_L (n_GetText1));

This provides 2 benefits:

What About SRE?

One will note this doesn't actually eliminate the original problem of needing SRE, it just makes it much less likely. If a user binds a library with a delegate we've never seen before they'll fall back to SRE. We can eliminate this by adding any missing delegates to their application assembly.

Before we compile the user application, we need to scan their referenced assemblies for any delegates not part of the supported "built-in" set. We then need to generate the C# delegate wrappers and add the generated code to their application. Lastly we need to pass a reference to this fall-back method to JNINativeWrapper on app startup so it can use the fall-back.

// In the user's app
class AdditionalDelegates
{
  public static Delegate CreateAdditionalDelegate (Delegate dlg, Type delegateType)
  {
    switch (delegateType.Name) {
      case nameof (Wrap_JniMarshal_PPZZ_Z):
        return new Wrap_JniMarshal_PPZZ_Z (Unsafe.As<_JniMarshal_PPIJIJIJIJ_V> (dlg)._JniMarshal_PPIJIJIJIJ_V);
    }
  }

  internal static bool Wrap_JniMarshal_PPZZ_Z (this _JniMarshal_PPZZ_Z callback, IntPtr jnienv, IntPtr klazz, bool p0, bool p1) { ... }
}

Then at app startup we pass this to JNINativeWrapper:

JNINativeWrapper.RegisterAdditionalDelegateCreator (AdditionalDelegates.CreateAdditionalDelegate);

And JNINativeWrapper.CreateDelegate uses it as a fallback if CreateBuiltInDelegate fails:

var result = CreateBuiltInDelegate (dlg, delegateType);

if (result != null)
  return result;

var result = _additionalCreator (dlg, delegateType);

if (result != null)
  return result;

SRE Is Gone Now?

Almost!

There is still a case where SRE would be used: if the user is referencing a Classic binding assembly built before we changed from using Action<T>/Func<T> to _Jni_Marshal_* delegates. This feels like an acceptable limitation. We could add a build warning when this case is detected if desired.

So SRE would be gone for pure .NET for Android applications.