Хабрахабр

Пишем плагин для 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 зависимостями, и там проблема включения сторонних нативных библиотек более острая, поэтому без такого инструмента никак не обойтись. Но об этом — в следующей части статьи.

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

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

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