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

Когда делаешь на Unity игры для мобильных платформ, рано или поздно придется писать часть функционала на нативном языке платформы, будь то iOS (Objective C или Swift) или Android (Java, Kotlin). Это может быть свой код или интеграция сторонней библиотеки, сама установка может заключаться в копировании файлов или распаковки unitypackage, не суть. Итог этой интеграции всегда один: добавляются библиотеки с нативным кодом (.jar, .aar, .framework, .a, .mm), скрипты на C# (для фасада к нативному коду) и Game Object со специфичным MonoBehavior для отлавливания событий движка и взаимодействия со сценой. А еще часто требуется включать библиотеки зависимостей, которые нужны для работы нативной части.

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

Вот основные из них:

  1. Game Object обычно должен загружаться с первой сценой, и быть DontDestroyOnLoad. Приходится создавать специальную сцену с кучей таких невыгружаемых объектов, а потом еще и лицезреть их в редакторе в процессе тестирования.
  2. Все эти файлы часто складываются в Assets/Plugins/iOS и Assets/Plugins/Android, со всеми зависимостями. Потом сложно разобраться, откуда и для чего какой файл библиотеки, а зависимости часто конфликтуют с уже установленными для других плагинов.
  3. Если библиотеки лежат в специальных подпапках, конфликта при импорте не происходит, зато при сборке может возникнуть ошибка дубликата классов, если в итоге все-таки лежат где-то одни и те же зависимости разных версий.
  4. Иногда вызывать инициализацию нативной части в Awake слишком поздно, а событий MonoBehavior может быть недостаточно.
  5. Unity Send Message для взаимодействия между нативным и C# кодом неудобен, так как асинхронный и с одним строковым аргументом, без вариантов.
  6. Хочется использовать C# делегаты в качестве колбеков.
  7. Некоторые плагины требуют на iOS запускать реализацию своего UIApplicationDelegate, наследника UnityAppController, а на Android своей Activity, наследницей UnityPlayerActivity, или своего класса Application. Так как на iOS может быть только один UIApplicationDelegate, а на Android одно основное Activity (для игр) и один Application, несколько плагинов становится сложно ужить в одном проекте.

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

Главный принцип при написании плагинов: не используйте Game Object, если вам не требуется рисовать что-то на сцене (использовать graphics api). У Unity и Cocoa Touch уже есть все основные события, требуемые рядовому плагину: start, resume, pause, notification event. А взаимодействие между C# и ObjectiveC (Swift) можно осуществить через AOT.MonoPInvokeCallback. Суть этого метода в том, что мы регистрируем статическую C# функцию какого-то класса в качестве C функции, и храним в C (ObjectiveC) коде ссылку на нее.

Приведу пример моего класса, реализующего функционал, аналогичный UnitySendMessage:

/* MessageHandler.cs */ using UnityEngine; using System.Runtime.InteropServices;  public static class MessageHandler {     // Этот делегат задает сигнатуру нашего экспортируемого метода     private delegate void MonoPMessageDelegate(string message, string data);      // Этот метод реализует вышеописанный делегат и говорит компилятору,     // что он будет вызываться извне     [AOT.MonoPInvokeCallback(typeof(MonoPMessageDelegate))]     private static void OnMessage(string message, string data)     {         // Переадресуем наше сообщение всем желающим         MessageRouter.RouteMessage(message, data);     }      // Этот метод будет вызываться автоматически при инициализации Unity Engine в игре     [RuntimeInitializeOnLoadMethod]     private static void Initialize()     {         // Передаем ссылку на наш экспортируемый метод в нативный код         RegisterMessageHandler(OnMessage);     }      // Нативная функция, которая получает ссылку на наш экспортируемый метод     [DllImport("__Internal")]     private static extern void RegisterMessageHandler(MonoPMessageDelegate messageDelegate); }

В данном классе присутствует как объявление сигнатуры экспортируемого метода через delegate, так и его реализация OnMessage, и автоматическая передача ссылки на эту реализацию при старте игры.

Рассмотрим реализацию этого механизма в нативном коде:

/* MessageHandler.mm */ #import <Foundation/Foundation.h>  // Объявляем новый тип для делегата, эквивалентный объявленному в Unity typedef void (*MonoPMessageDelegate)(const char* message, const char* data);  // Создаем статическую ссылку на делегат. // В больших проектах эту ссылку лучше хранить в каком-нибудь классе static MonoPMessageDelegate _messageDelegate = NULL;  // Реализуем функцию регистрации, которую вызываем из Unity FOUNDATION_EXPORT void RegisterMessageHandler(MonoPMessageDelegate delegate) {     _messageDelegate = delegate; }  // Пишем какую-нибудь функцию, которая будет отправлять сообщения в Unity, // используя статический делегат void SendMessageToUnity(const char* message, const char* data) {     dispatch_async(dispatch_get_main_queue(), ^{         if(_messageDelegate != NULL) {             _messageDelegate(message, data);         }     }); }

В качестве примера я написал нативную реализацию в виде глобальной статической переменной и функции. При желании можно все это обернуть в каком-нибудь классе. Важно делать вызов MonoPMessageDelegate в главном потоке, потому что на iOS это и есть Unity поток, а на стороне C# перевести в нужный поток, не имея Game Object на сцене, нельзя.

Мы реализовали взаимодействие между Unity и нативным кодом без использования Game Object! Конечно, мы просто повторили функционал UnitySendMessage, но тут мы контролируем сигнатуру, а таких методов с нужными аргументами можем создать сколько угодно. И если требуется вызывать что-нибудь еще до инициализации Unity, можно организовать очередь сообщений, если MonoPMessageDelegate еще null.

Но передавать примитивные типы бывает недостаточно. Часто нужно передавать в нативную функцию C# колбек, которому потом надо будет передать результат. Конечно, можно сохранить колбек в какой-нибудь Dictionary, а уникальный ключ к нему передать в нативную функцию. Но в C# есть готовое решение, используя возможности GC, зафиксировать объект в памяти и получить на него указатель. Этот указатель передаем в нативную функцию, она, выполнив операцию и сформировав результат, передает указатель вместе с этим результатом обратно в Unity, где мы получаем по нему объект колбека (например, Action).

/* MonoPCallback.cs */ using System; using System.Runtime.InteropServices; using UnityEngine;  public static class MonoPCallback {     // Объявляем новый делегат, который будет вызывать наш Action     // и передавать ему данные     private delegate void MonoPCallbackDelegate(IntPtr actionPtr, string data);      [AOT.MonoPInvokeCallback(typeof(MonoPCallbackDelegate))]     private static void MonoPCallbackInvoke(IntPtr actionPtr, string data)     {         if(IntPtr.Zero.Equals(actionPtr))         {             return;         }          // Возвращаем по указателю хранящийся там Action         var action = IntPtrToObject(actionPtr, true);         if(action == null)         {             Debug.LogError("Callaback not found");             return;         }          try         { 	    // Определяем, какой тип аргумента требуется для данного Action             var paramTypes = action.GetType().GetGenericArguments();             // Приводим к этому типу данные для колбека             var arg = paramTypes.Length == 0 ? null : ConvertObject(data, paramTypes[0]);             // Вызываем Action с передачей ему данных колбека,             // приведенных к нужному типу             var invokeMethod = action.GetType().GetMethod("Invoke", paramTypes.Length == 0  ? new Type[0] : new []{ paramTypes[0] });             if(invokeMethod != null)             {                 invokeMethod.Invoke(action, paramTypes.Length == 0 ? new object[] { } : new[] { arg });             }             else             {                 Debug.LogError("Failed to invoke callback " + action + " with arg " + arg + ": invoke method not found");             }         }         catch(Exception e)         {             Debug.LogError("Failed to invoke callback " + action + " with arg " + data + ": " + e.Message);         }     }          // Функция получения объекта по его указателю     public static object IntPtrToObject(IntPtr handle, bool unpinHandle)     {         if(IntPtr.Zero.Equals(handle))         {             return null;         }          var gcHandle = GCHandle.FromIntPtr(handle);         var result = gcHandle.Target;         if(unpinHandle)         {             gcHandle.Free();         }         return result;     }          // Функция получения указателя для переданного объекта     public static IntPtr ObjectToIntPtr(object obj)     {         if(obj == null)         {             return IntPtr.Zero;         }          var handle = GCHandle.Alloc(obj);         return GCHandle.ToIntPtr(handle);     }          // Вспомогательная функция, потребуется в дальнейшем     public static IntPtr ActionToIntPtr<T>(Action<T> action)     {         return ObjectToIntPtr(action);     }           private static object ConvertObject(string value, Type objectType)     {         if(value == null || objectType == typeof(string))         {             return value;         }          return Newtonsoft.Json.JsonConvert.DeserializeObject(value, objectType);     }      // Автоматическая регистрация делегата     [RuntimeInitializeOnLoadMethod]     private static void Initialize()     {         RegisterCallbackDelegate(MonoPCallbackInvoke);     }      [DllImport("__Internal")]     private static extern void RegisterCallbackDelegate(MonoPCallbackDelegate callbackDelegate); }

И на стороне нативного кода:

/* MonoPCallback.h */  // Определим для наглядности специальный тип для Unity указателей typedef const void* UnityAction;  // Функция передачи колбека с данными, с которыми он вызывается void SendCallbackDataToUnity(UnityAction callback, NSDictionary* data);  /* MonoPCallback.mm */ #import <Foundation/Foundation.h> #import "MonoPCallback.h"  // Продублируем определение делегата в Objective C typedef void (*MonoPCallbackDelegate)(UnityAction action, const char* data);  // Еще одна статическая переменная, // в идеале их лучше объединить в одном глобальном объекте static MonoPCallbackDelegate _monoPCallbackDelegate = NULL;  FOUNDATION_EXPORT void RegisterCallbackDelegate(MonoPCallbackDelegate callbackDelegate) {    _monoPCallbackDelegate = callbackDelegate; }  // Этот метод можно объявить в каком-нибудь классе void SendCallbackDataToUnity(UnityAction callback, NSDictionary* data) {     if(callback == NULL)         return;     NSString* dataStr = nil;     if(data != nil) {         // Сериализуем данные в json         NSError* parsingError = nil;         NSData* dataJson = [NSJSONSerialization dataWithJSONObject:data options:0 error:&parsingError];         if (parsingError == nil) {             dataStr = [[NSString alloc] initWithData:dataJson encoding:NSUTF8StringEncoding];         } else {             NSLog(@"SendCallbackDataToUnity json parsing error: %@", parsingError);         }     }     // Переводим исполнение в Unity (главный) поток     dispatch_async(dispatch_get_main_queue(), ^{         if(_monoPCallbackDelegate != NULL)             _monoPCallbackDelegate(callback, [dataStr cStringUsingEncoding:NSUTF8StringEncoding]);     }); }

В этом примере использовался довольно универсальный подход передачи результата в виде json-строки. По переданному указателю извлекается Action со снятием фиксации в GC (то есть колбек вызывается один раз, после этого указатель становится невалидный, а Action может удалиться GC), проверяется тип требуемого аргумента (одного!), и через Json.Net данные десериализуются и приводятся к этому типу. Все эти действия не обязательны, можно создать сигнатуру MonoPCallbackDelegate другую, специфичную для конкретно вашего случая. Но данный подход позволяет не плодить много однотипных методов, а само использование свести к определению простейшего класса, задающего формат данных, и задания этого формата через generic аргументы:

/* Example.cs */ public class Example {    public class ResultData    {       public bool Success;       public string ValueStr;       public int ValueInt;    }     [DllImport("__Internal", CharSet = CharSet.Ansi)]    private static extern void GetSomeDataWithCallback(string key, IntPtr callback);     public static void GetSomeData(string key, Action<ResultData> completionHandler) {       GetSomeDataWithCallback(key, MonoPCallback.ActionToIntPtr<ResultData>(completionHandler);    } }
/* Example.mm */ #import <Foundation/Foundation.h> #import "MonoPCallback.h"  FOUNDATION_EXPORT void GetSomeDataWithCallback(const char* key, UnityAction callback) {    DoSomeStuffWithKey(key);    SendCallbackDataToUnity(callback, @{ @"Success" : @YES, @"ValueStr" : someResult, @"ValueInt" : @42  }); } 

С взаимодействием между Unity и нативным кодом разобрались. Стоит добавить, что нативный код в виде .mm файлов, или скомпиленных .a или .framework необязательно класть в Assets/Plugins/iOS. Если вы пишете не для себя, а какой-нибудь пакет для экспорта в другие проекты, складывайте все в подпапку внутри вашей специфической папки с кодом — так потом проще будет связывать концы с концами и удалять ненужные пакеты. Если плагин требует добавить какие-то стандартные iOS зависимости (фреймворки) в проект, используйте настройки импорта в Unity редакторе для .mm, .a и .framework файлов. Прибегайте к PostProcessBuild функциям только в крайнем случае. Кстати, если нужного фреймворка нет в списке инспектора, его можно написать напрямую в meta файле через текстовый редактор, соблюдая общий синтаксис.

Теперь рассмотрим, как можно отлавливать события UIApplicationDelegate и жизненного цикла приложения в частности. Тут нам на помощь приходят уже передаваемые в Unity сообщения через NotificationCenter. Рассмотрим способ выполнить нативный скрипт плагина еще до загрузки Unity и подписаться на эти события.

/* ApplicationStateListener.mm */ #import <Foundation/Foundation.h> #import <UIKit/UIKit.h> #import "AppDelegateListener.h"  @interface ApplicationStateListener : NSObject <AppDelegateListener> + (instancetype)sharedInstance; @end  @implementation ApplicationStateListener // Статическая переменная проинициализируется на старте приложения, // еще до запуска Unity Player static ApplicationStateListener* _applicationStateListenerInstance = [[ApplicationStateListener alloc] init];  + (instancetype)sharedInstance {     return _applicationStateListenerInstance; }  - (instancetype)init {     self = [super init];     if (self) {         // Тут можно сделать что-нибудь на старте приложения         // регистрируемся в Notification Center на основные события UIApplicationDelegate,         // для этого в Unity есть специальный метод         UnityRegisterAppDelegateListener(self);     }     return self; }  - (void)dealloc {     // Отписываемся от всех событий. По-идее, этого никогда не случится     [[NSNotificationCenter defaultCenter] removeObserver:self]; }  #pragma mark AppDelegateListener - (void)applicationDidFinishLaunching:(NSNotification *)notification {     NSDictionary *launchOptions = notification.userInfo;     // Довольно часто требуется что-то извлечь из launchOptions,      // особенно в маркетинговых sdk }  - (void)applicationDidEnterBackground:(NSNotification *)notification {     // Обрабатываем паузу приложения }  - (void)applicationDidBecomeActive:(NSNotification *)notification {     // Обрабатываем выход из паузы }  - (void)onOpenURL:(NSNotification*)notification {     NSDictionary* openUrlData = notification.userInfo;     // Обрабатываем запуск по ссылке }  @end

Так можно отловить большинство событий жизненного цикла приложения. Не все методы, конечно, доступны. Например, из последнего, нет application:performActionForShortcutItem:completionHandler: для реакции на запуск по ярлыку из контекстного меню 3d touch. Но так как этого метода нет и в базовом UnityAppController, его можно расширить с помощью категории в любом файле плагина и, например, кинуть новое событие в Notification Center:

/* ApplicationExtension.m */ #import "UnityAppController.h"  @implementation UnityAppController (ShortcutItems)  - (void)application:(UIApplication *)application performActionForShortcutItem:(UIApplicationShortcutItem*)shortcutItem completionHandler:(void (^)(BOOL succeeded))completionHandler {     [[NSNotificationCenter defaultCenter] postNotificationName:@"UIApplicationPerformActionForShortcutItem" object:nil userInfo:@{ UIApplicationLaunchOptionsShortcutItemKey : shortcutItem }];     completionHandler(YES); }  @end

На iOS есть еще одна проблема, когда требуется добавить сторонние библиотеки из CocoaPods — пакетного менеджера для XCode. Такое встречается редко, часто есть альтернатива внедрения библиотеки напрямую. Но на этот случай тоже есть решение. Суть его в том, что вместо Podfile (файла — манифеста зависимостей) публикуются зависимости в xml файле, а при экспорте XCode проекта автоматически добавляется поддержка CocoaPods и создается xcworkspace с уже включенными зависимостями. Xml файлов может быть несколько, они могут лежать в Assets в подпапке с конкретным плагином, Unity Jar Resolver сам просканирует все эти файлы и найдет зависимости. Свое название инструмент получил, потому что изначально он создавался делать то же самое с Android зависимостями, и там проблема включения сторонних нативных библиотек более острая, поэтому без такого инструмента никак не обойтись. Но об этом — в следующей части статьи.

FavoriteLoadingДобавить в избранное
Posted in Без рубрики

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