聊聊iOS 应用瘦身方法思路

移动开发 iOS
如果能够实现 APP thinning,那么往往 2 倍屏幕的手机包大小会小于 3 倍屏手机的包大小,起到差异性优化的目的。在调研过程中我们还发现,应用的体积与图片资源的数量密切相关(听起来好像是废话)。

[[394887]]

 1. 前言

前段时间注意到我们 APP 的包大小超过100MB了,所以随口跟吕老板说了下能否采用字体文件(.ttf)替代 PNG 图片,吕老板对应用瘦身很感兴趣因此让我做下技术调研。这篇文章主要是将我们的各个技术方案的思路做一下整理和总结,希望对大家有所帮助。

2. iOS 内置资源的集中方式

在介绍技术方案前我们先来看下 iOS 内置图片资源都有哪些常见的方式:

2.1 将图片存放在 bundle 下

这是一种非常常见的方式,项目中各类文件分类放在各个 bundle 下,项目既整洁又能达到隔离资源的目的。我们项目中图片绝大多数都是这样内置的,其加载方式为 [UIImage imageNamed:"xxx.bundle/xxx.png"](请记住这个字符串的规则,因为这种规则非常非常重要!!!"xxx.bundle/xxx.png")。

但是这种方式有比较明显的缺点:首先使用 bundle 存储图片 iOS 系统不会对其进行压缩存储,造成了应用体积的增大。其次是使用 bundle 存储图片放弃了 APP thinning,其明显的表现是使用2倍屏手机的用户和使用3倍屏手机的用户下载的应用包大小一样。

如果能够实现 APP thinning,那么往往 2 倍屏幕的手机包大小会小于 3 倍屏手机的包大小,起到差异性优化的目的。在调研过程中我们还发现,应用的体积与图片资源的数量密切相关(听起来好像是废话)。

换句话说,iPhone 的 rom 存在 4K 对齐的情况,一张 498B 大小的图片在应用包中也要占据 4KB 大小。因此项目中每添加一张图片就至少增大了 4KB。

为了证实这个观点特地创建空应用进行测试。首先创建空应用,其大小在 7P 上为 213KB,引入一张 498B 的图片前后对比如下:

一张498B的图片

占据 4KB 磁盘空间

未添加资源的应用

添加图片资源后的大小

上述实验未经过 App Store 上线认证,仅仅通过本地打包测试,因此观点仅供参考。

2.2 使用 .ttf 字体文件替代图标

使用字体文件替代图片也是一种比较常见的资源内置方式。很多应用都使用过这种方案,如淘宝、爱奇艺等知名应用,都采用过这种方式。

使用字体文件的好处是显而易见的,如果 APP 中某个图片比较大,那么为了保证清晰度,UI 可能会提供比较大的图标。

使用字体文件会避免这个问题,而且不必导入 @2x 和 @3x 图片,一套字体文件就能保证 UI 的清晰度。关于如何生成 .ttf 文件在这里就不在赘述了(因为我并不喜欢这个方案),我们只要如何使用就可以了。

字体文件使用起来比较简单,但是使用方法与 png 图片的使用方法有很大的不同,因为字体文件时机所展示的图标都是 UTF8 编码转来的字符串。因此当我们需要展示一个图标的时候不再是使用 UIImageView 了,而是 UILabel。

字体文件展示图片的代码示例

由于我们使用了字体来替代图片,所以我们可以通过设置字体的颜色来改变图标的颜色。我们之前经常会遇到一个场景,如两个一模一样的图标但是由于颜色不同,UI 同学就需要提供 2 套图片,每套图片中包含 @2x 和 @3x 图片。如果采用了字体替代简单的图标,那么 UI 只需要提供一套字体即可,并且拉伸后也不会失真。

使用字体文件的好处总结起来主要有两点:

  1. 可以降低应用图片内置资源的体积。
  2. 可以随意放缩和修改颜色。

但是其缺点也很明显:

  1. 图标的查找和替换比较麻烦,不如直接使用图片那样简单。
  2. 最重要的是如果在 58 同城 APP 中使用,则意味着无法替换之前存在的图片,只能起到缩小增量的目的,无法减小全量。

ps:任何一种需要大刀阔斧改革的优化都是一种不明智的行为。

2.3图片存在Assets.xcassets下(苹果推荐,我也推荐)

使用 Assets.xcassets 是苹果推荐的一种方式。Assets.xcassets 是 iOS7 推出的一种图片资源管理工具,将图片内置到 Assets.xcassets 下系统会对图片资源进行压缩,并且支持 APP thinning。

APP Slicing

项目优化不能脱离场景,很多很好的方案由于场景的限制并不能起到优化的作用。因此先简单介绍下我们的项目场景:为了达到跨团队快速开发的目的,我们项目很早就利用cocoapods 实现组件化。项目中存在多个业务 pod,每个 pod 都有各自的团队维护,各个团队的代码彼此不开放,各个 pod 最终会被编译为.a的形式。

这里需要说明一下我为什么要强调 .a,与 .a 相对应的还有一个 .framework,他们之间有一个重要的区别就是资源的问题。framework 中可以存放资源,但是 .a 却不可以,因此生成 .a 的 pod 下的资源会被转移到 main bundle 下,这为资源冲突造成了隐患,为了避免这种冲突我们之前采用的使用 bundle 管理资源,bundle 名很少会重复这样就大大降低了资源冲突的可能性。

优化的前提之一也是不破坏这种组件化开发的模式,换句话说也就是各个业务线不产生资源耦合、业务线的 RD 不必担心彼此资源的冲突、业务 Pod 下的资源文件彼此隔离。哪怕招聘团队中存在 a.png,房产团队中也存在 a.png 也不会有什么问题。所以我们先要抛出两个问题:

1. cocoapods 是否支持使用 Assets.xcassets。

2. 各个 pod 各自维护自己的 Assets.xcassets 会不会造成资源冲突。

为了弄清楚上面两个问题,我们先要看下 podspec 的几个重要参数:

podspec

s.public_header_files :表明了哪些路径下的文件可以在 framework 外被引用。

source_files :源文件路径。

s.resources :资源文件路径及文件类型。

s.resource_bundles :资源文件路径及类型,同时资源文件会被打成 bundle。(推荐使用)。

实验发现各个 pod 下都可以创建自己的 xcassets,因此问题1不算问题是问题。如果我们在各个业务 pod 下都创建 .xcassets 文件内置图片,那么 cocoapods 的脚本会在编译时将各个目录下的 xcassets 文件内容提取出来,合并到一个 xcassets 中并生成一个 .car 文件。

这样的话如果资源文件重名,那么很可能其中某一个文件会被覆盖替换。因此我们主要是要解决问题2。查看 podspec 的写法发现 s.resource_bundles 貌似是我们所需要的法宝。为此我们天真的以为问题马上就要解决了:

将指定路径下的资源打包成bundle

最终打包结果很理想,确实能够生成 ImagesBundle.bundle,并且 bundle 下存在Assets.car。

mainbundle下存在ImagesBundle

ImageBundle.bundle下存在Assets.car

事情到这里可能已经看到曙光了,但是我们发现通过

  1. [UIImage imageNamed:@"ImagesBundle.bundle/1"]; 

加载不出来图片。必须使用

  1. [UIImageimageNamed:@"1"inBundle:[WBIMViewControllericonBundle]compatibleWithTraitCollection:nil]; 

才能加载出来。

图片加载失败

指定bundle后加载成功

也就是说只有 Assets.car 如果不在 main bundle 下,那么加载图片都需要指定bundle。

既然需要指定 bundle 加载图片,那么如何获取这个 bundle 呢?换句话说如何才能低成本的将现在项目中的图片放到特定 bundle下的 Assets.car 文件中呢?

对此我们提出了一个解决方案:

1. 在 pod 下新建一个空文件夹。找出该 pod 存放图片的所有 bundle,在新建文件夹下创建与 bundle 数量相等的 Asset。

2. 修改 podspec 文件,设置 resource_bundles 将 Asset 指定为资源,并指定 bundle名称。如 A.bundle,其对应的 Asset 最终资源 bundle 为 A_Asset.bundle。

3. 新增方法,imageWithName:,从符合 xxx.bundle/yyy.png 特征的参数中获取 bundle 名和图片名 xxx_Asset.bundle 和 yyy.png,获取图片并返回。

4. 查找并全部替换 imageNamed: 和 imageWithContentOfFile: 为 imageWithName:

只要能拿到原来代码中 imageNamed: 的参数就能知道现在图片存在那个 bundle 下,这样就能通过 imageNamed:inBundle: 获取到图片,其思路如下图所示:

imageWithName:方法内部处理

打包后bundle情况

看到这里老司机们已经应该能遇见这种优化的成本了。加载图片都需要指定 bundle 也就意味着成千上万处的 API 需要修改。我们最初探讨到这里的时候首先想到的是脚本,但是这个方案很快就被否定了,因为项目中存在大量的 XIB,XIB 中设置图片我们无法通过脚本替换 API。

为了解决 XIB 设置图片的问题,我们首先想到了AOP。通过 hook XIb 加载图片的方法将方法偷偷替换为 imageNamed:inBundle: ,但是很遗憾我们 hook 了 UIImage 所有加载图片的方法,没有一个方法能拿到 XIB 上所设置的图片名称,也就意味着我们无法得知优化后的图片在哪个 bundle 下,也就不知道图片该如何加载。

虽然有坎坷,但是我们始终坚信 XIB 一定是通过某些方法将图片加载出来的,我们一定能拿到这个过程!为了验证这个问题,首先定义一个 UIImageView 的子类,并将 XIB 上的 UIImageView 指定为这个子类。

大家都知道通过XIB加载的视图都一定会执行 initWithCoder: 方法

UIImageView的子类加载

我们发现在得到执行 [super initWithCoder:aDecoder] 之前通过 lldb 查看 slef.image 是nil。当执行完这行代码后 self.image 就有值了。

因此推断图片的信息(图片名称、路径等信息)都在 aDecoder 中!在网上搜索了一些资料后发现 aDecoder 有一些固定的key,可以通过这些固定的 key 得到一部分信息。如

aDecoder可以通过某些key得到其中信息

很显然通过 “UIImage” 这个 key 能拿到图片,但是很遗憾经过多次尝试没能找到图片的路径信息。因此这个问题的关键是怎么找到合适的 key,为了解决这个问题,最好是能拿到 aDecoder 的解码过程。

因此 hook aDecoder 的解码方法 decodeObjectForKey:是个不错的选择。如果能拿到 xib 上设置的图片名称那么我们就可以根据图片名称获取到正确的图片路径。经过断点查看 aDecoder 是UINibDecoder(私有类)类型。

aDecoder

hook UINibDecoder的decode方法

打印系统 decode 的所有 key 后发现有个 key 为 UIResourceName,value 为图片的名称。也就是说我们能得到 XIB 上设置的图片名称了。但是这个图片的名称怎么传递给这个 XIB 对应的UIImageView 对象呢?

换句话说也就是说我们怎么把图片传给这个 XIB 对应的 view 呢?为了将图片名称传给 UIImageView,需要给 aDecoder 添加一个 block 的关联引用。

UIImageView在initWithCoder:的时候设置回调

在 hook 到的 decodeObjectForKey: 方法中将图片名称回传给 initWithDecoder: 方法:

aDecoder hook到图片名称后回调给UIImageView类

这里需要注意的是一点是:XIB 默认设置图片是在 rentun value 之后,也就是说如果我们回调过早有可能图片被替换为 nil。因此需要 dispatch_after 一下,等 return 之后再回调图片名称并设置图片。

受此启发,我们也可以 hook UIImage 的 imageNamed: 方法,根据参数的规则到 xxxCopy.bundle 下获取图片,并返回图片。这就意味着放弃通过脚本修改 API,减少了代码的改动。

看到这里似乎是没有什么问题,但是我们忽略了一个很严重的问题 aDecoder 对象和 UIImageView 类型的对象是一一对应的吗?一个 imageView 它的 aDecoder 是它唯一拥有的吗?带着这个问题,我们先来看下打印信息:

重复生成UIImageView对象和aDecoder对照关系

重复生成对象并打印后发现 aDecoder 的地址都相同,也就是说存在一个 aDecoder 对应多个UIImageView 的现象。因此异步方案不适用,需要同步进行设置图片,因此全局变量最为合适。

其实这一点很容易理解,aDecoder 是与 XIB 对应的,XIB 是不变的所以 aDecoder 是不变的。

因此异步回调的方案不适用,需要同步进行设置图片,在这种情况(主线程串行执行)下跨类传值全局变量最为合适:

hook UINibDecoder的decodeObjectForKey

hook UIImageView 的initWithCoder:

上面两段代码仅仅介绍思路,可能加载图片的代码并不是十分的严谨,请读者自己鉴别。同理hook 项目中 UIImage 所用到的加载图片的API即可加载图片。

如果将所有的hook方法放到一个类中,那么只要将这个类拖入到项目中,并将项目中所有的bundle下的图片都放到对应的 Assets.xcassets 文件下那么无需修改一行代码即可将所有的图片迁移到 Assets.xcassets 下,达到应用瘦身的目的。

但是我们组内老练的架构师们指出:项目中 hook 如此重要的 API 对增加了项目维护的难度。这也引发了我对项目中 AOP 场景的思考,项目中到底 hook 了多少 API?

可能在我场多年的老司机们都难以回答了,为此特地赶制了一个基于 fishhook 的一个 hook 打印工具,检测和统计项目中的 AOP 情况。但是缺点是必须调整编译顺序保证工具类最先被load。

hook method_exchangeImplementations 方法

检测方法(字典写入时不要忘了加锁)

 

责任编辑:武晓燕 来源: 网罗开发
相关推荐

2015-02-02 11:03:12

2019-01-30 11:21:57

Swift iOS开发

2019-01-09 13:20:28

GPU虚拟化应用

2021-08-23 14:36:26

coredump

2017-07-22 15:54:04

iOS组件化路由

2021-01-22 05:49:41

数据源思路规划

2011-09-01 11:12:02

Restaurant 美食应用餐饮应用

2012-08-28 09:12:52

App瘦身

2021-04-30 17:02:52

coredump内核故障

2018-05-18 08:20:32

数据治理应用

2021-08-12 18:49:41

DataStreamAPI注册

2020-12-22 06:05:43

Mbedtls应用基础

2017-08-23 08:35:37

iOS边缘计算网站

2017-02-09 17:30:05

Android应用瘦身

2011-04-22 11:09:41

华硕家用台式电脑晶品CP5

2017-03-02 15:09:29

AndroidAPK瘦身实践

2021-07-07 12:01:48

iOS内存对齐

2021-10-14 07:28:03

Kubernetes通用排查

2022-01-26 08:47:17

部署应用分布式

2020-06-10 08:55:36

Docker容器工具
点赞
收藏

51CTO技术栈公众号