news 2026/9/18 13:55:25

用Kotlin与Compose Multiplatform实现Mermaid流程图原生渲染

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Kotlin与Compose Multiplatform实现Mermaid流程图原生渲染

做 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 TDgraph LR两种方向,加上节点、普通边、带文本的边、以及基础节点形状。理由很简单——这是使用频率最高的子集,覆盖了绝大多数“把流程画出来”的需求,同时把解析器和布局引擎的复杂度控制在一个可以验证的范围内。

技术选型上,Kotlin + Compose Multiplatform 算是一个比较顺理成章的选择。Kotlin 的字符串处理在 JVM 和非 JVM 平台上表现一致,Compose 的Canvas绘制能力提供了一套统一的绘图 API。我更看重的是 Compose Multiplatform 对drawPathdrawText这些底层能力的封装,这意味着我写的绘制代码在 Android、iOS、Desktop 上完全是同一套逻辑,天然解决了跨平台一致性问题。

2. 解析器:把 Mermaid 文本变成数据结构

2.1 四步走的解析流水线

Mermaid 的graph语法有很强的规律性。它本质上是一个树状描述,核心语句就那么几种:

graph TD A[开始] --> B{判断} B -->|是| C[执行] B -->|否| D[结束]

这些文本信息经过解析器,最终要变成渲染引擎能理解的结构化数据。我设计的解析流程包含四个阶段:

  1. 去噪与切分:去掉注释(以%%开头的内容)、空行;按行切分,或者按块识别。
  2. 定位图定义:识别graph关键字及其后的方向(TDLR)。
  3. 节点收集:遍历每一行,识别节点 ID、节点文本、节点形状(方括号、花括号、圆角括号),建立节点ID -> 节点数据的映射表。
  4. 边关联:识别-->---|文本|形式的边文本,建立边集合,把起点、终点、文本关联起来。

每个阶段各司其职,后续如果要扩展新语法,只需要改对应阶段,不用牵一发动全身。

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 要经过“分层 → 排序减少交叉 → 分配坐标”三个阶段。我的实现简化为:

  1. 对节点做拓扑排序,确定每个节点位于哪一层。
  2. 同一层内的节点按“源边数量”排序,保证箭头密集的节点往中间靠。
  3. 按层分配 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 画节点形状

布局引擎输出的结果,是一组带坐标的GraphNodeGraphEdge。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 组测试用例不能是零散的随机文本,而是要有覆盖率的组合。我的生成逻辑分三个维度:

  1. 节点数量:从 2 到 12 个节点。
  2. 边数量:从 1 条到节点数的 1.5 倍,保证存在多边重叠的可能。
  3. 形状组合:矩形、圆角矩形、菱形随机出现。
  4. 标签长度:短标签(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 作为标签,在节点多的时候图上全是一堆n1n2,毫无可读性。正确的做法是把 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 吞进来。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 13:48:43

从 “歪瓜裂枣“ 到粒粒圆润:一粒刀豆的膨化史

刀豆是典型的药食同源品种 —— 嫩荚能当菜&#xff0c;老籽能入药&#xff0c;《本草纲目》说它 "温中下气&#xff0c;利肠胃&#xff0c;止呃逆&#xff0c;益肾补元"。但真要把它做成一款 "安全、好吸收、好吃" 的现代产品&#xff0c;远没有把豆子丢进…

作者头像 李华
网站建设 2026/9/18 13:48:36

RL强化学习从小白到老鸟(二)——手撕GPT(零基础保姆级教学)

标题RL强化学习从小白到老鸟(二)——手撕GPT&#xff08;零基础保姆级教学&#xff09; 第三篇&#xff1a;让贪吃蛇训练更稳定、更容易复现 简介 帮老师带几个研究生&#xff0c;他们想学GPT和强化学习&#xff0c;正好我两者都略懂&#xff0c;正在研究结合两者优势创建一个…

作者头像 李华
网站建设 2026/9/18 13:48:32

gsplat:工业级高斯泼溅的CUDA原生实现与部署指南

1. 项目概述&#xff1a;为什么是 gsplat&#xff0c;而不是其他高斯泼溅实现&#xff1f;最近三个月&#xff0c;我在三个不同客户现场部署3D重建管线时&#xff0c;反复被问到一个问题&#xff1a;“你们用的是哪个高斯泼溅实现&#xff1f;原生3DGS太吃显存&#xff0c;训练…

作者头像 李华
网站建设 2026/9/18 13:47:42

注意避坑!不是所有 AI 写作工具都靠谱,2026 导师推荐工具盘点

每年毕业季&#xff0c;无数同学深陷论文难题&#xff1a;开题毫无思路、搭建框架耗费数日、初稿逻辑松散、查重标红泛滥、AI检测超标、格式反复被导师驳回。现如今市面上通用型AI工具遍地开花&#xff0c;但绝大多数通用大模型存在编造虚假参考文献、学术语句口语化、AI生成痕…

作者头像 李华