支付宝 App 构建优化解析:通过安装包重排布优化 Android 端启动性能
然后内核将读取的数据缓存到 cache 中,这样后续的读请求就可以命中 cache 了。Page 可以只缓存一个文件部分的内容,不需要把整个文件都缓存进来。对磁盘的数据进行缓存从而提高性能主要是基于两个因素:
第一,磁盘访问的速度比内存慢好几个数量级(毫秒和纳秒的差距)。
第二是被访问过的数据,有很大概率会被再次访问。
结合 Android 系统实际来看,上层 App 每次读取磁盘时,文件系统默认会按 16 * 4k block 去磁盘读取数据,并把数据放到 pagecache 中。如果下次读取文件已经在 pagecache 中,则不会发生真实的磁盘 IO,而是直接从 pagecache 中 读取,大大提升读的速度。有缓存就有回收,pagecache 的另一个重要工作是释放 page,从而释放内存空间。Cache 回收的任务是选择合适的 page 释放,并且如果 page 是 dirty 的,需要将 page 写回到磁盘中再释放。
理想的做法是释放距离下次访问时间最久的 page,但是很明显,这是不现实的。基于 LRU 改进的 Two-List 是 Linux 使用的策略。这个回收策略非常类似业务开发领域,常见的图片加载的缓存策略。LRU 算法是选择最近一次访问时间最靠前的 page,即干掉最近没被光顾过的 page。原始 LRU 算法存在的问题是,有些文件只会被访问一次,但是按照 LRU 的算法,即使这些文件以后再也不会被访问了,但是如果它们是刚刚被访问的,就不会被选中。
Two-List 策略维护了两个 list,active list 和 inactive list。在 active list 上的 page 被认为是 hot 的,不能释放。只有 inactive list 上的 page 可以被释放的。首次缓存的数据的 page 会被加入到 inactive list 中,已经在 inactive list 中的 page 如果再次被访问,就会移入 active list 中。两个链表都使用了伪
《Android 学习笔记总结+最新移动架构视频+大厂安卓面试真题+项目实战源码讲义》
【docs.qq.com/doc/DSkNLaERkbnFoS0ZF】 完整内容开源分享
LRU 算法维护,新的 page 从尾部加入,移除时从头部移除,就像队列一样。
如果 active list 中 page 的数量远大于 inactive list,那么 active list 头部的页面会被移入 inactive list 中,从而维持两个表的平衡。简单的说,通过文件重布局的目的,就是将启动阶段需要用到的文件在 APK 文件中排布在一起,尽可能的利用 pagecache 机制,用最少的磁盘 IO 次数,读取尽可能多的启动阶段需要的文件,减少 IO 开销,从而达到提升启动性能的目的。
4. 落地方案
在了解原理之后,就需要考虑怎么用工程化的方案在支付宝 App 上落地,主要从以下三个流程来设计方案并落地。
度量:
重布局的前提必须是精确的度量,定位到那些可以调整,需要调整的文件。这个过程需要足够的准确,否则会导致重布局之后的效果不佳。 度量的最终目的是要,统计到支付宝启动阶段,哪些文件加载了,并且是发生真实的磁盘 IO,还是命中了 pagecache 缓存。我们提供了一个度量工具,通过修改 kernel 源码,dump 出文件系统的 IO 行为,在特定的 Android ROM 上打个补丁,用来统计启动时刻文件行为。部分数据如下:

数据中,第一列的数据表示发生 IO 行为的文件,第二列表示该文件中此偏移量对应的部分发生了 IO 行为。
第三列表示发生 IO 的位置,如果为 0,则表示发生了真实的磁盘 IO;如果为 1,则表示从 pagecache 缓存中读取了内容。
通过数据可以发现,Apk 中部分文件,实际上是发生了磁盘 IO,可以尝试将启动阶段, Apk 中所用到的文件排布到一起,期望通过少量的 IO,就将所有的文件全部读到。之后的工作,需要通过解析 zip 包结构,将上述结果中,文件偏移量对应到详细的文件名。首先需要得到安装包中的文件排布情况,可以通过类似 010 Editor 的工具得到,为了工程化的考虑,也可以参考 zip 格式定义通过脚本分析 zip 文件实现。

然后通过解析结果和先前的统计结果对应分析,就能找到 zip 中哪些文件,在启动阶段被读到,为重布局提供数据支撑。
重布局:
在得到一个启动阶段的文件列表后,第二步工作,就是根据这个文件列表,在构建打包阶段,在 Apk 中把这部分文件排布在一起。这里需要修改 7z 压缩工具的源码。支付宝构建流程,为了提升压缩效率,减少包大小,使用 7z 工具进行最后压缩出 Apk 的过程。这里在简单阐述下,重排布的原因,无论是那种压缩工具,zip 中文件顺序是文件系统的默认顺序,即按照阿拉伯数字和字母顺序。如果想指定文件排在一起,必然要打破这种规则。 修改 7z 源码的过程,简单思路如下,扩展一个命令行参数,我们使用了上箭头'^'(表意性强,提前的意思),可以传入 list.txt,然后 7z 执行输出文件流时候,按照 list 中的文件顺序,改变最后的输出顺序,从而达到重排布的目的。例如如下命令,就是将 source 目录中,所有文件压缩,并且把 list 中指定文件排布在 zip 包的开始位置。
7z a -tzip archive.zip source* ^list.txt
通过这种方式,就实现了文件重排布的简单过程,当然在支付宝的构建流程中,较为复杂,中间还涉及到重打包,重签名等一系列流程。后续内容会提到。 这里有一个小插曲,在刚开始调整文件顺序时,我们通过测量发现效果并不好。后来发现了原因,原先我们调整的文件列表,只是度量阶段发现,所有发生磁盘 IO 的文件,把他们排布到一起,错误的认为,只要他们调整了,整体 IO 情况就会改善。可是忽略了“此消彼长”的问题,如果只调整这些文件,那么原先排布在这些文件后面,利用预读机制进缓存 cache 的文件,如果在启动阶段用到,可能会发生新的磁盘 IO。正确的调整方式,应该能精确按时间顺序统计启动阶段的所有文件,排布在一起,这样发生少量 IO,就能全部读到 cache 中。 简单看下某一次实验主 Apk 中文件调整前后的效果如下,几个和配置相关的移到文件头部。
调整前

调整后

回归测试:
按照所有计划将文件全部调整完毕后,就到了验证效果的环节。主要有以下几种验证方式和思路:
线下录屏,然后拆解视频帧,测直观的启动时间。
线下使用工具度量 IO 情况,观察启动阶段磁盘 IO 数量是否减少,量化一个“cache miss 率”的概念。
线下通过埋点的方案,通过脚本,多次模拟冷启动,取平均值测量,消除可能误差,观察趋势。
线上灰度在其他优化和代码类似情况下,只通过调整 IO,比较两个版本的启动时间变化。 在重布局方案实验阶段,使用一二两种方案较多,后续工程化落地和常态化优化时,应采用三四种方案。
5. 演进
通过上述落地方案,在线下以及某些线上灰度版本中完成初步实验后,我们考虑工程化,常态化的进行这件事情。在工程化之前,先对度量流程进行了扩充,探索出了一种较为简单的度量手段。
度量优化:
原先的度量方案,具备较深的技术含量,在这个方案中,需要对 Linux 底层文件系统非要熟悉和了解,并且还需具备修改源码的能力,此方案是由其他资深专家指导下实现,短期内,团队暂时无法独立这个方案。 为了让整体方案可控,我们想到了直接在 Android 源码的资源加载流程中记录日志,然后通过日志直接分析,这样启动阶段文件加载一目了然,当然缺陷也很明显,无法通过判断文件读取是通过磁盘 IO 还是 pagecache 缓存。 干预资源加载记录,要不通过 hook 方式,要不就是直接改 framework,刷个 ROM,考虑到工程化自动化测试的因素,采用了修改 framework 的方式,方便后续有测试平台,直接使用特定手机跑脚本执行即可。 以 Android 7.0 版本为例,主要修改 drawable 相关流程和 xml 相关流程。其他版本如果做测试度量机型的化,修改方式类似。
xml 加载流程修改,在解析 xml 文件流程,直接打日志。
/**
Loads an XML parser for the specified file.
@param file the path for the XML file to parse
@param id the resource identifier for the file
@param assetCookie the asset cookie for the file
@param type the type of resource (used for logging)
@return a parser for the specified XML file
@throws NotFoundException if the file could not be loaded*/@NonNullXmlResourceParser loadXmlResourceParser(@NonNull String file, @AnyRes int id, int assetCookie,@NonNull String type)throws NotFoundException {if (id != 0) {try {synchronized (mCachedXmlBlocks) {if (!getResourcePackageName(id).equalsIgnoreCase("android")) {Log.i("AlipayRes", "ResourceId: " + Integer.toHexString(id) + " ResourcePackage name: " + getResourcePackageName(id) + " Loading xml: " + file);}final int[] cachedXmlBlockCookies = mCachedXmlBlockCookies;final String[] cachedXmlBlockFiles = mCachedXmlBlockFiles;final XmlBlock[] cachedXmlBlocks = mCachedXmlBlocks;// First see if this block is in our cache.final int num = cachedXmlBlockFiles.length;for (int i = 0; i < num; i++) {if (cachedXmlBlockCookies[i] == assetCookie && cachedXmlBlockFiles[i] != null&& cachedXmlBlockFiles[i].equals(file)) {return cachedXmlBlocks[i].newParser();}}…………}
drawable 修改
/**
Loads a drawable from XML or resources stream.*/private Drawable loadDrawableForCookie(Resources wrapper, TypedValue value, int id,Resources.Theme theme) {if (value.string == null) {throw new NotFoundException("Resource "" + getResourceName(id) + "" ("
Integer.toHexString(id) + ") is not a Drawable (color or path): " + value);}
final String file = value.string.toString();
if (TRACE_FOR_MISS_PRELOAD) {// Log only framework resourcesif ((id >>> 24) == 0x1) {final String name = getResourceName(id);if (name != null) {Log.d(TAG, "Loading framework drawable #" + Integer.toHexString(id)
": " + name + " at " + file);}}}
if (DEBUG_LOAD) {Log.v(TAG, "Loading drawable for cookie " + value.assetCookie + ": " + file);}if (!getResourcePackageName(id).equalsIgnoreCase("android")) {Log.i("AlipayRes", "ResourceId: " + Integer.toHexString(id) + " ResourcePackage name: " + getResourcePackageName(id) + " Loading drawable: " + file);}…………}
刷入 ROM,替换修改后 framework 后,冷启动支付宝,清楚缓存,通过日志过滤即可得到完整启动文件加载列表。
adb shell am force-stop com.eg.android.AlipayGphone
总结:
面试是一个不断学习、不断自我提升的过程,有机会还是出去面面,至少能想到查漏补缺效果,而且有些知识点,可能你自以为知道,但让你说,并不一定能说得很好。
有些东西有压力才有动力,而学到的知识点,都是钱(因为技术人员大部分情况是根据你的能力来定级、来发薪水的),技多不压身。
附上我的面试各大专题整理: 面试指南,满满的都是干货,希望对大家有帮助!

评论