Skip to content

Instantly share code, notes, and snippets.

@kolyuchiy
Last active January 13, 2019 21:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kolyuchiy/04a695195826b6f626271521afc56301 to your computer and use it in GitHub Desktop.
Save kolyuchiy/04a695195826b6f626271521afc56301 to your computer and use it in GitHub Desktop.

Быстрый старт iOS приложения на примере iOS Почты Mail.Ru

Слайд-заголовок

Всем привет! Меня зовут Николай Морев, и я разрабатываю приложение Почты Mail.Ru для iOS. Для тех, кто никогда о нем не слышал, несколько фактов:

О нашем приложении

  • Это email клиент, позволяющий работать с любым почтовым сервисом, а не только с Mail.ru.
  • Мы существуем в сторе с 2012 года (iOS 5), хотя история разработки приложения несколько дольше и уходит корнями в мессенджер Агент Mail.Ru.

Почта Mail.Ru для iOS

  • Практически все это время мы находимся в районе 30 места в рейтинге популярности бесплатных приложений в русском сторе и на первом-втором месте в категории "Производительность". Это не та производительность, о которой я сегодня буду говорить, это просто неоднозначный перевод слова Productivity.

Пролистываем отзывы

  • Для международной аудитории мы выпускаем то же приложение под брэндом myMail и наши пользователи иногда это замечают (слайд с отзывами).

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

Проблема медленного запуска

Много ли в зале iOS разработчиков?

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

Пролистываем отзывы

Наши пользователи вообще очень внимательные и время от времени напоминают нам о том, что для них действительно важно.

Старт быстрее N секунд

К тому же данные аналитики подтверждали наличие проблемы: график.

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

Кстати, мой коллега Даниил Румянцев недавно делал доклад на встрече CocoaHeads про определение качества сетевого соединения. К сожалению, видео с этой встречи не выложено в открытый доступ, поэтому, если вам будет интересно, обращайтесь, помогу найти информацию.

Актуальность

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

Частое использование

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

Исторически сложилось

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

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

Отсутствие контроля

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

Все осложняется тем, что каждый раз время запуска увеличивается на очень незначительную величину, которую невозможно заметить при ручном тестировании. Да и использование специальных инструментов вроде Time Profiler-а не особо помогает, так как ухудшение производительности умещается в погрешность измерений.

Profiling (график)

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

Прежде чем начать

Но сначала поговорим о том, как построить сам процесс работы над улучшением скорости запуска.

Всем известно главное правило оптимизации: преждевременная оптимизация - корень всех зол. Поэтому прежде чем начать, необходимо определиться с тем,

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

Сценарий

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

Эффект

В идеале в результате всех оптимизаций у пользователей должно пропасть ощущение тормозов на старте. Чтобы этого добиться, мы заходим с двух сторон: стараемся сократить время запуска приложения и стараемся с помощью различных визуальных приемов создать ощущение быстрого запуска. Здесь я буду говорить только о технической части, но вы можете найти несколько советов по улучшению субъективного восприятия в моей статье, ссылку на которую я дам в конце.

Как измерить?

Для поиска мест, которые можно оптимизировать, мы использовали Time Profiler, а для оценки эффекта от оптимизации мы использовали логи времени выполнения, встроенные в приложение.

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

Предел оптимизации

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

  • во-первых, оно уже выполняет какой-то минимум кода для инициализации UIKit и прочих системных объектов, необходимых для работы,
  • во-вторых, мы добавили туда один экран с заголовком вверху, со списком и набором ячеек, который имитирует список писем.

Первый этап

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

Слайд с графиком из TP

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

WWDC

Очень подробный рассказ про первый этап был на WWDC в этом году в замечательном докладе 406 Optimizing App Startup Time. Там подробно рассказано обо всем, что происходит на этом этапе и что мы можем с этим сделать. Перескажу очень коротко основные моменты из него.

Что происходит на этом этапе? iOS загружает исполняемый код приложения в память, производит над ним необходиые манипуляции: сдвиг указателей, привязка указателей, ссылающихся на внешние библиотеки, проверка подписи всех исполняемых файлов. Затем выполняются методы +load и статические конструкторы.

Для примера я привел диаграмму, показывающую сколько времени занимают в нашем приложении различные этапы. Такую же статистику по своему приложение вы можете получить, задав переменную окружения DYLD_PRINT_STATISTICS.

Соответственно основные рекомендации по сокращению времени первого этапа сводятся к уменьшению показанных этапов. Как же это сделать, спросите вы?

Do less stuff

Ответ очевиден: в приложении должно быть меньше кода, тогда оно будет выполняться быстрей. Этот совет - это не мое изобретение, этот слайд я скопировал из презентации Apple.

Рекомендации

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

    В нашем приложении всего один динамический фреймворк, в который мы выделили общий код, который используется различными экстеншенами. Главным образом это было сделано для сокращения размера приложения, но для скорости мы могли бы вообще не добавлять динамических фреймворков. Кстати, если вы используете Swift, то он добавляет как раз в районе 5 кастомных библиотек, т.е. получается, что использование Swift добавляет свой оверхед.

  • На этапы rebase fixups, binding fixups и Objective-C setup влияет количество символов Objective-C, поэтому здесь основная рекомендация - писать большие классы, большие методы или переходить на Swift, где все статично и не нужно в рантайме делать маппинг между именем класса или селектора и его адресом. Естественно для уже существующего большого приложения эта рекомендация не подойдет, т.к. придется много рефакторить, да и в новых приложениях лучше отдать приоритет читаемости кода, которая страдает от этой рекомендации.

Второй этап

Это все классно, но хотелось бы уже перейти к оптимизации собственного кода, где у нас больше простор для действий. Естественно мы начали с исследования с помощью Time Profiler-а. Для тех, кто не знает, это инструмент, снимающий с работающего приложения стек трейсы каждую миллисекунду, по которым затем строится дерево вызовов и видна длительность каждого вызова. Он показывает не только код, который вы сами написали, но и всё, что лежит ниже - на уровне системных фреймворков.

Проблемы

Time Profiler - это очень крутой и мощный инструмент, но он не решает все ваши проблемы автоматически. Вот с какими сложностями мы столкнулись:

  • Мы не нашли в приложении явных узких мест

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

    Еще одной причиной Uniformly Slow Code могут быть особенности используемой платформы, например, динамическая сущность рантайма Objective-C и с этим ничего нельзя сделать.

Не вся информация доступна

  • В некоторых случаях мы видим в профайлере тяжелые части дерева вызовов, но по ним невозможно понять, какая именно часть приложения в них участвует. Частые примеры: вызовы layoutSubviews, особенно при использовании autolayout, и загрузка вьюшек из XIB-ов.

Провалы

  • В идеале главный тред во время запуска должен быть загружен на все 100%, но на графике есть провалы и не всегда понятно, чем они вызваны. Основных причин здесь две:
    • последовательность запуска выстроена таким образом, что завершение какого-то действия, например, открытия базы данных, задерживает всю остальную работу, даже не связанную с базой данных.
    • Синхронные операции ввода-вывода: как очевидные - работа с файлами, так и менее очевидные - некоторые вызовы SDK общаются с системными процессами по XPC, например Keychain, Touch ID, проверка пермишенов на доступ к фото или геолокации и т.д.

Разброс

  • Бывает сложно понять общий эффект от оптимизации, так как время сильно варьируется от запуска к запуску. Например, мы убрали кусок кода, который в профайлере занял 50мс, а разброс измерений - может быть гораздо больше.

Советы

  • Профайлер дает очень много полезной информации, но наша психология устроена так, что очень легко попасть на ложный след: во время анализа мы склонны больше внимания уделять не тем местам, которые занимают много времени, а тем, которые проще понять и с которыми интереснее разбираться. Например, я вижу, что обращения к Pasteboard в процессе запуска занимают аж целых 20 мс и начинаю изучать как от них избавиться, можно ли в данном случае Pasteboard заменить на что-то другое и так далее, хотя по хорошему надо сразу перейти на более высокий уровень рассмотрения, увидеть, что эти вызовы используются в коде отправки статистики о запуске приложения и подумать, а не отложить ли это действие на более поздний этап, при этом возможно получится убрать еще больше вспомогательного кода.
  • Конечно, в первую очередь мы пытаемся сократить объем работы, выполяющейся на главном треде, так как в конечном итоге именно он должен показать конечную цель запуска - UI. Но на фоновые треды тоже стоит обращать внимание, так как возможности распараллеливания не безграничны. Например, в нашем случае одна сторонняя библиотека сразу после инициализации уходила в фоновый тред и там выполняла всю работу, поэтому поначалу мы не обращали на нее внимания, но когда мы все-таки попробовали ее отключить, эффект был заметным.
  • Вы можете заметить, что большая часть времени расходуется на отрисовку UI и лэйаут, но из трейсов не всегда понятно, на что именно в UI. Практика показывает, что самые прожорливые элементы - это лейблы и любые картинки, в том числе небольшие иконки. Лейблы из-за сложности отрисовки и рассчета размеров текста, а картинки из-за необходимости загружать данные с диска и декодировать их. Единственное, что с ними можно сделать - это сократить их количество.
  • Поэтому, старайтесь все делать лениво. Что это значит? Не создавайте и не настраивайте никакие экраны и вьюшки, не загружайте картинки, если они не будут показаны сразу же после старта.

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

Что мы сделали ленивым:

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

Это правило относится не только к UI. Если какой-то менеджер или действие надо инициализировать на старте приложения, подумайте как можно отложить его инициализацию на момент после показа основного UI. Во многих случаях не будет заметно никакой разницы с точки зрения функциональности.

Слайд про nib

Пару слов о такой спорной теме, как создание UI в интерфейс билдере или в коде. Как ни странно XIB-ы обычно не являются проблемой, так как создание аналогичного UI в коде занимает столько же времени, а бывает, что даже больше.

Ввод-вывод

В принципе чтение/запись на флэш-память происходит на современных девайсах очень быстро - единицы или десятки миллисекунд, поэтому не всегда стоит над этим заморачиваться, но бывает так, что ваш или сторонний код этим злоупотребляет, открывая слишком много файлов на старте. Например, мы обнаружили такую проблему с фреймворком аналитики Flurry и раскиданными по всему коду вызовами UIImage imageNamed.

Time Profiler не покажет такие места, они будут видны в нем в лучшем случае как небольшие провалы на графике CPU. Вместо этого можно использовать другой инструмент - I/O Activity, который показывает все системные вызовы связанные с вводом-выводом и имена файлов. Аналогичную информацию можно получить и просто с помощью отладчика и брейкпоинта на функцию __open.

Случай с системными фреймворками и XPC иногда можно отследить, обращая внимание на провалы на графике и предшествующие им вызовы в списке Call Stack-ов в профайлере.

Когда TP не дает достаточно информации: layout

Получить более подробную информацию по прогонам layout-а вам поможет свизлинг методов layoutSubviews во всех классах.

Пояснение для не iOS-разработчиков: свизлинг - это подмена имплементации метода, кое-что, что динамическая сущность Objective-C очень легко позволяет делать даже с системными методами.

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

Профайлинг логами

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

Использовать Time Profiler для этого - не вариант, так как сложно автоматизировать его запуски и программно собирать результаты выполнения, да и не нужен такой объем информации для этой задачи. Поэтому мы в само приложение добавили код, выводящий в консоль и в файл профайлинговые логи.

Логи

Логи выглядят примерно так. Мы выбрали ключевые точки критического пути запуска приложения и расставили в них вызовы, которые выводят абсолютное время от самого первого вызова (+load) и относительное время от предыдущего вызова.

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

Примеры диаграмм

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

Автоматизация замеров времени старта

В сообществе разработчиков очень много говорится о Continuous Integration, TDD и других практиках непрерывного контроля качества кода, но почему-то очень мало информации о том, как постоянно контроллировать производительность. Мы попытались восполнить этот пробел.

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

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

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

Реализация

Расскажу о технической реализации:

  • На каждый коммит в основную ветку репозитория запускается задача на Jenkins, которая собирает приложение в релизной конфигурации, а также с включенными профайлинговыми логами и автозавершением на последнем этапе старта.
  • Эта сборка запускается 270 раз на специально выделенном под задачу устройстве (на данный момент это iPhone 5S на iOS 9).
    • Откуда взялось число 270? Понятно, что для уменьшения погрешности, оно должно стремиться к бесконечности :), но тогда каждый прогон будет занимать бесконечное время. Мы сделали 10.000 замеров и рассчитали его по формуле определения объема выборки для нормального распределения с ошибкой около 10 мс.
    • Кстати, на графике, если присмотреться, можно увидеть момент, когда мы переключились с 10 запусков на 270.
  • Далее мы обрабатываем данные по всем запускам, рассчитываем их статистические характеристики, и сохраняем сводные величины и информацию об устройстве, ОС, хэш коммита в Influxdb, по которым затем строится график и таблица в Grafana.

Инструменты

  • Конкретные примеры скриптов, которыми все это делается, вы сможете найти в моей статье. А здесь просто перечислю основные моменты:
    • Как вы знаете, iOS - это закрытая система, поэтому для автоматизации таких задач, как установка, автозапуск, получение результатов работы приложения, есть два варианта: либо сторонние утилиты, позволяющие работать с недокументированным USB протоколом, который применяется самой Apple в iTunes или Xcode, либо ставим Jailbreak и как белые люди заходим на телефон по ssh и просто выполняем команды в shell.
    • Мы испытали оба варианта и остановились на последнем, так как он проще, надежнее, гибче. Нам не нужно привязывать тестовый телефон по USB к конкретному серверу, он просто лежит на столе одного из разработчиков и мы можем с ним работать с любого из слейвов Jenkins или с машины разработчика, если потребуется.

Проблемы

После того, как эта система проработала у нас несколько месяцев, мы смогли увидеть некоторые ее проблемные места, а точнее направления для улучшения.

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

  2. Даже, если мы определили коммит, то, чтобы понять, что именно в нем нужно исправить, чтобы вернуться к прежним показателям, все равно приходится проделывать какую-то исследовательскую работу в профайлере, проводить эксперименты-сравнения и анализировать код.

    Конечно, хотелось бы иметь какую-то методику, позволяющую без напряжения мозга, легко увидеть, в каких именно местах в рантайме изменилось поведение после накатывания большого коммита с множеством правок, что-то вроде гибрида Time Profiler и diff, но пока нам не известен такой инструмент.

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

Заключение

Итак, вот основные выводы, к которым мы пришли по итогам всей работы по оптимизации и о которых я рассказал.

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

Видео

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

Результаты

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

Есть еще красивая картинка, сделанная нашими аналитиками по собираемой с пользователей стистике, которая показывает, что количество запусков приложения, попадающих в интервал от 0 до 2 секунд, увеличилось в 10 раз до половины всех запусков.

Интуитивно может быть не совсем понятно, как это при ускорении всего на треть, этот показатель вырос в 10 раз, но если мы посчитаем взвешенное среднее по всем группам, то получится ускорение примерно 40%, то есть приблизительно одного порядка с данными TP.

Удовлетворенность

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

Подробности

Моменты, которые я упустил в своем докладе, вы можете прочитать в моей статье на хабре.

Спасибо

Время на вопросы 5-10 минут. Мой twitter и github.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment