Shark Explorer 位图像素获取全解析:heap dump 里到底藏着多少像素,以及如何拿到没有的那些
【免费下载链接】leakcanaryA memory leak detection library for Android.项目地址: https://gitcode.com/gh_mirrors/le/leakcanary
Shark Explorer(LeakCanary 仓库中的桌面端堆转储浏览器,位于shark/shark-explorer)需要在树形图中直接绘制出进程里每一张 Bitmap 的缩略图。但一张位图的像素数据在不同 Android 版本上存放的位置截然不同,这直接决定了「一份 heap dump 里有没有像素」「没有的话还能不能拿回来」。本文以仓库内的设计笔记 shark/shark-explorer/notes/bitmaps.md 为主线,结合shark-explorer-core、shark-explorer-jdwp与shark-explorer-app的源码实现,完整讲解位图像素在三个 Android 时代的存放位置、三种获取手段、native pointer 的复用校验、无 Config 信息时的mBuffer解码,以及一系列实测得出的 adb 操作要点。读完你既能理解 Shark Explorer 为什么这样设计,也能掌握在自己的工具链里「从一份没有像素的 dump 反向找回像素」的完整思路。
Bitmap 像素的三个时代:只有两个时代能让 dump 自带像素
一张android.graphics.Bitmap的像素数据放在哪里,历史上发生过两次重大迁移。Shark Explorer 的所有设计都建立在这张表上:
| Android 版本 | 位图像素存放位置 | heap dump 里有什么 |
|---|---|---|
| API 26 之前 | Bitmap.mBuffer,Java 堆上的一个byte[] | 每张位图的每一个像素 |
| API 26–34 | native 内存,由mNativePtr指向 | 宽、高和一个地址 |
| API 35 及以上 | native 内存 | 宽、高和地址,外加——如果用am dumpheap -b png转储——每张位图一张 PNG |
两个关键转折点:
- API 26(Android 8):
Bitmap把像素迁移到了 native 内存,这就是众所周知的 "Bitmap is now backed by native memory" 变更。Java 堆转储从此不再覆盖像素数据。 - API 35(Android 15):
am dumpheap -b <format>把像素「还了回来」。该命令会让ActivityThread.handleDumpHeap在调用Debug.dumpHprofData之前先调用Bitmap.dumpAll(format),把结果填充进一个静态的Bitmap.dumpData字段——一个Bitmap$DumpData对象,包含count、format、natives: long[]和buffers: byte[][]——写完后随即清空。因此压缩后的图像以普通byte[]的形式存在于 dump 中,natives[i]标明buffers[i]属于哪张位图。
Shark Explorer 对dumpData的读取实现在 HeapBitmaps.kt:通过readDumpDataIndex()找到android.graphics.Bitmap类的静态字段dumpData,读出format、natives与buffers,构建出「native pointer → byte[] 对象 id」的索引。这里有一个值得注意的实现细节:DumpData会为进程里的每张位图分配数组槽位,但只填充成功压缩的那些,因此数组尾部是空的——所以代码用DumpData.count(而非数组长度)来决定遍历多少个条目。
为了让读者(以及未来的维护 Agent)不产生「中间那行是边缘情况」的错觉,笔记给出了仓库内实测数据:
| Dump | API | Bitmap 数 | 有像素的 |
|---|---|---|---|
compose_leak.hprof | 23 | 360 | 360,来自mBuffer |
hashmap_api_25.hprof | 25 | 93 | 93,来自mBuffer |
large-dump.hprof | 25 | 151 | 151,来自mBuffer |
Pixel 9,am dumpheap -b png | 36 | 4 | 4,来自dumpData,指针失配 0 |
模拟器,am dumpheap后接调试器 | 29 | 6 | dump 内 0 张,抓取后 6 张,失配 0 |
注意一个反直觉的结论:仓库里提交的所有 heap dump 都「老到」自带像素,因此最关键的代码路径恰恰是没有任何一个已提交 dump 能覆盖的那条(API 26+、没有-b的 dump)。要验证它,要么在shark-explorer-core的测试中手工合成 dump——BitmapDumps.kt 提供了bitmapClass()/bitmapInstance()/bitmapDumpData()三个辅助函数,可以构造带或不带dumpData的Bitmap类;要么直接从设备上抓一份新 dump。测试辅助函数中bitmapClass的字段列表(mWidth、mHeight、mRecycled、mRequestPremultiplied、mNativePtr、mBuffer)与真实 dump 中 Explorer 读取的字段完全一致,这本身就是对读取逻辑的一份可执行文档。
直接从窗口抓 dump:API 35+ 最便宜的拿像素方式
「在这台设备上抓的 dump」是获取像素成本最低的路径。在 DeviceHeapDumps.kt 的dumpHeap中,只要设备 API 达到 35(常量MIN_BITMAP_DUMP_SDK_INT = 35,见 DeviceHeapDumps.kt),pullHeapDump就会在am dumpheap的参数里追加-b png(BITMAP_FORMAT = "png",见 DeviceHeapDumps.kt)。这样通过窗口抓下来的 dump 自带位图,之后什么也不用补。
选择 PNG 而不是 JPEG/WEBP,有一个非常具体的技术理由,后面「native pointer 是 join key」一节会详细展开:PNG 的IHDR头只需 24 字节就能读到图像的宽和高,这是指针复用校验得以成立的前提。
dumpHeap里还有一处与位图无关、但同属「抓 dump 前置工作」的设计:转储前先做一次垃圾回收。原因在于am dumpheap本身不会先回收——ART 的 hprof 转储器是挂起运行时而不是先收集垃圾,不回收的话 Explorer 打开的是一个充满无人可处理垃圾的堆。API 27+ 用am dumpheap -g(MIN_GC_BEFORE_DUMP_SDK_INT = 27,见 DeviceHeapDumps.kt),更老的设备则回退到 JDWP 调试器(JdwpGc)在进程内执行同样的System.gc()序列。实测同一 API 29 进程三种抓法:不收集时 18.05 MB 不可达,-g时 0.13 MB,调试器时 0.80 MB。详细论证见 shark/shark-explorer/notes/decisions.md。
fetchBitmaps:给一份「外来的」dump 补像素
如果被浏览的 dump 来自其他地方(比如同事发来的文件),或者来自没有-b参数的转储,那它里面就没有任何像素。此时 Shark Explorer 提供DeviceHeapDumps.fetchBitmaps(DeviceHeapDumps.kt)——「再抓一份同一个进程的 dump,只为了其中的图像」,或者「让调试器逼进程自己压缩」。根据设备能力二选一:
- API 35+:对同一进程再执行一次带
-b png的 dump,读完后立即删除临时文件(它只为图像而存在)。注意这里withGc = false——与「抓来浏览」的 dump 不同,这份 dump 只读图像,而先收集垃圾反而会让还存在于上一份 dump 中的位图在这份里消失。 - API 26–34:交给
BitmapDebugger接口的实现(JdwpBitmaps),下文详述。如果既没有-b能力又没有调试器,fetchBitmaps会抛出一个把原因讲清楚的AdbFailureException——「am dumpheap -b是 Android 15(API 35)才有的,从更老的设备读像素意味着给进程挂调试器,而这个窗口没有提供这种方式」。
无论是 dump 里自带的dumpData,还是抓取回来的图像,两者都以同一把钥匙索引:位图的 native pointer。下一节解释为什么这个索引是安全的。
native pointer 是 join key,而且它可以被复用
两份 dump(被浏览的旧 dump + 为补像素新抓的 dump)能对上号,靠的是mNativePtr——同一进程稍后抓取的 dump 通过指针「加入」正在浏览的 dump。代码层面,HeapBitmaps维护两个PointerImages来源:dump 内的dumpedImages(惰性按需读取,见 HeapBitmaps.kt)和抓取来的liveImages(HeapBitmaps.kt),两者都以Long指针为键。
让指针匹配「足够安全、敢展示给用户」的,是一条硬规则,写死在PointerImages.imageOf(HeapBitmaps.kt):
一幅图像只有尺寸和位图匹配时才被接受。
原因很本质:指针只是 native 分配的地址。一张位图被 recycle 之后,另一张位图完全可能在同一地址上分配——那么拿到的像素就是该进程某张真实位图的,只是不是屏幕上那一张。PNG 的IHDR在 24 字节内给出图像的宽高,这正是-b只请求 PNG 的原因:只有 PNG 能让校验廉价到「看一眼头就知道尺寸对不对」。
尺寸不匹配的拒绝会被计数(BitmapCounts.mismatchedCount,见 HeapBitmaps.kt)并明确说出来——否则「抓取没匹配上任何位图」看起来会和「抓取悄悄什么都没做」一模一样。counts()对 dump 中每张位图做一次遍历,统计总量、有图数量与失配数量,carriesNativePixels标志则告诉 UI「这些像素是-b压缩产物还是mBuffer」,从而决定是否值得再次转储进程来补图。
API 26–34:让进程为自己的位图压缩——JDWP 方案
这一段的起点是个死结:API 26–34 的任何 heap dump 都不可能携带像素——am dumpheap不接受-b,而唯一能显示这些像素的另一款工具 Perfetto 的 Heap Dump Explorer,在没有它的情况下也渲染不出任何东西。但像素仍在进程里,Bitmap.compress也仍在进程里,所以可以让进程自己压缩自己的位图。
JdwpBitmaps(JdwpBitmaps.kt)的实现思路:通过 JDWP 附加到进程,用ReferenceType.instances枚举所有存活的Bitmap,对每一张调用compress(PNG, 100, ByteArrayOutputStream()),再把字节读回来。产出与dumpAll完全同构的、以 native pointer 为键的图像集合,因此能以完全相同的方式 join 回一份 dump。
它的工程优点(写在 JdwpBitmaps.kt 的 KDoc 里):com.sun.jdi是 JDK 自带模块,所以不需要为设备构建任何东西——没有 JVMTI agent、没有 NDK、没有按 ABI 区分的.so、没有任何需要 push 的文件。代价与am dumpheap相同:应用必须是 debuggable 的(这是开启 JDWP 连接的条件),且不能有别的调试器挂着——被 Android Studio 调试中的应用是无法附加的。
实测成本
在 API 29 模拟器上转储leakcanary-android-sample实测:
- 抓取 6 张位图共 5.4 秒,几乎全是固定开销:
adb forward、附加、等待应用运行起来。如果「转储+抓取」一步完成,成本是两者相加且不多一分——17 MB dump 花 2.3 秒,然后附加并读取同一进程的位图再花 5.5 秒。 - 压缩一张 1080×2400 的
Config.HARDWARE位图耗时 461 ms;64×64 的小图只要 85 ms。 - 通过 JDWP 读回 2 MB 数据耗时 144 ms——传输根本不是这笔账的大头。
抓取还有明确的预算保护,因为这是一次「挂起整个应用」的操作:60 秒压缩预算(COMPRESS_BUDGET_SECONDS)与 64 MB 字节上限(MAX_TOTAL_BYTE_COUNT),谁先到谁停(JdwpBitmaps.kt)。位图按像素数从大到小排序(drawableBitmaps,见 JdwpBitmaps.kt),因为预算切断的是小图尾巴,而大图才是树形图上值得看的大矩形;同样地,recycled 与零尺寸的位图被提前剔除(compress对 recycled 会抛异常)。
三个踩了很久才搞清楚的关键点
笔记特别记录了三条「代码依赖其上」的深水区经验:
Config.HARDWARE位图也能压回来。这很重要,因为现代图片加载库产出的正是 HARDWARE 位图,而getPixels会拒绝它。compress没有 HARDWARE 守卫:它通过 render thread 把像素从 GPU 读回来。因此每一次调用都不能传INVOKE_SINGLE_THREADED(代码中统一使用RESUME_OTHER_THREADS = 0,见 JdwpSession.kt)——冻结 render thread,readback 就永远完不成。- 只挂起 VM 不足以调用任何东西。ART 会对
VirtualMachine.suspend()停住的线程回答IncompatibleThreadStateException;真正需要的是被事件停住的线程。所以代码请求一次「应用内任意位置的方法入口」事件,用 count filter 为 1 保证恰好触发一次、之后不留任何插桩(awaitSafePoint,见 JdwpSession.kt)。 - 空闲或后台的应用什么也不会运行,因此没有事件可等,直到有人「捅」它一下。
dumpsys meminfo <pid>就是那根刺:框架会通过 binder 回调应用,于是应用无论是否在前台都会执行代码——safe point 落在某个Binder:<pid>_N线程上——而且和合成按键事件不同,它不会改变应用正在显示的任何内容。
另外还有个加载细节:Bitmap.CompressFormat只有压缩过东西的应用才会加载,而被抓取位图的应用恰恰没压缩过。多数构建把它放在 boot image 里,但为了兜底,loadedClass会在类缺失时通过Class.forName现场加载(JdwpBitmaps.kt)。
刻意不做的四种方案
getPixels而非compress。单看应用内耗时它更快(同一 API 29 模拟器实测,1080×2400ARGB_8888位图:44 ms 对 505 ms),但它是错的那一个:它拒绝Config.HARDWARE(IllegalStateException: unable to getPixels(), pixel access is not supported on Config#HARDWARE bitmaps)——而 HARDWARE 恰恰是最重要的 config;它搬动 10,368,000 字节而compress只搬 15,584,传输从 6 ms 涨到 221 ms 且随像素数恶化;它需要在被调试的应用内部分配一个和位图一样大的int[];JDI 把数组以装箱的IntegerValue交回来,Explorer 需要数 GB 堆才能接收一屏数据;而且裸像素不携带尺寸信息,PNG 的IHDR才是指针复用校验读的东西。另外那 505 ms 对compress也是最佳情况——那张位图是均匀填充色,deflate 得异常快。- JVMTI/ART TI agent。能做同样的枚举,但它是按 ABI 构建的 NDK
.so,必须先放进应用自己的 data 目录am attach-agent才肯加载;同样的结果,却要付出原生构建和针对 ART 内部实现版本化的产物。 - 直接读
/proc/<pid>/mem。意味着要顺着mNativePtr→BitmapWrapper→android::Bitmap→SkPixelRef::fPixels追指针,而这些布局在版本与 ABI 之间会变;且 HARDWARE 位图的像素躺在GraphicBuffer里,CPU 可能根本映射不了。 - LeakCanary 本身。它运行在应用内部、本来就负责抓 dump,因此可以在任意 API 级别、甚至在非 debuggable 应用里压缩它能触及的位图并写到 dump 旁边——这是上面所有方案都做不到的。但那是 LeakCanary 的功能而不是 Explorer 的,所以 Explorer 只从「不点名的来源」接收
NativeBitmapPixels(HeapBitmaps.kt):LeakCanary 写的文件会和现有两种来源以完全相同的方式送达。
没有 Config 信息时如何解码 mBuffer
API 26 之前,位图的像素躺在 Java 堆的mBuffer(byte[])里。但即便在那个时代,Config 也是 native 的——pre-26 的 dump 只记录像素占了多少字节,不记录它们是什么意思。解码的关键就是笔记里那句话:「mBuffer.byteSize / (mWidth * mHeight)是布局唯一依赖的东西」,因此解码完全按它来:
- 每像素 1 字节 →
ALPHA_8 - 每像素 2 字节 →
RGB_565 - 每像素 4 字节 →
ARGB_8888
对应实现是HeapBitmaps.kt里的PixelLayout枚举(HeapBitmaps.kt),bufferPixelLayout用字节数除法取整决定布局(HeapBitmaps.kt),readBufferPixels按布局逐像素解码并缩放到目标尺寸(HeapBitmaps.kt)。这个推断方法有三处会出错或直接放弃的地方,按重要性降序:
- 缓冲区可能比位图实际需要的更大——
reconfigure()会保留较大位图的分配。除法向下取整是对的(像素排在前头),但也意味着「缓冲区尺寸永远不能单独作为 Config 的证据」。 ARGB_8888实际按 RGBA 存储,即内存顺序,而且是预乘的(mRequestPremultiplied,旧版本上是mIsPremultiplied)。去预乘是读像素的一部分——跳过它,所有半透明的东西都会偏暗。对应实现见ARGB_8888.argbAt中对预乘的还原(HeapBitmaps.kt)。RGBA_F16(每像素 8 字节)和RGBA_1010102(每像素 4 字节)不参与解码。4 字节在这里与ARGB_8888无法区分,所以 pre-26 dump 里的RGBA_1010102位图会解出错误的颜色。不过 API 26 之前没有任何设备产生过这种 config(它当时还不存在),所以只有把这个字节数推断复用到更新的数据上时才构成隐患。
ALPHA_8是只有掩码、没有颜色的格式;它被绘制成它代表的黑色——这正是 nine-patch 阴影看起来像阴影而不是「什么都没有」的原因。
为什么 dump 里没有 Config 的 int
这看起来「应该有」,所以值得说清楚:Bitmap.Config确实携带一个final int nativeInt(native 颜色类型):ALPHA_81、RGB_5653、ARGB_44444、ARGB_88885、RGBA_F166、HARDWARE7、RGBA_10101028——是类型 id 而非字节数——而且枚举常量就在每份 dump 里、值可读。但没有任何 dump 存在「从位图指向其中一个」的线索:getConfig()是Config.nativeToConfig(nativeConfig(mNativePtr)),答案来自 native 内存。对照五份真实 dump(API 23、25、29、36)核对:Bitmap实例有mWidth、mHeight、mRecycled、mDensity、mNativePtr、mRequestPremultiplied、mNinePatchChunk、mNinePatchInsets;API 26 之前另有mBuffer和mIsMutable,26 起有mColorSpace,到 36 又多了mGainmap、mHardwareBuffer、mId。任何时代都没有 config 字段,所以每像素字节数就是唯一的证据。
剩下的唯一碰撞是 2 字节:RGB_565和ARGB_4444都占 2 字节。而ARGB_4444自 KitKat 起就不可达了——从那时起Bitmap对任何索要它的请求都创建ARGB_8888。所以任何 API 19 起的 dump 里,2 字节缓冲区就是RGB_565;而这覆盖了 Explorer 可能遇到的所有 dump。
花时间换来的 adb 事实清单
最后这部分是「每一条都有人为此付过时间」的 adb 实操要点。实测环境:两台模拟器(API 36 与 API 29),转储leakcanary-android-sample——API 36 上 45 MB、4 张位图且像素齐全;API 29 上 17 MB、1 张位图且完全无像素;两者写入都不足一秒。
- 只有 debuggable 的进程才能被转储或调试。对其它进程执行
am dumpheap会得到java.lang.SecurityException: Process not debuggable: <package>——这是模拟器拒绝转储自己的 launcher;真机上的 release 构建自然更没戏。这是所有用户遇到的第一个问题,所以错误信息会直说(orFailToDump专门把这条解释清楚,见 DeviceHeapDumps.kt)。注意adb forward tcp:0 jdwp:<pid>对任何 pid 都照样建立转发,没有任何东西在连接之前说不——所以连接失败的信息也要措辞明确而不是直接透传。 adb forward tcp:0 <remote>会打印它选中的端口。这就是 JDWP 转发获得本地端口的方式:不用自己挑端口然后和机器上其它开 socket 的东西赛跑。对应实现见forwardJdwp(JdwpSession.kt)。- 拒绝有两种形态,
AdbOutput.orFail两种都查:Error: Unknown option: -b(API 29 对-b的回答),或Exception occurred while executing 'dumpheap':后面跟异常和十二帧框架栈。adb shell确实会传播远端退出码(上述两种都是 255),但并不总是——Error:配退出码 0 是真实组合——而栈轨迹在到达窗口之前值得压成第一行。 am dumpheap有时等写完、有时不等。API 36 会打印 "Waiting for dump to finish" 并阻塞;旧版本提前返回,立刻 pull 会拉到半个文件。stat -c %s轮询直到文件大小不再变化可以同时覆盖两种情况(awaitDumpWritten,见 DeviceHeapDumps.kt);在本来就会等待的设备上,代价只是一次多余的stat和一次 sleep。/data/local/tmp里的 dump 属于shell而不是应用。am自己打开文件并把描述符传给进程,所以adb pull能读它;应用自己创建的文件在那里反而读不了。这也是转储落盘目录固定为/data/local/tmp的原因(REMOTE_DIRECTORY,见 DeviceHeapDumps.kt)。- 进程名看不出它是不是应用。
media.extractor和android.hardware.audio.service读起来和包名一模一样,而它们都没有 Java 堆。pm list packages是区分手段——每台设备多一次调用,但值得:它把 API 36 模拟器的进程列表从 44 个「应用」缩减到 35 个真实应用。这正是appProcesses的做法(DeviceHeapDumps.kt)。 - 不要
adb shell一台未授权的设备:它会一直阻塞到有人点掉对话框。connectedDevices只对状态为device的设备执行getprop。 - 桌面应用不能依赖
PATH。从 dock 或 launcher 启动时几乎不继承任何环境变量,所以CommandLineAdb会依次在ANDROID_HOME、ANDROID_SDK_ROOT和 SDK 自装的两个位置查找adb,最后才回退到裸命令名。 - heap dump 记录的是
Build.FINGERPRINT,即某型号的某个构建而非某台设备——两台同型号同构建的手机在 dump 里无法区分。这正是对话框给设备排序并让人选择、而不是自己拍板的原因。相关的HeapDumpOrigin解析见 HeapDumpOrigin.kt,它从android.os.Build、android.os.Build$VERSION和ActivityThread的应用绑定数据中读出 SDK 版本、指纹、厂商、型号与进程名,从而把一份 dump 关联回「哪个设备、哪个进程」——这是「回到原进程补像素」整条链路的地基。
一张图看懂整条数据流
把以上所有内容串起来,Shark Explorer 的位图渲染数据流是这样的:HeapExplorer打开 dump →HeapBitmaps先查 dump 内dumpData索引(API 35+ 且带-b时命中)→ 未命中则查Bitmap.mBuffer(API 26 之前命中)→ 仍未命中则查liveImages(来自DeviceHeapDumps.fetchBitmaps或JdwpBitmaps的抓取结果)→ 三层都拿到尺寸校验过的图像后,BitmapImage以Encoded(压缩字节)或Pixels(缩放后的 ARGB 数组)两种形态交给BitmapImages、TreemapView与DetailsPanel绘制(UI 侧入口是TakeHeapDumpDialog与BitmapsFromDeviceDialog,位于 shark-explorer-app 模块)。一次imageOf调用就把这条链走完(HeapBitmaps.kt),且像素数据按需读取、绝不整份驻留——因为一屏 PNG 就是数 MB,而树形图上的一个矩形只有一百像素宽。
这份笔记与实现共同回答了一个看似简单、实则横跨三个 Android 大版本的问题:「这一张位图的像素到底在哪,拿不到的话怎么拿回来。」对想要在自己的工具链里复现类似能力(dump 内嵌位图、JDWP 远程调用、指针级数据对齐)的开发者来说,shark-explorer-core的 HeapBitmaps.kt、shark-explorer-jdwp的 JdwpBitmaps.kt 与 JdwpSession.kt,以及测试端的 BitmapDumps.kt,都是可以直接对照阅读的完整实现样本。
【免费下载链接】leakcanaryA memory leak detection library for Android.项目地址: https://gitcode.com/gh_mirrors/le/leakcanary
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考