Unity Client

Unity Native: Android Asynchronous Operations and Threading

By Artem Glotov

This article is a continuation of the post about Android Native Calls. Here, we will explore writing bindings for asynchronous operations, threading usage, and the best practices for these scenarios.

The most convenient ways to represent asynchronous operations in C# are through TPL (Task Parallel Library)** or UniTask. Both offer nearly identical APIs, and each is suitable for specific situations. In most scenarios, it’s preferable to use UniTask over Task, except for operations involving thread switching.

In this article, I have utilized TPL because it is better suited for the thread switching operations described towards the end. However, almost all examples provided here can be easily rewritten to use UniTask.

Asynchronous Operations

When converting types that represent asynchronous operations from Java to C#, keep these guidelines in mind:

  • Utilize TaskCompletionSource to create a Task and manage its result, exception, or cancellation via AndroidJavaProxy. Provide it to the callback from outside
  • Include a cancellation token in the method. Verify it prior to invoking JNI methods and set up a callback to cancel the native async job upon a cancellation request.
  • Account for all potential outcomes: success, failure, and cancellation.
  • Ensure to dispose of AndroidJavaObject immediately after its use.

Example

Let’s examine a scenario where we need to wait for a result of Single from RxJava. The Java method looks like this:

public class AsyncMethodsSample {
    public Single<TestItem> callAsyncWithSingle() {
        return Single.fromCallable(() -> new TestItem(42));
    }
}

Where TestItem is a simple class:

public class TestItem {
    public long value;
    public TestItem(long value) {
        this.value = value;
    }
}

We can subscribe to the Single result using an implementation of SingleObserver that contains the following methods:

  • onSubscribe: On subscription, receive a Disposable object as a parameter, which allows canceling (disposing of) the operation.
  • onSuccess: Notifies an observer of the result of the operation.
  • onError: Notifies an observer about an error.
public interface SingleObserver<@NonNull T> {
    void onSubscribe(@NonNull Disposable d);
    void onSuccess(@NonNull T t);
    void onError(@NonNull Throwable e);
}

Let’s create an extension to convert aSingle async operation to a Task.

public static class TaskExtensions
{
    public static Task<AndroidJavaObject> FromSingleToTask(this AndroidJavaObject singleJavaObject, CancellationToken cancellationToken)
    {
        var tcs = new TaskCompletionSource<AndroidJavaObject>();
        var singleObserver = new SingleObserverProxy(tcs, cancellationToken);
        singleJavaObject.Call("subscribe", singleObserver);
        return tcs.Task;
    }
}

The AndroidJavaProxy comes into play here; it can be utilized to implement any Java interface on the Unity side.

  • In our implementation, we handle all possible outcomes: success, failure, and cancellation.
  • We return, as a result of the Task, the same AndroidJavaObject that we received from the Single operation without converting it to the expected type. This approach allows us to reuse this observer and the extension for Single of any reference type (excluding String, because Unity convert it to a managed string automatically).
  • The SingleObserver interface is specifically designed to cancel an asynchronous operation inside a callback. However, with certain asynchronous operations, you will receive a cancellable object only as a result of subscription. Therefore, it’s advisable to provide a TaskCompletionSource to the callback from outside. This practice promotes maintaining a consistent code style.
public class SingleObserverProxy : AndroidJavaProxy
{
    private readonly TaskCompletionSource<AndroidJavaObject> _tcs;
    private AndroidJavaObject _disposable;
    private CancellationTokenRegistration _cancellationTokenRegistration;

    public SingleObserverProxy(TaskCompletionSource<AndroidJavaObject> tcs, CancellationToken cancellationToken) 
        : base("io.reactivex.rxjava3.core.SingleObserver")
    {
        _tcs = tcs;
        _cancellationTokenRegistration = cancellationToken.Register(() =>
        {
            _disposable?.Call("dispose");
            _tcs.TrySetCanceled();
            _cancellationTokenRegistration.Dispose();
        });
    }

    [Preserve]
    void onSuccess(AndroidJavaObject item)
    {
        _tcs.TrySetResult(item);
        _cancellationTokenRegistration.Dispose();
    }

    [Preserve]
    void onError(AndroidJavaObject throwable)
    {
        var message = throwable.Call<string>("getMessage");
        _tcs.SetException(new Exception(message));
        _cancellationTokenRegistration.Dispose();
    }

    [Preserve]
    void onSubscribe(AndroidJavaObject disposable)
    {
        _disposable = disposable;
    }
}

The complete execution of an Android asynchronous operation will look as follows:

public class AsyncMethodsBindings
{
    public async Task<long> CallAsyncWithSingle(CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        using var javaInstance = new AndroidJavaObject("com.sample.java.samples.AsyncMethodsSample");
        using var singleJavaObject = javaInstance.Call<AndroidJavaObject>("callAsyncWithSingle");
        using var resultJavaObject = await singleJavaObject.FromSingleToTask(cancellationToken);
        return resultJavaObject.Get<long>("value");
    }
}

Now, you can manipulate asynchronous operations from Android using the async-await C# keywords.

Threading

Unity’s main thread is distinct from the main (UI) thread of an Android application. Unity operates on its own main thread, separate from the Android UI thread. When Java methods are invoked from Unity using AndroidJavaObject, these methods are executed within the context of Unity’s main thread, not on the Android main thread.

One of the reasons for this design choice is likely to avoid blocking the Android application’s UI thread during long-running Unity operations, thereby reducing the number of Application Not Responding (ANRs)*** incidents. Unity leaves the responsibility of handling input events to the Android Main Thread, while the Unity Main Thread is responsible for handling game logic.

Additionally, starting from Unity 2023, the new GameActivity* is supported. It offers enhanced control over the interaction between Android and your application and features an optimized input event buffer, which may further reduce the number of ANRs.

Unity main thread design for the Android platform leads to the following potential situations:

1. Android APIs that must be invoked from the Android main thread

For instance, when you need to call a UI-related API, such as the WebView.

public class ThreadingSample {
    public boolean executeOnUIThread() {
        WebView webView = new WebView(UnityPlayer.currentActivity);
        return true;
    }
}

Calling this method directly from Unity will result in an exception, as the Unity Main thread is different from the Android Main thread. To resolve this issue, call the runOnUiThread method of the Activity, passing an AndroidJavaRunnable that contains your code.
Example

Wrong (exception will be thrown)

public Task<bool> CallUiSensitiveApiNotOnUiThread()
{
    var result = _javaInstance.Call<bool>("executeOnUIThread");
    return Task.FromResult(result);
}
FATAL Exception AndroidRuntime java.lang.IllegalStateException: Calling View methods on another thread than the UI thread.

Right

public Task<bool> CallUiSensitiveApiOnUiThread()
{
    using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
    using var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
    var tcs = new TaskCompletionSource<bool>();
    activity.Call("runOnUiThread", new AndroidJavaRunnable(() =>
    {
        try
        {
            var result = _javaInstance.Call<bool>("executeOnUIThread");
            tcs.TrySetResult(result);
        }
        catch (Exception e)
        {
            tcs.TrySetException(e);
        }
    }));
    return tcs.Task;
}

2. Unity Callbacks that are Called from Android Main Thread

For example, if you implement a callback interface using AndroidJavaProxy and need to call this callback from the Android Main thread, or if a third-party Android library does so, the C# implementation of AndroidJavaProxy will be executed on Unity’s background thread.

public class ThreadingSample {
    public void callbackOnUiThread(String message, IAsyncCallback<String> callback) {
        UnityPlayer.currentActivity.runOnUiThread(() -> {
            callback.onSuccess(message);
        });
    }
}
public Task<GameObject> TriggerCallbackOnUiThread(string gameObjectName)
{
    var tcs = new TaskCompletionSource<GameObject>();
    var callback = new ThreadingAsyncCallbackProxy(tcs);
    _javaInstance.Call("callbackOnUiThread", gameObjectName, callback);
    return tcs.Task;
}

In such cases, if your callback includes Unity API calls, such as those from the GameObject API, it will result in an exception. To prevent this, you should post your callback to Unity’s Main thread using the Unity Synchronization Context. The UnitySynchronizationContext utility described below can assist by caching the Unity Synchronization
Context prior to scene loading and posting actions to it if you are on a background thread.
Example

Wrong (exception will be thrown)

public class ThreadingAsyncCallbackProxy : AndroidJavaProxy
{
    // ... constructor and other stuff

    void onSuccess(string result)
    {
        // This code will be executed on Unity's background thread and will result in an exception.
        var gameObject = new GameObject(result);
    }
}
Unhandled log message: '[Exception] UnityException: Internal_CreateGameObject can only be called from the main thread.

Right

public class ThreadingAsyncCallbackProxy : AndroidJavaProxy
{
    // ... constructor and other stuff

    void onSuccess(string result)
    {
        UnitySynchronizationContext.Post(() =>
        {
            // This code will be executed on Unity's main thread.
            var gameObject = new GameObject(result);
        });
    }
}
public static class UnitySynchronizationContext
{
    private static SynchronizationContext _unitySynchronizationContext;

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    private static void Install()
    {
        _unitySynchronizationContext = SynchronizationContext.Current;
    }

    public static void Post(Action action)
    {
        if (SynchronizationContext.Current == _unitySynchronizationContext)
        {
            action();
        }
        else
        {
            _unitySynchronizationContext.Post(_ => action(), null);
        }
    }
}

3. Android Bindings that are Called from a Unity Background Thread

Calling the Android API from a Unity background thread will lead to an uncaught exception. To avoid this, attach the current thread to the JVM using AndroidJNI.AttachCurrentThread before any Android API calls, and then detach it with AndroidJNI.DetachCurrentThread.
Example

Wrong (exception will be thrown)

public Task<bool> CallOnBackgroundThreadWithoutAttach()
{
    return Task.Run(() =>
    {
        // This code will be executed on Unity's background thread and will result in an exception.
        var result = _javaInstance.Call<bool>("executeOnAnyThread");
        return Task.FromResult(result);
    });
}

Right

public Task<bool> CallOnBackgroundThreadWithAttach()
{
    return Task.Run(() =>
    {
        AndroidJNI.AttachCurrentThread();
        var result = _javaInstance.Call<bool>("executeOnAnyThread");
        AndroidJNI.DetachCurrentThread();
        return Task.FromResult(result);
    });
}

Preserve Bindings

Unity has a linker* that removes unused managed code from the final build.

Since most methods in the inheritors of AndroidJavaProxy are executed only from the native side, they may be interpreted as unused code and removed by the linker. To prevent this, you need to add the classes, methods or the entire assembly to the link.xml file:

<linker>
  <assembly fullname="Native.Sample.Android.Tests" preserve="all" />
</linker>

or use Preserve attribute for the methods:

public class AsyncCallbackProxy : AndroidJavaProxy
{
    [Preserve]
    void onSuccess(AndroidJavaObject result)
    {
        // ...
    }

It’s important to note that applying the [Preserve] attribute to a class will only preserve the class itself and its default constructor. If you want to preserve all methods in the class, you should apply this attribute to each method individually or use a link.xml file.

Summary

Now you know how to write bindings for asynchronous operations and threading usage and best practices for it.

(*) Content in this article is sourced from Unity’s Documentation. Copyright © 2024 Unity Technologies. Publication Date: 2024-02-05.

(**) Content in this article is sourced from Microsoft’s Documentation. Copyright © 2024 Microsoft.

(***) Content in this article is sourced from Android’s Documentation. Copyright © 2024 Google LLC.



Tags