Статические и динамические фреймворки

Как и большинство людей в мире технологий, мы экспериментировали с ChatGPT и хотели посмотреть, как он справится с низкоуровневыми техническими деталями. Мы попросили ChatGPT написать блог о статических и динамических фреймворках в iOS. Здесь - его ответ, а также то, что он сделал правильно, а что нет.

Запрос: я инженер-программист, работающий над приложениями для iOS. Можете ли вы написать статью в блоге, в которой кратко излагаются различия между статическими и динамическими фреймворками в iOS и то, как они влияют на размер и производительность приложения.

Вступление

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

Во-первых, давайте определимся, что такое фреймворк. Фреймворк — это набор предварительно скомпилированного кода, ресурсов и API, которые разработчики могут использовать для создания собственных приложений. Фреймворки iOS обеспечивают стандартизированную структуру для организации и доступа к коду и ресурсам, облегчая разработчикам повторное использование кода и создание сложных приложений.

В iOS есть два типа фреймворков: статические и динамические.

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

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

Теперь давайте рассмотрим ключевые различия между статическими и динамическими фреймворками и то, как они влияют на размер и производительность приложения.

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

Во-первых, позвольте мне пояснить, что бандл приложения (например, загружаемый файл .ipa) отличается от двоичного файла приложения (исполняемого файла Mach-O внутри бандла приложения). Эти понятия часто используются как взаимозаменяемые, что может сбивать с толку.

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

Выделение основного исполняемого двоичного файла
Выделение основного исполняемого двоичного файла

То, что говорит ChatGPT, частично верно. Динамические фреймворки, предоставляемые системой, - например, среда выполнения Swift, имеющая теперь стабильный ABI, - можно обновлять независимо от вашего приложения. Однако предоставляемые пользователем динамические фреймворки должны быть встроены в ваш бандл приложения и не могут быть обновлены таким же образом.

Допустим, вы используете Stripe SDK для обработки платежей. Ответ ChatGPT предполагает, что если Stripe SDK будет обновлен для включения новых функций, ваше iOS приложение сможет использовать более новую версию без каких-либо изменений. В действительности работает это не так. Чтобы новая версия была включена в бандл приложения, код необходимо обновить, а приложение пересобрать.

Мы будем использовать версию 2022.1201.1015 приложения LinkedIn для iOS, чтобы проиллюстрировать, как динамические фреймворки включаются в бандл приложения.

Мы можем видеть все динамические фреймворки, сгруппированные в разделе «Frameworks», для примера:

  • VoyagerLibs

  • AzureСвязьВызов

  • AgoraRTCKit

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

> otool -L LinkedIn 

...
@rpath/VoyagerLibs.framework/VoyagerLibs (compatibility version 0.0.0, current version 0.0.0)
@rpath/AgoraRtmKit.framework/AgoraRtmKit (compatibility version 0.0.0, current version 0.0.0)
@rpath/AgoraRtcKit.framework/AgoraRtcKit (compatibility version 0.0.0, current version 0.0.0)
...
/usr/lib/swift/libswiftCoreFoundation.dylib (compatibility version 1.0.0, current version 14.0.0)
/usr/lib/swift/libswiftCoreGraphics.dylib (compatibility version 1.0.0, current version 3.0.0)
/usr/lib/swift/libswiftCoreImage.dylib (compatibility version 1.0.0, current version 2.0.0, weak)
…

Объявление @rpath означает, что двоичный файл LinkedIn связывается с динамическим фреймворком на основе настроенных параметров поиска путей выполнения, тогда как библиотеки Swift связываются с абсолютными системными путями. Основной двоичный файл приложения использует динамические фреймворки, включенные в бандл приложения. Да, динамические фреймворки «загружаются во время выполнения», но загружается версия самого бандла, и ее нельзя «обновить или изменить» не пересобрав сам бандл.

Влияние на размер приложения

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

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

Опять же, то, что говорит ChatGPT, в целом верно для фреймворков — если бы вы создавали приложение для macOS, ответ был бы верным. Но это неверно для среды выполнения iOS.

Статические фреймворки и размер

Ответ, который дает здесь ChatGPT, не совсем неверен, но он упускает некоторые нюансы статических фреймворков.

Одним из основных преимуществ статических фреймворков является то, что компоновщик часто может поддерживать полную зачистку исполняемых файлов. Вы можете включить эту функцию в Xcode и разрешить компоновщику анализировать используемые символы, что приведет к потенциальной экономии размера.

Мы создали образец приложения Swift, чтобы продемонстрировать это. В одной сборке приложения используются статические фреймворки, а в другой — те же фреймворки, связанные динамически. Никаких других изменений, кроме переключения фреймворков со статических на динамические, не было.

Сравнивая две сборки, мы видим, что размер установки на 2,7 МБ меньше в сборке, использующей фреймворки статически.

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

Динамические фреймворки и размер

Как мы показали выше, динамические фреймворки встроены где-то внутри конечного бандла приложения и увеличивают общий размер бандла приложения. Ответ ChatGPT, что динамические фреймворки «не увеличивают размер пакета приложения» категорически неверен.

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

Чтобы продемонстрировать, как динамические фреймворки могут совместно использовать код, мы снова воспользуемся приложением LinkedIn для iOS и рассмотрим его подключаемые модули.

LinkedIn имеет пять различных плагинов:

  • NotificationServiceExtension_extension

  • ShareExtension_extension

  • WVMPTodayExtension_extension

  • NewsModuleExtension_extension

  • ShareExtension_extension

Вот команда otool -L, чтобы увидеть, какие общие фреймворки использует каждый плагин.

> otool -L WVMPTodayExtension_extension
@rpath/VoyagerLibs.framework/VoyagerLibs (compatibility version 0.0.0, current version 0.0.0)
> otool -L NewsModuleExtension_extension
@rpath/VoyagerLibs.framework/VoyagerLibs (compatibility version 0.0.0, current version 0.0.0)
> otool -L ShareExtension_extension
@rpath/VoyagerLibs.framework/VoyagerLibs (compatibility version 0.0.0, current version 0.0.0)

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

Заглянув немного глубже, мы видим ArtDecoIconsResources.bundle размером в 7,4 МБ в плагине NotificationServiceExtension_extension. Этот файл также существует в VoyagerLibs, но NotificationServiceExtension не связан с VoyagerLibs. По всей видимости, это недосмотр LinkedIn, из-за которого дублируется значительный файл.

ArtDecoIconsResources (красный) излишне дублируется в плагине
ArtDecoIconsResources (красный) излишне дублируется в плагине

Влияние на производительность

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

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

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

ChatGPT также слишком упрощает объяснение динамических фреймворков при запуске приложения. Хотя технически возможно загрузить dylib позже с помощью dlopen(), мы не особо рекомендуем использовать эту опцию для приложений iOS, и это не то, что разработчики учитывают при выборе между статическими и динамическими фреймворками. На практике приложения для iOS загружают все свои динамические фреймворки заранее во время запуска приложения с помощью динамического компоновщика dyld (подробнее о dyld можно прочитать здесь и здесь).

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

Статические фреймворки и производительность

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

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

Чтобы проиллюстрировать это, мы рассмотрим ошибки страниц в приложении Robinhood (версия 2022.48), которое имеет основной исполняемый двоичный файл размером 86,5 МБ и использует 1559 страниц при запуске. Каждая синяя сетка представляет собой страницу, которая используется во время запуска.

Ошибки страницы для iOS-приложения Robinhood
Ошибки страницы для iOS-приложения Robinhood

Обратите внимание, что в левой части диаграммы используемые страницы разбросаны по двоичному файлу. Это означает, что во время запуска приложения весь файл будет считан из файловой системы, затем в память и, в конечном итоге, в ЦП. Это происходит медленно, потому что двоичный файл настолько большой файл.

Порядок файлов аналогичен дефрагментации жесткого диска на старом компьютере с Windows. Он перемещает все функции, используемые во время запуска, в двоичный файл, чтобы они были близко друг к другу, поэтому весь файл не считывается. Вот пример падения страницы при использовании оптимизированного Порядкового Файла.

Пример оптимизированных ошибок страницы
Пример оптимизированных ошибок страницы

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

Apple на протяжении многих лет давала рекомендации относительно того, сколько динамических фреймворков следует загружать во время запуска приложения. Это постоянно меняется, по мере развития DYLD с каждым выпуском iOS, по этому важно, чтобы вы проводили собственное профилирование. Я настоятельно рекомендую вам ознакомиться с последним выступлением Apple на WWDC2022 по этому вопросу!

Выше мы упоминали, что dyld будет загружать все динамические библиотеки во время запуска приложения. Это означает, что чем больше динамических фреймворков мы добавляем, тем больше времени занимает этот процесс. Давайте вернемся к нашему примеру приложения Swift, чтобы продемонстрировать это. Напоминание — у нас есть сборка, почти полностью использующая статические фреймворки, и сборка, использующая те же фреймворки, связанные динамически.

Прежде чем приступить к сравнению запуска приложения для обоих подходов, важно понять роль dyld в запуске — что ChatGPT сделал неправильно. Перед вами флеймограф запуска приложения для нашего образца приложения, когда он использует статические фреймворки. Перед выполнением любого кода запуска приложения dyld загружается во все динамические фреймворки, которые мы определяем как <early startup>.

Флеймограф запуска приложения, показывающий dyld в пути запуска
Флеймограф запуска приложения, показывающий dyld в пути запуска

Теперь вот что происходит с запуском, когда мы переключаем приложение на использование динамических фреймворков. Изменение фреймворков для динамического связывания привело к регрессу запуска приложений на 8,7%.

Сравнение запуска приложений, показывающее регрессию в сборке с динамическими платформами
Сравнение запуска приложений, показывающее регрессию в сборке с динамическими платформами

Да, это очень простое приложение, и фактическое изменение при запуске довольно незначительное. Однако это изменение показывает, что чем больше у вас динамически связанных фреймворков, тем больше работы приходится выполнять dyld.

В данном случае динамические фреймворки увеличились с 2,6 МБ до 12,7 МБ и все же вызвали ощутимый регресс. Раздувание приложений динамическими фреймворками - очень распространенное явление, и многие разработчики не осознают реальных последствий небрежного управления динамическими фреймворками при запуске приложения.

(Если вам интересно, вот сравнение запуска приложения вместе с полным списком зависимостей, которые мы использовали в примере приложения — мы практически скопировали то, что использует Signal).

Резюме

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

ChatGPT уже достаточно обучен, чтобы знать, что частый ответ - «это зависит». ????

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

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

1. Начните со статического фреймворка, когда это возможно

  • Убедитесь, что функция Dead Code Stripping включена, чтобы гарантировать, что они имеют наименьший размер.

  • Вы можете использовать Порядковые Файлы (*файлы порядка), чтобы оптимизировать производительность запуска.

2. Попробуйте динамические фреймворки, если есть возможность разделить дублирование кода/ресурсов между двоичными файлами.

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

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

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

Тест-драйв

Ради интереса я хотел посмотреть, на что еще способен ChatGPT. В последнее время я изучаю некоторые форматы Mach-O, так что, возможно, это поможет.

Запрос: напишите мне программу Swift, которая извлекает LOAD_COMMAND из исполняемого файла Mach-O.

import Foundation 

// Open the Mach-O binary file
let path = "myprogram"
let file = fopen(path, "r")

// Read the Mach-O header
var header = mach_header()
fread(&header, MemoryLayout.size(ofValue: header), 1, file)

// Read the LOAD_COMMANDS
for i in 0..<header.ncmds {
    // Read the LOAD_COMMAND header
    var command = load_command()
    fread(&command, MemoryLayout.size(ofValue: command), 1, file)

    // Read the LOAD_COMMAND data
    let data = Data(count: Int(command.cmdsize) - MemoryLayout.size(ofValue: command))
    fread(data.withUnsafeBytes { $0.baseAddress }, data.count, 1, file)

    // Print the LOAD_COMMAND information
    print("Command:", command.cmd)
    print("Size:", command.cmdsize)
    print("Data:", data)
}

// Close the file
fclose(file)

Удивительно! Это почти компилируется, мне нужно было преобразовать $0.baseAddress в UnsafeMutableRawPointer.init(mutating: $0.baseAddress), но это было все. Мне также пришлось обновить его, чтобы использовать mach_header_64(), но это была моя вина, что я не спросил.

Тестирую это:

Command: 25
Size: 72
Data: 64 bytes

Command: 25
Size: 1512
Data: 1504 bytes
...

Мы видим, что это совпадает с выводом otool -l AmazingApp:

Load command 0
cmd LC_SEGMENT_64
cmdsize 72
segname __PAGEZERO
vmaddr 0x0000000000000000
vmsize 0x0000000100000000
fileoff 0
filesize 0
maxprot 0x00000000
initprot 0x00000000
nsects 0
flags 0x0

Load command 1
cmd LC_SEGMENT_64
cmdsize 1512
segname __TEXT
vmaddr 0x0000000100000000
vmsize 0x00000000001b4000
fileoff 0
filesize 1785856
maxprot 0x00000005
initprot 0x00000005
nsects 18
flags 0x0

Довольно аккуратно. ????

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