company_banner

Пишем плагин для 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
    • 7,6k
    • 3

    Pixonic

    295,00

    Международная компания по разработке мобильных игр

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

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

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

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое