Разбираем iOS-приложение: код, сборка, статические и динамические библиотеки, компиляция, запуск
Часть первая: сущности
Хотел бы начать с кода. Я разделяю код на 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
В этой команде пишем ключевое слово «file», название binary, и на выходе получаем это:
Первая строка — это сообщение, что у вас Executable binary.
Вторая — что это динамическая библиотека.
Третья — что это статическая библиотека.
У меня пример библиотек для x86 архитектуры. Если будет несколько архитектур, то они будут перечислены через пробел.
#2. otool -L
Эта команда позволяет понять, какие фреймворки («динамические библиотеки») слинкованы с вашим Executable binary. Список библиотек будет выглядеть примерно так.
Первая часть закончена.
Часть вторая: процессы
В самом начале я писал, что процесс компиляции выглядит так.
Однако, если на каком-нибудь техническом интервью на вопрос о процессе компиляции вы покажете эту картинку, вам останется только надеяться, что у интервьюера есть чувство юмора.
Почему? Давайте разберем процесс компиляции глубже и вы всё сами поймёте.
Xcode
Итак, у нас есть Swift-файлы с нашим калькулятором. Мы отдаём их Xcode и первое, что их встретит — это препроцессор. На вход препроцессора мы отдаем Swift-файлы. На выходе получаем Swift-файлы.
Что происходит внутри препроцессора?
Препроцессор
Возможно, вы видели в коде что-то такое:
#if DEBUG
f
print("hello world”)
#endi
Наш преподаватель в университете называл такие вещи препроцессорными директивами. В англоязычном коммьюнити это называется 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».
Но это уже другая история:)