iOS安装包瘦身方案探索和实践

安装包过大,不利于市场人员做推广,最近做了 iOS 安装包瘦身的技术研究和实践。
iOS APP经过编译,打包文件中除了资源文件,剩下的就是一个可执行文件了。

瘦身,可以从 ​三个方面入手:
  1. ​资源文件
  2. 可执行文件
  3. ​编译选项

下面从这三个方面来分析安装包瘦身的方法和一些工具使用。

1. 资源文件

资源文件包括图片、声音、配置文件、文本文件、xib、storyboard、证书等。其中最常用的资源是第一种,优化方式无非删除或压缩处理。

1.1 删除无用的资源文件

推荐使用工具 LSUnusedResources


搜索出来结果后,选中某行,点击Delete按钮即可删除资源。

1.2 压缩资源文件

常用的有两个工具:

  • ImageOptiom:无损压缩工具,图片较小时使用
  • TinyPNG:有损压缩工具,图片较大尺寸时使用。
1.3 使用.xcassets 导入图片

打包之后会生成 Assets.car ,文件的大小会降低。

2. 可执行文件

Mach-O为Mach Object文件格式的缩写,是mac上可执行文件的格式,类似于windows上的PE格式 (Portable Executable )或 linux上的elf格式。Mach-O文件分为这几类:

  • Executable:应用的主要二进制;
  • Dylib Library:动态链接库;
  • Static Library:静态链接库;
  • Bundle:不能被链接的Dylib,只能在运行时使用dlopen( )加载,可当做macOS的插件;
  • Relocatable Object File :可重定向文件类型。

对于这几种类型的Mach-O文件,我们可以使用MachOView进行查看。MachOView是一个开源的工具,源码在GitHub上:https://github.com/gdbinit/MachOView
不过该项目已经很久没有更新了,在 MacOS High Sierra 10.13.3系统上,使用很短的时间后会崩溃。

查看一个可执行文件(Executable 文件):

查看一个静态库文件(Static Library):

点开一个Static Library

从上图可以看到,Static Library有很多.o文件,每个.o文件都对应一个类编译后的文件,展开查看“Mach Header”信息,可以看到每个类的CPU架构信息、Load Commands数量 、Load Commands Size 、File Type、Flags等信息。

我们也可以在Xcode中,开启编译选项Write Link Map File,编译之后来查看可执行文件的全貌。

2.2 linkmap文件

LinkMap文件是Xcode产生可执行文件的同时生成的链接信息,用来描述可执行文件的构造成分,包括代码段(__TEXT)和数据段(__DATA)的分布情况。

在Xcode中,选择XCode -> Target -> Build Settings -> 搜map -> 把Write Link Map File选项设为YES,并指定好linkMap的存储位置,如图所示:

LinkMap里展示了整个可执行文件的全貌,列出了编译后的每一个.o目标文件的信息(包括静态链接库.a里的),以及每一个目标文件的代码段,数据段存储详情。下面来简单分析一下这个文件的结构。

2.2.1目标文件列表

打开LinkMap文件,首先看到的就是编译后的每一个.o目标文件的信息
2.2.2 段表

接着是一个段表,描述各个段在最后编译成的可执行文件中的偏移位置及大小,包括了代码段(__TEXT,保存程序代码段编译后的机器码)和数据段(__DATA,保存变量值)。

这里可以清楚看到各种类型的数据在最终可执行文件里占的比例,例如__text表示编译后的程序执行语句,__data表示已初始化的全局变量和局部静态变量,__bss表示未初始化的全局变量和局部静态变量,__cstring表示代码里的字符串常量,等等。

2.2.3符号表(Symbols)

Symbols 是对 Sections 进行了再划分,这里会描述所有的 methods、ivar 和字符串,以及它们对应的地址、大小、文件编号信息。

首列是数据在文件的偏移地址,第二列是占用大小,第三列是所属文件序号,对应2.2.1中的文件编号,最后是名字。

例如第69行代表了文件序号为3(反查上面就是 AppDelegate.o)的window方法占用了44 byte大小。

计算某个.o文件在最终安装包中占用的大小,主要是解析目标文件和符号表两个部分,从目标文件读取出每个.o文件名和对应的序号,然后对Symbols中序号相同的文件的Size字段相加,即可得到每个.o文件在最终包的大小。

2.3 可执行文件瘦身

通过脚本分析前面说的LinkMap文件,我们可以更加清晰的知道具体的某个类在可执行文件中的大小。

var readline = require('readline'),
    fs = require('fs');

var LinkMap = function(filePath) {
    this.files = []
    this.filePath = filePath
}

// 记录总大小
    var totalSize = 0


LinkMap.prototype = {
    start: function(cb) {
        var self = this
        var rl = readline.createInterface({
            input: fs.createReadStream(self.filePath),
            output: process.stdout,
            terminal: false
        });
        var currParser = "";
        rl.on('line', function(line) {
            if (line[0] == '#') {
                if (line.indexOf('Object files') > -1) {
                    currParser = "_parseFiles";
                } else if (line.indexOf('Sections') > -1) {
                    currParser = "_parseSection";
                } else if (line.indexOf('Symbols') > -1) {
                    currParser = "_parseSymbols";
                }
                return;
            }
            if (self[currParser]) {
                self[currParser](line)
            }
        });

        rl.on('close', function(line) {
            cb(self)
        });
    },

    _parseFiles: function(line) {
        var arr =line.split(']')
        if (arr.length > 1) {
            var idx = Number(arr[0].replace('[',''));
            var file = arr[1].split('/').pop().trim()
            this.files[idx] = {
                name: file,
                size: 0
            }
        }
    },

    _parseSection: function(line) {
    },

    _parseSymbols: function(line) {
        var arr = line.split('\t')
        if (arr.length > 2) {
            var size = parseInt(arr[1], 16)
            var idx = Number(arr[2].split(']')[0].replace('[', ''))
            if (idx && this.files[idx]) {
                this.files[idx].size += size;
            }
        }
    },

    _formatSize: function(size) {
        //totalSize += size;

        if (size > 1024 * 1024) return (size/(1024*1024)).toFixed(2) + "MB"
        if (size > 1024) return (size/1024).toFixed(2) + "KB"
        return size + "B"
    },

    statLibs: function(h) {
        var libs = {}
        var files = this.files;
        var self = this;
        for (var i in files) {
            var file = files[I]
            var libName
            if (file.name.indexOf('.o)') > -1) {
                libName = file.name.split('(')[0]
            } else {
                libName = file.name
            }
            if (!libs[libName]) {
                libs[libName] = 0
            }
            libs[libName] += file.size
        }
        var i = 0, sortLibs = []
        for (var name in libs) {
            sortLibs[i++] = {
                name: name,
                size: libs[name]
            }
        }
        sortLibs.sort(function(a,b) {
            return a.size > b.size ? -1: 1
        })
        if (h) {
            sortLibs.map(function(o) {
                o.size = self._formatSize(o.size)
            })
        }
        return sortLibs
    },

    statFiles: function(h) {
        var self = this
        self.files.sort(function(a,b) {
            return a.size > b.size ? -1: 1
        })
        if (h) {
            self.files.map(function(o) {
                o.size = self._formatSize(o.size)
            })
        }
        return this.files
    }
}

if (!process.argv[2]) {
    console.log('usage: node linkmap.js filepath -hl')
    console.log('-h: format size')
    console.log('-l: stat libs')
    return
}
var isStatLib, isFomatSize
var opts = process.argv[3];
if (opts && opts[0] == '-') {
    if (opts.indexOf('h') > -1) isFomatSize = true
    if (opts.indexOf('l') > -1) isStatLib = true
}

var linkmap = new LinkMap(process.argv[2])



linkmap.start(function(){
    
    var ret = isStatLib ? linkmap.statLibs(isFomatSize) 
                        : linkmap.statFiles(isFomatSize)
    for (var i in ret) {
        console.log(ret[i].name + '\t' + linkmap._formatSize(ret[i].size))
        totalSize += ret[i].size
    }
    console.log("totalSize:" + linkmap._formatSize(totalSize))
})

新建一个只引入高德地图的项目,生成alipaylinkmap.txt文件后。
将以上js代码保存为 linkmap.js ,执行脚本(python linkmap.py ./alipaylinkmap.txt)后,输出结果如下:

MAMapKit(MAMapKit-arm64-master.o)   405.51KB
AMapFoundationKit(AMapFoundationKit-arm64-master.o) 314.42KB
AMapFoundationKit(wgs2gcj.o)    13.61KB
AppDelegate.o   8.86KB
libSystem.tbd   2.56KB
CoreGraphics.tbd    2.34KB
libobjc.tbd 1.13KB
CoreFoundation.tbd  544B
ViewController.o    531B
Security.tbd    512B
UIKit.tbd   320B
libPods-TestAMMap.a(Pods-TestAMMap-dummy.o) 257B
MAMapKit(Pods-MAMapKit-dummy.o) 256B
libz.tbd    256B
Foundation.tbd  256B
SystemConfiguration.tbd 256B
libc++.tbd  232B
CFNetwork.tbd   192B
main.o  186B
QuartzCore.tbd  96B
CoreLocation.tbd    64B
linker synthesized  0B
libstdc++.6.0.9.tbd 0B

totalSize:752.30KB

下面是对只引入百度地图的项目link文件的统计

BaiduMapAPI_Map(BMKMapView.o)   133.41KB
BaiduMapAPI_Search(BMKRouteSearch.o)    119.47KB
BaiduMapAPI_Map(BVDEDataCfg.o)  111.16KB
BaiduMapAPI_Map(BVDBBase.o) 96.09KB
BaiduMapAPI_Base(VCMMap.o)  94.91KB
BaiduMapAPI_Map(VMapControl.o)  93.09KB
libcrypto.a(obj_dat.o)  81.05KB
BaiduMapAPI_Search(BMSerail.o)  69.13KB
BaiduMapAPI_Search(RoutePlanJsonPharser.o)  68.54KB
BaiduMapAPI_Map(DrawUnit.o) 61.88KB
BaiduMapAPI_Search(Searcher.o)  61.56KB
BaiduMapAPI_Map(MapView.o)  57.85KB
BaiduMapAPI_Search(PoiJsonPharser.o)    52.56KB
BaiduMapAPI_Base(VHttpClient.o) 43.88KB
BaiduMapAPI_Map(BMKOverlayView.o)   41.80KB
BaiduMapAPI_Search(BMKRouteSearchType.o)    41.74KB
BaiduMapAPI_Map(BVDBUrl.o)  41.37KB
BaiduMapAPI_Base(CommonMemCacheEngine.o)    40.33KB
BaiduMapAPI_Map(Style.o)    37.07KB
BaiduMapAPI_Search(BMKPoiSearch.o)  36.69KB
BaiduMapAPI_Base(SpatialUtil.o) 34.29KB
BaiduMapAPI_Map(BMMapViewManager.o) 33.52KB
BaiduMapAPI_Map(PoiMarkData.o)  29.14KB
BaiduMapAPI_Map(PoiMarkLayer.o) 27.46KB
libssl.a(t1_lib.o)  26.24KB
BaiduMapAPI_Search(RoutePlanSearchUrl.o)    26.15KB
BaiduMapAPI_Map(BVMDDataVMP.o)  24.88KB
BaiduMapAPI_Map(TapDetectingView.o) 24.77KB
BaiduMapAPI_Map(bmanimationfactory.o)   24.68KB
libssl.a(s3_lib.o)  23.41KB
BaiduMapAPI_Map(LocalMap.o) 23.07KB
libcrypto.a(ec_curve.o) 21.93KB
libssl.a(s3_clnt.o) 21.44KB
BaiduMapAPI_Map(GridIndoorLayer.o)  21.29KB
BaiduMapAPI_Cloud(BMKCloudSearch.o) 21.24KB
BaiduMapAPI_Base(BGLLine.o) 21.20KB
BaiduMapAPI_Map(MapController.o)    20.68KB
libssl.a(ssl_ciph.o)    20.32KB
BaiduMapAPI_Map(BMHeatMapService.o) 20.23KB
libssl.a(s3_srvr.o) 19.80KB
BaiduMapAPI_Base(BGLBase.o) 19.70KB
BaiduMapAPI_Base(AppMan.o)  19.63KB
libcrypto.a(wp_block.o) 19.27KB
BaiduMapAPI_Base(gpc.o) 18.86KB
BaiduMapAPI_Map(BVIDDataTMP.o)  18.59KB
BaiduMapAPI_Map(BMKOfflineMap.o)    18.33KB
BaiduMapAPI_Utils(Adapter.o)    18.16KB
libcrypto.a(err.o)  18.08KB
BaiduMapAPI_Map(BaseLayer.o)    17.76KB
...
totalSize:4.83MB

从结果看到,不仅是我们编写的类的大小可以统计出来,第三方的也可以。在实际工程中,我们可以对一些可执行文件中过大的第三方库,思考其存在的必要性,对于不需要存在或者有替换方案的,可以考虑替换或删除。

2.4 清理无用代码神器: AppCode

我们可以用它的inspect code来扫描无用代码,包括无用的类、函数、宏定义、value、属性等,而safe delete功能使得删除一些由于runtime被调用到的代码时更加安全智能。扫描结果示例:
3、 编译选项优化
  • Strip Link Product设成YES

  • Make Strings Read-Only设为YES

  • 去掉异常支持,Enable C++ Exceptions和Enable Objective-C Exceptions设为NO,并且Other C Flags添加-fno-exceptions,可执行文件减少了1M,

  • Build Settings->Strip Debug Symbols During Copy: release版应该设置为YES,可以去除不必要的调试符号。

  • Build Settings->Optimization Level:release版应该选择Fastest, Smalllest,这个选项会开启那些不增加代码大小的全部优化,并让可执行文件尽可能小。

其它途径
  • iOS9 App Thinning:严格来说App Thinning不会让安装包变小,但用户安装应用时,苹果会根据用户的机型自动选择合适的资源和对应CPU架构的二进制执行文件(也就是说用户本地可执行文件不会同时存在armv7和arm64),安装后空间占用更小
  • iOS8 Embed-Framework:该特性需要最低版本iOS8才能用,iOS7设备启动会crash
  • ARC->MRC:ARC代码会在某些情况多出一些retain和release的指令,通过实验,结论是ARC大概会使代码段增加10%的size,考虑代码段占可执行文件大约有80%,估计对整个可执行文件的影响会是8%。
  • 类/方法命名长度 :从LinkMap可以发现每个类和方法名都在__cstring段里都存了相应的字符串值,所以类和方法名的长短也是对可执行文件大小是有影响的,原因还是Objective-C的动态特性,因为需要通过类/方法名反射找到这个类/方法进行调用,Objective-C对象模型会把类名,方法名列表都保存下来。实际上这部分占用的长度比较小,较大项目也就几百K,可以忽略。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,607评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,047评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,496评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,405评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,400评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,479评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,883评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,535评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,743评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,544评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,612评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,309评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,881评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,891评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,136评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,783评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,316评论 2 342

推荐阅读更多精彩内容