«В картах у нас есть такой сценарий: на ходу достать телефон, запустить приложение, быстро определить, где я нахожусь, сориентироваться по компасу, куда мне идти, и убрать телефон.
Похожих сценариев, когда приложение открывают ненадолго, много. Поэтому нам очень важно, чтобы приложение запускалось быстро. Недавно мы провели большую работу по оптимизации времени запуска. Этим опытом я и хочу с вами сейчас поделиться.»
В основу данного материала легло выступление Николая Лихогруда, руководителя разработки мобильных Яндекс.Карт для iOS, на конференции Mobius 2017.
likhogrud уже написал пост на эту тему в блоге Яндекса, но мы не могли не выпустить один из лучших докладов конференции. Здесь есть и видео, и текст под катом, и презентация смотрите, как вам удобнее.
Стив Джобс говорил, что самый ценный ресурс человека — это его время. Возможно, вы слышали историю о том, как Стив заставил на полминуты ускорить запуск операционной системы Macintosh, мотивируя это тем, что ускорение всего лишь на 10 секунд сэкономит 300 миллионов пользовательских часов за год. Стив считал запуск приложения очень важным аспектом производительности.
Очевидно, что из двух приложений с одинаковым набором функций пользователь выберет то, которое быстро запускается. Если альтернативы нет, а приложение запускается долго, пользователя это будет раздражать, он будет реже возвращаться в ваше приложение, писать плохие отзывы. И, наоборот, если приложение запускается быстро, то все будет здорово.
Не стоит забывать и про ограничение времени запуска в 20 секунд, при превышении которого система прерывает загрузку вашего приложения. На слабых устройствах эти 20 секунд достаточно реально превысить.
Почему об оптимизации времени запуска активнее всего говорят последние полгода или год, а параллельно с тем, когда мы занимались оптимизацией времени запуска нашего приложения, несколько команд делали то же самое в своих проектах? Все больше фреймворков, которые мы с вами любим и используем, переписываются на Swift. А Swift не может быть скомпилирован в статическую библиотеку. Поэтому число динамических библиотек в нашем приложении растет, как и время.
Все настолько плохо, что Apple посвятила времени запуска отдельную статью на последнем WWDC (WWDC 2016: Optimizing App Startup Time), где раскрыла подробности работы динамического загруз
чика и рассказала, как его можно профилировать и что вообще можно сделать с временем запуска.
Что нам нужно знать, чтобы эффективно заниматься оптимизацией запуска наше
го приложения?
Начнем с того, что есть время запуска и как его замерять. Время запуска — это время от нажатия пользователем на иконку приложения и до момента, когда приложение готово к использованию.
В простейшем случае можно считать, что приложение готово к использованию по завершении функции DidFinishLaunching, то есть когда загружен основной интерфейс приложения. Однако если ваше приложение должно на старте куда-то сходить за данными, повзаимодействовать с базами данных, обновить UI, это тоже приходится учитывать. Поэтому что считать окончанием запуска — личное дело каждого разработчика.
Определились с тем, что есть время запуска, начинаем его замерять. Обнаруживаем, что время очень сильно скачет. В случае Яндекс.Карт оно скачет в два раза.
Здесь нужно ввести понятия холодного и теплого запуска. Холодный запуск — это когда приложение давно завершило свою работу и было удалено из кэша операционной системы. Холодный запуск всегда происходит после перезагрузки приложения и, в принципе, так WWDC рекомендует моделировать его в своих тестах. Теплый запуск — это когда приложение было завершено недавно.
Откуда возникает такая разница?
Запуск состоит из двух больших этапов:
При подготовке образа приложения система должна:
В случае холодного запуска этот pre-main может быть на порядок дольше, чем в случае теплого запуска.
И как раз за счет этого у нас и получается такая большая разница между холодным и теплым запуском.
Особенно сильно увеличивается время загрузки динамических библиотек в случае Swift-а. Поэтому сегодня оптимизации этого этапа мы будем уделять достаточно много времени.
Таким образом, когда вы меряете запуск своего приложения, вы должны обязательно учитывать не только тот отрезок, где ваш код работает, но и pre-main, когда система собирает веб-приложение, а также учитывать холодный запуск.
Замер pre-main — нетривиальная задача, поскольку наш код там не работает. К счастью, в последних iOS (9 и 10) Apple добавила переменную окружения DYLD_PRINT_STATISTICS, при включении которой в консоль выводится статистика работы системного загрузчика.
Выводится полное время pre-main и далее поэтапно:
У нас есть удобный инструмент для замера pre-main, теперь нужно правильно померить after-main.
Распространенная ошибка — замерять только didFinishLaunching. Но до didFinishLaunching происходит ещё инициализация UIApplication, UIApplicationDelegate, там у вас могут быть сложные конструкторы, и это всё тоже нужно учитывать. Поэтому время нужно мерить с начала main.
Если у вас в проекте нет файла main.swift, придётся его добавить и первой строчкой поставить замер запуска, а потом уже явно вызывать UIApplicationMain.
Итак, мы научились правильно замерять полное время. Однако даже в одной и той же ситуации время запуска может сильно скакать, и вы этим никак не управляете, потому что устройство в фоне может что-то делать. Скачки могут достигать 20%.
В том случае, когда мы пытаемся по копеечке улучшать наше приложение, это неприемлемо. Приходится делать много запусков, чтобы избавиться от шума. Хотелось бы это все автоматизировать, потому что вручную делать накладно, особенно если у вас много устройств.
К счастью, утилита libimobiledevice решает эту проблему. Она взаимодействует с устройством напрямую через те же самые протоколы, что и xcode и ITunes, при этом она не требует jailbreak. Утилита позволяет делать все, что нам нужно.
Во-первых, позволяет получить список подключенных устройств и их UID. Во-вторых, установить приложение на конкретное устройство, запустить приложение и перезагрузить устройство. Нам это важно, чтобы померить холодный запуск.
Что особенно важно, в запуск приложения можно передать переменную окружения (нам неоходимо передавать переменную DYLD_PRINT_STATISTICS, чтобы померить pre-main).
Разберем, как всем этим пользоваться. Нужно приготовить сборку для тестов. Это должна быть релизная конфигурация с включёнными оптимизациями, чтобы были:
И нужно сделать автоматическое завершение приложения после того, как оно загружено, если в переменных окружения указана DYLD_PRINT_STATISTICS.
Дальше пишем скрипт, который на каждой итерации:
Кажется, что все хорошо. Но мы столкнулись с тем, что перезагрузка телефона — это очень долго.
И после этого очень сильно скачет время запуска, потому что долгое время после загрузки телефон в фоне что-то делает (время скачет очень сильно — до 40%). Это неприемлемо.
К счастью, картина, примерно похожая на холодный запуск, получается, если мы не перезагружаем устройство, а просто переустанавливаем приложение. Это логично, потому что при переустановке приложение должно быть удалено из кэша.
После переустановки мы получаем явно не теплый запуск. При этом время запуска после перезагрузки и время запуска после переустановки связано, и если вы уменьшаете одно, то одновременно уменьшается и другое. А переустановка гораздо быстрее, и там не такой большой разброс значений.
Поэтому скрипт мы немного поменяем:
Имея такой скрипт, мы можем собрать статистику запуска на разных устройствах с учетом pre-main, а также холодного и теплого запуска, и дальше от этой статистики отталкиваться.
Вам может показаться, что можно сразу переходить к оптимизации. Но на самом деле нет. Сначала следует посмотреть, к чему нам нужно стремиться.
А стремиться нам нужно к времени запуска пустого проекта, поскольку быстрее, чем пустой проект, наше приложение запускаться не может.
И тут мы натыкаемся на неприятный сюрприз Swift. Возьмем простое приложение на Objective-C, замеряем его pre-main (допустим, на iPhone 5S).
У него время загрузки динамических библиотек будет меньше миллисекунды. Делаем такое же приложение на Swift, запускаем на том же устройстве:
Загрузка динамических библиотек — 200 миллисекунд — на 2 порядка больше. Если же мы запускаем приложение на iPhone 5, то здесь загрузка динамических библиотек занимает почти 800 миллисекунд.
Надо разбираться, из-за чего. Для этого включаем галочку Dynamic Library Loads, чтобы нам выводились загружаемые динамические библиотеки:
И смотрим на лог:
Сравниваем логи проектов на Objective-C и Swift. Видим, что в обоих случаях загружаются 146 динамических библиотек, являющихся системными, и бинарное приложение. Но в Swift дополнительно загружаются ещё девять подозрительных библиотек из бандла приложений — из папки Frameworks, называющиеся libswift***.dylib. Это так называемые swift standard libraries.
Если вы когда-нибудь смотрели на лог компиляции вашего приложения, там один из последних этапов — это coping swift standard libraries. Откуда они берутся?
Дело в том, что swift очень быстро развивается, и его разработчики не заморачиваются соблюдением обратной бинарной совместимости. Поэтому если вы соберете какой-нибудь модуль на swift 3.0, то даже на swift 3.01 вы уже не сможете его использовать. Компилятор так и напишет, что этого делать нельзя. Swift еще не может быть частью iOS, ведь иначе на старых iOS новый swift запускаться не будет. Поэтому приложения всегда тащат за собой Swift runtime — библиотеки поддержки swift-а, в отличие от Objective-C, который уже давно является частью системы.
Поэтому мы получаем следующие выводы:
Теперь пора переходить к хардкоровой оптимизации.
На что теоретически можно повлиять? Что рекомендует WWDC в своей презентации? Сначала вспомним этапы:
Apple рекомендует:
У нас приложение на swift. Оно большое. В нем и так уже мало Objective-C — только там, где нужно взаимодействовать с системой, с SDK. В последнем swift +load уже запрещён. Глобальных переменных C++ нет. Поэтому из этих рекомендаций, к сожалению, большинство уже не актуально. Остается только бороться с динамическим библиотеками и пытаться как-то уменьшить размер бинарного файла, загружаемого на старте, то есть выносить символы в динамические библиотеки и грузить их лениво. Это неизбежно уменьшит нам время запуска (уменьшится rebase/binding, objc-setup).
Попытаемся оптимизировать тестовое приложение.
Предположим, тестовое приложение у нас содержит много подов, написанных на swift-е, и где-то внутри себя использует Yandex MapKit и YandexSpeechKit, являющиеся статическими библиотеками, а также MapKit из iOS SDK (зачем это предположение — станет понятно чуть позже).
Замеряем время запуска.
Оно у нас в три раза выше, чем у пустого приложения. В основном за счет динамических библиотек, т.е. у пустого приложения они грузятся 200 миллисекунд, а у нашего тестового — уже 600. Из-за чего это происходит?
Начнем с самого простого: как убрать загрузку лишних динамических библиотек, которые приходят от подов? Для этого мы можем воспользоваться плагином для cocoa pods, который называется cocoapods-amimono. Он патчит скрипты и xcconfig-и, которые генерируются подами, так, чтобы после компиляции подовских библиотек оставшиеся объектники влинковать сразу же в бинарный файл приложения, и таким образом избавиться от необходимости линковки с динамическими библиотеками. Совершенно замечательное решение. И оно здорово работает. Как им воспользоваться?
В Podfile добавляем использование плагина и post-install.
Если вам повезло, все скомпилировалось сразу. Если нет — придется повозиться. Но в итоге у нас время запуска уменьшается практически в два раза: время загрузки динамических библиотек падает с 600 секунд до 320.
Это как раз за счет того, что у нас исчезли все динамические библиотеки от подов.
К сожалению, мы напоролись на несколько недостатков этого решения, связанных с тем, что у него нет большого коммьюнити (люди делали его практически под себя):
Со всем этим можно жить. Я пробовал инструмент для трех немаленьких приложений, и каждый раз где-то час-полтора у меня уходило на то, чтобы поправить линковку. Но в итоге все работает хорошо.
Итак, у нас сейчас время загрузки динамических библиотек — 320 миллисекунд. Это всё ещё в полтора раза больше, чем у пустого проекта. Почему так происходит?
Посмотрим, что у нас осталось в папке Frameworks в бандле. Там прибавилось 5 новых динамических библиотек:
Называются они: libswiftAVFoundation.dylib, libswiftCoreAudio.dylib, libswiftCoreLocation.dylib, libswiftCoreMedia.dylib и libswiftMapKit.dylib. Откуда они взялись?
Если вы где-то в коде swift делаете import CoreLocation, либо в bridging header-е делаете import <CoreLocation/CoreLocation.h>, система автоматически добавляет в бандл вашего приложения библиотеку libswiftCoreLocation.dylib. Соответственно, время загрузки увеличивается. К сожалению, никакого другого решения, кроме как не использовать CoreLocation для swift, я не нашел.
Поэтому берем и оборачиваем его в Objective-C.
Для упрощения рефакторинга мы можем взять только ту часть CoreLocation, которую используем, и написать точно такую же обертку, но с другим префиксом — вместо CoreLocation использовать, например, CLWLocationManager (подразумевая core location wrapped), CLWLocation, CLWHeading и т.п. Тогда в swift можно использовать только эти обертки — и библиотека, казалось бы, не добавляется.
Я так сделал, но сразу «не взлетело».
Оказалось, что CoreLocation может импортировать в заголовочных файлах зависимости, которые добавляются в bridging header-е. Их приходится тоже оборачивать в Objective C, либо как-то bridging header-ом рефакторить. Также CoreLocation может импортироваться как зависимость других библиотек SDK, например, MapKit. Получается, что MapKit тащит за собой сразу две библиотеки: libswiftCoreLocation.dylib и libswiftMapKit.dylib, а AVFoundation вообще сразу три.
То есть если вы где-то в swift-овом файле напишите import AVFoundation и import MapKit, время загрузки динамических библиотек у вас сразу подрастёт в 1,5 раза, даже если вы этот API не использовали. Поэтому пишем обертки, после чего возвращаемся к времени загрузки динамических библиотек пустого проекта.
Дальше стремиться уже некуда — получили 10 миллисекунд. Остается немного помучиться, чтобы уменьшить остальные этапы.
Как я говорил, здесь рекомендации Apple по оптимизации отдельного этапа уже не подходят, потому что у нас всё на swift и нет Objective C или C++.
Здесь приходится руководствоваться банальной идеей, что на старте должны загружаться только те символы, которые необходимы для отображения стартового экрана. В идеале, вообще у нас бинарник приложения должен состоять только из тех символов, которые нужны на старте. Все остальные было бы здорово грузить лениво. Как это сделать в нашем тестовом приложении?
Ранее я говорил, что у нас внутри приложения используется Yandex MapKit, YandexSpeechkit. Было бы здорово эти статические библиотеки сделать динамическими и загружать их лениво через dlopen. Это неизбежно уменьшит время rebase/bind, obj startup и инициализации, потому что меньше символов Objective C будет грузиться на старте. Как это сделать удобным способом?
Для начала конвертнём их в динамические библиотеки. Для этого для каждой статической библиотеки мы создаем отдельный таргет Cocoa Touch Framework в нашем приложении, и в Podfile в созданный таргет добавляем нужный под. Остается только поправить линковку (если под спек написан хорошо, то он у вас сразу слинкуется, а если под спек написан плохо, придется поправить линковку).
В основном таргете мы, соответственно:
Последнее, на самом деле, — самый сложный момент. Хотя не столько сложный, сколько пугающий, потому что здесь приходится использовать API dlopen из dlfcn.
Я раньше этого API очень боялся. Мне казалось, что там всё страшно и сложно. Но на самом деле все не очень плохо.
Для начала можно загрузить динамическую библиотеку через dlopen. Она принимает первым параметром просто путь до библиотеки (библиотека лежит в папке Frameworks нашего бандла). dlopen возвращает дескриптор, который в дальнейшем будет использоваться в функции dlsym, загружающей отдельные символы.
Теперь, если нам нужен символ функции, мы используем dlsym и передаем туда имя функции (какое было в исходной библиотеке). dlsym вернёт указатель на эту функцию.
Далее эту функцию мы можем вызвать обычным синтаксисом по указателю. С глобальной переменной точно так же — загружаем по ее имени через dlsym, возвращается адрес этой глобальной переменной. Дальше нужно только ее разыменовать. Все не так сложно, как казалось изначально.
С классами немного посложнее, но всё еще жить можно.
Во-первых, мы можем импортировать наши бывшие статические библиотеки. Это не приведет к их загрузке и не вызовет ошибки линковки. Это просто какие-то декларации компилятору, и пока мы их не используем напрямую, ошибки линковки не происходит. Тем не менее, этот h-ник импортировать стоит.
Далее нам, предположим, нужен какой-то класс. Имя его символа будет состоять из имени класса плюс префикс OBJC_CLASS_$. dlsym вернёт нам класс как инстанс всего метакласса. Далее для этого инстанса метакласса мы можем вызвать alloc. Это вернёт нам объект типа ID. А дальше начинается волшебство Objective-C, потому что здесь мы можем у любого объекта типа ID вызвать селектор, про который компилятор знает. А селекторы у нас объявлены как раз в импортируемом h-нике библиотеки. Далее мы можем скастить к нужному объекту и использовать API из нашей библиотеки. Т.е. вся проблема заключается лишь в том, что символ класса нужно загрузить, а потом использовать его как обычно.
Что это нам дает?
На самом деле, в нашем тестовом проекте это дает не очень много — всего лишь 30 миллисекунд.
Но, тем не менее, такой источник оптимизации нужно рассматривать. Возможно, у вас где-то используется большая статическая библиотека, которая на старте не нужна, а символы её на старте всё равно грузятся — тут зависит уже от приложения.
Естественно, через dlopen вы можете загружать не только какие-то зависимости, но и свой код разбить на модули и загружать их лениво. Иными словами, мы стремимся к тому, чтобы бинарный файл приложения был как можно меньше. Это, конечно же, требует сильного рефакторинга. Но то, что я написал, можно сделать достаточно быстро.
Вот сводная картинка, как мы оптимизировали запуск приложения в три раза через три этапа.
Если у вас проект на Objective-C, то первые два этапа вам, скорее всего, будут не нужны, поскольку у вас и так динамических библиотек нет. Но над dlopen-ом вам стоило бы подумать.
Как можно dlopen автоматизировать, а не писать руками? Можно посмотреть в Facebook SDK. Там это сделано через систему макросов.
Использование каждого символа класса вам нужно обернуть в загрузку динамической библиотеки (если она еще не загружена) и загрузку символа (если он ещё не загружен). Да, получаются громоздкие конструкции, но вы можете сделать все это через макросы, чтобы код по загрузке символа схлопывался в одну строчку использования макроса.
Остается немного поговорить про after-main. Немного — потому что на самом деле это обычная задача по оптимизации iOS приложения, про которую уже много написано. И в основном она зависит от самого приложения. Нельзя придумать какую-то супер-общую схему, которая подойдет для любого приложения.
Но, тем не менее, я расскажу о том, с чем мы столкнулись в Картах и что сработало.
Рекомендую посмотреть на три вещи, которые есть в вашем приложении:
Как это будет выглядеть в случае Карт?
У нас в Картах был класс RootViewController, который, следуя стандартному подходу к инъекции зависимостей, принимал зависимости через свой конструктор.
То есть чтобы создать RouteController, нужно сначала создать Facade поиска и Facade маршрутизации. Пользователь еще не нажал на кнопку поиска и маршрутизации, однако эти фасады уже созданы.
Причем если исследовать дальше инъекцию зависимостей, то и все зависимости от этих двух фасадов тоже будут созданы. И в итоге у нас на старте вообще все зависимости приложения создавались. Это не здорово, потому что, во-первых, затрачивается дополнительное процессорное время, а во-вторых, это очень усложняет профилирование, потому что профилировщик забивается невероятным количеством вызовов, и разобраться в этом уже очень сложно.
Вместо этого мы перешли на инъекцию контейнеров зависимостей, которые реализуют ленивое создание зависимостей. То есть мы теперь:
Как это выглядит:
Создаем протокол RootViewControllerDeps, который декларирует, что его реализация должна отдавать SearchFacade и RoutingFacade. RootViewController теперь принимает некоторую реализацию этого протокола.
В реализации этого протокола мы используем lazy var:
Теперь searchFacade и routingFacade будут созданы только тогда, когда они понадобятся RootViewController, т.е. только тогда, когда пользователь нажмет на Поиск. И всё это не мешает нам сделать классический Composition Root — некоторый класс, который реализует все зависимости приложения.
Он будет реализовывать все протоколы верхнеуровневых сущностей и передавать себя как контейнер зависимостей.
Что это нам дало?
Теперь нужно постараться сократить дерево view, чтобы система не тратила времени на его рендеринг. Здесь есть несколько аспектов:
У нас используется NavigationController. У нас карта лежит в NavigationController, так как меню карточки и все прочее приходят через Push.
При этом у NavigationController изначально скрыт NavigationBar — он нам на старте не нужен. И получается, что создание NavigationController не влияет на стартовый UI. Так зачем нам его тогда создавать?
Давайте его создавать лениво. Когда нам надо будет что-то запушить, будет создан NavigationController, а контент, в нашем случае — карта, будет переложен в этот NavigationController из того места, где он лежит сейчас. И тогда мы получаем, что на старте у нас будет создан только контент, а не NavigationController.
Помимо этого у нас ещё есть SplitViewController (для iPad), который на iPhone вообще не нужен. Да и на iPad он нужен только тогда, когда требуется показать боковую панельку. Поэтому смотрите на свои приложения, анализируйте. Возможно, у вас тоже что-то похожее есть.
В результате наших мучений мы пришли к оптимизации дерева View в несколько раз. Теперь у нас создаются только те контейнеры, view-контроллеры, кнопочки, вьюхи и т.п., которые нужны на старте.
В принципе, над этой картинкой можно много думать. Например, обратите внимание на конструкцию, которая торчит слева поверх всего контента, но при этом на экран не попадает. Это старый добрый Navigation Bar, который спрятан. Но он создан. А у нас в Navigation Bar использовалось два кастомных шрифта, соответственно, эти два шифта загружаются на старте, хотя ничего этого не видно. Теперь у нас он не создается.
Пачки вьюх слева — это экранные кнопки. Раньше у нас к каждой кнопке создавался лейбл, Image, background. Теперь создается только то, подо что есть контент. Всё остальное — где-то затесался SplitViewController, где-то просто контейнеры. Все это мы вычистили, оставив только то, что нужно на старте.
В качестве прочих оптимизаций UI можно напомнить про то, что:
Список можно продолжать долго, все зависит от вашего приложения и опыта.
Остается последнее из того, что можно сказать в контексте оптимизации after-main.
Открываем профилировщик. Теперь в профилировщике остались только те вызовы, только та работа, которая на старте нужна. Скорее всего, это создание UI, инициализация, например, библиотек аналитики, открытие баз данных и всё прочее. Задумайтесь, вся ли эта работа вам нужна именно в момент генерации начального интерфейса? Возможно, что-то вы можете отложить на 200-300 миллисекунд, таким образом раньше показав стартовый UI, а саму работу сделав чуть попозже (и при этом пользователю сильно хуже не станет).
В Картах у нас такая работа нашлась:
Такой список в каждом приложении будет свой. Смотрите на свое приложение, анализируйте. Возможно, что-то вы можете отложить.
За месяц-полтора (или сколько у вас это займет) вы получили некоторые результаты — оптимизировали запуск. Хотелось бы, чтобы этот результат не испортился в очередном апдейте.
Если у вас есть какие-то элементы continuous integration, то сам Бог велел вам добавить туда замеры запусков и сбор статистики. Т.е. после каждого билда прогонять замеры на каком-то тестовом устройстве, собирать статистику, отправлять эту статистику, обеспечить к ней доступ. На этом я сейчас останавливаться не буду, потому что всё хорошо описано в докладе mail.ru, который был опубликован на Хабре. Я просто дополню этот доклад несколькими советами.
Во-первых, если у вас есть Composition Root, давайте будем собирать лог создания зависимостей.
Сделаем generic-функцию, которая принимает дженерик-блок, а возвращает instance некоторого типа. В этой функции вызываем этот блок и логгируем какие-то действия — в общем, декорируем создание зависимости. Такая функция достаточно органично вписывается в наш подход с composition root, реализующим ленивое создание зависимостей, потому что нам нужно всего лишь добавить вызов этой функции в блоке, который вызывается при создании очередной зависимости (а это дженерик, поэтому даже с типизацией возиться не нужно).
Таким образом, мы можем обернуть в этот trackCreation всё создание зависимостей и задекорировать их создание. В нашем случае нужно хотя бы собрать лог.
Зачем это делать? Затем, что вы при рефакторинге можете что-нибудь задеть, и у вас создастся зависимость, которая на самом деле создаваться не должна.
Бич swift — это динамические библиотеки. Так давайте следить за тем, чтобы у нас не появлялось новых динамических библиотек. Мы можем использовать Objective-C runtime, чтобы получить список загруженных в данный момент библиотек.
Функция objc_copyImageNames возвращает указатель на массив, содержащий пути до всех загруженных фреймворков. Там много всего, но главное — оттуда можно отфильтровать те библиотеки, которые лежат в бандле нашего приложения, поскольку именно они влияют на запуск. Можно следить за тем, чтобы там ничего нового не появлялось. А если появилось, то сразу же пытаться разобраться, почему так произошло. Появиться что-то там может, например, из-за обновления iOS: добавили новую обёртку над какой-нибудь стандартной библиотекой, или же какой-нибудь под обновился, потянул другую зависимость, которая написана на swift, или какой-то класс на Objective-C переписан. В общем, тут вариантов очень много. Нужно следить за тем, чтобы новых динамических библиотек не появлялось.
А вот это уже вообще хардкор:
Есть такая функция — sysctl. Она взаимодействует напрямую с ядром операционной системы и позволяет вытащить из него информацию о текущем процессе. В структуре, которую она возвращает, лежит таймстамп начала этого процесса, и, мы проверили, через эту функцию реально получить полное время загрузки приложения, начиная от нажатия пользователем на кнопочку, т.е. считая pre-main.
Почему я не стал говорить об этом раньше, когда рассказывал про замеры запуска? Потому что на самом деле для замеров запуска при оптимизации вполне достаточно DYLD_PRINT_STATISTICS. А эта штука нужна, чтобы замерять время пользовательских запусков, потому что туда вы DYLD_PRINT_STATISTICS передать не можете. А было бы очень здорово замерять пользовательские запуски и отправлять их в системы аналитики и ещё раз проверять, что после каждого апдейта у вас время полного запуска не изменилось. Тут нужно использовать sysctl, чтобы получить начало процесса, и gettimeofday, чтобы какую-то точку взять, например, start main. И друг из друга их вычесть.
Остается рассказать о том, чего мы добились в Картах.
Мы смогли холодный запуск ускорить на 30%.
Мы поработали над всеми частями. Загрузку динамических библиотек оптимизировали за счёт Objective-C обёрток на AVFoundation, MapKit и CoreLocation (она у нас упала в полтора раза, как и для того тестового приложения). У нас не было изначально swift-овых библиотек в подах, поэтому в принципе не так долго приложение грузилось на S5 — всего 2,3 секунды. Бывает и хуже, особенно в приложениях, где много swift подов. Но всё равно мы сделали лучше — динамические библиотеки у нас грузятся быстрее в полтора раза. Obj setup, rebase/bind и инициализацию мы сделали чуть-чуть поменьше за счёт ленивой загрузки SpeechKit. Тут можно продолжать дальше — какие-то собственные куски приложения лениво загружать.
Зеленый и сиреневый блоки — это after-main, просто у нас он разделен на два отрезка: с начала main до didFinishLaunching и сам didFinishLaunching. С начала main и до didFinishLaunching у нас в несколько раз улучшилось время за счёт того, что на старте лишние зависимости не создаются, а сам didFinishLaunching у нас на 40-50% улучшился как раз за счёт оптимизации view-tree и откладывания необязательной на старте работы на 200-300 миллисекунд.
Спасибо Apple за то, что такая же картина и на других устройствах!
Здесь нет такого, что мы оптимизировали на одном устройстве, а на другом это все не сработало. Все устройства примерно одинаково работают, и соотношение между разными этапами запуска на них примерно одинаково. И пока мы на iPhone 5S на 30% оптимизировали запуск, на iPhone 7, 6S и 5 мы получили аналогичное ускорение.
Если говорить про теплый запуск, тут дело еще лучше.
Потому что в теплым запуске у нас, как мы помним, pre-main практически ни на что не влияет. А вот after-main — это, собственно, то, из чего тёплый запуск и состоит. И в нашем случае, так как мы didFinishLaunching оптимизировали не на 30%, а на 40%, получилось, что время запуска улучшилось на 37%. То же самое на остальных устройствах.
Картинка для 5S повторяется и для других устройств. Причем для новых устройств типа iPhone 6S и 7 теплый запуск ускорился в 2 раза.
И несмотря на то, что все эти значения не такие большие, когда реально пользуешься приложением, это заметно. Наши пользователи на это отреагировали. Есть хорошие отзывы в AppStore.
В заключение пробежимся по тому, о чем мы сегодня говорили. Оптимизация времени запуска — это итерационный процесс.
На каждой итерации вы что-то пытаетесь сделать, какие-то свои положения выдвигаете, реализовываете. После итерации проводите цикл замеров на разных или даже на одном устройстве (то, что работает на одном iPhone, будет работать и на другом iPhone). Дальше опять пытаетесь что-то улучшить.
Сама оптимизация состоит из работы на двух отрезках: pre-main и after-main. И они оптимизируются совершенно независимо друг от друга, потому что при pre-main-е у нас только система работает — DYLD (динамический загрузчик), а after-main — это наш код.
При оптимизации pre-main мы стараемся, во-первых, уменьшить количество динамических библиотек через использование плагина для cocoapods и через написание обёрток для лишних swift standard libraries. А во-вторых, пытаемся уменьшится размер бинарного файла.
При оптимизации after-main мы руководствуемся профилировщиком и т.п., пытаясь на старте делать как можно меньше работы. Стараемся не создавать лишних сущностей (создаем только тот объем UI, который нужен для показа стартового интерфейса), а необязательную для показа работу пытаемся отложить.
Вот и все секреты.
Если вы любите мобильную разработку также как мы, рекомендуем обратить внимание на следующие доклады на грядущей конференции Mobius 2017 Moscow:
Похожих сценариев, когда приложение открывают ненадолго, много. Поэтому нам очень важно, чтобы приложение запускалось быстро. Недавно мы провели большую работу по оптимизации времени запуска. Этим опытом я и хочу с вами сейчас поделиться.»
В основу данного материала легло выступление Николая Лихогруда, руководителя разработки мобильных Яндекс.Карт для iOS, на конференции Mobius 2017.
likhogrud уже написал пост на эту тему в блоге Яндекса, но мы не могли не выпустить один из лучших докладов конференции. Здесь есть и видео, и текст под катом, и презентация смотрите, как вам удобнее.
Зачем сокращать время запуска?
Стив Джобс говорил, что самый ценный ресурс человека — это его время. Возможно, вы слышали историю о том, как Стив заставил на полминуты ускорить запуск операционной системы Macintosh, мотивируя это тем, что ускорение всего лишь на 10 секунд сэкономит 300 миллионов пользовательских часов за год. Стив считал запуск приложения очень важным аспектом производительности.
Очевидно, что из двух приложений с одинаковым набором функций пользователь выберет то, которое быстро запускается. Если альтернативы нет, а приложение запускается долго, пользователя это будет раздражать, он будет реже возвращаться в ваше приложение, писать плохие отзывы. И, наоборот, если приложение запускается быстро, то все будет здорово.
Не стоит забывать и про ограничение времени запуска в 20 секунд, при превышении которого система прерывает загрузку вашего приложения. На слабых устройствах эти 20 секунд достаточно реально превысить.
Почему об оптимизации времени запуска активнее всего говорят последние полгода или год, а параллельно с тем, когда мы занимались оптимизацией времени запуска нашего приложения, несколько команд делали то же самое в своих проектах? Все больше фреймворков, которые мы с вами любим и используем, переписываются на Swift. А Swift не может быть скомпилирован в статическую библиотеку. Поэтому число динамических библиотек в нашем приложении растет, как и время.
Все настолько плохо, что Apple посвятила времени запуска отдельную статью на последнем WWDC (WWDC 2016: Optimizing App Startup Time), где раскрыла подробности работы динамического загруз
чика и рассказала, как его можно профилировать и что вообще можно сделать с временем запуска.
Что нам нужно знать, чтобы эффективно заниматься оптимизацией запуска наше
го приложения?
- Что есть время запуска, и его нужно мерить;
- Как оптимизировать тот этап загрузки приложения, который происходит до того, как вызывается функция Main, — то есть до того, как начинает выполняться наш код;
- Как активизировать время запуска после этого момента;
- Как не испортить результат при последующих апдейтах.
Замеры времени запуска
Начнем с того, что есть время запуска и как его замерять. Время запуска — это время от нажатия пользователем на иконку приложения и до момента, когда приложение готово к использованию.
В простейшем случае можно считать, что приложение готово к использованию по завершении функции DidFinishLaunching, то есть когда загружен основной интерфейс приложения. Однако если ваше приложение должно на старте куда-то сходить за данными, повзаимодействовать с базами данных, обновить UI, это тоже приходится учитывать. Поэтому что считать окончанием запуска — личное дело каждого разработчика.
Определились с тем, что есть время запуска, начинаем его замерять. Обнаруживаем, что время очень сильно скачет. В случае Яндекс.Карт оно скачет в два раза.
Здесь нужно ввести понятия холодного и теплого запуска. Холодный запуск — это когда приложение давно завершило свою работу и было удалено из кэша операционной системы. Холодный запуск всегда происходит после перезагрузки приложения и, в принципе, так WWDC рекомендует моделировать его в своих тестах. Теплый запуск — это когда приложение было завершено недавно.
Откуда возникает такая разница?
Запуск состоит из двух больших этапов:
- подготовка образа приложения;
- запуск нашего кода — когда запускается функция main.
При подготовке образа приложения система должна:
- загрузить все динамические библиотеки, для каждой динамической библиотеки проверить ее цифровую подпись, отобразить ее виртуальное адресное пространство;
- поскольку библиотека может быть расположена в любом месте памяти, нужно поправить указатели, подставить адреса неизвестных символов в других библиотеках;
- создать контекст Objective-C, т.е. зарегистрировать C-классы, селекторы, категории, уникализировать селекторы, плюс там выполняется и другая работа;
- проинициализировать классы, выполнить вызов +load и конструктора глобальных переменных C++.
В случае холодного запуска этот pre-main может быть на порядок дольше, чем в случае теплого запуска.
И как раз за счет этого у нас и получается такая большая разница между холодным и теплым запуском.
Особенно сильно увеличивается время загрузки динамических библиотек в случае Swift-а. Поэтому сегодня оптимизации этого этапа мы будем уделять достаточно много времени.
Таким образом, когда вы меряете запуск своего приложения, вы должны обязательно учитывать не только тот отрезок, где ваш код работает, но и pre-main, когда система собирает веб-приложение, а также учитывать холодный запуск.
Замер pre-main
Замер pre-main — нетривиальная задача, поскольку наш код там не работает. К счастью, в последних iOS (9 и 10) Apple добавила переменную окружения DYLD_PRINT_STATISTICS, при включении которой в консоль выводится статистика работы системного загрузчика.
Выводится полное время pre-main и далее поэтапно:
- время загрузки динамических библиотек;
- время rebase/binding — т.е. правки указателей и связывания;
- время создания ObjC контекста;
- время инициализации — там, где +load и глобальные переменные.
Замер after-main
У нас есть удобный инструмент для замера pre-main, теперь нужно правильно померить after-main.
Распространенная ошибка — замерять только didFinishLaunching. Но до didFinishLaunching происходит ещё инициализация UIApplication, UIApplicationDelegate, там у вас могут быть сложные конструкторы, и это всё тоже нужно учитывать. Поэтому время нужно мерить с начала main.
Если у вас в проекте нет файла main.swift, придётся его добавить и первой строчкой поставить замер запуска, а потом уже явно вызывать UIApplicationMain.
Итак, мы научились правильно замерять полное время. Однако даже в одной и той же ситуации время запуска может сильно скакать, и вы этим никак не управляете, потому что устройство в фоне может что-то делать. Скачки могут достигать 20%.
В том случае, когда мы пытаемся по копеечке улучшать наше приложение, это неприемлемо. Приходится делать много запусков, чтобы избавиться от шума. Хотелось бы это все автоматизировать, потому что вручную делать накладно, особенно если у вас много устройств.
К счастью, утилита libimobiledevice решает эту проблему. Она взаимодействует с устройством напрямую через те же самые протоколы, что и xcode и ITunes, при этом она не требует jailbreak. Утилита позволяет делать все, что нам нужно.
Во-первых, позволяет получить список подключенных устройств и их UID. Во-вторых, установить приложение на конкретное устройство, запустить приложение и перезагрузить устройство. Нам это важно, чтобы померить холодный запуск.
Что особенно важно, в запуск приложения можно передать переменную окружения (нам неоходимо передавать переменную DYLD_PRINT_STATISTICS, чтобы померить pre-main).
Сборка для тестов
Разберем, как всем этим пользоваться. Нужно приготовить сборку для тестов. Это должна быть релизная конфигурация с включёнными оптимизациями, чтобы были:
- выключены ассерты,
- включены оптимизации (иначе будет добавляться библиотека libswiftSwiftOnoneSupport.dylib, которая влияет на время запуска).
И нужно сделать автоматическое завершение приложения после того, как оно загружено, если в переменных окружения указана DYLD_PRINT_STATISTICS.
Дальше пишем скрипт, который на каждой итерации:
- перезагружает устройство (indevicediagnostics restart);
- дожидается завершения загрузки;
- затем мы запускаем приложение (indevicedebug run — это будет холодный запуск);
- оно автоматически завершается;
- мы ещё раз запускаем (indevicedebug run — это уже будет тёплый запуск);
- скрипт обрабатывает вывод, сохраняет логи.
Кажется, что все хорошо. Но мы столкнулись с тем, что перезагрузка телефона — это очень долго.
И после этого очень сильно скачет время запуска, потому что долгое время после загрузки телефон в фоне что-то делает (время скачет очень сильно — до 40%). Это неприемлемо.
К счастью, картина, примерно похожая на холодный запуск, получается, если мы не перезагружаем устройство, а просто переустанавливаем приложение. Это логично, потому что при переустановке приложение должно быть удалено из кэша.
После переустановки мы получаем явно не теплый запуск. При этом время запуска после перезагрузки и время запуска после переустановки связано, и если вы уменьшаете одно, то одновременно уменьшается и другое. А переустановка гораздо быстрее, и там не такой большой разброс значений.
Поэтому скрипт мы немного поменяем:
- первым этапом идёт переустановка приложения (indevicinstaller -i);
- потом запуск indevicedebug run (холодный);
- ещё один indevicedebug run (теплый запуск);
- и обработка вывода.
Имея такой скрипт, мы можем собрать статистику запуска на разных устройствах с учетом pre-main, а также холодного и теплого запуска, и дальше от этой статистики отталкиваться.
Пустой проект на swift
Вам может показаться, что можно сразу переходить к оптимизации. Но на самом деле нет. Сначала следует посмотреть, к чему нам нужно стремиться.
А стремиться нам нужно к времени запуска пустого проекта, поскольку быстрее, чем пустой проект, наше приложение запускаться не может.
И тут мы натыкаемся на неприятный сюрприз Swift. Возьмем простое приложение на Objective-C, замеряем его pre-main (допустим, на iPhone 5S).
У него время загрузки динамических библиотек будет меньше миллисекунды. Делаем такое же приложение на Swift, запускаем на том же устройстве:
Загрузка динамических библиотек — 200 миллисекунд — на 2 порядка больше. Если же мы запускаем приложение на iPhone 5, то здесь загрузка динамических библиотек занимает почти 800 миллисекунд.
Надо разбираться, из-за чего. Для этого включаем галочку Dynamic Library Loads, чтобы нам выводились загружаемые динамические библиотеки:
И смотрим на лог:
Сравниваем логи проектов на Objective-C и Swift. Видим, что в обоих случаях загружаются 146 динамических библиотек, являющихся системными, и бинарное приложение. Но в Swift дополнительно загружаются ещё девять подозрительных библиотек из бандла приложений — из папки Frameworks, называющиеся libswift***.dylib. Это так называемые swift standard libraries.
Если вы когда-нибудь смотрели на лог компиляции вашего приложения, там один из последних этапов — это coping swift standard libraries. Откуда они берутся?
Дело в том, что swift очень быстро развивается, и его разработчики не заморачиваются соблюдением обратной бинарной совместимости. Поэтому если вы соберете какой-нибудь модуль на swift 3.0, то даже на swift 3.01 вы уже не сможете его использовать. Компилятор так и напишет, что этого делать нельзя. Swift еще не может быть частью iOS, ведь иначе на старых iOS новый swift запускаться не будет. Поэтому приложения всегда тащат за собой Swift runtime — библиотеки поддержки swift-а, в отличие от Objective-C, который уже давно является частью системы.
Поэтому мы получаем следующие выводы:
- кто бы что ни говорил, загрузка системных библиотек оптимизирована (я сам потратил много времени на то, чтобы окончательно в этом убедиться). Даже если у вас 200 или 300 системных библиотек грузится, это всё равно не влияет на время запуска, так что не тратьте время, пытаясь сократить их количество;
- а вот библиотеки из бандла приложений грузятся долго. К сожалению, к ним относятся swift standard libraries;
- даже пустое приложение на iPhone грузится секунду (при холодном запуске). Соответственно, ваше приложение не может грузиться меньше секунды;
- когда-нибудь эта проблема исчезнет. Уже готовится swift 4, и swift standard libraries просто станут частью системы, а приложения перестанут за собой эти библиотеки таскать.
Теперь пора переходить к хардкоровой оптимизации.
Оптимизация pre-main
На что теоретически можно повлиять? Что рекомендует WWDC в своей презентации? Сначала вспомним этапы:
- загрузка динамических библиотек;
- rebase/binding, т.е. исправление указателей;
- objc-setup, т.е. создание Objective C контекста;
- инициализация, то есть глобальные переменные C++ и метода +load.
Apple рекомендует:
- уменьшить число загружаемых динамических библиотек;
- уменьшить использование Objective-C (больше использовать swift), что позволит уменьшить количество метаданных Objective-C, создаваемых на старте;
- перенести код из +load в +initialize, чтобы он вызывался тогда, когда код первый раз обращается к классу;
- избавиться от статических переменных C++ со сложными конструкторами.
У нас приложение на swift. Оно большое. В нем и так уже мало Objective-C — только там, где нужно взаимодействовать с системой, с SDK. В последнем swift +load уже запрещён. Глобальных переменных C++ нет. Поэтому из этих рекомендаций, к сожалению, большинство уже не актуально. Остается только бороться с динамическим библиотеками и пытаться как-то уменьшить размер бинарного файла, загружаемого на старте, то есть выносить символы в динамические библиотеки и грузить их лениво. Это неизбежно уменьшит нам время запуска (уменьшится rebase/binding, objc-setup).
Оптимизация динамических библиотек
Попытаемся оптимизировать тестовое приложение.
Предположим, тестовое приложение у нас содержит много подов, написанных на swift-е, и где-то внутри себя использует Yandex MapKit и YandexSpeechKit, являющиеся статическими библиотеками, а также MapKit из iOS SDK (зачем это предположение — станет понятно чуть позже).
Замеряем время запуска.
Оно у нас в три раза выше, чем у пустого приложения. В основном за счет динамических библиотек, т.е. у пустого приложения они грузятся 200 миллисекунд, а у нашего тестового — уже 600. Из-за чего это происходит?
- добавилась динамическая библиотека для каждого пода, скомпилированного из swift;
- стало больше библиотек swift standard libraries. Изначально их 9, а сейчас — уже 14.
Начнем с самого простого: как убрать загрузку лишних динамических библиотек, которые приходят от подов? Для этого мы можем воспользоваться плагином для cocoa pods, который называется cocoapods-amimono. Он патчит скрипты и xcconfig-и, которые генерируются подами, так, чтобы после компиляции подовских библиотек оставшиеся объектники влинковать сразу же в бинарный файл приложения, и таким образом избавиться от необходимости линковки с динамическими библиотеками. Совершенно замечательное решение. И оно здорово работает. Как им воспользоваться?
В Podfile добавляем использование плагина и post-install.
Если вам повезло, все скомпилировалось сразу. Если нет — придется повозиться. Но в итоге у нас время запуска уменьшается практически в два раза: время загрузки динамических библиотек падает с 600 секунд до 320.
Это как раз за счет того, что у нас исчезли все динамические библиотеки от подов.
К сожалению, мы напоролись на несколько недостатков этого решения, связанных с тем, что у него нет большого коммьюнити (люди делали его практически под себя):
- cocoapods-amimono пропускает интеграцию подов, поставляемых фреймворками, то есть такие поды вам нужно самим добавить в приложение и выполнить интеграцию;
- в нём нет контроля за тем, какие поды вмержить в бинарный файл, а какие — оставить как есть;
- еще почему-то таргеты, название которых содержат «test», опускаются.
Со всем этим можно жить. Я пробовал инструмент для трех немаленьких приложений, и каждый раз где-то час-полтора у меня уходило на то, чтобы поправить линковку. Но в итоге все работает хорошо.
Обертки Objective-C
Итак, у нас сейчас время загрузки динамических библиотек — 320 миллисекунд. Это всё ещё в полтора раза больше, чем у пустого проекта. Почему так происходит?
Посмотрим, что у нас осталось в папке Frameworks в бандле. Там прибавилось 5 новых динамических библиотек:
Называются они: libswiftAVFoundation.dylib, libswiftCoreAudio.dylib, libswiftCoreLocation.dylib, libswiftCoreMedia.dylib и libswiftMapKit.dylib. Откуда они взялись?
Если вы где-то в коде swift делаете import CoreLocation, либо в bridging header-е делаете import <CoreLocation/CoreLocation.h>, система автоматически добавляет в бандл вашего приложения библиотеку libswiftCoreLocation.dylib. Соответственно, время загрузки увеличивается. К сожалению, никакого другого решения, кроме как не использовать CoreLocation для swift, я не нашел.
Поэтому берем и оборачиваем его в Objective-C.
Для упрощения рефакторинга мы можем взять только ту часть CoreLocation, которую используем, и написать точно такую же обертку, но с другим префиксом — вместо CoreLocation использовать, например, CLWLocationManager (подразумевая core location wrapped), CLWLocation, CLWHeading и т.п. Тогда в swift можно использовать только эти обертки — и библиотека, казалось бы, не добавляется.
Я так сделал, но сразу «не взлетело».
Оказалось, что CoreLocation может импортировать в заголовочных файлах зависимости, которые добавляются в bridging header-е. Их приходится тоже оборачивать в Objective C, либо как-то bridging header-ом рефакторить. Также CoreLocation может импортироваться как зависимость других библиотек SDK, например, MapKit. Получается, что MapKit тащит за собой сразу две библиотеки: libswiftCoreLocation.dylib и libswiftMapKit.dylib, а AVFoundation вообще сразу три.
То есть если вы где-то в swift-овом файле напишите import AVFoundation и import MapKit, время загрузки динамических библиотек у вас сразу подрастёт в 1,5 раза, даже если вы этот API не использовали. Поэтому пишем обертки, после чего возвращаемся к времени загрузки динамических библиотек пустого проекта.
Дальше стремиться уже некуда — получили 10 миллисекунд. Остается немного помучиться, чтобы уменьшить остальные этапы.
Загрузка через dlopen
Как я говорил, здесь рекомендации Apple по оптимизации отдельного этапа уже не подходят, потому что у нас всё на swift и нет Objective C или C++.
Здесь приходится руководствоваться банальной идеей, что на старте должны загружаться только те символы, которые необходимы для отображения стартового экрана. В идеале, вообще у нас бинарник приложения должен состоять только из тех символов, которые нужны на старте. Все остальные было бы здорово грузить лениво. Как это сделать в нашем тестовом приложении?
Ранее я говорил, что у нас внутри приложения используется Yandex MapKit, YandexSpeechkit. Было бы здорово эти статические библиотеки сделать динамическими и загружать их лениво через dlopen. Это неизбежно уменьшит время rebase/bind, obj startup и инициализации, потому что меньше символов Objective C будет грузиться на старте. Как это сделать удобным способом?
Для начала конвертнём их в динамические библиотеки. Для этого для каждой статической библиотеки мы создаем отдельный таргет Cocoa Touch Framework в нашем приложении, и в Podfile в созданный таргет добавляем нужный под. Остается только поправить линковку (если под спек написан хорошо, то он у вас сразу слинкуется, а если под спек написан плохо, придется поправить линковку).
В основном таргете мы, соответственно:
- под убираем;
- фреймворк, который компилируется из только что созданного таргета, добавляем в embedded binaries, чтобы он шёл вместе с бандлом, но не добавляем его в линковку;
- переносим нужные ресурсы, правим интеграцию;
- пишем Objective-C обёртки для ленивых загрузок — Objective-C обертки, которые реализуют тот же самый интерфейс, но при этом загружают библиотеку и символы лениво.
Последнее, на самом деле, — самый сложный момент. Хотя не столько сложный, сколько пугающий, потому что здесь приходится использовать API dlopen из dlfcn.
Я раньше этого API очень боялся. Мне казалось, что там всё страшно и сложно. Но на самом деле все не очень плохо.
Для начала можно загрузить динамическую библиотеку через dlopen. Она принимает первым параметром просто путь до библиотеки (библиотека лежит в папке Frameworks нашего бандла). dlopen возвращает дескриптор, который в дальнейшем будет использоваться в функции dlsym, загружающей отдельные символы.
Теперь, если нам нужен символ функции, мы используем dlsym и передаем туда имя функции (какое было в исходной библиотеке). dlsym вернёт указатель на эту функцию.
Далее эту функцию мы можем вызвать обычным синтаксисом по указателю. С глобальной переменной точно так же — загружаем по ее имени через dlsym, возвращается адрес этой глобальной переменной. Дальше нужно только ее разыменовать. Все не так сложно, как казалось изначально.
С классами немного посложнее, но всё еще жить можно.
Во-первых, мы можем импортировать наши бывшие статические библиотеки. Это не приведет к их загрузке и не вызовет ошибки линковки. Это просто какие-то декларации компилятору, и пока мы их не используем напрямую, ошибки линковки не происходит. Тем не менее, этот h-ник импортировать стоит.
Далее нам, предположим, нужен какой-то класс. Имя его символа будет состоять из имени класса плюс префикс OBJC_CLASS_$. dlsym вернёт нам класс как инстанс всего метакласса. Далее для этого инстанса метакласса мы можем вызвать alloc. Это вернёт нам объект типа ID. А дальше начинается волшебство Objective-C, потому что здесь мы можем у любого объекта типа ID вызвать селектор, про который компилятор знает. А селекторы у нас объявлены как раз в импортируемом h-нике библиотеки. Далее мы можем скастить к нужному объекту и использовать API из нашей библиотеки. Т.е. вся проблема заключается лишь в том, что символ класса нужно загрузить, а потом использовать его как обычно.
Что это нам дает?
На самом деле, в нашем тестовом проекте это дает не очень много — всего лишь 30 миллисекунд.
Но, тем не менее, такой источник оптимизации нужно рассматривать. Возможно, у вас где-то используется большая статическая библиотека, которая на старте не нужна, а символы её на старте всё равно грузятся — тут зависит уже от приложения.
Естественно, через dlopen вы можете загружать не только какие-то зависимости, но и свой код разбить на модули и загружать их лениво. Иными словами, мы стремимся к тому, чтобы бинарный файл приложения был как можно меньше. Это, конечно же, требует сильного рефакторинга. Но то, что я написал, можно сделать достаточно быстро.
Вот сводная картинка, как мы оптимизировали запуск приложения в три раза через три этапа.
- сначала избавились от динамических библиотек подов;
- потом написали Objective-C обёртки для системных фреймворков, вызывающих добавление новых swift standard libraries;
- и добили все тем, что часть новых символов перестаём на старте грузить — грузим их лениво.
Если у вас проект на Objective-C, то первые два этапа вам, скорее всего, будут не нужны, поскольку у вас и так динамических библиотек нет. Но над dlopen-ом вам стоило бы подумать.
Как можно dlopen автоматизировать, а не писать руками? Можно посмотреть в Facebook SDK. Там это сделано через систему макросов.
Использование каждого символа класса вам нужно обернуть в загрузку динамической библиотеки (если она еще не загружена) и загрузку символа (если он ещё не загружен). Да, получаются громоздкие конструкции, но вы можете сделать все это через макросы, чтобы код по загрузке символа схлопывался в одну строчку использования макроса.
Оптимизация after-main
Остается немного поговорить про after-main. Немного — потому что на самом деле это обычная задача по оптимизации iOS приложения, про которую уже много написано. И в основном она зависит от самого приложения. Нельзя придумать какую-то супер-общую схему, которая подойдет для любого приложения.
Но, тем не менее, я расскажу о том, с чем мы столкнулись в Картах и что сработало.
Рекомендую посмотреть на три вещи, которые есть в вашем приложении:
- избавиться от избыточного создания сущностей на старте, т… е. создавать только те зависимости вашего приложения, которые нужны на старте;
- далее, естественно, нужно поработать над оптимизацией UI, чтобы грузилась только та часть UI, которая нужна на старте;
- посмотреть инициализацию, например, каких-то библиотек, баз данных. Если она не влияет на восприятие запуска пользователем, давайте её отложим.
Как это будет выглядеть в случае Карт?
Оптимизация лишних зависимостей
У нас в Картах был класс RootViewController, который, следуя стандартному подходу к инъекции зависимостей, принимал зависимости через свой конструктор.
То есть чтобы создать RouteController, нужно сначала создать Facade поиска и Facade маршрутизации. Пользователь еще не нажал на кнопку поиска и маршрутизации, однако эти фасады уже созданы.
Причем если исследовать дальше инъекцию зависимостей, то и все зависимости от этих двух фасадов тоже будут созданы. И в итоге у нас на старте вообще все зависимости приложения создавались. Это не здорово, потому что, во-первых, затрачивается дополнительное процессорное время, а во-вторых, это очень усложняет профилирование, потому что профилировщик забивается невероятным количеством вызовов, и разобраться в этом уже очень сложно.
Вместо этого мы перешли на инъекцию контейнеров зависимостей, которые реализуют ленивое создание зависимостей. То есть мы теперь:
- зависимости сущностей оформляем в протокол;
- в конструкторе принимаем некоторую реализацию этого протокола;
- а в реализации этого протокола используем lazy var, чтобы зависимости создавались только при обращении.
Как это выглядит:
Создаем протокол RootViewControllerDeps, который декларирует, что его реализация должна отдавать SearchFacade и RoutingFacade. RootViewController теперь принимает некоторую реализацию этого протокола.
В реализации этого протокола мы используем lazy var:
Теперь searchFacade и routingFacade будут созданы только тогда, когда они понадобятся RootViewController, т.е. только тогда, когда пользователь нажмет на Поиск. И всё это не мешает нам сделать классический Composition Root — некоторый класс, который реализует все зависимости приложения.
Он будет реализовывать все протоколы верхнеуровневых сущностей и передавать себя как контейнер зависимостей.
Что это нам дало?
- На старте теперь создаются только нужные зависимости. Это уменьшило время запуска и упростило профилирование, потому что теперь на старте очевидно меньше вызовов, и профилировщику уже можно как-то разобраться.
- В виде побочного эффекта мы получили здоровскую инъекцию зависимостей, которая не использует рефлексию. Теперь, если в каком-то классе понадобилась какая-то новая зависимость, мы эту новую зависимость указываем в протоколе, а resolve будет происходить не в runtime, а при компиляции. Т.е. нам компилятор скажет, что Composition Root эту зависимость не может отдать. Это упрощает refactoring, потому что теперь нам не нужно запускать и ждать, пока что-нибудь где-нибудь упадет из-за того, что какую-нибудь зависимость мы не указали в Composition Root или как-то неправильно использовали конструктор. Поэтому рефакторить стало намного проще.
Оптимизация UI
Теперь нужно постараться сократить дерево view, чтобы система не тратила времени на его рендеринг. Здесь есть несколько аспектов:
- новые вьюхи могут приходить от контейнеров и контроллеров. Вполне может быть, что контейнерный контроллер создается, его вьюхи создаются, но на UI он не влияет. Тогда их не нужно создавать на старте;
- не нужно создавать контейнеры, в которые ничего на старте не кладётся. Они занимают дополнительное место во view-tree и в памяти, на них приходится тратить работу layout, чтобы отрендерить;
- не надо создавать на старте вьюхи, которые не содержат контента. Например, лейблы без текста. Их нужно создавать тогда, когда нужный контент для них появится.
У нас используется NavigationController. У нас карта лежит в NavigationController, так как меню карточки и все прочее приходят через Push.
При этом у NavigationController изначально скрыт NavigationBar — он нам на старте не нужен. И получается, что создание NavigationController не влияет на стартовый UI. Так зачем нам его тогда создавать?
Давайте его создавать лениво. Когда нам надо будет что-то запушить, будет создан NavigationController, а контент, в нашем случае — карта, будет переложен в этот NavigationController из того места, где он лежит сейчас. И тогда мы получаем, что на старте у нас будет создан только контент, а не NavigationController.
Помимо этого у нас ещё есть SplitViewController (для iPad), который на iPhone вообще не нужен. Да и на iPad он нужен только тогда, когда требуется показать боковую панельку. Поэтому смотрите на свои приложения, анализируйте. Возможно, у вас тоже что-то похожее есть.
В результате наших мучений мы пришли к оптимизации дерева View в несколько раз. Теперь у нас создаются только те контейнеры, view-контроллеры, кнопочки, вьюхи и т.п., которые нужны на старте.
В принципе, над этой картинкой можно много думать. Например, обратите внимание на конструкцию, которая торчит слева поверх всего контента, но при этом на экран не попадает. Это старый добрый Navigation Bar, который спрятан. Но он создан. А у нас в Navigation Bar использовалось два кастомных шрифта, соответственно, эти два шифта загружаются на старте, хотя ничего этого не видно. Теперь у нас он не создается.
Пачки вьюх слева — это экранные кнопки. Раньше у нас к каждой кнопке создавался лейбл, Image, background. Теперь создается только то, подо что есть контент. Всё остальное — где-то затесался SplitViewController, где-то просто контейнеры. Все это мы вычистили, оставив только то, что нужно на старте.
В качестве прочих оптимизаций UI можно напомнить про то, что:
- шрифты стоит грузить лениво, так как это занимает время;
- если вы на старте какую-то графику рендерите в Core Graphics, то лучше вам её отрендерить заранее;
- если у вас какой-то шрифт используется ради одного единственного лейбла, то этот лейбл можно также отрендерить в картинку;
- естественно, надо работать над autolayout — оптимизировать его, либо избавляться.
Список можно продолжать долго, все зависит от вашего приложения и опыта.
Необязательная на старте работа
Остается последнее из того, что можно сказать в контексте оптимизации after-main.
Открываем профилировщик. Теперь в профилировщике остались только те вызовы, только та работа, которая на старте нужна. Скорее всего, это создание UI, инициализация, например, библиотек аналитики, открытие баз данных и всё прочее. Задумайтесь, вся ли эта работа вам нужна именно в момент генерации начального интерфейса? Возможно, что-то вы можете отложить на 200-300 миллисекунд, таким образом раньше показав стартовый UI, а саму работу сделав чуть попозже (и при этом пользователю сильно хуже не станет).
В Картах у нас такая работа нашлась:
- синхронизацию закладок мы запускаем позже, потому что она нам нужна во внутренних экранах;
- отображение закладок на карте мы тоже делаем чуть попозже, потому что оно, во-первых, требует синхронизации, и, во-вторых, там Account merge тоже тянет за собой некоторую работу. А если закладки появятся на 200 миллисекунд позже, этого никто и не заметит;
- также у нас позже загружается конфигурация приложения;
- аудиосессии настраиваются только, когда они нужны и т.д.
Такой список в каждом приложении будет свой. Смотрите на свое приложение, анализируйте. Возможно, что-то вы можете отложить.
Сохранение результата
За месяц-полтора (или сколько у вас это займет) вы получили некоторые результаты — оптимизировали запуск. Хотелось бы, чтобы этот результат не испортился в очередном апдейте.
Если у вас есть какие-то элементы continuous integration, то сам Бог велел вам добавить туда замеры запусков и сбор статистики. Т.е. после каждого билда прогонять замеры на каком-то тестовом устройстве, собирать статистику, отправлять эту статистику, обеспечить к ней доступ. На этом я сейчас останавливаться не буду, потому что всё хорошо описано в докладе mail.ru, который был опубликован на Хабре. Я просто дополню этот доклад несколькими советами.
Во-первых, если у вас есть Composition Root, давайте будем собирать лог создания зависимостей.
Сделаем generic-функцию, которая принимает дженерик-блок, а возвращает instance некоторого типа. В этой функции вызываем этот блок и логгируем какие-то действия — в общем, декорируем создание зависимости. Такая функция достаточно органично вписывается в наш подход с composition root, реализующим ленивое создание зависимостей, потому что нам нужно всего лишь добавить вызов этой функции в блоке, который вызывается при создании очередной зависимости (а это дженерик, поэтому даже с типизацией возиться не нужно).
Таким образом, мы можем обернуть в этот trackCreation всё создание зависимостей и задекорировать их создание. В нашем случае нужно хотя бы собрать лог.
Зачем это делать? Затем, что вы при рефакторинге можете что-нибудь задеть, и у вас создастся зависимость, которая на самом деле создаваться не должна.
Бич swift — это динамические библиотеки. Так давайте следить за тем, чтобы у нас не появлялось новых динамических библиотек. Мы можем использовать Objective-C runtime, чтобы получить список загруженных в данный момент библиотек.
Функция objc_copyImageNames возвращает указатель на массив, содержащий пути до всех загруженных фреймворков. Там много всего, но главное — оттуда можно отфильтровать те библиотеки, которые лежат в бандле нашего приложения, поскольку именно они влияют на запуск. Можно следить за тем, чтобы там ничего нового не появлялось. А если появилось, то сразу же пытаться разобраться, почему так произошло. Появиться что-то там может, например, из-за обновления iOS: добавили новую обёртку над какой-нибудь стандартной библиотекой, или же какой-нибудь под обновился, потянул другую зависимость, которая написана на swift, или какой-то класс на Objective-C переписан. В общем, тут вариантов очень много. Нужно следить за тем, чтобы новых динамических библиотек не появлялось.
А вот это уже вообще хардкор:
Есть такая функция — sysctl. Она взаимодействует напрямую с ядром операционной системы и позволяет вытащить из него информацию о текущем процессе. В структуре, которую она возвращает, лежит таймстамп начала этого процесса, и, мы проверили, через эту функцию реально получить полное время загрузки приложения, начиная от нажатия пользователем на кнопочку, т.е. считая pre-main.
Почему я не стал говорить об этом раньше, когда рассказывал про замеры запуска? Потому что на самом деле для замеров запуска при оптимизации вполне достаточно DYLD_PRINT_STATISTICS. А эта штука нужна, чтобы замерять время пользовательских запусков, потому что туда вы DYLD_PRINT_STATISTICS передать не можете. А было бы очень здорово замерять пользовательские запуски и отправлять их в системы аналитики и ещё раз проверять, что после каждого апдейта у вас время полного запуска не изменилось. Тут нужно использовать sysctl, чтобы получить начало процесса, и gettimeofday, чтобы какую-то точку взять, например, start main. И друг из друга их вычесть.
Вместо заключения
Остается рассказать о том, чего мы добились в Картах.
Мы смогли холодный запуск ускорить на 30%.
Мы поработали над всеми частями. Загрузку динамических библиотек оптимизировали за счёт Objective-C обёрток на AVFoundation, MapKit и CoreLocation (она у нас упала в полтора раза, как и для того тестового приложения). У нас не было изначально swift-овых библиотек в подах, поэтому в принципе не так долго приложение грузилось на S5 — всего 2,3 секунды. Бывает и хуже, особенно в приложениях, где много swift подов. Но всё равно мы сделали лучше — динамические библиотеки у нас грузятся быстрее в полтора раза. Obj setup, rebase/bind и инициализацию мы сделали чуть-чуть поменьше за счёт ленивой загрузки SpeechKit. Тут можно продолжать дальше — какие-то собственные куски приложения лениво загружать.
Зеленый и сиреневый блоки — это after-main, просто у нас он разделен на два отрезка: с начала main до didFinishLaunching и сам didFinishLaunching. С начала main и до didFinishLaunching у нас в несколько раз улучшилось время за счёт того, что на старте лишние зависимости не создаются, а сам didFinishLaunching у нас на 40-50% улучшился как раз за счёт оптимизации view-tree и откладывания необязательной на старте работы на 200-300 миллисекунд.
Спасибо Apple за то, что такая же картина и на других устройствах!
Здесь нет такого, что мы оптимизировали на одном устройстве, а на другом это все не сработало. Все устройства примерно одинаково работают, и соотношение между разными этапами запуска на них примерно одинаково. И пока мы на iPhone 5S на 30% оптимизировали запуск, на iPhone 7, 6S и 5 мы получили аналогичное ускорение.
Если говорить про теплый запуск, тут дело еще лучше.
Потому что в теплым запуске у нас, как мы помним, pre-main практически ни на что не влияет. А вот after-main — это, собственно, то, из чего тёплый запуск и состоит. И в нашем случае, так как мы didFinishLaunching оптимизировали не на 30%, а на 40%, получилось, что время запуска улучшилось на 37%. То же самое на остальных устройствах.
Картинка для 5S повторяется и для других устройств. Причем для новых устройств типа iPhone 6S и 7 теплый запуск ускорился в 2 раза.
И несмотря на то, что все эти значения не такие большие, когда реально пользуешься приложением, это заметно. Наши пользователи на это отреагировали. Есть хорошие отзывы в AppStore.
В заключение пробежимся по тому, о чем мы сегодня говорили. Оптимизация времени запуска — это итерационный процесс.
На каждой итерации вы что-то пытаетесь сделать, какие-то свои положения выдвигаете, реализовываете. После итерации проводите цикл замеров на разных или даже на одном устройстве (то, что работает на одном iPhone, будет работать и на другом iPhone). Дальше опять пытаетесь что-то улучшить.
Сама оптимизация состоит из работы на двух отрезках: pre-main и after-main. И они оптимизируются совершенно независимо друг от друга, потому что при pre-main-е у нас только система работает — DYLD (динамический загрузчик), а after-main — это наш код.
При оптимизации pre-main мы стараемся, во-первых, уменьшить количество динамических библиотек через использование плагина для cocoapods и через написание обёрток для лишних swift standard libraries. А во-вторых, пытаемся уменьшится размер бинарного файла.
При оптимизации after-main мы руководствуемся профилировщиком и т.п., пытаясь на старте делать как можно меньше работы. Стараемся не создавать лишних сущностей (создаем только тот объем UI, который нужен для показа стартового интерфейса), а необязательную для показа работу пытаемся отложить.
Вот и все секреты.
Если вы любите мобильную разработку также как мы, рекомендуем обратить внимание на следующие доклады на грядущей конференции Mobius 2017 Moscow: