查看原文
其他

百度APP iOS端包体积50M优化实践(四)代码优化

百度Geek说 2023-12-27

The following article is from 百度App技术 Author RichardYang


一、前言

GEEK TALK

百度APP iOS端包体积优化系列文章的前三篇重点介绍了包体积优化整体方案、图片优化和资源优化,图片优化是从无用图片、Asset Catalog和HEIC格式三个角度做深度优化,资源优化包括大资源优化、无用配置文件和重复资源优化,本文重点介绍代码优化,在百度APP实践中,代码优化包括无用类优化、无用模块瘦身、无用方法瘦身、精简重复代码、工具类瘦身和AB实验固化。在代码优化过程,需要分析Mach-O和Link Map,在前面的文章我们已经针对Mach-O文件做过了分析,本文先介绍Link Map文件,然后再详细介绍代码优化方案。

百度APP iOS端包体积优化实践系列文章回顾: 

《百度APP iOS端包体积50M优化实践(一)总览》

《百度APP iOS端包体积50M优化实践(二) 图片优化》

《百度APP iOS端包体积50M优化实践(三) 资源优化》


二、Link Map文件详解

GEEK TALK

2.1 简介

Link Map 是 Mach-O 格式的二进制文件的一种辅助文件,它描述了可执行文件的全貌,包括编译后的每一个目标文件的信息以及它们在可执行文件中的代码段、数据段存储详情。通过Link Map文件,我们可以知道可执行文件的路径、CPU架构、目标文件、符号等信息,分析可执行文件中哪个类或库占用比较大,进行安装包瘦身,此外,我们可以清楚地了解可执行文件的内部结构和各个目标文件在其中的位置关系,这对于分析和调试非常有帮助。

2.2 生成linkMap文件

Xcode -> Project -> Build Settings -> Write Link Map File选项值设为yes,Path to Link Map File设置为指定好的LinkMap文件存储位置。

2.3 LinkMap文件结构解析

2.3.1 基础信息

# Path: /Users/richard/Desktop/demo/DerivedData/demo/Build/Products/Debug-iphoneos/demo.app/demo# Arch: arm64
Path是可执行文件的路径,Arch是架构类型。

2.3.2 Object文件列表

Object文件列表列出了所有编译后的目标文件,包括.o文件和dylib库。每个目标文件都有一个对应的编号,上图第一列就是,通过该编号可以对应到具体的类,在后面的Symbols部分,还会用到此编号。

2.3.2 Section段表

Section段表描述了各个段在最终编译成的可执行文件中的偏移位置及大小,包括代码段(TEXT)和数据段(DATA)。段表中第一列是数据在文件的偏移位置,第二列是Section占用大小,第三列是Segment类型,第四列是Section类型,关于Segment和Section,在前面文章对于Mach-O详解做过介绍,这儿不再赘述。

2.3.4 Symbols

Symbols模块给出了类里面的方法在内存具体情况。其中
  • 第一列是方法起始地址,通过这个地址我们可以查上面的段表;

  • 第二列是大小,通过这个可以算出方法占用的大小;

  • 第三列是归属的类(.o),值是具体编号,通过反查目标文件列表可以知道对应的类;

  • 第四列是方法名称。

通过Symbols模块我们可以分析出来每个类对应方法的大小。


三、代码优化

GEEK TALK

3.1 无用类瘦身

3.1.1 静态检测获取无用类

方案介绍

所谓的静态检测,就是分析linkmap文件和Mach-o文件,Mach-o文件中__DATA __objc_classlist段记录了所有类的地址,__DATA __objc_classrefs段记录了引用类的地址,取差集可以得到未使用的类的地址,然后进行符号化,就可以得到未被引用的类信息。
  • 第一、获取所有类地址,命令:otool -v -s __DATA __objc_classlist。

otool -v -s __DATA __objc_classlist /Users/ycx/Desktop/demo.app/demoContents of (__DATA,__objc_classlist) section0000000100008238  00009980 00000001 000099d0 000000010000000100008248  00009a48 00000001 00009a98 000000010000000100008258  00009ac0 00000001 00009b38 00000001
  • 第二、获取引用类的地址,命令:otool -v -s __DATA __objc_classrefs。

otool -v -s __DATA __objc_classrefs /Users/yangchengxu/Desktop/demo.app/demoContents of (__DATA,__objc_classrefs) section000000010000990000000000 00000000 00000000 00000000000000010000991000000000 00000000 000099d0 00000001000000010000992000000000 00000000 00000000 00000000
  • 第三、取差集,所有类的地址减去引用类的地址,拿到的就是未使用类的地址信息。

  • 第四、符号化,遍历Linkmap和Mach-O文件可获取地址信息对应的具体类名,建立类和地址的映射关系,通过地址反解析出类名。

优缺点

优点:检测方式简单易行。
缺点:
  • 对于存在引用关系但根本不会被调用的类,是无法被判断为无用类的。随着版本迭代,新老员工工作交接,很多功能的入口已经不存在了,相关的类也根本不会被调用,但是引用关系仍然保留。通过静态检测的方式,无法检测出这种情况。

  • 静态检测无法适用于通过反射调用类及方法的场景。因为静态检测无法感知运行时的环境,无法预测哪些类或方法会被反射调用。因此,在这种情况下,静态检测将无法准确地检测出无用类或无用的方法。

3.1.2 动态检测获取无用类

方案介绍

我们知道OC类结构有个isa指针,指向该类的原类meta-class,通过阅读objc源代码,我们发现在meta-class的class_rw_t结构体中的一个flag标志位,flags 的1<<29位标识当前类在运行时中是否被初始化过,参考源码路径:
// Values for class_rw_t->flags// These are not emitted by the compiler and are never used in class_ro_t. // Their presence should be considered in future ABI versions.// class_t->data is class_rw_t, not class_ro_t#define RW_REALIZED (1<<31)// class is unresolved future class#define RW_FUTURE (1<<30)// class is initialized#define RW_INITIALIZED (1<<29)// class is initializing#define RW_INITIALIZING (1<<28)// class_rw_t->ro is heap copy of class_ro_t#define RW_COPIED_RO (1<<27)// class allocated but not yet registered#define RW_CONSTRUCTING (1<<26)// class allocated and registered#define RW_CONSTRUCTED (1<<25)// available for use; was RW_FINALIZE_ON_MAIN_THREAD// #define RW_24 (1<<24)// class +load has been called#define RW_LOADED (1<<23)
由此,检测类是否被初始化的方法如下所示:
#define W_INITIALIZED (1<<29)bool isinitialized() { return getMeta() -›data()-›flags & W_INITIALIZED;}
优缺点
优点:
  • 对于业务线代码没有侵入性;
  • 没有性能损耗;
  • 可以针对线上实际运行环境做检测;
缺点:
仅支持OC,无法覆盖Swift和C、C++。

3.1.3 手百采用的技术方案

通过动态和静态两种方式结合提高准确度。详细方案我们后面会有文章重点介绍。

3.2 无用模块瘦身

没有任何依赖关系即不会被其他库引用的无用模块,比较容易识别,还有一种类型的无用模块是需要关注的,它们虽然还有代码引用关系,但从逻辑上已经不再被使用。例如一些过时的活动代码(如北京冬奥会等)、业务改版后的老代码以及被废弃使用的开源库等。随着版本的迭代,这类无用模块的数量会逐渐增加。
在百度APP包体积优化实践中,我们采用无用类占比这个指标来快速识别不再被使用的模块。具体来说,这个指标的计算方式是统计模块中所有无用类的数量,然后将其除以模块中总类的数量,再乘以100%。如果这个指标的值比较高,就说明该模块中有较多的无用代码,需要进行优化和清理。
经过前面的无用类瘦身环节,我们已经知道了百度APP中所有的无用类,从而可以从LinkMap文件中获取每个模块包含的具体类和数量,并计算出每个组件中无用类占比。如果一个库的占比为100%,就说明这个库已经完全不再被使用,可以直接下线。对于占比超过90%的库,可以进行适当关停并转,将无用类删除,仅保留有用的几个类并将其迁移到其他库中,从而降低组件数量。
通过遍历LinkMap文件中的Object files字段,可以获取每个组件包含的具体类。你可以参考以下的脚本代码来实现这个功能:
def find_class(base_link_map_file): link_map_file = open(base_link_map_file, 'rb') reach_files = 0 reach_sections = 0 reach_symbols = 0 files_map = {} while 1: line = link_map_file.readline() line = line.decode('utf-8', errors='ignore') if not line: break if line.startswith("#"): if line.startswith("# Object files:"): reach_files = 1 if line.startswith("# Sections"): reach_sections = 1 if line.startswith("# Symbols"): reach_symbols = 1 else: if reach_files == 1 and reach_sections == 0 and reach_symbols == 0: index = line.find("]") if index != -1: tmpfile = line[index + 2:-1] file = tmpfile.split("/")[-1] frameworkIndex = file.find("(") if frameworkIndex!= -1: frameworkName = file[0: frameworkIndex] className = file[frameworkIndex + 1:len(file)-1] if files_map: if frameworkName in files_map: files_map[frameworkName] = files_map[frameworkName] + " , " + className else: files_map[frameworkName] = className else: files_map[frameworkName] = className link_map_file.close() return files_map

3.3 无用方法瘦身

关于无用方法检查,业内常用的方法是结合Mach-O和LinkMap文件,分析二者结构来获取无用方法。首先Mach-O中的__objc_selrefs代表所有引用到的方法集合,Mach-O中__objc_classlist代表Objective-C 类列表,然后拆解其结构获取其中BaseMethods、InstanceMethods以及ClassMethods中的数据,作为所有方法的集合,最后和第一步获取的引用方法做差值从而得到无用方法,为了获取每个无用方法的包体积收益,还需要结合linkmap文件做分析。这是目前为止看到可行方案,但是该方案存在的问题是准确率不高,实测不超过40%,这也是某些大厂放弃无用方法瘦身的原因。
百度在这方面做了一些技术创新,从编译角度解决这个业界难题,简单来说,首先编写LLVM插件获取静态编译阶段所有方法,然后获取方法调用关系,做diff可初步获取无用方法,然后排除如下特殊case:分类方法识别异常、继承链子类调用父类方法、实现系统类协议方法、协议继承链方法识别问题、硬编码调用问题、反射调用问题、通知调用方法识别为无用方法问题,详细方案我们后面会有文章重点介绍。

3.4 精简重复代码

在软件开发过程中,尤其是不同部门多人开发项目,存在复制粘贴的代码,还有一些特殊情况,如项目重构时,为了不影响已有逻辑,程序员会复制一份老代码然后在此基础上重新开发,此时重复代码更有可能大量出现,随着版本迭代上述情况会愈演愈烈导致重复代码越来越多,因此,不论是减少包体积,还是降低历史包袱,精简重复代码非常有必要。
在百度APP包体积优化方案中,我们采用了开源工具PMD来扫描重复代码,然后再结合实际情况来从逻辑上重构这些代码达到精简的目的,PMD是一个开源工具,官网地址:https://pmd.github.io/,简单易用,通过静态分析可获知代码错误,在不运行程序的情况下报告错误,其中PMD 附带的CPD工具可以直接检测重复代码,支持Java, C, C++, C#, Groovy, PHP, Ruby, Fortran, JavaScript, PLSQL, Objective C, Matlab, Python, Go, Swift语言,并且检测规则可以自由定制,使用方法参考:https://pmd.sourceforge.io/pmd-5.5.1/usage/cpd-usage.html
用brew 命令安装
brew install pmd
用如下命令做重复代码检测
//其中,--files 用于指定文件目录,--minimum-tokens 用于设置最小重复代码阈值,--format 用于指定输出文件格式,支持 xml/csv/txt 等格式,这里建议使用 xml,方便查看 //生成的 XML 文件内容如下,根据 file 标签信息就能定位到重复代码位置。pmd cpd --files 扫描文件目录 --minimum-tokens 70 --language objectivec --encoding UTF-8 --format xml > repeat.xml
检测结果如下所示,其中duplication标签中的lines表示重复内容的行数,file标签表示从那一行开始重复及具体重复文件路径,codefragment标签表示重复的代码。
<pmd-cpd> <duplication lines="16" tokens="162"> <file begintoken="16933" column="33" endcolumn="4" endline="28" endtoken="17094" line="13" path="path1"> <file begintoken="23979" column="47" endcolumn="4" endline="26" endtoken="24140" line="11" path="path2" /> <codefragment> *************************** </codefragment> </duplication></pmd-cpd>

3.5 工具方法瘦身

日常开发工程中我们都要使用各种工具方法,常用的实现方式有如下两种
  • 实现系统类的Category,如NSDate、UIImage、NSArray、NSDictionary的分类方法实现;
  • 独立封装;

App在初始开发阶段都有commonTools模块,用来存放各种工具方法,但是随着版本迭代和人员变动,业务也越来越复杂,新来的同学不知道底层模块已经实现了类似方法,为了开发方便会在自己的模块再集成一套,这样导致的结果是工具方法重复建设,此模块瘦身主要目的就是挖掘重复工具方法并优化,百度APP实践过程中主要从以下两个角度入手。
  • 遍历LinkMap文件,挖掘出重复的Category,参考以下的脚本代码来实现此功能:
def get_files_map(base_link_map_file): link_map_file = open(base_link_map_file, 'rb') reach_files = 0 reach_sections = 0 reach_symbols = 0 files_map = {} while 1: line = link_map_file.readline() line = line.decode('utf-8', errors='ignore') if not line: break if line.startswith("#"): if line.startswith("# Object files:"): reach_files = 1 if line.startswith("# Sections"): reach_sections = 1 if line.startswith("# Symbols"): reach_symbols = 1 else: if reach_files == 1 and reach_sections == 0 and reach_symbols == 0: # files index = line.find("]") if index != -1: symbol = {"file": line[index + 2:-1]} key = int(line[1: index]) files_map[key] = symbol pass link_map_file.close() return files_map
  • 对于非Category的工具方法,进行排查和合并,最终下沉到统一工具库里面。

3.6 AB实验固话

在APP开发过程中,为了更加有效地验证新开发功能的实际效果,我们会进行AB实验,通常会将实验组和对照组分开,并在实验组中进行某种操作,而在对照组中不进行该操作,我们会观察这个操作对实验变量的影响,以确定该操作是否对实验结果产生显著影响。
像百度APP这种日活过亿的应用,每个版本会有10个左右AB实验,一年有240个AB实验,随着长时间的版本迭代,会积累大量AB实验代码,但实际上只有一个分支的代码是线上生效的,另一个分支代码是不会被执行的,所以推进AB实验固化,去除无效分支的代码可以实现减少包体积的目的。
百度APP推进AB实验固化分为三个步骤,第一 、从AB实验平台获取已经固化的开关;第二、开发工具判断此实验对应的开关是否在代码中存在;第三、分发给负责的开发同学固化AB实验,删除不用的代码。
其中第二步的实现非常关键,就是判断一个开关是否仍有对应的代码逻辑,百度APP采用的方案是获取所有可能使用开关的字符串集合,然后判断第一步拿到的开关是否在集合中,若在说明该开关的对应的实验需要做固化操作。
在Objective-C的.h和.m文件中,我们经常用如下代码来定义一个AB开关,然后再后续代码中引用。
#define kFaceverifyResourceOptimizeABTestKey @"face_verify_resource_optimize_enable"
针对Objective-C的.h和.m的文件内容,用正则过滤,匹配表达式为 @"(.*?)",即可获取所有可能加载开关的字符串集合。
同样道理,在Swift文件我们通常通过如下代码来定义一个AB开关,然后再后续代码中引用,加载方式完全一样,针对Swift这种文件,正则表达式应为"(.?)"。
static let verifyResourceOptimizeABTestKey: String = "face_verify_resource_optimize_enable"


四、总结

GEEK TALK

代码优化同样也是包体积优化的重头戏,但跟图片和资源优化相比较,代码修改影响范围大,再加上OC语言动态调用方式多种多样,这导致代码的删除操作更容易引起质量问题,所以优化收益落地难度比较大。百度APP在优化实践过程中挖掘出20M的收益,经过两个季度仅落地8M左右,剩余部分还需要继续推动。
本文首先对LinkMap文件格式做了详细介绍,然后对百度APP代码优化方案(无用类优化、无用模块瘦身、无用方法瘦身、精简重复代码、工具类瘦身和AB实验固化)做了系统阐释,后续我们会针对其他优化详细介绍其原理与实现,敬请期待。

 END

参考资料:

[1]、PMD介绍:https://pmd.github.io/
[2]、PMD CPD使用方法:https://pmd.sourceforge.io/pmd-5.5.1/usage/cpd-usage.html
[3]、XNU源码:https://github.com/apple/darwin-xnu
[4]、objc源码:https://github.com/apple-oss-distributions/objc4/tags


推荐阅读:

百度iOS端长连接组件建设及应用实践

百度App启动性能优化实践篇

扫光动效在移动端应用实践

Android SDK安全加固问题与分析

搜索语义模型的大规模量化实践

如何设计一个高效的分布式日志服务平台



一键三连,好运连连,bug不见👇
继续滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存