Эволюция архитектуры приложения Facebook для iOS

14 февраля 2023

Facebook для iOS (FBiOS) — старейшая мобильная кодовая база в Meta. С тех пор как приложение было переписано в 2012 году, над ним работали тысячи инженеров, оно было отправлено миллиардам пользователей, и оно может поддерживать работу сотен инженеров одновременно.

После многих лет итерации кодовая база Facebook не похожа на типичную кодовую базу iOS:

  • Большой объём кода на C++, Objective-C(++) и Swift.
  • Имеет десятки динамически загружаемых библиотек (dylib) и так много классов, что их невозможно загрузить в Xcode сразу.
  • Apple SDK практически не используется в чистом виде — все было обёрнуто или заменено собственной абстракцией.
  • В приложении активно используется генерация кода с помощью Buck, нашей пользовательской системой сборки.
  • Без интенсивного кэширования нашей системы сборки инженерам пришлось бы провести целый рабочий день в ожидании сборки приложения.


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

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

 

2014: Создание собственного мобильного фреймворка

Через два года после того, как Meta запустила нативную версию приложения Facebook, кодовая база новостной ленты начала испытывать проблемы с надёжностью. В то время модели данных новостной ленты опирались на фреймворк Apple по умолчанию для управления моделями данных: Core Data. Объекты в Core Data изменяемы, и это не подходило для многопоточной архитектуры новостной ленты. Что ещё хуже, новостная лента использовала двунаправленный поток данных, основанный на использовании де-факто шаблона проектирования Apple для приложений Cocoa: Model View Controller.

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

При рассмотрении новых дизайнов один инженер исследовал React, платформу пользовательского интерфейса Facebook (с открытым исходным кодом), которая стала довольно популярной в сообществе Javascript. Декларативный дизайн React абстрагировался от сложного императивного кода, который вызывал проблемы в ленте (в Интернете), и использовал односторонний поток данных, что значительно упростило анализ кода. Эти характеристики казались хорошо подходящими для проблем, с которыми столкнулась новостная лента. Была только одна проблема.

В SDK Apple не было декларативного пользовательского интерфейса.

Swift не будет анонсирован в течение нескольких месяцев, а SwiftUI (декларативный UI-фреймворк Apple) не будет анонсирован до 2019 года. Если News Feed захочет иметь декларативный UI, команде придется создать новый UI-фреймворк.

В конце концов, это то, что они сделали.

Потратили несколько месяцев на создание и перенос новостной ленты для работы с новым декларативным пользовательским интерфейсом и новой моделью данных, и  производительность FBiOS увеличилась на 50 процентов. Несколько месяцев спустя они открыли исходный код своего вдохновлённого React UI-фреймворка для мобильных устройств, ComponentKit.

По сей день ComponentKit по-прежнему является фактическим выбором для создания нативных пользовательских интерфейсов в Facebook. Это обеспечило бесчисленные улучшения производительности приложения за счёт пулов повторного использования view (представление, вью, вьюшка), выравнивания view и вычисления фонового макета. Он также вдохновил его аналог на Android, Litho и SwiftUI.

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

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

 

2015: Переломный момент в архитектуре

К 2015 году Meta удвоила свою мантру “Mobile First”, а кодовая база FBiOS продемонстрировала стремительный рост числа ежедневных участников. По мере того, как всё больше и больше продуктов интегрировалось в приложение, время его запуска стало увеличиваться, и люди начали это замечать. К концу 2015 года скорость запуска была настолько низкой (почти 30 секунд!), что она могла быть убита операционной системой телефона.

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

  • Время ‘pre-main’ (до основного) приложения росло с неограниченной скоростью, поскольку размер приложения увеличивался с каждым продуктом.
  • ‘module’ (модульная) система приложения давала каждому продукту нерегулируемый доступ ко всем ресурсам приложения. Это привело к трагедии проблемы общего пользования, поскольку каждый продукт использовал свой ‘hook’ (крюк) для запуска, чтобы выполнять ресурсоёмкие операции, чтобы первоначальный переход к этому продукту был быстрым.

Изменения, которые были необходимы для смягчения и улучшения запуска, коренным образом изменили способ написания кода для FBiOS инженерами по продуктам.

 

2016: Dylibs и модульность

Согласно вики Apple об улучшении времени запуска, необходимо выполнить ряд операций, прежде чем можно будет вызвать функцию ‘main’ приложения. Как правило, чем больше кода в приложении, тем больше времени это займёт.

В то время как ‘pre-main’ внёс лишь небольшую часть из 30 секунд, затраченных во время запуска, это вызывало особую озабоченность, поскольку оно продолжало расти с неограниченной скоростью по мере того, как FBiOS продолжала накапливать новые функции.

Чтобы смягчить неограниченный рост времени запуска приложения, наши инженеры начали перемещать большие участки кода продукта в lazily load (лениво загружаемый) контейнер, известный как динамическая библиотека (dylib). Когда код перемещается в динамически загружаемую библиотеку, его не требуется загружать перед функцией main() приложения.

Изначально структура dylib FBiOS выглядела так:

Были созданы два dylib продукта (FBCamera и NotOnStartup), а третий продукт dylib (FBShared) использовался для совместного использования кода между различными dylib и двоичным файлом основного приложения.

Решение dylib работало прекрасно. FBiOS смогла сдержать неограниченный рост времени запуска приложения. Шли годы, большая часть кода оказывалась в dylib, так что производительность при запуске оставалась высокой и не зависела от постоянных колебаний добавленных или удалённых продуктов в приложении.

Добавление dylib вызвало ментальный сдвиг в том, как инженеры по продуктам Meta писали код. С добавлением dylib API-интерфейсы времени выполнения, такие как NSClassFromString(), подвергались риску сбоев во время выполнения (в рантайме), поскольку требуемый класс находился в незагруженных dylib. Так как многие базовые абстракции FBiOS были построены на iterating through all the classes in memory (повторении всех классов в памяти), FBiOS пришлось переосмыслить, как работают многие из её основных систем.


Помимо сбоев в рантайме, dylibs также представили новый класс ошибок компоновщика. Если код в Facebook (the startup set – начальный набор) ссылается на код в dylib, инженеры увидят такую ошибку компоновщика:

Undefined symbols for architecture arm64:
  "_OBJC_CLASS_$_SomeClass", referenced from:
      objc-class-ref in libFBSomeLibrary-9032370.a(FBSomeFile.mm.o)

 

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

Если раньше функция main() была такой:

int main() {
  DoSomething(context);
}

 

То сейчас она будет выглядеть так:

int main() {
  FBCallFunctionInDylib(
    NotOnStatupFramework,
    DoSomething,
    context
  );
}

 

Решение сработало, но имело довольно много “кода с душком”:

  • Enum (перечисление) dylib для конкретного приложения было жёстко закодировано в различные сайты вызовов. Все приложения в Meta должны были совместно использовать enum dylib, и читатель должен был определить, использовалась ли эта dylib приложением, в котором выполнялся код.
  • Если использовалось неправильное enum dylib, код не работал, но только в рантайме. Учитывая огромное количество кода и функций в приложении, этот запоздалый сигнал привёл к большому разочарованию во время разработки.

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

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

 

2017: Переосмысление архитектуры FBiOS

С введением dylib пришлось переосмыслить несколько ключевых компонентов FBiOS:

  • ‘module registration system’ (система регистрации модулей) больше не может быть runtime-based (основана на времени выполнения).
  • Инженерам необходим был способ узнать, может ли какой-либо путь во время запуска вызвать загрузку dylib.

Чтобы решить эти проблемы, FBiOS обратилась к системе сборки Meta с открытым исходным кодом Buck.

Внутри Buck каждый ‘target’ (приложение, dylib, библиотека и т. д.) объявляется с некоторой конфигурацией, например так:

apple_binary(
  name = "Facebook",
  ...
  deps = [
    ":NotOnStartup#shared",
    ":FBCamera#shared",
  ],
)

apple_library(
  name = "NotOnStartup",
  srcs = [
    "SomeFile.mm",
  ],
  labels = ["special_label"],
  deps = [
    ":PokesModule",
    ...
  ],
)

 

Каждый ‘target’ содержит всю информацию, необходимую для его сборки (зависимости, флаги компилятора, источники и т. д.), и при вызове ‘buck build’ вся эта информация выстраивается в граф, который можно запрашивать.

$ buck query “deps(:Facebook)”
> :NotOnStartup
> :FBCamera

$ buck query “attrfilter(labels, special_label, deps(:Facebook))”
> :NotOnStartup

 

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

 

2018: Распространение сгенерированного кода

Теперь, когда FBiOS смогла использовать Buck для запроса информации о коде в зависимости, она могла создать сопоставление ‘function/classes -> dylibs’, которое можно было генерировать на лету.

{
  "functions": {
    "DoSomething": Dylib.NotOnStartup,
    ...
  },
  "classes": {
    "FBSomeClass": Dylib.SomeOtherOne
  }
}

 

Используя это сопоставление в качестве входных данных, FBiOS использовала его для генерации кода, абстрагирующего enum dylib от callsites:

static std::unordered_map<const char *, Dylib> functionToDylib {{
  { "DoSomething", Dylib.NotOnStartup },
  { "FBSomeClass", Dylib.SomeOtherOne },
  ...
}};

 

Использование генерации кода было привлекательным по нескольким причинам:

  • Поскольку код был регенерирован на основе локального ввода, нечего было проверять, и конфликтов слияния больше не было! Учитывая, что инженерный состав FBiOS мог удваиваться каждый год, это был большой выигрыш в эффективности разработки.
  • FBCallFunctionInDylib больше не требует dylib для конкретного приложения (поэтому его можно переименовать в ‘FBCallFunction’). Вместо этого вызов будет считываться из статического сопоставления, созданного для каждого приложения во время сборки.

Сочетание запроса Buck с генерацией кода оказалось настолько успешным, что FBiOS использовала его в качестве основы для новой системы плагинов, которая в конечном итоге заменила систему модулей приложений на основе времени выполнения.

 

Перемещение сигнала влево

С новой системой плагинов на базе Buck FBiOS смогла заменить большинство сбоев в рантайме предупреждениями во время сборки путём переноса части инфраструктуры в архитектуру на основе плагинов.

Когда FBiOS собрана, Buck может создать график, показывающий расположение всех плагинов в приложении, например:

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

  • “Плагин D, E может вызвать загрузку dylib. Это не разрешено, так как вызывающая сторона этих плагинов находится в пути запуска приложения”.
  • “В приложении нет плагина для рендеринга профилей… это означает, что переход на этот экран не будет работать”.
  • “Есть два плагина для рендеринга групп (плагин A, плагин B). Один из них должен быть удалён”.

В старой системе модулей приложений эти ошибки были бы “lazy” утверждениями в рантайме. Теперь инженеры уверены, что при успешной сборке 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 начала советовать, чтобы APIs/code продукта не содержал C++, чтобы мы могли свободно использовать Swift и будущие API Swift от 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 и исправление цепочки, предоставляют программное обеспечение, которое ещё больше ускоряет загрузку кода.
  • Представлен SwiftUI, декларативный API для пользовательского интерфейса, который имеет много общих концепций с ComponentKit.
  • Представлены улучшенные SDK, а также API (например, прерываемую анимацию в iOS8), для которых мы могли бы создать собственные платформы.

По мере того, как Facebook, Messenger, Instagram и WhatsApp обмениваются опытом, FBiOS пересматривает все эти оптимизации, чтобы увидеть, где она может приблизиться к ортодоксальности платформы. В конечном счёте, мы увидели, что самый простой способ поделиться кодом — это использовать то, что приложение даёт вам бесплатно, или создать что-то, что практически не зависит от зависимостей и может интегрироваться между всеми приложениями.

Увидимся здесь в 2032 году, чтобы подвести итоги 20-летия кодовой базы!

Оригинал статьи

Содержание