Разработка
Эволюция архитектуры приложения Facebook для iOS
Кодовая база приложения отражает 10-летнюю эволюцию, вызванную техническими решениями, необходимыми для поддержки растущего числа инженеров, работающих над приложением, его стабильностью и, прежде всего, всем пользовательским опытом.
Meta, в том числе ее продукты Facebook и Instagram, признана экстремистской организацией в России.
Facebook для iOS (FBiOS) — старейшая мобильная кодовая база в Meta. С тех пор как приложение было переписано в 2012 году, над ним работали тысячи инженеров, оно было отправлено миллиардам пользователей, и оно может поддерживать итерации сотен инженеров одновременно.
После многих лет изменений кодовая база приложения Facebook не похожа на типичную кодовую базу iOS:
- Она полна C++, Objective-C(++) и Swift.
- Она имеет десятки динамически загружаемых библиотек (dylib) и так много классов, что их невозможно загрузить в Xcode сразу.
- Apple SDK практически не используется в чистом виде — все было обернуто или заменено собственной абстракцией.
- В приложении активно используется генерация кода с помощью Buck, нашей кастомной системы сборки.
- Без интенсивного кэширования нашей системой сборки инженерам пришлось бы провести целый рабочий день в ожидании сборки приложения.
FBiOS никогда не проектировалась таким образом намеренно. Кодовая база приложения отражает 10-летнюю эволюцию, вызванную техническими решениями, необходимыми для поддержки растущего числа инженеров, работающих над приложением, его стабильностью и, прежде всего, всем пользовательским опытом.
Теперь, чтобы отпраздновать 10-летний юбилей кодовой базы, мы проливаем свет на технические решения, лежащие в основе этой эволюции, а также на их исторический контекст.
2014: Создание собственного мобильного фреймворка
Через два года после того, как Meta запустила нативную версию приложения Facebook, кодовая база новостной ленты (News Feed) начала испытывать проблемы с надежностью. В то время модели данных новостной ленты опирались на дефолтный фреймворк Apple для управления моделями данных — Core Data. Объекты в Core Data изменяемы (mutable), и это не подходило для многопоточной архитектуры новостной ленты. Что еще хуже, News Feed использовал двунаправленный поток данных, основанный на использовании шаблона проектирования Apple для Cocoa-приложений — Model View Controller.
В конечном итоге такой дизайн усугублялся созданием недетерминированного кода, который было очень трудно отлаживать или воспроизводить ошибки. Было ясно, что эта архитектура нежизнеспособна, и пришло время ее переосмыслить.
При рассмотрении новых дизайнов один инженер исследовал React, UI-фреймворк Facebook с открытым исходным кодом, который стал довольно популярным в JavaScript-сообществе. Декларативный дизайн React абстрагировался от сложного императивного кода, который вызывал проблемы в фиде (в вебе), и использовал односторонний поток данных, что значительно упростило анализ кода. Эти характеристики казались хорошо подходящими для проблем, с которыми столкнулся News Feed. Была только одна проблема.
В SDK Apple не было декларативного пользовательского интерфейса.
Swift еще не будет анонсирован в течение нескольких месяцев, а SwiftUI (декларативный UI-фреймворк Apple) не будет анонсирован до 2019 года. Если News Feed захочет иметь декларативный UI, команде придется создать новый UI-фреймворк.
В конце концов, это то, что они и сделали.
После нескольких месяцев, потраченных на создание и перенос новостной ленты на новый декларативный UI и новую модель данных, производительность FBiOS увеличилась на 50%. Несколько месяцев спустя разработчики открыли исходный код своего вдохновленного React UI-фреймворка для мобильных устройств — ComponentKit.
По сей день ComponentKit по-прежнему является фактическим выбором для создания нативных пользовательских интерфейсов в Facebook. Он обеспечил бесчисленные улучшения производительности за счет пулов повторного использования представлений, упрощения представлений и просчета макетов в фоне. Он также вдохновил создание Android-аналога, Litho, и самого SwiftUI.
В конечном счете, замена UI и уровня данных кастомной инфраструктурой была компромиссом. Чтобы добиться приятного пользовательского опыта, который можно было бы надежно поддерживать, новым сотрудникам пришлось бы отложить свои отраслевые знания API Apple, чтобы изучить кастомную внутреннюю инфраструктуру.
Это не последний раз, когда FBiOS придется принимать решение, которое сбалансирует опыт конечного пользователя с опытом и скоростью разработки. В 2015 году успех приложения вызвал то, что мы называем взрывом функций. И это определило свой собственный набор уникальных проблем.
2015: переломный момент в архитектуре
К 2015 году Meta поставила все на свою мантру Mobile First, а кодовая база FBiOS продемонстрировала стремительный рост числа ежедневных участников. По мере того, как все больше и больше продуктов интегрировалось в приложение, время его запуска стало ухудшаться, и люди начали это замечать. К концу 2015 года скорость запуска приложения была настолько низкой (почти 30 секунд!), что оно могло быть закрыто операционной системой телефона.
После расследования стало ясно, что существует множество факторов, способствующих снижению производительности при запуске. Для краткости мы сосредоточимся только на тех, которые оказали долгосрочное влияние на архитектуру приложения:
- Время до запуска функции Main (“Pre-main”) приложения росло с неограниченной скоростью, поскольку размер приложения увеличивался с каждым продуктом.
- «Модульная» система приложения давала каждому продукту нерегулируемый доступ ко всем ресурсам. Это привело к трагедии общих ресурсов, поскольку каждый продукт использовал свой «хук» на старте, чтобы выполнять ресурсоемкие операции и чтобы первоначальный переход к этому продукту был быстрым.
Изменения, которые были необходимы для смягчения и улучшения запуска, коренным образом изменили способ написания кода для FBiOS продуктовыми-инженерами.
2016: Dylibs и модульность
Согласно документации Apple об улучшении времени запуска, необходимо выполнить ряд операций, прежде чем можно будет вызвать функцию Main приложения. Как правило, чем больше кода в приложении, тем больше времени это занимает.
В то время как «pre-main» операции вносил лишь небольшую часть в те 30 секунд, что тратились на запуск, это вызывало особую озабоченность, поскольку время продолжало расти с неограниченной скоростью по мере того, как FBiOS продолжала накапливать новые функции.
Чтобы смягчить неограниченный рост времени запуска приложения, наши инженеры начали перемещать большие участки кода продукта в лениво загружаемый контейнер, известный как динамическая библиотека (dylib). Когда код перемещается в динамически загружаемую библиотеку, его не требуется загружать перед функцией main() приложения.
Изначально структура dylib FBiOS выглядела так:
Были созданы две продуктовые dylib (FBCamera и NotOnStartup), а третья dylib (FBShared) использовалась для совместного использования кода между различными dylib и двоичным файлом основного приложения.
Решение dylib работало прекрасно. FBiOS смогла сдержать неограниченный рост времени запуска приложения. Шли годы, большая часть кода оказывалась в dylib, так что производительность при запуске оставалась высокой и не зависела от постоянных колебаний добавленных или удаленных продуктов в приложении.
Добавление dylib вызвало ментальный сдвиг в том, как инженеры по продуктам Meta писали код. С добавлением dylib, рантайм API-интерфейсы, такие как NSClassFromString(), подвергались риску сбоев во время выполнения, поскольку требуемый класс находился в еще незагруженных dylib. Поскольку многие базовые абстракции FBiOS были построены на итерации всех классов в памяти, FBiOS пришлось переосмыслить, как работают многие из ее основных систем.
Помимо сбоев во время выполнения, dylib также представили новый класс ошибок компоновщика. Если код в Facebook (запускаемый на старте) ссылается на код в dylib, инженеры увидят такую ошибку компоновщика:
Undefined symbols for architecture arm64: "_OBJC_CLASS_$_SomeClass", referenced from: objc-class-ref in libFBSomeLibrary-9032370.a(FBSomeFile.mm.o)
Чтобы исправить это, инженеры должны были обернуть свой код специальной функцией, которая могла бы при необходимости загрузить dylib.
Внезапно:
int main() { DoSomething(context); }
Превращалось в:
int main() { FBCallFunctionInDylib( NotOnStatupFramework, DoSomething, context ); }
Решение сработало, но довольно плохо пахло:
- Перечисление (enum ) dylib, специфичное для конкретного приложения, было жестко закодировано в различные вызовы. Все приложения в Meta должны были совместно использовать перечисление dylib, и разработчик должен был определить, использовалась ли эта dylib приложением, в котором выполнялся код.
- Если использовалось неправильное перечисление dylib, код не работал, но только во время выполнения. Учитывая огромное количество кода и функций в приложении, этот запоздалый сигнал приводил к большому разочарованию во время разработки.
Вдобавок ко всему, наша единственная система защиты от введения этих вызовов во время запуска была основана на среде выполнения, и многие выпуски были отложены, поскольку в приложение были внесены регрессии в последнюю минуту.
В конечном счете, оптимизация dylib сдержала неограниченный рост времени запуска приложения, но это означало серьезный переломный момент в том, как приложение было спроектировано. Инженеры FBiOS потратят следующие несколько лет на реархитектуру приложения, чтобы сгладить некоторые шероховатости, появившиеся из-за dylib, и мы (в конце концов) выпустили архитектуру приложения, которая была более надежной, чем когда-либо прежде.
2017: Переосмысление архитектуры FBiOS
С введением dylib пришлось переосмыслить несколько ключевых компонентов FBiOS:
- «Система регистрации модулей» (module registration system) больше не могла быть основана на рантайме.
- Инженерам нужен был способ понимать, может ли какой-либо код во время запуска вызвать загрузку dylib.
Чтобы решить эти проблемы, FBiOS обратилась к системе сборки Meta с открытым исходным кодом Buck.
Внутри Buck каждая «цель» (приложение, dylib, библиотека и т.д.) объявляется с некоторой конфигурацией, например так:
apple_binary( name = "Facebook", ... deps = [ ":NotOnStartup#shared", ":FBCamera#shared", ], ) apple_library( name = "NotOnStartup", srcs = [ "SomeFile.mm", ], labels = ["special_label"], deps = [ ":PokesModule", ... ], )
Каждая «цель» содержит всю информацию, необходимую для ее сборки (зависимости, флаги компилятора, исходники и т. д.), и при вызове «buck build» вся эта информация выстраивается в граф, который можно запрашивать.
$ buck query “deps(:Facebook)” > :NotOnStartup > :FBCamera $ buck query “attrfilter(labels, special_label, deps(:Facebook))” > :NotOnStartup
Используя эту базовую концепцию (и некоторый особый ингредиент), FBiOS начало создавать некоторые запросы к buck, которые могли генерировать целостное представление о классах и функциях в приложении во время сборки. Эта информация станет строительным блоком архитектуры следующего поколения приложения.
2018: Быстрое увеличение сгенерированного кода
Теперь, когда FBiOS смогла использовать Buck для запроса информации о коде в зависимости, она могла создать сопоставление «функции/классы -> dylibs», которое можно было генерировать на лету.
{ "functions": { "DoSomething": Dylib.NotOnStartup, ... }, "classes": { "FBSomeClass": Dylib.SomeOtherOne } }
Используя это сопоставление в качестве входных данных, FBiOS использовала его для генерации кода, абстрагирующего enum dylib от вызовов:
static std::unordered_map<const char *, Dylib> functionToDylib {{ { "DoSomething", Dylib.NotOnStartup }, { "FBSomeClass", Dylib.SomeOtherOne }, ... }};
Использование генерации кода было привлекательным по нескольким причинам:
- Поскольку код был перегенерирован на основе локального ввода, нечего было проверять, и конфликтов слияния больше не было! Учитывая, что инженерный состав FBiOS мог удваиваться каждый год, это был большой выигрыш в эффективности разработки.
- FBCallFunctionInDylib больше не требовал dylib для конкретного приложения (поэтому его можно переименовать в «FBCallFunction»). Вместо этого вызов будет считываться из статического сопоставления, созданного для каждого приложения во время сборки.
Сочетание запроса Buck с генерацией кода оказалось настолько успешным, что FBiOS использовала его в качестве основы для новой системы плагинов, которая в конечном итоге заменила runtime-based систему модулей приложений.
Перемещение сигнала влево
С новой системой плагинов на базе Buck, FBiOS смогла заменить большинство сбоев во время выполнения предупреждениями во время сборки, перенеся части инфраструктуры в архитектуру на основе плагинов.
Когда FBiOS собрана, Buck может создать график, показывающий расположение всех плагинов в приложении, например:
С этой точки зрения система плагинов может выявлять ошибки во время сборки, чтобы инженеры могли их предупредить:
- «Плагин D, E может вызвать загрузку dylib. Это не разрешено, так как вызовы этих плагинов находится на пути запуска приложения».
- «В приложении нет плагина для рендеринга профилей… это означает, что переход на этот экран не будет работать».
- «Есть два плагина для рендеринга групп (плагин A, плагин B). Один из них должен быть удален».
В старой системе модулей приложений эти ошибки были бы «ленивыми» утверждениями во время выполнения. Теперь инженеры уверены, что при успешной сборке FBiOS она не выйдет из строя из-за отсутствующей функциональности, загрузки dylib во время запуска приложения или инвариантов в системе выполнения модулей.
Стоимость генерации кода
Хотя миграция FBiOS на систему плагинов повысила надежность приложения, обеспечила более быструю работу для инженеров и позволила приложению легко обмениваться кодом с другими мобильными приложениями, за это пришлось заплатить:
- Ошибки плагина не находятся в Stack Overflow и могут запутать при отладке.
- Система плагинов, основанная на генерации кода и Buck, сильно отличается от традиционной разработки для iOS.
- Плагины вводят уровень косвенности в кодовую базу. Там, где большинство приложений имеют файл реестра со всеми функциями, они генерируются в FBiOS, и их может быть на удивление трудно найти.
Нет никаких сомнений в том, что плагины увели FBiOS дальше от идиоматической iOS-разработки, но компромиссы, похоже, того стоят. Наши инженеры могут изменить код, используемый во многих приложениях в Meta, и быть уверенными, что, если система плагинов работает, ни одно приложение не должно падать из-за отсутствия функциональности в редко тестируемом пути. Такие команды, как News Feed и Groups, могут создать точку расширения для плагинов и быть уверенными, что команды разработчиков продуктов смогут интегрироваться с ними, не касаясь основного кода.
2020: Swift и языковая архитектура
Хотя большая часть этой статьи посвящена архитектурным изменениям, связанным с проблемами масштабирования приложения Facebook, изменения в Apple SDK также вынудили FBiOS переосмыслить некоторые из своих архитектурных решений.
В 2020 году FBiOS начал замечать рост числа API-интерфейсов Apple, предназначенных только для Swift, и растущую потребность в большем количестве Swift в кодовой базе. Наконец-то пришло время смириться с тем фактом, что Swift стал неизбежным жителем в приложении FB.
Исторически сложилось так, что FBiOS использовала C++ в качестве рычага для создания абстракций, что позволяло экономить на размере кода из-за принципа нулевых накладных расходов C++. Но С++ не интероперабелен со Swift (пока). Для большинства API-интерфейсов FBiOS (таких как ComponentKit) для использования в Swift необходимо было бы создать какую-то прокладку, что привело бы к раздуванию кода.
Вот диаграмма, показывающая проблемы в кодовой базе:
Имея это в виду, мы начали формировать языковую стратегию, учитывающую то, когда и где следует использовать различные фрагменты кода:
В конце концов, команда FBiOS начала советовать, чтобы продуктовые API/код не содержали C++, чтобы мы могли свободно использовать Swift и будущие Swift API от Apple. Используя плагины, FBiOS могла абстрагироваться от реализаций C++, чтобы они по-прежнему подпитывали приложение, но были скрыты от большинства инженеров.
Этот тип рабочего процесса означал небольшой сдвиг в том, как инженеры FBiOS думали о построении абстракций. С 2014 года одними из самых важных факторов в построении фреймворка были вклады в размер и выразительность приложения (именно поэтому ComponentKit выбрал Objective-C++ вместо Objective-C).
Добавление Swift было первым случаем, когда они отошли на второй план из-за эффективности для разработчиков, и мы ожидаем, что в будущем их станет больше.
2022: Путешествие завершено на 1%
С 2014 года архитектура FBiOS немного изменилась:
- В приложении появилось бесчисленное количество внутренних абстракций, таких как ComponentKit и GraphQL.
- Оно использует dylibs, чтобы свести “pre-main” время к минимуму для невероятно быстрого запуска.
- Оно представило систему плагинов (на основе Buck), чтобы dylibs были абстрагированы от инженеров, и поэтому код легко распространялся между приложениями.
- Оно представило языковые рекомендации о том, когда и где следует использовать различные языки, и начало изменять кодовую базу, чтобы отразить эти языковые рекомендации.
Тем временем Apple представила интересные улучшения для своих телефонов, ОС и SDK:
- Их новые телефоны работают быстро. Стоимость загрузки намного меньше, чем была раньше.
- Улучшения ОС, такие как dyld3 и chain fixups, еще больше ускоряют загрузку кода.
- Они представили SwiftUI, декларативный API для пользовательского интерфейса, который имеет много общих концепций с ComponentKit.
- Они предоставили улучшенные SDK и API (например, прерываемую анимацию в iOS 8), для которых мы могли бы создать собственные фреймворки.
По мере того, как Facebook, Messenger, Instagram и WhatsApp обмениваются опытом, FBiOS пересматривает все эти оптимизации, чтобы увидеть, где приложение может приблизиться к ортодоксальности платформы. В конечном счете, мы увидели, что самый простой способ поделиться кодом — это использовать то, что приложение дает вам бесплатно, или создать что-то, что практически не зависит от зависимостей и может интегрироваться между всеми приложениями.
Увидимся здесь в 2032 году, чтобы подвести итоги 20-летия кодовой базы!