ТЕХНОЛОГИЯ ЭКСПЕДИА ГРУПП - ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ
Влияние связывания зависимостей на производительность и разработку приложений
Могут ли статически связанные приложения iOS быть быстрее и меньше?
Четыре года назад над приложением iOS Vrbo ™ (входит в Expedia Group ™) работало всего около пяти инженеров. Сейчас у нас более 30 активных участников, поддерживающих гораздо большую кодовую базу. Как вы понимаете, потребовались серьезные изменения как с организационной, так и с архитектурной точки зрения, чтобы мы могли масштабироваться. Ключевым архитектурным изменением, которое обеспечило эту масштабируемость, было решение разбить кодовую базу на модули и установить шаблоны для поддержки этой новой структуры.
Для тех, кто не знаком с этой концепцией, модульность включает в себя декомпозицию программной системы на множество дискретных модулей, которые могут работать независимо. Это метод разработки программного обеспечения, идеально подходящий для команд, стремящихся к масштабированию. Эта новая структура пока хорошо работает для нас, обеспечивая более быстрые сборки при работе с каждым модулем и упрощая обмен кодом между командами.
Однако, как и у большинства решений, есть компромиссы. Поддержка этой архитектуры требует от нас создания и связывания множества динамических фреймворков и поддержки дополнительного кода, чтобы объединить модули вместе и позволить каждому функционировать независимо. Время запуска, размер приложения и время сборки увеличились в результате этого подхода наряду с новыми функциями, которые продолжают добавляться. Стремясь улучшить эти показатели и уменьшить трение разработчиков, мы начали рассматривать эту проблему с разных сторон, начиная с управления зависимостями.
Цель
Посмотрите на эффекты статического и динамического связывания зависимостей между ключевыми показателями производительности и разработки приложений. Наша гипотеза была такой:
Если большинство зависимостей связаны статически, мы создадим приложение большего размера с более быстрым запуском. Если большинство зависимостей связаны динамически, мы создадим приложение меньшего размера с более медленным запуском.
Мы не были уверены, как это повлияет на время сборки.
Результаты
Я использовал несколько подходов в ответвлениях репозитория, похожего на этот, который действует как рабочее пространство шаблона, которое точно отражает структуру фактического монорепозитория Vrbo iOS: модульное, мультибрендовое приложение. К сожалению, я не могу включить реальный исходный код, на котором был основан мой спайк, но я постараюсь объяснить, как была настроена каждая ветка, ниже. На высоком уровне есть один xcworkspace
, содержащий несколько проектов:
- Приложение - проект, содержащий две цели для создания исполняемого файла Lightside и Darkside (да, я фанат Звездных войн) и одну для тестов пользовательского интерфейса. Обе цели, создающие исполняемый файл, зависят от целей, перечисленных ниже (Module1, Module2 и Core), а также от трех внешних зависимостей: Alamofire, ApolloGraphQL ™ ️ и Facebook.
- Module1 - проект, содержащий единственную цель: Module1. Он содержит код для определения подмножества UX в приложении (единый контроллер представления и ресурс изображения в случае этого проекта всплеска). Эта цель зависит от Core и трех внешних зависимостей, перечисленных выше.
- Module2 - проект, содержащий единственную цель: Module2. Подобно Module1, эта цель содержит код для определения подмножества UX в приложении и зависит от Core и трех внешних зависимостей, перечисленных выше.
- Ядро - проект, содержащий единственную цель: Ядро. Он содержит низкоуровневый код, который используется всеми перечисленными выше целями. Никаких зависимостей.
Как собирались данные
Во-первых, позвольте мне прояснить несколько моментов относительно того, как я собирал эти данные, чтобы вы могли лучше сформировать собственное мнение:
- Все значения времени, перечисленные выше, являются средними за пять прогонов. Я знаю, что это небольшой размер выборки, но, учитывая, сколько времени потребовалось для разработки всех этих подходов и сбора показателей, мне пришлось где-то провести черту.
- Время запуска приложения собиралось с помощью этого UI-теста.
- Для чистых сборок я использовал действие Xcode Product → Clean Build Folder перед сборкой.
- Для перестроек (также называемых инкрементными сборками) я внес небольшие изменения в контроллер представления Module1 перед сборкой. Такая же модификация вносилась перед каждой перестройкой.
- Ветка SpikeMixedLinking настроена для построения внутренних зависимостей (Module1, Module2 и Core) как фреймворков с динамической библиотекой Mach-O Type. Эти зависимости динамически связаны с целями, которые зависят от них, и встроены в цели, создающие исполняемый файл (Lightside / Darkside). Это обычная установка динамического связывания. Вот сложная часть: внешние зависимости (Alamofire, Apollo и Facebook) втягиваются как пакеты Swift, создающие статические библиотеки. Они статически связаны в зонтичную структуру под названием CoreDependencies, которая определяется как цель в проекте Core. CoreDependencies динамически связан со всеми внутренними целями, которые зависят от этих внешних зависимостей, и встроены в целевые объекты, создающие исполняемый файл. Это необходимый обходной путь, чтобы избежать ошибок повторяющихся символов, которые мы бы увидели во время компиляции, если бы попытались статически связать эти внешние зависимости со всеми целевыми объектами, которые от них зависят. Этот подход наиболее точно отражает то, как настраивается фактическое приложение iOS Vrbo.
- Ветвь SpikeStaticLinkingWithFrameworks настроена для построения внутренних зависимостей в виде фреймворков со статической библиотекой типа Mach-O. Эти зависимости статически связаны с целевыми объектами, которые зависят от них, но не встроены в какую-либо цель, поскольку их статическое связывание означает, что они уже будут скопированы в исполняемый файл приложения. Поскольку статические библиотеки не могут легко обмениваться ресурсами, они содержатся в отдельном пакете ресурсов, который копируется в цели Lightside / Darkside. Внешние зависимости втягиваются как пакеты Swift, производящие статические библиотеки, и подключаются к каждой цели, которая от них зависит.
- Ветвь SpikeStaticLinkingWithLibraries настраивается точно так же, как ветка SpikeStaticLinkingWithFrameworks, но внутренние зависимости создают статические библиотеки напрямую, а не фреймворки, содержащие статические библиотеки.
- Ветка SpikeStaticLinkingWithSwiftPackages настроена для построения внутренних зависимостей в виде локальных пакетов Swift, создающих статические библиотеки. Эти зависимости статически связаны с целевыми объектами, которые от них зависят. Внешние зависимости втягиваются в виде удаленных пакетов Swift, создающих статические библиотеки и связанных с целевыми объектами, которые от них зависят.
- Ветвь SpikeDynamicLinkingWithXCFrameworks настроена для построения внутренних зависимостей как фреймворков с динамической библиотекой типа Mach-O. Эти зависимости динамически связаны с целевыми объектами, которые зависят от них, и встроены в целевые объекты, создающие исполняемый файл. Внешние зависимости управляются и создаются Carthage как XCFrameworks и динамически связаны с целевыми объектами, которые зависят от них, а также встраиваются в целевые объекты, производящие исполняемый файл. Исключением является Facebook, который плохо поддерживает эту настройку, поэтому он все еще используется как пакет Swift с использованием упомянутого выше зонтичного фреймворка.
Что означают эти результаты?
Размер приложения
Сразу же у нас есть потрясающее зрелище. Статическое связывание всех зависимостей (внутренних и внешних) с использованием пакетов Swift дает минимально возможный размер приложения, а динамическое связывание их с помощью XCFrameworks дает самый большой. Это прямо противоречит нашей гипотезе! Если мы посмотрим на размер исполняемого файла, мы обнаружим, что верно обратное, но это имеет смысл, учитывая, что двоичные файлы наших зависимостей копируются непосредственно в исполняемый файл при статической ссылке. Таким образом, динамическое связывание наших зависимостей дало нам исполняемый файл меньшего размера, но не меньшее приложение (это то, что нас действительно волнует). Почему это могло быть так? Что ж, только статический компоновщик может удалить мертвый код, что уменьшит размер приложения. Таким образом, хотя статический компоновщик позволяет нам встраивать урезанную версию двоичных файлов Module1, Module2, Core, Alamofire, Apollo и Facebook в исполняемый файл приложения, динамический компоновщик должен включать весь двоичный файл для каждой из этих зависимостей! Это функция, которую многие разработчики, в том числе и я, часто упускают из виду, рассматривая компромисс между статическим и динамическим связыванием, но если включено удаление кода, статическое связывание ваших зависимостей может фактически создать меньшее приложение. У Apple есть документация по удалению мертвого кода здесь (она старая, но все еще точная, AFAIK).
🥇 Победитель - SpikeStaticLinkingWithSwiftPackages
Время запуска приложения
Когда мы смотрим на время запуска приложений на симуляторах и физических устройствах, ситуация продолжает оставаться интересной. Мы ожидаем увидеть более длительное время запуска при динамическом связывании зависимостей и более быстрое время запуска при статическом связывании. Эта гипотеза верна на физических устройствах, но не на симуляторах. Только предположение, но, возможно, Apple не рандомизирует карту памяти на симуляторах, поскольку нет причин беспокоиться об уязвимостях системы безопасности. Или, возможно, динамический компоновщик просто быстрее работает на архитектуре x86 (у меня еще нет Apple Silicon Mac)? Я не совсем понимаю, почему это так, но в любом случае нас действительно волнует только время запуска приложения на физическом устройстве, поэтому победитель очевиден.
🥇 Победитель - SpikeStaticLinkingWithSwiftPackages
Время сборки
Этот показатель был нашим большим неизвестным. SpikeDynamicLinkingWithXCFrameworks - явный победитель для чистых сборок, а также хорошие результаты при восстановлении на симуляторах. Однако SpikeStaticLinkingWithSwiftPackages удается опубликовать лучшее время восстановления как для симуляторов, так и для физических устройств. Чтобы понять, как мы относимся к этому компромиссу, мы должны рассмотреть, как обычно ведут себя разработчики. Когда инженеры разрабатывают, они обычно проводят большую часть своего времени, строя против симуляторов, часто строя только против физических устройств ближе к концу своей работы, чтобы убедиться, что изменения там хорошо выглядят. Это не всегда так, но, говоря со многими коллегами, это мнение в целом справедливо. Это означает, что время сборки физических устройств не должно иметь для нас такого значения, как время сборки симулятора. Чистое время сборки также не так важно. После того, как вы скомпилировали все один раз, большая часть разработки будет потрачена на создание ситуаций, когда необходимо перекомпилировать только подмножество кода. Это означает, что из всех четырех столбцов в таблице, посвященных времени сборки, Время восстановления (симулятор) - это тот, который нас больше всего волнует, и у нас есть связь по этому показателю.
🥇 Победитель - SpikeDynamicLinkingWithXCFrameworks
🥈 SpikeStaticLinkingWithSwiftPackages - второе место
Строить зависимости
Вам может быть интересно, почему так много подходов выделено зеленым цветом в столбце Зависимости сборки. Мне стало ясно, что производительность при разрешении пакетов Swift может сильно различаться. Все подходы, выделенные зеленым цветом, используют те же три внешние зависимости, что и пакеты Swift, и поэтому для разрешения этих зависимостей должно потребоваться такое же количество времени. Фактическое сравнение здесь проводится между SPM и Карфагеном. В среднем потребовалось 960 секунд для создания двух из трех внешних зависимостей с использованием Carthage, в то время как для создания их всех в виде пакетов Swift потребовалось всего от 122 до 184 секунд. Это большая победа для SPM. Я знаю, что большая часть сообщества iOS все еще использует CocoaPods, но Vrbo отошел от этого диспетчера зависимостей, поэтому я не включил его в этот всплеск. Наша цель - в конечном итоге перенести все на SPM, поскольку это поддерживают зависимости, но мы не торопимся с этим процессом.
🥇 Победитель - Swift Package Manager
Вывод
Анализ данных, статическое связывание всех зависимостей с использованием пакетов Swift - наиболее эффективный подход по показателям, измеренным в этом пике. Хотя этот подход и обеспечил более длительное время чистой сборки, он превзошел свои аналоги по всем другим показателям. Это совершенно правильное утверждение, и оно согласуется с тем, что Apple недавно продвигала: пакеты Swift создают статические библиотеки. Имейте в виду, что этот всплеск был сосредоточен исключительно на показателях, описанных выше, и есть много других факторов, которые следует учитывать при определении того, как распространять и использовать код. Например, поддержка полностью статически связанного графа зависимостей может быть трудной, особенно когда некоторые из зависимостей вашего приложения живут в собственном репо и разделяют зависимости с вашим приложением или другими целями в вашем xcworkspace
(подход, не рассмотренный в этом пике). Или, возможно, вы не хотите продавать свой исходный код потребителям и вместо этого предпочитаете распространять свою библиотеку как двоичную структуру. Подобные факторы могут ограничивать способ управления и связывания зависимостей независимо от влияния на производительность. По крайней мере, эти результаты заставляют меня чувствовать себя более уверенно в нашем подходе в Vrbo к продолжению перехода на SPM, а также подчеркивают преимущества использования статических ссылок перед динамическими.