Пишем плагин для Unity правильно. Часть 1: iOS
Когда делаешь на Unity игры для мобильных платформ, рано или поздно придется писать часть функционала на нативном языке платформы, будь то iOS (Objective C или Swift) или Android (Java, Kotlin). Это может быть свой код или интеграция сторонней библиотеки, сама установка может заключаться в копировании файлов или распаковки unitypackage, не суть. Итог этой интеграции всегда один: добавляются библиотеки с нативным кодом (.jar, .aar, .framework, .a, .mm), скрипты на C# (для фасада к нативному коду) и Game Object со специфичным MonoBehavior для отлавливания событий движка и взаимодействия со сценой. А еще часто требуется включать библиотеки зависимостей, которые нужны для работы нативной части.
Весь этот механизм интеграции обычно не вызывает проблем на чистом проекте, в котором еще нет (или мало) интеграций таких сторонних библиотек. Но когда проект разрастается, всплывает много проблем, усложняющих этот процесс, а часто дающих необходимость дополнительных модификаций и адаптирования под проект плагина, что потом выливается в увеличении сложности последующей поддержки и обновления.
Вот основные из них:
- Game Object обычно должен загружаться с первой сценой, и быть DontDestroyOnLoad. Приходится создавать специальную сцену с кучей таких невыгружаемых объектов, а потом еще и лицезреть их в редакторе в процессе тестирования.
- Все эти файлы часто складываются в Assets/Plugins/iOS и Assets/Plugins/Android, со всеми зависимостями. Потом сложно разобраться, откуда и для чего какой файл библиотеки, а зависимости часто конфликтуют с уже установленными для других плагинов.
- Если библиотеки лежат в специальных подпапках, конфликта при импорте не происходит, зато при сборке может возникнуть ошибка дубликата классов, если в итоге все-таки лежат где-то одни и те же зависимости разных версий.
- Иногда вызывать инициализацию нативной части в Awake слишком поздно, а событий MonoBehavior может быть недостаточно.
- Unity Send Message для взаимодействия между нативным и C# кодом неудобен, так как асинхронный и с одним строковым аргументом, без вариантов.
- Хочется использовать C# делегаты в качестве колбеков.
- Некоторые плагины требуют на 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
// Объявляем новый тип для делегата, эквивалентный объявленному в 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(Action 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
#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 completionHandler) {
GetSomeDataWithCallback(key, MonoPCallback.ActionToIntPtr(completionHandler);
}
}
/* Example.mm */
#import
#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
#import
#import "AppDelegateListener.h"
@interface ApplicationStateListener : NSObject
+ (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 зависимостями, и там проблема включения сторонних нативных библиотек более острая, поэтому без такого инструмента никак не обойтись. Но об этом — в следующей части статьи.