目
录
1.背景
2.关键技术实现
3.工具的使用
4.总结
背景
随着移动App不断的发展, 从以往粗放式叠加需求的模式,已经转换为如何利用技术手段去治理APP的各项指标的新模式。包大小也是衡量APP的一项目重要指标,其直接影响着APP下载转化率。本文通过58同城包大小治理的实践经验,来讲解如何解决混编环境下OC/Swift无用类、无用资源、重复资源等检测问题,同时结合业内常见的段迁移、链接时优化(LTO)等多种技术手段,来辅助App进行瘦身。
1.1 包大小治理的痛点
包大小治理涉及无用代码、无用资源的检测、优化等诸多工具,这些工具分散难以开箱即用,使用成本高;
市面上开源工具对OC/Swift混编架构支持不足,特别是无用代码检测在混编架构下不精准;
要全面治理APP中代码文件,需要对Mach-O有比较深入的理解,而Mach-O的学习成本较高;
1.2 技术特点
本工具只需要拖入Debug下的构建产物(.app),就能一键获取包瘦身各种优化建议。本工具通过读取Mach-O可执行文件中__Text、__DATA、符号表中等数据,来获取Swift类型结构、类名字符串、函数调用区间等辅助信息, 并通过反汇编提取__TEXT/__text中的指令,来执行一些重要的查询操作,如Swift中AccessFunction地址的查找。目前本工具支持以下检测项:
关键技术实现
2.1 包大小检测
.app包中主要包含二进制文件、Assets.car资源文件、nib等其它类型的资源文件等组成部分。
二进制文件包括主二进制、PlugIns中的二进制,还有Frameworks中的动态库文件,占整个APP大部分体积。对这些二进制文件,剥离架构后统计单架构文件大小即是二进制大小。二进制检测的项目主要有OC/Swift无用类、段迁移、动态库的符号表剥离,也是主要的优化方向,除此以外,还有链接时优化(LTO)。
资源文件中主要检测Assets.car、.car外的图片、其它资源文件。对于Assets.car文件需要分片检测其大小,我们主要统计3x下的大小。对于.car外的图片,如果有1x/2x/3x图片存在,将其放入.xcassets中管理也有一定的优化空间。对于其它资源,我们主要检测在APP中是否使用,以及哪些图片是重复的。
所有的检测结果我们在诊断视图上分项列出,并提供链接文件的形式保存到指定目录,研发人员可以查看更详细的结果数据。
2.2 无用类检测
在APP经过长时间业务迭代后,或多或少都会有一些业务下线,为了使APP能持续瘦身状态,就需要有业务检测能力,而无用类检测通常是最直接的方式。现在APP主要分为纯OC环境、纯Swift环境和OC/Swift混编环境,并且OC/Swift混编环境占多数。
对于纯OC环境,由于OC语言已经很成熟,结构相对较简单,无用类检测方案也相对简单些。而对于纯Swift或OC/Swift混编环境的无用类检测,由于Swift语言本身稳定的时间并不长,并且其结构相对OC来说复杂很多,OC中的检测方案在Swift中并不适用。因此,我们需要寻求一种能适用于OC/Swift混编环境的无用类检测方案。
2.2.1 OC无用类检测
对于OC无用类的检测,业内比较流行的方案是classlist段和classrefs做差集,其中classlist是所有OC和Swift类的全集,而classrefs是引用类的集合,如下图所示。这种方案能粗略地统计出OC无用类,但也存在一些不足。一是动态调用类识别不出来;二是外部没有调用,但是+load中需要执行一些操作,如hook方法,这些类识别不出来。因此,需要对classrefs集合做进一步扩展。
检测动态调用
动态调用通常有两种,一是完整的类名字符串生成class调用,二是通过拼接类名或者通过后台下发类名来生成class调用。对于后台过于复杂,不在我们的讨论范围内,这里主要讨论前者如何检测。对于通过类名生成的class,通常OC中的字符串会记录到__DATA,__cfstring段中,因此,通过遍历__cfstring中的字符串,能找到所有动态调用的类名,并将这些字符串添加到classrefs集合中。
检测没有调用关系但实现了load方法的类
而对于外部没有直接的调用关系,但是实现了+load方法的类或类别,通常也是需要用的类。遍历__DATA,__objc_nlclslist段中的类,这些类是实现了+load的类,将其添加到classrefs集合中,再遍历__DATA,__objc_nlcatlist段中的类别,这些类别中实现了+load方法,将其添加到classrefs集合中。
扩展后再做差集
classrefs通过以上扩展后,再通过classlist和classrefs做差集,得到的OC无用类检测结果更为准确。
2.2.2 混编环境下Swift无用类检测
相比纯OC项目, 换边情况下,OC调用Swift类改如何检测呢?这要从Swift Class 定义说起,让我们看在runtime源码中Swift Class的定义方式:
struct swift_class_t : objc_class {
uint32_t flags;
uint32_t instanceAddressOffset;
uint32_t instanceSize;
uint16_t instanceAlignMask;
uint16_t reserved;
uint32_t classSize;
uint32_t classAddressOffset;
void *description;
// ...
void *baseAddress() {
return (void *)((uint8_t *)this - classAddressOffset);
}
};
通过上面发现, swift_class_t是继承自OC类模型swift_class_t的, 所以混编环境中的Swift Class的特性与OC中Class特性一致, 直接用2.2.1中的检测方案即可。
2.2.3 Swift无用类检测
相比OC,Swift无用类的检测就更复杂,首先是Swift的类结构,如下所示,与OC差得比较大,再一个是classrefs段中并没有保存Swift的引用类。经调试会发现,外部在调用Swift类中函数之前,会先调用该类的AccessFunction函数获取该类的MetadataClass地址,也就是说,外部访问Swift类的方法时,首先需要访问AccessFunction地址。
struct SwiftClassType {
uint32_t Flag;
uint32_t Parent;
int32_t Name;
int32_t AccessFunction;
int32_t FieldDescriptor;
int32_t SuperclassType;
uint32_t MetadataNegativeSizeInWords;
uint32_t MetadataPositiveSizeInWords;
uint32_t NumImmediateMembers;
uint32_t NumFields;
uint32_t FieldOffsetVectorOffset;
uint32_t Offset;
uint32_t NumMethods;
// VTableList等变长字段
};
通过这一思路,我们可以通过遍历APP中各类的方法实现中的汇编指令,遍历bl跳转指令,如果后面的返回地址为某个Swift类的AccessFunction地址,则该Swift类标识为有用类,并添加到classrefs集合中。为了对APP中所有自定义Swift类的检测,我们需要借助符号表,因此,对于Swift无用类的检测,需要用到debug下的.app包。整体实现方案是先要遍历出所有Swift类的AccessFunction集合,再遍历符号表,对每个函数的起始地址进行排序,定位出每个函数的开始和结束地址,然后通过对函数实现进行反汇编得到函数的汇编指令,最后通过每个函数的起始和结束位置,结合函数的汇编指令,查找函数范围内的bl指令地址,和前面AccessFunction集合进行匹配,能匹配上的并且当前函数不是AccessFunction所在的Swift类,即可将该Swift类加入到classrefs集合中。
读取所有Swift类的AccessFunction集合
要读取所有Swift类的AccessFunction集合,需要从__TEXT,__swift5_types进行遍历,如下所示。
swift5_types中存储的是SwiftClassType结构的偏移地址,其加上__swift5_types的起始地址后,是指向__TEXT,__const段,____const段存储的是SwiftClassType结构体数据。遍历__const段中SwiftClassType结构体数据可得到AccessFunction集合,具体根据需要可以存储为<Swift类名,AccessFunction地址>的key-value类型的结构,具体实现参考WBBlades的开源代码。
定位函数的起始和结束地址
有一个前提,这里要检测的Mach-O文件必须是带有符号表的。首先我们要读取Symbol Table段符号表数据,把其中每个函数的符号和起始地址解析出来,如下图所示。
其存储的数据结构如下,通过Load Commands/LC_SYMTAB中string table offset的偏移+nlist.n_un.n_strx地址即可在字符串表中读取到当前函数的symbol字符串,而nlist.n_value即为当前符号的函数起始地址begin,将symbol字符串和起始地址保存到数据结构<symbol字符串,begin>中。
struct nlist_64 {
union {
uint32_t n_strx; /* index into the string table */
} n_un;
uint8_t n_type; /* type flag, see below */
uint8_t n_sect; /* section number or NO_SECT */
uint16_t n_desc; /* see <mach-o/stab.h> */
uint64_t n_value; /* value of this symbol (or stab offset) */
};
再对begin地址进行从小到大排序,将下一个符号的begin地址记为当前符号的结束地址end。
函数实现中查找AccessFunction地址
对于每个函数定位了起始和结束位置后,我们就可以在函数汇编指令中进行AccessFunction的查找了。为了便于操作,首先我们需要对二进制进行反汇编得到函实现的汇编指令,这里我们引入了三方开源代码Capstone。
(1)方案一
开始我们的查找方案是通过对Swift类进行遍历,对每个Swift的AccessFunction地址,去遍历一遍符号表,通过符号的起始和结束指令,对当前函数指令范围再进行遍历,判断其中bl地址是否命中前面的AccessFunction地址,如果命中,则说明AccessFunction地址对应的Swift类是有用的。但是通过58同城Mach-O二进制文件进行测试,出现了性能问题,内存峰值太高,达到6G+,并且遍历时间过长,整个检测过程经历了1.5小时,如下图所示。
查看同城APP中汇编指令数4200W+条,符号表中符号数达400W+,Swift类2000+个,整个AccessFunction地址查找过程时间复杂度为107x106x103=1016。其中107为汇编指令的量级,106为符号表量级,10^3为Swift类量级,这个数量级太大了,故执行时间很漫长,这个执行时间对于用户来说,是不可接受的。因此,有必要对汇编指令及AccessFunction地址的策略做些调整。
(2)方案二
经过分析,汇编指令读取这块可以在量上做一个优化,并且AccessFunction地址查找方式也可以做一个优化。汇编指令读取优化策略为只读取bl指令,保存到汇编指令数组中,因为我们要分析的只有bl指令,这个数量级为600W+。AccessFunction地址查找方式调整为遍历符号表,结合bl指令数组找出当前函数范围内的bl指令,再通过当前bl指令后的地址去AccessFunction列表中反查Swift类名,如果查到即命中了某个Swift类,则说明此Swift类是有用的。
按调整后的方案,我们将指令和其对应的指令位置保存到指令数组中,如下图所示。指令位置是指在__text段中出现的顺序下标,从0开始,由于每条指令占用4个字节大小,因此,通过当前指令地址-__TEXT__text的起始地址可算出当前指令的偏移地址,再通过偏移地址/4就是指令对应的下标。同理在符号表遍历结果中begin和end也能找到其在汇编指令中的一个位置,这个位置可能是穿插在两个bl位置中间。
对前面遍历符号表的结果列表进行遍历,通过begin和end可以在指令数组中找到一个开始和结束的位置区间,如[44,123],然后在这个区间内遍历所有的bl指令,和前面的AccessFunction集合进行匹配,并将匹配上的Swift类加入到classrefs集合中。
调整后时间复杂度为106x106=1012,其中第一个106为汇编指令的量级,第二个10^6为符号表量级,相比初始方案降低了4个数量级。内存峰值1.7G,AccessFunction地址查找时间减少到36秒,内存和查寻时间都优化到了可接受的范围内,如下图所示。
(3)方案三
执行时间是否还可以优化点?细细分析,指令数组还有一定的优化空间,可以对bl指令进一步过滤,指令数组中仅保存与AccessFunction地址相匹配的bl指令。经分析,这些指令数量级为8000+,时间复杂度为103x106=109,其中103为指令数组的量级,10^6为符号表量级,内存峰值为1G,整个查找时间为20秒。相比前一方案,时间复杂度降低了3个数量级,内存和查找时间优化近一半。
(4)方案总结
总结以上方案数据如下:
方案 | 时间复杂度 | 内存峰值 | 执行时间 |
---|---|---|---|
方案一 | 10^16 | 6G | 1.5小时 |
方案二 | 10^12 | 1.7G | 36秒 |
方案三 | 10^9 | 1G | 20秒 |
至此,Swift有用类在classrefs集合中也完成了扩展。最后,通过classlist段和classrefs做差集,得出所有无用类,其中也包含了OC的无用类,详细代码实现请参考WBBlades。
2.3 无用资源检测
了解包大小的构成,除了对二进制内容进行分析以外,包内的资源也是需要进行监控的。基于对各类二进制内采集到的字符串与包内资源文件名的匹配检测,找出包内可能存在的无用资源文件。
2.3.1 二进制信息提取
为了检测包内无用资源,我们首先要做的是从二进制中提取出所有的字符串信息。
除主Mach-O文件以外,包内资源还有可能被nib、动态库使用。为了检测结果更加精确。需要将包内所有的Mach-O文件、nib文件都纳入检测范围内。
我们可以使用WBBlades工具直接对Mach-O文件字符串进行提取。而nib文件由于被特殊压缩过,所以无法直接提取。但是通过强行打开发现其中的资源文件名等字符串信息没有进行压缩,所以我们可以直接将nib文件转为字符串然后再进行分割将其提取出来。
完成字符串信息提取后,接下来我们要做的就是要对文件名进行遍历提取。
2.3.2 文件名提取
一般情况下的普通文件名的提取是最简单的,我们可以直接通过遍历bundle内所有文件获取。但是实际操作时还是会有很多特殊问题需要处理。
Asset包内文件名
首先是Asset包内文件的问题。App的包内只能够获取到Asset内文件编译后的.car压缩文件,其中的内容无法提取。所以我们必须先对Asset文件进行解压。可以利用开源工具 cartool对.car文件内容读取获得到Asset包内文件名。
图片文件名
与一般文件的引用时使用的文件名不同,图片资源在加载时有可能会有多种情况。比如一张名为“cover.png”的图片,我们既可以使用"cover"来获取图片资源,也可以使用"cover.png"来获取图片资源。
这就需要为每个文件都进行别名匹配。比如图片"cover@2x.png",需要增加别名"cover"、“cover.png”、“cover@2x.png”,
Bundle包内文件名
另外如果一个资源文件在bundle中,我们有时会以"xxx.bundle/xxx.json"的形式来直接获取使用,所以在获取文件名时,也要将文件所处的bundle信息找出,以补充在bundle内的资源文件可能使用的文件名。
模糊文件名
上述的文件名获取方式都是通过总结常用文件名引用规则而自动生成的。但是开发过程中也常常会利用一些自定的文件名规则来读取资源数据。
比如:定义了一组图标文件名为“icon1.png”~“icon10.png”,结合业务逻辑会动态使用“icon%@”、“icon%@.png”这种规则来读取资源数据。
为解决该问题,需要支持自定义别名的功能。我们可以将icon1~10配置到一个plist配置文件内,然后为这些配置自定义别名"icon%@"、“icon%@.png”,这样在无用资源检测的时候,就会通过配置文件获取到自定义的文件名,使最终无用资源文件的检测结果更加精确。
2.3.3 重复资源处理
除了对未使用的资源文件,内容相同的资源文件也算是一种无用资源。尤其是在跨团队配合的项目中,常常由于沟通不畅导致引入了不同名称但内容完全相同的重复资源文件。所以我们会将所有文件进行一次SHA值计算,然后将相同SHA值的文件检测出来,作为无用资源文件检测结果的补充。
2.4 其它检测
2.4.1 段迁移检测
iOS 13 以下的用户,若APP的下载大小超过200M限制,将无法使用蜂窝网络下载 App,会收到文件容量太大的提示,需通过 Wi-Fi 网络下载。iOS 13 及以上的用户,需要手动设置才可以使用蜂窝网络下载 App。
APP包上传到AppStore后,苹果会对可执行文件进行加密,再发布到AppStore,这种加密会严重影响可执行文件的压缩效率,导致压缩后的.ipa 大小增加,从而下载大小增大。由于苹果只会对二进制Mach-O 文件中的__TEXT段加密,因此,理论上只要把__TEXT段中的section移到其它段,如自定义的一些segment,就能减少苹果的加密范围,使压缩效率提升,最终能减小APP的下载大小。
由于__TEXT,__text段大小是最大的,因此,如果段迁移__text段迁移的收益是最大的,如果__text段不迁移的话,仅其它段迁移包大小收益会大打折扣。因此,我们可以检测__text段是否在__TEXT中来判断是否有段迁移。
如上图,APP存在段迁移,其中__TEXT中已经没有__text段,而是移到了自定义segment __WB_TEXT。如需要段迁移,可以在Xcode->Build Settings->Other Linker Flags中进行如下配置。其中__WB_TEXT为自定义segment名,将__text和__stubs迁移到此segment下,并将__TEXT中只读的section如__objc_methname和__objc_classname迁移到只读Segment __RODATA中。
-Wl,-rename_section,__TEXT,__cstring,__RODATA,__cstring
-Wl,-rename_section,__TEXT,__objc_methname,__RODATA,__objc_methname
-Wl,-rename_section,__TEXT,__objc_classname,__RODATA,__objc_classname
-Wl,-rename_section,__TEXT,__objc_methtype,__RODATA,__objc_methtype
-Wl,-rename_section,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab
-Wl,-rename_section,__TEXT,__const,__RODATA,__const
-Wl,-rename_section,__TEXT,__text,__WB_TEXT,__text
-Wl,-rename_section,__TEXT,__stubs,__WB_TEXT,__stubs
-Wl,-segprot,__WB_TEXT,rx,rx
2.4.2 LTO
Link-Time Optimization 链接时优化(LTO),是苹果在WWDC 2016提出,在Xcode 自带的一个编译/链接参数。LTO是链接期间的程序优化,多个中间文件通过链接器合并在一起,并将它们组合为一个程序,缩减代码体积,因此链接时优化是对整个程序的分析和跨模块的优化。开启LTO后主要是能给包大小带来优化。
LTO优化方式如下:
链接器首先按照顺序读取所有目标文件(此时,是bitcode文件,仅伪装成目标文件)并收集符号信息。
接下来,链接器使用全局符号表解析符号。找到未定义的符号,替换weak符号等等。
按照解析的结果,告诉执行LTO的库文件(默认是libLTO.dylib)那些符号是需要的。紧接着,链接器调用优化器和代码生成器,返回通过合并bitcode文件并应用各种优化过程而创建的目标文件。然后,更新内部全局符号表。
链接器继续运行,直到生成可执行文件。
LTO有以下两种模式:
Full LTO是将每个单独的目标文件中的所有LLVM IR代码组合到一个大的module中,然后对其进行优化并像往常一样生成机器代码。
Thin LTO是将模块分开,但是根据需要可以从其他模块导入相关功能,并行进行优化和机器代码生成。
主工程中配置LTO为Monolithic,即LLVM_LTO = YES,如下图所示。
如果含有cocoapods子工程,需要对子工程进行LTO配置,在Podfile中配置即可,如下所示。
post_install do |installer|
installer.sandbox.target_support_files_root.glob('**/*.xcconfig').each do |xcconfig_file|
config = Xcodeproj::Config.new(xcconfig_file)
config.attributes['LLVM_LTO'] = 'YES'
config.other_linker_flags[:simple] << "-Wl,-mllvm,--enable-machine-outliner=always,-mllvm,--machine-outliner-reruns=1"
config.save_as(xcconfig_file)
end
end