Рекордний час: як ми збільшили швидкість запуску програми Пошти Mail.Ru на iOS



Швидкість запуску — критично важливий фактор для довгострокового успіху програми. Вона особливо важлива для таких додатків як Пошта Mail.Ru, які запускають по багато разів на день з метою швидко перевірити нові листи у «Вхідних».

В статті мова піде про оптимізацію великого за обсягом коду програми, що має відносно довгу історію розробки і встиг обрости за цей час безліччю видимої і прихованої від користувача функціональності. Завдання полягало в тому, щоб скоротити час запуску без урізання функціональності.

Зміст:

Що можна і потрібно оптимізувати?
Під часом запуску ми розуміємо інтервал між натисканням на іконку програми і моментом, коли у користувача з'являється можливість виконувати дії, заради яких він і запускає додаток. Найважливіше — це суб'єктивне сприйняття часу користувач, тому можна йти на різні хитрощі, не пов'язані безпосередньо з оптимізацією коду. Але і об'єктивні показники, тобто виміряний час основних етапів запуску, безпосередньо впливають на суб'єктивні. Крім того, вони дозволяють виміряти вплив того чи іншого зміни на результат.

Ми розглядаємо випадок, коли додаток вивантажено з оперативної пам'яті, так як відкриття вже запущеного додатка займає набагато менше часу. В ідеалі слід ще більше обмежити умови, виключивши вплив різних кешей операційної системи, але тоді довелося б перед кожним виміром перезавантажувати пристрій, що займає більше часу і складніше піддається автоматизації.

Весь час запуску програми можна розділити на два великих етапи: перший — від натискання на іконку до першого виклику користувальницького коду (зазвичай це методи
+load
), другий – безпосередньо виконання коду програми, включаючи дії, що виконуються системними фреймворками (запуск run loop, завантаження екрана запуску і основного UI) і ваш власний код.

Завантаження програми в пам'ять
Перший етап являє собою завантаження виконуваного коду програми у віртуальну пам'ять, завантаження використовуваних системних і користувальницьких динамічних бібліотек, підгонку адрес, які вказують на різні символи всередині і зовні основного виконуваного файлу, ініціалізацію рантайма Objective-C. Деякі системні фреймворки можуть мати свої методи
+load
або функції-конструктори, які також виконуються на цьому етапі.

Дуже докладну розповідь про перший етап був на WWDC в цьому році в чудовому доповіді 406 Optimizing App Startup Time, коротку витримку з якого можна прочитати в блозі Use Your Loaf — Slow App Startup Times.

Перерахую основні рекомендації.

  • Запустіть додаток з змінної оточення
    DYLD_PRINT_STATISTICS
    (щоб запрацювало на iOS 9, необхідно також поставити галочку Dynamic Linker API Usage), щоб побачити, скільки часу займають різні дії першого етапу. Ось приклад такої статистики на iPhone 5 під iOS 9.2.1:

    total time: 2.1 seconds (100.0%)
    total images loaded: 309 (304 from dyld shared cache)
    total segments mapped: 14, into 2352 pages with 140 pages pre-fetched
    total images loading time: 842.08 milliseconds (39.3%)
    total dtrace DOF registration time: 0.19 milliseconds (0.0%)
    total rebase fixups: 310,006
    total rebase fixups time: 51.52 milliseconds (2.4%)
    total binding fixups: 376,990
    total binding fixups time: 598.68 milliseconds (27.9%)
    total weak binding fixups time: 6.55 milliseconds (0.3%)
    total bindings lazily fixed up: 0 of 0
    total time in initializers and ObjC setup: 639.21 milliseconds (29.8%)
    libSystem.B.dylib : 130.68 milliseconds (6.1%)
    libBacktraceRecording.dylib : 1.05 milliseconds (0.0%)
    libc++.1.dylib : 0.16 milliseconds (0.0%)
    CoreFoundation : 1.74 milliseconds (0.0%)
    CFNetwork : 0.02 milliseconds (0.0%)
    vImage : 0.00 milliseconds (0.0%)
    libGLImage.dylib : 0.15 milliseconds (0.0%)
    QuartzCore : 0.02 milliseconds (0.0%)
    libViewDebuggerSupport.dylib : 0.11 milliseconds (0.0%)
    libTelephonyUtilDynamic.dylib : 0.00 milliseconds (0.0%)
    CoreTelephony : 0.04 milliseconds (0.0%)
    MRMail-Alpha-Enterprise-Shared : 275.17 milliseconds (12.8%)
    MRMail-Alpha-Enterprise : 224.68 milliseconds (10.5%)
    total symbol trie searches: 473750
    total symbol table binary searches: 0
    total images defining weak symbols: 26
    total images using weak symbols: 63
    
  • Щоб скоротити
    total images loading time
    , використовуйте якомога менше динамічних фреймворків. Зазвичай велика частина з них (близько 200-300) — це системні фреймворки, які поставляються разом з iOS, і їх час завантаження вже оптимізовано. Але якщо ви додаєте власні фреймворки, це може негативно вплинути на швидкість запуску програми. До речі, стандартні бібліотеки Swift теж вважаються одними.
  • На час
    rebase fixups
    ,
    binding fixups
    та
    ObjC setup
    впливає кількість символів Objective-C: класів, селекторів, категорій. Для вже існуючих додатків тут складно дати рекомендації, які не були б пов'язані зі значним рефакторінгом або урізанням функціональності. Але якщо у вас є така можливість, можна спробувати максимальна кількість коду писати на чистому Swift, тобто без використання рантайма Objective-C.
Ці рекомендації можуть забезпечити непогане прискорення, і ними не варто нехтувати. Але набагато цікавіше розглянути другий етап, коли виконується код, який ми пишемо самі і, отже, можемо профілювати, досліджувати і покращувати.

Пошук можливостей оптимізації
В першу чергу необхідно визначити ключові показники, за якими ми будемо оцінювати корисність оптимізацій. Ми вирішили зосередити основні зусилля на одному з найбільш поширених сценаріїв: користувач вже був раніше залягання в свій обліковий запис і, запускаючи програму, потрапляє в список листів у папці «Вхідні». Крім того, ми визначили кілька невидимих для користувача етапів, які є частиною внутрішньої логіки додатка, щоб краще розуміти, на які з них йде велика частина часу. Ось деякі з них: завантаження налаштувань програми завантаження облікових записів, завантаження закэшированного списку папок, поява в'ю контролера списку листів, ініціалізація фонових екранів.

Time Profiler
Звичайно ж, будь-який грамотний iOS-розробник при пошуку проблем з продуктивністю в першу чергу звернеться до інструменту Time Profiler. І ми теж почали з нього. У нашому уявленні завдання повинна була легко зважитися приблизно такою послідовністю кроків: дивимося в профайлер, помічаємо, що більша частина часу витрачається на два-три, ну максимум десять якихось окремих викликів, і зосереджуємо всі зусилля по оптимізації на них. Але в профайлер ми побачили наступне.

  • Call Tree немає явних лідерів за часом виконання, від яких можна було б легко позбутися. В основному це завантаження UI з xib-ів, створення дерева об'єктів UI, layout. Зрозуміло, що якщо мета запуску програми — побачити UI, то ми не можемо просто відкинути цей код.
  • Якщо виключити з розгляду код завантаження UI, все інше час розмазане дуже тонким шаром за всіма етапами і подэтапам запуску. З кожного окремого зміни тут вдавалося вичавити в кращому випадку 50 мс.
  • На графіку використання CPU видно, що процесор не задіяний постійно на всі 100% (або хоча б 50%, відповідні однопоточному виконання), в деяких місцях видно провали і паузи. Що відбувається в ці моменти, за допомогою Time Profiler можна дізнатися лише приблизно, подивившись у списку Samples, які стек-трейсы передують паузі. Найчастіше це введення/виведення або взаємодія із системними службами по XPC.

Графік використання CPU

Тим не менш, Time Profiler все одно виявився корисним.

По-перше, він дає уявлення про те, якою код всередині системних фреймворків, особливо в UI Kit, займає багато часу. Виходячи з цього, ми можемо приймати рішення про використання тих чи інших засобів UI Kit і послідовності ініціалізації UI.

По-друге, після того як ми виділили ключові події в процесі запуску, ми можемо подивитися, що саме між цими подіями. Без використання Time Profiler це не завжди очевидно, так як послідовність запуску являє собою складну взаємозв'язок різних підсистем і класів бізнес-логіки та UI, асинхронних колбэков і паралельно виконується коду. Ми використовували такий метод: знаходимо у списку Samples цікавлять нас семпли, в яких відбулася подія, ставимо в цих місцях на графіку мітку (Edit > Add Flag), виділяємо область між двома мітками і вивчаємо Call Tree.

Ще Time Profiler дозволяє знайти функції і методи, які самі по собі виконуються швидко, але гальмують запуск з-за великої кількості дзвінків. Для цього в налаштуваннях Call Tree відзначте галочками Invert Call Tree і Top Functions, упорядкуйте список по полю Self Weight або Self Count і йдіть по створеному списку зверху вниз, виключаючи нецікаві виклики за допомогою опції Charge XXX to callers або Prune XXX and subtrees. Так ви зможете, наприклад, дізнатися, що майже 228 мс займають виклики функції
objc_msgSend
, тобто накладні витрати на виклик методів в Objective-C, і 106 мс займають виклики
CFRelease
— накладні витрати на управління пам'яттю. Навряд чи саме це вийде оптимізувати, але в кожному конкретному випадку є шанси знайти ще щось цікаве. У нашому випадку це були виклики
+[UIImage imageNamed:]
, так як створення конфігурацій зовнішнього вигляду для всіх екранів програми відбувалося на старті.

Логи, вбудовані в додаток
Time Profiler не дає простий можливості виміряти загальний час, витрачений на складну послідовність дій, яка складається з асинхронних викликів. Спосіб, описаний вище — з пошуком по Samples та проставленням підписів — неможливо автоматизувати. Тому ми вирішили застосувати інший підхід — розставити логи з критичних точок процесу запуску. Кожна запис такого лода складається з назви події, абсолютного часу з початку вимірів, відносного часу, що пройшов з попереднього події. Приклад лода.

load 0 0
main 44 44
did finish launching 295 250
did init BIOD 489 194
will load accounts 1145 655
ELVC view did appear 1663 518
did load accounts 1933 269
did load cached folders 2075 142
items from cache 5547 3471
ELVC did show initial items 7146 1599
did update slide stack 7326 179
stop 7326 0

Як правильно вибрати місця для розстановки логів? Враховуючи те, що в процесі запуску окремі підетапи можуть виконуватися паралельно, має сенс звертати більше уваги на ті з них, які займають більше часу і закінчення яких обов'язково потрібно дочекатися, щоб продовжити процес запуску. Наприклад, завантаження списку повідомлень з бази даних і завантаження xib в'ю контролера можна здійснювати паралельно, але не можна завантажити з бази список повідомлень до тих пір, поки ми не відкриємо базу даних. Сформувати такий критичний шлях з подій, які обов'язково повинні виконатися, можна з допомогою фічі Xcode, яка дозволяє бачити не тільки поточний стек-трейс, але і всі попередні стек-трейсы в послідовності асинхронних викликів. Просто поставте брейкпоинт в рядку, яку ви вважаєте кінцем процесу запуску, переведіть Debug Navigator в режим View Process By Queue — і отримаєте критичний шлях, що веде від
main
або
application:didFinishLaunchingWithOptions:
до кінцевої мети.

Хоча логи мають меншу гранулярність, ніж дані Time Profiler, вони мають ряд важливих переваг.

  • Виміри за допомогою логів можна автоматизувати. Це допоможе швидше оцінити ефект реалізованого зміни у ході розробки, а також зробити перевірку швидкості запуску частиною процесу Continuous Integration.


    Grafana
  • Можливо, ви помічали, що час виконання одного і того ж коду може відрізнятися досить суттєво від запуску до запуску. На тривалість впливають різні оптимізації, вбудовані в саму операційну систему (такі як дисковий кеш), а також інші процеси, що паралельно виконуються в цей же час на пристрої. Щоб отримати більш стабільні і придатні для порівняння результати, ми проводимо заміри кілька разів і беремо середнє або медіану.
  • Логи можна копіювати в Google Sheets, аналізувати і порівнювати будь-яким зручним способом, і навіть будувати гарні діаграми, що наочно показують послідовність і тривалість різних етапів.






У профайлінгу з допомогою логів є і підводні камені. Іноді можна помітити, що один з етапів займає непропорційно багато часу. Не поспішайте звалювати всю провину на нього. Спершу переконайтеся в тому, що все це час дійсно відноситься до цього етапу. Можливо, завершення етапу насправді гальмується очікуванням
dispatch_async
на main queue, а main queue в цей час зайнята іншими справами. Якщо ви приберете
dispatch_async
в цьому місці, тривалість цього етапу може зменшитися, але загальний час виконання всіх етапів просто перерозподілиться і залишиться колишнім.

При пошуку місць для оптимізації недостатньо лише дивитися, скільки часу виконується та чи інша функція: потрібно ще й звертати увагу на те, як влаштована високорівнева архітектура програми. Важливо зрозуміти залежності між етапами запуску: можуть виконуватись паралельно або тільки послідовно. Можливо, прискорення досягається за рахунок розпаралелювання послідовних етапів або за рахунок відкладання необов'язкових етапів на більш пізній час. Профайлинг логами може в цьому допомогти.

Введення/висновок
Читання файлів з флеш-пам'яті займає на порядок більше часу, ніж робота з оперативною пам'яттю або CPU, тому має сенс мінімізувати кількість дискових операцій на старті. Гальмування, пов'язане з читанням даних з диска, проявляється в Time Profiler в кращому випадку як провали, а часто ніяк, тому для відстеження необхідно використовувати інші методи.

  • Інструмент I/O Activity (шаблон System Usage) працює тільки на реальних пристроях. Він показує всі системні виклики, пов'язані з введенням/виведенням, шляху до відкритих файлів і стек-трейсы місць в коді, звідки виконувалися відповідні операції.
  • Якщо перший метод вам не підходить, аналогічну інформацію можна отримати з допомогою звичайного брейкпоинта на функцію
    __open
    . При цьому ім'я відкритого файлу можна отримати командою LLDB
    p (char *)$r0
    (назва регістра, в якому знаходиться перший параметр виклику, залежати від архітектури).
Layout та інше
Деякі виклики, що дають навантаження на CPU, практично неможливо аналізувати за допомогою Time Profiler: в їх стек-трейсах не завжди міститься інформація про те, до якої саме частини програми вони належать. Приклад — проходи layout по ієрархії в'ю. Часто їх стек-трейс складається тільки із системних методів, але нам хотілося б знати, для яких в'ю layout займає найбільше часу. Такі виклики можна виміряти і співвіднести з об'єктами, над якими вони працюють, з допомогою свизлинга, додавши до і після відповідного дзвінка-код, що виводить на консоль інформацію про оброблюваних об'єктах і час виконання кожного виклику. Маючи такий лог, легко побудувати в Google Sheets табличку з розподілом часу, витраченого на layout по кожній з вьюшек. Ми використовували для свизлинга бібліотеку Aspects.

static void LayoutLoggingForClassSelector(Class cls, SEL selector) {
static NSMutableDictionary *counters = nil;
if (!counters) {
counters = [NSMutableDictionary dictionary];
}

SEL selector = NSSelectorFromString(selectorName);
[cls aspect_hookSelector:selector withOptions:AspectPositionBefore usingBlock:^(id<AspectInfo> info) {
TLLOG(NL(@"lob %s %p"), class_getName([[info instance] class]), (void *)[info instance]);
} error:nil];
[cls aspect_hookSelector:selector withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> info) {
NSValue *key = [NSValue valueWithPointer:(void *)[info instance]];
NSNumber *counter = counters[key];
if (!counter) {
counter = @(0);
}
counter = @(counter.integerValue + 1);
counters[key] = counter;

TLLOG(NL(@"loa %s %p %@"), class_getName([[info instance] class]), (void *)[info instance], counter);
} error:nil];
}

Оптимізація
Висновки, які ми зробили для себе в ході робіт по прискоренню запуску, ми сформулювали у вигляді рад. Сподіваємося, вони будуть корисні іншим розробникам. Часто в процесі аналізу зібраних даних про продуктивності ми не зовсім вірно інтерпретуємо ті чи інші дані. Взявши помилковий слід, ми намагаємося виправити те, що насправді не потребує виправлень. Найчастіше спроби оптимізувати код роблять його більш складним для розуміння і менш надійним, збільшується кількість крайніх випадків, які необхідно враховувати, і так далі. Тому, коли ви намагаєтеся щось оптимізувати, дуже важливо робити це не наосліп, а маючи чітку методику вимірювань, що показує стабільні результати і допомагає оцінити корисність кожної правки.


Передчасна оптимізація

Перш ніж приступити до оптимізації часу запуску, корисно визначити, яке граничне значення часу старту в принципі можлива, якщо по максимуму видалити додатки весь код, крім необхідного для того, щоб додаток хоча б зовні виглядало як занедбане (про збереження функціональності тут не йдеться). В нашому випадку таке мінімальне додаток являє собою один екран, що містить
UINavigationController
з баром і кнопками,
UITableView
і кілька осередків.

Тестуйте версію програми, яка максимально наближена до складання для App Store, вимкніть всілякі рантайм-перевірки, логи і ассерти, увімкніть оптимізацію у налаштуваннях складання. У нашому випадку складання для внутрішнього тестування містить, наприклад, свизлинг великої кількості методів для рантайм-перевірки їх виконання на головному тред. Природно, це має дуже великий вплив на одержувані результати.

В першу чергу слід оптимізувати код, що виконується на головному тред, так як кінцева мета всього процесу запуску — щось показати на екрані, а це можна зробити лише з головного треда. Фонові треди теж не варто ігнорувати, так як апаратні можливості розпаралелювання не безмежні: на більшості пристроїв доступна тільки два ядра, і дуже важкий код, що виконується в тлі, може значно вплинути на загальний час запуску. Ми зіткнулися з цим, коли досліджували вплив однієї з бібліотек для аналітики на час старту. Після ініціалізації бібліотеки вона повністю працювала у фоновому тред, але, незважаючи на це, її відключення дало великий виграш.

Вивчаючи, як можна скоротити час, що витрачається на побудову UI, ми виявили, що значний час витрачається на завантаження, конфігурування та відображення комірок в списку листів. Причин кілька: комірок багато, кожна з них складається з безлічі подвью, з яких найбільш ненажерливі — різні іконки та лейбли. Іконки відносно повільні тому, що завантаження зображень в принципі працює повільніше інших дій, а повільність лейблів викликана необхідністю розрахунку їх розмірів залежно від вмісту.

Іконки, які відображаються в комірці листи — це індикатори атрибутів листи (непрочитанность, помеченность прапором, наявність аттачей), а також іконки дій, які з'являються при свайпе. Ми зробили створення панелі дій ледачим: вона не створюється до тих пір, поки не відбудеться свайп. Так само ми вчинили з іконками атрибутів — вони не будуть завантажені, якщо листів з такими атрибутами немає в списку.

Принцип ліниво взагалі дуже добре застосовувати для всіх дій, які відбуваються на старті. Все, що не задіяні безпосередньо під час старту, повинен створюватись ліниво або * ліниво*: різні в'юшки, показ яких залежить від умов, заглушки, іконки, невидимі на початковому UI в'ю контролери. Один з прикладів — завантаження зображень з допомогою
+[UIImage imageNamed:]
. Здавалося б,
imageNamed:
не має віднімати багато часу, так як безпосередньо завантаження і декодування зображення відбудуться тільки в момент показу — але за рахунок великої кількості викликів час накопичувалося. У нашому додатку налаштування зовнішнього вигляду всіх елементів користувальницького інтерфейсу відбувається централізовано на старті програми в класі
AppearanceConfigurator
. В ідеалі цю настройку теж слід було б робити ліниво, але ми не знайшли достатньо красивого рішення, щоб одночасно всі налаштування були в одному місці, винесену за межі класів настроюються вьюшек, і при цьому застосовувалися ліниво в момент першого використання відповідної в'юшки або контролера. Щоб оптимізувати
imageNamed:
, ми зробили дві речі: поміняли виклик
imageNamed:
на більш новий
imageNamed:inBundle:compatibleWithTraitCollection:
, який працює трохи швидше, і відмовилися від виклику на етапі старту
imageNamed:
. Замість цього ми конфігуруємо іконки об'єктами-проксі, які пробрасывают всі виклики до реального
UIImage
, який створюється ліниво в момент першого виклику будь-якого методу.

+ (UIImage *)imageWithBlock:(UIImage *(^)(void))block {
MRLazyImage *lazyImage = [(MRLazyImage *)[self alloc] initWithBlock:block];
return (UIImage *)lazyImage;
}

- (UIImage *)image {
if (!_image && self.block) {
_image = self.block();
self.block = nil;
}
return _image;
}

- (void)forwardInvocation:(NSInvocation *)invocation {
invocation.target = self.image;
[invocation invoke];
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
return [self.image methodSignatureForSelector:sel];
}

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

Рендеринг
UILabel
в комірках ми спробували оптимізувати, використовуючи AsyncDisplayKit. ASDK — досить гнучкий фреймворк, ступінь задіяності у додатку можна регулювати від повного побудови всього UI на його основі до використання тільки деяких класів в окремих частинах UI, де це необхідно. Ідею переписати весь список листів на ASDK ми відкинули практично відразу, так як це виходило за рамки часу, відведеного на оптимізацію старту. Тому ми вирішили спробувати просто замінити всі
UILabel
у клітинці на ASTextNode. Ми припускали, що це дозволить показати осередку листів, не чекаючи відтворення лейблів. Крім того, була надія, що альтернативна імплементація ренденринга буде працювати швидше, ніж вбудована в SDK.
ASTextNode
дозволяє рендери текст в трьох режимах: синхронно, асинхронно і асинхронно з показом плейсхолдеров — сірих плашок на місці тексту. Жоден з цих варіантів не виправдав наших надій. Асинхронний рендеринг виглядає не дуже красиво і не дуже практично, оскільки саме текст листів цікавить користувача найбільше, а синхронний і асинхронний з плейсхолдерами працював по швидкості приблизно також чи навіть повільніше, ніж
UILabel
(що не дивно, так як в обох випадках використовується всередині CoreText).

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

Ще одна потенційна можливість для оптимізації, пов'язана з осередками, яка відразу впадала в очі Time Profiler, — це час, що витрачається на завантаження комірки з xib. Звичайно, ми багато разів чули про розробників, які принципово відмовляються від використання Interface Builder з різних причин. Можливо, швидкість створення ієрархії в'ю — одна з них? Ми вирішили це перевірити і переписали один в один вміст xib у вигляді коду. Але, як не дивно, заміри показали, що час створення осередків змінилося зовсім незначно, причому в більшу сторону. Схожі результати були опубліковані ще в 2010 році в блозі Cocoa with Love. До речі, там є посилання на проект, який використовувався для замірів, і ви можете самі перевірити отримані результати.

Інша оптимізація, пов'язана з xib, яка дійсно працює і особливо корисна для комірок, — це кешування об'єктів
UINib
в пам'яті. В клас
UINib
вже вбудована функція звільнення пам'яті за умови її брак, тому написати такий кеш не складе праці.

Також ми зіткнулися з тим, що використання деяких стандартних фреймворків може внести затримку в процес запуску, яка буде відображатися в Time Profiler як пауза порядку декількох десятків мілісекунд. Це такі дії, як робота з адресною книгою, з keychain-му, з Touch ID, з Pasteboard, а також перевірка різних дозволів: на доступ до бібліотеки фото, на геолокації. Там, де це можливо, ми постаралися перенести ці виклики на більш пізній етап у послідовності запуску програми — після показу основного UI.

Часто автори сторонніх бібліотек для аналітики або для доступу до різних сервісів рекомендують здійснювати їх ініціалізацію прямо в
application:didFinishLaunchingWithOptions:
. Перш ніж слідувати цим рекомендаціям, перевірте, чи не вплине це на час запуску програми, і при необхідності також відкладіть ініціалізацію на етап після показу основного UI.

Якщо ваш додаток працює з даними через Core Data або безпосередньо читає з SQLite бази, може виявитися, що час запуску можна ще трохи скоротити за рахунок накладних витрат на ініціалізацію стека Core Data і відкриття бази. Для цього збережіть мінімум даних, необхідний для показу початкового UI, в окремий файл у простому форматі, який можна швидко розпарсити, і відкладіть відкриття БД на більш пізній етап. Тут головне — дотримати баланс між отриманим виграшем та ускладненням коду за рахунок організації кешування.

Як впливає використання Swift на час запуску? Велика частина коду нашого додатка написана на Objective-C. Ми використовували Swift лише для написання декількох невеликих класів в якості експерименту і з наміром поступово збільшувати їх кількість. Теоретично використання Swift дозволить дещо скоротити фазу rebase/bind та накладні витрати на пересилання повідомлень, які присутні в рантайме Objective-C. З іншого боку, використання Swift трохи збільшує час старту за рахунок додаткових динамічних фреймворків. В кінцевому рахунку ми вирішили повністю позбутися Swift-коду (головним чином у зв'язку з вирішенням іншого завдання — зменшення розміру завантаження додатка). Швидше за все, ми знову почнемо використовувати Swift не раніше наступного року, коли нарешті дороблять стабільний ABI.

Суб'єктивне сприйняття
З точки зору користувача повним запуском програми буде вважатися момент, коли на екрані з'явився актуальний список листів. Можна виділити проміжні етапи, швидкість виконання яких впливає на суб'єктивне сприйняття швидкості запуску.

  • Поява обрисів користувальницького інтерфейсу.
  • Поява основних елементів користувальницького інтерфейсу: кнопка списку папок, кнопка написання листа (причому ці елементи спочатку можуть бути неактивними: перш ніж користувач почне з ними взаємодіяти, пройде якийсь час, за який ми встигнемо їх оживити).
  • Поява закэшированного списку листів — до того, як список оновився з сервера.




Використовуйте різні прийоми, що впливають на сприйняття часу старту користувачем.

  • Чомусь не всі розробники серйозно сприймають рада з iOS Human Interface Guidelines: «Екран запуску — це не можливість художнього самовираження. Він призначений виключно для того, щоб поліпшити сприйняття вашого застосування як швидко запускающегося і відразу готового до використання». Раніше і ми на старті показували логотип Mail.Ru, але в новій версії вирішили почати дотримуватися рекомендацій HIG в цьому питанні.
  • Будьте обережні з анімованими індикаторами завантаження. деяких досліджень слід, що користувачі сприймають їх як щось гальмує.
  • Існує поширена практика відображати додатковий екран запуску після показу основного. Зазвичай це роблять, щоб показати індикатор завантаження або зробити анімований перехід з екрана запуску на основний UI, або просто щоб уникнути завершення програми системою при виконанні тривалих дій в ході ініціалізації. Якщо головний пріоритет для вас — швидкість запуску, і немає особливих причин показувати додатковий екран, краще від нього позбутися, так як на його завантаження та показ витрачається час.
Автоматизація вимірів
Час запуску програми має тенденцію до поступового непомітного збільшення по мірі того, як додаються нові фічі і рефакторится старий код. Деякі зміни додають лише одиниці або десятки мілісекунд, що стає помітним далеко не відразу. Природно, після того, як виконана така велика робота по прискоренню запуску, не хотілося б повернутися до колишнього стану. Щоб цього не сталося, ми будуємо графік зміни часу запуску, на якій проставляється нова точка кожен раз, коли в основну гілку репозиторію додається новий комміт.

Виконувати вимірювання краще всього на самому повільному пристрої, яке ви зможете знайти, і ні в якому разі не на симуляторі.

Наші автоматичні виміри запускаються через Jenkins, як і всі інші завдання, які виконуються в рамках CI. Спеціально для цієї мети виділено окремий iPhone. В ході розробки ми протестували дві схеми роботи: з використанням jailbreak і без. В кінцевому підсумку ми вирішили зупинитися на використанні пристрою з jailbreak, так як ця схема має ряд переваг.

  • Пристрою не потрібно постійне підключення по USB до одного з серверів Jenkins, до більшості з яких немає фізичного доступу розробників. Всі взаємодія з пристроєм відбувається за ssh.
  • Завдання на Jenkins не потрібно прив'язувати до конкретної слейву, до якого підключено пристрій.
  • Для слейвов Jenkins не потрібна установка спеціального софту, необхідного для взаємодії з пристроєм USB.
  • Менше проблем з підписами і профілями складання програми.
Складання програми для jailbreak і для звичайного пристрою виглядає однаково. Єдина відмінність в тому, що для jailbreak знижені вимоги до підпису і профілів. З допомогою xcodebuild збираємо проект в конфігурації App Store Release з включеним логированием етапів запуску.
ENABLE_TIME_LOGGER
містить не тільки логування, але і автоматичне завершення програми по досягненню кінцевої точки процесу запуску.

$XCODEBUILD -project MRMail.xcodeproj -target "$TARGET" -configuration "$CONFIGURATION" \
-destination "platform=iOS" -parallelizeTargets -jobs 4 \
CODE_SIGN_IDENTITY="iPhone Developer" \
MAIN_INFOPLIST_FILE="tools/profiler/Info.plist" \
GCC_PREPROCESSOR_DEFINITIONS='$GCC_PREPROCESSOR_DEFINITIONS ENABLE_TIME_LOGGER=1 DISABLE_FLURRY=1'

Підміна
Info.plist
робиться для того, щоб включити iTunes File Sharing: це дозволяє у версії без jailbreak діставати лог-файл з папки Documents. Відключення Flurry пов'язано з особливістю роботи цієї бібліотеки: якщо багато разів запускати програму і зупиняти його раніше, ніж Flurry встигне відправити всі події, бібліотека накопичує величезну кількість тимчасових файлів і намагається все їх прочитати на старті, що негативно впливає на час запуску. Установка зібраного програми на пристрій без jailbreak здійснюється за допомогою утиліти ios-deploy:

APP_BUNDLE="$PROJECT_ROOT/build/${CONFIGURATION}-iphoneos/$PRODUCT.app"
$IOS_DEPLOY --bundle "$APP_BUNDLE" --id "$DEVICE_ID" \
--noninteractive --justlaunch

Для пристрою з jailbreak досить заархівувати каталог
.app
на
.ipa
і скопіювати по ssh на пристрій (в скрипті —
localhost
, так як ssh працює через тунель). Потім програма встановлюється за допомогою утиліти ipainstaller з Cydia.

APP_BUNDLE="$PROJECT_ROOT/build/${CONFIGURATION}-iphoneos/$PRODUCT.app"

cd "$PROJECT_ROOT/build"
rm -rf Payload; mkdir -p Payload
cp -a "$APP_BUNDLE" Payload/
rm -f MRMail.ipa; zip -r MRMail.ipa Payload

SCP_TO_DEVICE MRMail.ipa root@localhost:
SSH_TO_DEVICE ipainstaller MRMail.ipa

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

Далі проводиться необхідна кількість запусків програми. Після кожного запуску лог-файл дістається з пристрою і зберігається для подальшого аналізу. Для запуску програми на пристрої без jailbreak використовується утиліта idevicedebug, а для скачування результатів по USB — утиліта ifuse.

for i in $(seq 1 $NUMBER_OF_RUNS)
do
$IDEVICEDEBUG --udid "$DEVICE_ID" run "$BUNDLE_ID" >/dev/null 2 > /dev/null &

COMPLETION_PATH="$MOUNTPOINT_PATH/$COMPLETION_INDICATOR"
LOG_PATH="$MOUNTPOINT_PATH/$LOG_NAME"

for j in $(seq 1 5)
do
sleep $MOUNT_SECONDS_PEDIOD
$UMOUNT "$MOUNTPOINT_PATH" 2>/dev/null || true
$MKDIR "$MOUNTPOINT_PATH"
$IFUSE --documents "$BUNDLE_ID" --udid "$DEVICE_ID" "$MOUNTPOINT_PATH"
sleep $AFTER_MOUNT_SECONDS_PEDIOD

if [ -f "$COMPLETION_PATH" ] && [ -f "$LOG_PATH" ]; then
break
fi
done

RESULT_PATH="$LOGS_FOLDER_PATH/$(date +"%d-%m-%Y-%H-%M-%S").csv"
(set -x; cp "$LOG_PATH" "$RESULT_PATH")

$UMOUNT "$MOUNTPOINT_PATH"
done

На пристрої з jailbreak все дещо простіше. Для запуску програми використовується утиліта open з Cydia, результати копіюються по ssh.

SANDBOX_PATH=`SSH_TO_DEVICE ipainstaller -i "$BUNDLE_ID" | grep '^Data: '| awk '{print $2}"
SANDBOX_PATH="${SANDBOX_PATH//[$'\t\r\n ']}"
COMPLETION_PATH="$SANDBOX_PATH/Documents/$COMPLETION_INDICATOR"
LOG_PATH="$SANDBOX_PATH/Documents/$LOG_NAME"

for i in $(seq 1 $NUMBER_OF_RUNS)
do
SSH_TO_DEVICE open "$BUNDLE_ID"
sleep $MOUNT_SECONDS_PEDIOD

for j in $(seq 1 5)
do
if SSH_TO_DEVICE test -f "$COMPLETION_PATH" && \
SSH_TO_DEVICE test -f "$LOG_PATH"
then
break
fi
sleep $AFTER_MOUNT_SECONDS_PEDIOD
done

RESULT_PATH="$LOGS_FOLDER_PATH/$(date +"%d-%m-%Y-%H-%M-%S").csv"
(set -x; SCP_TO_DEVICE root@localhost:"$LOG_PATH" "$RESULT_PATH")
done

Після цього нескладний скрипт на Ruby збирає результати усіх запусків, обчислює за всіх подій на статистичні характеристики (мінімальний час, максимальний час, середнє, медіану, квантили) і відправляє ці дані в Influxdb, за яким дэшборде на Grafana будується графік.

При вимірах на реальному пристрої неминучі похибки. Скільки вимірів необхідно зробити, щоб отримати стабільний результат з заданою похибкою? Для визначення цього числа можна скористатися формулою визначення об'єму вибірки, параметри який підраховані на підставі досить великої кількості вимірювань (наприклад, 10000). Для нас це число — близько 270, але навіть якщо зробити всього 10 вимірювань, похибка виходить досить прийнятною, щоб помітити на графіку тенденцію збільшення часу на великому часовому проміжку.

Заміри, вбудовані в CI, мають обмежену корисність із-за того, що вони відображають лише один з можливих сценаріїв запуску програми і тільки в одній конфігурації пристрій-версія iOS. Тому в доповнення до них ми збираємо у реальних користувачів статистику, яка показує весь розкид значень. Для порівняння версій програми між собою всі запуски групуються за кількістю секунд.


Статистика

Результати
За результатами виконаної роботи нам вдалося скоротити реальний час запуску приблизно на третину і поліпшити суб'єктивне сприйняття швидкості запуску користувачами, про що свідчить збільшення ретеншен. І що найголовніше — ми отримали механізм, що дозволяє контролювати і запобігати регрес за цим показником.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.