用Kotlin与Compose Multiplatform实现Mermaid流程图原生渲染 📅 发布时间:2026/9/18 13:55:45 👁 浏览次数: 做 Mermaid 原生渲染这个项目起因其实挺朴素的我需要在 Compose Multiplatform 里展示流程图但翻来翻去大家给出的方案几乎都绕不开 WebView——嵌入 HTML、加载 mermaid.js、再用 JsBridge 通信。这套方案在 Android 上还行可一旦切到 iOS、DesktopWebView 的行为差异立刻让人头疼。再加上项目本身对首屏体积和内存占用有要求我就在想是不是能把 Mermaid 的渲染链路整个搬到原生侧用 Kotlin 解析语法、计算布局、再用 Canvas 画出来。这个想法折腾了大概一个月最后做了一个能完整解析并渲染graph流程图子集的原生渲染器还用 2048 组测试用例和 WebView 版 mermaid.js 做了对拍。虽然不敢说覆盖了 Mermaid 全语法但至少把核心链路跑通了。这篇就来拆一拆整体设计、解析器实现、布局引擎、Compose 绘制以及那 2048 组对拍是怎么做的踩过哪些坑也都一并记录。1. 方案选型为什么坚决不用 WebView1.1 WebView 方案的成本被人为低估了先说个残酷的事实WebView 渲染 Mermaid大部分情况下是“能跑”和“体验好”之间的巨大鸿沟。我在项目初期试过官方推荐的mermaid.jsiframe方案一套流程走完发现麻烦远不止写几行 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 最终会被组装为一个GraphDatadata class GraphData( val direction: Direction, val nodes: ListGraphNode, val edges: ListGraphEdge )这段数据是后面所有流程的输入只要它是正确的布局和绘制就会变得非常“顺理成章”。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。它的底层是TextMeasurerTextLayoutResult我会先把文本测量的结果缓存下来避免每次重组反复测量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: MapString, Rect, val edgePaths: ListEdgePath ) 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 吞进来。