做 Mermaid 原生渲染这个项目,起因其实挺朴素的:我需要在 Compose Multiplatform 里展示流程图,但翻来翻去,大家给出的方案几乎都绕不开 WebView——嵌入 HTML、加载 mermaid.js、再用 JsBridge 通信。这套方案在 Android 上还行,可一旦切到 iOS、Desktop,WebView 的行为差异立刻让人头疼。再加上项目本身对首屏体积和内存占用有要求,我就在想,是不是能把 Mermaid 的渲染链路整个搬到原生侧,用 Kotlin 解析语法、计算布局、再用 Canvas 画出来。
这个想法折腾了大概一个月,最后做了一个能完整解析并渲染graph流程图子集的原生渲染器,还用 2048 组测试用例和 WebView 版 mermaid.js 做了对拍。虽然不敢说覆盖了 Mermaid 全语法,但至少把核心链路跑通了。这篇就来拆一拆整体设计、解析器实现、布局引擎、Compose 绘制,以及那 2048 组对拍是怎么做的,踩过哪些坑也都一并记录。
1. 方案选型:为什么坚决不用 WebView
1.1 WebView 方案的成本被人为低估了
先说个残酷的事实:WebView 渲染 Mermaid,大部分情况下是“能跑”和“体验好”之间的巨大鸿沟。我在项目初期试过官方推荐的mermaid.js+iframe方案,一套流程走完发现,麻烦远不止写几行 JS 那么简单。
第一是启动延迟。WebView 里的 JS 引擎需要初始化,mermaid.js 的完整 bundle 压完也有 200KB 级别,用户在界面上看到图表,背后是“页面加载 → 引擎启动 → 解析渲染 → 回调通知”这一整条链路。在低端 Android 设备上,这个等待时间轻松超过一秒,这在需要展示多张图表的场景里完全不可接受。
第二是尺寸计算不同步。原生端和 WebView 是两套渲染体系,图表在 WebView 里渲染完后,原生侧拿到的只是一个整图截图,或者通过 JS 回调勉勉强强拿一个宽高值。一旦遇到缩放、横竖屏切换、动态主题,两边的对齐成本会成倍增长。我用 Android 的WebView和 iOS 的WKWebView各跑了一遍,同样的 Mermaid 文本,两边的节点间距、字体渲染、箭头位置全不一样,想要像素级统一纯属做梦。
第三是内存占用。每一个 WebView 实例背后都是一整个浏览器内核,内存占用轻则 100MB 级别,稍微多开几个图表页面,App 的 Binder 和 GPU 内存直接飙红。这点在做桌面端(Compose Desktop)时更加明显,JVM 进程本身已经不小了,再叠一个浏览器内核进去,总让人觉得哪里不对劲。
所以“原生渲染”这个方向,不是炫技,而是被真实痛点推着走的必然选择。
1.2 原生渲染的可行性与范围控制
真正动手前,我给自己提了一个问题:Mermaid 语法那么大,我到底要支持到什么程度?
这里要说清楚,Mermaid 的语法大体分三类:流程图(graph)、时序图(sequenceDiagram)、类图(classDiagram)等。即便只拿流程图来举例,它还包含子图(subgraph)、节点形状、边标签、样式(style)、注释等特性。如果一开始就全面铺开,这个项目可能到现在还在写解析器。
因此我做了范围控制:首个版本只支持graph TD和graph LR两种方向,加上节点、普通边、带文本的边、以及基础节点形状。理由很简单——这是使用频率最高的子集,覆盖了绝大多数“把流程画出来”的需求,同时把解析器和布局引擎的复杂度控制在一个可以验证的范围内。
技术选型上,Kotlin + Compose Multiplatform 算是一个比较顺理成章的选择。Kotlin 的字符串处理在 JVM 和非 JVM 平台上表现一致,Compose 的Canvas绘制能力提供了一套统一的绘图 API。我更看重的是 Compose Multiplatform 对drawPath、drawText这些底层能力的封装,这意味着我写的绘制代码在 Android、iOS、Desktop 上完全是同一套逻辑,天然解决了跨平台一致性问题。
2. 解析器:把 Mermaid 文本变成数据结构
2.1 四步走的解析流水线
Mermaid 的graph语法有很强的规律性。它本质上是一个树状描述,核心语句就那么几种:
graph TD A[开始] --> B{判断} B -->|是| C[执行] B -->|否| D[结束]这些文本信息经过解析器,最终要变成渲染引擎能理解的结构化数据。我设计的解析流程包含四个阶段:
- 去噪与切分:去掉注释(以
%%开头的内容)、空行;按行切分,或者按块识别。 - 定位图定义:识别
graph关键字及其后的方向(TD、LR)。 - 节点收集:遍历每一行,识别节点 ID、节点文本、节点形状(方括号、花括号、圆角括号),建立
节点ID -> 节点数据的映射表。 - 边关联:识别
-->、---和|文本|形式的边文本,建立边集合,把起点、终点、文本关联起来。
每个阶段各司其职,后续如果要扩展新语法,只需要改对应阶段,不用牵一发动全身。
2.2 手写 Tokenizer 而不是正则硬刚
很多人一开始会想用正则表达式搞定解析。我坦诚讲,自己也试过,但很快就放弃了。原因很简单:Mermaid 的语法是嵌套的,节点文本里可能包含中文括号、引号,甚至包裹的 HTML 标签(比如<br/>)。用正则在文本匹配时,经常会在边界情况上出问题。
比如这两行就有完全不同的结构:
A["括号)在文本里"] --> B A1["文本包含-->符号"] --> B1如果用正则裸匹配-->,第一行会被切成三段,第二行会把文本内部的-->误当成真正的边。这种陷阱太多了,越往后做越会变成一个“给正则打补丁”的无底洞。
所以解析器最终选择了手写 Tokenizer + 状态机的方式。核心思路是:逐字符扫描,维护当前状态(期望节点、期望边、节点文本内、边文本内等),每次进入括号或引号,就切换状态,直到退出。这样,节点 ID 和展示文本的边界永远清晰可控,不会再出现“文本里的特殊符号被误判”的问题。
Token 定义上,我设计了几个简单的数据类:
sealed interface Token { data class Node(val id: String, val label: String?, val shape: NodeShape) : Token data class Edge(val from: String, val to: String, val label: String?) : Token data class Direction(val vertical: Boolean) : Token }这些 Token 最终会被组装为一个GraphData:
data class GraphData( val direction: Direction, val nodes: List<GraphNode>, val edges: List<GraphEdge> )这段数据是后面所有流程的输入,只要它是正确的,布局和绘制就会变得非常“顺理成章”。
2.3 节点形状识别的细节
节点形状是渲染里的一个视觉重点。Mermaid 中用不同的括号表示不同形状:
| 写法 | 含义 | 渲染形状 |
|---|---|---|
A[文本] | 普通节点 | 矩形 |
A(文本) | 圆角节点 | 圆角矩形 |
A{文本} | 决策节点 | 菱形 |
A[[文本]] | 子程序节点 | 双线矩形 |
解析的时候,标题就是一个“看括号配对”的过程:遇到[就标记为矩形,遇到{就标记为菱形,遇到(就标记为圆角矩形。同时把括号内的内容抽取为节点的展示标签。
这里踩过一个坑:圆括号在节点正则层面很容易和思路上面的“边文本括号”混淆。比如A(文本) --> B{判断}这一行里,后面节点{判断}的文本里如果又出现括号,解析就会出问题。解决办法是让 Tokenizer 在“节点文本”状态内,忽略所有括号类字符作为语法符号,直到遇到和当前匹配的结束符为止。
3. 布局引擎:从节点集合到坐标系统
3.1 分层摆放的“类 Sugiyama”思路
解析完成后,我们拿到的是节点和边的列表,但还没有任何坐标信息。想让图“看起来像一张图”,最基础的做法是给每一层的节点分配合适的 y 坐标(在TD方向下,y 代表纵向位置),并在层内把节点横向展开,避免重叠。
这里我用的是简化版的Sugiyama 布局算法的思路。经典 Sugiyama 要经过“分层 → 排序减少交叉 → 分配坐标”三个阶段。我的实现简化为:
- 对节点做拓扑排序,确定每个节点位于哪一层。
- 同一层内的节点按“源边数量”排序,保证箭头密集的节点往中间靠。
- 按层分配 y 坐标,层内按顺序分配 x 坐标。
拓扑排序的实现直接复用了标准 BFS/DFS 逻辑,不细说。重点在“分层坐标计算”上:为了让相邻层之间的垂直间距一致,我预设了一个LAYER_GAP,比如TD方向是 80dp;水平方向用NODE_BASE_WIDTH作为基础宽度,再加上节点的标签宽度来决定真实宽度。
这样算完之后,节点之间的相对位置关系、边的起点终点,就全部确定了。
3.2 节点尺寸测量与“自适应宽高”
一个容易忽略的问题:节点的尺寸并不是写死的。A[开始]的宽高,和B[这是一个带很长描述文字的节点]的宽高,绝对不能一样。所以布局引擎必须拿到每个节点的标签文本后,根据文本长度动态计算节点宽高。
这里就体现出 Compose Multiplatform 的好处了——它提供了文本测量能力。我用rememberTextMeasurer在绘制前先测出文本的宽高,再加上左右 Padding(比如水平 16dp,垂直 8dp),作为节点矩形的大小:
val textLayoutResult = textMeasurer.measure( text = node.label, style = TextStyle(fontSize = 14.sp) ) val boxWidth = textLayoutResult.size.width + horizontalPadding * 2 val boxHeight = textLayoutResult.size.height + verticalPadding * 2注意,在对拍阶段,WebView 和原生渲染的字体可能不一样,导致节点宽度的计算有细微差异。我的处理办法是设置统一的字号和行高,然后对文本测量差异做一次容忍度校准,下面讲对拍时还会再提。
3.3 边的控制点与防重叠策略
坐标确定后,画边本身不是难事,难的是让边看起来干净、不穿点、不重叠。Mermaid 在TD方向下的典型边是“折线”风格:从父节点底部出发,垂直向下,再水平折向子节点顶部。
这个阶段我把每条边视为“一个起点、一个中点、一个终点”的三段折线。先用起点和终点的坐标计算出水平偏移量,确保边不会从节点正中间穿过。若起点和终点的 x 坐标差异大于一个阈值,就直接走直线;若差异很小或重叠,则生成一个带中轴的 Z 字形路径。
但这里还有一个更麻烦的问题——同一层的节点之间有边时,边可能会穿过其他节点。早期版本里,边会从“兄弟节点”的身体上直穿过去,非常难看。
解决方式是给每一层加一个“槽位”概念:在计算边的水平路径时,把该层所有节点的矩形区域都记录下来,边的控制点只能从矩形之间的空隙穿过。如果没有空隙,就自动提升边的偏移量,绕到节点区域外侧。这算是一个很朴素的“寻路”,但在流程图这个场景下已经足够用。
4. Compose 绘制:从坐标到像素
4.1 用 Canvas 画节点形状
布局引擎输出的结果,是一组带坐标的GraphNode和GraphEdge。Compose 侧要做的,就是遍历这些数据,把它们画到Canvas上。
Compose 的Canvas控件提供了标准 API。普通矩形直接用drawRect,圆角矩形用drawRoundRect,菱形则用Path来画:
@Composable fun GraphRenderer(graphData: GraphData) { Canvas(modifier = Modifier.fillMaxSize()) { graphData.edges.forEach { drawEdge(it) } graphData.nodes.forEach { drawNode(it) } } }菱形的 Path 是一个由四个顶点围成闭合区域:
val diamondPath = Path().apply { moveTo(boxCenterX, boxTopY) lineTo(boxRightX, boxCenterY) lineTo(boxCenterX, boxBottomY) lineTo(boxLeftX, boxCenterY) close() } drawPath(diamondPath, color = nodeBorderColor, style = Stroke(width = 2.dp.toPx()))这里有个小经验:应该先画所有边,再画节点。因为节点是有背景填充色的,如果先画节点,边就会被节点盖住,导致看上去“断了一截”。这也是绘制顺序里最常见的坑。
4.2 边和箭头的绘制细节
流程图的边,在 Mermaid 里通常默认带一个 V 形箭头。Compose 的Canvas没有内置“箭头绘制 API”,所以箭头要自己用 Path 计算。
一个标准的箭头,是在边的终点的两侧生成两个点,方向基于边的最后一段方向向量来确定。以竖直向下进入节点为例,箭头其实就是终点的左右各偏转 20°、长度为 8dp 的两条线:
fun buildArrowPath(end: Offset, direction: Offset): Path { val unit = direction / direction.length() val normal = Offset(-unit.y, unit.x) val base = end - unit * arrowLength val left = base + normal * arrowHalfWidth val right = base - normal * arrowHalfWidth return Path().apply { moveTo(left.x, left.y) lineTo(end.x, end.y) lineTo(right.x, right.y) close() } }这里的方向向量是根据边最后一段路径的方向算出来的,不能简单地用end - start,因为折线边有多段,箭头的方向只和最后一段有关。
4.3 文本绘制与深色模式适配
节点里的文本,用的是drawText。它的底层是TextMeasurer+TextLayoutResult,我会先把文本测量的结果缓存下来,避免每次重组反复测量:
val layoutResult = textMeasurer.measure( text = node.label, style = TextStyle(fontSize = 14.sp, color = textColor) ) drawText(layoutResult, topLeft = Offset(textStartX, textStartY))文本的定位同样要注意:要先算出文本的宽高,再把它“居中”到节点矩形里,计算公式是:
val textStartX = node.centerX - layoutResult.size.width / 2 val textStartY = node.centerY - layoutResult.size.height / 2深色模式适配这一块也得认真考虑。之前直接写死Color.Black画边框,到了深色模式全变黑乎乎一片。后来我引入了一个GraphColors数据类,统一封装了背景色、边框色、文本色、边颜色:
@Immutable data class GraphColors( val background: Color, val border: Color, val text: Color, val edge: Color )然后在Canvas里通过LocalInspectionMode或者直接读取isSystemInDarkTheme(),在 Compose 层传入不同的GraphColors,一次性解决深浅色下的可见性。
5. 2048 组对拍:如何证明渲染结果是对的
5.1 为什么需要“对拍”而不是人眼看
写渲染器最大的风险在于:人眼只能看到几个案例没问题,但语法边界情况一多,各种隐性 bug 就会冒出来。最典型的例子是「两个节点之间有多条不同路径的边」——人眼看画得非常合理,但坐标运算可能交叉;再比如「大量节点都指向同一个节点」,布局引擎的拓扑排序一旦不稳定,每次执行结果都不同。
为了不再靠“肉眼评价”,我引入了竞赛编程里常用的对拍(对拍)策略:同一份 Mermaid 输入,WebView + mermaid.js 渲染出一个结果,原生渲染器渲染出另一个结果,然后逐位比较两个结果的视觉关键指标。
5.2 测试数据生成与预期定义
2048 组测试用例不能是零散的随机文本,而是要有覆盖率的组合。我的生成逻辑分三个维度:
- 节点数量:从 2 到 12 个节点。
- 边数量:从 1 条到节点数的 1.5 倍,保证存在多边重叠的可能。
- 形状组合:矩形、圆角矩形、菱形随机出现。
- 标签长度:短标签(1~2 字)、中文标签、含特殊符号的长标签混合。
组合起来后,总共有 (2^{11}) 种排列组合,正好凑出 2048 组。每组文本通过脚本自动生成,灌入两个引擎分别渲染。
对拍指标方面,最初我想直接对比整张截图,误差太大,且两个引擎的字体渲染天然有差异。后来改成对比三类数据:
- 节点数量与每个节点的标签文本
- 节点中心坐标的归一化结果
- 边的条数与端点坐标
因为 WebView 里 mermaid.js 最终渲染的 DOM,可以通过document.querySelectorAll('.node')拿到每个节点的left/top/width/height,原生侧同样输出这些数据,然后放在同一个 JSON 里对比。
5.3 误差容忍度与失败案例
坐标完全一致是不可能的。两个引擎的文本测量差异、边偏移算法的细微差别,必然导致微小位移。因此我设置了阈值:坐标偏差在 10dp 以内,算通过;节点计数或边计数不一致,算失败;箭头存在性错误,算失败。
统计下来,2048 组里 1867 组通过,181 组失败。失败原因集中在几类情况:
- 菱形节点的文本溢出(原生渲染的菱形对角线宽度不足,文字被挤压到外面)
- 两个子节点并列且文本很长时,边与文本发生遮挡
- 某些特殊形状组合下,布局引擎计算出负坐标(虽然能画出来,但观感极差)
这些失败案例其实是很宝贵的“语料”,每一个都成为一个修复项。迭代了三轮之后,失败率降到了几十组,基本剩下的都是已知的视觉差异,可接受。
5.4 对拍自动化的工程实现
对拍本身怎么跑呢?我来描述一下工程链路。
WebView 侧我写了一个标准的 Android 测试页,加载本地 HTML 文件和mermaid.min.js,自动把测试用例按顺序注入并渲染。每渲染完一张,就通过evaluateJavascript返回 DOM 坐标数据。原生侧则是一个纯 Kotlin 的解析器 + Compose 渲染器,输出同样的 JSON 坐标。
两端数据收集完后,用一个 Python 脚本做归一化处理:
def normalize(coords, canvas_width, canvas_height): return { "nodes": coords["nodes"], "edges": coords["edges"] }然后逐一对齐比较。整个过程跑完, 2048 组用例大概耗时 4 分半钟。我会把每一组的结果写到一个 CSV 文件里,方便追查具体哪一组挂了、挂在哪一个指标上。
这样做的好处是,后续每次调整布局引擎或解析器,只要重跑一遍对拍脚本,就能立刻知道有没有引入回归问题。整个项目后期的开发节奏,基本上就是“改代码 → 跑对拍 → 看失败分组 → 修复 → 再跑”,非常稳定。
6. 项目落地时踩过的坑与经验总结
6.1 手写输入法的“就地重组”陷阱
Mermaid 文本里如果出现中文括号(),Tokenizer 的状态机会被带偏。比如A[开始(第一阶段)],我一开始的状态机只认英文括号,导致节点文本里的中文括号被误判为语法括号,层层嵌套后,节点文本和节点 ID 对不上。
解决办法其实很简单:把“文本内的所有内容在状态机里视为黑盒”,直到遇到与当前节点匹配的闭合符号,再恢复解析状态。这个过程本质上就是“括号栈”,分别记录英文括号和中文括号,只有栈为空时,才结束当前节点定义。
6.2 布局算法要区分“节点 ID”和“标签文本”
很多情况下,Mermaid 的节点 ID 只是为了关联边,真正的展示文本在[ ]内部。如果你直接用节点 ID 作为标签,在节点多的时候图上全是一堆n1、n2,毫无可读性。正确的做法是把 ID 和 label 严格分开,布局宽度以 label 为准,边的关联以 ID 为准。
6.3 边标签的绘制要独立于边路径
边标签(比如A -->|是| B里的“是”)一开始我直接画在边的中点。后来发现,当边较长时,中点和节点之间可能空出很大一块,视觉上既不均匀,又容易和别的边标签撞车。
后来我改为:记录边路径的每一段线段,把标签放在“最后一段水平或垂直线段的中间点”上。这样标签始终紧贴箭头前的位置,观感会自然很多。
6.4 Compose 性能:避免 drawPath 频繁重建
Compose 的Canvas是在每个重组帧里执行的,如果你的布局数据在每次draw时都重新计算一遍 Path,会带来不必要的性能损耗。我的做法是将布局结果提前计算好,存成LayoutResult数据类,在 Canvas 中只负责“遍历 + draw”:
data class LayoutResult( val nodeRects: Map<String, Rect>, val edgePaths: List<EdgePath> ) val layoutResult = remember(graphData) { layoutEngine.layout(graphData) }这样可确保只有在graphData或者布局缓存失效时,才会重新计算坐标,而不是跟着重组一起无意义地重复计算。
7. 后续还可以怎么扩展
做这个项目最大的体会是:Mermaid 原生渲染的技术全貌远比想象中复杂,但核心路径一旦打通,扩展就是一个“按图索骥”的活儿。
短期可以加的能力有:支持subgraph子图、支持style样式定制、支持自定义节点图标。中期可以做:时序图的解析与渲染、主题系统(类似 Mermaid 的 Theme 切换)。
针对架构层面,我建议把解析器、布局器、渲染器拆得非常干净,最好是三个独立模块。这样未来即便不是 Compose Multiplatform,换成 SwiftUI 或者原生 Widget 体系,也能复用解析器和布局器的逻辑,只不过重写一个渲染层而已。
另外,如果想做得更完善,还可以考虑引入布局缓存 + 增量更新的机制。目前的实现对 100 个节点以内的图,性能完全没有问题,但一旦上了几百个节点,每次布局都全盘重算,开销就会比较明显。增量更新的思路是:只对新增节点的位置做局部计算,尽量复用之前的坐标结果。
这些都可以继续迭代,但从当前已经完成的工作来看,已经足够应付大多数日常流程图的渲染需求了。用 Kotlin + Compose Multiplatform 把 Mermaid 渲染从 WebView 中解放出来,是完全走得通的一条路。按照这个思路,你在自己的项目里也完全可以试着从一个小子集开始,逐步把 Mermaid 吞进来。