company_banner

Пишем плагин для 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<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.

    Unity относительно недавно стала полноценно поддерживать gradle сборщик для Android, а ADT объявила как legacy. Появилась возможность создавать template для gradle конфигурации экспортируемого проекта, полноценная поддержка Aar и переменных в манифестах, слияние манифестов. Но плагины сторонних sdk еще не успели адаптироваться под эти изменения и не используют те возможности, что предоставляет редактор. Поэтому мой совет, лучше модифицируйте импортируемую библиотеку под современные реалии: удалите зависимости и объявите их через xml для Unity Jar Resolver, скомпилируйте весь java код и ресурсы в Aar. Иначе каждая последующая интеграция будет ломать предыдущие и отнимать все больше времени.
    • +23
    • 3,2k
    • 1
    Pixonic 133,70
    Международная компания по разработке мобильных игр
    Поделиться публикацией
    Комментарии 1
    • +2
      Спасибо, статья отличная! Жаль, что не все создатели плагинов думают о соседях.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое