@qidiandasheng
2021-06-22T11:24:45.000000Z
字数 11932
阅读 3800
项目
一般我们打出来的包是包含多个指令集的,当我们上传到App Store之后,苹果会通过App Thinning
技术来对包进行瘦身,当用户下载时就只会下载一个适合自己设备的芯片指令集架构文件。
App Thinning 有三种方式,包括:App Slicing、Bitcode、On-Demand Resources。
我们打企业包的时候有一个APP Thinning
选择,我们如下图选择所有版本。然后我们就能看到打出来的包含有一个包含所有指令架构的总包和各种芯片指令集单独的一个包。
我们还能看到有一个App Thinning Size Report.txt
文件,里面会有记录每一个包支持的手机及系统版本,以及压缩前压缩后的包大小(ipa本质是一个压缩包)。
Variant: InHouse-137F49AB-508D-4843-96C2-D0B9F181BA16.ipa
Supported variant descriptors: [device: iPhone5,4, os-version: 9.0], [device: iPhone5,1, os-version: 9.0], [device: iPhone5,2, os-version: 9.0], [device: iPod5,1, os-version: 9.0], [device: iPhone4,1, os-version: 9.0], and [device: iPhone5,3, os-version: 9.0]
App + On Demand Resources size: 22 MB compressed, 64.9 MB uncompressed
App size: 22 MB compressed, 64.9 MB uncompressed
On Demand Resources size: Zero KB compressed, Zero KB uncompressed
Apple Store的包在你打包时是没有App Thinning
选项的,会在你上传到app store connect
的时候苹果来帮你做。我查看的话进入app store connect
选择自己的app,点击活动->所有构建版本->选择一个版本进入详情->App Store 文件大小
我们就能看到如下图所示的每种设备对应的安装包的下载大小和安装大小,其中安装大小就是我们在App Store
上看到的app的大小。
将IPA包修改后缀名为ZIP,解压缩后,获取payload中的App文件,查看App文件的内容,你会发现该文件主要包含以下内容。
里面包含的是一些swift的标准库,也是打包到ipa包里面的。都是一些以libSwiftxxx开头的标准款。(好像是iOS12.2之后的的手机就不会打包进去了)
使用file
命令查看标准库包含的指令集,这里以libswiftCore.dylib
为例,如下包含了arm64、armv7、armv7s、arm64、arm64e,我看了一下大小为95.5M。如果单单只包含armv7的话是3.7M:
➜ ~ file /Users/dasheng/Desktop/AppStore\ 2020-02-06\ 13-22-53/AppStore/SwiftSupport/iphoneos/libswiftCore.dylib
/Users/dasheng/Desktop/AppStore 2020-02-06 13-22-53/AppStore/SwiftSupport/iphoneos/libswiftCore.dylib: Mach-O universal binary with 4 architectures: [arm_v7:Mach-O dynamically linked shared library arm_v7] [arm64]
/Users/dasheng/Desktop/AppStore 2020-02-06 13-22-53/AppStore/SwiftSupport/iphoneos/libswiftCore.dylib (for architecture armv7): Mach-O dynamically linked shared library arm_v7
/Users/dasheng/Desktop/AppStore 2020-02-06 13-22-53/AppStore/SwiftSupport/iphoneos/libswiftCore.dylib (for architecture armv7s): Mach-O dynamically linked shared library arm_v7s
/Users/dasheng/Desktop/AppStore 2020-02-06 13-22-53/AppStore/SwiftSupport/iphoneos/libswiftCore.dylib (for architecture arm64): Mach-O 64-bit dynamically linked shared library arm64
/Users/dasheng/Desktop/AppStore 2020-02-06 13-22-53/AppStore/SwiftSupport/iphoneos/libswiftCore.dylib (for architecture arm64e): Mach-O 64-bit dynamically linked shared library arm64
基本上swift的标准库包含如下图这些,我算了下单独只包含armv7指令集的话大小一共7.2M左右,如果包含全部指令集的话就达到了160M。
Xcode 支持编译器层面的一些优化选项,可以让我们介于更快的编译速度、更小的二进制大小和更快的执行速度之间自由选择想要进行的优化粒度;
All Symbols: 去除所有符号,一般是在主工程中开启。
Non-Global Symbols: 去除一些非全局的 Symbol(保留全局符号,Debug Symbols 同样会被去除),链接时会被重定向的那些符号不会被去除,此选项是静态库/动态库的建议选项。
Debug Symbols: 去除调试符号,去除之后将无法断点调试。
Strip Linked Product去除不必要的符号信息Release下为YES。注意:去除了符号信息之后我们就只能使用 dSYM 来进行符号化了,所以需要将 Debug Information Format 修改为 DWARF with dSYM file(Release下),如果在Debug下设置为DWARF with dSYM file那么在崩溃时将无法看到堆栈信息。
Strip Linked Product 选项在 Deployment Postprocessing 设置为 YES 的时候才生效,而在 Archive 的时候 Xcode 总是会把 Deployment Postprocessing 设置为 YES,Debug下,Deployment Postprocessing 设置为 NO。
Strip Debug Symbols During Copy将那些拷贝进项目包的三方库、资源或者 Extension 的 Debug Symbol 去除掉,在Build Settings -> Strip Debug Symbols During Copy设置,Release下设置为YES。
由于swift和OC混编使用了use_frameworks!,对应的库都会打包成动态库,造成包体积的增大。以及很多库的依赖问题导致的依赖库增加,比如我使用了一个swift的UI库DSTUserInterface
,造成了OC和swift的混编,以及引入了很多相关库,最后包体积从12M飙升到了36.3M。
现通过podfile里hook的方式,时动态库改为静态库,可显著减小包的体积。
注意:当OC工程引入swift的pod编译时可能出现Apple Mach-O Linker(Id) Error
的报错,这是在OC主工程里面随意创建一个swift文件即可。好像是当都改为静态库时需要项目名-Swift.h
这个文件进行一些编译链接的一个处理。而这个文件只有在主工程里有创建过swift文件后编译器才会自动生成。
pre_install do |installer|
installer.pod_targets.each do |pod|
def pod.build_type;
Pod::Target::BuildType.static_framework
end
end
end
//或者设置某几个库为动态库
dynamic_frameworks = ['SwiftyJSON']
pre_install do |installer|
installer.pod_targets.each do |pod|
if dynamic_frameworks.include?(pod.name)
def pod.build_type;
Pod::Target::BuildType.static_framework
end
end
end
end
通过 find 命令获取 App 安装包中的所有资源文件,比如 find /Users/daiming/Project/ -name。
设置用到的资源的类型,比如 jpg、gif、png、webp。
使用正则匹配在源码中找出使用到的资源名,比如 pattern = @"@"(.+?)""。
使用 find 命令找到的所有资源文件,再去掉代码中使用到的资源文件,剩下的就是无用资源了。
对于按照规则设置的资源名,我们需要在匹配使用资源的正则表达式里添加相应的规则,比如 @“image_%d”。
确认无用资源后,就可以对这些无用资源执行删除操作了。这个删除操作,你可以使用 NSFileManger 系统类提供的功能来完成。
可以使用可视化工具:LSUnusedResources
对于 App 来说,图片资源总会在安装包里占个大头儿。对它们最好的处理,就是在不损失图片质量的前提下尽可能地作压缩。目前比较好的压缩方案是,将图片转成 WebP。WebP 是 Google 公司的一个开源项目。有损压缩模式下图片体积只有 jpeg 格式的 1/3,无损压缩也能减小 1/4,可以使用 cwebp 进行格式压缩转换,目前 SDWebImage、Kingfisher 都用支持该格式解析的拓展。无损压缩命令如下:
// 语法
cwebp [options] input_file -o output_file.webp
// 无损压缩
cwebp -lossless original.png -o new.webp
除了终端命令,还可以使用 iSparta 进行批量转换格式:
Compress PNG Files
打包的时候基于 pngcrush 工具自动对图片进行无损压缩,如果我们已自行对图片进行压缩,该选项最好关闭。
Remove Text Medadata From PNG Files
移除 PNG 资源的文本字符,比如图像名称、作者、版权、创作时间、注释等信息。
通过校验所有资源的 MD5,筛选出项目中的重复资源,推荐使用 fdupes 工具进行重复文件扫描,fdupes 是 Linux 平台的一个开源工具,由 C 语言编写 ,文件比较顺序是大小对比 > 部分 MD5 签名对比 > 完整 MD5 签名对比 > 逐字节对比。
通过 Homebrew 安装 fdupes:
brew install fdupes
查看目标文件夹下的重复文件:
fdupes -Sr 文件夹 // 查看文件夹下所有子目录中的重复文件及大小
fdupes -Sr 文件夹 > 输出地址.txt // 将信息输出到txt文件中
输出内容如下,一般情况下,相同文件仅保留一份,修改对应的引用即可。
4474 bytes each:
Test/Images.xcassets/TabBarImage/tabBar_2.imageset/tabBar_2@2x.png
Test/Resource/TabBarImage/tabBar_2@2x.png
3912 bytes each:
Test/Images.xcassets/TabBarImage/tabBar_3.imageset/tabBar_3@2x.png
Test/Resource/TabBarImage/tabBar_3@2x.png
尽量将图片资源放入 Images.xcassets
中,包括 pod 库的图片。Images.xcassets
中的图片加载后会有缓存,提升加载速度,并且在最终打包时会自动进行压缩(Compress PNG Files
),再根据最终运行设备进行 2x 和 3x 分发。
我们可以通过分析 LinkMap
来获得所有的代码类和方法的信息。获取 LinkMap
可以通过将 Build Setting
里的 Write Link Map File
设置为 Yes,然后指定 Path to Link Map File
的路径就可以得到每次编译后的 LinkMap
文件了。设置选项如下图所示:
LinkMap 文件分为以下三部分:
得到了代码的全集信息以后,我们还需要找到已使用的方法和类,这样才能获取到差集,找出无用代码。所以接下来,我就先和你说说怎么通过 Mach-O 取到使用过的方法和类。
iOS 的方法都会通过 objc_msgSend
来调用。而,objc_msgSend
在 Mach-O 文件里是通过 __objc_selrefs
这个 section 来获取 selector 这个参数的。
所以,__objc_selrefs
里的方法一定是被调用了的。__objc_classrefs
里是被调用过的类,__objc_superrefs
是调用过 super 的类。通过 __objc_classrefs
和 __objc_superrefs
,我们就可以找出使用过的类和子类。
我们可以使用MachOView
来查看 Mach-O 里的信息:
//main.m
int main(int argc, char * argv[]) {
@autoreleasepool {
Boy *boy = [[Boy alloc] init];
[boy say];
return 0;
}
}
//Boy.m
#import "Boy.h"
#import "Girl.h"
@implementation Boy
- (void)say{
NSLog(@"hi there again!\n");
}
- (void)sayhi{
[self sayhi];
NSLog(@"say hi!\n");
//会出现在__objc_classrefs中
Girl *girl = [[Girl alloc] init];
//不会出现在__objc_classrefs中
Class Girl = NSClassFromString(@"Girl");
id girl = [Girl new];
}
@end
如上我们main里面只初始化了Boy
和调用了say
方法,sayhi
方法没有任何地方调用。但我们看下图的__objc_classrefs
和__objc_selrefs
,里面显示出了Girl
和sayhi
,所以这里目标文件中出现的是只要alloc
过的类和调用过objc_msgSend
的都会出现在这两个section
里:
问题:
但是,这种查看方法并不是完美的,还会有些问题。原因在于, Objective-C 是门动态语言,方法调用可以写成在运行时动态调用,这样就无法收集全所有调用的方法和类。所以,我们通过这种方法找出的无用方法和类就只能作为参考,还需要二次确认。
用 AppCode 做分析的方法很简单,直接在 AppCode 里选择 Code->Inspect Code
就可以进行静态分析:
静态分析完以后,我们可以在 Unused code 里看到所有的无用代码,如下图所示:
无用代码的主要类型:
AppCode 静态检查的问题:
基于以上种种原因,使用 AppCode 检查出来的无用代码,还需要人工二次确认才能够安全删除掉。
在 App 的不断迭代过程中,新人不断接手、业务功能需求不断替换,会留下很多无用代码。这些代码在执行静态检查时会被用到,但是线上可能连这些老功能的入口都没有了,更是没有机会被用户用到。也就是说,这些无用功能相关的代码也是可以删除的。
通过 ObjC 的 runtime 源码,我们可以找到怎么判断一个类是否初始化过的函数,如下:
#define RW_INITIALIZED (1<<29)
bool isInitialized() {
return getMeta()->data()->flags & RW_INITIALIZED;
}
isInitialized
的结果会保存到元类的 class_rw_t
结构体的 flags 信息里,flags 的 1<<29 位记录的就是这个类是否初始化了的信息。而 flags 的其他位记录的信息,你可以参看 objc runtime
的源码,如下:
// 类的方法列表已修复
#define RW_METHODIZED (1<<30)
// 类已经初始化了
#define RW_INITIALIZED (1<<29)
// 类在初始化过程中
#define RW_INITIALIZING (1<<28)
// class_rw_t->ro 是 class_ro_t 的堆副本
#define RW_COPIED_RO (1<<27)
// 类分配了内存,但没有注册
#define RW_CONSTRUCTING (1<<26)
// 类分配了内存也注册了
#define RW_CONSTRUCTED (1<<25)
// GC:class 有不安全的 finalize 方法
#define RW_FINALIZE_ON_MAIN_THREAD (1<<24)
// 类的 +load 被调用了
#define RW_LOADED (1<<23)
既然能够在运行中看到类是否初始化了,那么我们就能够找出有哪些类是没有初始化的,即找到在真实环境中没有用到的类并清理掉。
具体编写运行时无用类检查工具时,我们可以在线下测试环节去检查所有类,先查出哪些类没有初始化,然后上线后针对那些没有初始化的类进行多版本监测观察,看看哪些是在主流程外个别情况下会用到的,判断合理性后进行二次确认,最终得到真正没有用到的类并删掉。
多人开发的项目可能存在大量复制粘贴代码,可以通过 PMD 扫描重复的代码片段,再结合实际逻辑重构代码。
PMD 是一个代码静态扫描工具,直接通过 brew 命令安装。
brew install pmd
安装完成后,通过 PMD-CPD 即可得到重复代码信息,格式如下:
pmd cpd --files 扫描文件目录 --minimum-tokens 70 --language objectivec --encoding UTF-8 --format xml > repeat.xml
其中,--files
用于指定文件目录,--minimum-tokens
用于设置最小重复代码阈值,--format
用于指定输出文件格式,支持 xml/csv/txt
等格式,这里建议使用 xml
,方便查看 。
生成的 XML
文件内容如下,根据 file 标签信息就能定位到重复代码位置。
<?xml version="1.0" encoding="UTF-8"?>
<pmd-cpd>
<duplication lines="87" tokens="565">
<file column="1" endcolumn="3" endline="197" line="111"
path="/Users/github/iOS-Develop-Tools/IOSDevelopTools/IOSDevelopTools/OCTimeConsumeMonitor/YECallMonitor.m"/>
<file column="1" endcolumn="3" endline="152" line="66"
path="/Users/github/iOS-Develop-Tools/IOSDevelopTools/IOSDevelopTools/OCTimeConsumeMonitor/YECallTraceShowViewController.m"/>
<codefragment><![CDATA[}
#pragma private
- (NSUInteger)findStartDepthIndex:(NSUInteger)start arr:(NSArray *)arr
{
NSUInteger index = start;
if (arr.count > index) {
YECallRecordModel *model = arr[index];
int minDepth = model.depth;
int minTotal = model.total;
for (NSUInteger i = index+1; i < arr.count; i++) {
YECallRecordModel *tmp = arr[i];
if (tmp.depth < minDepth || (tmp.depth == minDepth && tmp.total < minTotal)) {
minDepth = tmp.depth;
minTotal = tmp.total;
index = i;
}
}
}
return index;
}
- (void)setRecordDic:(NSMutableArray *)arr record:(YEThreadCallRecord *)record
{
if ([arr isKindOfClass:NSMutableArray.class] && record) {
int total=1;
for (NSUInteger i = 0; i < arr.count; i++)
{
YECallRecordModel *model = arr[i];
if (model.depth == record->depth) {
total = model.total+1;
break;
}
}
YECallRecordModel *model = [[YECallRecordModel alloc] initWithCls:record->cls sel:record->sel time:record->time depth:record->depth total:total];
[arr insertObject:model atIndex:0];
}
}
]]></codefragment>
</duplication>
</pmd-cpd>
没有开启 Bitcode 时,App 内的 Framework 会包含多个指令集,我们可以手动移除不需要的指令集。
这个存疑:官方的App Thinning
应该也会对这种三方的Framework做一个指令集的拆分,具体实际情况需要验证。
具体可查看 删除 FrameWork 中无用 mach-O 文件
关于bitcode的知识点可以看:关于bitcode, 知道这些就够了
支持bitcode之后,在上传到apple connect的时候苹果,苹果会做一次优化把bitcode中间码转换为对应架构的机器码。这期间符号地址之类的会变,所以原来编译的时候生成的符号表是没用的,需要重新要网站上下载。
原因是我并没有使用过GoogleProtobuf的方法或者类,xcode在编译的时候并不会链接进去,所以就算没bitcode也不会报错。但如果我在代码中使用了相关静态库的代码,则会在编译时链接进去,这时候发现静态库没有bitcode就会编译报错。
#include <google/protobuf/descriptor.h>
#include <google/protobuf/descriptor.pb.h>
- (void)testGoogleProtobuf{
std::string typeName;
typeName = "Hello World";
google::protobuf::Message *message = nullptr;
const google::protobuf::Descriptor* desc = google::protobuf::DescriptorPool::generated_pool()->FindMessageTypeByName(typeName);
}
我们设置了enable bitcode
为YES正常build生成的静态库是有bitcode段,但没有内容的,需要设置Other C Flags
和Other C++ Flags
的值为-fembed-bitcode
编译出来的静态库bitcode段才会有内容。
使用pod package
打包静态库的话使用podspec里加入s.xcconfig = {'OTHER_CFLAGS' => '-fembed-bitcode'}
即可加入bitcode
。
注:有出现源代码文件编的.o文件都有bitcode内容的情况,但pod库创建的dummy.m
文件生成的.o文件没有bitcode内容,其实这个dummy.m
没啥用,可以删除。pod加这个文件主要是因为Xcode的编译是依赖.m文件的,如果一个库里没有.m文件,将不会被编译,为了防止这种情况就会在每个库里增加一个空的.m文件。
多个类中,可能有近义词的方法名,尽量方法名相同。因为MachO文件中__cstring
和__objc_methname
这两个代码段记录的是方法名字符的ASCII码的十六进制表示。如果多个类中有相同的方法名,相同的方法名会进入link map
的Dead Stripped Symbols
中,最后只留一个。
以原包体积169.3M算,优化后为168.5M,实际优化1M不到
应用 Swift 静态库的各种坑
深入探索 iOS 包体积优化
iOS 优化IPA包体积(今日头条)
今日头条 iOS 安装包大小优化—— 新阶段、新实践