Bitmap: 监控与分析
大概六七年前写的一篇《Bitmap: 监控与分析》,原先是发在内部技术论坛。因为原文内多处链接、图片、代码涉及公司信息,虽然不是什么机密,但直接外发不合适,我也懒得打码处理。今天处理后发出来,当作面试前的复习吧。
上一篇 Bitmap: 从出生到死亡 中讲的是 Bitmap 的创建和回收。这一篇来看看如何打造一个小工具来监控 Bitmap 的创建过程,以便有效分析 Bitmap 内存。
背景
优化Bitmap的必要性
首先谈谈优化Bitmap内存的必要性。
目前主流屏幕分辨率 1080x1920,一张 Bitmap 占满屏幕时内存大小是 1080x1920x4,大约 8MB。所以一个不起眼的 Bitmap 占用的内存也远远大于多数对象。
以某App中某个时刻内存数据为例,内存占用总量 171.8MB。其中 native 内存为 102.5MB,而实际上这其中光 Bitmap 就占 54MB 左右。Bitmap 内存总体占比超过 30%,这个占比足够我们关注其内存大小。
仅此两点就不难理解很多时候辛苦砍代码、砍资源、优化数据结构等方式得来的内存优化,效果却不如去掉或减少一个Bitmap来得直接(当然,并不是说其他优化方式就不重要)。实际上,很多 Android 内存优化的文章提到减少内存几十兆,其中大部分的内存减少来自 Bitmap 的减少。
对开发者来说,优化不合理的Bitmap内存占用,是个成本很小收益却很高的活。
现有工具的不足
再来看看现有工具的不足。毫无疑问,Android Studio 变得越来越好用。它的 Memory Profiler 分析内存问题非常方便。但对于 Bitmap 内存有时显得无能为力,尤其是对于 Android 8.0 及以后的机型而言。
比如说,在分析内存时能预览 Bitmap 会特别直观方便。但 Android 8.0 之后 Bitmap 像素数据在 native 内存,所以 Memory Profiler 中无法预览图片。如下图,可以看到 Bitmap 的创建路径,但看不到它的模样。
注:这里的测试机型是华为 Nova 2,Android 8.0
尽管 Memory Profiler 提供了很丰富的内存信息,但假如我想知道 App 中有多少个 Bitmap 对象、每个 Bitmap 多大、这些 Bitmap 是在哪里生成的、Bitmap 共占用多少内存,Memory Profiler 并不能告诉我答案。LeakCanary 和 MAT 同样也不能很轻松地分析 Bitmap。规根结底,它们是通用工具,并不会为 Bitmap 这类特定对象提供特别的信息。
所有有必要针对 Bitmap 打造一个专用的监控和分析工具。仍然以某App为例,左图是打开App后的第一个Tab,界面上加载了一些图片。右图是 Bitmap 监控工具,列表中显示的是该时刻某 App 中所有的 Bitmap 对象,包括预览图、内存大小、尺寸、创建线程、创建时间、所在页面以及创建路径。
接下来看如何一步步打造这样一个工具。
方案讨论
如果针对 Bitmap 做内存分析,我们最关注的因素包括:
- Bitmap 内存大小
- Bitmap 尺寸
- 创建 Bitmap 的线程
- 创建 Bitmap 的调用栈
- Bitmap 可预览
- Bitmap 的存活时间
基于这些因素来简单讨论下技术方案。
基于 Heap 分析
如果 Bitmap 像素数据存放在 Java Heap,从导出的 heap 中还是不难拿到内存大小以及图片尺寸的,也可实现预览效果。不过 heap 中通常不包括调用栈信息,所以无法得知在哪里创建 Bitmap 的。如果像素数据存放在 native heap,分析过程就要麻烦许多了。再考虑到通过 HAHA库 解析 heap 数据也有不少工作量,所以首先排除基于 heap 分析的方案。
基于对象引用分析
如果在运行时拿到 Bitmap 对象的引用,很容易就能计算出内存大小以及图片尺寸,且实现预览功能极方便。对于应用自定义的类,获取对象引用确实简单。
1 | class Foo { |
不过 Android 平台上的 Bitmap
是个很特别的类,它没有公开的构造方法。所以,
- 第一,应用没法直接使用
new
关键字来创建 Bitmap 对象 - 第二,Bitmap 对象由系统生成,其生成路径多种多样,见 Bitmap 文档。即便制定严格的代码规范(微信 Android 终端内存优化实践中有提到类似做法),也难以保证完全收拢 Bitmap 创建入口。比如第三方库中的某行代码导致生成 Bitmap 对象,甚至仅仅是在XML布局中引用了一个图片资源也会生成 Bitmap 对象,而我们无法拿到这些对象的引用
另一种思路是使用 Java Instrumention 技术来获取 Bitmap 引用。 伪代码如下:
1 | public class Bitmap { |
这是插桩前的 Bitmap。
1 | public class Bitmap { |
这是插桩后的 Bitmap。向 Bitmap 的构建方法中插入 getRef()
方法,该方法将保存并处理当前对象引用。
但这个方案不可行。因为 Bitmap 的代码实际上只存在于 Android 设备上,并不在 APK 包中,所以无法对 Bitmap 插桩。
最后能想到的办法是 hook。Epic 是个不错的 hook 库,可以在 Java 层进行 hook。Hook Bitmap 的代码非常简单:
1 | DexposedBridge.hookAllConstructors(Bitmap.class, |
不过我折腾这个库一段时间,发现稳定性不好。到目前最新的 0.6.0
版本,上述这段简单的代码仍然会由于从寄存器获取参数失败导致 native crash (测试机华为 Nova 2,Android 8.0)。不稳定的原因简单来说就是:在 Java 层进行 hook,除了要考虑不同架构 CPU 指令差异外,还要对抗 ART 虚拟机。而 ART 虚拟机真的很复杂。在 Java 层 hook 一些简单的对象尚可,hook 像 Bitmap 这种相对复杂的对象,坑很多。
至此,几乎唯一可行的方法就是在 native 层进行 hook 后想办法拿到 Bitmap 对象引用。
Hook 技术介绍
深入探讨这个问题可能超出了我的能力范围,也偏离了本文目标。好在 hook 技术是实现很复杂但核心原理简单,加上前人总结了很多不错的资料,写了很多可供直接调用的库。所以理解技术原理和代码库用法之后,也能动手解决问题。另外,为了简单起见,这里说的 hook 特指 inline hook。对于 PLT hook,不妨参考Android PLT hook 概述 (非常不错的资料,推荐一看)
这里是这样描述 inline hook 的:
inline hook 是拦截目标函数调用的一种手段。通常是将原始方法调用重定向到我们指定的新函数,以便在目标函数执行前后可以执行某些操作,包括检查参数、打印日志、过滤某些调用等等。
inline hook 的实现手段是直接修改目标函数的原始指令(即内联修改),通常是用跳转指令替换目标函数代码的前几个字节。跳转指令让目标函数进行实际处理前被重定向
inline hook 的几个关键概念包括:
- 目标函数
- 新函数
- 原始指令
- 跳转指令
Android Arm Inline Hook 是 Android 平台上 inline hook 的一个开源实现,详细介绍见这里。Android Arm Inline Hook 的核心是跳转指令构造以及跳转指令修正,其主要过程如下:
第一步,调用 registerInlineHook()
将目标函数信息记录在 inlineHookItem
结构体中。
1 | enum ele7en_status registerInlineHook(uint32_t target_addr, uint32_t new_addr, uint32_t **proto_addr) |
inlineHookItem
定义如下:
1 | struct inlineHookItem { |
第二步,调用 doInlineHook()
进行真正的 hook。
1 | static void doInlineHook(struct inlineHookItem *item) |
doInlineHook()
是 inline hook 的关键之一。它的作用包括:
- 保留原始指令访问入口。向
proto_addr
写入trampoline_instructions
跳转指令,如果想调用原函数(被hook前的目标函数),执行proto_addr
地址处的方法即可 - 修改目标函数,写入跳转指令
- 首先将目标函数的指令保存到
orig_instructions
(下图中第1处) - 再向目标函数写入跳转指令(红色文字) (下图中第2处)
- 首先将目标函数的指令保存到
调用 doInlineHook()
后其实就已经完成了对 target function 的 hook。所以这时如果调用 target function,其实会跳转到 new function (target function 中的指令已经被修改)。
但还有个重要的问题没解决,如果想调用原始的那个 target function,该怎么办?从上图看,似乎执行 proto_addr
地址处的 trampoline_instructions
即可。实际并非如此。之前只是简单地向 proto_addr
写入 trampoline_instructions
跳转指令,PC值已变化,所以并不能保证 proto_addr
地址处的方法能正确执行。
所以第三步是修正 trampoline_instructions
跳转指令,确保能正确调用原始的 target function (见上图第3处)。这是 inline hook 的另外一个关键。精简后的修正代码如下:
1 | static void relocateInstructionInArm(uint32_t target_addr, uint32_t *orig_instructions, int length, uint32_t *trampoline_instructions, int *orig_boundaries, int *trampoline_boundaries, int *count) |
具体修正过程跟平台想关的,完整代码见 relocate.c。
大致了解 Android Arm Inline Hook库原理后,就能基于它写个 demo。
这个 demo 是个简单的计算器程序,完整代码见 Github
不过它有 bug:点击”计算”按钮,预期应调用 minus()
做减法计算,但实际是调用 add()
做加法计算。
不要紧,可以使用 inline hook 来修复这个 bug。点击 “Hook” 按钮时,将调用 hook()
函数对 add()
执行某种”神奇”的操作,将原本错误的加法会被”切换”成正确的减法。先看效果图:
从图中可以看出:
- 原始的计算结果是错误的,
2 - 1
得到的是3
- Hook 后的计算是正确的,
2 - 1
得到的是1
,20 - 1
得到的是19
- Unhook 恢复成原来的计算方式,是错误的
hook()
函数并不神奇,精简后的代码如下:
1 | int (*old_calc)(int, int) = NULL; |
完整代码见 hello-inline-hook
Bitmap Profiler
Bitmap Profiler 是基于上述 inline hook 实现的 Bitmap 监控工具。其原理是 hook JNI 层的 Bitmap.createBitmap()
函数,如下图:
- hook 前,Android 系统原本是调用
Bitmap.createBitmap()
来创建 Bitmap 对象 - hook 后,Android 系统并非直接调用
Bitmap.createBitmap()
来创建 Bitmap 对象,而是调用我们指定的hook_bitmap_create()
函数。这个函数包括以下两个操作- 先调用
Bitmap.createBitmap()
(被 hook 前的那个原始函数) 创建 Bitmap 对象 - 再回调
BitmapCreationCallback
将新创建的 Bitmap 对象通知给监听方
- 先调用
不妨将这里的 Bitmap.createBitmap()
理解成 Demo 中的 add()
,而将 hook_bitmap_create()
理解成 minus()
。计算器程序中是使用 inline hook 将加法”切换”成减法,Bitmap 监控工具则使用 inline hook 将单纯的 Bitmap 创建 “切换”成 Bitmap 创建 + 回调。两者并无本质差别。
清楚原理之后,还需要解决两个问题:
- 第一个问题,hook 的目标函数是谁?上面我直接给出答案,详细原因稍后展开
- 第二个问题,Android 私有方法访问限制问题
遇到的问题
Hook 谁
第一个问题hook 的目标函数是谁,这个问题很关键。为什么 hook Bitmap.createBitmap()
而不是别的函数?
我在 Bitmap 之从出生到死亡 中提到过:Bitmap 的创建方式有很多,但最终殊途同归,都会调用到 JNI 层中 Bitmap.createBitmap()
方法。
所以显然 JNI 层的 Bitmap.createBitmap()
是最佳的 hook 目标。
私有方法问题
马上又遇到另一个问题,JNI 层的 Bitmap.createBitmap()
是系统私有方法,而 Android 从 7.0 开始对私有方法有访问限制:
- Android 7.0 开始,系统将阻止应用动态链接非公开 NDK 库
- Improving Stability with Private C/C++ Symbol Restrictions in Android N
- Android Q 对非 SDK 接口的限制
官方的作出上述限制的理由是只允许应用访问公开的 API(无论是 Java 层还是 Native 层),不允许访问非公开的方法,以提升应用的稳定性。Nougat_dlfunctions 通过解析 /proc/self/maps
来在一定程度上避开私有方法访问限制。具体细节可参考 Android dlopen 方法的使用限制及解决方案。
Nougat_dlfunctions API 形式和用法跟 dlopen()
和 dlsysm()
基本保持一致:
1 | void *fake_dlopen(const char *libpath, int flags); |
借助以上 API,我们可以正常访问 JNI 层 Bitmap.createBitmap()
函数。代码如下:
1 | /* Prototype of original function */ |
注意这里的一个小细节:JNI 层的 Bitmap.createBitmap()
函数被编译到 libandroid_runtime.so
,不过它在 so 文件中的符号名并不是 Bitmap.createBitmap
,而是类似 _ZN7android6bitmap12createBitmapEP7_JNIEnvPNS_6BitmapEiP11_jbyteArrayP8_jobjecti
这样的名字。调用 fake_dlsym()
时第二个参数传的是函数的符号名。使用 nm
命令可以根据函数名查询出符号名。
JNI 层的 Bitmap.createBitmap()
是私有函数,其参数在每个版本的 Android 中可能稍有不同,所以编译出来的符号名也不一样,需要对此进行兼容处理。
1 | class BitmapCreationCallbackHelper { |
代码结构
- BitmapProfiler 核心库用于 hook 并获取 Bitmap 对象引用
- 应用调用
BitmapProfiler.hook()
方法来 hook - 应用实现
BitmapCreationCallback
接口来指定 hook 后的操作 libbitmap-profiler.so
包含必要的 native 代码
- 应用调用
- BitmapProfiler 扩展库在前者基础上实现一些常用的 Bitmap 分析功能
BitmapTrace
是 Bitmap 的弱引用,它封装了 Bitmap 相关信息BitmapTraceCollector
用于监控和管理 BitmapTrace 对象BitmapTraceListActivity
展示 BitmapTrace 列表,见下图
配置
先在根目录 build.gradle 文件中添加内网 Maven 库:
1 | repositories { |
应用模块的 build.gradle 文件中添加如下依赖:
1 | dependencies { |
基本用法
Bitmap Profiler 的基本用法如下。
1 | BitmapProfiler.hook(new BitmapCreationCallback() { |
可以简单地将其理解为给所有 Bitmap 的创建过程添加了两个生命周期回调,Bitmap 对象本身会作为 afterBitmapCreated()
回调的参数。通过回调方法拿到 Bitmap 对象后就可以做些有意思的工作。
更多用法
Bitmap Profiler 扩展库来分析 Bitmap。用法如下:
- 集成 BitmapProfiler 扩展库
1 | dependencies { |
- 在 manifest.xml 中配置
BitmapTraceListActivity
1 | <activity android:name="com.xyz.abc.bitmapprofiler.extension.BitmapTraceListActivity" /> |
- 启动
BitmapTraceCollector
,开始监控 Bitmap
1 | BitmapTraceCollector.startCollect() |
- 启动
BitmapTraceListActivity
,查看当前 Bitmap
1 | BitmapTraceListActivity.start() |
以下是 BitmapTraceListActivity
截图,列表中显示了当前所有 Bitmap。可以按不同方式对 Bitmap 列表排序,点击进入详细页可以看到 Bitmap 的生成路径。
兼容性
Bitmap Profiler 兼容 Android 4.4 - Android 10。在 WeTest 平台上使用 Top 100 台机型对 Demo 测试,运行正常。
要注意一个坑。Bitmap 的创建方式可分成两种,一种是 Bitmap.createBitmap()
直接创建,另一种是 Bitmap.decodeXXX()
解码创建。
Bitmap Profiler 在 Android 9.0 上无法正常 hook 到由 Bitmap.createBitmap()
直接创建的 Bitmap (在其他版本上正常,包括 Android 10),只能 hook 到解码创建的 Bitmap,所以该机型上可能监控不到某些 Bitmap 对象。
案例介绍
再看看 Bitmap Profiler 的几个应用案例。
检查过大的 Bitmap
使用 Bitmap Profiler 可以很容易观察到有哪些大图。一方面,并不能简单地认为大 Bitmap 对象一定是不合理的。但另一方面,大对象,尤其是那些由主线程创建的大对象意味着潜在风险和可优化空间。潜在的问题包括:
- 图片尺寸超过屏幕大小?
- 图片尺寸超过所在控件大小?
- 线程使用是否合理?
- 重复的大图片?
以某App为例,用户首次启动时会看到介绍页。介绍页使用了多张大图,这些图片均为主线程加载。仅仅是将这些图片换成异步加载,介绍页启动过程就变得更流畅。
上面的大图片还算正常,再来看一个不那么正常的。Bitmap Profiler 显示以下这张图片非常奇怪,明明只是一张纯色图片,内存占用却达 2MB,尺寸 1125x492。
好在 Bitmap Profiler 提供 Bitmap 所在的页面以及创建路径两个关键信息。从图中可以看到,
- 该 Bitmap 对应的界面是
MyFragment
(第4个Tab,”我的”页面) - 该 Bitmap 来自布局中某个
ImageView
控件的src
属性
所以很快就能定位到对应的代码。代码显示是这里确实加载一个很大的图片作为背影色,但实际上可以将这个图片优化成值为 #3C3D4D
,透明度为 85% 的 ColorDrawable
。优化后,内存减少约 2MB。
(注:上面丢失了几幅图片)
如果不借助工具,恐怕很难留意到这样的不起眼的小问题。
Glide transform 问题
上图中的英雄头像上有一个小角标,角标大小为180x180像素。但我们通过 Bitmap Profiler 看到的这个角标对应的 Bitmap 内存大小超过 4MB,显然不合理。
为了容易定位问题,我写了一个 demo 来进行分析。demo 源码见 Github。该 demo 中分别使用 Glide 和 Picasso 加载图片。
- 第一次使用 Glide 加载图片,这时生成了两个 Bitmap
- 第二次使用 Picasso 加载图片,这时只生成一个 Bitmap
Glide transform 问题分析对这一现象有详细分析,这里只放结论。
- Glide 加载图片过程的
transform
操作会导致多生成一个 Bitmap (Picasso 只会生成一个) - Glide 根据控件大小来为
transform
自动计算出具体的width
和height
。但对于宽高为wrap_content
的控件,自动计算出的结果有点出意料,居然是当前屏幕大小
ViewTarget.getSize(SizeReadyCallback cb) 方法用于自动计算 width
和 height
。
- 首先,检查
View.getWidth()
和View.getHeight()
。如果其中某一个为0,进入下一步。否则,结束 - 检查 view 的
LayoutParams
。如果其中宽和高其中某一个小于等于0,进入下一步。否则,结束 - 创建
SizeDeterminerLayoutListener
,等待 measure 过程结束。结束后回调SizeDeterminerLayoutListener
具体代码如下:
1 | public void getSize(SizeReadyCallback cb) { |
以如下布局中 ID 为 iv_tag
的 ImageView
为例,其宽高均为 wrap_content
。按照上述代码逻辑,Glide 认为 iv_tag
大小是 1080x2150 (即当前屏幕大小)!
1 |
|
我打断点看了下,确认无误。
前面提到的 4MB Bitmap 是这样产生的:Glide 认为 iv_tag
大小为 1080x2150 像素,所以它要对大小为 180x180 像素的角标进行 fit center transform 操作。fit center transform 说白了就是对图片进行等比缩放。根据 fit center transform 的缩放规则,会得到一个 1080x1080 的图片。该图片大小为 1080x1080x4,约为 4.4MB,正是 Bitmap Profiler 中看到的那个 Bitmap。
问题原因弄清楚了,解决起来就很简单。
- 解决方法一:Glide 加载图片时尽量避免给
ImageView
设置wrap_content
,而是使用更明确的值或match_parent
- 解决方法二:实际开发中图片是放在腾讯云,利用基础图片处理API可以直接从云端获取指定宽高的图片,所以多数情况下可以禁用 Glide Transformation 功能,减少内存占用的同时还能降低的CPU使用
重复 Bitmap 问题
Tencent Matrix 的 Resource Canary 提供一个很有意思的重复 Bitmap 检查功能。功能介绍如下:
基于 WeakReference 的特性和 Square Haha 库开发的 Activity 泄漏和 Bitmap 重复创建检测工具
具体代码见 DuplicatedBitmapAnalyzer。
不过基于 heap 来检查重复 Bitmap 有两个小小的不足:
- Android 8.0 以后 Bitmap 的像素数据保存在 Native Heap 中,所以实际上 Resource Canary 是无法支持 Android 8.0及以上版本的,见 issue #118
- 基于 Square Haha 写代码,还是比较头大滴
Bitmap Profiler 可以直接拿到 Bitmap 对象引用,所以检查重复 Bitmap 非常容易。检查策略如下:
- 将所有 Bitmap 对象按尺寸分组
- 忽略大小为1的分组
- 对大小为2及2以上的分组,再次将 Bitmap 对像按 MD5 分组
- 忽略大小为1的分组
- 所有大小为2及2以上的分组,均为重复 Bitmap
代码写出来也不复杂:
1 | fun detectDuplicated(): List<List<BitmapTrace>> { |
一开始我也很好奇,怎么可能出现重复 Bitmap 对象呢?使用 Bitmap Profiler 检查一下发现还真的有。
问题原因是同一图片资源以不同的名字被重复添加到项目中,结果生成了重复的 Bitmap 对象。完全可以去掉其中一张图片,减少内存 600KB。 (当然,这种错误最好是编译期能检查到,而不是等到运行期)
参考
- Epic
- Inline hook
- ELF 文件格式
- 访问 Android 私有方法
总结
主要介绍了如何使用 inline hook 来打造一个 Bitmap 监控工具,并通过几个案例介绍了这个工具的一些使用场景。简单总结一下:
- 首先讨论了 Bitmap 内存优化的必要性以及现有工具的不足
- 接下来讨论了为什么要在 native 层进行 hook
- inline hook 技术介绍及 demo 演示
- Bitmap Profiler 的实现原理及遇到的问题
- 问题一:如何确定 hook 的目标
- 问题二:如何访问 Android 私有方法
- Bitmap Profiler 的配置方法和基本用法
- Bitmap Profiler 的使用案例,包括如何用于检查过大的 Bitmap 以及重复 Bitmap
回头来看我的实际工作量很小,Bitmap Profiler 很大程度上是基于已有的代码库。
- Bitmap Profiler 这个工具最初的思路来自 Droid-Thread-Profiler
- Bitmap Profiler 使用 Hook SDK,它经过验证、非常稳定
- Bitmap Profiler 使用 Nougat_dlfunctions 访问 Android 私有方法。
(注:Droid-Thread-Profiler、Hook SDK 都是公司内部代码库,所以相应的链接去掉了。不过,Github 上应该可以找到类似的三方库)
非常感谢他们的工作。
我的工作主要集中在确定 hook 目标这个问题,以及将已有的代码整合起来。不过,工具虽小但作用不小,一定能助你更高效地分析和优化 Bitmap 内存。
(拖延了很久才完成,原因很多。一是确实有拖延症。二是一些技术细节我没有弄得一清二楚,这种情况下拿来分享不免诚惶诚恐,但真的要搞清每个细节又不知何年何月。三是 Android 9 上部分 Bitmap hook 不到,原想解决这个问题再发出来。不想又出了 Android 10…)