Часть первая: сущности

Хотел бы начать с кода. Я разделяю код на 4 категории:

  • «Исходный код» (Source на иллюстрации ниже) — код, на котором мы с вами пишем: Swift, Objective-C, C++.

  • Промежуточный (Intermediate): живёт внутри компилятора, который, в свою очередь, применяет разные оптимизации.

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

  • Машинный: самое низкоуровневое представление вашей программы, которая представлена в виде последовательности нулей и единиц, группа которых образует инструкции для конкретного процессора.

Если грубо визуализировать процесс компиляции, то он будет выглядеть примерно так:

Executable binary

Давайте представим, что мы написали калькулятор: создали несколько Swift-файлов, несколько классов и калькулятор готов (процесс упрощён, естественно).

Мы отдаем эти файлы в Xcode и нажимаем Cmd + B (Command Build). В результате получаем Executable binary — первую сущность, с которой я хочу вас познакомить.

Executable binary — это один файл, где вся ваша программа, весь ваш код представлен в виде машинного кода целиком. 

Executable binary живёт в нашем приложении на устройстве. И когда мы запускаем приложение, iPhone выполняет инструкции, которые в нём заложены.

Библиотеки

Допустим, мы решили развивать калькулятор дальше и добавить в него такую крутую функцию, как решение интегральных исчислений. Правда интегралы мы решали достаточно давно, а алгоритмы и решения не писали вообще никогда. Но через 5 минут «гуглинга» мы находим тех, кто написал алгоритм за нас и представил результаты своего труда в виде библиотеки.

Библиотека — это второе понятие, с которым я бы хотел вас познакомить. 

Если мы взяли наш Swift-код, отдали Xcode, нажали кнопку Cmd + B и получили Executable binary, то разработчики библиотеки в аналогичном процессе получили библиотеку.

Но в чём отличия?

Executable binary — это файл, с которым взаимодействует пользователь.

Библиотека — это файл с кодом, с которым взаимодействует программист.

Есть 2 вида библиотек: статические и динамические.

Статическая библиотека — это binary

Такой же binary, как и Executable binary. 

И под словом «такой же» я подразумеваю, что это один файл, где представлен весь код программы в виде машинного кода. 

В нашем случае — весь код интегральных исчислений. 

Как это работает?

  • Берём Swift-файлы с нашим калькулятором и отдаём Xcode.

  • Статическую библиотеку также отдаём Xcode.

  • В наших Swift-файлах пишем ключевое слово import_название_библиотеки.

  • Используем функции библиотеки и, вуаля, наш калькулятор может решать уравнения.

  • Нажимаем Cmd + B и получаем Executable binary.

Отличие этого Executable binary от первого в том, что помимо кода нашей собственной программы, этот Executable binary хранит весь код статической библиотеки, которую мы использовали.

 Если посмотреть на этот процесс детальнее, то происходит вот что:

  • Xcode берёт машинный код нашей программы, полученный в результате компиляции;

  • берёт машинный код статической библиотеки;

  • а механизм под названием статический линкер объединяет эти два вида кода в один и помещает в файл — в новый Executable binary.

Этот файл живет в вашем iPhone и когда вы запускаете приложение, процессором выполняются инструкции, заложенные в файле. 

Это была статическая библиотека. Теперь рассмотрим второй вид.

Динамическая библиотека — это тоже binary

Представим ту же ситуацию, тех же разработчиков и те же интегральные исчисления. Но теперь они решили обернуть свою работу в динамическую библиотеку. А что это такое?

Динамическая библиотека — это тоже binary.

Такой же binary, как и Executable binary, и статическая библиотека. И под словами «такой же» я подразумеваю, что, как и в первых двух случаях, этот binary хранит в себе весь машинный код интегральных исчислений. 

Как это работает?

  • Swift-файлы с нашим калькулятором отдаём Xcode.

  • Динамическую библиотеку также отдаём Xcode. 

  • В Swift-файлах пишем ключевое слов import_название_библиотеки.

  • Используем функции библиотеки.

  • Калькулятор готов.

  • Нажимаем Cmd + B.

Получаем Executable binary.

Этот Executable binary хранит в себе весь машинный код нашей программы, как и в предыдущих случаях.

Но, в отличие от случая со статической библиотекой, этот Executable binary хранит в себе не код динамической библиотеки, а только ссылки на этот код

Сам код библиотеки хранится обособленно в отдельном файле. Рассмотрим этот нюанс подробнее.

  • Xcode берёт машинный код нашей программы;

  • берёт динамическую библиотеку;

  • всё тот же статический линкер всё это объединяет, добавляя ссылки на функции динамической библиотеки.

На выходе мы получаем, как минимум, два файла: один файл — наш Executable binary, с нашей программой, а другой — файл библиотеки с функциями интегрального исчисления.

И теперь на нашем устройстве в нашем приложении лежат как минимум 2 файла: один — с нашей программой, другой — с кодом библиотеки.

Когда мы запускаем приложение, то динамическая библиотека загружается в память, не сразу как Executable binary, а только по требованию. И загружается она не целиком — загружаются только те функции, которые используются приложением в данный момент.

Теперь давайте наглядно посмотрим на методы использования статической и динамической библиотек. В одном случае мы получим один файл, где весь машинный код находится в нём. В другом случае увидим два файла (минимум): один с программой, другой с динамической библиотекой.

Сравнения библиотек

Сравним эти две библиотеки по нескольким направлениям: 

  • Скорость вызова функции — Good Function Calls Performance.

  • Размер в памяти — Optimal Memory Footprint Size.

  • Скорость времени запуска — Optimal Launch Time.

  • Линковка — Linkage. 

Скорость вызова функции.

Здесь очевидный фаворит — статическая библиотека. Дело в том, что статическая библиотека живет в Executable binary и на момент старта приложения она уже загружена в память. Всё, что нужно iPhone — это просто исполнить функции, которые уже загружены в память. 

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

Размер в памяти.

Здесь ситуация обратная: 

  • Статическая библиотека живет внутри Executable binary и загружается целиком.

  • Динамическая загружается только по требованию (и только те функции, что используются в данный момент). 

Следовательно, с точки зрения памяти, лучше использовать динамическую библиотеку. 

Скорость времени запуска приложения.

По этому параметру сравнение противоречиво, потому что скорость запуска для каждого проекта — индивидуальна. Давайте покажу на примерах. 

Пример №1. Допустим, что в нашем проекте есть статическая библиотека весом 400 Мб. После компиляции эти 400 Мб окажутся в Executable binary. И после запуска приложения пользователь будет ждать, пока 400 Мб загрузятся в память. 

Если же библиотека будет динамической, то время запуска будем меньше, потому что, опять же, загрузка происходит в момент использования библиотеки, и только та часть библиотеки (т.е. функции), которая используется.

Пример №2. Допустим, что у нас есть много маленьких динамических библиотек. Если в таком сетапе запустить приложение, то пользователь будет ждать, пока iPhone просмотрит все эти динамические библиотеки, проведет с ними валидации и другие манипуляции. Только после пользователь увидит старт приложения. 

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

Линковка или использование библиотек.

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

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

Я постоянно говорил о таком понятии, как binary, и всё время говорил: «Такой же binary, как и…» — всё потому, что между этими тремя бинарниками есть сходство: все они хранят в себе машинный код.

Но всё же между есть различие. Оно называется Mach-O. Вы неизбежно столкнетесь с этим понятием, когда начнете использовать многомодульную систему в своём приложении.

Mach-O — это формат файла.

Все эти бинарники хранят в себе машинный код. Но как он там хранится — определяется форматом.

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

  • Динамический формат позволяет делать ссылки на машинный код.

  • А executable-формат позволяет запускать этот код.

Здесь я бы привёл грубую аналогию с PNG и JPEG — и там, и там речь идёт про пиксели. Но вот хранятся пиксели в разных форматах и используются по-разному. Также с форматами хранения.

Давайте немного подытожим то, о чём мы поговорили. 

Мы поговорили о том:

  • какие виды кода бывают;

  • что Executable binary — это результат компиляции кода;

  • что такое библиотеки и каких видов они бывают;

  • что лежит на устройстве в приложении.

Теперь перейдем к обёрткам.

Фреймворки

Думаю, что вам знакомо это понятие. Но что такое фреймворк (framework)? Это тоже binary? Или какая-то третья сущность? Для себя я ответил на этот вопрос так:

Фреймворк — это обёртка над динамической библиотекой. 

Фреймворк сам по себе — это папка или директория, внутри которой находится динамическая библиотека. Как следствие, у фреймворка тот же способ использования: по требованию и в рантайме. 

Но кроме библиотеки фреймворк хранит в себе ещё и ресурсы, которые используются внутри динамической библиотеки, например, картинки или музыку. 

Это было описание простого фреймворка. А есть ещё такое понятие, как fat framework (так они названы в документации Apple).

По структуре fat framework такой же, как и обычный, но позволяет хранить в себе машинный код для нескольких архитектур. 

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

Fat framework позволяет хранить в себе копии машинного кода для нескольких архитектур, например, для Mac, который работает на Intel, и для iPhone, который работает на ARM. 

Примечание. При этом статические библиотеки также могут хранить в себе несколько видов машинного кода. 

Это не все фреймворки. Ещё есть umbrella framework. 

Но я его не использовал и не знаю, зачем он нужен. Единственное, что я о нём знаю, так это то, что umbrella framework — это фреймворк над фреймворками, внутри которого есть другие фреймворки. 

Зачем нам нужны фреймворки?

Дело в том, что Apple не разрешает нам, как разработчикам, создавать и использовать динамические библиотеки. Компания разрешает создавать только фреймворки.

Но мы помним, что фреймворк, это просто обёртка над динамической библиотекой и поэтому для нас нет особой разницы. Поэтому, если мы вернемся к примеру с разработчиками, которые сделали для нас динамическую библиотеку, то поймём, что, на самом деле, это был фреймворк.

 Ещё раз подытожим, что мы обсудили: 

  • виды кода;

  • результат компиляции кода;

  • понятия статическая библиотека;

  • файлы Executable binary, которые живут на нашем устройстве;

  • понятие фреймворк, который живет в папках, внутри которых находится динамическая библиотека.

И вот это всё многообразие схематически выглядит так.

Да, кроме этого в нашем iPhone больше ничего и нет. Всё то разнообразие Swift Package Manager, вроде CocoaPods или SwiftPM, все те извращения, которые мы делаем, разделяя проект на модули, после нажатия кнопки Cmd + B приобретают вид Executable binary, либо статической библиотеки, которая будет жить в виде Executable binary или фреймворка.

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

Команды

#1. file <binary_name>

В этой команде пишем ключевое слово «file», название binary, и на выходе получаем это:

  • Первая строка — это сообщение, что у вас Executable binary.

  • Вторая — что это динамическая библиотека.

  • Третья — что это статическая библиотека.

У меня пример библиотек для x86 архитектуры. Если будет несколько архитектур, то они будут перечислены через пробел. 

#2. otool -L <executable_binary_name>

Эта команда позволяет понять, какие фреймворки («динамические библиотеки») слинкованы с вашим Executable binary. Список библиотек будет выглядеть примерно так.

Первая часть закончена.

Часть вторая: процессы

В самом начале я писал, что процесс компиляции выглядит так.

Однако, если на каком-нибудь техническом интервью на вопрос о процессе компиляции вы покажете эту картинку, вам останется только надеяться, что у интервьюера есть чувство юмора. 

Почему? Давайте разберем процесс компиляции глубже и вы всё сами поймёте. 

Xcode

Итак, у нас есть Swift-файлы с нашим калькулятором. Мы отдаём их Xcode и первое, что их встретит — это препроцессор. На вход препроцессора мы отдаем Swift-файлы. На выходе получаем Swift-файлы.

Что происходит внутри препроцессора? 

Препроцессор

Возможно, вы видели в коде что-то такое:

#if DEBUG
print(“hello world”)
#endi
f

Наш преподаватель в университете называл такие вещи препроцессорными директивами. В англоязычном коммьюнити это называется Active Compilation Conditions. Это вещи, благодаря которым препроцессор понимает, какой Swift-код вам оставить. 

  • Если условие удовлетворяет текущему положению вещей, когда вы билдите, препроцессор оставляет этот Swift-код.

  • Если не удовлетворяет — вырезает, будто ничего и не было.

На этом задача препроцессора заканчивается. 

Дальше ваш Swift-код встретит компилятор.

Компилятор

Результат работы компилятора — Assembly-код. 

В Xcode существуют 2 компилятора. 

  • Первый — Swift-C, компилятор для Swift.

  • Второй — Clang, компилятор для С-подобных языков.

Что происходит внутри компилятора?

  • На вход компилятора приходит Swift-код.

  • Внутри код компилируется в intermediate-код.

  • На выходе компилятор отдаёт нам Assembly-код.

Возможно, что у вас были такие моменты, когда на интервью собеседующий делал серьёзное лицо и задавал вопрос:

– А скажите-ка мне, в какой момент происходит управление памятью ARC?

Обычно, также делая умное лицо, я отвечал:

— На этапе компиляции.

Или на кухне за разговорами о вечном возникает такая тема, как инлайнинг-код. Обычно в этот момент звучит: «Эппл реально гении, они придумали оптимизации ещё на этапе компиляции» 

Когда мы говорим об оптимизациях на этапе компиляции, то подразумеваем оптимизации, которые происходят в момент компиляции intermediate языка. И происходят они вот здесь:

Появление Executable binary

Теперь у нас есть крутооптимизированный Assembly-код, который попадает в механизм под названием Assembler. 

И вот уже Assembler даёт нам тот самый машинный код, о котором мы говорили в начале. Теперь этот машинный код попадает в статический линкер, о котором также говорили в начале.

Помимо нашего машинного кода туда попадают и статические, и динамические библиотеки.

И в этот момент как раз и происходит создание того самого Executable binary.

Работая с библиотеками, вы неизбежно столкнетесь с такой ошибкой, как Undefined symbols for architecture armv7

Как её решить вы от меня не узнаете — это вы должны выяснить сами:) Но скажу две вещи:

  • Это случилось потому, что статический линтер на смог найти имплементацию тех функций, которые вы используете в какой-либо из библиотек.

  • Это случилось именно на этапе, который мы сейчас и рассматриваем.

Итак, мы с вами рассмотрели процесс компиляции приложения. Давайте рассмотрим обратную сторону — запуск.

Запуск приложения

Представим, что мы решили посмотреть в одной из соцсетей сторис какого-нибудь блогера. Все же так делают?)

Что происходит в тот момент, когда мы нажимаем на иконку приложения в телефоне? Ядро операционной системы берет Executable binary приложения и помещает его в оперативную память.

Дальше это же ядро запускает процесс под названием dyld или dynamical loader. 

Этот dyld забираем из приложения все фреймворки («динамические библиотеки») и на жестком диске строит что-то вроде словаря, где:

  • ключ — это линковочное имя библиотеки, данное ей статическим линкером ещё в момент компиляции;

  • а значение словаря — это сама библиотека.

После этого процессор начинает выполнять команды, описанные в Executable binary.

Вуаля — приложение открылось! Теперь мы сможем посмотреть сторис. 

А что происходит когда мы «тапаем» на сторис?

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

Далее лоадер помещает сторис в оперативную память, процессор начинает её выполнять и у нас перед глазами открытая история.

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

Как можно это всё использовать? Для этого переходим к третьей части.

Часть третья: история

Наши коллеги из Альфа-Банка придумали такую историю: у них много модулей, и когда готовится релиз для AppStore, то эти модули они переводят в статические библиотеки, и все они живут в Executable binary. 

Зачем?

Потому что запуск приложения со статическими библиотеками занимает 90 мс, а с динамическими — 160 мс. Оптимизация запуска налицо.

Но также они сделали ещё одну крутую вещь: для дебага все их модули — это динамические библиотеки. 

Зачем это нужно? 

Если они вносят изменения в один модуль, то не хотят компилировать все приложение, ибо это странно. И как раз то, что их модули это динамические библиотеки, позволяет компилировать модули по одному, не трогая всё остальное.

Вместо вывода: о чём я не сказал

Я говорил, что фреймворк — это папка. Но это не какая-то рандомная папка — у неё есть определенная структура. Фреймворк правильнее рассматривать через понятие «bundle», а саму папку через понятие «package». Package — это некое «отношение» ОС к этому виду данных.

Мы также не поговорили про ресурсы. 

В модуле могут существовать ресурсы. Что произойдет с этими ресурсами, если мы скомпилируем модуль в статическую библиотеку? А что, если в динамическую? 

Есть такой файл под названием Assets.car. Я готов поспорить, что вы все столкнетесь с проблемой конфликта этого Assets.car, когда начнете делить проект на модули. Что это за файл, что за конфликты и как их резолвить — это тема отдельной статьи.

Мы также не поговорили о том, что такое приложение вообще. Ведь это своего рода папка со структурой схожей со структурой фреймворка, поэтому приложения стоит рассматривать, опять же, через понятия «bundle» и «package».

Но это уже другая история :)

Комментарии (1)


  1. akaDuality
    15.11.2024 18:34

    Спасибо за статью, хорошо получилось!

    Хочется добавить про практический кейс: SPM создает по умолчанию статические библиотеки (т.е. копируются в апку), но если создать какой-нибудь экстеншен (например, Live Activities) и подключить туда либу, то либа скопируется в оба бинарника. Все как в схемах из статьи, но вот такая реальность со вторым экзекьютебл-файлом.

    Emerge в Твиттере постоянного натыкается на такое разбирая структуру приложений