LeakCanary Shark Explorer 变更日志解读:从shark://深链到跨会话笔记的桌面端内存分析新特性
【免费下载链接】leakcanaryA memory leak detection library for Android.项目地址: https://gitcode.com/gh_mirrors/le/leakcanary
Shark Explorer 是 LeakCanary 仓库中独立于库版本线发布的桌面应用,用于打开 Android 堆转储(heap dump)并可视化展示"是什么在持有内存"。本文基于 shark-explorer-changelog.md 的 Unreleased 条目,逐一剖析其首批发布特性——shark://深链、位置笔记与导航历史下拉——并结合 shark-explorer.md 用户指南与仓库内shark/shark-explorer/模块的源码实现,讲清每个能力的设计动机、工作原理与使用方式。读完你将理解 Shark Explorer 为什么给每个位置生成链接、笔记如何做到跨运行持久化,以及这些设计如何在源码层落地。
为什么 Shark Explorer 需要自己的变更日志
Shark Explorer 是一个桌面应用,而不是库。它在自己的版本线上独立发布,因此拥有独立的变更日志:
- LeakCanary 与 Shark 库的变更统一记录在 changelog.md,该文件从不提及Shark Explorer;
- Shark Explorer 的变更记录在 shark-explorer-changelog.md,其中从不出现库的新特性;
- 一次版本如何被打包发布,详见 releasing-shark-explorer.md。
两条发布线共用同一个仓库,但面向的读者不同:读库变更日志的人关心 API、依赖与 Android 集成;读应用变更日志的人关心桌面端的功能行为。
变更标记符号体系
Shark Explorer 的变更日志沿用了 LeakCanary 变更日志的标记符号,唯一删掉的是"新识别库泄漏"(🐤)——桌面应用没有库泄漏识别的概念:
| 标记 | 含义 |
|---|---|
| ⚠️ | 破坏性变更:你依赖的某些行为变了或没了。 |
| 🔀 | 行为变更:应用现在做了不同的事情。 |
| 💥 | 崩溃修复:之前会崩溃,现在不会了。 |
| 🐛 | 缺陷修复:应用做了错误的事,但没有崩溃。 |
| ✨ | 新增:之前不存在的功能。 |
| 🔨 | 改进:原本就能工作的东西,现在工作得更好。 |
LeakCanary 主 changelog.md 中该体系描述为:"⚠️ 升级可能破坏构建"、"🐤 新识别库或厂商 ROM 中的泄漏",本日志在引用时保持了同样的语义但去掉了 🐤。
Unreleased:首批发布的三个新能力
截至当前仓库,Shark Explorer 的Unreleased一节包含四个条目,全部是 ✨ 新增:
- 初始发布(Initial release);
shark://深链:右键任何窗口可达的位置即可复制指向它的链接;- 笔记(Notes):每个位置可以保存一条 Markdown 笔记,跨运行保留;
- 导航历史下拉:右键 ← 或 → 箭头,直接跳转到该方向上的任意一步。
下面结合 shark-explorer.md 与源码分别展开。
右击即得的shark://深链:把"看这里"变成一行 URL
变更日志说了什么
右键点击窗口能带你去到的任何东西——一个标签页、一个矩形、一行、一个字段——即可复制一个指向它的
shark://链接,也可以在新标签页中打开它。点击链接会把应用带到前台,并在新标签页中打开那个位置:一个对象、一个过滤后的对象列表、展开了相同分组的泄漏列表。
在用户指南中的用法
shark-explorer.md 的 "Link to a tab" 一节给出了完整语义:右键并选择 "Copy link"得到当前位置的shark://URL,粘贴到任何可点击链接的地方(聊天消息、issue、给自己的笔记),点击后 Shark Explorer 会来到前台并在新标签页打开该位置。它在所有提供"在新标签页打开"的地方旁侧提供:标签页、顶部按钮、地图上的矩形、对象列表或泄漏列表的行、引用链的一步、详情面板的字段、星标对象。
文档给出了三个示例链接:
shark://vugs93jp/object?id=0x7f2a4b18 shark://vugs93jp/objects?query=Bitmap&exact=true shark://vugs93jp/leaks任何标签页所在的位置都是一个链接:一个对象、带着已填搜索与过滤条件的对象列表、展开了相同分组的泄漏、星标对象。因此"看一下这个"是一段 URL 而非一段操作说明——这也是"一个已经读过你堆转储的工具或 Agent 能直接把你指到它发现的东西"的方式。
两个关键设计点:
- 链接指向的是窗口,而不是堆转储。同一份 dump 打开两次是两个窗口,链接只会指向它被复制时所在的那个窗口。因此链接只在那个窗口开着时有效,窗口关闭或应用重启后失效——此时跟随链接会打开一个空窗口并说明情况。链接永远不会替换你正在读的内容:它总是打开自己的标签页。
- 链接只能到达已安装构建中的应用:是安装包告诉操作系统
shark://属于这个应用。从源码运行的副本之间仍可互相链接,但操作系统不会因为链接而启动它。
源码级实现:DeepLink.kt
链接的解析与拼装完全在非 UI 模块实现,以便单测而非靠手点验证。核心类 DeepLink.kt 的文档注释定义了这一格式:shark://<window>/<place>?<what that place needs>,一个窗口中的一处位置。
值得展开的实现细节:
- Scheme 与 window id:scheme 固定为
shark,因为它是自有的、且短到值得手打和粘贴。window id 是 8 个小写字符,取自一个不含l、1、o、0的字母表——因为链接经常要从屏幕上读出来再敲回去,这保证了读到的就是原链接。id 是随机生成而非顺序编号的:顺序编号会让昨天复制的链接今天打开"某个"窗口(本次运行的第二个窗口)而悄悄变错;随机 id 要么指向它命名的窗口,要么什么都不是——后者是一个明确的错误信息。 - 位置路径(place):
Place的每种形态都有对应路径——object、smaller-objects、objects、leaks、starred,见 Place.kt 中的五种位置类型。链接参数携带完整的位置状态:过滤后的对象列表到达时仍是过滤状态,泄漏页面到达时展开的是同一组。 - 节点 id 的完整保留:链接中的对象 id 写作
0x加全部 64 位无符号十六进制。这与界面上标签所用的hexObjectId(掩码到低 32 位)刻意不同——树的节点跑满整个Long范围,32 位 dump 中 2 GB 以上的对象在 shark 中是负数,标签写法会丢失信息。注释明确:"链接必须能回到它被创建时的那个 id"。 - 解析错误信息:
parse()对非法链接抛出带说明的IllegalArgumentException(如"A link starts with "shark://"…"),因为每个链接都来自应用外部:命令行、另一次运行、或操作系统递来的浏览器 URL。
DeepLinkTest.kt 验证了这些保证:链接写出后能读回同一对象;Long.MAX_VALUE、-1L、Int.MIN_VALUE.toLong()等极端 id 都能完整往返;负数 id 不会按标签的 32 位写法写,否则两个不同节点会变成同一个链接。
位置笔记(Notes):跨运行的 Markdown 便签
变更日志说了什么
Notes:每个位置都可以保存一条 Markdown 笔记,跨运行保留;标签栏会标记那些位置有笔记的标签。笔记属于位置而不是标签页,所以两个标签页指向同一位置时共享同一条笔记。笔记中写到的类名、地址和
shark://链接会变成回到窗口中的链接,按散文风格缩短显示;GitHub URL 则按 GitHub 自己的缩短方式缩短。
在用户指南中的用法
shark-explorer.md 的 "Take notes" 一节完整描述了操作流:在显示标签页位置标题下方的✎ Add Note开始写,输入框内输入,按Save保存,笔记就画在输入框原来的位置;Cancel丢弃输入。下次再到这个位置,笔记还在;标签栏会给有笔记的位置的标签打上 ✎ 标记。
关键语义:
- 笔记属于位置而非标签页:同一位置的两个标签页共享一条笔记,同一份 dump 的两个窗口亦然。所以两次到达同一对象时写下的内容会追加到已有的笔记,而不是在旁边另起一条。删除方式是打开笔记、删光文字再Save:空笔记等于没有笔记,✎ 标记随之消失。
- 存储位置:笔记存放在
~/.shark-explorer/notes,每个堆转储一个目录,每个位置一个.md文件。因此笔记可以用编辑器打开、粘贴进 issue、或让 Agent 直接读取,无需经过应用。 - 可识别的写法:保存后,笔记中任何这个堆转储能识别的片段都会变成回到窗口的链接:
| 你写的 | 它读成 | 点击后 |
|---|---|---|
com.example.MyApp$Cache | MyApp$Cache | 在新标签页打开该类 |
0x7f2a4b18 | Cache instance (0x7f2a4b18) | 在新标签页打开该对象 |
shark://vugs93jp/leaks | Leaks | 像在任何地方点击一样跟随链接 |
https://github.com/square/leakcanary/issues/2841 | square/leakcanary#2841 | 在浏览器中打开 |
- 名字或地址若 dump 里没有对应物,就原样保留为文本:没听说过的类就是你写到的类,而不是一个坏链接。这也让笔记在应用之外保持可读——磁盘上的内容从不被改写,只有屏幕上的显示会变。
- 支持标题、列表、引用、
code、加粗、斜体、围栏代码块和[链接](https://example.com);一行就是一行:两行之间不需要空行,如同 GitHub 评论框读 Markdown 的方式。围栏代码块内的内容不会被链接化或缩短。 - 位置是"你在哪里"而不是"你如何排列",所以在对象列表中搜索、展开泄漏或调整窗口大小都会停留在同一条笔记上,而不是新开一条。
- 由于
shark://链接命名的是窗口,写进笔记的链接在该窗口关闭后即失效——所以在写着它的时候为你想回来的标签页复制链接,只要那个窗口还开着它就能带你回去。
源码级实现:NoteFile.kt 与 NoteMarkdown.kt
笔记存储的落盘逻辑在 NoteFile.kt。设计决策都有明确的源码注释:
- 不放在堆转储旁边:dump 可能来自设备拉取的目录、临时文件、只读挂载或本仓库的 checkout,写进那些地方会弄脏其中一些并在其余地方失败。应用自己的目录(
root之下)始终可写且总能再次找到。 - 一个位置一个文件,而非一个文件按章节分区:保存只触及正在输入的那条笔记;不必从一份还含有人家自己标题的文档中解析回内容;目录列表本身就是索引——这正是
keysWithNotes()所做的事,也是标签栏标记的依据。 - 目录命名:
<dump文件名>-<父目录哈希>,如large-dump.hprof-1f3a9c0b。名字在前是因为这些目录会被人类按名字浏览;哈希在后是因为两次运行的large-dump.hprof是两次调查。路径用absoluteFile.normalize()规范化,使./heap.hprof与heap.hprof指向同一组笔记。 - 写入的原子性:先写
*.partial文件再重命名,保证保存中途被杀掉时留下的是上一次的完整笔记而不是半个;空内容则删除文件而非留下空文件,否则下一次运行会把空文件当作有笔记并给标签打标记。读写都抛IOException而非吞掉——读不出来的笔记绝不会被悄悄用空内容覆盖。
Markdown 的解析在 NoteMarkdown.kt,它是刻意实现的子集:"一行是一个块"——不会把某人写成的两行重排成段落,列表上方不需要空行,这正是 GitHub 评论框读 Markdown 的方式。解析器本身不读堆转储,只把类名/地址留成NoteMention,等打字停顿后再由Note.resolvedWith一次性询问 dump。围栏代码块内的内容保持原样。GitHub URL 的缩短按 GitHub 自己的规则:…/issues/2841显示为square/leakcanary#2841,commit 短 SHA 取 7 位,评论片段追加(comment)。
笔记键(note key)的归属规则在 Place.noteKey() 中同样明确:每个对象一条、每个对象堆一条、对象列表(无论怎么过滤)一条、泄漏(无论怎么展开)一条、星标对象一条;整个堆转储本身也是一个对象(HeapDominatorTreemap.ROOT_OBJECT_ID),是窗口首开标签页所在的位置,也是写"关于整个 dump 的笔记"的地方。
前进/后退历史:一次点击走四步
变更日志说了什么
右键点击 ← 或 →,会列出该箭头能带你去到的所有地方,于是后退四步是一次点击而不是四次。
源码级实现:NavigationHistory.kt
NavigationHistory.kt 实现每个标签页(而非每个窗口)一份的导航历史。其设计文档说得很清楚:Shark Explorer 的一切操作都是横向移动——点击支配者或路径的一步会转而显示那个对象,打开对象列表或星标列表则完全离开地图——因此没有"向上"可走,需要记住的正是这些移动本身。
实现要点:
- 不可变数据结构(
entries+index),可安全地作为 UI 状态持有,并保持在核心模块中以供单测; backEntries/forwardEntries按箭头访问的顺序排列——最接近当前的一步在前,所以"列表第一项就是一次后退点击,第二项是两次";goBack(steps)/goForward(steps)钳制而非校验越界,因为点击来源的列表与它来自的历史在重组之间是同一个值;goTo(entry)记录当前位置并丢弃前进方向的记录,行为同浏览器;对当前条目是 no-op,避免反复点击同一矩形塞满历史;replacingCurrent(entry)不记录移动地替换当前位置——用于"路径回来后比请求的短"以及"对象列表里敲入的过滤"这类不应让后退箭头返回的情况。
NavigationHistoryTest.kt 覆盖了这些语义。
深入理解:为什么"窗口链接"与"位置笔记"如此设计
把两个新特性放回 shark-explorer.md 的语境,其设计哲学是一致的:一切命名的对象都是到达它的方式。地图上每个矩形、引用链每一行、列表每一行都可点击(点击前往、中键/⌘/Ctrl 点击后台新标签、右键菜单同时提供两者与链接);标签页可全部关闭——关到最后一个仍保留堆转储打开与按钮就绪。
在这种"处处可去、处处可链"的界面里:
shark://链接让"协作"与"自动化"成为可能——把发现贴进 issue、或让已读取 dump 的工具/Agent 直接把人带到现场;- 位置笔记让"调查过程"本身持久化——堆转储文件会过期、窗口会关闭,但写下来的调查记录以普通 Markdown 的形式留在
~/.shark-explorer/notes,比应用活得久; - 导航历史下拉则是日常浏览的减负:深树形结构里后退四步是一次点击而非四次。
三者共同支撑的定位是:Shark Explorer 回答 LeakCanary 旁边的那个问题——"这个应用占了 200 MB,全都是什么,是什么在留着它?"(见 shark-explorer.md 开头)。它运行在 Shark 之上,应用无需添加任何东西:任何可调试应用的.hprof都可以直接打开。
如何验证与运行
按 shark-explorer.md 的说明,从源码运行需要 JDK 17:
git clone git@github.com:square/leakcanary.git cd leakcanary ./gradlew :shark:shark-explorer:shark-explorer-app:run --args="path/to/dump.hprof"路径参数可省略:没有它时窗口只显示Open heap dump…按钮。注意shark://链接只对已安装构建生效(安装包负责注册 scheme);从源码运行时链接仍可在副本之间互传,但操作系统不会因链接而启动应用。若要报告问题,每次运行都会向~/.shark-explorer/logs写入日志(保留最近 20 份),附上出问题那次运行的日志即可。
小结
- Shark Explorer 使用与 LeakCanary 相同的标记体系(⚠️ 🔀 💥 🐛 ✨ 🔨)维护独立变更日志,首发版本全部是 ✨ 新增:初始发布、
shark://深链、位置笔记、导航历史下拉; shark://链接在 DeepLink.kt 中实现为"窗口 + 位置"两级寻址,随机 8 字符 window id 保证链接要么准确要么明确失效,参数携带完整界面状态;- 笔记存储于
~/.shark-explorer/notes的纯 Markdown 文件(NoteFile.kt),按位置而非标签页归属,支持类名/地址/链接的屏幕内回链(NoteMarkdown.kt); - 导航历史(NavigationHistory.kt)以不可变"条目列表 + 索引"为每个标签页维护,右键箭头可一次跳跃多步。
对于想跟进 Shark Explorer 桌面端开发的读者,docs/shark-explorer-changelog.md 是当前功能的权威来源;深入功能语义可读 docs/shark-explorer.md;了解版本如何被切割、macOS 签名如何完成,可读 docs/releasing-shark-explorer.md。
【免费下载链接】leakcanaryA memory leak detection library for Android.项目地址: https://gitcode.com/gh_mirrors/le/leakcanary
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考