作者:杨超,腾讯移动客户端开发 工程师
商业转载请联系腾讯 WeTest 获得授权,非商业转载请注明出处。
原文链接:http://wetest.qq.com/lab/view/362.html


WeTest 导读

我这样减少了 26.5M Java 内存! 一文中内存优化一期已经告一段落,主要做的事情是,造了几个分析内存问题的轮子,定位进程各种类型内存占用情况,分析了线程创建 OOM 的原因。当然最重要的是,优化了一波进程静息态的内存占用(减少 26M+)。而二期则是在一期的基础之上,推进已发现问题的 SDK 解决问题,最终要的是要优化进程的动态 Java 内存占用!

通常来说不管是做什么性能优化,逃不出性能优化 3 步曲:

  1. 找到性能瓶颈

  2. 分析优化方案

  3. 执行优化

上述三步看似第三步最能决定优化结果,而事实上,从笔者的几次性能优化经历来看,找到瓶颈确占据了绝对的影响力!

● 能否找到瓶颈意味着优化做不做的下去。

● 找到的瓶颈性能越差意味着优化效果越明显。

● 找到的瓶颈越多同样意味着优化效果越好。


一、如何找瓶颈所在

在分析方法上,主要:

● 分析代码逻辑,检查有问题的逻辑,对其进行相关优化。

● 模拟用户操作 在内存占用较高的时候 dump 内存,使用 MAT 分析

● 然后是分析 HeapDump 的方法

  1. 看 DominatorTree,确定占用内存最多的实例

  2. 通过 GC root 辅助分析内存占用的来源

  3. 通过 RetainHeapSize 量化的分析内存占用

动态内存优化比静态要更难,其难点在于动态二字之上。动态不仅是的查找瓶颈变得困难,也使得对比优化成果不显而易见。而不同的环境、操作路径、设备、使用习惯等各个因素都有可能导致内存占用的不同。可能的情况是:找到的性能瓶颈和用户实际操作的方式不同,导致不能解决外网的 OOM。因此直接获取手机用户的真实数据则是最行之有效的一种方式。

因此辅助采取了另一种方式, 收集真实的用户数据。

● 在手机发生 OOM 的时候 dump 内存,上传到后台,以便后续分析

措施 1:可以优化现有代码逻辑,针对内存占用过多/不合理的场景进行优化。这是主场景。

措施 2:主要分析外网用户的使用习惯下,发生 OOM 的场景。比较容易发现 bug 类问题导致瞬间内存占用过多的场景。

二、找到哪些瓶颈

找到的瓶颈问题很多,稍微按照分类梳理一下:

1. 加载进内存,实际上没用到(还没用到)的数据

1)PullToRefreshListView 的 Loading 和 Empty View lazyLoad,这是下拉刷新的组件,其下拉刷新有一个帧动画,图片较多,占用较多内存。

2)Minibar PlayListView。每个页面都会有一个 Minibar,但是不一定 Minibar 都会打开播放列表。

3)AsyncImageView 的 默认图和失败图以 Drawble 的形式直接加载进内存的。

2、 UI 相关数据,未及时释放

1)24 小时直播间数据,只在节目切换的时候才有用

2)弹幕,只在播放页展示弹幕的时候才有用

3)播放页 TransitionBackgroundManager 大图内存占用问题 。这个一个大图,为了做渐变动画。

3、数据结构不合理,占用内存过多

1)播放历史最多记录 600 个节目信息,每一个 ShowInfo 占用内存多达 22K(通过 MAT 查看 RetainHeap)

2)下载管理会在内存中存储用户下载的 节目信息,歌词,专辑信息,分别占用内存 12K, 0-10K, 12K。并且这里没有数量限制。

4、 图片占用内存过多

1)在应用主页操作一下,发现图片(Bitmap)占用的内存很多

2)高斯模糊图片。

5、 bug 类导致内存占用过多

播放历史应为代码逻辑 bug,导致没有控制记录数量上限。于是用户听的节目越多内存占用就越大。这里的问题主要通过 OOM 上报发现,占用内存最多的一次上报,仅播放历史记录就占内存 50M 之多。

上述 1-4 点通过措施 1 主动检查内存发现。而第 5 点则是在分析了 OOM 上报 “意外” 发现的,如果是通过措施 1 的方式,几乎不可能知道这么多 OOM 竟然是因为这个问题引起的。

三、怎么优化瓶颈

找到问题之后,剩下的就是比较好做的了,只需顺藤摸瓜,各个击破!

1、懒加载(LazyLoad)

针对上面的 1.1, 1.2, 都可以做 LazyLoad,真正需要下拉刷新/展示播放列表的时候再创建相关实例。

1.4 则可以在动画结束之后清理掉相关 Bitmap

1.3 会复杂一点。图片加载组件可以提供 default 图,在图片加载过程中临时展示;以及 faild 图,在图片加载失败之后展示。这两个图在 AsyncImageView 中都是直接引用住图片(Drawable)的。事实上绝大多数场景都会显示成功的图片。因此这里的修改方式是:
AsyncImageView 的 default/fail 图片不再引用 drawable,而是引用资源 ID,在需要的时候再由 ImageLoader 加载进内存,同时这些图片将有 ImageCache 统一管理,并占用内存 LRU 空间(之前是由 Resource 管理)。

这里去掉了几个大图的内存占用。内存占用在几 M 级别。

2、及时释放

上面 2.1 中的 24 小时直播间的数据会一直在内存中,即使用户当前没有在听 24 小时直播间。这个显然是不合理的。

修改的做法是业务数据缓存的 DB 中,在需要用到的时候从 DB 中查询出来

2.2 的弹幕则是纯粹的 UI 相关数据,在播放页退出之后即可释放了。

2.3 是为了动画准备的一张大图,为了做一个炫酷的动画效果。事实上,在动画结束之后,就可以释放了。这个图片占用的内存和手机分辨徐率相关,分辨率(严格来说是 density)越高的手机,图片尺寸越大。在主流手机上 1080p 约 1M。

这里分别减少了 287K + 512K + 1M

3、 优化数据结构

3.1 和 3.2 都会存储节目信息,而节目信息相关的 jce 结构都比较大,通过 MAT,可以看到 Show:12K, Album:10K, 一个 ShowInfo 同时包含了上面两种数据结构。

最合理的方式应该是:

  1. 数据存储在 DB

  2. 在需要数据的时候通过一次 db 查询,拿到具体的数据。

但是因为现有代码都是从内存中查询,接口是同步的方式,全部改异步的成本会比较大,这里我们的时间成本和测试自由都有限。

综合上面 MAT 分析的结果,有个思路:

内存中存储 节目信息(ShowMeta)最少的内存,例如: 节目名,节目 id,专辑 id 之类的信息。而真正的 Show 和 Album 结构存在 DB 中。

这样内存中的数据可以尽量的少,同时大部分已有接口还可以保持同步调用的方式。

此外,从用户的角度出发,假设一个重度用户下载了 1000 个节目,那么每一个 ShowMeta 占用的内存都会被放大 1000 倍,因此载极限的优化 ShowMeta 都不为过。

这里做了两件事:

1. 删字段,把 ShowMeta 中的非必要字段删掉。
比如其中的 url 字段,实际只用来通过 hash 生成文件名,我们完全可以用 showId 代替。而一个 url 长度可达 500Byte,1000 个 ShowMeta 的话,这里就能节省 500K 内存了!

再比如:dowanloadTaskId 字段,是存储下载任务的 id 的,在节目下载完成后,该字段即失去意义,因此可以删除之。

2、 intern 这里是参考了 String.intern 的思路。不同的 ShowMeta 可能会有相同的字段,或者说字段中有相同的部分。

比如同一个专辑中的 ShowMeta 其 albumId 字段都会是相同的,我们只需要保留一份 albumId,其他 ShowMeta 都可以用同一个实例。(内存优化一期对 ShowList 做了同样的改造)

再比如:ShowMeta 中会存储下载文件的全路径,而事实上所有节目都会存储在同一个文件目录中,因此这里把文件路径拆成 目录 + 文件名来存储,而路径采用 intern 的方式,保证了内存中只会有一份。

这里写图片描述

优化前

这里写图片描述

优化后

最直观的看变化是内存占用从 14272B 到 120B。仔细看会发现 ShowRecordMeta 的 retainHeap 不等于各字段内存占用之和,这是因为上面提到的 String intern 的作用,相同字段被复用了,因此这里的 retainheap 不准确,通过 RecordDataManager/countof(records) 计算,平均每一个 record 14800/60 = 247B,减少 98%。

这里的修改结果:
播放历史 ShowHistoryBiz -> ShowHistoryMeta 内存占用从 19k 到 约 216B

下载记录 ShowRecordBiz -> ShowRecordMeta 内存占用 从 14k 到 约 100B

粗略估计,这里修改的播放历史(每次播放都会增加一个记录,上限 600 个),(19256-216)* 600 = 10.9M

和下载记录(假设一个轻度使用用户用户下载 100 个节目),内存总共可以减少:
(14727-100)* 100 = 1.4M

如果是重度用户,下载 1000 个节目,则有 14M 之多!

不得不说这是个很大的数字!

四、图片内存

在 Android 2.3 之后,Bitmap 改了实现,图片内存从 native heap 转移到了 Java heap。这就导致了 JavaHeap 占用暴增。(然而 8.0 又改成 NativeHeap 了,具体原因官方文档并没有提及,有待考察)。

通常我们分析 heap dump 的时候会发现 Bitmap 占用的内存是绝对的大头。这次我们做内存优化也不例外。

这里的思路是分析内存占用是否合理:

  1. 是否所有图片都用于界面展示

  2. 是否图片尺寸过大。

首先,分析内存占用是否合理。经过一期的优化,在不打开 MainActivity 的时候,内存中几乎没有图片。但是打开 MainActivity 之后,内存中会出现几十兆的图片内存。
图片内存主要是用于展示的,也即:被 AsyncImageView 持有的部分。

另外是内存的图片缓存,会持有 最大 JavaHeap 1/8 的内存充当 Bitmap 缓存,使用 LRU 算法淘汰老数据。

当然另外一些图片过大属于使用不当,实际上可以裁剪才 View 实际的大小。

而一些全屏(和屏幕等宽的图,主要是 Banner)图其实可以裁剪的更小一点(如 3/4 大小)减少近 46% 的内存占用,而观感不会有特别明显的区别。(写这个文档的时候突然想到的,TODO 一下)。

问题 1:针对 AsyncImageView 的问题,思考是否所有图片都在用户展示?
答案显然是否定的,一部分图片被 ListView 回收的 view 所持有,这些内存占用显然是不合理的。

问题 2:另外就是 ViewPager 这种多页面视图,给用户展示的实际上只有一个,其他几个视图并没有在展示,因此这里是否可以改造 ViewPager 呢?

针对第一个问题,被 ListView 回收的 view 仍然在内存中的问题,通过改造 AsyncImageView,在 View 从 windowdetach 的时候,主动释放 Bitmap,attach 到 Window 的时候再次尝试加载图片。另外是多图滚动视图,这里的图片很大,因此占用内存也很多。因为历史原因之前使用的是 Gallery,其有 bug 导致会额外引用住两个大图(已经不可见),因此这里使用 RecyclerView 修改了其实现,解决上述问题。

针对第二个问题,目前还没有采取有效措施,主要依赖 Android 系统,主动回收 Activity 的内存。(这里存疑,需要深挖系统代码,理清理逻辑之后再下结论。短期的结论是:系统的清理行为不可靠)。如果要改的话,可以简单的修改一下 ViewPager 的内存,保证在其他 page 不可见的时候,回收其相关的 Fragment。留个 TODO。

LRU + TTL

针对图片缓存,这里本身只是缓存图片并且有 LRU 算法保证不会超过最大内存,理论上内存占用合理。但是 LRU 算法有一个问题,就是一旦缓存满了,后续只能通过添加新 Bitmap 才能淘汰掉老的 Bitmap,而此时缓存占用的内存仍然是最大值。因此这里的思考是 LRU+TTL 算法:即在 LRU 的基础上,指定每一个 Bitmap 在缓存中存在是有效时长。超过时长之后主动将其从缓存中清理掉。这样我们就可以解决 LRUcache 占用的内存不可减少的问题。

再次感谢 afc 组件作者 raezlu 和笔者讨论问题,欣然接受建议,并身体力行的实现了 TTL 方案!

高斯模糊

这里补充一个,关于高斯模糊图片占用内存过高的问题,在之前版本已经优化过了。

因为高斯模糊的图片本身会让图片变得模糊(废话。。),因此图片的信息实质上是丢失了很大一部分的。在此思路的基础上,我们可以把需要高斯模糊的图片先缩小(比如 100x100),然后再做高斯模糊。这样不仅减少了内存占用,同时高斯模糊处理的速度也可以大大增加!

比如,之前遇到播放页封面 cover 图 720*720 的大小,占内存 720 * 720 * 4 = 2M,降低到 100x100 占用内存大小 100 * 100 * 4= 40K,内存优化效果明显,而视觉上几乎没有差距。

五、其他优化

这里主要针对外网的 TOP1 crash,WNS 内部线程创建导致的 OOM。

笔者的解决方案是先根据 crash 上报信息,深挖系统源码《Android 创建线程源码与 OOM 分析》,彻底理清楚线程创建逻辑,并最终确定 crash 原因是线程的无节制创建。然后针对 crash,整理出详细的原因分析,再给 WNS 的小伙伴提了 bug,待修复之后替换 sdk。

六、成果对比

内存优化的效果总体还不错,这里一共做了两期,优化了几十个项目。首先要比较感谢项目组给了可观的排期,这样才有时间做一些比较深入的改动。

静息态内存

一期优化效果是在 Nexus6P@7.1 上测试到的静息态内存优化 26.5M。

二期又进一步做了优化(上文 3.2 3.3 节),现在静息态内存再次 dump 会发现只有 3M 内存了,而这 3M 有一部分是播放列表,一部分是播放页持有的小图片。

通过计算,可以得出静息态内存进一步减少了:
24 小时直播间单例: 287K
弹幕 manager 单例: 512K
播放页动画大图:1M
播放历史 600 个(上限):(19256-216) * 600 = 10.9M
下载记录 下载 100 个节目:(14727-100)* 100 = 1.4M

总共减少: 28M+

动态内存

动态内存比较不好对比,这里决定采用黑盒测试的方式:
打开应用,MainActivity 各个 tab 操作一遍,打开播放页,然后对比内存占用量。鉴于笔者只有一台 Nexus6P 开发机,为了控制变量,这里创建了两台模拟器,并排摆放,分别打开企鹅 FM4.0 和 3.9 版本,确保使用相同的操作路径。

这里测试了两种场景:

  1. 应用新安装

  2. 老用户,听了很多节目(播放历史 600 个),下载近 200 个节目
    这里写图片描述

    experiment

操作对照图

通过 AndroidStudio 查看内存占用情况。

这里写图片描述

compare clean install

在场景一种:4.0 版本占用 38.74M,而 3.9 版本占用 59.78M。减少了 21.04M 内存。

compare heavy use

在场景二中:4.0 版本占用 45.5M,而 3.9 版本占用 87.4M。减少了 41.9M 内存。

事实上,因为有图片缓存在 LRU 算法的基础上增加了 TTL 逻辑,在静止 1 分钟之后(只要不再加载新图片),4.0 版本,内存还会下降。(图片缓存超时主动清理)。

这里写图片描述

4.0 ImageCache TTL

可以看到 Java 内存下降到 34.92M,而此时 3.9 版本仍然没有变化,此时内存减少 52.48M。

PS:需要注意的是 3.9 版本的 “广播” tab 在 4.0 版本替换成了 “书城” tab,而书城 tab 的页面要远复杂的多,图片也更多。

最后,在 4.0 版本发布外网之后,笔者对比了一下 3.9 版本的 Crash 上报,结果如下:

这里写图片描述

总的 crash 率从 0.41% 下降到%0.16,减少了 0.21%。而 OOM 类型的 crash 率从 0.19% 下降到 0.04%,减少了 0.15%!而剩下的 0.04% 则主要是线程创建导致的。目前在通过线程监控组件查找根本原因,后续推动相关 SDK 进行优化!

七、结论

另外需要注意的一点是,动态内存和静态内存虽然分别减少了 52M 和 28M,但是两者是有一部分交集的。

两者的测量标准稍有不同,对应用的影响也不同。

动态内存主要优化 app 在低内存设备上的性能,并减少 OutOfMemory 发生的几率。

而静态内存,主要优化 app 退后台后的内存占用,一方面可以减少应用进程被 Android 系统的 LowMemoryKiller 杀死,另一方面可以让用户的设备有更多剩余内存,用户体验更好。


UPA—— 一款针对 Unity 游戏/产品的深度性能分析工具,由腾讯 WeTest 和 unity 官方共同研发打造,可以帮助游戏开发者快速定位性能问题。旨在为游戏开发者提供更完善的手游性能解决方案,同时与开发环节形成闭环,保障游戏品质。

目前,限时内测正在开放中,点击http://wetest.qq.com/cube/ 即可使用。

对 UPA 感兴趣的开发者,欢迎加入 QQ 群:633065352

如果对使用当中有任何疑问,欢迎联系腾讯 WeTest 企业 QQ:800024531


↙↙↙阅读原文可查看相关链接,并与作者交流