Q音直播抽离成pod库分别引入到QQ音乐和Fan直播两个独立app中,而对于直播业务来讲,直播SDK通过pod本地引入集成到Demo中进行日常直播业务的开发,通过Demo来精简工程规模,提高研发效率。
但随着业务扩展直播SDK越来越庞大,出现了以下痛点:
于是决定逐一解决以上三个痛点。
直播Demo通过本地pod引入直播SDK去日常开发,每次出现文件配置变更时需要重新执行pod;频繁pod常会导致编译缓存失效,引起整个pod库的重新编译。
对于痛点1:优化编译速度,有很多方式去做。但最为有效的措施包含以下两点:
对于痛点2:直播SDK日常开发调试是在独立工程中进行,无需对Q音主端暴露源码,因此可以将整个直播SDK以静态库的形式引入到主端。
针对以上解决方案的设想,选择合理的二进制方案至关重要。
这是常规的打包方式,我们可以选择不同的XCode工程模版来打包静态库(.a | .framework)或动态库(.frame)。主要分以下几步:
步骤1是要提前搭好的工程脚手架,后面的步骤可以编写打包脚本来简化操作。
cocoapods-packager是cocoapods官方的一款二进制打包插件,通过gem安装后可通过 pod package 命令行来生成 framework 或 static library。使用非常方便,只需要提供一个podspec文件即可完成打包。
看了一下插件的源码,实现逻辑倒也不复杂,关键步骤如下:
1. 将提供的podspec迁移到一个沙盒目录下,根据此podspec生成podfile文件。
2. 执行 pod install 生成pod工程(podfile中需要设置配置项intefrate_targets为false,不然会因找不到target而报错)。
3. xcodebuild 生成二进制包,然后合并模拟器及真机并输出到指定路径。
如果说cocoapods-packager仅仅是针对单个pod库的打包,那么cocoapod-binary则是对工程中整个pod库的二进制方案。它在 pod install 时通过将引入的pod库预编译成binary然后缓存至本地,后续工程编译直接link到binary,对于binary的pod库以几乎零编译成本的形式来提高整个项目的编译效率。
同时cocoapods-binary可以通过修改podfile灵活地切换源码和二进制,优化编译效率的同时也方便调试。
方案 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
工程脚手架+打包脚本 | 完全自主打包,可任意修改工程配置支持不同打包方式,灵活性更强。 | 需要自行维护脚手架仓库。 | 原则上来讲只要知识到位所有场景都可用,不过存在一定的学习和维护成本。 |
cocoapods-packager | 只需要提供一个podspec便可零成本打包,使用方便,学习成本低。 | 提供的打包参数有限,如有额外需求需要自行修改插件。 | 适用于对单个pod仓库进行打包。 |
cocoapods-binary | 插件轻量对podfile原有语法无入侵,不需要额外的学习和操作成本;二进制流程完全自动化;可以方便地进行源码->二进制切换。 | 插件的部分功能还不太完善。如某个模块更新以后,需要 pod update 才能保证二进制也得到更新。 | 不同于packager作用于单个pod,项目的pod仓库可能会频繁变更版本,不可能每次版本变更都去对应打静态库,所以对于整个工程的pod库是一个不错的二进制选择。 |
三、方案落地:
以下编译时间皆以我的17款iMac(i7|16G)上的iphone11模拟器来计算。
1. Debug下设置Build active architecture only 为 YES,debug配置下没必要生成全架构。
2. 设置Debug下不生成dSYM,只在release下生成。
3. Build Setting -> Header Search Paths->non-recursive
如果过多头文件索引被设置为了递归引用,会导致编译器预处理头文件效率变低。
4. 关闭 Enable Index-While-Building Functionality
实践中,1和2 XCode12默认已经开启;3跟4减少的时间可忽略不计,所以我们还要另寻出路。
直播SDK内采用jce协议,开发至今模块内的jce文件数量>2000,大大增加了编译时长。
jce文件只依赖cocoaJce一个pod库且无外部资源引入,选择打包成.a静态库。由于业务迭代需要jce变更非常频繁而且存在多个版本并行迭代同时变更jce的情况,因此我们要做好版本管理,同时希望将打包流程自动化。
1. 将jce_oc文件通过pod本地引入(不需要手动链接文件),将pod操作+打包流程写为自动化脚本。
2. 将打包流程及头文件的导出分离,工程及打包脚本只负责打包,专写一个脚本负责从源文件按目录结构导出头文件放在Header下。(传统的方式是要在XCode工程中手动选择暴露的Header)。
3. 规范目录层级
f = open('subspec.rb', 'w+')for entry in os.listdir(basepath): if os.path.isdir(os.path.join(basepath, entry)): str = f"s.subspec '{entry}' do |ss|\n" str = str + f"\tss.source_files = \"{basepath}/{entry}/**/*\"\n" str = str + f"\tss.public_header_files = \"{basepath}/{entry}/**/*.h\"\n" str = str + "end\n\n" f.write(str)f.close()
4. 版本管理:
jce.podspec
是否存在。5. 自动化:
效果:初次编译时间从400s减到了160s。
使用Gonmon计算了一下单文件的编译时间。
# 安装xcpretty和gnomonsudo gem install xcprettynpm install -g gnomon
# 获取文件编译时间并用xcpretty对输出结果进行格式化xcodebuild -workspace xxx.xcworkspace -configuration "Debug" -scheme "xxx" -destination "platform=iOS Simulator,id=xxx" | xcpretty | gnomon -i | grep Compiling >>fileTime.txt
# 使用sort对输出结果进行排序sort -r fileTime.txt | head -n 100 | grep Compiling >> new_fileTime.txt
输出结果:
可以看出单个文件编译耗时比较久的很多是c++或oc/c++混编文件,果然引入c++静态库对iOS来讲就是编译灾难。高居首位的是KSIMSDK
中的一个混编文件,其中大部分逻辑是拿c++写的。包括MMKV里面也有一些编译耗时比较久的文件。
像是KSIMSDK
这种我们工程中用到的又不常变更的可以将其编成静态库引入,对于MMKV
这种通过pod引入的则可通过pod-binary方案将编成framework(也就是下一步做的)。
效果:初次编译时间从160s减到了140s。
pod-binary优化编译速度的原理在第二章节预研的时候讲过了,故这里只讲用法。
此方案要求pod库必须是以framework方式进行集成,即要开启use_framework!。可选配置如下:
配置 | 备注 |
---|---|
all_binary! | 加入后默认全部pod都编为二进制,可配合binary=>false/true来使用 |
keep_source_code_for_prebuilt_frameworks! | 编译完成后保留源码 |
enable_bitcode_for_prebuilt_frameworks! | 开启Bitcode |
pod默认的集成方案为static+library,开启了use_framework!后集成方案变为了dynamic+framework,非常不灵活,因此配合cocoapods-packing-cubes插件一起使用。
配置 | 备注 |
---|---|
static+library | 集成方式为.a静态库 |
static+framework | 集成方式为.framework静态库 |
dynamic+framework | 集成方式为.framework动态库 |
sudo gem install cocoapods-binarysudo gem install cocoapods-packing-cubes
效果:初次编译时间从140s减到了90s。
PCH文件是一个标准的预编译头文件( Pre-Compiled Header),其文件里的内容能被项目中的其他文件访问。
我们把一些全局的宏定义放到pch内,由于直播模块是通过pod引入的,所以使用pch需要在podspec中相应去设置:
#podspecs.prefix_header_file = 'Classes/PrefixHeader.pch'
// pch
#ifdef __OBJC__
#import "MLLSLog.h"#import "MLiveSDKMacro.h"#endif
llvm支持修改编译参数来查看编译各个阶段的耗时,包含-ftime-report与-ftime-trace。前者是打印出一堆格式化数据,而后者则是生成火焰图,相对来讲比较直观一些。
从火焰图中可以看出编译前端中对头文件的处理最为耗时,大概率是头文件的嵌套引用较为复杂。可以考虑优化topN的头文件引用。
减少头文件中无用类的引入,改为前向声明。可以使用IWYU(include-what-you-use)来做,它的主要功能是去分析头文件中的每个include是否必要,然后将不必要的引用替换掉从而提升编译速度。
这里通过yangyang大神提供的工具分析了一下头文件被引用次数及总处理时间,根据表格选取了我们工程中的top10的文件进行了头文件的引用优化。
由于直播模块只是优化了top10便效果很明显了,所以没有进一步用IWYU去处理。
PS:关于火焰图以及IWYU等工具的使用可以参考yangyang大神的文章,这里就不班门弄斧介绍了(https://cloud.tencent.com/developer/article/1564372)。
效果:初次编译时间从90s减到了40s。
经过以上的优化措施,直播工程的初次全量编译时间从最初的近400s减少到了现在的40s;同时少量修改后的增量编译时间只需要秒级,几乎达到了热重载的调试效果,较大程度地提升了组内同事们的研发效率。
在XCode9编译存在一个bug,pch会在无任何改动时触发重新编译,由此导致所有依赖pch的文件都会重新编译,产生预期外的全量编译。ccache主要是为解决此bug应运而生的方案,但随着XCode10解决了pch编译的bug后此方案便被废弃。
同时ccache会导致无缓存时首次编译时间几乎翻倍增加,故没有采用此方案。
distcc的原理是把一部分需要编译的文件发送到服务器上,服务器编译完成后把编译产物传回来。但是分派任务的效率较低,分派+回传的过程耗费的时间经常会超过本地编译的时间,也没有采用。
直播SDK的二进制方案选择了cocoapods-packager进行打包。
pod package QMLiveCombineModule.podspec \ --exclude-deps \ --spec-sources=xxx,https://github.com/CocoaPods/Specs.git \ --no-mangle \ --force
安装后可直接通过以上命令来打包,提供的打包参数不逐一解释了,这里只解释两个比较难理解的参数:
--exclude-deps
:不包含依赖的符号表,这里分两种情况使用:a).如果是静态库的话要使用此命令,否则外部引入被依赖库的话会报duplicatesymbol。b).如果是动态库的话则不要加此命令,动态库一定需要包含依赖的符号表。--no-mangle
:表示不使用name mangling技术,pod package默认是使用这个技术的。此命令会将我们的符号改为Pod#{pod_name}_#{symbol}这种格式。如果我们使用了--exclude-deps命令,那么我们依赖的其他库则需要在主端引入,如果此时开启了name mangling则会导致打的包报错undefine symbol。所以这两条命令是配合使用的,打成包含其他依赖的静态库的时候一般会同时使用这两行命令。
pod package在打包时会为打包工程分配一个沙盒路径。因此将被打包的工程与podspec放在同一目录下,再通过source_files根据相对路径引入是不会生效的。它实际是会读取podspec中的source并去拉取远端代码到沙盒路径后再引入的。
因为之前直播SDK是通过pod拉取git branch引入主端的,所以只需将podspec中的source也改为拉branch,branch通过变量传入即可将打包流程脚本化。
打静态库时将需要修改的工程配置写在podspec
的pod_target_xcconfig
中。
'EXCLUDED_ARCHS__EFFECTIVE_PLATFORM_SUFFIX_simulator__NATIVE_ARCH_64_BIT_x86_64' => 'arm64 arm64e armv7 armv7s armv6 armv8','EXCLUDED_ARCHS' => '$(inherited) $(EXCLUDED_ARCHS__EFFECTIVE_PLATFORM_SUFFIX_$(EFFECTIVE_PLATFORM_SUFFIX)__NATIVE_ARCH_64_BIT_$(NATIVE_ARCH_64_BIT))',
将pod源码打包成静态库后静态库本身再集成到pod引入到主工程中。
改为静态库引入后之前主端引入多处报错,主要原因是之前通过"MLiveRoom.h"这种形式是不规范的,改为静态库后会导致工程索引不到,改为<QMLiveCombineModule/MLiveRoom.h>即可。
这里由于主端引入较多,逐一修改工作量较大,因此通过脚本来自动化此过程。思路是递归搜索直播SDK包含的头文件并记录下来存为数组Arr,再递归遍历主工程文件中引用了Arr中的行,然后规范为正确的格式。
将打包流程跑通后部署到蓝盾上做自动化。
sudo gem install -n /usr/local/bin --source http://mirrors.tencent.com/rubygems/ cocoapods-packager -v 1.5.0
可通过切源或直接固化构建机ip来解决此问题。
直播SDK静态库引入后,以Generic时间统计,Q音编译时长从>2000s减少到1000~1200s。
之前如果在灰度期间,改bug并回归验证的步骤是:
整套流程下来至少1个半小时,尤其是在灰度以及发版前会阻塞所有相关负责人时间,太痛了!!
因此想专门固化一条蓝盾流水线来缓存编译产物,增量编译来提高出包速度。先说说方案的可行性:
既然增量编译的方案可行,接下来就可以编写脚本了。主要是以下几点:
将以上脚本部署到固化ip的流水上,增量编译后Q音的构建时间从之前的近50min减少到了4min30s。
直播模块由于需要使用一些特性,所以限制了系统最低版本为iOS11,而11支持的最低机型是iphone 5s,这是第一部arm64机。同时固化流水出的包本来也只是给测试同学验证而不做上架,所以选择只编arm64架构的包。
做完这步后,打包时间又从4min30s减少到了3min30s。
通过增量编译使得真正的打包时间可以从>50min减少到3min,目前此固化ip的流水已经在灰度期间真正投入使用,流水线空闲期间同事们日常出包也开始使用此流水,后续考虑申请固化多几条流水供大家使用。
蓝盾上固化ip的流水不执行会定期回收,因此加了个定时触发的逻辑,半夜空闲时偷偷执行,同时判断是定时执行触发时主动去拉取Q音主端的变更,虽然不必要但由此也可保证每天至少同步一次Q音主端的逻辑。
对于编译优化来讲,通过实践得出的几点建议:
对于二进制方案来讲,没有真正意义的优劣之分,关键是使用场景。例如普通的工程打包用XCode脚手架+打包脚本即可应对;针对单个复杂一点的pod库打包可使用cocoapod-packager来打包;对于整个项目所有的pod的二进制方案则可选用cocoapod-binary,灵活起见可配合cocoapod-packaging-cubes来使用。
在探索过程中发现cocoapods还是有不少好用的插件的,同时也支持我们自定义插件;除了以上实际用到的再推荐一款cocoapods-open。
后续还要进一步做好项目的模块化,逐步做到只编我需要的部分。
QQ音乐招聘Android/ios客户端开发,点击左下方“查看原文”投递简历~
也可将简历发送至邮箱:tmezp@tencent.com