背景
随着业务的快速发展与持续迭代,APP的包体积也在不断增加,从之前的十几M到几十M再到上百M。
安装包过大,将会影响下载转化率。google开发者大会上公布的统计数据显示:</p>
包体大小每上升 6MB,应用下载转化率就会下降 1%,
而每当包体大小减少 10MB 的时候,平均下载转化率也会有 0.5-1.5% 的增长。
安装包大小有下载大小和安装大小两个概念。
下载大小:通过网络下载的压缩 App 大小。为了节省流量,用户下载的都是压缩包,而解压的过程也就是我们说的安装。
安装大小:为 App解压后将在用户设备上占用的磁盘空间大小。也就是在App Store上看到的大小,安装大小较大,通常会影响用户的下载意愿。
下载大小过大,苹果会限制用户使用蜂窝网络下载App。
- 2017 年 9 月,iOS 11 后,下载限制从 100 MB 提升至 150 MB
- 2019 年 5 月,下载限制从 150 MB 提升至 200 MB
- 2019 年 9 月,iOS 13 后,若下载大小超过 200 MB,用户可选择是否使用蜂窝网络下载,但iOS 13以下的系统仍然无法通过蜂窝网络下载
虽然苹果在逐渐放宽限制。但下载大小若超出 200 MB,可以肯定对APP下载成本,推广效率都会产生比较大的影响。
而安装大小过大,是会影响用户的留存率的,毕竟当用户手机内存不够用时,肯定是优先删除占内存比较大的App。
所以降低下载大小和安装大小就是我们的目的。
通过解压一个ipa文件,我们可以看到一个.app文件中主要包括三个部分:
- 资源文件:主要是图片、音频、视频、等资源。
- 可执行文件:程序的主体,是将我们的代码、静态库、动态库通过编译链接生成的文件。
- bundle:工程中使用的三方或资源bundle。
不过.app的大小并不完全就是包体积的大小,在APP上传到 AppStore Connect 到之后,Apple 也会对安装包做一些处理,测试安装包的变化无法对应到真正的下载大小变化的变化。处理主要包括:
- App Slicing 对于不同架构的裁剪,可执行文件只剩下单架构;
- Asset.car 中图片只留下设备需要的特定尺寸和压缩算法的变体;
- __TEXT 段加密;
这也是在不同设备上看到的包大小不同的部分原因。
通过分析可知,瘦身的途径主要还是针对可执行文件和资源的优化。
1、删除无用类和方法
一般的无用代码筛查方式可以分为动态和静态两种方式。静态的方式主要是通过代码扫描、参与编译构建过程或者分析最终产物来确认哪些代码没有被用到。而动态的方式主要是靠插桩或者运行时信息来获取哪些代码没有执行。
1.1 动态查找
基于插桩的行级别代码覆盖率:
基于 GCOV 或者 LLVM Profile 二进制的插桩方案可以实现在运行时收集插桩数据来指导无用代码的删除。但插桩方案局限性也显而易见,插桩会劣化二进制本身的大小和性能,同时原生的插桩方案是无法过审上线。数据收集只能局限于线下。
基于 Runtime 的轻量级运行时「类覆盖率」方案:
Objc 的类首次调用类初始化时,+initialize 被执行,系统会自动标记已被调用,在 metaClass 中 data 的 flags 字段第 29 位就存着这个这个状态。可以使用 flags & RW_INITIALIZED 获取。
1.2 静态查找
Mach-O文件中,__DATA`` __objc_classrefs 中记录了引用类的地址,__DATA``__objc_classlist中记录了所有类的地址,我们通过otool打印对应的信息,然后两者取差值,再进行符号化,就得到没有被引用的类信息。
- 通过otool -v -s __DATA __objc_classrefs获取到引用类(明确用到的)的地址。
- 通过otool -v -s __DATA __objc_classlist获取所有类的地址。
- 用所有类信息减去引用类的信息,此时我们可以拿到未使用类的地址信息。
- 通过nm -nm命令可以得到地址和对应的类名字。
通过otool -v -s __DATA __objc_classrefs获取到引用类的地址。
python脚本:
#通过otool -v -s __DATA __objc_classrefs获取到引用类的地址。
def class_ref_pointers(path, binary_file_arch):
ref_pointers = set()
lines = os.popen('/usr/bin/otool -v -s __DATA __objc_classrefs %s' % path).readlines()
for line in lines:
pointers = pointers_from_binary(line, binary_file_arch)
ref_pointers = ref_pointers.union(pointers)
return ref_pointers
通过otool -v -s __DATA __objc_classlist获取所有类的地址。
#通过otool -v -s __DATA __objc_classlist获取所有类的地址。
def class_list_pointers(path, binary_file_arch):
list_pointers = set()
#__DATA_CONST __DATA
command = '/usr/bin/otool -v -s __DATA __objc_classlist %s' % path
lines = os.popen(command).readlines()
if len(lines) < 2:
command = '/usr/bin/otool -v -s __DATA_CONST __objc_classlist %s' % path
lines = os.popen(command).readlines()
for line in lines:
pointers = pointers_from_binary(line, binary_file_arch)
list_pointers = list_pointers.union(pointers)
return list_pointers
用所有类信息减去引用类的信息,此时我们可以拿到未使用类的地址信息。
#获取未被使用到类
def class_unrefpointers(path, binary_file_arch):
list_pointers = class_list_pointers(path, binary_file_arch)
ref_pointers = class_ref_pointers(path, binary_file_arch)
unref_pointers = list_pointers - ref_pointers
return unref_pointers
通过nm -nm
命令可以得到地址和对应的类名字。
#通过nm -nm命令可以得到地址和对应的类名字。
def class_symbols(path):
symbols = {}
#class symbol format from nm: 0000000103113f68 (__DATA,__objc_data) external _OBJC_CLASS_$_EpisodeStatusDetailItemView
re_class_name = re.compile('(\w{16}) .* _OBJC_CLASS_\$_(.+)')
lines = os.popen('nm -nm %s' % path).readlines()
for line in lines:
result = re_class_name.findall(line)
if result:
(address, symbol) = result[0]
symbols[address] = symbol
return symbols
运行输出结果:
由于是静态查找,对于动态生成的类,比如通过反射生成的类,会被认为没有被引用,所以查找出列表后,还需要人工检查一遍。
1.3 第三方工具检测无用类,GitHub - dblock/fui: Find unused Objective-C imports.
Usage
gem install fui
Get Help
fui help
Find Unused Classes in the Current Directory
fui find
The
find
command lists all the files that contain unused imports and exits with the number of files found.Find Unused Classes in any Path
fui --path=~/source/project/Name find
1.4,查找无用的方法
FindSelectorsUnrefs.py 脚本文件
# coding:utf-8
import os
import re
import sys
import getopt
reserved_prefixs = ["-[", "+["]
# 获取入参参数
def input_parameter():
opts, args = getopt.getopt(sys.argv[1:], '-a:-p:-w:-b:',
['app_path=', 'project_path=', 'black_list_Str', 'white_list_str'])
black_list_str = ''
white_list_str = ''
white_list = []
black_list = []
# 入参判断
for opt_name, opt_value in opts:
if opt_name in ('-a', '--app_path'):
# .app文件路径
app_path = opt_value
if opt_name in ('-p', '--project_path'):
# 项目文件路径
project_path = opt_value
if opt_name in ('-b', '--black_list_Str'):
# 检测黑名单前缀,不检测谁
black_list_Str = opt_value
if opt_name in ('-w', '--white_list_str'):
# 检测白名单前缀,只检测谁
white_list_str = opt_value
if len(black_list_str) > 0:
black_list = black_list_str.split(",")
if len(white_list_str) > 0:
white_list = white_list_str.split(",")
if len(white_list) > 0 and len(black_list) > 0:
print("\033[0;31;40m白名单【-w】和黑名单【-b】不能同时存在\033[0m")
exit(1)
# 判断文件路径存不存在
if not os.path.exists(project_path):
print("\033[0;31;40m输入的项目文件路径【-p】不存在\033[0m")
exit(1)
app_path = verified_app_path(app_path)
if not app_path:
exit('输入的app路径不存在,停止运行')
return app_path, project_path, black_list, white_list
def verified_app_path(path):
if path.endswith('.app'):
appname = path.split('/')[-1].split('.')[0]
path = os.path.join(path, appname)
if appname.endswith('-iPad'):
path = path.replace(appname, appname[:-5])
if not os.path.isfile(path):
return None
if not os.popen('file -b ' + path).read().startswith('Mach-O'):
return None
return path
# 获取protocol中所有的方法
def header_protocol_selectors(file_path):
# 删除路径前后的空格
file_path = file_path.strip()
if not os.path.isfile(file_path):
return None
protocol_sels = set()
file = open(file_path, 'r')
is_protocol_area = False
# 开始遍历文件内容
for line in file.readlines():
# 删除注释信息
# delete description
line = re.sub('\".*\"', '', line)
# delete annotation
line = re.sub('//.*', '', line)
# 检测是否是 @protocol
# match @protocol
if re.compile('\s*@protocol\s*\w+').findall(line):
is_protocol_area = True
# match @end
if re.compile('\s*@end').findall(line):
is_protocol_area = False
# match sel
if is_protocol_area and re.compile('\s*[-|+]\s*\(').findall(line):
sel_content_match_result = None
# - (CGPoint)convertPoint:(CGPoint)point toCoordinateSpace:(id <UICoordinateSpace>)coordinateSpace
if ':' in line:
# match sel with parameters
# 【"convertPoint:","toCoordinateSpace:"]
sel_content_match_result = re.compile('\w+\s*:').findall(line)
else:
# - (void)invalidate;
# match sel without parameters
# invalidate;
sel_content_match_result = re.compile('\w+\s*;').findall(line)
if sel_content_match_result:
# 方法参数拼接
# convertPoint:toCoordinateSpace:
funcList = ''.join(sel_content_match_result).replace(';', '')
protocol_sels.add(funcList)
file.close()
return protocol_sels
# 获取所有protocol定义的方法
def protocol_selectors(path, project_path):
print('获取所有的protocol中的方法...')
header_files = set()
protocol_sels = set()
# 获取当前引用的系统库中的方法列表
# system_base_dir = '/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk'
system_base_dir = '/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.5.sdk'
# get system librareis
lines = os.popen('otool -L ' + path).readlines()
for line in lines:
# 去除首尾空格
line = line.strip()
# /System/Library/Frameworks/MediaPlayer.framework/MediaPlayer (compatibility version 1.0.0, current version 1.0.0)
# /System/Library/Frameworks/MediaPlayer.framework/MediaPlayer
# delete description,
line = re.sub('\(.*\)', '', line).strip()
if line.startswith('/System/Library/'):
# [0:-1],获取数组的左起第一个,到倒数最后一个,不包含最后一个,[1,-1)左闭右开
library_dir = system_base_dir + '/'.join(line.split('/')[0:-1])
if os.path.isdir(library_dir):
# 获取当前系统架构中所有的类
# 获取合集
header_files = header_files.union(os.popen('find %s -name \"*.h\"' % library_dir).readlines())
if not os.path.isdir(project_path):
exit('Error: project path error')
# 获取当前路径下面所有的.h文件路径
header_files = header_files.union(os.popen('find %s -name \"*.h\"' % project_path).readlines())
for header_path in header_files:
# 获取所有查找到的文件下面的protocol方法,这些方法,不能用来统计
header_protocol_sels = header_protocol_selectors(header_path)
if header_protocol_sels:
protocol_sels = protocol_sels.union(header_protocol_sels)
return protocol_sels
def imp_selectors(path):
print('获取所有的方法,除了setter and getter方法...')
# return struct: {'setupHeaderShadowView':['-[TTBaseViewController setupHeaderShadowView]']}
# imp 0x100001260 -[AppDelegate setWindow:] ==>> -[AppDelegate setWindow:],setWindow:
re_sel_imp = re.compile('\s*imp\s*0x\w+ ([+|-]\[.+\s(.+)\])')
re_properties_start = re.compile('\s*baseProperties 0x\w{9}')
re_properties_end = re.compile('\w{16} 0x\w{9} _OBJC_CLASS_\$_(.+)')
re_property = re.compile('\s*name\s*0x\w+ (.+)')
imp_sels = {}
is_properties_area = False
# “otool - ov”将输出Objective - C类结构及其定义的方法。
for line in os.popen('/usr/bin/otool -oV %s' % path).readlines():
results = re_sel_imp.findall(line)
if results:
# imp 0x100001260 -[AppDelegate setWindow:] ==>> [-[AppDelegate setWindow:],setWindow:]
(class_sel, sel) = results[0]
if sel in imp_sels:
imp_sels[sel].add(class_sel)
else:
imp_sels[sel] = set([class_sel])
else:
# delete setter and getter methods as ivar assignment will not trigger them
# 删除相关的set方法
if re_properties_start.findall(line):
is_properties_area = True
if re_properties_end.findall(line):
is_properties_area = False
if is_properties_area:
property_result = re_property.findall(line)
if property_result:
property_name = property_result[0]
if property_name and property_name in imp_sels:
# properties layout in mach-o is after func imp
imp_sels.pop(property_name)
# 拼接set方法
setter = 'set' + property_name[0].upper() + property_name[1:] + ':'
# 干掉set方法
if setter in imp_sels:
imp_sels.pop(setter)
return imp_sels
def ref_selectors(path):
print('获取所有被调用的方法...')
re_selrefs = re.compile('__TEXT:__objc_methname:(.+)')
ref_sels = set()
lines = os.popen('/usr/bin/otool -v -s __DATA __objc_selrefs %s' % path).readlines()
for line in lines:
results = re_selrefs.findall(line)
if results:
ref_sels.add(results[0])
return ref_sels
def ignore_selectors(sel):
if sel == '.cxx_destruct':
return True
if sel == 'load':
return True
return False
def filter_selectors(sels):
filter_sels = set()
for sel in sels:
for prefix in reserved_prefixs:
if sel.startswith(prefix):
filter_sels.add(sel)
return filter_sels
def unref_selectors(path, project_path):
# 获取所有类的protocol的方法集合
protocol_sels = protocol_selectors(path, project_path)
# 获取项目所有的引用方法
ref_sels = ref_selectors(path)
if len(ref_sels) == 0:
exit('获取项目所有的引用方法为空....')
# 获取所有的方法,除了set方法
imp_sels = imp_selectors(path)
print("\n")
if len(imp_sels) == 0:
exit('Error: imp selectors count null')
unref_sels = set()
for sel in imp_sels:
# 所有的方法,忽略白名单
if ignore_selectors(sel):
continue
# 如果当前的方法不在protocol中,也不再引用的方法中,那么认为这个方法没有被用到
# protocol sels will not apppear in selrefs section
if sel not in ref_sels and sel not in protocol_sels:
unref_sels = unref_sels.union(filter_selectors(imp_sels[sel]))
return unref_sels
# 黑白名单过滤
def filtration_list(unref_sels, black_list, white_list):
# 黑名单过滤
temp_unref_sels = list(unref_sels)
if len(black_list) > 0:
# 如果黑名单存在,那么将在黑名单中的前缀都过滤掉
for unref_sel in temp_unref_sels:
for black_prefix in black_list:
class_method = "+[%s" % black_prefix
instance_method = "-[%s" % black_prefix
if (unref_sel.startswith(class_method) or unref_sel.startswith(
instance_method)) and unref_sel in unref_sels:
unref_sels.remove(unref_sel)
break
# 白名单过滤
temp_array = []
if len(white_list) > 0:
# 如果白名单存在,只留下白名单中的部分
for unref_sel in unref_sels:
for white_prefix in white_list:
class_method = "+[%s" % white_prefix
instance_method = "-[%s" % white_prefix
if unref_sel.startswith(class_method) or unref_sel.startswith(instance_method):
temp_array.append(unref_sel)
break
unref_sels = temp_array
return unref_sels
# 整理结果,写入文件
def write_to_file(unref_sels):
file_name = 'selector_unrefs.txt'
f = open(os.path.join(sys.path[0].strip(), file_name), 'w')
unref_sels_num_str = '查找到未被使用的方法: %d个\n' % len(unref_sels)
print(unref_sels_num_str)
f.write(unref_sels_num_str)
num = 1
for unref_sel in unref_sels:
unref_sels_str = '%d : %s' % (num, unref_sel)
print(unref_sels_str)
f.write(unref_sels_str + '\n')
num = num + 1
f.close()
print('\n项目中未使用方法检测完毕,相关结果存储到当前目录 %s 中' % file_name)
print('请在项目中进行二次确认后处理')
if __name__ == '__main__':
"""
-a /Users/xx/Library/Developer/Xcode/DerivedData/DemoTest2022-askobbdkfqpxumdsajtgrococpid/Build/Products/Debug-iphonesimulator/DemoTest2022.app/DemoTest2022 -p /Users/xxx/Documents/iOS/DemoTest2022
"""
# 获取入参
app_path, project_path, black_list, white_list = input_parameter()
# 获取未使用方法
unref_sels = unref_selectors(app_path, project_path)
# 黑白名单过滤
unref_sels = filtration_list(unref_sels, black_list, white_list)
# 打印写入文件
write_to_file(unref_sels)
执行结果:
2、编译选项优化
2.1 开启LTO
编译选项Link-Time Optimization优化
苹果官方介绍,开启LTO后会使在release下的运行速度提升10%,而且包体积会减小。
Apple uses LTO extensively internally
- Typically 10% faster than executables from regular Release builds Multiplies
- with Profile Guided Optimization (PGO)
- Reduces code size when optimizing for size
但是有个缺点,debug时的编译速度慢了很多,而且二次编译时会全部编译,所以我们只是在release模式下开启了LTO。
运行编译结果:
2.2 Optimization Level
Optimization Level是指clang采用什么样的编译优化等级,在Clang的文档里 clang - Code Generation Options可以查阅到主要有以下等级:
-O0 Means “no optimization”: this level compiles the fastest and generates the most debuggable
- -O1 Somewhere between-O0 and -O2
- -O2Moderate level of optimization which enables most optimizations
- -O3 Like-O2, except that it enables optimizations that take longer to perform or that may generate larger code (in an attempt to make the program run faster).
- -Ofast Enables all the optimizations from -O3 along with other aggressive optimizations that may violate strict compliance with language standards
- -Os Like-O2 with extra optimizations to reduce code size.
- -Oz Like -Os (and thus -O2<), but reduces code size further.
Xcode默认debug时为-O0不优化,release时为-Os。经过测试这里如果使用-Oz会大约减小3M左右的包体积,但是在一些页面会出现crash, 经过排查是一些延迟释放导致的内存问题。出于安全考虑,目前采用的是-Os这种优化等级。
2.3 符号相关
symbols是指程序中的所有的变量、类、函数、枚举、变量和地址映射关系,以及一些在调试的时候使用到的用于定位代码在源码中的位置的调试符号,符号和断点定位以及堆栈符号化有很重要的关系。
2.3.1 Strip Linked Product (STRIP_INSTALLED_PRODUCT)
If enabled, the linked product of the build will be stripped of symbols when performing deployment postprocessing.
如果设置为yes,打包的时候会将symbols裁剪。
并不是所有的符号都是必须的,比如 Debug Map,所以 Xcode 提供给我们 Strip Linked Product 来去除不需要的符号信息(Strip Style 中选择的选项相应的符号),去除了符号信息之后我们就只能使用 dSYM 来进行符号化了,所以需要将 Debug Information Format 修改为 DWARF with dSYM file。
2.3.2 **Strip Debug Symbols During Copy **(COPY_PHASE_STRIP)
Specifies whether binary files that are copied during the build, such as in a Copy Bundle Resources or Copy Files build phase, should be stripped of debugging symbols. It does not cause the linked product of a target to be stripped—use Strip Linked Product (STRIP_INSTALLED_PRODUCT) for that。
与 Strip Linked Product 类似,但是这个是将那些拷贝进项目包的三方库、资源或者 Extension 的 Debug Symbol 去除掉,同样也是使用的 strip 命令。这个选项没有前置条件,所以我们只需要在 Release 模式下开启,不然就不能对三方库进行断点调试和符号化了。
2.3.3 Symbols Hidden by Default (GCC_SYMBOLS_PRIVATE_EXTERN)
When enabled, all symbols are declared private extern unless explicitly marked to be exported using attribute((visibility("default"))) in code. If not enabled, all symbols are exported unless explicitly marked as private extern
意思就是设置为yes后,所有的symbols都会被申明为private extern,经过测试,确实可以减小包体积。
3、__TEXT段迁移
iOS的可执行文件就是一个MachO文件,MachO结构主要分为 Header、Load Commands、Data三部分。
- Header 包含该二进制文件的一般信息,字节顺序、架构类型、加载指令的数量等。使得可以快速确认一些信息,比如当前文件用于32位还是64位,对应的处理器是什么、文件类型是什么。
- Load Commands是一张包含很多内容的表。 内容包括区域的位置、符号表、动态符号表等。它们描述了 Data 在二进制文件和虚拟内存中的布局信息,有了这个布局信息就能够知道 Data 在二进制文件中和虚拟内存中是怎样排布的。
- Data 存储了实际的内容,通常是对象文件中最大的部分,包含Segement的具体数据,如静态C字符串,带参数/不带参数的OC方法,带参数/不带参数的C函数。
以下是在MachOView中查看的结构:
Data的结构又可以分为多个Segment,主要有__PAGEZERO 、__TEXT 、__DATA 、__LINKEDIT :
- __PAGEZERO 是在可执行文件有的,动态库里没有。这个段开始地址为0(NULL指针指向的位置),是一个不可读、不可写、不可执行的空间,能够在空指针访问时抛出异常。
- __TEXT 是代码段,里面主要是存放代码的,该段是可读可执行,但是不可写。
- __DATA 是数据段,里面主要是存放数据,该段是可读可写,但不可执行。
- __LINKEDIT 段用于存放签名信息,该段是只可读,不可写不可执行。
__TEXT
段迁移的方式:
一个Mach-O文件构建的构成主要包括: 预处理-> 编译->汇编->链接等 4 个阶段
通过移动Mark-O __TEXT段位也可以减少包的体积。
那么__TEXT段迁移为什么会减小下载大小
原因就是App在上传到App Store Connect后,苹果会对其进行加密,然后压缩成ipa。加密对可执行文件本身的大小几乎没有影响,但是却大大影响了压缩效率。而__TEXT段又是加密段中最主要的一部分,通过减小__TEXT段就可以减小加密范围,所以就可以将__TEXT段中的一些Section迁移到其它Segment中。
4、第三方库
尽量减少第三方库,在满足功能的前提下选择一些精简库。
1.PNG图片压缩
png压缩主要对比了两种方案:
TinyPNG
有损压缩,主要是使用Quantization的技术,通过合并图片中相似的颜色,通过将 24 位的 PNG 图片压缩成小得多的 8 位色值的图片,并且去掉了图片中不必要的 metadata,这种方式几乎能完美支持原图片的透明度。
网站:TinyPNG – Compress WebP, PNG and JPEG images intelligently ImageOptim
无损压缩,图片文件中往往包含一些注释、颜色 Profile 等多余信息,移除后图像质量不变,体积更小载入更快。ImageOptim 以此方式压缩图片,先分析图片,找到最优压缩参数,去除无关信息减小体积。
网站:ImageOptim — better Save for Web 经过压缩测试,发现TinyPNG压缩效果远好于ImageOptim,TinyPNG压缩比约为65%,ImageOptim压缩比约为30%,并且肉眼看起来无差异。
2.修改组件库中图片管理方式
Asset Catalog,是Xcode提供的一项图片资源管理方式。每个Asset表示一个图片资源,但是可以对应一张或者多张PNG图,比如可以提供@1x, @2x, @3x多张尺寸的图进行适配;
Asset Catalog中的图片,在编译时会被压缩,然后在App运行时,可以通过API动态根据设备scale factor来选择对应的真实的图片渲染,使用Asset Catalog管理的图片会在ipa包中生成一个Assets.car文件。
App Thing,是苹果平台上的一个用于优化App包下载资源大小的方案。在App包提交上传到App Store后,苹果后台服务器,会对不同的设备,根据设备的scale factor,重新把App包进行精简,这样不同设备从App Store下载需要的容量不同,3x设备不需要同时下载1x和2x的图。
但是,这套机制直接基于Asset Catalog,也就是说,只有在Asset Catalog中引入的图片,才能享受到App Thinning。直接拷贝到App Bundle中的散落图片,所有设备还是都会全部下载。
因此尽量提升Asset Catalog利用率,是一个很大的包大小优化点。
3、删除无用PNG图片
通过工具筛查:LSUnusedResources
通过以上方法可以有效的减少应用包的体积。