Пишем плагин для Unity правильно. Часть 2: Android
В предыдущей части мы рассмотрели основные проблемы написания нативных плагинов на Unity под iOS и Android, а также методы их решения для iOS. В этой статье я опишу основные рецепты по решению этих проблем для Android, стараясь сохранить схожую структуру взаимодействия и порядок их рассмотрения.
Библиотеки для Android в Unity могут быть представлены в виде Jar (только скомпилированный java код), Aar (скомпилированный java код вместе с ресурсами и манифестом), и исходников. В исходниках желательно хранить только специфичный для данного проекта код с минимальным функционалом, и то это необязательно и не очень удобно. Лучший вариант — завести отдельный gradle проект (можно прямо в репозитории с основным Unity проектом), в котором можно разместить не только код библиотеки, но и unit-тесты, и тестовый Android проект с Activity для быстрой сборки и проверки функционала библиотеки. А в gradle скрипт сборки этого проекта можно сразу добавить task, который будет копировать скомпилированный Aar в 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'
}
}
Здесь my-plugin — название проекта библиотеки; deployAarPath — путь, по которому копируется компилируемый файл, может быть любым.
Использовать Jar сейчас также нежелательно, потому что Unity уже давно научилась поддерживать Aar, а он дает больше возможностей: кроме кода можно включать ресурсы и свой AndroidManifest.xml, который будет сливаться с основным при gradle-сборке. Сами файлы библиотек не обязательно складывать в Assets/Plugins/Android. Правило действует такое же, как и для iOS: если пишете стороннюю библиотеку, складывайте все в подпапку внутри вашей специфической папки с кодом и нативным кодом для iOS — проще будет потом обновлять или удалять пакеты. В других случаях можно хранить, где хочется, в настройках импорта Unity можно указать, включать ли файл в Android сборку или нет.
Попробуем организовать взаимодействие между Java и Unity кодом без использования GameObject аналогично примерам для iOS, реализовав свой UnitySendMessage и возможность передавать колбеки из C#. Для этого нам понадобятся AndroidJavaProxy — С# классы, используемые как реализации Java интерфейсов. Названия классов оставлю те же, что из предыдущей статьи. При желании их код можно объединить с кодом из первой части для мультиплатформенной реализации.
/* 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
}
}
На стороне Java определим интерфейс для получения сообщений и класс, который будет регистрировать, а потом и делегировать вызовы вышеописанному JavaMessageHandler. Попутно решим задачу перенаправления потоков. Так как в отличие от iOS, на Android Unity создает свой поток, имеющий loop circle, можно создать android.os.Handler при инициализации и передавать выполнение ему.
/* 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);
}
}
});
}
}
Теперь добавим возможность вызывать Java функции с передачей колбека, используя все тот же 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);
}
}
На стороне Java объявляем интерфейс колбека, который потом будем использовать во всех экспортируемых функциях с колбеком:
/* CallbackJsonHandler.java */
package com.myplugin;
import org.json.JSONObject;
public interface CallbackJsonHandler {
void onHandleResult(JSONObject result);
}
В качестве аргумента колбека я использовал Json, также как и в первой части, потому что это избавляет от необходимости описывать интерфейсы и AndroidJavaProxy на каждый необходимый в проекте набор разнотипных аргументов. Возможно, вашему проекту больше подойдет string или array. Привожу пример использования с описанием тестового сериализуемого класса в качестве типа для колбека.
/* 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();
}
}
});
});
}
}
Типичная проблема при написании плагинов под Android для Unity: отлавливать жизненные циклы игрового Activity, а также onActivityResult и запуск Application. Обычно для этого предлагают отнаследоваться от UnityPlayerActivity и переопределить класс у launch activity в манифесте. То же можно сделать для Application. Но в этой статье мы пишем плагин. Таких плагинов в больших проектах может быть несколько, наследование не поможет. Нужно интегрироваться максимально прозрачно без необходимости модификаций основных классов игры. На помощь придут ActivityLifecycleCallbacks и ContentProvider.
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;
}
// Далее имплементация абстрактных методов
}
Не забудьте зарегистрировать InitProvider в манифесте (Aar библиотеки, не основном):
Тут используется тот факт, что Application на старте создает все объявленные Content Provider. И если даже он не предоставляет никаких данных, какие должен возвращать нормальный Content Provider, в методе onCreate можно сделать что-то, что обычно делается на старте Application, например зарегистрировать наш ActivityLifecycleCallbacks. А он уже будет получать события onActivityCreated, onActivityStarted, onActivityResumed, onActivityPaused, onActivityStopped, onActivitySaveInstanceState и onActivityDestroyed. Правда события будут идти от всех активити, но определить основное из них и реагировать только на него ничего не стоит:
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());
}
Также в манифесте была указана переменная ${applicationId}, которая при сборке gradle заменится на packageName приложения.
Не хватает только onActivityResult, которое обычно требуется для возврата результата от показа нативного экрана поверх игры. Напрямую этот вызов получить, к сожалению нельзя. Но можно создать новое Activity, которое покажет требуемое Activity, потом получит от него результат, вернет нам и финиширует. Главное исключить его из истории и сделать прозрачным, указав тему в манифесте, чтобы при открытии не мелькал белый экран:
Таким образом можно реализовать необходимый функционал, не прибегая к модификации основных классов Unity Java, и аккуратно упаковать манифест с кодом и ресурсами в Aar библиотеку. Но что делать с пакетами зависимостей из maven репозиториев, которые требуются нашему плагину? Unity генерирует gradle проект, в котором все java библиотеки проекта складываются в libs экспортируемого проекта и подключаются локально. Дубликатов быть не должно. Другие зависимости автоматом включены не будут. Положить зависимости рядом с скомпилированным Aar не всегда хорошая идея: чаще всего эти же зависимости нужны и другим Unity плагинам. И если они положили тоже свою версию в unitypackage, произойдет конфликт версий, gradle при сборке ругнется на дубликат классов. Также зависимости зависят от других пакетов, и вручную составить эту цепочку зависимостей, выкачав из maven-репозитория все, что нужно — задача не такая уж простая.
Искать в проекте дубликаты тоже утомительно. Хочется автоматизированного решения, которое само скачает нужные библиотеки нужных версий в проект, удаляя дубликаты. И такое решение есть: github.com/googlesamples/unity-jar-resolver. Данный пакет можно скачать самостоятельно, а также он поставляется вместе с Google Play Services и Firebase. Идея в том, что в Unity проекте создаем xml файлы со списком зависимостей, требуемых плагинам по синтаксису, схожему с определением в build.gradle (с указанием минимальных версий):
extra-google-m2repository
extra-android-m2repository
extra-google-m2repository
extra-android-m2repository
extra-google-m2repository
extra-android-m2repository
extra-google-m2repository
extra-android-m2repository
Далее после установки или изменения зависимостей в проекте выбираем в меню Unity редактора Assets → Play Services Resolver → Android Resolver → Resolve и вуаля! Утилита просканирует xml объявления, создаст граф зависимостей и все нужные пакеты зависимостей нужных версий скачает из maven репозиториев в Assets/Plugins/Android. Причем она отмечает в специальном файле скачанное и в следующий раз заменяет его новыми версиями, а те файлы, что положили мы, она трогать не будет. Также есть окно настроек, где можно включить автоматическое разрешение зависимостей, чтобы не нажимать Resolve через меню, и много других опций. Для работы требуется Android Sdk, установленный на компьютере вместе с Unity и выбранный target — Android. В том же файле можно писать CocoaPods зависимости для iOS билдов, и в настройках задать, чтобы Unity генерировала xcworkspace с включенными зависимостями для основного проекта XCode.
Unity относительно недавно стала полноценно поддерживать gradle сборщик для Android, а ADT объявила как legacy. Появилась возможность создавать template для gradle конфигурации экспортируемого проекта, полноценная поддержка Aar и переменных в манифестах, слияние манифестов. Но плагины сторонних sdk еще не успели адаптироваться под эти изменения и не используют те возможности, что предоставляет редактор. Поэтому мой совет, лучше модифицируйте импортируемую библиотеку под современные реалии: удалите зависимости и объявите их через xml для Unity Jar Resolver, скомпилируйте весь java код и ресурсы в Aar. Иначе каждая последующая интеграция будет ломать предыдущие и отнимать все больше времени.