We are writing the plugin for Unity correctly. Part 2: Android
In the previous part, we examined the main problems of writing native Unity plugins for iOS and Android, as well as methods for solving them for iOS. In this article, I will describe the basic recipes for solving these problems for Android, trying to maintain a similar interaction structure and the procedure for considering them.
Unity's Android libraries can be represented as Jar (only compiled java code), Aar (compiled java code along with resources and manifest), and sources. In the source code, it is desirable to store only the code specific to the project with minimal functionality, and this is optional and not very convenient. The best option is to start a separate gradle project (you can directly in the repository with the main Unity project), in which you can place not only the library code, but also unit tests, and a test Android project with Activity for quick assembly and verification of library functionality. And in the gradle build script for this project, you can immediately add a task that will copy the compiled Aar to Assets:
/* gradle.properties */
deployAarPath=../Assets/Plugins/Android
/* build.gradle */
task clearLibraryAar(type: Delete) {
delete fileTree("${deployAarPath}") {
include 'my-plugin-**.aar'
}
}
task deployLibraryAar(type: Copy, dependsOn: clearLibraryAar) {
from('build/outputs/aar/')
into("${deployAarPath}")
include('my-plugin-release.aar')
rename('my-plugin-release.aar', 'my-plugin-' + android.defaultConfig.versionName + '.aar')
doLast {
fileTree("${deployAarPath}"){ include { it.file.name ==~ "^my-plugin-([0-9.]+).aar.meta\$" }}.each { f -> f.renameTo(file("${deployAarPath}/my-plugin-" + android.defaultConfig.versionName + ".aar.meta")) }
}
}
tasks.whenTaskAdded { task ->
if (task.name == 'bundleRelease') {
task.finalizedBy 'deployLibraryAar'
}
}
Here my-plugin is the name of the library project; deployAarPath - the path along which the compiled file is copied can be any.
Using Jar now is also undesirable, because Unity has long learned how to support Aar, and it provides more options: in addition to code, you can include resources and your AndroidManifest.xml, which will merge with the main one during gradle assembly. Library files themselves do not have to be stored in Assets / Plugins / Android. The rule works the same as for iOS: if you are writing a third-party library, put everything in a subfolder inside your specific folder with the code and native code for iOS - it will be easier then to update or delete packages. In other cases, you can store where you want, in the Unity import settings you can specify whether to include the file in Android assembly or not.
Let's try to organize the interaction between Java and Unity code without using GameObject, similarly to the examples for iOS, by implementing our UnitySendMessage and the ability to transfer callbacks from C #. For this we need AndroidJavaProxy - C # classes used as implementations of Java interfaces. The class names will be left the same as from the previous article. If desired, their code can be combined with the code from the first part for a multi-platform implementation.
/* MessageHandler.cs */
using UnityEngine;
public static class MessageHandler
{
// Данный класс будет реализовывать Java Interface, который описан ниже
private class JavaMessageHandler : AndroidJavaProxy
{
private JavaMessageHandler() : base("com.myplugin.JavaMessageHandler") {}
public void onMessage(string message, string data) {
// Переадресуем наше сообщение всем желающим
MessageRouter.RouteMessage(message, data);
}
}
// Этот метод будет вызываться автоматически при инициализации Unity Engine в игре
[RuntimeInitializeOnLoadMethod]
private static void Initialize()
{
#if !UNITY_EDITOR
// Создаем инстанс JavaMessageHandler и передаем его
new AndroidJavaClass("com.myplugin.UnityBridge").CallStatic("registerMessageHandler", new JavaMessageHandler());
#endif
}
}
On the Java side, we define an interface for receiving messages and a class that will register and then delegate calls to the JavaMessageHandler described above. Along the way, we solve the problem of redirecting threads. Since, unlike iOS, on Android, Unity creates its own thread that has a loop circle, you can create android.os.Handler during initialization and pass execution to it.
/* com.myplugin.JavaMessageHandler */
package com.myplugin;
// Объявляем интерфейс, который реализовывали ранее
public interface JavaMessageHandler {
void onMessage(String message, String data);
}
/* com.myplugin.UnityBridge */
package com.myplugin;
import android.os.Handler;
public final class UnityBridge {
// Содержит ссылку на C# реализацию интерфейса
private static JavaMessageHandler javaMessageHandler;
// Перенаправляет вызов в Unity поток
private static Handler unityMainThreadHandler;
public static void registerMessageHandler(JavaMessageHandler handler) {
javaMessageHandler = handler;
if(unityMainThreadHandler == null) {
// Так как эту функцию вызываем всегда на старте Unity,
// этот вызов идет из нужного нам в дальнейшем потока,
// создадим для него Handler
unityMainThreadHandler = new Handler();
}
}
// Функция перевода выполнения в Unity поток, потребуется в дальнейшем
public static void runOnUnityThread(Runnable runnable) {
if(unityMainThreadHandler != null && runnable != null) {
unityMainThreadHandler.post(runnable);
}
}
// Пишем какую-нибудь функцию, которая будет отправлять сообщения в Unity
public static void SendMessageToUnity(final String message, final String data) {
runOnUnityThread(new Runnable() {
@Override
public void run() {
if(javaMessageHandler != null) {
javaMessageHandler.onMessage(message, data);
}
}
});
}
}
Now let's add the ability to call Java functions with a callback, using the same AndroidJavaProxy.
/* MonoJavaCallback.cs */
using System;
using UnityEngine;
public static class MonoJavaCallback
{
// Объявим класс, реализующий колбек на Java
// и проксирующий вызов в передаваемый Action
private class AndroidCallbackHandler : AndroidJavaProxy
{
private readonly Action _resultHandler;
public AndroidCallbackHandler(Action resultHandler) : base("com.myplugin.CallbackJsonHandler")
{
_resultHandler = resultHandler;
}
// В качестве аргумента передаем JSONObject
// по аналогии с примером из первой части,
// но можно было использовать и другие типы
public void onHandleResult(AndroidJavaObject result)
{
if(_resultHandler != null)
{
// Переводим json объект в строку
var resultJson = result == null ? null : result.Call("toString");
// и парсим эту строку в C# объект
_resultHandler.Invoke(Newtonsoft.Json.JsonConvert.DeserializeObject(resultJson));
}
}
}
// В дальнейшем будем использовать эту функцию для оборачивания C# делегата
public static AndroidJavaProxy ActionToJavaObject(Action action)
{
return new AndroidCallbackHandler(action);
}
}
On the Java side, we declare the callback interface, which we will then use in all exported functions with the callback:
/* CallbackJsonHandler.java */
package com.myplugin;
import org.json.JSONObject;
public interface CallbackJsonHandler {
void onHandleResult(JSONObject result);
}
I used Json as the argument to the callback, as in the first part, because it eliminates the need to describe the interfaces and AndroidJavaProxy for each set of arguments of different types needed in the project. Perhaps your project is more suitable for string or array. I give an example of use with a description of a test serializable class as a type for a callback.
/* Example.cs */
public class Example
{
public class ResultData
{
public bool Success;
public string ValueStr;
public int ValueInt;
}
public static void GetSomeData(string key, Action completionHandler) {
new AndroidJavaClass("com.myplugin.Example").CallStatic("getSomeDataWithCallback", key, MonoJavaCallback.ActionToJavaObject(completionHandler));
}
}
/* Example.java */
package com.myplugin;
import org.json.JSONException;
import org.json.JSONObject;
public class Example {
public static void getSomeDataWithCallback(String key, CallbackJsonHandler callback) {
// В качестве примера выполним какие-то действия в background потоке
new Thread(new Runnable() {
@Override
public void run() {
doSomeStuffWithKey(key);
// Колбек требуется вызывать в Unity потоке
UnityBridge.runOnUnityThread(new Runnable() {
@Override
public void run() {
try {
callback.OnHandleResult(new JSONObject().put("Success", true).put("ValueStr", someResult).put( "ValueInt", 42));
} catch (JSONException e) {
e.printStackTrace();
}
}
});
});
}
}
A typical problem when writing plugins for Android for Unity is to catch the life cycles of the game Activity, as well as onActivityResult and the launch of the Application. Usually for this they propose to inherit from UnityPlayerActivity and redefine the class of launch activity in the manifest. The same can be done for Application. But in this article we are writing a plugin. There can be several such plugins in large projects; inheritance will not help. It is necessary to integrate as transparently as possible without the need for modifications to the main classes of the game. ActivityLifecycleCallbacks and ContentProvider will come to the rescue.
public class InitProvider extends ContentProvider {
@Override
public boolean onCreate() {
Context context = getContext();
if (context != null && context instanceof Application) {
// ActivityLifecycleListener — наша реализация интерфейса Application.ActivityLifecycleCallbacks
((Application) context).registerActivityLifecycleCallbacks(new ActivityLifecycleListener(context));
}
return false;
}
// Далее имплементация абстрактных методов
}
Do not forget to register InitProvider in the manifest (Aar libraries, not mainly):
It uses the fact that Application creates all the declared Content Providers at startup. And even if it does not provide any data that the normal Content Provider should return, in the onCreate method you can do something that is usually done at the start of the Application, for example, register our ActivityLifecycleCallbacks. And he will already receive events onActivityCreated, onActivityStarted, onActivityResumed, onActivityPaused, onActivityStopped, onActivitySaveInstanceState and onActivityDestroyed. True, events will come from all activites, but identifying the main one and reacting only to it costs nothing:
private boolean isLaunchActivity(Activity activity) {
Intent launchIntent = activity.getPackageManager().getLaunchIntentForPackage(activity.getPackageName());
return launchIntent != null && launchIntent.getComponent() != null && activity.getClass().getName().equals(launchIntent.getComponent().getClassName());
}
Also, the variable $ {applicationId} was specified in the manifest, which, when assembling gradle, will be replaced by the packageName of the application.
The only thing missing is the onActivityResult, which is usually required to return the result from showing the native screen on top of the game. Unfortunately, this call cannot be received directly. But you can create a new Activity that will show the required Activity, then get the result from it, return it to us and finish. The main thing is to exclude it from the story and make it transparent by specifying the topic in the manifest so that the white screen does not flicker upon opening:
Thus, you can implement the necessary functionality without resorting to modifying the main Unity Java classes, and neatly pack the manifest with code and resources into the Aar library. But what to do with dependency packages from maven repositories that our plugin requires? Unity generates a gradle project, in which all the java libraries of the project are added to the libs of the exported project and connected locally. There should not be duplicates. Other dependencies will not be included automatically. Putting dependencies next to compiled Aar is not always a good idea: more often, other Unity plugins also need these dependencies. And if they also put their version in unitypackage, a version conflict will occur, gradle will swear at duplicate classes during assembly. Dependencies also depend on other packages, and manually compose this chain of dependencies,
Finding duplicates in a project is also tiring. I would like an automated solution that will download the necessary libraries of the necessary versions to the project itself, removing duplicates. And there is such a solution . This package can be downloaded independently, and it also comes with Google Play Services and Firebase. The idea is that in a Unity project we create xml files with a list of dependencies required by the plugins according to a syntax similar to the definition in build.gradle (indicating the minimum versions):
extra-google-m2repository extra-android-m2repository extra-google-m2repository extra-android-m2repository extra-google-m2repository extra-android-m2repository extra-google-m2repository extra-android-m2repository
Next, after installing or changing dependencies in the project, select Assets → Play Services Resolver → Android Resolver → Resolve from the Unity menu and voila! The utility will scan xml declarations, create a dependency graph and download all the necessary dependency packages of the required versions from maven repositories in Assets / Plugins / Android. Moreover, she notes the downloaded in a special file and next time replaces it with new versions, and she will not touch those files that we put. There is also a settings window where you can enable automatic resolution of dependencies, so as not to click Resolve through the menu, and many other options. To work, you need Android Sdk installed on the computer with Unity and the selected target is Android. You can write CocoaPods dependencies for iOS builds in the same file, and specify in the settings,
Unity relatively recently began to fully support the gradle builder for Android, and ADT announced as legacy. Now you can create a template for the gradle configuration of the exported project, full support for Aar and variables in manifests, merging manifests. But third-party sdk plugins have not yet had time to adapt to these changes and do not use the features that the editor provides. Therefore, my advice is that you better modify the imported library to fit modern realities: remove the dependencies and declare them via xml for the Unity Jar Resolver, compile all java code and resources into Aar. Otherwise, each subsequent integration will break the previous ones and take up more and more time.