即刻Swift静态库实践

12,476 阅读6分钟
原文链接: zhuanlan.zhihu.com

背景

即刻是国内较早全面拥抱Swift的iOS开发团队,目前即刻100%的业务代码(第三方库依赖除外)都通过Swift实现。随着业务的发展,即刻做了多次架构的拆分,项目按模块划分成多个target,依赖的第三方库也日渐增多。

在Xcode 9以前,由于官方不支持Swift静态库,开发团队不得不将项目的依赖打包成动态库。然而,随着项目里动态库越来越多,app的启动速度不可避免地受到影响(参考Optimizing App Startup Time)。

终于在Xcode 9时代,swift带来对静态库的原生支持。即刻一直以来都关注着swift静态库这个feature,因此在Xcode 9到来的时候,我们就决定将项目改造成静态库打包的方式。

遇到的问题和方法

静态库是Swift社区比较关注的点,因此在Xcode 9 出来的同时,即刻便开始着手支持。

在手动简单尝试(改target编译项)后,我们决定自己来尝试做这件事情,原因是一来对比测试后,发现静态库打包对我们当前项目有所优化,二是改动并不大,做完资源管理后,代码改动不大。

我们了解到cocoapod 也在着手支持,但在我们开始实践时 cocoapods的支持还存在些问题,而且我们所做的改造即使未来切换为cocoapod的方式也是必要的。于是我们决定先行,并将手动改造脚本化,使用Xcodeproj 来替代手动改造

main_project = Xcodeproj::Project.open('Ruguo.xcodeproj')
    main_target  = main_project.targets.first
    # add complie flag
    # 1. 静态库链接对于无引用的代码会优化掉,为binary增加 -all_load,加载全部,防止部分OC符号找不到
    main_target.build_configurations.each do |config|
        config.build_settings['OTHER_LDFLAGS'] += ['-all_load'] unless config.build_settings['OTHER_LDFLAGS'].include?('-all_load')
    end
    
    # main target build phases
    # main target 的不同阶段
    main_embed_frameworks_phase = main_target.build_phases.select {|phase| (phase.respond_to? :name) && phase.name == "Embed Frameworks" }.first
    main_copy_resources_phase = main_target.build_phases.select {|phase| phase.kind_of? Xcodeproj::Project::Object::PBXResourcesBuildPhase }.first
    main_linkPhase = main_target.build_phases.select { |phase| phase.kind_of? Xcodeproj::Project::Object::PBXFrameworksBuildPhase }.first


    excludeTargets = ['主project,有些target,比如拓展程序,不转成静态库']
    main_project.targets.each do |target|
        if not excludeTargets.include? target.name then
            target_linkPhase = target.build_phases.select { |phase| phase.kind_of? Xcodeproj::Project::Object::PBXFrameworksBuildPhase }.first
            target_copy_resources_phase = target.build_phases.select {|phase| phase.kind_of? Xcodeproj::Project::Object::PBXResourcesBuildPhase }.first
            
            # find dynamic library references
            # 1. 静态库是一个个Object文件的集合,对于其依赖的动态库加到binary最终的依赖就可以
            # 2. tbd 是一种动态库的 stub library,拥有相应动态库一样的链接符号,但没有相关代码,能加速编译等
            target_linkPhase.files_references.each do |file_reference|
                if file_reference.path.end_with?(".tbd") or file_reference.path.end_with?(".dylib") or file_reference.path.end_with?(".framework") then
                  main_linkPhase.add_file_reference(file_reference, true)
                end
                
                # stub library is not allow in static library
                if file_reference.path.end_with?(".tbd") then
                    target_linkPhase.remove_file_reference(file_reference)
                end
            end
            
            # add OTHER_LDFLAGS
            target.build_configurations.each do |config|
                config.build_settings['MACH_O_TYPE'] = 'staticlib'
                # 保留静态库符号信息
                config.build_settings['STRIP_INSTALLED_PRODUCT'] = 'NO'
            end
    
            # copy frameworks resources into main bundle
            target_copy_resources_phase.files_references.each do |file_reference|
                main_copy_resources_phase.add_file_reference(file_reference, true)
            end
        end
    end
        
    main_project.save
  • all_load

打包成静态库后,第一个问题就是一些OC的Selector 没有找到,解决方法便是增加all_load 编译选项,参考文献

  • 资源管理

与动态framework 不同,静态库的Object最终都将打到main binary,因此,项目里各个framework和target资源的路径将会有所不同。为了规范资源使用,对各个模块的资源,我们都建立了单独的bundle来管理,并修正了项目里资源的使用方式

  • 符号丢失

成功将项目改造成静态库打包后,在测试阶段,我们发现采集上来的崩溃日志信息上符号信息错乱的情况。(如下:堆栈函数地址偏移过大,显然是符号问题)

通过Hopper 工具,发现jike可执行文件里,大量符号丢失。于是立刻将静态库的编译配置改为

- Strip Debug Symbols During Copy: No
- Strip Style: Debugging Symbols
- Strip Linked Product: No

编译后,打包发现符号恢复了,但包大小增加了近10M。因此,一度怀疑静态库带来的10来M的优化是因为符号的丢失,仔细一想觉得不太合理,release 下符号信息应该存在于单独的dsym文件,由dsym 文件就可以重新符号化,因此不应该将符号打到最终的main binary。通过排查,发现静态库确实需要保留符号,因为静态库最终将被打到main binary,所以需要保留静态库的符号,这样生成main binary的时候其dsym的符号信息就能比较完整。

与此同时,设置main binary,因为符号最终无须存在于binary内。

Strip Debug Symbols During Copy: Yes
Strip Style: All Symbols
Strip Linked Product: Yes

这样我们就能获得完整符号信息的dsym和符号瘦身的main binanry。而原来我们的项目是由动态库构成,动态库和main binary一样,是独立的macho文件,丢弃符号的同时,也有其对应的dsym文件,因此也能正常符号化。

进展

  • 目前即刻将项目内的pods 依赖和模块target全部打包成静态库,并以及上线,体验地址
  • premain 消耗从800+ms 降低到 500+ms, iPhone 6s
  • 包大小48M 减少到35M
    • 当前没有找到静态库打包和动态库打包对app bundle 大小影响的直接资料
    • 但根据实践和我们推测,与动态库保持相对独立和通用不同,静态库打包后其代码都将拷贝成为main binary的一部分,因此编译器生成的main binary能获得更多的优化, 比如无用代码消除等。
    • 在iOS 平台,动态库是PIC(position independent code),相比较静态库no-PIC。

CocoaPods

  • 在我们着手做静态库打包的时候,cocoapods 也正进行相关支持,目前Cocoapods 1.4.0.2-beta 已经支持
  • Cocoapods 1.4.0.2-beta支持打包静态库,但需要在podspec 中添加属性static_framework = true,由于pod spec 一般由pod owner 提供,绝大部分pod目前都没有提供静态库打包的podspec。因此如果我们想依赖cocoapod完成打包工作,那么一个可行的方式就是自己提供项目依赖的pod对应的静态库 podspec

最好的方式是建立自己私有的repo,并将上传对应版本的podspec ,注意为每个 podspec 添加static_framework属性。对于podspec 里dependency 也需要为其创建静态库版本。通过这个方式,只要切换podfile指定的版本就能切换动态库打包和静态库打包

Note: 有些混编的库,目前Cocoapods 1.4.0.2-beta 尚未能很好支持
issue: github.com/CocoaPods/C…
e.g: Rxswift、RxCocoa等
  • 目前新的版本,对于Pod依赖 我们已经使用CocoaPods 来完成静态库打包, 主要考虑更小的维护成本,和能更灵活切换动/静 库打包, 带-static是即刻私有repo上相应pod 的静态库版本,例如:
pod 'AsyncSwift', '2.0.4-static'
    pod 'Alamofire',  '4.2.0-static'
    pod 'RxSwift', '4.0.0'
    pod 'RxCocoa', '4.0.0'
    pod 'AsyncDisplayKit', '2.0.2-static'
    pod 'SwiftyUserDefaults', '3.0.0-static'
    pod 'ObjectMapper', '3.1.0-static'

结语&广告

探索和跟进新技术是即刻iOS一直在前进的方向,欢迎👏更多优秀的同学加入即刻,一起努力打造更酷的技术和应用。

招聘链接: 即刻船票

参考

[Linker and Libraries Guide ]

[Reliable Software Technologies]

[Framework Programming Guide]

How are static libraries linked and how are dynamic libraries loaded?