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

Groozze 16 апреля в 11:58 3,6k


В предыдущей части мы рассмотрели основные проблемы написания нативных плагинов на 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
Сохранить: