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

комментарии (3)

+2
WeslomPo ,  
Отличная статья! Вспоминаю боль, когда впервые столкнулся с конфликтом библиотек, во время сборки проекта под андроид.
Стоит отметить что GameObject можно спрятать в иерархии, чтобы он не мешался:
gameObject.hideFlags = HideInHierarchy;

Вместо сцены, на мой взгляд, лучше использовать Zenject с инсталлером на ProjectContext, но для библиотек — это не очень подходит.
Можно еще использовать атрибут
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Initialize(){ Debug.Log("[RuntimeInitializeOnLoadMethod]");}

И, хотя, время исполнения его не гарантированно, это, в теории, позволит избежать создания GameObject. То есть, можно в этом методе создать класс не наследованный от MonoBehaviour и сохранить его в статическую переменную. Сигналы же, получать через колбэки от библиотеки.
+1
antrash ,  
когда вы сделаете версию Android?
+3
Groozze ,