LeakCanary Shark Explorer 支配树渲染原理:三种布局、自适应深度与命中测试全解析
【免费下载链接】leakcanaryA memory leak detection library for Android.项目地址: https://gitcode.com/gh_mirrors/le/leakcanary
导读
Shark Explorer 是 LeakCanary 生态中的独立桌面工具,用于以图形方式浏览堆转储(heap dump)中的支配树(dominator tree)。本文以仓库 notes/treemap-rendering.md 为骨架,结合shark-explorer-core与shark-explorer-app的源码实现,完整讲解支配树可视化的三大核心问题:三种布局形状(矩形树图、环形旭日图、堆叠行)如何共享同一棵树的同一切割、深度如何由面积而非固定层级驱动、以及单 Canvas 绘制下的显式命中测试如何工作。读完本文,你将掌握 Shark Explorer 从堆转储到可点击地图的完整渲染管线,以及每个设计决策背后的源码级依据。
一、渲染架构总览:纯逻辑核心与 Compose 视图的分工
支配树渲染被刻意拆成两个模块:
shark-explorer-core:承载所有布局与展示逻辑,且不依赖 Compose——TreemapRect.kt 的类注释明确指出,它刻意不用 Compose 的Rect,以便布局逻辑可以在无 UI 环境的纯 JVM 单元测试中验证,并可被 Android 复用。shark-explorer-app:Compose 桌面应用,负责把布局结果画到屏幕上,包括TreemapView、RadialView、StackView三个视图。
核心模块中的关键文件与职责如下表:
| 文件 | 职责 |
|---|---|
| Squarify.kt | 方形化行布局(squarified treemap),源自 Bruls、Huizing、van Wijk 的论文 "Squarified Treemaps",参照 d3-hierarchy 的实现 |
| TreemapLayout.kt | 矩形树图布局:自适应深度 + 命中测试 |
| RadialLayout.kt | 同样的自适应模型,以环形(环形扇区)呈现 |
| StackLayout.kt | 同样的模型,以每层一行的冰柱图呈现 |
| TreemapRect.kt | 布局空间中的轴对齐矩形 |
| LayoutCell.kt | 三种布局共同拥有的单元格抽象 |
| HeapDominatorTreemap.kt | 以TreemapTree呈现的支配树,以及为布局结果标注名称与颜色的present() |
| TreemapPresentation.kt | 每种形状一个展示类,其of()负责把布局与标注配对 |
一个关键的历史教训:of()属于展示层,不属于堆转储读取器
在只存在两种形状(树图与径向图)时,of()曾是HeapDominatorTreemap上的方法;当第三种形状(堆叠行)加入时,它被移了出来。原因很实际:HeapDominatorTreemap.kt 是一个约 1200 行的堆转储读取器,而 detekt 允许一个类最多 50 个函数,presentStack恰好是第 50 个。与其在任意位置拆分这个类,不如把按形状分派的方法整个移出——这也是更正确的架构分界:有哪些形状存在,不关一个堆转储读取器的事。
如今留在HeapDominatorTreemap上的只有一个present(cells):从CellSubject读取名称与强度(reachability strength)。每种形状的单元格都是CellSubject,所以第四种形状的加入,完全不需要改动HeapDominatorTreemap——这正是 TreemapPresentation.kt 中of()注释所强调的设计边界。
二、三种形状,一棵树的同一切割
单元格抽象:LayoutCell与CellSubject
一个单元格(cell)是一个LayoutCell,由三部分构成(见 LayoutCell.kt):
subject: CellSubject<N>——它代表什么:一个树节点,或一个节点没画出来的子项集合;depth: Int——深度,0 表示布局所扎根的节点;weight: Long——面积所正比的权重,例如以字节计的 retained size。
每种形状在LayoutCell之上附加自己的几何:
TreemapCell附加一个矩形(TreemapLayout.kt);RadialCell附加一个环形扇区RadialArc(RadialLayout.kt);StackCell再附加一个矩形,但位于一行之上(StackLayout.kt)。
几何之下的一切下游逻辑都只依赖CellSubject:标签、颜色、点击的去向,全部与形状无关。正是这个切分,让第二种形状不必成为全部逻辑的第二个副本;而第三种形状验证了它的代价有多小——StackLayout加StackView,一个新Canvas,加上三件小事:一个ViewShape、一个ViewPresentation和一个带of()的StackPresentation。颜色、标注、选中、命中解析、导航一行都没有动。
三种布局做同样的决定,只在"太小"的度量上分叉
三个布局共享同一套决策逻辑:
- 最大(面积/弧长/宽度)的单元格先细分;
- 小到看不见的子项被分组(group)而不是丢弃;
- 有一个单元格预算;
- 截断被计数并在 UI 中显式呈现。
区别只在"什么算太小":
| 布局 | "太小"的度量 | 原因 |
|---|---|---|
| Treemap | 面积 | 矩形以面积表现权重 |
| Radial | 沿环中线的弧长 | 相同扫角的扇区越靠外越大,必须按弧长而非角度衡量 |
| Stack | 宽度 | 行高固定,只有宽度可变 |
环的容纳能力远小于矩形:一个节点下 50 个等大小的子项,就已经超过一个环能逐一显示的上限,而树图可以画出数百个矩形。因此径向视图更早开始分组,它是读取树的形状的更好方式;而树图是读取大小的更好方式。
三、树布局的公共语言:惰性TreemapTree与三类CellSubject
TreemapTree:惰性读取,百万节点也能存活
TreemapLayout.kt 中的TreemapTree接口只有三个成员:
interface TreemapTree<N> { val root: N fun weight(node: N): Long // 节点权重,含其下所有内容,如 retained size(字节) fun children(node: N): List<N> // 节点的子项,任意顺序;叶子为空 }它被刻意设计为惰性读取:支配树约有 100 万节点,而一个视图能有效显示的是几千个矩形。children()只会在布局决定细分某个节点时被调用。这正是旧实现的死因(见第七节),也是面积驱动模型得以成立的先决条件。
CellSubject:一个节点、一堆对象、还是节点自己的字节
LayoutCell.kt 定义了三类CellSubject:
Node:树的一个节点,携带parent(所属容器,根节点为 null)和siblingIndex(在父节点子项中的排名,按最重在前排序)。siblingIndex在视口变化时保持稳定:更小的视口画更少的子项,但画的是同样最重的那些,因此排名永不漂移——这是颜色方案可以以它为依据的原因。Group:父节点细分时被排除在外的nodeCount个子项,作为一个单元格。它不是树节点,因此不可再细分、不可缩放进入,点击它显示它代表多少个对象。它携带parent,既说明归属,也用于区分一个 group 与另一个 group(这也是选中状态能够持久的关键,见第八节)。Own:节点自身的权重作为一个嵌套在它内部的单元格——对支配树而言就是它的 shallow size。没有它,被细分节点的子项会被放大填满父项,面积只在兄弟之间与权重成正比;有了它,视图中每个矩形在任意深度都是它占整个堆的比例。一个字节几乎全在自己身上的对象(位图是其中最重要的)会是一个实心块,而不是环绕子项的轮廓。
四、Stack:深度是一行,不是一个面积
性能剖析器把调用树画成冰柱图(icicle chart)——每一层一行,根在顶部,块的宽度是它占整体的份额。支配树以同样的方式阅读,只需把"谁调用了我"换成"谁保留了(retain)我"。StackLayout就是这张图(StackLayout.kt)。
它相对另外两种形状的独特价值:一层不花费任何宽度。树图和环都为嵌套付出面积代价,于是链条深处是一片细线;而堆叠视图给每一层一整行,所以一条 22 层链条中的最后一个对象,其宽度与其堆份额相称,并且——这是最关键的部分——在每一层都被标注了名称与大小。树图只能命名一层(见第五节),因为被细分的矩形被其子项覆盖;而一行只被它下面的一行覆盖,不会被自身内容遮挡,标签没有任何障碍。
由此产生三个后果,每个都是一次明确的决策:
- 子项相对于父项自身的权重来定尺寸,余量成为行右端的
CellSubject.Own块——与树图拥有 Own 块的同一个理由、同一个效果:一个块在任意深度都是它占整个堆的比例,而不是它占兄弟的比例。这也意味着一行中没有任何一个像素属于"无主之地",命中测试因此没有需要解释的空隙。 - Stack 必须限制行数(
maxRows,默认 64)。单元格预算无法限制它:一条全由单支配者组成的链条宽度从不收窄,每一层都超过细分下限,5000 个单元格就是 5000 行的画布。另外两种形状被自己的几何限制(环的弧、矩形的面积),不需要这样的数字。从源码看,maxRows = 64是 StackLayout.kt 的默认值,其注释指出:真实转储测得的最深链条是 22 层,64 行切断的是病态情况而非有趣情况,缩放进入节点即可到达其余部分。 - 它是唯一比窗口还高的形状,因此需要滚动。滚动把指针和块隔开一个偏移量:
StackView把指针保持在视图坐标中,只有在询问布局"指针下是什么"时才加上滚动偏移。滚动偏移变化时,悬停也必须重新计算——因为在静止指针下滚动,会不产生任何指针事件就把另一个块移入指针之下。
此外 Stack 跳过树图做的一件事:行不绘制位图。一行只有一行文字高,把位图塞进 18 dp 是一团涂抹——"显示图片"是树图的贡献,在这里要求它,每行都要付出一次堆转储读取和一次解码,而一无所获。
五、深度由面积驱动,而不是固定的层级数
一份堆转储的支配树约有100 万节点,而树图能有效显示的是几千个矩形。选一个固定深度是错的旋钮——同样的深度,对那个巨大的节点来说太粗,对长尾来说又荒谬地细。
正确的模型是:先布局一层,只有当子项的矩形大到值得细分时才递归进入,当矩形小到看不见时就停止。TreemapLayout的默认参数(TreemapLayout.kt)直接给出了三条规则:
- 只有大约12×12 dp以上才细分(
minSubdivideWidth/minSubdivideHeight); - 大约3×3 dp以下不绘制(
minDrawSize)——看不见且浪费 draw call; - 总矩形数上限约5000(
maxCells),预算按"最大矩形优先"分配,细节落在有空间展示的地方。
TreemapLayout以像素为单位工作,因此TreemapView会按当前屏幕密度缩放这些阈值——直接透传的话,2x 显示屏上每个矩形都会是预期大小的一半。
由此,深度在整张树图上因节点而异,这正是目的所在。同时它是确定性的:预算与递归都直接可做单元测试(TreemapLayoutTest等测试覆盖了这一点,见第九节)。
一个层级不再花费面积,因为标签条带曾是整个视口
第一版实现在每个被细分矩形的顶部预留一个18 dp 的头部给它的标签,minSubdivideHeight因此是 24 dp——因为一层要容纳头部加一个可见子项。在真实应用上这藏起了所有值得看的东西:在 82 MB 的生产转储中,从 activity 到列表行的链条长达38 层,38 × 18 dp 是 684 dp,而视口只有 630 dp。窗口被整宽标签带填满,链条底部的位图根本没机会被画出来。向内钻取每次只买回跳过的 18 dp,要多钻好几次仍会耗尽空间。
现在的做法是:被细分节点的子项恰好覆盖它,嵌套在事后绘制而不是预先预留空间——TreemapView先画所有填充、后画所有轮廓,于是一层读起来是覆盖在其内容之上的一条 1 px 线,而不是旁边的一条带子。当一串单子链共享一条边时,轮廓叠成更粗的线——这就是视图在说"这里比一个矩形更多"。在那个转储的 1180×630 px 视口中实测:三个最大的位图出现在深度 22,128×75 px,各占视口的 1.3%,共 2279 个单元格,无任何截断。(那条链是 22 层而非 38 层,因为两个视图之间的View[]不再构成树的一层——详见 notes/dominator-tree.md。上面的算术是它还是 38 层时的数字;即便按 21 个头部计算也仍是 630 dp 视口中的 378 dp,结论不变。)
两个随之而来的行为性(而非装饰性)结果:
- 节点自身的权重得到一个单元格。
squarify()会做归一化,子项填满分给它们的任何空间:没有weight − Σ children的CellSubject.Own单元格,子项会被放大填满父项,面积只在兄弟间与权重成正比。有了它,一个大对象在任意深度都能按"占整个堆的比例"被找到,无需预先知道它在哪。(在生产转储上,只有 4 个 Own 单元格能通过 3×3 dp 下限——一个对象自己的字节通常是其 retained 量的舍入误差。过不了下限的恰是那些不需要看到的,而过得了的那个是位图。) - 容器靠它的名字或轮廓被按压。子项覆盖了它的每一像素,因此
cellAt接受edgeGrab——4 dp——在此距离内的细分单元格胜过共享该边的任何东西,视图在检查矩形名字下面的底板之前先做这个判断。没有这两者,就完全无法指向一个容器了。指向细分中的空隙(子项太小未画而留下的区域)仍然落在持有它的节点上。
一个对 UI 的后果:被细分的矩形没有地方放自己的名字,所以给层级命名落在视图旁绘制的东西上——RootPathPanel绘制从整个堆转储到被点击对象的链条,再延伸到指针下的对象,并标出支配它的步骤。那些被标记的步骤就是该矩形所在的容器,同一个面板同时回答"这是什么"与"我在图中哪里"。相关决策详见 notes/decisions.md。
只有一个层级被命名,其边界是那些粗线
只有当前根的直属子项——ROOT_CHILD_DEPTH,即展示层的深度 1(CellView.kt 中的常量)——携带标签,只要空间够,每个都标(内容与位图包括在内)。给每个叶子都标,曾让真实转储的地图不可读:横跨半打层级的成百个类名,每个都在命名被下一层覆盖的东西,而没有一个在命名正在被阅读的那层。
两件事让这一层可读:
- 标签覆盖在嵌套于其内部的内容之上,放在半透明底板(
LABEL_PLATE_COLOR)上。文本坐在它无权干涉的填充、轮廓和位图之上,因此在冲淡的底板上用实心文本,能对着它们全都保持可读,同时仍让下面的内容透出来。底板在轮廓之后绘制。这个底板同时也是命中目标(见第八节),因此它被测量一次,存入MeasuredLabel,绘制的矩形与可点击的矩形是同一个值。 - 根的直属子项被粗轮廓标出(
ROOT_CHILD_BORDER_WIDTH),画在它内部的所有轮廓之上——因为下面的层级恰好覆盖了父项,没有它,两个被命名块之间的边界看起来和地图上任何其他边一样,地图在它被命名的层级上没有可见结构。
这也意味着:那个深度的位图在自己的图片上被命名,而它下面的位图完全不被命名。这一切都是树图与径向视图的问题,而不是 Stack 的——行被下一行覆盖而非被自身内容覆盖,所以每一行只要够宽就被命名并给出大小,无论它处于哪个深度。
放不下的子项成为一个矩形,而不是被丢弃
全有或全无的细分——节点要么被完整布局,要么显示为裸矩形——曾是第一版模型,它让整张树图在真实堆转储上变成单个矩形:compose_leak.hprof的 GC roots 直接支配27,476 个对象,远超 5000 的单元格预算,那个单元格细分失败,其下的一切永远无法到达。(事后按类分组可把它们降到几百个单元格——但只在树顶,布局不能依赖它。)结果什么都画不出、什么都无法缩放,读起来像"一切都被根支配"而不是一个 bug。
于是现在细分画它能画的子项——最大优先,受maxChildrenPerNode、面积下限和剩余预算限制——其余变成单个CellSubject.Group,权重为它们合计的权重。子项永远不会被悄悄从父项分出的面积中丢弃。同一个转储、同一个视口,改动之后:1190 个单元格、28 个组、深 7 层、无截断。
maxChildrenPerNode不适用于视口所扎根的那个节点,它获得maxRootChildren——单元格预算的一半,即 2500。一个不随空间变化的数字会让缩放失去意义,而"那堆"正是缩放存在的唯一理由:compose_leak.hprof中可绘制缓存的LongSparseArray[]有 668 个子项,无论它是整张堆图上的细条还是整个视口,都只画 200 个加一堆 468,点击那堆落在它被点击时的图片上。扎根到它之后,现在画 516 个加一堆 103——剩下的仍在面积下限之下。整张堆图没有变化——1726 个单元格,顶层 93 个矩形——因为在该根处,面积下限早在 200 之前就生效了。
径向布局还有一层限制:环。中心盘周围 8 个环(ringCount,见 RadialLayout.kt),每个环的宽度由视口导出,因此画面始终填满圆,超过该深度的部分需要缩放。扇区取父项整个扫角按权重分摊,环带是节点自身名字的位置,因此径向视图不需要树图那种自身权重单元格。
Stack 自己的限制是行(maxRows),且它的下限低于树图的——6 dp 细分、2 dp 绘制,对比 12 dp 和 3 dp。一层不花费宽度,因此细分一个窄块仍买来一整行细节,而在为嵌套付出面积的图画中,它买来的只是细线中的细线。
负节点 id 不是树自己的
树的节点是对象 id,而树发明的"堆"(pile)——未回收垃圾和类分组——需要自己的 id。nodeId < 0不是判断堆的测试,把它当成测试是一个看起来什么都不像的 bug:对象 id 是堆地址,32 位转储用 4 字节记录它,shark 按符号扩展,因此这种转储中 2 GB 以上的每个对象都有负 id。isPileId是对Int.MIN_VALUE的范围检查,堆 id 从Long.MIN_VALUE开始(GroupIds从那里向上计数,见 notes/dominator-tree.md)。
符号测试在被范围检查取代之前付出的代价:在large-dump.hprof上,开篇视图的4616 个矩形中有 44 个的contains()说树并不持有它们——指向一个时什么都不选中、链面板永不填充、点击它跳到根而不是进入它——而且全程没有报错,因为每个答案都同时是合法答案。它们的十六进制也不对(0x-7deb3000),这正是 NodeIds.kt 中hexObjectId存在的原因,也是这类 id 在日志中可辨识的方式:负 id 先与0xFFFFFFFFL相与,再转十六进制。
给新代码的教训:来自堆转储的 id 是一个不透明的 64 位值,不是可以和零比较的数字,只有包含 2 GB 以上对象的转储才能让你发现这一点。HeapExplorerDumps中的highAddressHeapDump()正是为此构建的——dump { }接受firstObjectId参数,测试只需一个参数就能请求这样的转储。
写针对布局的测试前,还有两件值得知道的后果:
squarify()需要降序权重,而两个合成单元格都不按权重有序。一个 group 代表很多子项,通常比单独绘制的最小几个子项更重;节点自身的权重则落在任意位置。TreemapLayout.kt 自己构建单元格并按权重排序——这正是子项不直接从children()取用的原因——而且是稳定排序,布局保持确定性。- 面积份额使其薄于最小可绘制尺寸的节点会消失而不是变成细线——group 也不例外,当它代表的长尾足够小时。兄弟权重比超过约 50:1 之后,较小的那个完全不画。
有子项但完全未被细分的节点仍会计入TreemapLayoutResult.truncatedNodeCount(TreemapLayout.kt),UI 会显示这个数字——树图展示的细节少于它空间所能容纳的,这是可见的而不是沉默的。
点击一个节点会在它那里重新扎根树图,并对整个视口重跑同一布局——缩放是到达更深细节的方式,地图旁的链条是返回的方式。点击解析经过什么,见第八节;为什么点击是"去往某处"而不是"原地选中",见 notes/decisions.md。
六、两个已删除的 Android 树图的 bug,留作记录
leakcanary-app曾有一个 d3-hierarchy squarify 的移植,在提交aa2bc4240中被移除。它有两个错误,绝不允许回归:
squarifyRatio中的 Int 溢出。beta = sumValue * sumValue * alpha在sumValue: Int超过约46,341时溢出。retained size 以字节计远超过此值,因此真实堆数据上的每个长宽比决策都是垃圾数据——几乎可以肯定这就是它// TODO Figure out what's up with negative numbers注释的成因。现在的实现中,比例数学在Double中进行,大小在Long中进行——Squarify.kt 的注释明说了这一点。- 节点树在布局运行前被急切地、递归地构建。对四个节点的预览没问题,对真实支配树是致命的。
TreemapTree改为惰性读取——这本来就是面积驱动模型所需要的。
它还硬编码了maxDepth = 1, minSize = 10000,而它自己的 TODO 请求的正是上文的自适应模型。
七、颜色:弱可达对象一个鲜明的色调,其余用一个方案
人眼从树图中提取的是色相,而值得提取的是嵌在强可达块内部的弱可达块——因此无论采用什么方案,一个非强可达(not strongly reachable)的对象都保留自己鲜明的色调。堆中几乎一切都是强可达的,这些如何着色是一个方案(scheme),在视图上方选择(CellColors.kt):
- Daisy(默认):每个顶层块一个色相,嵌套其中的一切继承它并随深度变浅,就像 DaisyDisk 给磁盘着色。一个块读起来是一个整体及其内容。它需要知道一个单元格属于哪个顶层块——这正是
CellSubject.Node上parent和siblingIndex的用途,每个展示一次遍历即可解析:单元格按父先于子的顺序产生,子到达时父的色相必然已确定。这里的"顶层"指ROOT_CHILD_DEPTH,即视图所扎根节点的子项——与地图命名和勾边的同一层级——所以被区分着色的块,正是读者正在读的块。 - Reachability:每个强度一个色相,按深度着色。关于收集器(GC)说得最多,关于结构说得最少。
- Slate:只用蓝灰色,当颜色妨碍形状时使用。
深度无界,所以色度循环——只要相邻不同就没问题。代表多个对象而非一个的单元格(类分组,或没放下的兄弟)是它强度的洗涤版(washed-out),强度为STRONG时是冷灰石板色——读起来是"不是对象",而不需要自己的颜色。全部集中在CellColors.kt,这是颜色被命名的地方。
没放下的兄弟在此基础上用点填充——CellView.kt中的pileDots。那个矩形常常是地图上最大的东西——一个 54,000 个字符串的类分组是一个矩形,几乎全被这些点填满——在这个尺寸上,一个平色块读起来像一个巨型对象,在真实转储上意味着一个位图。纹理在标签被读之前就传达"许多小东西";而作为均匀纹理而不是逐个绘制,让"堆"看起来仍然是点击可以落在其上的那一个东西。它用的是重复的ImageShader瓦片而不是逐点绘制,因为指针每移到另一个矩形上,整张地图就要重绘。
灰色只意味一件事:某个强度被关闭。视图上方的复选框就是一个CellColoring,取消勾选会把所有被该强度持有的内容变灰而不是隐藏——树无论如何都是整个堆转储,没有哪个强度是"关掉它没有意义"的,切换它是一次重绘。把强堆变灰正是让其余的一点点跳出来的方式。这就是为什么任何方案中都不允许其他东西是灰色——这正是CellColorsTest中关于灰色的用例所钉死的。UNREACHABLE与强堆一样按深度着色,与其余非强强度不同,因为未回收垃圾可能有数兆字节,一整片平色覆盖它会藏起它的形状。
八、位图:位图的矩形显示位图
像素来自哪里是 notes/bitmaps.md 的主题;视图对像素做三件事。
它们画在填充与轮廓之间。位图自己的像素是覆盖它的子矩形——API 26 之前是它的byte[],之后什么也没有——所以和填充一起画,图像会被那个子项盖掉;和轮廓一起画,它会盖住它所嵌套的结构。顺序因此是:每个填充、每张图像、每个轮廓与标签,然后是选中态。
图像是适配(fitted),绝不拉伸。矩形的长宽比是它占堆的份额,与位图的长宽比无关,被压扁的图标无法辨认。所以imageBounds居中最大适配,矩形其余部分保持填充色。
位图只有处在命名深度才被标注,和其他矩形一样,然后是在半透明底板上、自己的图片之上。文本直接压在图片上两者都读不清——这正是底板存在的意义;低于该深度,地图旁的链条是读取位图类名与大小的地方。
两个容易搞错的接线细节:图像按展示(presentation)请求,只为至少MIN_BITMAP_DRAW_SIZE(8 dp)见方的矩形请求——低于它,图像只是单色涂抹,却仍要付出一次堆转储读取和一次解码(常量定义见 BitmapImages.kt);bitmapImages是测量单元格的那个remember的一个键——一个带着像素到达的单元格,是一个需要不同绘法的单元格。
九、命中测试:单 Canvas 下的显式解析
单元格画进一个单一Canvas,因此 Compose 没有可命中测试的逐单元格节点,也没有可暴露给测试的节点。命中测试因此是显式的:保留已布局的单元格,把点击解析到它们的几何上——树图是包含该点的最深矩形,径向图是环与角度,栈是行及沿行的块。
但"最深的矩形"并不总是答案。被细分的矩形被自己的子项覆盖,所以cellAt接受edgeGrab(TreemapLayout.kt):在距边这个距离内,容器胜过与它共享该边的任何东西——这就是视图在那里画的那条线。细分中的空隙仍属于正在被细分的节点。这正是 UI 测试用来点击容器的clickContainerEdge——因为过去可按的标签带已经没了。
矩形上的名字是一个独立的目标,在布局之前被检查:TreemapView中的namedCellAt,针对的是为绘制而测量的底板。名字写在矩形命名的所有嵌套内容之上,所以底板是被细分矩形唯一仍可见的一块——读地图就是读这些名字——指向一个名字而让名字下的后代去应答一个没人问的问题,是错的。它活在 app 模块而非 core 中,因为只有 Compose 能测量文本;底板被钳制在它命名的矩形内,所以勉强一行高的单元格上的名字,不会替它下面的兄弟应答。其他所有情况,最内层矩形仍然获胜。
单击去往它落下的矩形,且在按下时处理而非释放时:这里没有东西在等第二次点击,把每次点击都扣到按钮抬起,会让整张地图白白挨一次延迟。所以整张地图只有一层点击深度——这取代了一个无人宣告的双击。detectOpenPresses(OpenIn.kt)读取按下,因为视图绘制单元格而非组合它们,没有可挂手势的 modifier——见 notes/decisions.md。
地图最终落到哪里不在这里解析。一个矩形交回一个Place——Place.of(cell)——地图在place.viewRootObjectId处布局,所以点击矩形与点击详情面板的一行或列表的一行是同一个动作:地图扎根于被点击的对象,而不是沿一条链缩放到它。一个不支配任何东西的对象,就是它自身字节的一个矩形——这是对"它持有什幺"的诚实回答;它如何被持有是链面板的回答,不是地图的。group 不是树节点,所以Place.SmallerObjects携带把它排除在外的父项,并把地图扎根在那里——那正是那些对象所在之处,也是地图有空间把它们中最大的逐个画出来的地方。
从"扎根"而非"缩放"中掉出两件事。树只被遍历一次,用来画链条——而不是一次画链、一次找地图放哪——支配树上的路径是唯一的,所以地图根是对象的函数,没有需要同步的东西。而一个扎根于树中没有节点的对象(一个字段可以命名这样的对象)的视图,会回退到整个堆转储并打一条日志说明——而不是一次空手而归的遍历。
把这些全部保持为shark-explorer-core中的纯函数,正是它可测试的原因——UI 测试如何围绕这一点构建,见 notes/decisions.md。
关于选中态,有两件从绘制代码看不出来的事:
- 选中是一个 id,不是一个单元格。调整窗口大小或切换形状会重新布局视图,每个单元格都是新对象,所以 UI 记住的是一个
SelectedCell:一个对象 id,或一个父 id 加一个 leftover 标记。这也是CellSubject.Group携带parent的另一个原因——没有东西区分两个 group,树图中每个 leftover 矩形会同时亮起。 - 选中单元格的轮廓在每个单元格之后绘制,而不是和它自己一起。子项画在父项之上,否则一个有任何子项的选中矩形只会露出它的子项没盖住的轮廓细条。
十、测试:确定性布局的验证网络
面积驱动的自适应模型之所以成立,部分原因在于它是确定性的,从而可直接单元测试。shark-explorer-core与shark-explorer-app的测试目录覆盖了这条渲染管线的每一环:
- SquarifyTest.kt:方形化算法的行切分与长宽比行为;
- TreemapLayoutTest.kt:细分阈值、预算、
truncatedNodeCount、cellAt与edgeGrab; - RadialLayoutTest.kt 与 StackLayoutTest.kt:环与行的对应行为;
- NodeIdsTest.kt:
hexObjectId对高位负 id 的格式化; - CellColorsTest.kt:钉死"灰色只属于被关闭的强度"等颜色规则;
- MapNamesTest.kt、MapMovesTest.kt、TreeLayoutTest.kt、TreemapViewTest.kt、StackViewTest.kt:应用层的命名、移动、点击与绘制行为;
- 测试转储由 HeapExplorerDumps.kt 构建,包括专门用于暴露负 id 问题的
highAddressHeapDump()。
配套的设计决策记录见 notes/decisions.md(点击模型、命中测试与 UI 测试结构)、notes/dominator-tree.md(支配树与堆 id 的构造)与 notes/bitmaps.md(位图像素来源)。
结语
Shark Explorer 的支配树渲染把"百万节点的树 + 几千矩形的屏幕"这一根本矛盾,化解为一套环环相扣的决策:CellSubject抽象让三种形状共享标签、颜色与导航;面积驱动的自适应深度让细节自动落在有空间的地方;先填充后轮廓的绘制顺序让嵌套不再耗费面积;显式命中测试让单 Canvas 绘制与容器可达性同时成立;而Long.MIN_VALUE起的堆 id 与Double比例数学,则为真实转储中的高位地址和巨型 retained size 兜住了底。这套设计既经得起 82 MB 生产转储的实测(22 层链条、三位位图各占视口 1.3%、零截断),也经得起纯 JVM 单元测试的逐格验证——是"为真实数据而设计"的一个完整范例。
【免费下载链接】leakcanaryA memory leak detection library for Android.项目地址: https://gitcode.com/gh_mirrors/le/leakcanary
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考