写点什么

百度 APP iOS 端包体积 50M 优化实践 (六) 无用方法清理

作者:百度Geek说
  • 2023-09-21
    上海
  • 本文字数:5506 字

    阅读完需:约 18 分钟

百度APP iOS端包体积50M优化实践(六)无用方法清理

一、前言

百度 APP 包体积经过一期优化,如无用资源清理,无用类下线,Xcode 编译相关优化,体积已经有了明显的减少。但是优化后 APP 包体积在 iPhone11 上仍有 350M 的空间占用。与此同时百度 APP 作为百度的旗舰 APP,业务迭代非常多且迅速,体积优化和防劣化仍然是当前阶段的一个核心任务。因此百度 APP 开启了粒度更小,修复风险更高的无用方法清理相关工作。期望通过无用方法清理,有效降低百度 APP 的包体积,同时删除项目中的无用方法,冗余代码,提高代码的整洁度。

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

二、方案调研

针对无用方法清理,调研了各家厂商目前已公布的方案,主流方案基于 Mach-O + LinkMap 文件的分析,但是主要存在以下问题:

1.准确度低

2.针对系统方法需要手动过滤

3.针对 load、initilize、attribute 相关调用无法识别

4.针对 string 反射调用无法识别,Target-Action 注册,Observer 注册方法等无法识别

5.复杂语法场景下无法识别,如继承链中的方法调用,子类实现父类方法等场景

6.系统通知等场景


因为目前已公布方案存在如上不足,同时因为下线代码敏感度非常高,相关业务都很慎重。因此推动相关无用方法清理,识别准确度将非常重要,直接关系到相关业务下线无用代码的积极性,因此弃用了上述方案。

三、方案选择

针对第二部分方案不足之处进行分析,可以看到其准确度低的核心问题是,针对产物进行分析,拿不到所有需要的信息,或者说还没有发现有效的手段去获取所期望获得的信息。而想要解决上面提到的问题,最佳途径就是获取到尽可能多的代码信息。既然从产物回溯不到所需要的,那么就可以考虑从源头也就是源码层面找到我们所需要的详细信息。

源码肯定包含了所有的信息,但是针对源码如何分析呢,主要有以下三种:

  • 通过脚本直接分析源码

需要匹配源码的所有语法规则,才能够针对源码进行有效的分析,相当于写一个源码解析器,所以这个方案放弃

  • 通过脚本直接分析 AST(抽象语法树)

编译过程中产生的抽象语法树(AST)包含了需要的所有信息,并且 clang 也提供了命令行,使用该命令行能够直接获取到 AST 数据。但是 clang 命令获取 AST 数据是以单个类为维度的,类与类之间的关系很难获取到,如继承关系,分类和主类的关系是无法获取的,所以这个方案同样放弃

  • 通过 libtooling 和 Swift Compiler 自建编译套件分析 AST (Swift 相关会在下一篇文章中介绍)

既然通过 clang 命令生成的 AST 产物分析仍然不能满足需求,那么直接介入编译过程,从编译内部生成 AST 过程中获取需要的信息,最终这个方案被采用。通过 libtooling 和 Swift Compiler 自建编译套件针对 AST 进行分析,获取所需要的所有信息。

四、方案设计

如上所述百度 APP 最终采用了 libtooling 和 Swift Compiler 静态分析方案,那么下面就从原理和实现层面分别进行阐述。

4.1 编译流程简介

4.1.1 Xcode 编译总体结构

本节先简单聊一下编译器的结构,编译流程,和静态分析是什么?

△图 4-1

如图 4-1 所示 LLVM 采用如上三段结构(Three Phase Design),分别是编译前端(Frontend),编译优化模块,编译器后端(Backend)。那么这三段结构如何对应到 Xcode 呢,如图 4-2 所示:

△图 4-2

日常使用 Xcode 编译时,Xcode 调用了两个编译器前端,分别为 Clang 和 Swift,通过两个编译器前端构建出通用的编译产物,然后统一经过 LLVM 后端编译器进行目标文件生成。

通过 Xcode 的编译 log,可以看到针对 Objective-C,C, C++ 使用了 clang 进行编译,针对上述三种不同语言分别用不同编译参数控制:

/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang
复制代码

针对 swift 文件则采用了 swift 编译器进行了编译:

/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift-frontend
复制代码

针对这两个可执行文件大家可以自行解包 Xcode,进行命令行调用,也可以通过其 --help 指令查看其支持哪些编译参数或者功能。Xcode 内部编译器实际上是苹果对 LLVM 和 Swift 开源版本的定制化版本, 和开源版本有一定的差异性。

4.1.2 Clang 和 Swift 编译流程

如下图所示 Clang 和 Swift 前端编译流程,可以看到 Swift 编译处理流程多了 SIL 部分,实际里面还有一个 SIL Guaranteed Transformations,当然 SIL 部分不是重点。从图 4-3 中可以看到 Clang 和 Swift compiler 都会生成 AST 且发现 AST 中包含了我们需要的绝大部分信息,并且 Clang 和 Swift Compiler 也暴露了相关获取 AST 信息的接口,那么剩下的工作只有四点:

1.搭建编译套件工程,确保它正常 run 起来

2.获取 AST,并且根据 Objective-C 或者 C,C++的语法特性获取所需要的数据

3.针对获取的数据进行业务分析处理

4.开源版本 LLVM 和 Xcode 实际使用版本具有一定差异性,因此部分编译相关内容需要进行相关适配


△图 4-3

4.2 总体方案设计

针对一门程序语言的使用而言,如图 4-4 所示,包含两个层面,一个层面是声明,另一个层面是调用。声明类,协议,属性,方法,函数等等,同时声明的内容是为了被使用,所以同样声明的内容皆可调用,只不过是内部调用还是公开调用问题。从技术角度而言,声明的所有内容 减去 被调用的声明内容,剩下的就是未被调用的内容,也就是我们需要的 无用方法。当然技术层面的判别最终还是要进行业务判定,因为有的属于基础能力对外提供,至于是否要删除则需要进一步探讨。本文主要探讨技术层面问题。

△图 4-4

从 clang 源码中可以知道声明和调用分别对应 LLVM 源码中的基类 Decl 和 Expr,整体技术方案如下图 4-5 所示,针对无用方法分为处理分为四层:

1.Basic 层:组装编译工具所需的编译参数 + 进行语法规则匹配

2.Transformer 层:针对语法规则匹配数据进行转换,转换通用型数据格式

3.通用数据层:通过 Transformer 层产出的数据进行分类存储,所存储数据包含了代码的所有数据,如针对属性,方法,协议等数据均进行了分类存储

4.业务应用层:针对通用数据层产出的存储数据进行业务分析即可

△图 4-5

4.3 详细方案实现

4.3.1 Objective-C 编译工具搭建

编译工具的呈现形式是一个类似 Xcode 自带 clang 的可执行文件,如图 4-6 红框所示内容。

/Users/UserName/Documents/XcodeEdition/Xcode14.2/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang
复制代码


△图 4-6

简单来说通过源码构建的编译工具具有 Xcode clang 的部分功能,利用其编译过程中产生的 AST 对象进行抽象语法树分析,获取到所需要的编程语言的所有语法信息。

4.3.1.1 LLVM 源码构建

编译工具的搭建需要依赖 LLVM 提供的静态库或动态库,这些库通过自己构建 LLVM 源码来获得。可以从 github 获取 LLVM 源码路径,进入 LLVM github 界面后有可能会困惑需要构建哪个分支或者 tag 的代码呢,哪个版本和 Xcode 使用的 clang 是对应的?目前 Xcode 的版本是 14.2 或者 14.3 ,使用命令 clang --version 可以看到 Xcode 用到的是 clang 14,因此构建了 release/14.x(没有找到对应关系,推理得出),构建成功后执行构建的 clang --version 会发现开源版本 clang 和 Xcode 的小版本号是不一样的,这是因为 Xcode 用的 clang 苹果会基于开源代码进行定制,这从 Xcode 中 clang 的依赖库或头文件数量。另外从编译 log 也可以看到,Xcode clang 支持的部分参数,开源 clang 是不支持的。尽管苹果有一些定制,但是总体影响有限。因此也不必过于在意小版本号是否一致。(初步验证了一下构建最新的 release/16.x clang16 也可以)。

△图 4-7

具体构建命令主要分两种,一个是 Ninja 构建方式,一个是 Xcode 方式,需要 Xcode 调试源码可以选择 Xcode 模式,但是最终集成到编译工具中的静态库,一定要构建成 Release 模式,这样工具体积会降到最低,一些警告类异常也会被屏蔽掉。可以参照 LLVM 开源库中的 start guide 构建过程进行构建,其中涉及的组装命令可以自行拼接也可以用下面的命令:

构建过程git clone https://github.com/llvm/llvm-project.gitcd llvm-projectmkdir build (这个build文件夹可以自行命名,不固定。针对不同目标可以创建不同文件夹进行不同构建,如 mkdir ninjaBuild 或 mkdir xcodeBuild)cd build (or cd xcodeBuild)cmake -G "Ninja" -DCMAKE_BUILD_TYPE=Release ../llvmcmake --build .
复制代码

编译 Xcode 版本,Ninja 替换为 Xcode 即可。


4.3.1.2 工程搭建

LLVM 提供了两种工具 libclang 和 libtooling,百度 APP 采用的是 libtooling,其异同点如下所示:

  • libclang:(网络资料,未实测)

1.提供稳定的 C 接口,具有遍历语法树,获取 Token,代码补全等能力。

2.接口稳定,clang 版本更新对齐影响不大

3.libclang 不能获取到 AST 的所有信息

  • libtooling:(实测)

1.提供 C++ 接口,产出的工具不依赖于编译器,可作为独立命令使用

2.接口不稳定,AST 有升级需要更新相关依赖库

3.libtooling 可以获得 AST 的所有信息

最终选择 libtooling 形式,核心原因就是 libtooling 可以获取 AST 的所有信息,同时能够不依赖于 Xcode 独立运行。工程的搭建本身并不复杂,还是属于 API 使用层面,可以直接参照 libtooling 的官方文档。

△图 4-8

总体代码流程如图 4-8 所示,主要核心点是五个部分:

  • 参数解析

  • 创建 ClangTool 参照 LLVM 源码 ClangTooling -> Tooling.h Line309

  • 创建 ASTFrontendAction,用于获取 AST 数据,创建 ASTConsumer 和 进行 ASTMatcher 绑定

  • 针对 ASTMatcher 匹配项进行各语法规则匹配

  • 根据匹配数据进行数据过滤及业务处理


4.3.1.3 数据存储结构设计

数据存储结构采用 json 格式,以下为基础数据格式示例,可以根据实际需求拓展:

"objc(协议or类)@类名(类方法or实例方法)@方法名称":{"identifier":"objc(协议or类)@类名(类方法or实例方法)@方法名称","isInstance":true,"kind":16,"location":{"col":36,"filename":"文件名称","line":147    },"name":"方法名称","paramters":"参数","returnType":"返回值类型","sourceCode":"源码"}
复制代码


{"declaration":{"identifier":"objc(协议or类)@类名(类方法or实例方法)@方法名称","isInstance":true,"kind":16,"location":{            "col":列数,"filename":"声明所在类名",            "line":行数        },"name":"方法名称","paramters":"参数名称","returnType":"返回值类型","sourceCode":"源代码"    },"kind":1,"location":{"col":5,"filename":"当前所在文件名","line":15    }}
复制代码

五、遇到的问题及解决方案

1. 属性调用识别问题

针对 Objective-C 的属性,在编译后对应两个方法 get 和 set 一个是 ivar,调用方有可能只调用 get 或者 set 或者 ivar,所以当只发生一种调用时,就算这个属性被调用,当前属性不属于无用方法。需要在结果中把另外两个方法剥离。


2. 提取方法内容时同样需要对头文件进行提取

方法的实现不一定只在.m 文件中,如 C++的头文件是可以进行方法实现的,Objective-C 的.h 文件 通过 inline 实现一些方法,在语法上也是可行的。所以进行方法提取时候关注实现文件,同时也要关注头文件。


3. 针对继承问题

子类实现父类方法等场景,在识别方法时,全部回溯其父类,以其父类名称作为 上文数据结构中 identifier 中类名部分,这样所有的方法都可以和其声明类匹配。


4. 过滤系统方法调用

LLVM 提供了接口判断当前方法是否属于系统类。


5. 过滤业务类实现系统方法问题

针对当前类中所有的方法均在当前类 和 回溯其继承链条中的父类, 分别判断其是否属于系统方法,如果属于系统方法则直接过滤掉。


6. 针对协议方法的实现,目前还没有有效手段识别,当前方案是直接过滤掉协议方法,所有协议方法均视为已经调用

在提取方法时,判断当前 interface 遵循了哪些协议,遍历协议中的方法,判断其是否为协议方法,是则标记为已调用。


7. 子类实现父类协议问题

回溯当前类的继承链条,在继承链条中判断遍历其所遵循的协议,判断其是否为协议方法。


8. 正常业务实现协议,应该明确标注当前类遵循了协议 如 interface <conformprotocol>,但是实际场景中有很多代码在实现协议时并没有标注 conformprotocol 这样就对协议方法的判断产生影响,如 6.7 方案均失效了

如果组件中少量这种问题,当推动相关方修复此问题,需要明确遵循协议。但是如果有的组件这种场景较多,短期不会修复所有,那么就需要进行临时性适配。针对这类组件收集其当前组件所声明的协议的所有协议方法,用收集的协议方法和当前组件提取的所有声明做差集,存在误伤的可能,但结果是置信的(组件只是一个维度,也可以针对其关联组件进行相关处理,因为有时他实现的组件不一定在当前组件内,这就需要当前组件的依赖关系了)。


无用方法 case 很多,列举部分供大家参考。

六、总结

这项技术实际上在百度 APP 早已经应用,因为笔者之前负责百度 APP 的接口变更审核,组件完整性校验,隐私合规调用链分析等均是依赖于此项技术,无用方法识别只是笔者在做体积优化时想到的其功能的一个延展。当然如上描述的技术问题,细节处理无用方法显然更细腻,case 更多。后续文章会针对 Swift 无用方法分析,接口变更审核,组件完整性校验,隐私合规调用链分析等一一作出介绍。


——END——


参考资料:

[1]libclang:https://clang.llvm.org/doxygen/group__CINDEX.html

[2]libtooling 官方文档:https://clang.llvm.org/docs/LibTooling.html

[3]LLVM 源码:https://github.com/llvm/llvm-project


推荐阅读:

基于异常上线场景的实时拦截与问题分发策略

极致优化 SSD 并行读调度

AI文本创作在百度App发文的实践

DeeTune:基于 eBPF 的百度网络框架设计与应用

百度自研高性能ANN检索引擎,开源了

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

百度Geek说

关注

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

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

评论

发布
暂无评论
百度APP iOS端包体积50M优化实践(六)无用方法清理_ios_百度Geek说_InfoQ写作社区