How we fight with dynamic libraries in Swift. Yandex experience

    Honestly, when we started work on restarting Yandex.Maps, I couldn’t imagine how many problems Swift would end up with. If you started writing on Swift quite recently, then, believe me, you missed all the fun. Some two years ago there was no incremental compilation in principle (even a space in any file led to a complete rebuild), the compiler itself constantly crashed with Segmentation Fault on completely harmless things like triple nesting of types or inheritance from Generic, indexing the project lasted unimaginably long, auto-completion worked every other time and so on and so forth, all the troubles can not be counted. Such moments undoubtedly complicate the life of programmers, but are gradually resolved with each update of Xcode. However, there are more significant problems that affect not only the development, but also the quality of the application:


    Initially, it was not obvious that using Swift and dynamic libraries would increase startup time. We did not compare the launch time with the previous version and took the long download as a given. And there weren’t any means of diagnosing what actually happened at the application loading stage. But one day, Apple developers added the ability to profile the work of the system bootloader. It turned out that loading dynamic libraries takes a lot of time compared to other stages. Of course, with our code, too, not everything was perfect, but, perhaps, these are particular features of a separate application and not everyone will be interested to read about them. But the fight against dynamic libraries is a common theme for all developers using Swift. It is this problem that will be discussed.

    Pre-main


    Downloading the application is carried out in two stages. Before launching main, the system loader does the job of preparing the application image in memory:

    1. loads dynamic libraries,
    2. assigns addresses to external pointers (bind) and base addresses to internal pointers (rebase),
    3. creates an Objective-C context,
    4. calls constructors C ++ global variables and + load methods in Objective-C classes.

    Only after that does the application code begin to run.

    Measuring pre-main is a non-trivial task, since this stage is performed by the system and cannot be pledged as user code. Fortunately, at WWDC 2016: Optimizing App Startup Time talked about the environment variable DYLD_PRINT_STATISTICS, when you turn it on, the loader statistics on the stages are displayed in the log. For example, for an empty Swift application when launching on iPhone 5, the statistics are as follows:
    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%)
    It can be seen that the huge part of pre-main is occupied by loading dynamic libraries. A list of them can be obtained using the environment variable DYLD_PRINT_LIBRARIES . Libraries are divided into system and user libraries downloaded from the Frameworks folder of the application bundle.

    The loading of system libraries is optimized - you can just make sure of this by creating an empty project on Objective-C and starting it with 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%)
    The stage of loading dynamic libraries is performed almost instantly, although in reality there are 147 of them, and all of them are system ones. Therefore, you need to focus on user libraries.

    Minimal set of dynamic libraries


    Before starting work on reducing the number of dynamic libraries, you need to determine their minimum set in an application using Swift. Obviously, these will be dynamic libraries linking to an empty project. To see them, you need to go to the assembled bundle after the build (through the "Show in Finder" in the context menu) and go to the Frameworks folder:



    These are the so-called Swift standard libraries (swift runtime). If at least one * .swift file is added to the project, Xcode copies them to the bundle and links to the binary file. What are they needed for? It's all about the youth of the language. Swift continues to evolve and does not support binary compatibility. If swift runtime were made part of the system (as it has long been done for Objective-C), then with the next iOS update, old programs could not work on the new version of the system and vice versa. Therefore, applications contain a copy of swift runtime in the Frameworks folder, and the system considers them as custom, hence the long download. This is the fee for using a dynamically developing language.

    The fight against dynamic libraries


    Let's move on to a more complex example. Let some application:
    - use CocoaPods to connect dependencies, and some dependencies come with ready-made dynamic libraries,
    - split into several targets,
    - use CoreLocation, MapKit, AVFoundation.

    Inside his bundle, in the Frameworks folder, are the following libraries:



    Download statistics of this application on iPhone 5 looks like this:
    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%)

    Decrease the number of Swift standard libraries


    As you can see, in this example, there are five more swift runtime libraries than in an empty project. If any * .swift file has import CoreLocation, or #importis in the bridging header, then Xcode adds libswiftCoreLocation.dylib to the bundle. In doing so, use #importin Objective-C code does not add this library. The solution is to make Objective-C wrappers over the necessary parts of CoreLocation and use only them in the application. An example of wrappers can be found here .

    Unfortunately, this may not be enough due to transitive dependencies. Using import MapKit in any * .swift file adds libswiftmapkit.dylib and libswiftCoreLocation.dylib, using import AVFoundation adds libswiftAVFoundation.dylib, libswiftCoreAudio.dylib, libswiftCoreMedia.dylib. Therefore, the necessary parts of MapKit and AVFoundation also have to be wrapped. And also libswiftCoreLocation.dylib is added if there is #importin any header file on which the bridging header is transitively dependent. If this #import is in a library, then it will also need to be wrapped. All this sounds unpleasant, but the result is justified - you can achieve the same set of Swift standard libraries as in an empty application.

    Static linking of hearths supplied by source files


    The next massive source for dynamic libraries is pods compiled into dynamic frameworks when! Use_frameworks is specified in the Podfile. The flag! Use_frameworks is necessary for connecting dependencies written in Swift, since Xcode does not allow the use of Swift in static frameworks - it throws the error "Swift is not supported for static libraries".

    Actually, this does not mean that you cannot create and use static libraries with Swift code, since a static library is just an archive of object files. The Swift compiler generates regular Mach-O format object files for each source file. Using ar or libtool, you can archive them into a static library and substitute the result in the link command:

    - Let the SomeLib module consist of two files: SomeClass.swift and SomeOtherClass.swift. SomeLib can be compiled with Xcode 8.3.1 into a static library and linked to main.swift with the following commands:
    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
    - The first two commands can be combined using OutputFileMap.json, as Xcode does. The specific parameters with which the swiftc compilation driver calls the swift compiler can be viewed by adding the -v option.

    Fortunately, in Xcode 9 beta 4, the ban on using Swift in static frameworks has been removed. You can wait for the release of Xcode and the corresponding edits in cocoapods (so that static frameworks are compiled), and the problem disappears by itself. But for those who do not plan or cannot upgrade to Xcode 9, it’s worth mentioning a fairly simple solution - cocoapods-amimono. The idea is simple - after assembling each pod, build artifacts, including object files, remain in a separate build folder. Instead of linking with dynamic libraries, you can link directly to the object files from which they were collected. Cocoapods-amimono:
    - adds a build phase that executes a script that makes up LinkFileList from object files located in the build folders of the hearths,
    - replaces the link with the hearth frameworks with the link with LinkFileList,
    - removes the embedding of the frameworks in the application bundle.

    The solution works: hearth frameworks disappear from the Frameworks folder, while you can use module import, that is, the application code does not change.

    Static linking of your own targets


    In the same way, you can also get rid of dynamic frameworks compiled from user targets: either wait for Xcode 9 (and use static frameworks), or link the object objects directly to the application binary, as cocoapods-amimono does. To do this, you need to:

    - leave target in the dependencies of the main target,
    - do not embed the framework in the bundle and do not link to it,
    - add the build phase that makes up LinkFileList, similar to 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
    - Link the main target with LinkFileList. To do this, add in OTHER_LDFLAGS:

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

    Lazy loading of dynamic libraries


    With ready-made dynamic frameworks, it is more difficult, since a dynamic library cannot be converted to a static one - it is essentially an executable file that allows only dynamic linking. If this is a core-framework application and its symbols are needed immediately at startup, there is nothing to be done, its use will inevitably increase the launch time. But if the framework is not used when starting the program, then you can load it lazily through dlopen. Moreover, it is lazy to load through dlopen + dlsym only the part of the interface compatible with Objective-C is possible, since when importing in Swift, the library is automatically linked. If everything you need is available from Objective-C, then you need:

    1. Remove the library link with the main target. If the dependency is connected via cocoapods, then you can remove the link by adding a fake target (to which problematic pods will be attached) or via post_install in the 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. Write a wrapper over the framework on Objective-C that implements lazy loading of the library and the necessary characters.

    - Download the library.
    #import

    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);
    - Obtaining symbol names in the DynamicLib library, according to which they need to be further downloaded via 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
    ...
    - Download and use global characters.
    // 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";
    - Download and use classes. Objective-C allows you to call any instance method declared on an object of type id, and any class method declared on an object of type Class. Moreover, you can use header files with the interface declaration of the desired class, this does not automatically load the library, as is the case with Swift.
    #import

    //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];
    The entire example can be seen here . The standard actions for loading the library and symbols can be arranged in macros, as is done in the Facebook SDK .

    Optimization Result


    As a result, only the swift runtime libraries and vendored frameworks, loaded as lazily as possible, remain. Moreover, the set of swift runtime libraries is the same as that of an empty application. Pre-main statistics now look like this:
    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%)
    The loading time of dynamic libraries was reduced from 3.5 to 1 second.

    Saving the result


    There are a couple of simple suggestions on how not to spoil the result with the next update. The first is to add a script to the build phase that checks after the build the list of libraries and frameworks in the application bundle’s Frameworks folder - if something new has appeared.
    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[@]}
    If there are any new files, this is definitely a reason for litigation. But there may be libraries that should load lazily, and it is important to verify that they did not start loading at startup. Therefore, the second sentence is to get the list of loaded libraries through objc_copyImageNames and check the list of libraries loaded from Frameworks:
    var count: UInt32 = 0

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

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

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

    for i in 0..     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))
    }
    The list should not be changed. These two points are quite enough so that the increase in pre-main time due to the increase in the loading time of dynamic libraries does not go unnoticed.

    Conclusion


    The above problems are entirely generated by the youth of Swift. Some of them will disappear with the release of Xcode 9, which allows static libraries on Swift, which will allow you to get rid of crutches like cocoapods-amimono. But finally, the problem of increasing the size of the bundle and the launch time of the application will be solved only when swift runtime becomes part of iOS. Moreover, for some time after this, applications will have to carry it with them in order to support previous versions of the system. The development of Swift 5 aims to stabilize the binary interface of the Swift standard library. It was planned to stabilize the binary interface in Swift 4, but Xcode 9 still copies swift runtime to the application bundle with deployment target iOS 11, which means Swift is still not part of iOS.

    Also popular now: