Хабрахабр

Пишем плагин для Unity правильно. Часть 2: Android

В этой статье я опишу основные рецепты по решению этих проблем для Android, стараясь сохранить схожую структуру взаимодействия и порядок их рассмотрения. В предыдущей части мы рассмотрели основные проблемы написания нативных плагинов на Unity под iOS и Android, а также методы их решения для iOS.

В исходниках желательно хранить только специфичный для данного проекта код с минимальным функционалом, и то это необязательно и не очень удобно. Библиотеки для Android в Unity могут быть представлены в виде Jar (только скомпилированный java код), Aar (скомпилированный java код вместе с ресурсами и манифестом), и исходников. А в gradle скрипт сборки этого проекта можно сразу добавить task, который будет копировать скомпилированный Aar в Assets: Лучший вариант — завести отдельный gradle проект (можно прямо в репозитории с основным Unity проектом), в котором можно разместить не только код библиотеки, но и unit-тесты, и тестовый Android проект с Activity для быстрой сборки и проверки функционала библиотеки.

/* gradle.properties */
deployAarPath=../Assets/Plugins/Android /* build.gradle */
task clearLibraryAar(type: Delete) ") { 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 — путь, по которому копируется компилируемый файл, может быть любым.

Сами файлы библиотек не обязательно складывать в Assets/Plugins/Android. Использовать Jar сейчас также нежелательно, потому что Unity уже давно научилась поддерживать Aar, а он дает больше возможностей: кроме кода можно включать ресурсы и свой AndroidManifest.xml, который будет сливаться с основным при gradle-сборке. В других случаях можно хранить, где хочется, в настройках импорта Unity можно указать, включать ли файл в Android сборку или нет. Правило действует такое же, как и для iOS: если пишете стороннюю библиотеку, складывайте все в подпапку внутри вашей специфической папки с кодом и нативным кодом для iOS — проще будет потом обновлять или удалять пакеты.

Для этого нам понадобятся AndroidJavaProxy — С# классы, используемые как реализации Java интерфейсов. Попробуем организовать взаимодействие между Java и Unity кодом без использования GameObject аналогично примерам для iOS, реализовав свой UnitySendMessage и возможность передавать колбеки из C#. При желании их код можно объединить с кодом из первой части для мультиплатформенной реализации. Названия классов оставлю те же, что из предыдущей статьи.

/* 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<T> : AndroidJavaProxy { private readonly Action<T> _resultHandler; public AndroidCallbackHandler(Action<T> resultHandler) : base("com.myplugin.CallbackJsonHandler") { _resultHandler = resultHandler; } // В качестве аргумента передаем JSONObject // по аналогии с примером из первой части, // но можно было использовать и другие типы public void onHandleResult(AndroidJavaObject result) { if(_resultHandler != null) { // Переводим json объект в строку var resultJson = result == null ? null : result.Call<string>("toString"); // и парсим эту строку в C# объект _resultHandler.Invoke(Newtonsoft.Json.JsonConvert.DeserializeObject<T>(resultJson)); } } } // В дальнейшем будем использовать эту функцию для оборачивания C# делегата public static AndroidJavaProxy ActionToJavaObject<T>(Action<T> action) { return new AndroidCallbackHandler<T>(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<ResultData> completionHandler) { new AndroidJavaClass("com.myplugin.Example").CallStatic("getSomeDataWithCallback", key, MonoJavaCallback.ActionToJavaObject<ResultData>(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 библиотеки, не основном):

<provider android:name=".InitProvider" android:authorities="${applicationId}.InitProvider" android:enabled="true" android:exported="false" android:initOrder="200" />

Тут используется тот факт, что 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, потом получит от него результат, вернет нам и финиширует.

<activity android:name=".ProxyActivity" android:excludeFromRecents="true" android:exported="false" android:configChanges="fontScale|keyboard|keyboardHidden|locale|mnc|mcc|navigation|orientation|screenLayout|screenSize|smallestScreenSize|uiMode|touchscreen" android:theme="@android:style/Theme.Translucent.NoTitleBar" />

Таким образом можно реализовать необходимый функционал, не прибегая к модификации основных классов Unity Java, и аккуратно упаковать манифест с кодом и ресурсами в Aar библиотеку. Но что делать с пакетами зависимостей из maven репозиториев, которые требуются нашему плагину? Unity генерирует gradle проект, в котором все java библиотеки проекта складываются в libs экспортируемого проекта и подключаются локально. Дубликатов быть не должно. Другие зависимости автоматом включены не будут. Положить зависимости рядом с скомпилированным Aar не всегда хорошая идея: чаще всего эти же зависимости нужны и другим Unity плагинам. И если они положили тоже свою версию в unitypackage, произойдет конфликт версий, gradle при сборке ругнется на дубликат классов. Также зависимости зависят от других пакетов, и вручную составить эту цепочку зависимостей, выкачав из maven-репозитория все, что нужно — задача не такая уж простая.

Хочется автоматизированного решения, которое само скачает нужные библиотеки нужных версий в проект, удаляя дубликаты. Искать в проекте дубликаты тоже утомительно. Данный пакет можно скачать самостоятельно, а также он поставляется вместе с Google Play Services и Firebase. И такое решение есть. Идея в том, что в Unity проекте создаем xml файлы со списком зависимостей, требуемых плагинам по синтаксису, схожему с определением в build.gradle (с указанием минимальных версий):

<dependencies> <iosPods> </iosPods> <androidPackages> <androidPackage spec="com.android.support:appcompat-v7:23+"> <androidSdkPackageIds> <androidSdkPackageId>extra-google-m2repository</androidSdkPackageId> <androidSdkPackageId>extra-android-m2repository</androidSdkPackageId> </androidSdkPackageIds> </androidPackage> <androidPackage spec="com.android.support:cardview-v7:23+"> <androidSdkPackageIds> <androidSdkPackageId>extra-google-m2repository</androidSdkPackageId> <androidSdkPackageId>extra-android-m2repository</androidSdkPackageId> </androidSdkPackageIds> </androidPackage> <androidPackage spec="com.android.support:design:23+"> <androidSdkPackageIds> <androidSdkPackageId>extra-google-m2repository</androidSdkPackageId> <androidSdkPackageId>extra-android-m2repository</androidSdkPackageId> </androidSdkPackageIds> </androidPackage> <androidPackage spec="com.android.support:recyclerview-v7:23+"> <androidSdkPackageIds> <androidSdkPackageId>extra-google-m2repository</androidSdkPackageId> <androidSdkPackageId>extra-android-m2repository</androidSdkPackageId> </androidSdkPackageIds> </androidPackage> </androidPackages>
</dependencies>

Далее после установки или изменения зависимостей в проекте выбираем в меню Unity редактора Assets → Play Services Resolver → Android Resolver → Resolve и вуаля! Утилита просканирует xml объявления, создаст граф зависимостей и все нужные пакеты зависимостей нужных версий скачает из maven репозиториев в Assets/Plugins/Android. Причем она отмечает в специальном файле скачанное и в следующий раз заменяет его новыми версиями, а те файлы, что положили мы, она трогать не будет. Также есть окно настроек, где можно включить автоматическое разрешение зависимостей, чтобы не нажимать Resolve через меню, и много других опций. Для работы требуется Android Sdk, установленный на компьютере вместе с Unity и выбранный target — Android. В том же файле можно писать CocoaPods зависимости для iOS билдов, и в настройках задать, чтобы Unity генерировала xcworkspace с включенными зависимостями для основного проекта XCode.

Появилась возможность создавать template для gradle конфигурации экспортируемого проекта, полноценная поддержка Aar и переменных в манифестах, слияние манифестов. Unity относительно недавно стала полноценно поддерживать gradle сборщик для Android, а ADT объявила как legacy. Поэтому мой совет, лучше модифицируйте импортируемую библиотеку под современные реалии: удалите зависимости и объявите их через xml для Unity Jar Resolver, скомпилируйте весь java код и ресурсы в Aar. Но плагины сторонних sdk еще не успели адаптироваться под эти изменения и не используют те возможности, что предоставляет редактор. Иначе каждая последующая интеграция будет ломать предыдущие и отнимать все больше времени.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть