一. 前言
百度APP iOS端包体积优化系列文章的前两篇重点介绍了包体积优化整体方案、各项优化收益和图片优化方案,图片优化是从无用图片、Asset Catalog和HEIC格式三个角度做深度优化。本文重点介绍资源优化,在百度APP实践中,资源优化包括大资源优化、无用配置文件和重复资源优化。不管是资源优化还是代码优化,都需要分析Mach-O文件,以获取资源和代码的引用关系,本文先详细介绍Mach-O文件。
百度APP iOS端包体积优化实践系列文章回顾:
《百度APP iOS端包体积50M优化实践(一)总览》:https://mp.weixin.qq.com/s/ANbFzg7X932o-iDpa8FcxQ
《百度APP iOS端包体积50M优化实践(二) 图片优化》:https://mp.weixin.qq.com/s/RR7sjhkuTFgUp7S5E8ECMw
二. Mach-O文件详解
MachOView下载地址: http://sourceforge.net/projects/machoview/
MachOView源码地址:https://github.com/gdbinit/MachOView
用MachOView能查看MachO文件信息,启动MachOView,在状态栏中点击file,打开MachO文件,如下图所示。
mac自带otool工具,otool -arch arm64 -ov xxx.app/xxx,可获取所有项目的类结构及定义的方法,示例代码如下所示:
Contents of (__DATA,__objc_classlist) section
0000000100008238 0x100009980
isa 0x1000099a8
superclass 0x0 _OBJC_CLASS_$_UIViewController
cache 0x0 __objc_empty_cache
vtable 0x0
data 0x1000083e8
flags 0x90
instanceStart 8
instanceSize 8
reserved 0x0
ivarLayout 0x0
name 0x100007349 ViewController
baseMethods 0x1000082d8
entsize 24
count 11
name 0x100006424 test4
types 0x1000073e4 v16@0:8
imp 0x100004c58
name 0x1000063b4 viewDidLoad
*****
下面列举otool常见命令:
命令 功能 otool -f xxx.app/xxx 查看fat headers信息 otool -a xxx.app/xxx 查看archive header信息 otool -h xxx.app/xxx 查看Mach-O头结构 otool -l xxx.app/xxx 查看load commands otool -L xxx.app/xxx 查看依赖的动态库,包括动态库名称、
当前版本号、兼容版本号
otool -t -v xxx.app/xxx 查看text section otool -d xxx.app/xxx 查看data section otool -o xxx.app/xxx 查看Objective-C segment otool -I xxx.app/xxx 查看symbol table otool -v -s __TEXT __cstring 获取所有静态字符串 otool -v -s
__TEXT __objc_methname
xxx.app/xxx
获取所有方法名称
采用file命令可以查看文件格式,lipo -info可查看该Mach-O文件支持的具体CPU架构。
~ % file /Users/ycx/Desktop/demo.app/demo
/Users/ycx/Desktop/demo.app/demo: Mach-O 64-bit executable arm64
~ % lipo -info /Users/ycx/Desktop/demo.app/demo
Non-fat file: /Users/ycx/Desktop/demo.app/demo is architecture: arm64
Mach-O文件主要由三部分组成Header、LoadCommands、Data,在MachO文件的末尾,还有Loader Info信息,表示可执行文件依赖的字符串表,符号表等信息。
Header(头部): 用于描述当前Mach-O文件的基本信息(CPU类型、文件类型等),XNU代码路径:EXTERNAL_HEADERS/mach-o/loader.h,数据结构如下所示:
struct mach_header_64 {
uint32_t magic; /* mach magic number identifier */
cpu_type_t cputype; /* cpu specifier */
cpu_subtype_t cpusubtype; /* machine specifier */
uint32_t filetype; /* type of file */
uint32_t ncmds; /* number of load commands */
uint32_t sizeofcmds; /* the size of all the load commands */
uint32_t flags; /* flags */
uint32_t reserved; /* reserved */
};
命令otool -hv可查看Header每个字段值。
otool -hv demo
demo:
Mach header
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
MH_MAGIC_64 ARM64 ALL 0x00 EXECUTE 22 3040 NOUNDEFS DYLDLINK TWOLEVEL PIE
各个字段具体含义如下所示:
字段 | 说明 |
magic | 魔数头,系统加载器通过该字段快速判断文件类型 armv7:FEEDFACE arm64:FEEDFACF |
cputype | CPU类型 |
cpusubtype | CPU指定子类型,inter、arm、powerpc等 |
filetype | 说明文件类型(可执行文件、库文件、核心转储文件、内核扩展文件、DYSM文件、动态库等) MH_OBJECT 编译过程中产生的 obj文件 MH_EXECUTE 可执行二进制文件 MH_CORE CoreDump MH_DYLIB 动态库 MH_DYLINKER 连接器linker MH_KEXT_BUNDLE 内核扩展文件 |
ncmds | 加载命令的条数 |
sizeofcmds | 加载命令长度 |
flags | dyld加载时的标志位 MH-NOUNDEFS表示:目标没有未定义的符号,不存在链接依赖 MH-DYLDLINK表示:该目标文件是dyld的输入文件 MH-TWOLEVEL表示:动态加载二级名称空间 MH-PIE表示:地址空间布局随机化 |
struct load_command {
uint32_t cmd; /* type of load command */
uint32_t cmdsize; /* total size of command in bytes */
};
/* Constants for the cmd field of all load commands, the type */
*****
用otool -lv命令可以看到该字段全部信息,如左下图所示,此外,我们也可用MachOView工具可更直观地观察具体字段,如右下图所示。
类型 | 作用 |
LC_SEGMENT/LC_SEGMENT_64 | 将文件中的段映射到进程地址空问中 |
LC_DYLD_INFO_ONLY | 动态库信息,根据该命令是真正动态库绑定,地址重定向重要的信息 |
LC_SYMTAB | 符号表信息 |
LC DYSYMTAB | 动态符号表信息 |
LC_LOAD_DYLINKER | 加载动态链接器 |
LC_UUID | 文件的唯一标识,crash解析中也会有,去匹配dysm文件和crash文件 |
LC_VERSION_MIN_IPHONEOS | 二进制文件要求的最低操作系统版本 (iOS Deployment Target) |
LC_MAIN | 程序主线程的入口地址 |
LC_ENCRYPTION_INFO_64 | 加密信息,查看文件是否加密,如果已加密需要砸壳 |
LC_LOAD_DYLIB | 加载的动态库,包括动态库地址和名称,当前版本号,兼容版本号 |
LC_FUNCTION_STARTS | 函数起始地址表 |
LC_CODE_SIGNATURE | 代码签名信息 |
在众多cmd命令中,我们需要重点关注的是LC_SEGMENT/LC_SEGMENT_64,LC_SEGMENT是32位,LC_SEGMENT_64是64位,目前主流机型是LC_SEGMENT_64。LC_SEGMENT_64作用是如何将Data中的各个Segment加载入内存中,而和我们APP相关的代码及数据,大部分位于各个Segment中。其数据结构名称是segment_command_64,XNU代码路径:EXTERNAL_HEADERS/mach-o/loader.h,源码如下所示:
struct segment_command_64 { /* for 64-bit architectures */
uint32_t cmd; /* LC_SEGMENT_64 */
uint32_t cmdsize; /* includes sizeof section_64 structs */
char segname[16]; /* segment name */
uint64_t vmaddr; /* memory address of this segment */
uint64_t vmsize; /* memory size of this segment */
uint64_t fileoff; /* file offset of this segment */
uint64_t filesize; /* amount to map from the file */
vm_prot_t maxprot; /* maximum VM protection */
vm_prot_t initprot; /* initial VM protection */
uint32_t nsects; /* number of sections in segment */
uint32_t flags; /* flags */
};
字段 | 含义 |
Segment Name | Segment名称 |
VM Address | 该段被加载后在进程地址空间中的虚拟地址 |
VM Size | 段的虚拟内存大小 |
File Offset | 该段在文件中的偏移 |
File Size | 段在文件中的大小 |
maxprot | 段页面所需要的最高内存保护(可读 可写 可执行) |
initprot | 段页面初始的内存保护 |
nsects | 段中包含section的数量 |
flags | 其他标志位 |
__TEXT Segment对应的就是代码段,下图是一张示例截图,其有11个Section,该段对应的内容加载到内存的过程是:从File Offset开始加载大小为File Size的文件,从虚拟地址VM Address开始装填,大小也是VM Size,VM Size跟文件大小File Size是相同的,我们发现其File Offset为0,在Mach-O文件布局中,__TEXT类型的Segment前面有_PAGEZERO类型的Segment,但_PAGEZERO段的File Offse和File Size为0,所以__TEXT段的File Offset为0。
maxprot和initprot值都为VM_PROT_READ和VM_PROT_EXECUTE,代码段权限是只读和可执行,防止在内存中被修改。
Mach-O的Data部分,其实是真正存储APP二进制数据的地方,前面的header和load command,仅是提供文件的说明以及加载信息的功能。
Section | 用途 |
__TEXT.__text | 主程序代码 |
__TEXT.__cstring | C 语言字符串 |
__TEXT.__const | const 关键字修饰的常量 |
__TEXT.__stubs | 用于 Stub 的占位代码,很多地方称之为桩代码。 |
__TEXT.__stubs_helper | 当 Stub 无法找到真正的符号地址后的最终指向 |
__TEXT.__objc_methname | Objective-C 方法名称 |
__TEXT.__objc_methtype | Objective-C 方法类型 |
__TEXT.__objc_classname | Objective-C 类名称 |
__DATA.__data | 初始化过的可变数据 |
__DATA.__la_symbol_ptr | lazy binding 的指针表,表中的指针一开始都指向 __stub_helper |
__DATA.nl_symbol_ptr | 非 lazy binding 的指针表,每个表项中的指针都指向一个在装载过程中,被动态链机器搜索完成的符号 |
__DATA.__const | 没有初始化过的常量 |
__DATA.__cfstring | 程序中使用的 Core Foundation 字符串(CFStringRefs) |
__DATA.__bss | BSS,存放为初始化的全局变量,即常说的静态内存分配 |
__DATA.__common | 没有初始化过的符号声明 |
__DATA.__objc_classlist | Objective-C 类列表 |
__DATA.__objc_protolist | Objective-C 所有的protocol |
__DATA.__objc_imginfo | Objective-C 镜像信息 |
__DATA.__objc_selfrefs | Objective-C self 引用 |
__DATA.__objc_protorefs | Objective-C 原型引用 |
__DATA.__objc_superrefs | Objective-C 超类引用 |
三. 资源优化
作为一个航母级别的APP,百度APP技术栈丰富多样,市面上常见的技术框架都有使用,如Hybrid框架、小程序框架、React Native框架、KMM和端智能。此外,百度APP作为日活过亿的APP,为满足用户复杂多变的需求,具有的功能包罗万象,如搜索、Feed、短视频、直播、购物、小说、地图、网盘、美颜、人脸识别、AR库等,导致内置的大块资源(大于40K)就有26M,具有很大的优化空间,资源优化分为三个部分,分别是大资源优化、无用配置文件和重复资源优化,本章节接下来详细介绍各个模块的优化方案。
资源是指plist、js、css、json、端智能模型文件等,因这些文件和图片在优化方式差异很大,所以把两者区分开来。获取大资源主要途径是递归遍历ipa包的所有资源,体积大于指定阈值的文件就是我们要针对性优化的大资源,在百度APP优化实践中我们选取了40K作为阈值,参考脚本如下所示:
def findBigResources(path,threshold):
pathDir = os.listdir(path)
for allDir in pathDir:
child = os.path.join('%s%s' % (path, allDir))
if os.path.isfile(child):
# 获取读到的文件的后缀
end = os.path.splitext(child)[-1]
# 过滤掉dylib系统库和asset.car
if end != ".dylib" and end != ".car":
temp = os.path.getsize(child)
# 转换单位:B -> KB
fileLen = temp / 1024
if fileLen > threshold:
#print(end)
print(child + " length is " + str(fileLen));
else:
# 递归遍历子目录
child = child + "/"
findBigResources(child,threshold)
异步下载:只要APP首次启动时不需要加载该资源,或者即使首次启动需要加载但是使用频率不高,那么该资源就可以走异步下载;
资源压缩:当APP首次启动需要加载且频率较高的情况下,可以对大块资源先进行压缩内置APP,启动阶段异步线程解压再使用;
从ipa包中获取plist、json、txt、xib等配置文件,百度技术方案采用的是排除法,因为实践中发现配置文件格式千奇百怪,很多业务模块出于安全考虑自定义各种后缀文件,无法穷举,所以采用了排除法。针对图片资源我们有专门的优化方法,所以首先将png、webp、gif、jpg排除掉,JS&CSS资源是一般HTML加载的,在mach-o文件中TEXT字段静态字符串常量不会有体现,所以也需要排除掉,最后获取到的就是我们需要的配置文件,参考脚本如下所示:
def findProfileResources(path):
pathDir = os.listdir(path)
for allDir in pathDir:
child = os.path.join('%s%s' % (path, allDir))
if os.path.isfile(child):
# 获取读到的文件的后缀
end = os.path.splitext(child)[-1]
if end != ".dylib" and end != ".car" and end != ".png" and end != ".webp" and end != ".gif" and end != ".js" and end != ".css":
print(child + " 后缀 " + end)
else:
# 递归遍历子目录
child = child + "/"
findProfileResources(child)
lines = os.popen('/usr/bin/otool -v -s __TEXT __cstring %s' % path).readlines()
从iPA包中获取所有资源文件,通过MD5判断资源是否重复,参考脚本如下所示:
def get_file_library(path, file_dict):
pathDir = os.listdir(path)
for allDir in pathDir:
child = os.path.join('%s/%s' % (path, allDir))
if os.path.isfile(child):
md5 = img_to_md5(child)
# 将md5存入字典
key = md5
file_dict.setdefault(key, []).append(allDir)
continue
get_file_library(child, file_dict)
def img_to_md5(path):
fd = open(path, 'rb')
fmd5 = hashlib.md5(fd.read()).hexdigest()
fd.close()
return fmd5
四. 总结
资源优化是包体积优化的重头戏,优化的过程中影响面可控,所以落地收益比较容易,百度APP经过两个季度的优化落地12M的收益,基本解决存量资源的优化问题,同时建立资源使用规范和相应的检测流水线解决增量问题。
本文对Mach-O文件格式做了系统阐释,并且详细介绍了百度APP大资源优化、无用配置文件和重复资源优化方案,后续我们会针对其他优化详细介绍其原理与实现,敬请期待。
[1]、Mach内核介绍:https://developer.apple.com/library/archive/documentation/Darwin/Conceptual/KernelProgramming/Mach/Mach.html
[2]、《深入解析Mac OS X & iOS操作系统》
[3]、XNU源码:https://github.com/apple/darwin-xnu
[4]、Mach-O介绍:https://alexdremov.me/mystery-of-mach-o-object-file-builders/
[5]、初识Mach-O文件:https://www.jianshu.com/p/81928c705c88