首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
LeakCanary Shark Explorer 支配树渲染原理:三种布局、自适应深度与命中测试全解析
📅 2026/9/20 13:05:09
✍️ 爱科研究院
👁 阅读 3,247
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-appCompose 桌面应用负责把布局结果画到屏幕上包括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.ktsubject: CellSubjectN——它代表什么一个树节点或一个节点没画出来的子项集合depth: Int——深度0 表示布局所扎根的节点weight: Long——面积所正比的权重例如以字节计的 retained size。每种形状在LayoutCell之上附加自己的几何TreemapCell附加一个矩形TreemapLayout.ktRadialCell附加一个环形扇区RadialArcRadialLayout.ktStackCell再附加一个矩形但位于一行之上StackLayout.kt。几何之下的一切下游逻辑都只依赖CellSubject标签、颜色、点击的去向全部与形状无关。正是这个切分让第二种形状不必成为全部逻辑的第二个副本而第三种形状验证了它的代价有多小——StackLayout加StackView一个新Canvas加上三件小事一个ViewShape、一个ViewPresentation和一个带of()的StackPresentation。颜色、标注、选中、命中解析、导航一行都没有动。三种布局做同样的决定只在太小的度量上分叉三个布局共享同一套决策逻辑最大面积/弧长/宽度的单元格先细分小到看不见的子项被分组group而不是丢弃有一个单元格预算截断被计数并在 UI 中显式呈现。区别只在什么算太小布局太小的度量原因Treemap面积矩形以面积表现权重Radial沿环中线的弧长相同扫角的扇区越靠外越大必须按弧长而非角度衡量Stack宽度行高固定只有宽度可变环的容纳能力远小于矩形一个节点下 50 个等大小的子项就已经超过一个环能逐一显示的上限而树图可以画出数百个矩形。因此径向视图更早开始分组它是读取树的形状的更好方式而树图是读取大小的更好方式。三、树布局的公共语言惰性TreemapTree与三类CellSubjectTreemapTree惰性读取百万节点也能存活TreemapLayout.kt 中的TreemapTree接口只有三个成员interface TreemapTreeN { val root: N fun weight(node: N): Long // 节点权重含其下所有内容如 retained size字节 fun children(node: N): ListN // 节点的子项任意顺序叶子为空 }它被刻意设计为惰性读取支配树约有 100 万节点而一个视图能有效显示的是几千个矩形。children()只会在布局决定细分某个节点时被调用。这正是旧实现的死因见第七节也是面积驱动模型得以成立的先决条件。CellSubject一个节点、一堆对象、还是节点自己的字节LayoutCell.kt 定义了三类CellSubjectNode树的一个节点携带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总矩形数上限约5000maxCells预算按最大矩形优先分配细节落在有空间展示的地方。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 视口中实测三个最大的位图出现在深度 22128×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即展示层的深度 1CellView.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.truncatedNodeCountTreemapLayout.ktUI 会显示这个数字——树图展示的细节少于它空间所能容纳的这是可见的而不是沉默的。点击一个节点会在它那里重新扎根树图并对整个视口重跑同一布局——缩放是到达更深细节的方式地图旁的链条是返回的方式。点击解析经过什么见第八节为什么点击是去往某处而不是原地选中见 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 whats up with negative numbers注释的成因。现在的实现中比例数学在Double中进行大小在Long中进行——Squarify.kt 的注释明说了这一点。节点树在布局运行前被急切地、递归地构建。对四个节点的预览没问题对真实支配树是致命的。TreemapTree改为惰性读取——这本来就是面积驱动模型所需要的。它还硬编码了maxDepth 1, minSize 10000而它自己的 TODO 请求的正是上文的自适应模型。七、颜色弱可达对象一个鲜明的色调其余用一个方案人眼从树图中提取的是色相而值得提取的是嵌在强可达块内部的弱可达块——因此无论采用什么方案一个非强可达not strongly reachable的对象都保留自己鲜明的色调。堆中几乎一切都是强可达的这些如何着色是一个方案scheme在视图上方选择CellColors.ktDaisy默认每个顶层块一个色相嵌套其中的一切继承它并随深度变浅就像 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_SIZE8 dp见方的矩形请求——低于它图像只是单色涂抹却仍要付出一次堆转储读取和一次解码常量定义见 BitmapImages.ktbitmapImages是测量单元格的那个remember的一个键——一个带着像素到达的单元格是一个需要不同绘法的单元格。九、命中测试单 Canvas 下的显式解析单元格画进一个单一Canvas因此 Compose 没有可命中测试的逐单元格节点也没有可暴露给测试的节点。命中测试因此是显式的保留已布局的单元格把点击解析到它们的几何上——树图是包含该点的最深矩形径向图是环与角度栈是行及沿行的块。但最深的矩形并不总是答案。被细分的矩形被自己的子项覆盖所以cellAt接受edgeGrabTreemapLayout.kt在距边这个距离内容器胜过与它共享该边的任何东西——这就是视图在那里画的那条线。细分中的空隙仍属于正在被细分的节点。这正是 UI 测试用来点击容器的clickContainerEdge——因为过去可按的标签带已经没了。矩形上的名字是一个独立的目标在布局之前被检查TreemapView中的namedCellAt针对的是为绘制而测量的底板。名字写在矩形命名的所有嵌套内容之上所以底板是被细分矩形唯一仍可见的一块——读地图就是读这些名字——指向一个名字而让名字下的后代去应答一个没人问的问题是错的。它活在 app 模块而非 core 中因为只有 Compose 能测量文本底板被钳制在它命名的矩形内所以勉强一行高的单元格上的名字不会替它下面的兄弟应答。其他所有情况最内层矩形仍然获胜。单击去往它落下的矩形且在按下时处理而非释放时这里没有东西在等第二次点击把每次点击都扣到按钮抬起会让整张地图白白挨一次延迟。所以整张地图只有一层点击深度——这取代了一个无人宣告的双击。detectOpenPressesOpenIn.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与edgeGrabRadialLayoutTest.kt 与 StackLayoutTest.kt环与行的对应行为NodeIdsTest.kthexObjectId对高位负 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),仅供参考
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/9/20 13:05:09
OpenResearch:面向本地优先研究工作流的协议规范
2026/9/20 13:05:09
Spring Boot + Spring AI + DeepSeek实战:Java生态快速集成大模型
2026/9/20 13:05:09
基于OpenCV和Qt的多功能抠图应用开发:从GrabCut到透明底图的工程实践
2026/9/20 13:55:17
进程状态转换记混了?用 TaoToken 接入的 Codex 对着 Linux 五状态模型核对
2026/9/20 13:55:17
Quasar Electron 应用开发入门:理解主进程、渲染进程与 Preload 桥接
2026/9/20 13:55:17
Windows下Kronos金融模型部署实战:环境配置与踩坑全记录
2026/9/20 13:55:17
PL-300备考实战:用自制练习数据攻克Power BI数据建模与DAX
2026/9/20 13:55:17
TaoToken 做 Cursor 的兼容通道,别找临时中转
2026/9/20 13:50:16
PicoClaw MCP Server CLI 完全指南:用 `picoclaw mcp` 管理 MCP 服务器配置
2026/9/20 0:03:47
深入解析Transformer多头注意力机制与工程优化
2026/9/20 0:03:47
OpenClaw 的 Skills 跑学习任务,模型通道改到 TaoToken 通道行不行?
2026/9/20 0:03:47
ChatGPT报错Oops, an error occurred! 全链路排查指南
2026/9/20 0:03:47
深入解析Transformer多头注意力机制与工程优化
2026/9/20 0:03:47
OpenClaw 的 Skills 跑学习任务,模型通道改到 TaoToken 通道行不行?
2026/9/20 0:03:47
ChatGPT报错Oops, an error occurred! 全链路排查指南