安装包组成:
谈到 App 瘦身,最直接的想法莫过于分析一个安装包内部结构,了解其每一部分的来源。解压一个 ipa 包,拿到其 payload 中 app 文件的数据,整理归类后其大致如下:
Exectutable: 可执行文件
Resources:资源文件
2.1 图片资源:Assets.car/bundle/png/jpg 等
2.2 视频/音频资源:mp4/mp3 等
2.3 静态网页资源:html/css/js 等
2.4 视图资源:xib/storyboard 等
2.5 其他:文本/字体/证书 等
- Framework:项目中使用的动态库
3.1 SwiftSupport: libSwiftxxx 等一系列 Swift 库
3.2 其他依赖库:Embeded Framework - Pulgins:Application Extensions
- CocosPods
appex:其组成大致与 ipa 包组成一致
从以上结构中可以看出一个 ipa 包大致由 Executable, Resources, Framework,Plugins 四大模块组成,接下来我们就从这四个方向来探讨 App 瘦身的具体方案。
一、资源瘦身
资源瘦身主要是去除无用资源或者压缩资源。
资源主要包括图片、音频、视频、多语言包、配置文件等。
无用资源指的是,项目中没有被引用的资源,找到的办法就是,去项目中搜索该文件名,图片资源去掉@2x,@3x,没有搜索到的就是无用资源。
当然,资源名在项目中另外拼接的,特殊处理,所以一个APP最好有一个统一的拼接格式。
压缩资源,一般指图片的压缩,图片资源控制在80k左右(@3x的全屏图片);资源压缩主要对png进行无损压缩,用的是ImageOptim工具和compress命令(需要安装XQuartz-2.7.5.dm插件)。不建议对资源做有损压缩,有损压缩需要设计一个个检查,通常压缩后效果不尽人意。
还有就是配置文件的压缩,比如内置的离线资源等。
1.1 审查安装包中的每个文件
审查安装包中的每个文件是最为简单有效的挖掘优化点的方式,在包大小优化过程中也应被反复执行。
1.2 Jimu app原生图片资源缩身
在本次包大小优化期间,需要支持iOS7-iOS11之间的系统。与苹果建议的方式一致,使用asset catalog来管理图片。绝大部分图片均为png格式。每种图片都加入了2x图和3x图。以下的优化和讨论也将基于这个前提。
对于图片资源文件的优化,我们主要采用了3个思路:
(1)图片压缩
(2)将图片放置到云端
(3)排查和清除冗余图片
3.1 图片压缩
想要优化资源文件,可能大家想到的第一个优化方式就是压缩。而更进一步,我们也考虑了是否能用webP等空间占用更小的格式来替换png图片。
3.1.1 png图片压缩
我们尝试了一个小有名气的png压缩工具:ImageOptim。这个工具能够在不改变图片质量的情况下压缩图片的大小。打开设置,我们能看到和选择它使用的压缩算法。
ImageOptim会对每张图片分别应用以上几种压缩算法,然后对比每种压缩算法产出的图片,选取最小的那张作为输出结果。
我们使用ImageOptim对工程中几乎所有的图片做了一次压缩。整个过程持续了若干小时。在压缩过程中,我们发现,大部分图片都能被压缩到原来的70%左右,个别图片能获得更高的压缩比。
ImageOptim的表现无疑是可观的。然而当我们满怀期望的提交修改、打包后,得到的结果却有点出乎意料。虽然工程中的图片都经过了ImageOptim压缩,但我们的ipa大小并没有什么变化。
在查阅了一些文档后,我们了解到,Xcode在构建的过程中,有一个步骤叫做compile asset catalog。在这个步骤中,Xcode会自行对png图片作压缩,并且会压缩成能够快速读取渲染的格式。如果我们对工程中的图片进行了ImageOptim的压缩,在compile asset catalog的过程中,Xcode会用自己的算法重新压缩,而这个”重新压缩“的过程,相当于将ImageOptim的压缩“回滚“了,很可能反而增大了图片。
这也就表明了,无论我们怎么压缩工程中的png图片,对包大小优化来说都是徒劳的。(但用ImageOptim工具压缩jpg图片还是有效的。)
使用8-bit的PNG图片,比32-bit的图片能减少4倍的压缩率。由于8-bit的图片支持最多256种不同的颜色,所以8-bit的图片一般只应该用于一小部分的颜色图片。例如灰度图片最好使用8-bit。
寻求Xcode中与图片相关的配置项
我们对png格式没有做深入研究,所以这里不能清楚解释这两个压缩过程究竟做了什么。但是Xcode让png图片“增大”的行为还是让我们感到不甘,于是我们开始寻求是否有一些配置项能够关闭Xcode的压缩过程。
Xcode 提供的给我们两个编译选项来帮助压缩 PNG 资源:
Compress PNG Files:打包的时候自动对图片进行无损压缩,使用的工具为 pngcrush,压缩比还是相当高的,比较流行的压缩软件 ImageOptim 也是使用 pngcrush 进行压缩 PNG 的。
Remove Text Medadata From PNG Files:能帮助我们移除 PNG 资源的文本字符,比如图像名称、作者、版权、创作时间、注释等信息。
项目引进的 PNG 资源都是自动被 Xcode 进行压缩了,所以完全不需要自己再去用工具压缩一遍。当除非你是使用 bundle 管理的资源,因为 bundle 是直接拷贝进项目,并不会被 Xcode 进行压缩;JPG 或者其他类型的图片资源可以使用 ImageOptim 进行无损压缩然后导入到 Xcode 中,为了提高效率建议还是提供 PNG 格式的图片。
iOS 9 中引入的 App Thinning 中提到过 Slicing 的技术,当我们把一个完整的安装包提交给 App Store 后,App Store 会为不同的设备准备不同的变体(Variant),设备的在下载 App 的时候它能帮助我们自动选择合适的 Variant 进行下载。
可执行文件的 Slicing 技术就是上面所说的 BitCode,同样资源文件也是支持 Slicing 的。比如 iPhone 6 下载的安装包中就只会包含 2x 图,iPhone 6 Plus 下载的安装包就只会包含 3x 图,但是只有使用 asset catelogs(也就是 XCAssets) 管理的资源才支持 Slicing,所以尽量还是使用 XCAsset 来管理资源图片。同时 XCAsset 也支持 PDFs 矢量图,在上传到 App Store 之后,会根据矢量图自动生成 1x, 2x, 3x 图,然后进行 Slicing。
当然 XCAsset 也有它的存在的问题:
使用 XCAsset 管理的资源会被压缩并打包成一个 Asset.car 文件,我们无法获取相应图片的物理路径,因此我们无法使用 [UIImage imageWithContentsOfFile:] 的方式来获取图片。对于那些需要使用物理路径的方式来访问的图片,建议还是直接拖拽到 App 中进行管理。
iOS 10.3 推出的更换 App Icon 的资源文件只能放在 App 根目录下进行管理。
使用 XCAsset 管理图片后,Xib/Storyboard 中设置的带后缀 .png 图片在 Interface Builder 是不可见的,都是显示的问号,但是运行起来是没有问题的。最好的做法是全局搜索并去掉后缀保证更好的开发体验。
可能的配置项:
Compress PNG Files (COMPRESS_PNG_FILES)
Optimization (ASSETCATALOG_COMPILER_OPTIMIZATION)
经过试验,Compress PNG Files选项对asset catalog中的资源无效,因为这个选项仅适用于零散资源文件。
Optimization置为space也对包大小没有任何影响,原因有两点:
(1)app工程使用cocoapods管理,并且命中了cocoapods合并asset catalog的策略,asset catalog的编译过程在cocoapods生成的脚本中,故build settings中的设置无效;
(2)Optimization参数只对最低支持iOS8及以上的app起作用。具体的分析过程下文中将介绍。
寻求修改构建过程
由于无法用正当途径跳过、改变Xcode对png的压缩过程,我们还抱着希望想寻求是否有其他trick的方式能够阻止Xcode压缩图片。
比如我们是否可以在编译期间插入脚本来干预compile asset catalog的过程?我们是否可以更改build rule来定制对asset catalog的处理方式?
在编译过程中,我们能看到compile asset catalog这个过程使用的工具是actool。这是一个内置在Xcode里的工具。我们可以在以下路径中找到actool:
遗憾的是,actool并非一个脚本,而是一个编译完成的二进制文件。这就导致compile asset catalog的过程变成了完全的黑盒。 我们尝试了直接将actool工具删除,但这样会直接导致无法构建成功。显然Xcode的设计者不会希望用户干预它的构建过程。
经过分析Jimu app工程的构建过程,我们发现,由于Jimu app的工程使用cocoapods进行了库管理,并且Jimu app的工程满足了一些条件,实际上真正有效执行asset catalog编译的过程是在[CP] Copy Pods Resources这个脚本中。这也是上文中设置Optimization参数无效的原因之一。
这个脚本调用了actool工具完成了最后一步:
在这里我们可以看到actool的一些参数,改动这些参数是否能改变actool的压缩策略呢? 遗憾的是,我们测试了去掉--compress-pngs参数、增加--optimization time和增加--optimization space参数,发现这些改动对包大小都没有任何影响。
为什么增加--optimization time和增加--optimization space参数对包大小没有影响呢?这个结果显然非常不符合预期,而网络上关于ASSETCATALOG_COMPILER_OPTIMIZATION参数的文档也甚少,让我们疑惑不已。
通过demo实验,我们发现,当工程不依赖cocoapods时,build setting中修改optimization选项是有效的,那理论上,在脚本调用actool时传入--optimization space,应该也能起作用才对。
经过将Jimu app工程多次与demo对比,最终我们发现了问题的根源:Jimu app的工程最低支持iOS7,而optimization参数似乎在iOS8及以后才能起作用。在这一轮包大小优化期间,我们还无法放弃iOS7,所以optimization的思路只能就此终止。
如果考虑修改build rule呢?我们是不是可以尝试使用自定义的工具编译asset catalog?但经过尝试,这个方法似乎也行不通。因为build rule是用来处理Xcode不认识的源代码类型的,并不能改变已有类型的编译方式。
至此,我们企图压缩asset catalog中png图片的想法暂时就告终了。从这个过程中,我们能看出,Xcode对于png图片的压缩方式进行了很强的控制,它似乎不允许第三方开发者干预png图片的压缩过程。
3.1.2 使用webP替代png
压缩实验失败后,我们仍然不甘心止步于此。由于开发者难以干预asset catalog内的图片,一个自然的想法产生了:我们是否能废弃asset catalog?
废弃asset catalog可能能带来以下两个收益:
(1)可以考虑将png图片切换到webP等其他格式
(2)废弃Asset Catalog后,可以删去2x的图片,只保留3x的图片。经过hook改造系统方法的实现,我们验证了这个想法是可行的。
考虑到app从asset catalog中读取图片可能比从bundle中读取图片有更高的性能,所以在开发过程中,启动阶段的图片依然被保留在了asset catalog中。最终10.5MB的asset.car文件被优化成了3.6MB的asset.car+3.6MB的零散资源文件,看起来减少了3.2MB,是一个比较可观的数值。
app slicing
然而在这一系列优化过程中,我们仅仅关注了内部平台构建出的安装包的包大小,而忽视了app store中用户看到的包大小值。实际上后者才是真正影响到转换率等核心指标的关键。
经同事提醒,我们这样的优化方法和苹果提供的app slicing优化有冲突。实验后我们发现,废弃asset catalog事实上可能会导致包大小不减反增。
app slicing是iOS9增加的功能。当用户从app store上下载app时,可以只下载适用于其设备的app架构版本和所需资源,从而减少app所占的空间。
如果开发者想要使用app slicing,只需要将资源文件用Asset Catalog管理,不需要做额外的任何事情。 因此,app已经有了app slicing的效果,用3x的设备查看app store中的“Jimu app”,显示的包大小比2x的设备大3MB(而没有使用asset catalog的腾讯新闻,两个手机显示的包大小是一致的)
使用Xcode的archive方式构建的安装包,可以在导出ipa时制定相应的设备,来测试app slicing功能。 尝试后我们发现,对于2x的设备,废弃asset catalog反而会导致安装包增加1MB,而对于3x的设备,废弃asset catalog能优化安装包大小1MB。然而显然,这样的优化是得不偿失的。
3.2 将图片放置到云端
将部分图片放置到云端,等到用户需要时再去下载,这看起来也是一个优化安装包大小的方法。我们对苹果提供的On Demand Resources功能进行了尝试,也自行开发了资源包下载逻辑。
3.2.1 On Demand Resource
苹果从iOS 9开始引入了On Demand Resource功能,即一部分图片可以被放置在苹果的服务器上,不随着app的下载而下载,直到用户真正进入到某个页面时才下载这些资源文件。
我们考虑可以让某些业务仅在iOS 9及以后版本中可用,然后应用On Demand Resource来优化这些业务的资源。
经过了一段时间的开发实验,一切都如同预期,当我们以为On Demand Resource是一个可行的思路时,我们却发现了一个Xcode巨坑的问题:当工程需要支持iOS9以下系统时,Xcode会在打包完成上传app store时失败。On Demand Resource的想法只能搁置。
3.2.2 资源文件云端下载
由于On Demand Resource实验失败,我们自行开发了一套云端下载流程,并且对个别个别大图(几乎为全屏大小的图片)进行了尝试。首批图片精简后,安装包大小减少了1.1MB。
云端下载的策略为:
(1)在若干时机尝试下载zip图片包,对zip包进行版本判断,若云端有更新版本,则根据屏幕是3x还是2x,下载对应的zip包,解压存入沙盒中;
(2)在读取图片时,首先从bundle中读取,若失败,则从沙盒中读取,若依然失败,则将该图片当作一个网络图片进行请求,确保图片能被展示。
经过线上测试,大约95%的场景下,用户可以从沙盒中成功读取图片,剩下约5%场景下用户会将图片当作网络图片来请求。当然这个实验结论会随着图片所在的页面层级变化。
3.3 排查和清除冗余图片
资源文件云端下载虽然是一个优化安装包大小的有效思路,但多少对用户体验有一些影响。所以我们又将优化的重点放到了排查和清理冗余图片上。最后的结果证明,排查和清理冗余图片的确能带来客观有效的优化。
出去正常的排查冗余图片的流程,我们还在不断审视安装包内容时收获了意外的发现。整个排查和清除冗余图片可以分为三个方向:
(1)常规的冗余图片清理
(2)修复cocoapods带来的图片重复合并问题
(3)利用tint color精简单色图标
3.3.1 常规的冗余图片清理
随着业务迭代,有不少图片成为了永远也不会使用到的僵尸图片。这些图片往往占据着较大空间,对于冗余图片的排查和清理是包大小优化中便捷而有效的一项优化内容。 Jimu app iOS端在三轮包大小优化中都进行了冗余图片排查,每次都能清理出的图片体现在ipa上的大小都在500KB以上,相对而言是比较可观的数值。
使用一个开源的Mac app,LSUnusedResources,来进行冗余图片的排查: https://github.com/tinymind/LSUnusedResources
这个app的原理是,对某一文件目录下所有的源代码文件进行扫描,用正则表达式匹配出所有的@"xxx"字符串(会根据不同类型的源代码文件使用不同的匹配规则),形成“使用到的图片的集合”,然后扫描所有的图片文件名,查看哪些图片文件名不在“使用到的图片的集合”中,然后将这些图片文件呈现在结果中。
推荐使用 FengNiao 来自动删除图片,因为其相对比较新,是 2017 年开始开发的,并且是使用 swift 语言开发的,方便进行二次开发。FengNiao 的基本原理是查找出项目中所有使用到的字符串和项目中所有的资源文件。两者进行匹配(完全匹配和模式匹配,模式匹配支持带数字资源的前缀/中缀/后缀匹配),计算差集就为未使用的资源。
相比于之前流行的 LSUnusedResources,FengNiao 支持模式匹配会更加强大:比如我们导入 image_01 image_02 image_03 这样的图片资源作为帧动画素材,使用的时候是 image_%d 或者 image_(index) 方式,FengNiao 会把这些图片资源作为使用中的资源,不会出现误删的情况。当然如果你还是用了其他 Pattern,可以考虑扩展 FengNiao。
除了这些之外,FengNiao 是命令行工具,我们可以给 Xcode 添加 Run Script,在每次构建的时候自动检测/清理未使用的资源。
由于基于源码的扫描工具结果不是百分百准确的,所以建议最好的做法是在项目编译的时候提供出显式的 Warning,然后再次确认之后再去删除。同时也可以配合资源命名规范来优化工具,如果你们的命名规范和工具的检测规范能够保持一致的话,搜索的结果无疑是最为准确的。
之所以要使用自动化工具来检测重复资源的原因是因为资源是弱类型,我们在项目迭代过程中手动去维护是相当麻烦的一个过程。转换一下思维,如果资源变成强类型了,那我们维护起来就相当容易了。目前就有这样一个工具-R.swift,类似于 Android 开发中的 R 文件,有兴趣的可以去尝试。
3.3.2 修复cocoapods带来的图片重复合并问题
Jimu app重度使用cocoapods进行库管理。随着平台化的进行,越来越多的代码被封装成了pod库,以库的形式集成进工程中。在排查安装包内资源文件的过程中,我们也发现了2个由cocoapod带来的“图片重复合入安装包”的问题。这两个问题的解决,也给安装包大小优化带来了700KB左右的优化。
png文件和asset catalog重复合入安装包
在排查安装包内容时,我们发现.app文件的最外层,有一些预期外的零散资源文件。Jimu app的资源文件绝大部分都是用asset catalog管理,仅有个别图片以零散png的形式打入安装包中。这些图片的出现不符合预期。
经过排查,我们发现这些图片来自于一个pod库。而奇怪的是,这个pod库的确是使用asset catalog进行资源文件管理的,为什么图片还会以png的形式进入到安装包中呢?
原来,这个pod库在编写podspec的时候,用了这样的语句指定资源文件:
我们使用demo进行了测试,发现podspec中这样书写,会导致asset catalog中的图片,既作为asset catalog被合并到主工程的asset.car中,也会作为png被拷贝到安装包中。导致其中一套图片白白占用了安装包空间。
在这个例子中,使用通配符来指定pod库中的资源文件显然是不合理的,会带来不可预期的陷阱。应该以白名单的形式明确指定哪些资源文件是pod库中有效的资源文件。
cocoapods暴力合并工程内asset catalog问题
在更新另一个业务方的pod库的时候,我们还发现了一个资源文件被重复合入安装包的问题。
Pod库在podspec中是这样指定资源文件的:
在业务方自己的独立app和pod库的样例工程中,这样指定资源文件没有任何异常。但是当这个pod库接入到Jimu app中时,我们却发现包大小的增长超过了预期。
简单排查发现,这个Image.xcassets中的图片,既作为了一个单独的asset.car被放入了名为MyPod的bundle中,又被合并到了主工程的asset.car中,而后者是预期之外的。这导致这些图片在安装包中存在了两份。
究其原因,我们发现原来这是cocoapod的一个缺陷导致的。
在工程构建的最后一步,会执行一个Copy Pods Resources的步骤,该步骤就是执行一个Pods-NewsInHouse-resources.sh脚本,脚本内容在pod install的时候生成。 这个脚本的最末几行有这样的一个操作:
即如果工程符合某些条件,则找到工程目录下所有的xcassets,使用xcode的actool工具将这些xcassets合并为一个assets.car文件。
这里合并的是“工程目录下的所有xcassets”,也就是说,不管这个xcassets针对的是哪个target,是否被工程使用了,只要它在工程的某个子文件夹下,就会被打包进安装包中。
显然这样的暴力合并可能导致安装包莫名其妙增大、图片资源莫名其妙冲突等问题。
暴力合并需要工程符合什么样的条件?第一行的if语句列出了三个条件:
(1) WRAPPER_EXTENSION是一个环境变量,构建iOS app是一般为app,所以我们工程肯定符合
(2) xcrun —find actool查找xcode的环境中是否有actool工具,我们的工程肯定符合
(3) XCASSET_FILES是一个数组,其中有几个元素取决于有多少个pod,将xcassets写到了s.resoures中。这个条件目前Jimu app的工程符合,而业务方独立app和样例工程不符合,所以Jimu app的工程符合以上三个条件,该脚本会执行暴力合并步骤。
为什么cocoapods需要这样暴力合并?
因为主工程的xcasset命名不规律,文件存储位置不规律,cocoapods的开发者也找不到更好的方法来准确合并所有需要的xcassets文件,所以只能采取这种暴力的方式。
如何避免这样的暴力合并?我们思考是否能通过制定pod库接入规范来杜绝podspec中resource_bundles的指定方式,但显然这样的规范没有什么合理性,也难以得到业务方的认同。
于是我们转而思考是否能通过技术手段来填补cocoapod的缺陷?工程中的xcasset的确有无法规范命名、存储位置不规律的问题,但是它们都属于某个target,可以通过target来检索到所有应该合并的xcasset文件。
目前我们执行的解决方案是:在build phase中,在执行copy pods resources之前,执行一个脚本,替换Pods-NewsInHouse-resources.sh脚本的某一行,用更合理的合并方式取代暴力合并。
替换掉的一行是:(这一行会找出工程根目录下所有的xcassets)
替换为:(get_all_xcassets是我们写的一个ruby脚本,这一行的作用是利用xcodeproj工具找出当前target的build phase中的copy bundle resources中的所有xcassets)
也就是说,替换后我们不再暴力合并工程根目录下所有的xcassets,而只是合并当前target需要的xcassets。
回过头,我们再来分析一下指定resource_bundles和指定resources的区别。
Resource_bundles是cocoapods 0.23.0加入的一个属性,比起resources,cocoapods官方更推荐使用resource_bundles:
We strongly recommend library developers to adopt resource bundles as there can be name collisions using the resources attribute. Moreover, resources specified with this attribute are copied directly to the client target and therefore they are not by Xcode.
Cocoapods的文档中提到了两点:
(1)使用resource_bundles能大大减小命名冲突的概率
(2)使用resources资源文件是直接拷贝到app中的(对于xib,xcassets等,cocoapods会用脚本进行编译),没有经过Xcode的优化(此处指的应该是零散的png)
在构建过程中,使用resources的资源文件,会在构建的最后一步Pods-NewsInHouse-resource.sh中被拷贝到app中。 使用resource_bundles的资源文件,在构建pod时,就已经被合并到bundle中了,最后在Pods-NewsInHouse-resource.sh中这些bundle被拷贝到app中。 放在resouce_bundles中的资源文件,整个构建过程更符合Xcode的构建方式,能应用Xcode的优化,跟进Xcode的版本,所以一般情况下更推荐将资源文件放到resource_bundles中。
制定pod库资源文件规范
经历了两次pod库资源文件合并带来的陷阱,我们认为有必要制定一个资源文件接入规范。
对于最低支持iOS7的pod:
我们推荐使用resource配合xcassets的方式来集成各个插件中的资源 具体的做法有:
1、Pod中的资源文件建议使用xcassets组织
(1)xcassets需要添加到podspec的resources中
(2)xcassets中的图片名,必须使用前缀;(xcassets间的命名冲突会导致读取的图片不可预期)
2、如果pod中有资源文件没有用xcassets组织
(1)这些资源文件必须放入resource_bundles中,禁止放入resource中;(resource_bundle中的资源在构建期能经过Xcode的优化,而resource中的资源不能)
对于最低支持iOS8的pod:
我们推荐使用resource_bundles配合xcassets的方式来集成各个插件中的资源文件。 具体的做法有:
1、Pod中的资源文件建议使用xcassets组织
(1)xcassets需要添加到podspec的resource_bundles中
(2)Pods中的代码,在读取图片资源时,使用imageNamed:inBundle:compatibleWithTraitCollection:读取(该方法最低支持iOS8),无法使用imageNamed:读取
2、如果pod中有资源文件没有用xcassets组织
(1)这些资源文件必须放入resource_bundles中,禁止放入resource中;(resource_bundle中的资源在构建期能经过Xcode的优化,而resource中的资源不能)
如果该pod不需要支持iOS7,则更支持使用后者方式,这样做的优点有:
(1)各个pod管理各自的资源文件,不会有命名冲突的问题
(2)能利用苹果的app slicing功能
(3)防止cocoapods暴力合并所有xcassets的引起其他潜在问题
3.3.3 利用tint color精简单色图标
在浏览了安装包内所有的图片文件后,我们产生了一个直观的感受:由于有日夜间模式,导致大部分图标都切出了日间和夜间两套图标,而这两套图标的形状是完全一致的,只是颜色有差异。
如果能结合tint color对单色图标做一次精简,对安装包大小和图标的管理都有正向的影响。
tint color是苹果在iOS7推出的功能,我们可以读取一个图标,然后给它赋予一个color值,在手机屏幕上它就能显示出相应的效果。tint color适用于对单色图标进行着色,相比于其他精简图标的解决方案,tint color方便、可靠、拥有原生支持。
精简图标的工作需要各业务端共同参与,可以预计将消耗较大的人力成本。为了尽量减轻业务方的负担,我们提前做了一些预备工作,包括筛选、色值抽取、色号匹配、分配到人等。这些工作均使用脚本完成。
最终我们筛选出了大约3MB、1500+张形状重复的图片,理想情况下可以精简掉其中的一半。
最后我们将候选图片以这样的文件名输出:
image
文件名中包含了精简所需要的全部信息,便于业务方接入。
image
为了将图片中的有效信息抽取出来放在文件名中,我们提前做了以下这些工作:
(1)获取ipa内的全部图片
使用工具 iOS Images Extractor可以帮助解压asset.car文件,获得ipa内全部图片。
(2)筛选“形状一致”的图片
由于我们对图像处理并没有做深入研究,所以使用了一个拍脑袋想出来的朴素方法:获取一张图片所有像素点的alpha值,alpha值完全一致的两张图片,就是“形状一样”的图片。
我们使用了ImageMagick这个工具抽取图像的每个像素点值,然后对所有alpha值做md5计算。经过目测,使用这个方式来筛选形状一致的图片还是比较有效的。
(3)获取单色图片的色值
使用ImageMagick工具,抽取图像每个像素点值,排除掉全透明的点,然后找到色值的众数,则可以认为是该单色图片的色值。
(4)获取图片的色号
拥有了色值之后,有些app可能就可以直接用色值来做后续开发了。但是Jimu app中不允许使用色值,必须使用UI规范中的“色号”,比如“面1”、“字1”之类的。
同时我们希望矫正那些“有一点偏差”的色值。
下图底色为标准色,而icon的颜色其实并不是标准色,有一点差,但是肉眼基本看不出来,可能是设计师在作图时手抖了。这种情况下我们就需要做“矫正”。
image
这个问题也可以表述为:如何将一个色值匹配到与它最接近的标准色上?
对图像没有研究,经过一番google,我知道了这个命题的关键字叫做“color distance”,于是又一番google,得知了一些公式,比如:http://colormine.org/delta-e-calculator/
最后找了一个开源的工具:http://chir.ag/projects/ntc/
这是一个js的工具,能将一个色值匹配到与它最接近的某个颜色名称上。
于是我直接将Jimu app的标准色色值给复制到了原码里。于是这个脚本可以完成的工作是:输入随便一个色值,输出与之最接近的Jimu app标准色色值。
最后,如何将标准色值再映射到“字x”、“面x”呢,这就需要找到一张图的日间模式和夜间模式,然后用两个标准色值去找色号名字。
(5)将图片分配到人
精简图标的工作需要各业务方来推动,所以在做准备工作时,我们需要将每个图标分配到各业务方。由于仅凭肉眼很难判断一个图标是属于哪个业务的,所以我们使用了git log作为分发依据,以谁添加谁负责为原则。
对于指定的图片名,我们首先使用mdfind命令找出它所在的路径,然后读取git log,查询到该图片的添加者,完成分类。
使用tint color着色,不仅能精简掉形状相同的夜间模式图标,可能对日间模式图标还能带来优化空间。
在使用tint color着色后,单色图片自身的颜色(RGB色值)便失去了意义。图片提供的全部信息实际就只有alpha通道的信息。在这种情况下,考虑将图片转为灰度图可以进一步缩减图片体积。
整个tint color的接入工作还在进展过程中。
1.3 Jimu app原生音频资源缩身
- 参考WWDC中的Audio Development for Games,里面介绍了如何有效的处理音频。常规来说,我们要使用AAC或MP3来压缩音频,并且可以尝试降低一下音频的比特率。有时候44.1khz的采样是没有必要的,稍微低一点的比特率也不会降低音频的质量。
1.4 Jimu app原生视频资源缩身
视频/音频等图片资源相对图片来说会大很多,所以建议把视频/音频放在服务端,客户端在使用的时候进行下载或者使用流播放。
1.5 Jimu app Unitiy资源缩身
1.6 Jimu app Blockly资源缩身
1.7 html资源缩身
H5 资源也是建议放在服务端,如果对 H5 加载和离线访问有要求的话,可以使用离线缓存的方式来缓存网页资源到本地。
1.8 视图资源
这里所说的视图资源是指 xib/storyboard。xib 在打包时会被压缩为 nib 文件,storyboard 文件会被压缩为 storyboardc 文件,storyboardc 是个压缩包,内部包含了相应的 nib 和 一个 plist 文件。一般的 nib 文件压缩后在几 KB 到几十 KB 大小,这部分包大小的影响相对于 xib 能提高开发效率来说影响是微乎及微的,网易漫画 App 中使用到了 257 个 xib 文件,但是其在 payload 中的数据仅仅只有 1.7M 大小。
1.8 Framework 和 Framework中的资源
Framework 文件夹存放的是 Embedded Framework,它在打包的时候最终会被拷贝进 Target App Bundle 中的 Framework 文件夹中,在 App 启动的时候才会被链接和加载。Embedded Framework 主要分类两类:
SwiftSupport:Framework 文件夹中前缀是 libSwift 的一些 framework。由于目前 Swift ABI 还未稳定,我们发布应用的时候还需要带上一份自己应用中使用到的 Swift 标准库代码,这部分占用最终 ipa 的大小可能在 10M 左右。
其他依赖库:使用 Cocoapods 管理依赖并且设置了 user_framework! 时三方库源码都会打包成 framework,然后导入到工程当中。
Framework中的资源
这里所说的 Framework 表示的是: 静态库(.a) Framework(Static Library)
目前绝大部分的 Framework 的做法是直接将资源放进 bundle 中进行管理的,在主工程打包的时候,Xcode 会将这部分资源直接拷贝进 App Target Bundle 中,这样做就存在2个问题:
使用 bundle 管理的资源是不会被 Xcode 优化的(图片压缩等)
使用 bundle 管理的资源不享受 App Thinning/Slicing。
所以尽量还是选择 XCAsset 进行 Framework 的资源管理,静态库和动态库的管理方式有所不同:
静态库(.a)/Framework(Static Library): 静态库的目标文件(.a/.framework) 中是不能包含资源文件的,所以这部分只能使用 bundle 来管理。但是由于 bundle 直拷贝的特性,我们需要把 xib/storyboard/asset catalog 编译后的产物(nib/storyboardc/Asset.car)放进 bundle 里。比较普遍的一个做法是借助 Bundle Target 来编译我们的资源文件,具体做法看这篇文章。
动态库: 动态库相对来说要简单一点,因为动态库本身就是一个 bundle。所以我们直接把资源文件放在目标文件(.framework)中就可以了。
如果你是使用 Cocoapods 管理你的源码,也可以使用 XCAsset 来管理资源,参考 在 Cocoapods 中使用 XCAsset。
1.8 Plusins
Plugin 内部主要存放的就是 App Extension,App Extension 是独立打包签名,然后再拷贝进 Target App Bundle 的。
Plugin中的静态库: 静态库最终会打包进可执行文件内部,所以如果 App Extension 依赖了三方静态库,同时主工程也引用了相同的静态库的话,最终 App 包中可能会包含两份三方静态库的体积。
Plugin中的动态库: 动态库是在运行的时候才进行加载链接的,所以 Plugin 的动态库是可以和主工程共享的,把动态库的加载路径 Runpath Search Paths 修改为跟主工程一致就可以共享主工程引入的动态库。
Plugin中的Swift Standard Library:
在 Swift ABI 稳定之前,Swift 标准库会被拷贝进 App 当中。Swift 标准库是动态链接库,也是可以在主工程和其他的 App Extensions 之间共享的,前提当然是所有 Target 使用的 Swift 版本是一致的,否则就会出现意料之外的 bug。 设置共享分为两步:(1)设置 Extension 中的 Always Embed Swift Standard Libraries 为 NO,让编译器不再为 Extension 生成 Swift 标准库。(2)设置 Extension 中的动态库的查找路径为主工程的 Framework 文件夹。
二、可执行文件瘦身
- 在讲可执行文件瘦身之前先介绍Xcode的LinkMap文件。LinkMap文件是Xcode产生可执行文件的同时生成的链接信息,用来描述可执行文件的构造成分,包括代码段(__TEXT)和数据段(__DATA)的分布情况。只要设置Project->Build Settings->Write Link Map File为YES,并设置Path to Link Map File,build完后就可以在设置的路径看到LinkMap文件了。
link map是编译链接时可以生成的一个txt文件,它生成目的就是帮助程序员分析包大小。link map记录了每个方法在当前的二进制架构下占据的空间。通过分析link map,我们可以了解每个类甚至每个方法占据了多少安装包空间。
在编译时开启Xcode build setting中的Write Link Map File开关,Xcode就会生成一份link map文件。
目前已经有不少开源的分析link map的工具,可以输出每个类、每个静态库占用的空间,并进行排序。通过查看link map,我们可以对二进制代码占据的包大小空间有个直观了解,同时在引入第三方库时也可以使用link map作出评估。
...
第一部分列举可执行文件里所有.obj文件,以及每个文件的编号。
- Sections:
第二部分是可执行文件的段表,描述各个段在可执行文件中的偏移位置和大小。第一列是段的偏移量,第二列是段占用大小,Address(n)=Address(n-1)+Size(n-1);第三列是段类型,代码段和数据段;第四列是段名字,如__text是可执行机器码,__cstring是字符串常量。有关段的概念可参考苹果官方文档《OS X ABI Mach-O File Format Reference》
- Symbols:
Address Size File Name
0x100005A50 0x00000074 [ 1] +[WCPayInfoItem initialize]
...
0x10231C120 0x00000018 [ 1] literal string: I16@?0@"WCPayInfoItem"8
...
0x10252A41A 0x0000000E [ 1] literal string: WCPayInfoItem
...
第三部分详细描述每个obj文件在每个段的分布情况,按第二部分Sections顺序展示。例如序号1的WCPayInfoItem.o文件,+[WCPayInfoItem initialize]方法在__TEXT.__text地址是0x100005A50,占用大小是116字节。根据序号累加每个obj文件在每个段的占用大小,从而计算出每个obj文件在可执行文件的占用大小,进而算出每个静态库、每个功能模块代码占用大小。这里要注意的地方是,由于__DATA.__bbs是代表未初始化的静态变量,Size表示应用运行时占用的堆大小,并不占用可执行文件,所以计算obj占用大小时,要排除这个段的Size。
回到我们的可执行文件瘦身问题,LinkMap文件可以帮助我们寻找优化点。
如何进行二进制文件优化
通过审查Jimu app的安装包,发现Jimu app的二进制文件占了相当大的体积(100+MB)。想要优化二进制文件的大小,我们必须精简代码。
在精简代码层面上,我们主要从两个思路着手:使用技术手段排查删减冗余代码、监控代码的增长情况和分布。另外优化编译选项也是行之有效的方法。
2.1 技术手段排查冗余代码
没有被引用的类和方法是可以通过技术手段被筛选出来的。
MachO文件中有__DATA.__objc_classrefs和__DATA.__objc_selrefs段,分别近似于“被使用的类的集合”和“被使用的方法的集合”。通过取差集的方式可以筛选出未被使用的类和方法。
去掉冗余字符串
代码上定义的所有静态字符串都会记录在在可执行文件的__cstring段,如果项目里Log非常多,这个空间占用也是可观的,也有几百K的大小,可以考虑清理所有冗余的字符串。另外如果有特别长的字符串,建议抽离保存成静态文件,因为AppStore对可执行文件加密导致压缩率低,特别长的字符串抽离成静态资源文件后压缩率会比在可执行文件里高很多。另外如果有特别长的字符串,建议抽离保存成静态文件,因为AppStore对可执行文件加密导致压缩率低,特别长的字符串抽离成静态资源文件后压缩率会比在可执行文件里高很多。
通过扫描查找无用代码
扫描无用代码的基本思路都是查找已经使用的方法/类和所有的类/方法,然后从所有的类/方法当中剔除已经使用的方法/类剩下的基本都是无用的类/方法,但是由于 Objective-C 是动态语言,可以使用字符串来调用类和方法,所以检查结果一般都不是特别准确,需要二次确认。目前市面上的扫描的思路大致可以分为 3 种:
基于 Clang 扫描,
基本思路是基于 clang AST。追溯到函数的调用层级,记录所有定义的方法/类和所有调用的方法/类,再取差集。具体原理参考 如何使用 Clang Plugin 找到项目中的无用代码,目前只有思路没有现成的工具。
基于可执行文件扫描
Mach-O 文件中的 (__DATA,__objc_classlist) 段表示所有定义的类, (__DATA.__objc_classrefs) 段表示所有引用的类(继承关系是在 __DATA.__objc_superrefs 中);使用的方法和引用的方法也是类似原理。因此我们使用 otool 等命令逆向可执行文件中引用到的类/方法和所有定义的类/方法,然后计算差集。
基于源码扫描
一般都是对源码文件进行字符串匹配。例如将 A *a、[A xxx]、NSStringFromClass("A")、objc_getClass("A") 等归类为使用的类,@interface A : B 归类为定义的类,然后计算差集。
基于源码扫描 有个已经实现的工具 - fui,但是它的实现原理是查找所有 #import "A" 和所有的文件进行比对,所以结果相对于上面的思路来说可能更不准确。
2.1.1 排查无用类
1.可以先查找无用的OC类,查找无用oc类有两种方式,一种是类似于查找无用资源,通过搜索"[ClassName alloc/new"、"*ClassName "、"[ClassName class]"等关键字在代码里是否出现。另一种是通过otool命令逆向__DATA.__objc_classlist段和__DATA.__objc_classrefs段来获取当前所有oc类和被引用的oc类,两个集合相减就是无用oc类。
此外,使用otool命令可查看__DATA.__objc_classrefs段和__DATA.__objc_classlist段,两者的差集可以认为是定义了但未使用的类。
不过__DATA.__objc_classrefs段和__DATA.__objc_classlist段中都只提供了类在二进制文件中的位置地址,而没有提供类名等可读信息。所以在获取到差集后,还需要结合
命令的输出,将地址转换成可读的类名。
使用脚本筛选出差集对应的类后,还需要进行一遍人工选择。因为动态使用的类、从nib或storyboard初始化的类以及在同一个文件中定义的多个类会被误判为未使用的类。这需要结合业务进行一次梳理。
2.1.2 排查无用方法
所有已经被实现的方法可以通过linkmap来获取,对linkmap做grep操作即可获得结果:
而所有已经被使用的方法可以通过对二进制文件逆向获得。使用otool工具逆向二进制文件的__DATA.__objc_selrefs 段,提取可执行文件里引用到的方法名:
使用这种方法取到的差集,还需要排除掉系统API中的protocol,accessor方法等。
另外以往C++在链接时,没有被用到的类和方法是不会编进可执行文件里。但Objctive-C不同,由于它的动态性,它可以通过类名和方法名获取这个类和方法进行调用,所以编译器会把项目里所有OC源文件编进可执行文件里,哪怕该类和方法没有被使用到。
结合LinkMap文件的__TEXT.__text,通过正则表达式([+|-][.+\s(.+)]),我们可以提取当前可执行文件里所有objc类方法和实例方法(SelectorsAll)。再使用otool命令otool -v -s __DATA __objc_selrefs逆向__DATA.__objc_selrefs段,提取可执行文件里引用到的方法名(UsedSelectorsAll),我们可以大致分析出SelectorsAll里哪些方法是没有被引用的(SelectorsAll-UsedSelectorsAll)。注意,系统API的Protocol可能被列入无用方法名单里,如UITableViewDelegate的方法,我们只需要对这些Protocol里的方法加入白名单过滤即可。
另外第三方库的无用selector也可以这样扫出来的。
2.1.3 extension代码精简
由于Jimu appiOS端最低需要支持iOS7,所以Jimu app中的库都以静态库形式集成。这种集成方式会导致,一旦extension依赖了一些基础库,这些基础库的占用的体积将会全部算入extension的体积中。
在审查安装包内每个文件时,我们发现Jimu app的today extension占用了2MB左右,而extension本身的功能非常简单,显然存在着精简的空间。我们对extension进行了重构,使它尽可能的少依赖基础库,尽可能所有功能都用系统自带的框架完成。最终today extension的大小从2MB缩减为了300KB。
- 扫描重复代码
可以利用第三方工具simian扫描。南非支付copy代码就是这样被发现的。但除此成果之外,扫描出来的结果过多,重构起来也不方便,不如砍功能需求效果好。 - protobuf精简改造
protobuf是Google推出的一种轻量高效的结构化数据存储格式,在微信用于网络协议和本地文件序列化。但google默认工具生成的代码比较冗余,像序列化、反序列化、计算序列化大小等方法都生成在具体的pb类里,每个类的实现大同小异。通过代码分析以及结合protobuf原理,要想把这些方法抽象到基类,派生类提供每个字段相关信息就够了:
field number
field label, optional, required or repeated
wire type, double, float, int, etc
是否packed
repeated的数据类型
另外通过无用selector列表,发现不少pb类属性的getter或setter没有被使用。原先的pb类属性是用@synthesize修饰,编译器会自动生成getter和setter。如果不想编译器生成,则要用@dynamic。甚至我们可以把pb类的成员变量去掉。做法如下:
基类增加id类型数组ivarValues(参考了objc_class结构体ivars做法),用于存放对象的属性值。对象属性值统一用oc对象表示,如果类型是基础类型(primitive,如int、float等),则用NSValue存
重载methodSignatureForSelector:方法,返回属性getter、setter的方法签名
重载forwardInvocation:方法,分析invocation.selector类型。如果是getter,从ivarValues获取属性值并设置为invocation的returnValue;如果是setter,从invocation第二个argument获取属性值,并存放到ivarValues里
重载setValue:forUndefinedKey:、valueForUndefinedKey:,防止通过KVO访问属性Crash
做下性能优化,如pb类在initialize做一次初始化,缓存属性名的hash值,属性的getter、setter方法的objcType等;属性值不用std::map(属性名->属性值),而是改用数组;MRC代替ARC(有些时候ARC自动添加的retain/release挺影响性能的);等等
如何进行编译选项优化
Xcode 支持编译器层面的一些优化优化选项,可以让我们介于更快的编译速度和更小的二进制大小并且更快的执行速度之间自由选择想要进行的优化粒度。
Clang/LLVM 编译器优化选项
我们都知道 Xcode 是使用 Clang 来编译 Objective-C 语言的,Clang 的优化选项在其文档 clang - Code Generation Options 中可以查阅得到。我们的 IDE-Xcode 只提供给我们 6 个等级的编译选项,在 Xcode -> Build Setting -> Apple LLVM 9.0 - Code Generation -> Optimization Level 中进行设置,每个等级的说明,可以参考官方文档
None[-O0]: 编译器不会优化代码,意味着更快的编译速度和更多的调试信息,默认在 Debug 模式下开启。
Fast[-O,O1]: 编译器会优化代码性能并且最小限度影响编译时间,此选项在编译时会占用更多的内存。
Faster[-O2]:编译器会开启不依赖空间/时间折衷所有优化选项。在此,编译器不会展开循环或者函数内联。此选项会增加编译时间并且提高代码执行效率。
Fastest[-O3]:编译器会开启所有的优化选项来提升代码执行效率。此模式编译器会执行函数内联使得生成的可执行文件会变得更大。一般不推荐使用此模式。
Fastest Smallest[-Os]:编译器会开启除了会明显增加包大小以外的所有优化选项。默认在 Release 模式下开启。
Fastest, Aggressive Optimization[-Ofast]:启动 -O3 中的所有优化,可能会开启一些违反语言标准的一些优化选项。一般不推荐使用此模式。
Fastest Smallest[-Os] 极小限度会影响到包大小,而且也保证了代码的执行效率,是最佳的发布选项,一般 Xcode 会在 Release 下默认选择 Fastest Smallest[-Os] 选项,较老的项目可能没有自动勾选。XCode 中设置的选项最终会反应在 Clang 命令上面,打开 build log 可以看到此选项最终的表现形式
Swift Complier/LLVM 编译优化选项
Swift 语言的编译器是 swiftlang,同时也是基于 LLVM 后端的。Xcode 9.3 版本之后 Swift 编译器会提供新的选项来帮助减少 Swift 可执行文件的大小:
No optimization[-Onone]:不进行优化,能保证较快的编译速度。
Optimize for Speed[-O]:编译器将会对代码的执行效率进行优化,一定程度上会增加包大小。
Optimize for Size[-Osize]:编译器会尽可能减少包的大小并且最小限度影响代码的执行效率。
Xcode 9.3 以前和优化选项混杂在一起的编译模式可以独立设置了:
Single File:单个文件优化,可以减少增量编译的时间,并且可以充分利用多核 CPU,并行优化多个文件,提高编译速度。但是对于交叉引用无能为力。
Whole Module:模块优化,最大限度优化整个模块,能处理交叉引用。缺点不能利 用多核 CPU 的优势,每次编译都会重新编译整个 Module。
在 Relese 模式下 -Osize 和 Whole Module 同时开启效果会发挥的最好,从现有的案例中可以看到它会减少 5%~30% 的可执行文件大小,并且对性能的影响也微乎其微(大约 5%)。参考官方文档 和 SwiftCafe。
此选项虽然是 Xcode 9.3 支持的,但是我们发现 Xcode 9.2 对应的 Swift Compiler 也是支持 Osize 的。所以 Xcode 9.2 版本中可以在 Build Settings -> Other Swift Flags 中添加 -Osize 提前获取编译器优化的好处。
去除符号信息
可执行文件中的符号)是指程序中的所有的变量、类、函数、枚举、变量和地址映射关系,以及一些在调试的时候使用到的用于定位代码在源码中的位置的调试符号,符号和断点定位以及堆栈符号化有很重要的关系。
Strip Stype
Strip Style
Strip Style 表示的是我们需要去除的符号的类型的选项,其分为三个选择项:
All Symbols: 去除所有符号,一般是在主工程中开启。
Non-Global Symbols: 去除一些非全局的 Symbol(保留全局符号,Debug Symbols 同样会被去除),链接时会被重定向的那些符号不会被去除,此选项是静态库/动态库的建议选项
Debug Symbols: 去除调试符号,去除之后将无法断点调试。
iOS 的调试符号是 DWARF 格式的,相关概念如下:
Mach-O: 可执行文件,源文件编译链接的结果。包含映射调试信息(对象文件)具体存储位置的 Debug Map。
DWARF:一种通用的调试文件格式,支持源码级别的调试,调试信息存在于 对象文件 中,一般都比较大。Xcode 调试模式下一般都是使用 DWARF 来进行符号化的。
dSYM:独立的符号表文件,主要用来做发布产品的崩溃符号化。dSYM 是一个压缩包,里面包含了 DWARF 文件。
使用 Xcode 编译打包的时候会先通过可执行文件的 Debug Map 获取到所有对象文件的位置,然后使用 dsymutil 来将对象文件中的 DWARF 提取出来生成 dSYM 文件。
Strip Linked Product
If enabled, the linked product of the build will be stripped of symbols when performing deployment postprocessing.
并不是所有的符号都是必须的,比如 Debug Map,所以 Xcode 提供给我们 Strip Linked Product 来去除不需要的符号信息(Strip Style 中选择的选项相应的符号),去除了符号信息之后我们就只能使用 dSYM 来进行符号化了,所以需要将 Debug Information Format 修改为 DWARF with dSYM file。
我之前一直疑惑没有 DWARF 调试信息之后 Xcode 是靠什么来生成 dSYM 的,答案其实还是 DWARF,因为 Xcode 编译实际的操作步骤是:生成带有 DWARF 调试信息的可执行文件 -> 提取可执行文件中的调试信息打包成 dSYM -> 去除符号化信息。去除符号是单独的步骤,使用的是 strip 命令。
另外一个问题是,去除符号化信息之后我们只能使用 dSYM 来进行符号化,那我们使用 Xcode 来进行调试的时候会不会太麻烦了?其实我们完全不用担心这个问题:Strip Linked Product 选项在 Deployment Postprocessing 设置为 YES 的时候才生效,而在 Archive 的时候 Xcode 总是会把 Deployment Postprocessing 设置为 YES 。所以我们可以打开 Strip Linked Product 并且把 Deployment Postprocessing 设置为 NO,而不用担心调试的时候会影响断点和符号化,同时打包的时候又会自动去除符号信息。这个选项也是默认打开的,较老的项目可以选择手动开启。
Strip Debug Symbols During Copy
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 模式下开启,不然就不能对三方库进行断点调试和符号化了。
如果依赖的 Target 是独立签名的(比如 App Extension),strip 操作就会失效,并伴随着 Warning:warning: skipping copy phase strip, binary is code signed: xxxx。此情况将依赖的 Target 中的 Strip Linked Product 修改为 YES,保证依赖的 Target 是已经去除了符号即可,Waning 忽略掉就可以了。
Cocoapods 管理的动态库(use_framework!)的情况就相对要特殊一点,因为 Cocoapods 中的的动态库是使用自己实现的脚本 Pods-xxx-frameworks.sh 来实现拷贝的,所以并不会走 Xcode 的流程,当然也就不受 Strip Debug Symbols During Copy 的影响。当然 Cocoapods 是源码管理的,所以只需要将源码 Target 中的 Strip Linked Product 设置为 YES 即可。
Strip Swift Symbols
Adjust the level of symbol stripping specified by the STRIP_STYLE setting so that when the linked product of the build is stripped, all Swift symbols will be removed.
开启 Strip Swift Symbols 能帮助我们移除相应 Target 中的所有的 Swift 符号,这个选项也是默认打开的。
补充一点:Swift ABI 稳定之前,Swift 标准库是会打进目标文件的,想要同时移除 Swift 标准库里面的符号的话需要在发布选项中勾选 Strip Swift symbols,如下图所示:
BitCode
BitCode 是 iOS 9 引入的新特性,官方文档解释 BitCode 是一种程序中间码,其实就是 LLVM IR 的一种编码形式 - BitCodeFormart。
当我们把携带 BitCode 的 App 提交到 AppStore 后,苹果会提取出可执行文件中的 BitCode 段,然后针对不同的 CPU 架构编译和链接成不同的可执行文件变体(Variant),不同 CPU 架构的设备会自动选择合适的架构的变体进行下载。而在 BitCode 之前没我们都是把所有需要的 CPU 架构集合打包成一个 Fat Binary,结果就是用户最终下载的安装包之中有很多冗余的 CPU 架构支持代码。
从以上编译器架构中我们也可以得出一个结论:开启 BitCode 之后编译器后端(Backend)的工作都由 Apple 接管了。所以假如以后苹果推出了新的 CPU 架构或者以后 LLVM 推出了一系列优化,我们也不再需要为其发布新的安装包了。
BitCode 一致性要求
一致性要求意味着工程开启 BitCode 之后必须要求所有打进 Bundle 的 Binary 都需要支持 BitCode,也就是说我们依赖的静态库和动态库都是含有 BitCode 的,不然就会打包失败。对于 Cocoapods 等源码管理工具来管理的依赖库来说操作会比较简单,我们只需要开启 Pods 工程中的 BitCode 就行。但是对于一些三方的闭源库,我们就无能为力了。
BitCode的崩溃定位
开启 BitCode 之后需要特别注意崩溃定位的问题:由于最终的可执行文件是 Apple 自动生成的,同时产生新的符号表文件,所以我们使用原本打包生成的 dSYM 符号化文件是无法完成符号化的。所以我们需要在上传至 App Store 时需要勾选 Include app symbols for your application to receive symboilcated crash logs from Apple:
勾选之后 Apple 会给我们生成 dSYM,然后就可以在 Xcode -> Organizer 或者 iTunes Connect 中下载对应的 dSYM 来进行符号化了。
BitCode的编译选项优化
上面所说的编译器优化是在编译器前端完成的,所以提交的 BitCode 应该是经过优化的。但是 去除符号信息,是在编译生成可执行文件之后完成的, 苹果在生成可执行文件之后是否给我们去除了符号也不得而知。
编译选项配置
(1) 配置编译选项
(Levels选项内)Generate Debug Symbols 设置为NO,这个配置选项应该会让你减去小半的体积。注意这个如果设置成NO就不会在断点处停下
Strip Link Product设成YES,app可执行文件减少0.3M
Make Strings Read-Only设为YES,如果项目从低版本Xcode升级过来,这个编译选项之前一直为NO,设为YES后可执行文件减少了3M
将 Build Settings -> Clang/LLVM Generate Code -> Optimize Level 设置为 Fastest, Smallest(-Os)。
将 Build Settings -> Swift/LLVMGenerate Code -> Optimize Level 设置为 Optimize for Size(-Osize)。
将 Build Settings -> Strip Linked Product 和 Strip Swift Symbols 设置为 YES,Deployment Postprocessing 设置为 NO,发布代码的时候也需要勾选 Strip Swift Symbols。
Strip Debug Symbols During Copy 在 Release 模式下设置为 YES
有条件的话,适配 BitCode。
无论在主工程或者 Framework 中都使用 XCAsset 来管理资源。
使用工具扫描删除无用资源,推荐选择 FengNiao,并添加 Run Scripts。
使用工具扫描重复资源,推荐选择 fdupes,并添加 Run Scripts。
如果你大量资源都放在本地,推荐使用 On-Demand Resources 方式来管理资源。
在 Swift ABI 稳定之前 Extension 和主 App 之间共享 Swift Standard Libraries。
开启 Compress PNG Files/Remove Text Metadata From PNG Files。
将 Dead Code Stripping 设置为 YES。
使用工具扫描和清理无用代码,同时养成良好习惯,在迭代或者重构的时候删除旧的代码。
使用工具扫描重复代码并重构它。
视频/音频/H5 等资源远端化。
使用 xib/storyboard 来开发视图界面会一定程序增加安装包的大小。
使用 Swift 来开发程序会一定程序增加安装包的大小,对包大小有严格要求的话也可以衡量是否使用 Swift。
如果你对包大小有严格要求的话,选择合适大小的三方库来进行开发。
(2) 舍弃架构armv7
armv7用于支持4s和4,4s是2011年11月正式上线,虽然还有小部分人在使用,但是追求包体大小的完全可以舍弃了。
(3) Build Settings->Optimization Level有几个编译优化选项,release版应该选择Fastest, Smalllest[-Os],这个选项会开启那些不增加代码大小的全部优化,并让可执行文件尽可能小。
(4). 去除符号信息
Strip Debug Symbols During Copy 和 Symbols Hidden by Default 在release版本应该设为yes,可以去除不必要的调试符号。Symbols Hidden by Default会把所有符号都定义成”private extern”,设了后会减小体积。
(5) Strip Linked Product:DEBUG下设为NO,RELEASE下设为YES,用于RELEASE模式下缩减app的大小;
(6) 编译器优化,去掉异常支持。Enable C++ Exceptions、Enable Objective-C Exceptions设置为NO,Other C Flags添加-fno-exceptions, 去掉异常支持,Enable C++ Exceptions和Enable Objective-C Exceptions设为NO,并且Other C Flags添加-fno-exceptions,可执行文件减少了27M,其中__gcc_except_tab段减少了17.3M,__text减少了9.7M,效果特别明显。可以对某些文件单独支持异常,编译选项加上-fexceptions即可。但有个问题,假如ABC三个文件,AC文件支持了异常,B不支持,如果C抛了异常,在模拟器下A还是能捕获异常不至于Crash,但真机下捕获不了(有知道原因可以在下面留言:)。去掉异常后,Appstore后续几个版本Crash率没有明显上升。个人认为关键路径支持异常处理就好,像启动时NSCoder读取setting配置文件得要支持捕获异常,等等
(7), 利用AppCode 检测未使用的代码:菜单栏 ->Code->InspectCode
它可以帮助我们查找出 AppCode 中无用的类、无用的方法甚至是无用的 import ,但是无法扫描通过字符串拼接方式来创建的类和调用的方法,所以说还是上面所说的 基于源码扫描 更加准确和安全。
通过扫描的方式去检查无用代码有个痛点就是 类的方法调用是一种引用关系,以上所说的四种思路都是查找到引用末端的未使用的代码,我们很难通过一次扫描就定位到所有未使用的类,自动化实现起来也较难。举个例子来说,假如 A 是一个未使用到的类,但是 A 引用了 B,所以首次检查结果是 A 未被引用,B 被无用类 A 引用了,我们需要把 A 删除了之后我们才能了解到 B 是否是无用的类。当然如果你重新去实现一个引用树的话就另当别论了。
由于扫描无用类实现起来较为麻烦,并且其检查结果也不是特别准确。所以建议还是让开发者养成一个良好的习惯,在迭代或者重构代码的时候把老的代码删除,不要等到量变引起质变的时候才回头去优化。
(8). Cocoapods中的优化选项配置
Cocoapods 的 project 文件在每次 pod install 或者 pod update 会重置,所以需要 hook pod install 来设置 Pods 中每个 Target 的编译选项:
post_install do |installer| installer.pods_project.targets.each do |target| target.build_configurations.each do |config| config.build_settings['ENABLE_BITCODE'] = 'NO' config.build_settings['STRIP_INSTALLED_PRODUCT'] = 'YES' config.build_settings['SWIFT_COMPILATION_MODE'] = 'wholemodule' if config.name == 'Debug' config.build_settings['SWIFT_OPTIMIZATION_LEVEL'] = '-Onone' config.build_settings['GCC_OPTIMIZATION_LEVEL'] = '0' else config.build_settings['SWIFT_OPTIMIZATION_LEVEL'] = '-Osize' config.build_settings['GCC_OPTIMIZATION_LEVEL'] = 's' end end endend
(8). ARC->MRC
有人提出用ARC写的代码编译出来的可执行文件是会比用MRC大的,原因大致是ARC代码会在某些情况多出一些retain和release的指令,例如调用一个方法,它返回的对象会被retain,退出作用域后会被release,MRC就不需要,汇编指令变多,机器码变多,可执行文件就变大了。还有其他细节实现的区别,先不纠结了。
那用ARC究竟会增大多少体积?我觉得从汇编指令的增多减少去算是很难算准确的,这东西涉及细节太多,还是得从统计的角度计算。做了几个对比试验,统计了几个同时支持ARC/MRC的开源项目在开启/关闭ARC的情况下__TEXT代码段的大小对比。只对比__TEXT代码段是因为:ARC对可执行文件大小的影响几乎都是在代码段.可执行文件会进行某种对齐,例如有些段在不足32K的时候填充0直到对齐32K,若用可执行文件大小对比结果可能是对齐后的,不准确。
结果是ARC大概会使代码段增加10%的size,考虑代码段占可执行文件大约有80%,估计对整个可执行文件的影响会是8%。
而在优化的后期,我们又发现了一个可改进的编译选项:LTO,即Link Time Optimization。
苹果在2016年的WWDC What’s new in LLVM中详细介绍了这一功能。LTO能带来的优化有:
(1)将一些函数內联化
(2)去除了一些无用代码
(3)对程序有全局的优化作用
在build setting中开启Link-Time Optimization为Incremental,经测试可缩减安装包大小500KB左右。苹果还声称LTO对app的运行速度也有正向帮助。
但LTO也会带来一点副作用。LTO会降低编译链接的速度,因此只建议在打正式包时开启;开启了LTO之后,link map的可读性明显降低,多出了很多数字开头的“类”(LTO的全局优化导致的),导致我们还经常需要手动关闭LTO打包来阅读link map。
总结
任何优化项目都会经历一个越来越难以突破的过程。在安装包优化的过程中,我们也从单枪匹马的挖掘优化点,到形成了监控和量化体系。优化一个app可以给一个app带来转化、留存上的收益,而总结出一套优化方法并推广出去,则可以给更多的产品线带来收益。
未来我们也会持续总结优化方法,形成方法论和工具,让公司的其他产品也得到受益。甚至我们可以开发一套诊断脚本,一键得知某个app的可优化点。将优化工作推进到一个更高的层面。
最后,附上Jimu app安装包大小优化的工作项和收益: