Pull to refresh
VK
Building the Internet

Рекордное время: как мы увеличили скорость запуска приложения Почты Mail.Ru на iOS

Reading time 21 min
Views 15K


Скорость запуска — критически важный фактор для долгосрочного успеха приложения. Она особенно важна для таких приложений как Почта 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. Поэтому в дополнение к ним мы собираем у реальных пользователей статистику, которая показывает весь разброс значений. Для сравнения версий приложения между собой все запуски группируются по количеству секунд.


Статистика

Результаты


По результатам проделанной работы нам удалось сократить реальное время запуска примерно на треть и улучшить субъективное восприятие скорости запуска пользователями, о чем свидетельствует увеличившийся ретеншен. И что самое главное — мы получили механизм, позволяющий контролировать и предотвращать регресс по этому показателю.
Tags:
Hubs:
+37
Comments 9
Comments Comments 9

Articles

Information

Website
vk.com
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия
Representative
Миша Берггрен