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 aTask
and manage its result, exception, or cancellation viaAndroidJavaProxy
. 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 aDisposable
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 sameAndroidJavaObject
that we received from theSingle
operation without converting it to the expected type. This approach allows us to reuse this observer and the extension forSingle
of any reference type (excludingString
, because Unity convert it to a managedstring
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 aTaskCompletionSource
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.