Розробка

Unity3D: Модифікація делегата iOS додатку

Думаю, багатьом в ході розробки гри для 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 додатки виглядає так:

Для вивчення цього механізму з точки зору коду, підійде нове, створене в 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
} } 

З рядком 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 самі підштовхують нас до цього підходу і переважна більшість розробників (якщо не всі) використовують саме його.

Щоб наочно переконатися, наскільки часто в ході роботи програми делегат додатки отримує те чи інше повідомлення, поглянемо на схему:

У жовтих прямокутниках позначені виклики тих чи інших методів делегата у відповідь на певні події життя додатки (application lifecycle). Ця схема ілюструє тільки події, пов’язані зі зміною стану програми і не відображає багатьох інших аспектів відповідальності делегата, таких як прийняття повідомлень або взаємодія з фреймворками.

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

  1. обробка push і локальних повідомлень
  2. логування в аналітику події про запуск програми
  3. визначення способу запуску програми — «чисту» або вихід з background
  4. як саме було запущено додаток — тачу на повідомлення, c використанням Home Screen Quick Actions або просто за тачу на инконку
  5. взаимодествие з WatchKit або HealthKit
  6. відкриття та обробка 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 проекту делегат створювався з використанням константного значення [class myClass], то, мабуть, хлопці з Unity вирішили, що це значення варто обернути в змінну. Методом наукового тику, беремо XCode проект, згенерований Unity3D і переходимо до файлу main.mm.

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

// WARNING: this MUST be c decl (NSString ctor will be called after +load, so we can't 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.

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

Related Articles

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *

Close