写点什么

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

作者:百度Geek说
  • 2023-07-18
    上海
  • 本文字数:7138 字

    阅读完需:约 23 分钟

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

一、前言

百度 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 文件详解

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 模块我们可以分析出来每个类对应方法的大小。


三、代码优化

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 位标识当前类在运行时中是否被初始化过,参考源码路径:


Valuesfor// 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"
复制代码

四、总结

代码优化同样也是包体积优化的重头戏,但跟图片和资源优化相比较,代码修改影响范围大,再加上 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安全加固问题与分析


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


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

发布于: 刚刚阅读数: 4
用户头像

百度Geek说

关注

百度官方技术账号 2021-01-22 加入

关注我们,带你了解更多百度技术干货。

评论

发布
暂无评论
百度 APP iOS 端包体积 50M 优化实践 (四) 代码优化_ios_百度Geek说_InfoQ写作社区