干货!京东商城iOS App瘦身实践

随着业务的快速增加,商城App的大小也在迅速增加,一度超过了300M。安装包大小的不断增加对App下载成本,推广效率产生了比较大的影响。从2018年9月份我们对商城App开始了为期二期的专项瘦身工作:一期从V7.2.0-V7.5.2版本,共计瘦身46M(设备:iPhone X,iOS12)。为了进一步减小包大小,同时为了建立长效机制,从今年5月份开始了第二期的专项优化工作,二期优化从最高的V8.1.0版本的272M到现在的V8.4.0的214.4M共计已经完成瘦身57.6M,当然二期优化还在继续推进中。瘦身工作非常不易,在本次安装包瘦身过程中我们遇到了不少坑,同时也积累了些经验,在此分享给大家。
优化方向

0 1

优化指标
itunes connect上有两种包大小显示:“Download Size”,“Install Size”。“Download Size”即下载包大小,超过150M需要使用无线网下载的限制就是这个大小(现在已经放宽到200M);“Install Size”即安装后占用的磁盘空间大小,在appstore上显示的也是这个大小,用户往往会误认为这是下载安装包消耗的流量大小。所以一开始我们就将“Install Size”作为了优化指标。“Install Size”减小后,“Download Size”自然也会减小。

0 2

优化方向
ipa 解压后JD4iPhone.app主要的成分如下:

  • Frameworks,动态库存放路径;
  • PlugIns,插件存放路径,如today extension;
  • Mach-O,可执行文件;
  • Assets.car,Asset Catalog编译产物;
  • react.bundle,内置的ReactNative业务;
  • bundle,主要存放资源文件;
  • 其他文件;

这些都是包大小的主要影响因素,优化工作都是围绕这些元素进行,后续的监控点也主要是这些元素。
优化措施

0 1

资源文件
在一期优化前通用包(包含多种指令集,分辨率图片,多种机型通用的包)中的图片在 13500张左右,二期优化开的V8.1.0 版本通用包中的的图片还有 11000 张以上。资源文件的数量非常之多,使用场景异常复杂。资源文件的优化自然成了我们花费精力最多的地方,我们需要一整套的方案去应对接下来的优化。

1.1 资源文件的归属

在优化之前,我们需要将不同的资源文件归属到对应的模块,落实到对应的负责人。得益于商城App高度的模块化,资源文件的归属、甚至获取模块代码大小变得很简单:

  • 根据资源文件在本地工程project.pbxproj所在模块的路径和ipa包中的资源文件进行匹配。
  • 分析 linkmap 文件,获取各个静态库组件代码部分在可执行文件中的占比。

1.2 单个资源文件大小统计

以往,我们常常以为本地开发工程中的资源文件大小就是最后安装包中的大小,将本地图片压缩一下就可以瘦身。而经过本次瘦身实践发现其实并不是这样:很多图片在本地和安装包中的大小差异非常之大,往往相差几倍,甚至有几十倍的。通过调研知道 Apple 为了在优化 iPhone 设备读取 png 图片速度,将 png 转换成 CgBI 非标准的 png 格式:

  • extra critical chunk (CgBI)
  • byteswapped (RGBA -> BGRA) pixel data, presumably for high-speed direct blitting to the framebuffer
  • zlib header, footer, and CRC removed from the IDAT chunk
  • premultiplied alpha (color’ = color * alpha / 255)

苹果的优化对于大多数应用来说都是包大小的负优化,商城也不例外。所以简单的压缩(有损,无损)处理并不能达到很好的瘦身效果。而经过测试,以下文件会被负优化:

  • 放在根目录下png格式的图片。
  • 放在Asset Catalog中的png,jpg格式的图片,其中jpg会转成png。

放在根目录下的jpg,bundle中的png不会被优化,这个规律也在后续优化中起到了重要作用。
终上所述,我们决定使用安装包中的资源文件来统计大小。

1.2.1 Asset Catalog中的文件大小计算

Assest.car 做为 Asset Catalog 的编译产物,我们怎么获取到car文件中的图片大小呢?在以前查iOS9,P3格式图片问题的时候我们用过苹果提供的assetutil工具,使用assetutil就能获取到图片信息描述:
sudo xcrun –sdk iphoneos assetutil –info xxx/Assets.car > xxx/Assets.json
Assets.json 中的图片详细数据如下:

  {

    "AssetType" : "Image",

    "BitsPerComponent" : 8,

    "ColorModel" : "RGB",

    "Colorspace" : "srgb",

    "Compression" : "lzvn",

    "Encoding" : "ARGB",

    "Idiom" : "universal",

    "Image Type" : "kCoreThemeOnePartScale",

    "Name" : "1001", //xxx.imageset 的文件名

    "NameIdentifier" : 11584,

    "Opaque" : false,

    "PixelHeight" : 48,

    "PixelWidth" : 72,

    "RenditionName" : "1001@3x.png",//工程文件中的实际图片名

    "Scale" : 3,

    "SHA1Digest" : "E34FCAC314E26DE7FF30442AA33E436B242AA4BA",

    "SizeOnDisk" : 800,//占用的磁盘大小,Asset Catalog中的图片编译后的大小取该值。

    "Template Mode" : "automatic"

  },

最终我们使用SizeOnDisk 字段来获取图片大小。使用SizeOnDisk计算精度很高(所有图片的SizeOnDisk相加和car文件大小误差在1M以内)。
对于 Assets.car 的分析,还有个小插曲:我们最开始是使用 cartool 导出图片,然后统计图片大小。分享模块在更换双十一大促氛围兜底图后,因为部分活动图片大于了32KB(微博分享缩略限制不能超过32KB),触发调用兜底图分享逻辑,分享兜底失败,最后定位是因为 Apple 的负优化,将原大小为22KB,负优化后部分设备上大于了32KB,但是cartool导出的大小为18KB。定位后发现:一个Asset Catalog的图片在Assets.car中实际上根据不同设备有4张对应的图片,大小不同,但名字相同。而cartool解压出的图片大小为其中某一张,这样大小计算就不太准确了。所以放弃原先使用cartool,改用assetutil。

1.2.2 其他的资源文件大小计算

其他的文件大小计算方式相对简单,苹果的APFS文件系统的最小存储单元为4KB,即使只有几十字节大小的文件,占用的空间也是4KB。对于安装包里面的独立文件我们使用4KB对齐的方式进行大小计算,有些大点的文件磁盘占用空间并不是4的整数倍,但大小相近,影响不大:
Math.ceil(size/4000.0)*4,size为文件实际大小,单位字节;
在 MB、KB、Byte 之间的换也是对齐 Apple 使用的是 1000 而不是 1024(即1MB = 1000 KB = 1000*1000 Byte)。

1.3 模块所用资源文件大小,数量统计

在介绍模块的资源文件大小统计前,先简单介绍下 App Slicing ,在我们将ipa包提交到iTunes connect,App store会针对不同的设备,系统制作成不同的精简版app:可执行文件,动态库根据不用的指令集,Asset Catalog中的资源文件根据不同的屏幕分辨率进行分发,最终做到按需下载,如下图。


关于App Slicing 的内容详细叙述,感兴趣的可以查看App Thinning in Xcode。
App Slicing只会对在Asset Catalog的资源文件进行分发,而放在根目录,bundle中的资源文件不会分发,所以在统计模块所使用资源文件之前我们需要注意到这个特性。如果以通用包来统计模块使用的资源文件大小、数量,其实并不能真正反映此模块对整个安装包大小的影响。所以我们决定使用单个设备来衡量资源文件使用情况。目前我们选择iPhone X,iOS11设备做为参考标准。
要统计单个设备的资源文件使用情况,一个方式是使用adhoc包导出支持单个设备的安装包统计,不过这样的方式需要每次集成后都需要单独打包,因为现在ci并不会出支持单个设备的包。后来我们发现assetutil除了可以导出car文件信息之外,还可以从通用包car文件导出指定设备的car文件,入参较多,经过尝试iPhone X的如下:

sudo xcrun –sdk iphoneos assetutil –idiom phone –subtype 570 –scale 3 –display-gamut srgb –graphicsclass MTL2,2 –graphicsclassfallbacks MTL1,2:GLES2,0 –memory 1 –hostedidioms car,watch xxx/Assets.car -o xxx/thinning_assets.car

从上文知道car以外的资源文件不会分发,获取指定设备的car文件后我们就可以计算出模块所用资源文件大小,数量。

1.4 资源文件优化

1.4.1 大资源文件优化

上文提到了苹果对图片的负优化,大图经过负优化后对安装包的大小影响更大,动辄几百K,甚至上M。这也是一期优化通过改造 Assets.car 中的 183 张图片能优化了近 30M 原因,千万不要将大图随意拖到工程中。
结合上文中负优化规律,改造处理方案如下:

  • 删除无用或者可以使用其他方案替换的图片;
  • 优先转网络下载,使用默认图/纯色兜底,如楼层背景图;
  • 不能转下载的使用压缩过的jpg格式图片。
  • 不能使用jpg的图片经过压缩后( 主要是tinypng有损压缩)后放到 bundle 中使用。

二期优化开始,对大资源的处理不在局限于 Assets.car 中的大图(大于50KB),对于放在 bundle 中的大图、音视频、模型文件针对这部分大文件,逐一梳理后并针对性处理,收益很高。

1.4.2 无用图片筛查

现有基于源码检测无用图片的原理:根据各种类型的源文件,通过正则表达式获取使用的图片集合,扫描获取所有图片名集合,取所有图片集合和使用图片集合差值获取无用图片集合;
但是源码的方式在商城App中并不不适用,因为商城App的各个模块是以二进制的形式集成的,而我们并没有所以模块的源码权限。既然扫描源码的路走不通,又不能放任不管,我们反其道而行通过安装包来扫描无用图片:
通过分析安装包中使用图片可分为三类文件:

  • 可执行文件;
  • 可读文件(.plist、.js、.html);
  • 不可读文件(.nib、.storyboardc);

可执行文件通过 otool -v -s __TEXT __cstring 获取可执行文件中的 __TEXT.__cstring 段。__cstring 包含了可执行文件中的字符串常量(源码中的 @“xxx” 字符串);
不可读文件 .nib 和 .storyboardc 分别是 xib 和 storyboard 的构建产物。ibtool 是xib 和 stroyborad 的编译工具,通过 man 查看 ibtool 的具体使用方法发现:–flatten NO –compile 组合使用的时候可以生成可运行、可执行的 .nib 和 stroyboardc 文件。
可执行性文件、不可读文件确定处理方法后开发工具筛选,思路如下:
1)针对不同的文件,使用 otool、正则和直接读取将获取到的内容拼接成引用图片的超字符 str,遍历所有图片名是否被 str 包含;
2)如果包含直接过滤;
3)如果不包含,再判断是否是 image_%ld 相似图片过滤;
4)开发人员确定无误后删除,存在部分字符串拼接被误扫的,添加白名单过滤。
最后筛选出无用图 196 张删除,总大小约1.5M。无用图片扫描是个长期工作,我们也会邮件定期推送。

1.4.3 转下载

在进行大图,无用图片处理的同时,我们也给出了便于本地图片转下载的方案,基本功能如下:
•模块内内置默认配置文件,支持对不同分辨率的机型加载对应的图片。

{

    "imageId1": {

        "3x": "url1",

        "2x": "url2"

    },

    "imageId2": {

        "3x": "url3",

        "2x": "url4"

    }

   .

   .

   .

}

  • 支持图片url的在线更新。

  • 支持基于cdn的图片降质、webp压缩。


些图片适合转下载?

  • 功能性引导图
  • 背景图:如楼层背景,页面背景。
  • 标签,提示类的图片。
  • 其他入口较深的图片。

1.4.4 iconfont

即使经过了图片转下载,无用图片删除,但是工程中的图片数量还是极为可观,其中各种各样的icon图标占了不少的数量。为了进一步减少图片数量,我们引入了iconfont方案, iconfont优点:

  • 矢量,缩放不失真。
  • 可以设置颜色。
  • 接入成本低,不需要引入额外的类库。

iconfont 可以解决因为icon大小,颜色不同而重新切图的窘境。从京东内部的quark平台了解到目前已经可以很好的支持iconfont,我们在一个模块就找到了55个icon并且成功转成了iconfont。不难看出iconfont是一个能减少图片数量的好方案。

1.4.5 规范与监控

为了建立长效机制,我们拟出了资源文件使用规范,同时也搭建了资源分析系统,来跟踪各模块的资源使用情况,主要功能如下:

  • 安装包大小,资源文件大小数量,包成分的展示;
  • 各模块(JDReact,动态库,静态库)模块资源文件使用情况的记录展示;
  • 各模块排名,不同版本间的对比;
  • 违反资源文件使用规范模块的邮件触达(开发中);
  • 根据各模块的资源文件使用情况,动态给出优化建议(开发中);

0 2

可执行文件/动态库

2.1 动态库

由于iOS8对可执行文件__Text字段60M限制,商城App多个版本逼近60M大关,为了不影响发版,有不少模块以动态库的形式集成到工程中(只有2个库会启动时加载,其他均为使用时加载,不影响启动速度)。以优化前的V8.1.0 版本为例,共计19个动态库,总大小超过了100M(包含arm64,armv7架构)。所以动态库的优化成了瘦身工作的重要组成部分。那么怎么给动态库瘦身呢?

  • 梳理动态库使用方,精简代码

目前商城App中动态库主要有三类:1)公共基础库,这类库在代码上的优化点并不多,因为基础库可能被多个App使用;2)可执行文件限制被迫转的动态库,这类库是基于已有基础库打包的,已经完成代码共享,优化点也不多。但是我们也梳理出来个别下线业务;3)第三方公司提供的库,这类库中虽然有很多的重复代码,但是推动修改成本较大。经过各种尝试虽然有所减少,但是精简代码的方式整体收益并不好,未能达到我们的预期,我们需要其他的瘦身方式。

  • strip

经过调研,我们了解到有两种去除动态库多余符号(符号表等)的方式:
1)在链接时去除,即在动态库工程中Other Linker Flags中添加-s参数,经过测试:不管是在启动时加载,还是手动方式加载动态库都没问题。于是准备使用这个方案。然而,在执行的时候发现了一个严重的问题:加了此参数后,不能生成完整的dsym文件,这会影响崩溃后符号的解析。于是此方案作罢。
2)使用strip -x命令处理动态库。因为是对动态库产物进行处理,所以不会对dsym产生影响,经过测试,strip后的动态库,也可以使用dsym文件找到符号。于是我们尝试在工程中添加脚本统一处理工程中的动态库。在添加脚本的时候遇到个问题:动态库被拷贝到沙盒的时候会签名,而我们的strip操作发生在这个后面。在debug环境下,加载动态库的时候会提示签名后动态库被修改的错误。而在release导出包的时候会重新对动态库进行签名。所以在release下不会有问题。最终,我们修改了脚本,只在release环境下,执行strip操作:

if [ $CONFIGURATION == Release ]; then 

    strip -x dylib路径 

fi

经过strip处理后共计减少28M(arm64+armv7),瘦身效果明显。

2. 2 无用类/方法

无用类通过 otool 逆向Mach-O文件 __DATA.__objc_classlist段和__DATA.__objc_classrefs 段获取所有 OC 类和被引用的类,两个集合差值为无用类集合,结合 nm -nm 得到地址和对应类名符号化无用类类名;根据商城的限制做过滤,规则如下:

  • otool 逆向 __DATA.__objc_nlclslist 获取实现 load 方法的类过滤(RN与原生的桥接类、Swizzle Method 类);
  • 通过 otool 逆向 __TEXT.__cstring 获取所有字符串常量,过滤通过 NSClassFromString 调用的;
  • 子类实例化,父类没有实例化,父类不会出现在中 __objc_classrefs,通过 otool -oV 逆向出类的继承关系,过滤出子类被实例化(NSClassFromString 调用),父类没有实例化(NSClassFromString 调用)的类;
  • 过滤使用 Plist 文件引用的类;

无用方法 通过 otool 逆向 __DATA.__objc_selrefs 段获取使用到的方法,通过 otool -oV 获取实现的所有方法取差值。然后过滤掉 setter、getter、系统方法和协议、自定义的协议、sel 调用。
结合 linkMap 映射出无用的方法和类归属的组件,并且初步量化大小,如下所示:

业务组件 大小 基础组件 数量 大小
无用类 41 214 590KB 22 61 212KB
无用方法 64 1434 430KB 49 1182 259KB

因为基础组件中的无用方法和类,不能确定是否被非商城的 App 使用,只能对业务组件优化,考虑到涉及组件众多,并且收益和工程量不成正比,并且删除方法风险比较大,将无用方法和类优化的优先级降低。

2.6 内置的ReactNative业务

JDReact提供了预置和后装两种发布方式,而为了用户体验,大部分业务模块都选择使用预置包的方式。时间一长,文件的数量就越来越多。由于文件系统的4K对齐,对包大小的影响也是非常大。对内置的ReactNative业务优化如下:
•推动流量相对较低的模块(三级及三级以上页面)转后装方式;
•根据资源文件使用规范,推动业务整改;
这部分的工作量主要在和业务方的沟通,经过部分模块转后装后,瘦身效果也是很明显。

0 3

插件
在ipa包中我们也注意到了PlugIns目录,这里主要存放一些插件,比如today extension,share extension等,虽然这些插件在整个ipa包中的大小占比不大,但是我们还是决定梳理下有没有优化点。梳理后发现这些插件对于一些基础类库(网络框架,图片加载框架等)的使用都是以拷贝代码的方式加到工程中。我们知道这些类库完全可以和主app共享,因为主app中这些库是以动态库的形式使用的。经过优化后,成功的将today extension的大小减少了0.9M(嗯~蚊子虽小…)。

04
Xcode配置
除了以上提到的优化点,我们也对Xcode对包大小优化的一些相关配置做了尝试:

4.1 Link-Time Optimization(LTO)

苹果在WWDC2016对LTO的介绍如下:
What is Link-Time Optimization (LTO)? Maximize runtime performance by optimizing at link-time Inline functions across source files Remove dead code Enable powerful whole program optimizations.
通过修改Build Settings中的Link-Time Optimization=Incremental,测试后ipa包减少4M,后续经过进一步验证后可以打开。

4.2 Compress PNG Files & Remove Text Metadata From PNG Fils

上文提到的 负优化使png格式图片增大,那么能否关闭负优化?在尝试将 Compress PNG Files 设置为 NO 对包大小没有任何影响,想放弃又不甘心,通过创建新的 Demo 工程测试,通过查看 Build 日志发现是通过 copypng 将原 png 图片复制到构建产物根目录的,幸运的是 copypng 不是一个可执行文件,而是一个由 perl 编写的脚本。copypng部分源码如下:

#!/usr/bin/perl



my $PNGCRUSH = `xcrun -f pngcrush`; chomp $PNGCRUSH;

my $compress = 0; my $stripPNGText = 0; my @FILES = ();

# Gather command line options. while( @ARGV ) { $_ = shift @ARGV; next if ( $_ eq "" ); if ( $_ =~ /-strip-PNG-text/ ) { $compress = 1; $stripPNGText = 1; next; } if ( $_ eq "-compress" ) { $compress = 1; next; } } my @args; if ( $compress ) { @args = ( $PNGCRUSH, "-q", "-iphone", "-f", "0" ); if ( $stripPNGText ) { push ( @args, "-rem", "text" ); } push ( @args, $SRCFILE, $DSTFILE ); } else { @args = ( "cp", "$SRCFILE", "$DSTFILE" ); }

其中 Compress PNG Files 和 Remove Text Metadata From PNG Fils 分别对应入参为 -compress 和 -strip-PNG-text。看到源码,即使我们不懂 perl 也应该明白了。为什么 Compress PNG Files 设置为 NO,不能取消负优化,要想取消根目录下的负优化,需要将 Compress PNG Files 和 Remove Text Metadata From PNG File 都设置为 NO 才能取消。
测试将 Compress PNG Files 和 Remove Text Metadata From PNG File 设置为 NO 之后安装包优化 1.6M。
同时也探究其对 Assets.car 的影响,通过对比是取消负优化对 Assets.car 的编译工具 actool 的影响,取消后没有 –compress-pngs 的入参。
–compress-pngs PNGs copied into iOS targets will be processed using pngcrush to optimize reading the images on iOS devices. This has no effect for images that wind up in the compiled CAR file, as it only affects PNG images copied in to the output bundle.
在验证了 Compress PNG Files 和 Remove Text Metadata From PNG File 对 actool 是否有入参 –compress-pngs 的关系或后,我们也验证了对大小的影响,结论是取消负优化,不会影响 Assets.car 的大小。
在二期优化过程中通过梳理根目录下的图片,现在只剩下 AppIcon 和 Launch Iamge,对于 Launch Iamge 我们通过 Launch Screen Storyboard 只保留一份启动图优化包大小,不考虑取消负优化。同时通过创建一个新工程只给 Asset Catalog 中添加图片, 查看 Build Settings 是没有这两项配置项,可是 Build 日志 actool 也是有 –compress-pngs 的入参。相信 Apple 已经给我们做了最佳的选择。

4.3 Asset Catalog Compiler 之 Optimization

对于 Optimization 中的 space 的优化,在一期就想通过灰度验证是否有其他影响,如果没有影响后启用,因为那时启用精简包可以优化十几M,在后面重点开始优化 Assets.car 后,考虑到启用之后可能会消极的优化 Assets.car 就搁置,到目前商城最新版 8.4.0 以 iPhoneX 的 adhoc 包数据对比重点优化 Assets.car 后,启用也只在 iOS12 以下 1.1M 的影响。

IPhone X iOS11 iOS12 iOS13
default 17.3M 15.5M 14.9M
space 16.2M 15.5M 14.9M
time 17.3M 15.5M 14.9M

Apple 在 iOS 12 Optimizing App Assets。space 也不准备启用,还是那句话相信 Apple 已经给我们做了最佳的选择。

4.4 苹果给的“惊喜”

在最近的一个版本集成后,通用包中的car文件减少7.9M,这当然有处理无用图片,大图的原因,但是以我们对数据的预估影响应该没这么大。同时将数据导入到资源分析系统后发现很多模块的资源文件大小大幅度减小,这很异常。打开car文件发现了原因:因为在最近的一个版本,商城App放弃了iOS8。支持iOS9以上后Xcode打包的时候会将部分小图合并成类似雪碧图的文件(进一步说明Asset Catalog中不要放大图),如下图:


这本来是件好事,可是对我们的文件大小统计产生了影响:图片被合并后通过assetutil获取到的图片大小不对了。无奈,我们需要解决这个问题。经过尝试,我们将本地cocoapods对资源文件的编译版本改成iOS8,资源文件的统计恢复到了之前的版本,间接解决统计的问题。
最后
以上为商城App包大小优化的做的尝试和主要措施,希望对大家有帮助。当然后面我们还有不少优化工作,比如商城App已经放弃了iOS8,那么我们有足够的空间将部分动态库转成静态库;iconfont对模块化工程的支持(各模块独立使用ttf);大图经过压缩,但是还是不小,后续考虑转成webp格式等。随着业务的快速发展,包大小的增加在所难免。但是在整个业务迭代过程中,我们更加需要遵守更加合理的设计规范,资源文件使用规范,业务准入准出原则来标准化产研过程。这样我们的安装包大小才能得到较好的控制,毕竟优化过程也是相当痛苦的。
参考链接:
[1]App Thinning in Xcode:
https://developer.apple.com/videos/play/wwdc2015/404/
[2]Optimizing App Assets:
https://developer.apple.com/videos/play/wwdc2018/227/
[3]iOS瘦包常见方式梳理:
https://mp.weixin.qq.com/s/J_XYpIfDeeWJBlk9sRQMAA
[4] iPhone安装包的优化:
https://mp.weixin.qq.com/s/t4EVvXJWqX-MFuVy7T-hPQ
[5] CgBI:
http://iphonedevwiki.net/index.php/CgBI_file_format