Unity3D: Модификация делегата iOS приложения +14


Думаю, многим в ходе разработке игры для iOS приходилось сталкиваться с тем, что возникает необходимость использовать тот или иной нативный функционал. Касаемо Unity3D, в данном вопросе может возникать очень много проблем: для того, чтобы внедрить какую-то фичу, приходится смотреть в сторону нативных плагинов, написанных на Objective-C. Кто-то в этот момент сразу отчаивается и забрасывает идею. Кто-то ищет готовые решения в AssetStore или на форумах, надеясь на то, что готовое решение уже существует. Если же готовых решений не существует, то самые стойкие из нас не видят другого выхода, кроме как погрузиться в пучину iOS программирования и взаимодействия Unity3D с Objective-C кодом.

Тех, кто выбирает последний путь (хотя, думаю, они и сами знают), ожидает множество проблем на этом нелегком и тернистом пути:

  • iOS — абсолютно незнакомая и обособленная экосистема, развивающаяся своим путем. Как минимум придется потратить довольно много времени, чтобы понять как можно подобраться к приложению, и где в недрах автоматически сгенерированного XCode проекта находится код взаимодействия Unity3D движка c нативной составляющей приложения.
  • Objective-C — довольно обособленный и мало на что похожий язык программирования. А когда речь заходит о взаимодействии с C++ кодом Unity3D приложения, то на сцену выходит «диалект» этого языка, под названием Objective-C++. Информации о нем совсем немного, большая ее часть древняя и архивная.
  • Сам протокол взаимодействия Unity3D с iOS приложением довольно скудно описан. Расчитывать стоит исключительно на туториалы энтузиастов в сети, которые пишут как разработать простейший нативный плагин. Мало кто при этом затрагивает более глубокие вопросы и проблемы, возникающие при потребности сделать что-то сложное.

Тех, кто хочет узнать о механизмах взаимодействия Unity3D с iOS приложением, прошу под кат.

С целью внести больше ясности в покрытое мраком узкое место взаимодействия Unity3D с нативным кодом, в этой статье расписаны аспекты взаимодействия делегата iOS приложения с кодом Unity3D, с помощью каких инструментов С++ и Objective-C это реализовано, и как самому модифицировать делегат приложения. Эта информация может быть полезна как для лучшего понимания механизмов работы связки Unity3D+iOS, так и для практического применения.

Взаимодействие между iOS и приложением


В качестве введения, давайте рассмотрим как реализовано в iOS взаимодействие приложения с системой и наоборот. Схематично старт iOS приложения выглядит так:

image

Для изучения этого механизма с точки зрения кода, подойдет новое, созданное в XCode приложение по шаблону «Single View App».



Выбрав этот шаблон, на выходе мы получим простейшее iOS приложение, которое сможет запуститься на устройстве или эмуляторе и показать белый экран. XCode услужливо создаст проект, в котором будет всего 5 файлов с исходным кодом (при этом 2 из них — заголовочные .h файлы) и несколько вспомогательных файлов, неинтресных нам (верстка, конфиги, иконки).



Давайте разберемся, за что отвечают файлы исходного кода:

  • ViewController.m / ViewController.h — не сильно интересующие нас исходники. Так как в вашем приложении есть View (который представлен не кодом, а с помощью Storyboard), вам понадобится класс-Controller, который будет этим View управлять. В целом, таким образом сам XCode подталкивает нас использовать паттерн MVC. В проекте, который генерирует Unity3D не будет этих исходных файлов.
  • AppDelegate.m / AppDelegate.h — делегат вашего приложения. Интересующая нас точка в приложении, где начинается работа кастомного кода приложения.
  • main.m — стартовая точка приложения. На манер любого C/C++ приложения содержит функцию main, с которой начинается работа программы.

Теперь, посмотрим, код, начиная с файла main.m:

int main(int argc, char * argv[]) { //1
    @autoreleasepool {  //2
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); // 3
} } 

Cо строкой 1 все понятно и без объяснений, перейдем к сторке 2. В ней указывается, что жизненный цикл приложения будет происходить внутри Autorelease pool. Использование autorelease pool, говорит нам о том, что мы поручим управление памятью приложения именно этому пулу, то есть он будет заниматься решением вопросов, когда нужно освободить память под ту или иную переменную. Рассказ об управлении памятью на iOS выходит за рамки данного повествования, по этому нет смысла углубляться в эту тему. Для тех, кому интересна эта тема, можно ознакомиться, например, с этой статьей.

Перейдем к строке 3. В ней вызывается функция UIApplicationMain. Ей передаются параметры запуска программы (argc, argv). Затем, в этой функции указывается какой класс использовать в качестве главного класса приложения, создается его экземпляр. И, наконец, указывается какой класс использовать в качестве делегата приложения, создается его экземпляр, настраиваются связи между экземпляром класса приложения и его делегатом.

В нашем примере, в качестве класса, который будет представлять экземпляр приложения передается nil — грубо говоря, местный аналог null. Помимо nil, вы можете передать туда конкретный класс, унаследованный от UIApplication. Если указывается nil, то будет использован UIApplication. Этот класс представляет собой централизованную точку управления и координации работы приложения на iOS и является синглтоном (singleton). С его помощью вы можете узнать практически все о текущем состоянии (state) приложения, уведомлениях, окнах, произошедших события в самой системе, которые затрагивают данное приложение и обо многом другом. Этот класс практически никогда не наследуют. На создании класса Делегата Приложения мы остановимся подробнее.

Создание делегата приложения


Указание того, какой класс использовать в качестве делегата приложения происходит в вызове функции

NSStringFromClass([AppDelegate class])

Разберем этот вызов по частям.

[AppDelegate class]

Эта конструкция возвращает объект класса AppDelegate (который объявлен в AppDelegate.h/.m), а функция NSStringFromClass возвращает имя класса как строку. Мы просто передаем в функцию UIApplicationMain строковое имя класса, который нужно создать и использовать в качестве делегата. Для большего понимания, строку 3 в файле main.m можно было бы заменить следующей:

return UIApplicationMain(argc, argv, nil, @"AppDelegate");

И результат ее выполнения был бы идентичным исходному варианту. Видимо, разработчики решили прийти именно к такому подходу, чтобы не использовать строковую константу. Со стандартным подходом, если вы переименуете класс делегата, синтаксический анализатор тут же выдаст ошибку. В случае использования обычной строки, код будет успешно компилироваться, а ошибку вы получите только запустив приложение.

Подобный механизм создания класса, используя только строковое имя класса, может напоминать вам Reflection из C#. Objective-C и его среда исполнения (runtime) обладает гораздо большими возможностями, чем Reflection в C#. Это достаточно важный момент в контексте данной статьи, но на описание всех возможностей ушло бы очень много времени. Однако, мы еще встретимся с «Reflection» в Objective-C далее. Осталось разобраться с понятием делегата приложения и его функциями.

Делегат приложения


Все взаимодействие приложения с iOS происходит в классе UIApplication. Данный класс берет на себя крайне много ответственностей — уведомляет о происхождении событий, о состоянии приложения и многом другом. По большей части, его роль — уведомительная. Но когда в системе что-то происходит, мы должны иметь возможность как-то отреагировать на это изменение, выполнить какую-то кастомную функциональность. Если экземпляр класса UIApplication будет заниматься еще и этим — такая практика начнет напоминать подход под названием Божественный объект. По этому стоит задуматься о том, чтобы освободить этот класс от части обязанностей.

Именно для этих целей в экосистеме iOS используется такая вещь как делегат приложения. Из самого названия можно сделать вывод, что мы имеем дело с таким паттерном проектирования, как Делегирование. Если кратко, то мы просто передаем ответственность за обработку реакции на те или иные события приложения делегату приложения. С этой целью в нашем примере создан класс AppDelegate, в котором мы можем написать кастомную функциональность, оставляя при этом класс UIApplication работать в режиме черного ящика. Такой подход может показаться кому-то спорным в плане красоты проектирования архитектуры, но авторы iOS сами подталкивают нас к этому подходу и подавляющее большинство разработчиков (если не все) используют именно его.

Чтобы наглядно убедиться, насколько часто в ходе работы приложения делегат приложения получает то или иное сообщение, взглянем на схему:

image

В желтых прямоугольниках обозначены вызовы тех или иных методов делегата в ответ на определенные события жизни приложения (application lifecycle). Эта схема иллюстрирует только события, связанные с изменением состояния приложения и не отображает многих других аспектов ответственности делегата, таких как принятие уведомлений или взаимодействие с фреймворками.

Приведем примеры, когда нам может понадобится доступ к делегату приложения из Unity3D:

  1. обработка push и локальных уведомлений
  2. логирование в аналитику события о запуске приложения
  3. определение способа запуска приложения — «на чистую» или выход из background
  4. как именно было запущено приложение — по тачу на уведомление, c использованием Home Screen Quick Actions или просто по тачу на инконку
  5. взаимодествие с WatchKit или HealthKit
  6. открытие и обработка URL из другого приложения. Если данный URL относится к вашему приложению, вы можете обработать его в своем приложении вместо того, чтобы давать системе открыть этот URL в браузере

Это далеко не весь список сценариев. Кроме того, стоит отметить, что делегат модифицируют многие системы аналитики и рекламы в своих нативных плагинах.

Как Unity3D реализует делегат приложения


Давайте теперь посмотрим на XCode проект, сгенерированный Unity3D и узнаем, как делегат приложения реализован в Unity3D. При сборке для платформы iOS Unity3D автоматически генерирует вам XCode проект, в котором используется довольно много шаблонного кода. К такому шаблонному коду относится и код Делегат Приложения. Внутри любого сгенерированного проекта вы можете найти файлы UnityAppController.h и UnityAppController.mm. В этих файлах содержится код интересующего нас класса UnityAppController.

Фактически, Unity3D использует модифицированный вариант шаблона «Single View Application». Только в этом шаблоне Unity3D использует делегат приложения не только для обработки событий iOS, но и для инициализации самого движка, подготовки графических компонент и многого другого. Это очень легко понять, если взглянуть на метод

- (BOOL)application:(UIApplication*)application didFinishLaunchingWithOptions:(NSDictionary*)launchOptions

в коде класса UnityAppController. Этот метод вызывается в момент инициализации приложения, когда можно передавать управление вашему кастомному коду. Внутри этого метода, например, можно найти следующие строки:

UnityInitApplicationNoGraphics([[[NSBundle mainBundle] bundlePath] UTF8String]);

    [self selectRenderingAPI];
    [UnityRenderingView InitializeForAPI: self.renderingAPI];

    _window         = [[UIWindow alloc] initWithFrame: [UIScreen mainScreen].bounds];
    _unityView      = [self createUnityView];

    [DisplayManager Initialize];
    _mainDisplay    = [DisplayManager Instance].mainDisplay;
    [_mainDisplay createWithWindow: _window andView: _unityView];

    [self createUI];
    [self preStartUnity];

Даже не вдаваясь в подробности того, что делают эти вызовы можно догадаться, что они связаны с подготовкой Unity3D к работе. Получается следующий сценарий:

  1. Вызывается функция main из main.mm
  2. Создаются экземпляры классов приложения и его делегата
  3. В делегате приложения происходит подготовка и запуск Unity3D движка
  4. К работе приступает ваш кастомный код. Если вы используете il2cpp, то ваш код переводится из C# в IL а затем в C++ код, который непосредственно попадает в XCode проект.

Этот сценарий звучит довольно просто и логично, но несет с собой потенциальную проблему: как мы можем модифицировать делегат приложения, если у нас нет доступа к исходному коду при работе в Unity3D?

Задел Unity3D для модификации делегата приложения


Мы можем заглянуть в файлы AppDelegateListener.mm/.h. В них содержатся макросы, которые позволяют зарегистрировать любой класс как слушатель событий делегата приложения. Это неплохой подход, нам не нужно модифицировать существующий код, а только дописать новый. Но в нем есть весомый недостаток: поддерживаются далеко не все события приложения и нет возможности получить информацию о запуске приложения.

Самый очевидный, однако, неприемлемый выход из положения — изменить исходный код делегата руками после того, как Unity3D соберет XCode проект. Проблема этого подхода очевидна — вариант подойдет, если вы делаете сборки руками и вас не смущает необходимость после каждой сборки модифицировать код вручную. В случае с использованием сборщиков (Unity Cloud Build или любая другая билд-машина) такой вариант абсолютно неприемлем. Для этих целей разработчики Unity3D оставили нам лазейку.

В файле UnityAppController.h кроме объявления переменных и методов содержится также определение макроса:

#define IMPL_APP_CONTROLLER_SUBCLASS(ClassName) ... 

Этот макрос как раз дает возможность переопределить делегат приложения. Для этого вам нужно сделать несколько несложных шагов:

  1. Написать собственный делегат приложения на Objective-C
  2. Где-нибудь внутри исходного кода добавить следующую строку
    IMPL_APP_CONTROLLER_SUBCLASS(Имя_класса_вашего_класса)
  3. Положить этот исходник внутрь папки Plugins/iOS вашего Unity3D проекта

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

Как работает макрос по замене делегата


Посмотрим на полный исходный код макроса:

#define IMPL_APP_CONTROLLER_SUBCLASS(ClassName) ...
@interface ClassName(OverrideAppDelegate)       {                                               }                                               +(void)load;                                    @end                                            @implementation ClassName(OverrideAppDelegate)  +(void)load                                     {                                                   extern const char* AppControllerClassName;      AppControllerClassName = #ClassName;        }                                               @end    

Использование этого макроса в вашем исходнике добавит код, описанный в макросе, в тело вашего исходника на этапе компиляции. Этот макрос делает следующие действия. Сначала он добавит в интерфейс вашего класса метод load. Интерфейс в контексте Objective-C можно рассматривать как набор публичных полей и методов. Говоря языком C#, в вашем классе появится статический метод load, который ничего не возвращает. Далее, в код вашего класса добавится реализация этого метода load. В этом методе будет объявлена переменная AppControllerClassName, представляющая собой массив типа char и затем этой переменной будет присвоено значение. Это значение — строковое имя вашего класса. Очевидно, что этой информации недостаточно, для понимания механизма работы этого макроса, по этому нам стоит разобраться с тем, что это за метод такой «load» и зачем объявляется переменная.

В официальной документации говорится о том, что load — специальный метод, который вызывается один раз для каждого класса (конкретно класса, а не его экземпляров) на самом раннем этапе запуска приложения, еще до того, как будет вызвана функция main. Среда исполнения Objective-c (runtime) при старте приложения, зарегистрирует все классы, которые будут использоваться в ходе работы приложения и вызывет у них метод load, если он реализован. Получается, что еще до начала работы какого-либо кода нашего приложения, в ваш класс добавится переменная AppControllerClassName.

Тут вы можете подумать: «А какой смысл с наличия этой переменной, если она объявляется внутри метода и будет уничтожена из памяти, при выходе из этого метода ?». Ответ на этот вопрос лежит немного за границами Objective-C.

Причем тут С++?


Давайте взглянем еще раз на объявление этой переменной

extern const char* AppControllerClassName;

Единственное, что может быть непонятного в этом объявлении — модификатор extern. Если попытаться использовать этот модификатор в чистом Objective-C, то XCode выдаст ошибку. Дело в том, что этот модификатор не является частью Objective-C, он реализован в C++. Objective-C можно довольно лаконично описать, сказав, что это «язык C с классами». Он является расширением языка С и разрешает неограниченное использование C кода вперемежку с Objective-C кодом.

Однако, чтобы использовать extern и другие возможности C++ нужно пойти на некоторый трюк — использовать Objective-C++. Информации об этом языке практически нет, по причине того, что это просто Objective-C код, который допускает вставки С++ кода. Для того, чтобы компилятор посчитал, что какой-то исходный файл следует компилировать как Objective-C++, а не Objective-C нужно всего лишь поменять расширение этого файла с .m на .mm.

Сам модификатор extern используется, чтобы объявить глобальную переменную. Точнее, сказать компилятору «Поверь мне, такая переменная существует, но память под нее была выделена не здесь, а в другом исходнике. И значение у нее тоже есть, гарантирую». Таким образом, наша строка кода просто создает глобальную переменную и хранит в ней имя нашего кастомного класса. Осталось только понять, где эта переменная может использоваться.

Обратно в main


Вспоминаем о том, что говорилось ранее — делегат приложения создается путем указания имени класса. Если в обычном шаблоне XCode проекта делегат создавался с использованием константного значения [myClass class], то, по видимому, ребята из Unity решили, что это значение стоит обернуть в переменную. Методом научного тыка, берем XCode проект, сгенерированный Unity3D и переходим к файлу main.mm.

В нем мы видим более сложный код, чем ранее, часть этого кода упущена за ненадобностью:

// WARNING: this MUST be c decl (NSString ctor will be called after +load, so we cant really change its value)
const char* AppControllerClassName = "UnityAppController";

int main(int argc, char* argv[])
{
    ...
        UIApplicationMain(argc, argv, nil, [NSString stringWithUTF8String: AppControllerClassName]);
    }  return 0;
}

Тут мы видим объявление этой самой переменной, и создание делегата приложения с ее помощью.
Если мы создали кастомный делегат, то нужная переменная существует и уже имеет значение — имя нашего класса. А объявление и инициализация переменной до функции main гарантирует, что у нее есть значение по умолчанию — UnityAppController.

Теперь с этим решением должно быть все предельно ясно.

Проблема макроса


Конечно, для подавляющего большинства ситуаций, использование этого макроса — отличное решение. Но стоит заметить, что в нем есть большой подводный камень: вы не сможете иметь больше одного кастомного делегата. Так получается потому, что если 2 или более классов используют макрос IMPL_APP_CONTROLLER_SUBCLASS(ClassName), то для первого из них значение нужной нам переменной будет присвоено, а дальнейшие присваивания проигнорированы. Да и эта переменная — строка, то есть ей нельзя присвоить больше одного значения.

Вы можете подумать, что эта проблема вырождена и на практике мало вероятна. Но, этой статьи не было бы, если бы такая проблема действительно не произошла, да еще при весьма странных обстоятельствах. Ситуация может быть следующая. У вас есть проект, в котором вы используете много сервисов аналитик и рекламы. У многих из этих сервисов есть Objective-C составляющие. Они давно в вашем проекте и вы не знаете с ними бед. Тут у вас появляется необходимость написать кастомный делегат. Вы используете волшебный макрос, призванный избавить вас от проблем, собираете проект и получаете отчет об успехе сборки. Запускаете проект на устройстве, а ваш функционал не работает и при этом вы не получаете ни единой ошибки.

А дело может быть в том, что один из плагинов рекламы или аналитики использует тот же макрос. К примеру, в плагине от AppsFlyer данный макрос используется.

Какое значение примет extern переменная в случае нескольких объявлений?


Интересно разобраться, если одна и та же extern переменная объявлена в нескольких файлах, и они инициализируются на манер нашего макроса (в методе load) то как можно понять, какое значение примет переменная? Чтобы понять закономерность было создано простое тестовое приложение, код которого можно посмотреть здесь.

Суть приложения проста. Есть 2 класса A и B, в обоих классах объявлена extern переменная AexternVar, ей присваивается определенное значение. Значения переменной в классах задается разное. В функции main происходит вывод в лог значения этой переменной. Экспериментальным путем выяснилось, что значение переменной зависит от того, в каком порядке исходники добавлены в проект. От этого зависит в каком порядке среда исполнения Objective-C будет регистрировать классы в ходе работы приложения. Если хотите повторить эксперимент, откройте проект и выберите в настройках проекта вкладку Build Phases. Так как проект тестовый и маленький — в нем всего 8 исходников. Все они присутствуют на вкладке Build Phases в списке Compile Sources.



Если в этом списке исходник класса A будет выше исходника класса B, то переменная примет значение из класса B. В противном случае переменная примет значение из класса A.

Только представьте, сколько проблем может теоретически вызвать это маленький нюанс. Особенно, если проект огромный, автоматически сгенерированный и вы не знаете, в каких классах такая переменная объявлена.

Решение проблемы


Ранее в статье уже говорилось о том, что Objective-C даст фору C# Reflection. Конкретно для решения нашей проблемы можно использовать механизм, который носит название Method Swizzling. Суть этого механизма заключается в том, что мы имеем возможность заменить реализацию метода любого класса на другую в ходе работы приложения. Таким образом, мы можем заменить интересующий нас метод в UnityAppController на кастомный. Берем существующую реализацию и дополняем нужным нам кодом. Пишем код, который подменяет существующую реализацию метода на нужную нам. В ходе работы приложения делегат использующий макрос будет работать как раньше, вызывая базовую реализацию у UnityAppController, а там вступит в дело наш кастомный метод и мы добьемся желаемого результата. Такой подход хорошо расписан и проиллюстрирован в этой статье. С помощью этого приема мы можем сделать вспомогательный класс — аналог кастомного делегата. В этом классе напишем весь кастомный код, сделав кастомный класс своего рода Оберткой (Wrapper) для вызова функционала других классов. Такой подход будет работать, но обладает крайней неявностью в силу того, что сложно отследить, где происходит замена метода, и к каким последствиям это приведет.

Еще одно решение проблемы


Основной аспект случившейся проблемы заключается в том, что кастомных делегатов хочется иметь много, а можно — только один, или частично заменить на второй. При этом, нет возможности сделать так, чтобы код кастомных делегатов не расползался по разным исходным файлам. Получается, что эталонной можно считать ситуацию, когда делегат в приложении всего один, кастомных классов нужно иметь возможность создавать сколько угодно, при этом макросом не пользуется ни один из этих классов, чтобы избежать проблем.

Дело за малым, осталось определить, как подобное можно сделать с помощью Unity3D, при этом оставляя возможность сборки проекта с помощью билд-машины. Алгоритм решения следующий:

  1. Пишем кастомные делегаты в необходимом количестве, разделяя логику плагинов по разным классам, соблюдая принципы SOLID и не прибегая к изощрениям.
  2. Чтобы избежать использования макроса берем исходник UnityAppController из сгенерированного XCode проекта и модифицируем для собственных нужд. Напрямую создаем экземпляры нужных классов и вызываем из UnityAppController методы этих классов.
  3. Сохраняем наш модифицированный UnityAppController и добавляем себе в Unity проект.
  4. Ищем возможность при сборке XCode проекта автоматизировано подменять стандартный UnityAppController на тот, который мы сохранили себе в проект

Самым сложным пунктом из этого списка, бесспорно является последний. Однако, данная возможность может быть реализована в Unity3D по средствам скрипта пост-процесса сборки (post process build). Такой скрипт и был написан одной прекрасной ночью, его можно посмотреть на GitHub.

Этот пост-процесс довольно прост в использовании, выбираете его в Unity проекте. Смотрите в окно Inspector и видите там поле под названием NewDelegateFile. Перетаскиваете в это поле ваш модифицированный вариант UnityAppController'a и сохраняете.



При сборке iOS проекта стандартный делегат будет заменен на модифицированный, при этом никакого ручного вмешательства не требуется. Теперь, при добавлении новых кастомных делегатов в проект, вам нужно будет только модифицировать валяющийся у вас в Unity проекте вариант UnityAppController'a.

P.S.


Спасибо всем, кто добрался до конца, статья действительно получилась крайне длинной. Надеюсь, расписанная информация окажется полезной.




К сожалению, не доступен сервер mySQL