Как мы боремся c динамическими библиотеками в Swift. Опыт Яндекса +50

Честно говоря, когда мы приступили к работе над перезапуском Яндекс.Карт, я и представить себе не мог, сколько проблем нам в итоге доставит Swift. Если вы начали писать на Swift совсем недавно, то, поверьте, вы пропустили все самое интересное. Каких-то два года назад не было инкрементной компиляции в принципе (даже пробел в любом файле приводил к полной пересборке), сам компилятор постоянно вылетал с Segmentation Fault на вполне безобидных вещах вроде тройной вложенности типов или наследования от Generic, индексация проекта длилась невообразимо долго, автодополнение работало через раз и так далее и тому подобное, всех бед не счесть. Подобные моменты несомненно усложняют жизнь программистам, но постепенно решаются с каждым обновлением Xcode. Однако есть более существенные проблемы, влияющие не только на разработку, но и на качество приложения: запрет компиляции статических библиотек из свифтового кода, а также отсутствие поддержки Swift на уровне iOS.


Изначально не было очевидно, что использование Swift и динамических библиотек приводит к росту времени запуска. Мы не сравнивали время запуска с предыдущей версией и воспринимали долгую загрузку как данность. Да и средств диагностики того, что же на самом деле происходит на этапе загрузки приложения, в общем-то не было. Но в один прекрасный день разработчики Apple добавили возможность профилирования работы системного загрузчика. Оказалось, что загрузка динамических библиотек занимает очень много времени по сравнению с другими этапами. Конечно, с нашим кодом тоже не все было идеально, но, пожалуй, это частные особенности отдельного приложения и не всем будет интересно о них читать. А вот борьба с динамическими библиотеками — общая тема для всех разработчиков, использующих Swift. Именно об этой проблеме и пойдет речь.

Pre-main


Загрузка приложения выполняется в два этапа. До запуска main системный загрузчик выполняет работу по подготовке образа приложения в памяти:

1. загружает динамические библиотеки,
2. проставляет адреса внешним указателям (bind) и базовые адреса внутренним указателям (rebase),
3. создает контекст Objective-C,
4. вызывает конструкторы глобальных переменных C++ и методы +load в классах Objective-C.

Только после этого начинает выполняться код приложения.

Замер pre-main — нетривиальная задача, поскольку этот этап выполняется системой и его нельзя залогировать как пользовательский код. К счастью, на WWDC 2016: Optimizing App Startup Time рассказали о переменной окружения DYLD_PRINT_STATISTICS, при включении которой в лог выводится статистика работы загрузчика по этапам. Например, для пустого приложения на Swift при запуске на iPhone 5 статистика следующая:
Total pre-main time: 1.0 seconds (100.0%)
dylib loading time: 975.17 milliseconds (95.8%)
rebase/binding time: 14.39 milliseconds (1.4%)
ObjC setup time: 12.46 milliseconds (1.2%)
initializer time: 15.27 milliseconds (1.6%)
Видно, что огромную часть pre-main занимает загрузка динамических библиотек. Их список можно получить, воспользовавшись переменной окружения DYLD_PRINT_LIBRARIES. Библиотеки делятся на системные и пользовательские, загружаемые из папки Frameworks бандла приложения.

Загрузка системных библиотек оптимизирована — в этом просто убедиться, создав пустой проект на Objective-C и запустив его с DYLD_PRINT_LIBRARIES & DYLD_PRINT_STATISTICS:
dyld: loaded: /var/containers/Bundle/Application/6232DEDA-1E38-44B9-8CE8-01E244711306/Test.app/Test
...
dyld: loaded: /System/Library/Frameworks/JavaScriptCore.framework/JavaScriptCore
dyld: loaded: /System/Library/Frameworks/AudioToolbox.framework/AudioToolbox
dyld: loaded: /System/Library/PrivateFrameworks/TCC.framework/TCC

Total pre-main time: 19.65 milliseconds (100.0%)
dylib loading time: 1.32 milliseconds (6.7%)
rebase/binding time: 1.30 milliseconds (6.6%)
ObjC setup time: 5.11 milliseconds (26.0%)
initializer time: 11.90 milliseconds (60.5%)
Этап загрузки динамических библиотек выполняется практически мгновенно, хотя на самом деле их 147, и все — системные. Поэтому сфокусироваться нужно на пользовательских библиотеках.

Минимальный набор динамических библиотек


Прежде чем начинать работу над уменьшением числа динамических библиотек, нужно определить их минимальный набор в приложении, использующем Swift. Очевидно, что это будут динамические библиотеки, линкующиеся к пустому проекту. Чтобы их посмотреть, нужно после сборки перейти к собранному бандлу (через «Show in Finder» в контекстном меню) и зайти в папку Frameworks:



Это так называемые Swift standard libraries (swift runtime). Если в проект добавлен хотя бы один файл *.swift, Xcode копирует их в бандл и линкует к бинарному файлу. Зачем они нужны? Все дело в молодости языка. Swift продолжает активно развиваться и не поддерживает бинарную совместимость. Если бы swift runtime сделали частью системы (как это уже давно сделано для Objective-C), то при очередном обновлении iOS старые программы не смогли бы работать на новой версии системы и наоборот. Поэтому приложения содержат копию swift runtime в папке Frameworks, причем система рассматривает их как пользовательские, отсюда и долгая загрузка. Такова плата за использование динамично развивающегося языка.

Борьба с динамическими библиотеками


Перейдем к более сложному примеру. Пусть некоторое приложение:
— использует СocoaPods для подключения зависимостей, причем некоторые зависимости приходят готовыми динамическими библиотеками,
— разбито на несколько таргетов,
— использует CoreLocation, MapKit, AVFoundation.

Внутри его бандла, в папке Frameworks, лежат следующие библиотеки:



Статистика загрузки этого приложения на iPhone 5 выглядит так:
Total pre-main time: 3.6 seconds (100.0%)
dylib loading time: 3.5 seconds (95.3%)
rebase/binding time: 50.04 milliseconds (1.3%)
ObjC setup time: 59.78 milliseconds (1.6%)
initializer time: 60.02 milliseconds (1.8%)

Уменьшение числа Swift standard libraries


Как видно, в указанном примере на пять библиотек swift runtime больше, чем в пустом проекте. Если в каком-либо файле *.swift есть import CoreLocation, или #import <CoreLocation/CoreLocation.h> стоит в bridging header, то Xcode добавляет в бандл libswiftCoreLocation.dylib. При этом использование #import <CoreLocation/CoreLocation.h> в коде на Objective-C не приводит к добавлению этой библиотеки. Напрашивается решение — сделать обертки Objective-C над нужными частями CoreLocation и использовать в приложении только их. Пример оберток можно посмотреть тут.

К сожалению, этого может оказаться недостаточно из-за транзитивных зависимостей. Использование import MapKit в любом файле *.swift приводит к добавлению libswiftmapkit.dylib и libswiftCoreLocation.dylib, использование import AVFoundation — к добавлению libswiftAVFoundation.dylib, libswiftCoreAudio.dylib, libswiftCoreMedia.dylib. Поэтому нужные части MapKit и AVFoundation тоже приходится оборачивать. А еще libswiftCoreLocation.dylib добавляется, если есть #import <CoreLocation/CoreLocation.h> в каком-либо заголовочном файле, от которого транзитивно зависит bridging header. Если этот #import находится в какой-либо библиотеке, то ее тоже нужно будет обернуть. Все это звучит неприятно, но результат оправдан — можно достигнуть того же набора Swift standard libraries, что и в пустом приложении.

Статическая линковка подов, поставляемых исходными файлами


Следующий массовый источник динамических библиотек — поды, собираемые в динамические фреймворки при указании !use_frameworks в Podfile. Флаг !use_frameworks необходим для подключения зависимостей, написанных на Swift, поскольку Xcode не разрешает использование Swift в статических фреймворках — выкидывает ошибку «Swift is not supported for static libraries».

На самом деле это не значит, что нельзя создавать и использовать статические библиотеки с кодом на Swift, так как статическая библиотека — это просто архив объектных файлов. Компилятор Swift для каждого исходного файла генерирует обычные объектники формата Mach-O. При помощи ar или libtool их можно заархивировать в статическую библиотеку и подставить результат в команду линковки:

— Пусть модуль SomeLib состоит из двух файлов: SomeClass.swift и SomeOtherClass.swift. SomeLib можно собрать с Xcode 8.3.1 в статическую библиотеку и слинковать с main.swift следующими командами:
DEVELOPER_DIR=/Applications/Xcode8.3.1.app/Contents/Developer/
SWIFTC=$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc
SDK=$DEVELOPER_DIR/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS10.3.sdk

# сгенерировать объектные файлы
$SWIFTC -target armv7-apple-ios8.0 -sdk $SDK -module-name SomeLib \
SomeClass.swift SomeOtherClass.swift -c

# сгенерировать swiftmodule, необходимый для импорта в main.swift
$SWIFTC -target armv7-apple-ios8.0 -sdk $SDK -module-name SomeLib \
SomeClass.swift SomeOtherClass.swift -emit-module

# создать статическую библиотеку из объектных файлов
$libtool -static -o libSomeLib.a SomeClass.o SomeOtherClass.o

# создать исполняемый файл из main.swift и libSomeLib.a
$SWIFTC -target armv7-apple-ios8.0 -sdk $SDK -I . -L . main.swift -lSomeLib
— Первые две команды можно объединить, используя OutputFileMap.json, как это делает Xcode. Конкретные параметры, с которыми драйвер компиляции swiftc вызывает компилятор swift, можно посмотреть, добавив опцию -v.

К счастью, в Xcode 9 beta 4 запрет на использование Swift в статических фреймворках убран. Можно дождаться релиза Xcode и соответствующих правок в cocoapods (чтобы компилировались статические фреймворки), и проблема исчезнет сама собой. Но для тех, кто не планирует или не может перейти на Xcode 9, стоит упомянуть про имеющееся достаточно простое решение — cocoapods-amimono. Идея проста — после сборки каждого пода в отдельной билд-папке остаются билд-артефакты, в том числе объектные файлы. Вместо линковки с динамическими библиотеками можно слинковаться напрямую с объектными файлами, из которых они были собраны. Cocoapods-amimono:
— добавляет билд-фазу, выполняющую скрипт, который составляет LinkFileList из объектных файлов, находящихся в build-папках подов,
— линковку с фреймворками подов заменяет на линковку с LinkFileList,
— удаляет встраивание фреймворков в бандл приложения.

Решение работает: фреймворки подов исчезают из папки Frameworks, при этом можно использовать module import, то есть код приложения не меняется.

Статическая линковка собственных таргетов


Таким же образом можно избавиться и от динамических фреймворков, собираемых из пользовательских таргетов: либо дождаться Xcode 9 (и использовать статические фреймворки), либо линковать объектники напрямую в бинарный файл приложения, как это делает cocoapods-amimono. Для этого нужно:

— оставить target в dependencies основного таргета,
— не встраивать framework в бандл и не линковаться с ним,
— добавить билд-фазу, составляющую LinkFileList, по аналогии с cocoapods-amimono:
# таргеты, которые нужно статически влинковать
DEPENDENCIES=('SomeTarget' 'SomeOtherTarget');
ARCHS_LIST=($ARCHS)

# итерация по архитектурам, для которых проводится сборка
for ARCH in ${ARCHS[@]}; do
    DIR=$OBJECT_FILE_DIR_normal/$ARCH

    # абсолютный путь до создаваемого LinkFileList
    FILE_PATH=$DIR/$TARGET_NAME.Dependencies.LinkFileList
    FILE_LIST=""

    # итерация по таргетам
    for DEPENDENCY in "${DEPENDENCIES[@]}"; do
    
        # путь до папки, содержащей билд-артефакты таргета
        PATH=$CONFIGURATION_TEMP_DIR/${DEPENDENCY}.build/Objects-normal/$ARCH

        # паттерн объектных файлов
        SEARCH_EXP="$PATH/*.o"

        # итерация по всем файлам, удовлетворяющим SEARCH_EXP
        for OBJ_FILE in $SEARCH_EXP; do
            # добавить файл в FILE_LIST
            FILE_LIST+="${OBJ_FILE}\n"
        done
    done
    FILE_LIST=${FILE_LIST%$'\n'}

    # записать FILE_LIST на диск по пути FILE_PATH
    echo -n -e $FILE_LIST > $FILE_PATH
done
— линковать основной таргет с LinkFileList. Для этого в OTHER_LDFLAGS добавить:

-filelist "${OBJECT_FILE_DIR_normal}/${CURRENT_ARCH}/${TARGET_NAME}.Dependencies.LinkFileList"

Ленивая загрузка динамических библиотек


С готовыми динамическими фреймворками сложнее, так как динамическую библиотеку нельзя преобразовать в статическую — это по сути исполняемый файл, допускающий только динамическую линковку. Если это core-framework приложения и его символы нужны сразу при запуске — ничего не сделать, его использование будет неизбежно увеличивать время запуска. Но если фреймворк не используется при старте программы, то можно загружать его лениво через dlopen. Причем лениво загружать через dlopen+dlsym можно только совместимую с Objective-C часть интерфейса, поскольку при module import в Swift библиотека линкуется автоматически. Если все необходимое доступно из Objective-C, то нужно:

1. Убрать линковку библиотеки с основным таргетом. Если зависимость подключается через cocoapods, то убрать линковку можно через добавление фальшивого таргета (к которому будут привязаны проблемные поды) или через post_install в Podfile:
post_install do | installer |

    # вызвать Amimono::Patcher.patch!(installer), если используется amimono

    # итерация по таргетам, агрегирующим зависимости основных таргетов
    installer.aggregate_targets.each do |aggregate_target|

        # xcconfig-и, с которыми собираются агрегируюшие и основные таргеты
        target_xcconfigs = aggregate_target.xcconfigs
        # у каждой конфигурации - свой xcconfig
        aggregate_target.user_build_configurations.each do |config_name,_|
            
            # путь до xcconfig для конкретной конфигурации
            path = aggregate_target.xcconfig_path(config_name)

            # взять текущее состояние
            xcconfig = Xcodeproj::Config.new(path)
            
            # удалить что нужно
            xcconfig.frameworks.delete("SomeFramework")
            
            # перезаписать
            xcconfig.save_as(path)
        end
    end
end
2. Написать на Objective-C обертку над framework-ом, реализующую ленивую загрузку библиотеки и нужных символов.

— Загрузка библиотеки.
#import <dlfcn.h>

NSString *frameworksPath = [[NSBundle mainBundle] privateFrameworksPath];
NSString *dyLib = @"DynamicLib.framework/DynamicLib";

// абсолютный путь до файла библиотеки
NSString *path = [NSString stringWithFormat:@"%@/%@", frameworksPath, dyLib];
const char *pathPtr = [path cStringUsingEncoding:NSASCIIStringEncoding]

// загрузка библиотеки
void *handle = dlopen(pathPtr, RTLD_LAZY);
— Получение имен символов в библиотеке DynamicLib, по которым их далее нужно загружать через dlsym.
$nm -gU $BUNDLE_PATH/Frameworks/DynamicLib.framework/DynamicLib

DynamicLib (for architecture armv7):
00007ef0 S _DynamicLibVersionNumber
00007ec8 S _DynamicLibVersionString
0000837c S _OBJC_CLASS_$__TtC10DynamicLib16SomeClass
00008408 D _OBJC_METACLASS_$__TtC10DynamicLib16SomeClass
...
00004b98 T _someGlobalFunc
000083f8 D _someGlobalStringVar
000083f4 D _someGlobalVar
...
— Загрузка и использование глобальных символов.
// dlsym возвращает указатель на символ библиотеки.

// получение указателя на функцию
int (*someGlobalFuncPtr)(int) = dlsym(handle, "someGlobalFunc");

// вызов функции по ее указателю
someGlobalFuncPtr(5);

// получение указателя на глобальную переменную
int *someGlobalVarPtr = (int *)dlsym(handle, "someGlobalVar");
NSLog(@"%@", *someGlobalVarPtr);

// использование глобальной переменной через разыменование указателя
NSString *__autoreleasing *someGlobalStringVarPtr =
(NSString *__autoreleasing *)dlsym(handle, "someGlobalStringVar");

NSLog(@"%@", *someGlobalStringVarPtr);
*someGlobalStringVar = @"newValue";
— Загрузка и использование классов. Objective-C позволяет вызвать у объекта типа id любой объявленный в каком-либо классе instance-метод, а у объекта типа Class — любой объявленный class-метод. Причем можно использовать заголовочные файлы с объявлением интерфейса нужного класса, это не вызывает автоматической загрузки библиотеки, как в случае со Swift.
#import <DynamicLib/SomeClass.h>

//dlsym возвращает сущность типа Class
Class class = (__bridge Class)dlsym(handle,
"OBJC_CLASS_$__TtC10DynamicLib16SomeClass")

// вызов class-метода
[class someClassFunc];

// создание объекта
SomeClass *obj = [(SomeСlass *)[class alloc] init];

// использование
NSLog(@"%@", obj.someVar)
[obj someMethod];
Пример целиком можно посмотреть тут. Стандартные действия по загрузке библиотеки и символов можно оформить в макросы, как это сделано в Facebook SDK.

Результат оптимизаций


В итоге остаются только библиотеки swift runtime и vendored-фреймворки, загружаемые по-возможности лениво. Причем набор библиотек swift runtime такой же, как у пустого приложения. Статистика pre-main теперь выглядит так:
Total pre-main time: 1.0 seconds (100.0%)
dylib loading time: 963.68 milliseconds (90.0%)
rebase/binding time: 35.65 milliseconds (3.3%)
ObjC setup time: 29.08 milliseconds (2.7%)
initializer time: 41.35 milliseconds (4.0%)
Время загрузки динамических библиотек сократилось c 3,5 до 1 секунды.

Сохранение результата


Есть пара простых предложений, как не испортить достигнутый результат с очередным обновлением. Первое — добавить в билд-фазы выполнение скрипта, проверяющего после сборки список библиотек и фреймворков в папке Frameworks бандла приложения — не появилось ли что-то новое.
FRAMEWORKS_DIR="${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}"
FRAMEWORKS_SEARCH_PATTERN="${FRAMEWORKS_DIR}/*"

# возвращает все элементы на диске, удовлетворяющие FRAMEWORKS_SEARCH_PATTERN
FRAMEWORKS=($FRAMEWORKS_SEARCH_PATTERN)

# ожидаемый список файлов и папок в Frameworks
ALLOWED_FRAMEWORKS=(libswiftFoundation.dylib SomeFramework.framework)

for FRAMEWORK in ${ALLOWED_FRAMEWORKS[@]}
do
    PATTERN="*${FRAMEWORK}"
    # удалить все элементы, удовлетворяющие PATTERN
    FRAMEWORKS=(${FRAMEWORKS[@]/${PATTERN}/})
done

echo ${FRAMEWORKS[@]}

# вернуть число оставшихся элементов в FRAMEWORKS
# любое ненулевое число будет интерпретировано как ошибка сборки
exit ${#FRAMEWORKS[@]}
Если появились какие-то новые файлы, это точно повод для разбирательств. Но могут быть библиотеки, которые должны загружаться лениво, и важно проверять, что они не начали загружаться на старте. Поэтому второе предложение — получать список загруженных библиотек через objc_copyImageNames и проверять список библиотек, загруженных из Frameworks:
var count: UInt32 = 0

// получение списка загруженных библиотек
let imagesPathsPointer: UnsafeMutablePointer<UnsafePointer?>! =
                                               objc_copyImageNames(&count)

// ожидаемый список загруженных библиотек
let expectedImages: Set = ["libswiftCore.dylib"]

// путь до папки с библиотеками внутри бандла приложения
let frameworksPath = Bundle.main.privateFrameworksPath ?? "none"

for i in 0..<count {
    let pathPointer = imagesPathsPointer.advanced(by: Int(i)).pointee
    let path = pathPointer.flatMap { String(cString: $0) } ?? ""

    // системные библиотеки не учитываем
    guard path.contains(frameworksPath) else { continue }

    let name = (path as NSString).lastPathComponent
    assert(expectedImages.contains(name))
}
Список не должен меняться. Этих двух моментов вполне достаточно, чтобы увеличение времени pre-main за счет увеличения времени загрузки динамических библиотек не прошло незаметным.

Заключение


Перечисленные проблемы целиком и полностью порождены молодостью Swift. Часть из них исчезнет с выходом Xcode 9, в котором разрешены статические библиотеки на Swift, что позволит избавиться от костылей вроде cocoapods-amimono. Но окончательно проблема роста размера бандла и времени запуска приложения решится только тогда, когда swift runtime станет частью iOS. Причем еще какое-то время после этого приложениям придется таскать его с собой, чтобы поддерживать предыдущие версии системы. Разработка Swift 5 нацелена на стабилизацию бинарного интерфейса Swift standard library. Бинарный интерфейс планировалось стабилизировать в Swift 4, но Xcode 9 по-прежнему копирует swift runtime в бандл приложения с deployment target iOS 11, а значит, Swift все еще не является частью iOS.

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (14):