Pull to refresh

Как мы сократили время запуска нашего iOS-приложения на 60%

Reading time6 min
Views1.6K
Original author: Filip Busic

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

В DoorDash мы очень серьезно относимся к скорости запуска приложений. Мы одержимы оптимизацией опыта наших клиентов и постоянными улучшениями.

В этой статье мы рассмотрим три отдельные оптимизации, которые сократили время, необходимое для запуска нашего потребительского приложения для iOS, на 60%. Мы определили эти возможности, используя проприетарные инструменты повышения производительности, но инструменты Xcode или DTrace также могут быть подходящими альтернативами.

Изменение String(describing:) на ObjectIdentifier()

В начале 2022 года наш путь оптимизации запуска приложений начался с визуализации основных узких мест с помощью инструмента анализа производительности Emerge Tools, как показано на рис. 1.

Рис. 1. Трассировка стека, показывающая три возможности оптимизации производительности
Рис. 1. Трассировка стека, показывающая три возможности оптимизации производительности

Этот инструмент производительности помог продемонстрировать неоптимизированные ветки как с высоты птичьего полёта, так и с подробностями. Одним из самых больших отличий было время, которое мы потратили на Swift protocol conformance checks (проверка соответствия типа протоколу). Но почему?

Архитектурные принципы, такие как принцип единой ответственности, разделение задач и другие, являются ключевыми в том, как мы пишем код в DoorDash. Службы и зависимости часто внедряются и описываются по их типу. Проблема в том, что мы использовали String(describing:) для идентификации служб, что привело к снижению производительности во время выполнения из‑за проверки того, соответствует ли тип различным другим протоколам. Трассировка стека на рис. 2 взята непосредственно из запуска нашего приложения, чтобы продемонстрировать это.

Рис. 2. Трассировка стека того, что происходит за кулисами API String(describing:)
Рис. 2. Трассировка стека того, что происходит за кулисами API String(describing:)

Первый вопрос, который мы задали себе, был: «Действительно ли нам нужна строка для идентификации типа?» Устранение требования к строке и переход на идентификацию типов с использованием вместо этого ObjectIdentifier, который является простым указателем на тип, позволил сократить время запуска приложения на 11%. Мы также применили эту технику к другим областям, где вместо необработанной строки было достаточно указателя, что дало дополнительное улучшение на 11%.

Если возможно использовать необработанный указатель на тип вместо использования String(describing:), мы рекомендуем внести такое же изменение, чтобы сэкономить на задержке.

Прекратите преобразовывать ненужные объекты в AnyHashable

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

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

Мы могли бы оптимизировать нашу стратегию хеширования несколькими способами, но мы начали с переосмысления наших первоначальных ограничений и границ. Первоначально хеш‑значение команды представляло собой комбинацию связанных с ней членов. Это решение было принято намеренно, поскольку мы хотели сохранить гибкую и мощную абстракцию команд. Но после повсеместного внедрения новой архитектуры мы заметили, что выбор дизайна был преждевременным и в целом остался неиспользованным. Изменение этого требования для идентификации команд по их типу привело к ускорению запуска приложений на 29%, ускорению выполнения команд на 55% и ускорению регистрации команд на 20%.

Аудит инициализаторов сторонних фреймворков

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

Недавний аудит показал, что из‑за определённой сторонней платформы наше iOS‑приложение запускается примерно на 200 мс медленнее. Одна только эта структура занимала примерно 40% (!) времени запуска нашего приложения, как показано на рис. 3.

Рис. 3. “Пламенная” диаграмма, показывающая примерно 200 мс времени запуска нашего приложения, возникла из-за того, что сторонний фреймворк итерировал наш NSBundle.
Рис. 3. “Пламенная” диаграмма, показывающая примерно 200 мс времени запуска нашего приложения, возникла из-за того, что сторонний фреймворк итерировал наш NSBundle.

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

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

Когда приложение запускается и загружается в память, за его подготовку отвечает динамический компоновщик (dyld). Одним из шагов dyld является сканирование динамически связанных фреймворков и вызов любых функций инициализации модулей, которые могут быть у него. dyld делает это, определяя типы разделов, помеченные 0×9 (S_MOD_INIT_FUNC_POINTERS), обычно расположенные в сегменте «__DATA».

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

Рассматриваемая сторонняя структура имела в общей сложности девять инициализаторов модулей, которым всем из‑за dyld было разрешено работать до того, как наше приложение запустило main(). Эти девять инициализаторов относятся к общей стоимости, которая задержала запуск нашего приложения. Итак, как мы это исправим?

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

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

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

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

Заключение

Точное определение узких мест и возможностей производительности часто является самой сложной частью любой оптимизации. Общеизвестно, что распространённой ошибкой является измерение A, оптимизация B и заключение C.

Именно здесь хорошие инструменты повышения производительности помогают выявить узкие места. Инструменты Xcode, часть Xcode, поставляются с несколькими шаблонами, помогающими выявить различные потенциальные проблемы в приложении macOS/iOS. Но для дополнительной детализации и простоты использования Emerge Tools предоставляет упрощённое представление о производительности приложений с помощью своих инструментов производительности.

Tags:
Hubs:
Total votes 2: ↑1 and ↓10
Comments1

Articles