Как стать автором
Обновить
0
Pixonic
Developing and publishing games since 2009

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

Время на прочтение 10 мин
Количество просмотров 17K


Когда делаешь на 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
Комментарии 3
Комментарии Комментарии 3

Публикации

Информация

Сайт
pixonic.com
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Кипр

Истории