干货|今日头条iOS端安装包大小优化—思路与实践

前言

自2016年8月至2017年10月,今日头条iOS端已经进行了3轮安装包大小优化。

2016.08-2016.10第一期优化78MB -> 42MB

(1)删除Swift代码,从混编改为纯OC编程 (2)无用资源文件清理 (3)工具类、工具方法合并 (4)编译选项优化

2017.05-2017.07 第二期优化 优化3MB

(1)无用资源文件清理 (2)无用类清理,无用第三方库清理 (3)C++ pb库改为light版本 (4)Today Extension改写 (5)解决cocoapod重复合并资源文件问题

2017.09-2017.10 第三期优化,进行中 优化5MB

(1)无用业务排查下线 (2)正式版下线调试相关代码 (3)部分资源文件云端下载 (4)使用tint color精简图标 (5)OC pb库切换到google官方编译版本

在安装包大小优化的初期,我们主要跟随网络上常规的优化思路进行优化,取得了明显的优化效果。而到了优化后期,明显冗余可优化的部分已经基本优化完毕,但我们希望还能进一步挖掘、发现优化点,同时控制安装包大小的增长。

经过探究和实践,我们还挖掘出了一些原创度较高的优化思路,并取得了不错的效果,同时实行了一些控制安装包大小增长的措施。此次我们将重点分享这些优化思路,以及在安装包优化后期执行的工作和使用的工具,还有一些失败的经验。

一. 基础方法

在安装包优化过程中,有一些基础方法帮助我们发掘优化点。无论优化进行到了哪个阶段,这些基础方法都贯穿在整个安装包优化过程中。

为了便于观察和统计,在没有特殊说明的情况下,本文中的“安装包大小”指的是CI平台打出的ipa的大小。

1.1 审查安装包中的每个文件

审查安装包中的每个文件是最为简单有效的挖掘优化点的方式,在包大小优化过程中也应被反复执行。

在审查安装包文件的过程中,我们多次发现了冗余的资源文件,进而发现了cocoapods在合并资源文件时的陷阱;我们发现了extension的大小略高于预期,有精简空间;还促使一些资源bundle改为了云端下载的模式。

1.2 分析link map

link map是编译链接时可以生成的一个txt文件,它生成目的就是帮助程序员分析包大小。link map记录了每个方法在当前的二进制架构下占据的空间。通过分析link map,我们可以了解每个类甚至每个方法占据了多少安装包空间。

在编译时开启Xcode build setting中的Write Link Map File开关,Xcode就会生成一份link map文件。

目前已经有不少开源的分析link map的工具,可以输出每个类、每个静态库占用的空间,并进行排序。通过查看link map,我们可以对二进制代码占据的包大小空间有个直观了解,同时在引入第三方库时也可以使用link map作出评估。

image

针对头条app,我们还对link map做了可视化更好的分析,这些将在下文中描述。

在优化过程中,我们将优化的重点分为了两大部分:代码和资源。针对这两部分,我们分别挖掘和总结了优化思路、优化工具。

二. 如何进行二进制文件优化

通过审查头条的安装包和对比竞品的安装包,我们发现头条的二进制文件占了相当大的体积(100+MB)。想要优化二进制文件的大小,我们必须精简代码。

在精简代码层面上,我们主要从两个思路着手:使用技术手段排查删减冗余代码、监控代码的增长情况和分布。另外优化编译选项也是行之有效的方法。

2.1 技术手段排查冗余代码

没有被引用的类和方法是可以通过技术手段被筛选出来的。

MachO文件中有DATA.objcclassrefs和DATA.objcselrefs段,分别近似于“被使用的类的集合”和“被使用的方法的集合”。通过取差集的方式可以筛选出未被使用的类和方法。

2.1.1 排查无用类

使用otool命令可查看DATA.objcclassrefs段和DATA.objcclasslist段,两者的差集可以认为是定义了但未使用的类。

不过DATA.objcclassrefs段和DATA.objcclasslist段中都只提供了类在二进制文件中的位置地址,而没有提供类名等可读信息。所以在获取到差集后,还需要结合

image

命令的输出,将地址转换成可读的类名。

使用脚本筛选出差集对应的类后,还需要进行一遍人工选择。因为动态使用的类、从nib或storyboard初始化的类以及在同一个文件中定义的多个类会被误判为未使用的类。这需要结合业务进行一次梳理。

最终,头条删除了70个类,ipa体积减小约300KB。

2.1.2 排查无用方法

所有已经被实现的方法可以通过linkmap来获取,对linkmap做grep操作即可获得结果:

image

而所有已经被使用的方法可以通过对二进制文件逆向获得。使用otool工具逆向二进制文件的DATA.objc_selrefs 段,提取可执行文件里引用到的方法名:

image

使用这种方法取到的差集,还需要排除掉系统API中的protocol,accessor方法等。

使用这个方法,头条排查出了无用方法2000余个,总共累积约2MB,其中最长的方法约7KB。考虑到删除方法的工作量和风险都相对较大,目前我们仅对其中很小一部分进行了删除。

2.1.3 extension代码精简

由于头条iOS端最低需要支持iOS7,所以头条中的库都以静态库形式集成。这种集成方式会导致,一旦extension依赖了一些基础库,这些基础库的占用的体积将会全部算入extension的体积中。

在审查安装包内每个文件时,我们发现头条的today extension占用了2MB左右,而extension本身的功能非常简单,显然存在着精简的空间。我们对extension进行了重构,使它尽可能的少依赖基础库,尽可能所有功能都用系统自带的框架完成。最终today extension的大小从2MB缩减为了300KB。

2.2 各业务线对包大小占用的展示和监控

在用技术手段排查冗余代码的过程中,我们发现了一个问题:用技术手段排查能得到的是“未被引用的类和方法”,而事实上,有很多类和方法虽然被引用了,但实际上是永远不会执行的。这些方法是无法通过技术手段排查出来的,必须依赖于对业务熟悉的开发人员人工排查。

因此我们转换了代码优化的思路,从“排查和删除冗余代码”转向了“对各业务线代码占用状况进行摸底和监控”。这有助于我们从全局的角度了解头条的代码状况,找到“性价比”较低的模块。

2.2.1 各业务线代码所占安装包大小可视化展示

进行“摸底和监控”的第一步,是需要展示出各业务线代码所占的安装包大小。头条app内集成的业务方向分为主端、视频、UGC、商业化、问答、火山直播等,我们的目的是获得这些业务方向的代码,分别占用了多少安装包大小。

要统计代码对包大小的贡献,基本的方法是查看link map。网络上流行着一些分析link map的工具,可以查看每个类、每个静态库所占的大小。然而,头条app业务庞大,类众多(几千个),且主要功能都在主工程而非静态库中,展示每个类所占的二进制文件大小依然可读性较差,无法从中看出每个业务占用的大小。

如何将一个类归类到某个业务线?这时我们想到了一项可以利用的线程的数据:工程的目录结构。

在头条iOS端中,由于近半年平台化的建设和代码的不断整理,工程的目录结构已经比较清晰可读,各个group的命名也能表明这个group属于哪个业务线。所以结合工程的目录结构,我们应该可以较方便的将一个类归类到某个业务线中去。

于是我们开发了一个脚本,按照工程目录的树形结构输出各个类、各个group的大小占用(下称“link map分析报表”)。通过一些文本编辑软件(比如sublime)提供的折叠/展开功能,我们可以方便的查看各个group的包大小占用情况,就像在Xcode上浏览工程目录一样。

脚本使用开源的ruby工具Xcodeproj读取工程目录,使用开源的python脚本linkmap.py读取link map,获得类和静态库的大小,最后进行整合,输出link map分析报表。

image

上图:link map分析报表展示,呈现出和工程目录一致的结果,并且能看到每个group的包大小占用情况。

虽然头条工程中的类有几千个,但group的数目显然是非常有限的。人工的对一些group进行合计,我们可以轻松的获得各个业务线占用的二进制文件大小。

这些数据也成为了我们推动各业务线“删除冗余代码、下线废弃业务、为包大小优化作贡献”的依据。对于安装包大小占用特别大的业务,我们会优先进行包大小优化推动。

另外,通过观察link map分析报表,我们还能发现了一些收益较低但代码颇多的业务。比如此次我们发现,“动态”业务相关代码占用的二进制文件大小竟然达到了1.4MB。作为一个准下线业务,这一占用量较为惊人,我们需要推动加速这类代码的下线。

而在业务方对自身业务进行优化时,link map分析报表也具有指导性意义。业务方可以查看自己业务中哪些子方向相关的代码占用最多从而优先进行优化。

对各业务线所占包大小进行盘点后,我们推动各业务线对无用的业务和代码进行了下线,最终优化包大小1.8MB。在这一阶段的包大小优化中,1.8MB的代码优化是比较可观的。

为了便于及时的产出报告,这一脚本可以集成到工程的Run Script中去,这样在需要时只用build一下,在构建的最后一步便会执行脚本,产出分析报告。

image

2.2.2 版本差异监控

在安装包大小的优化中,我们不仅需要优化当前的安装包大小,更需要控制包大小的增长。在前几次的安装包大小优化过程中,我们有过业务增长造成的包大小增长快过包大小优化量的尴尬局面;而当技术组的重心从包大小优化中移开时,业务产生的增量往往也让人措手不及。

我们在自动打包平台增加了一些用于监控版本差异的脚本,用于对比各个版本间包大小的增长量。如果发现当前版本的包大小较上一版本有较大增长,则会以钉钉报警的方式通知给开发人员。

这时我们就需要排查这两个版本间包大小的增长点。为此我们编写了一个脚本辅助我们快速排查版本间的差异。

这个脚本执行的工作有:

1、从打包平台上抓去特定两个版本的安装包和linkmap文件。

2、解压两个安装包,对比安装包中的每个文件,输出每个文件的差量值

3、解压两个asset.car文件,对比安装包中的图片的差量,输出增加/减少的图片的top x

4、处理和对比两个linkmap文件,输出增长/减小的类的top x

最后它可以输出报表,帮助开发人员快速定位版本间的增长点。

2.2.3 功能分支增量监控

版本差异监控的时机在于合码完成后。我们经常震惊的发现当前版本的包大小比上一版本增加了4MB,然后慌张的排查增长点。由于此时合码已经完成,发版迫在眉睫,即便排查到了增长点,也很难督促业务方进行优化。

所以我们需要将包大小增长的监控前置。目前,我们通过对比feature分支和主分支,企图在需求开发阶段就能发现包大小的异常增长。在功能包下载的界面,我们列出了每个功能分支的包大小增量,提醒开发测试人员关注自己负责的需求对包大小的影响。

image

具体的实现方式是,在CI平台上,主分支每次出包时,都用包大小在当前commit上打一个tag,比如size/58000000。而feature分支每次出包时,都通过git description命令查询距离当前commit最近的size tag,进行对比,将包大小增量显示出来。

这样每个需求对包大小的贡献可以一目了然,便于各业务方在需求开发阶段及时发现超出预期的包大小增量。

但是这个方式也有弊端:用tag的方式来记录可以说是比较tricky的,不太符合git这个工具的设计目的。之后我们可能会考虑换一种方式记录包大小的变化趋势,比如使用orphan分支。

2.3 编译选项改进

在精简代码之外,对编译选项做一次审查有时能带来意想不到的效果。头条在首次进行包大小优化时,发现由于我们未使用Xcode的archive功能导致正式包中的调试符号依然被保留了下来,直接导致包大小增大20MB。

而在优化的后期,我们又发现了一个可改进的编译选项:LTO,即Link Time Optimization。

苹果在2016年的WWDC What’s new in LLVM中详细介绍了这一功能。LTO能带来的优化有: (1)将一些函数內联化 (2)去除了一些无用代码 (3)对程序有全局的优化作用

image

在build setting中开启Link-Time Optimization为Incremental,经测试可缩减安装包大小500KB左右。苹果还声称LTO对app的运行速度也有正向帮助。

但LTO也会带来一点副作用。LTO会降低编译链接的速度,因此只建议在打正式包时开启;开启了LTO之后,link map的可读性明显降低,多出了很多数字开头的“类”(LTO的全局优化导致的),导致我们还经常需要手动关闭LTO打包来阅读link map。

三. 如何进行资源文件优化

比起精简代码,资源文件优化思路较多,风险也相对较小。在资源文件优化方面我们做了更多尝试,有成功的经历,也有失败的经验。由于头条app内有优化空间的资源主要为图片,故本文中“资源”与“图片”可认为是等价词汇。

在本次包大小优化期间,头条需要支持iOS7-iOS11之间的系统。与苹果建议的方式一致,头条使用asset catalog来管理图片。绝大部分图片均为png格式。每种图片都加入了2x图和3x图。以下的优化和讨论也将基于这个前提。

对于资源文件的优化,我们主要采用了3个思路: (1)图片压缩 (2)将图片放置到云端 (3)排查和清除冗余图片

3.1 图片压缩

想要优化资源文件,可能大家想到的第一个优化方式就是压缩。而更进一步,我们也考虑了是否能用webP等空间占用更小的格式来替换png图片。

3.1.1 png图片压缩

我们尝试了一个小有名气的png压缩工具:ImageOptim。这个工具能够在不改变图片质量的情况下压缩图片的大小。打开设置,我们能看到和选择它使用的压缩算法。

image

ImageOptim会对每张图片分别应用以上几种压缩算法,然后对比每种压缩算法产出的图片,选取最小的那张作为输出结果。

我们使用ImageOptim对工程中几乎所有的图片做了一次压缩。整个过程持续了若干小时。在压缩过程中,我们发现,大部分图片都能被压缩到原来的70%左右,个别图片能获得更高的压缩比。

ImageOptim的表现无疑是可观的。然而当我们满怀期望的提交修改、打包后,得到的结果却有点出乎意料。虽然工程中的图片都经过了ImageOptim压缩,但我们的ipa大小并没有什么变化。

在查阅了一些文档后,我们了解到,Xcode在构建的过程中,有一个步骤叫做compile asset catalog。在这个步骤中,Xcode会自行对png图片作压缩,并且会压缩成能够快速读取渲染的格式。如果我们对工程中的图片进行了ImageOptim的压缩,在compile asset catalog的过程中,Xcode会用自己的算法重新压缩,而这个”重新压缩“的过程,相当于将ImageOptim的压缩“回滚“了,很可能反而增大了图片。

这也就表明了,无论我们怎么压缩工程中的png图片,对包大小优化来说都是徒劳的。(但用ImageOptim工具压缩jpg图片还是有效的。)

寻求Xcode中与图片相关的配置项

我们对png格式没有做深入研究,所以这里不能清楚解释这两个压缩过程究竟做了什么。但是Xcode让png图片“增大”的行为还是让我们感到不甘,于是我们开始寻求是否有一些配置项能够关闭Xcode的压缩过程。

可能的配置项: Compress PNG Files (COMPRESSPNGFILES)
Optimization (ASSETCATALOGCOMPILEROPTIMIZATION)

image

image

经过试验,Compress PNG Files选项对asset catalog中的资源无效,因为这个选项仅适用于零散资源文件。

Optimization置为space也对包大小没有任何影响,原因有两点:

(1)头条工程使用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:

image

遗憾的是,actool并非一个脚本,而是一个编译完成的二进制文件。这就导致compile asset catalog的过程变成了完全的黑盒。 我们尝试了直接将actool工具删除,但这样会直接导致无法构建成功。显然Xcode的设计者不会希望用户干预它的构建过程。

经过分析头条工程的构建过程,我们发现,由于头条的工程使用cocoapods进行了库管理,并且头条的工程满足了一些条件,实际上真正有效执行asset catalog编译的过程是在[CP] Copy Pods Resources这个脚本中。这也是上文中设置Optimization参数无效的原因之一。

这个脚本调用了actool工具完成了最后一步:

image

在这里我们可以看到actool的一些参数,改动这些参数是否能改变actool的压缩策略呢? 遗憾的是,我们测试了去掉--compress-pngs参数、增加--optimization time和增加--optimization space参数,发现这些改动对包大小都没有任何影响。

image

为什么增加--optimization time和增加--optimization space参数对包大小没有影响呢?这个结果显然非常不符合预期,而网络上关于ASSETCATALOGCOMPILEROPTIMIZATION参数的文档也甚少,让我们疑惑不已。

通过demo实验,我们发现,当工程不依赖cocoapods时,build setting中修改optimization选项是有效的,那理论上,在脚本调用actool时传入--optimization space,应该也能起作用才对。

经过将头条工程多次与demo对比,最终我们发现了问题的根源:头条的工程最低支持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中的“今日头条”,显示的包大小比2x的设备大3MB(而没有使用asset catalog的腾讯新闻,两个手机显示的包大小是一致的)

使用Xcode的archive方式构建的安装包,可以在导出ipa时制定相应的设备,来测试app slicing功能。 尝试后我们发现,对于2x的设备,废弃asset catalog反而会导致安装包增加1MB,而对于3x的设备,废弃asset catalog能优化安装包大小1MB。然而显然,这样的优化是得不偿失的。

整个尝试废弃asset catalog的实验花费了我们大约2周的时间,然而最后以失败告终。但是这个案例给我们的优化工作带来了一些反思: (1)对优化量的衡量,应该站在用户看到的角度上,而非开发人员看到的角度; (2)有时苹果已经为开发者寻求到了一个较优的解决方案,违背苹果建议的best practise时需要更全面的评估影响

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 常规的冗余图片清理

随着业务迭代,有不少图片成为了永远也不会使用到的僵尸图片。这些图片往往占据着较大空间,对于冗余图片的排查和清理是包大小优化中便捷而有效的一项优化内容。 头条iOS端在三轮包大小优化中都进行了冗余图片排查,每次都能清理出的图片体现在ipa上的大小都在500KB以上,相对而言是比较可观的数值。

我们主要使用一个开源的Mac app,LSUnusedResources,来进行冗余图片的排查: https://github.com/tinymind/LSUnusedResources

image

这个app的原理是,对某一文件目录下所有的源代码文件进行扫描,用正则表达式匹配出所有的@"xxx"字符串(会根据不同类型的源代码文件使用不同的匹配规则),形成“使用到的图片的集合”,然后扫描所有的图片文件名,查看哪些图片文件名不在“使用到的图片的集合”中,然后将这些图片文件呈现在结果中。

对于头条的工程而言,由于头条在图片读取时有一些字符串拼接逻辑,所以直接使用这个开源工具有一些局限。因此我们修改了这个工具的算法部分,使之更好的适应我们的工程: (1)考虑夜间模式night后缀 (2)考虑ipad~ipad后缀 (3)考虑press、_selected后缀 (4)LSUnusedResources对源代码中字符串的匹配方式较为激进,容易误判(最终体现为非冗余图片被误判为冗余图片),故改为了保守的对@\"(.+?)\"的匹配。

为了保险,用这个工具跑出冗余图片候选后,还需要依次在工程中搜索确认,才能删除。

3.3.2 修复cocoapods带来的图片重复合并问题

头条重度使用cocoapods进行库管理。随着平台化的进行,越来越多的代码被封装成了pod库,以库的形式集成进工程中。在排查安装包内资源文件的过程中,我们也发现了2个由cocoapod带来的“图片重复合入安装包”的问题。这两个问题的解决,也给安装包大小优化带来了700KB左右的优化。

png文件和asset catalog重复合入安装包

在排查安装包内容时,我们发现.app文件的最外层,有一些预期外的零散资源文件。头条的资源文件绝大部分都是用asset catalog管理,仅有个别图片以零散png的形式打入安装包中。这些图片的出现不符合预期。

经过排查,我们发现这些图片来自于一个pod库。而奇怪的是,这个pod库的确是使用asset catalog进行资源文件管理的,为什么图片还会以png的形式进入到安装包中呢?

原来,这个pod库在编写podspec的时候,用了这样的语句指定资源文件:

image

我们使用demo进行了测试,发现podspec中这样书写,会导致asset catalog中的图片,既作为asset catalog被合并到主工程的asset.car中,也会作为png被拷贝到安装包中。导致其中一套图片白白占用了安装包空间。

在这个例子中,使用通配符来指定pod库中的资源文件显然是不合理的,会带来不可预期的陷阱。应该以白名单的形式明确指定哪些资源文件是pod库中有效的资源文件。

cocoapods暴力合并工程内asset catalog问题

在更新另一个业务方的pod库的时候,我们还发现了一个资源文件被重复合入安装包的问题。

Pod库在podspec中是这样指定资源文件的:

image

在业务方自己的独立app和pod库的样例工程中,这样指定资源文件没有任何异常。但是当这个pod库接入到头条app中时,我们却发现包大小的增长超过了预期。

简单排查发现,这个Image.xcassets中的图片,既作为了一个单独的asset.car被放入了名为MyPod的bundle中,又被合并到了主工程的asset.car中,而后者是预期之外的。这导致这些图片在安装包中存在了两份。

image

究其原因,我们发现原来这是cocoapod的一个缺陷导致的。

在工程构建的最后一步,会执行一个Copy Pods Resources的步骤,该步骤就是执行一个Pods-NewsInHouse-resources.sh脚本,脚本内容在pod install的时候生成。 这个脚本的最末几行有这样的一个操作:

image

即如果工程符合某些条件,则找到工程目录下所有的xcassets,使用xcode的actool工具将这些xcassets合并为一个assets.car文件。

这里合并的是“工程目录下的所有xcassets”,也就是说,不管这个xcassets针对的是哪个target,是否被工程使用了,只要它在工程的某个子文件夹下,就会被打包进安装包中。

显然这样的暴力合并可能导致安装包莫名其妙增大、图片资源莫名其妙冲突等问题。

暴力合并需要工程符合什么样的条件?第一行的if语句列出了三个条件: (1) WRAPPEREXTENSION是一个环境变量,构建iOS app是一般为app,所以我们工程肯定符合 (2) xcrun —find actool查找xcode的环境中是否有actool工具,我们的工程肯定符合 (3) XCASSETFILES是一个数组,其中有几个元素取决于有多少个pod,将xcassets写到了s.resoures中。这个条件目前头条的工程符合,而业务方独立app和样例工程不符合,所以头条的工程符合以上三个条件,该脚本会执行暴力合并步骤。

为什么cocoapods需要这样暴力合并? 因为主工程的xcasset命名不规律,文件存储位置不规律,cocoapods的开发者也找不到更好的方法来准确合并所有需要的xcassets文件,所以只能采取这种暴力的方式。

如何避免这样的暴力合并?我们思考是否能通过制定pod库接入规范来杜绝podspec中resource_bundles的指定方式,但显然这样的规范没有什么合理性,也难以得到业务方的认同。

于是我们转而思考是否能通过技术手段来填补cocoapod的缺陷?工程中的xcasset的确有无法规范命名、存储位置不规律的问题,但是它们都属于某个target,可以通过target来检索到所有应该合并的xcasset文件。

目前我们执行的解决方案是:在build phase中,在执行copy pods resources之前,执行一个脚本,替换Pods-NewsInHouse-resources.sh脚本的某一行,用更合理的合并方式取代暴力合并。

替换掉的一行是:(这一行会找出工程根目录下所有的xcassets)

image

替换为:(getallxcassets是我们写的一个ruby脚本,这一行的作用是利用xcodeproj工具找出当前target的build phase中的copy bundle resources中的所有xcassets)

image

也就是说,替换后我们不再暴力合并工程根目录下所有的xcassets,而只是合并当前target需要的xcassets。

回过头,我们再来分析一下指定resource_bundles和指定resources的区别。

Resourcebundles是cocoapods 0.23.0加入的一个属性,比起resources,cocoapods官方更推荐使用resourcebundles:

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中。 使用resourcebundles的资源文件,在构建pod时,就已经被合并到bundle中了,最后在Pods-NewsInHouse-resource.sh中这些bundle被拷贝到app中。 放在resoucebundles中的资源文件,整个构建过程更符合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)这些资源文件必须放入resourcebundles中,禁止放入resource中;(resourcebundle中的资源在构建期能经过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)这些资源文件必须放入resourcebundles中,禁止放入resource中;(resourcebundle中的资源在构建期能经过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可能就可以直接用色值来做后续开发了。但是头条app中不允许使用色值,必须使用UI规范中的“色号”,比如“面1”、“字1”之类的。 同时我们希望矫正那些“有一点偏差”的色值。

下图底色为标准色,而icon的颜色其实并不是标准色,有一点差,但是肉眼基本看不出来,可能是设计师在作图时手抖了。这种情况下我们就需要做“矫正”。

image

这个问题也可以表述为:如何将一个色值匹配到与它最接近的标准色上?

对图像没有研究,经过一番google,我知道了这个命题的关键字叫做“color distance”,于是又一番google,得知了一些公式,比如:http://colormine.org/delta-e-calculator/ 最后找了一个开源的工具:http://chir.ag/projects/ntc/ 这是一个js的工具,能将一个色值匹配到与它最接近的某个颜色名称上。

于是我直接将头条的标准色色值给复制到了原码里。于是这个脚本可以完成的工作是:输入随便一个色值,输出与之最接近的头条标准色色值。

最后,如何将标准色值再映射到“字x”、“面x”呢,这就需要找到一张图的日间模式和夜间模式,然后用两个标准色值去找色号名字。

(5)将图片分配到人

精简图标的工作需要各业务方来推动,所以在做准备工作时,我们需要将每个图标分配到各业务方。由于仅凭肉眼很难判断一个图标是属于哪个业务的,所以我们使用了git log作为分发依据,以谁添加谁负责为原则。

对于指定的图片名,我们首先使用mdfind命令找出它所在的路径,然后读取git log,查询到该图片的添加者,完成分类。

使用tint color着色,不仅能精简掉形状相同的夜间模式图标,可能对日间模式图标还能带来优化空间。

在使用tint color着色后,单色图片自身的颜色(RGB色值)便失去了意义。图片提供的全部信息实际就只有alpha通道的信息。在这种情况下,考虑将图片转为灰度图可以进一步缩减图片体积。

整个tint color的接入工作还在进展过程中。

总结

任何优化项目都会经历一个越来越难以突破的过程。在安装包优化的过程中,我们也从单枪匹马的挖掘优化点,到形成了监控和量化体系。优化一个app可以给一个app带来转化、留存上的收益,而总结出一套优化方法并推广出去,则可以给更多的产品线带来收益。

未来我们也会持续总结优化方法,形成方法论和工具,让公司的其他产品也得到受益。甚至我们可以开发一套诊断脚本,一键得知某个app的可优化点。将优化工作推进到一个更高的层面。

最后,附上头条安装包大小优化的工作项和收益:

image

image

更新日期:
本文总阅读量