入坑 Compose Multiplatform 做 Markdown 预览的时候,我第一反应也是:嵌一个 WebView,把 Mermaid 丢给浏览器内核就完事。直到我做完 2048 组对拍,才把这条路线彻底否掉。标题里这个“Mermaid 原生渲染”不是指把 mermaid.js 生成的 SVG 截图贴到 Compose 里,而是用 Kotlin 解析 Mermaid 语法、自己排布节点坐标、再用 Compose Canvas 直接画出来;“2048 组对拍”则是我用来证明“画得像”的手段。整个项目做完,我最大的感受是:原生渲染本身并不难,难的是让原生渲染的结果可度量、可回归、可解释。
这套方案适合谁?如果你正在做 Compose Multiplatform 富文本编辑器、文档类 App、代码文档工具,或者在桌面端做一个轻量级的 Markdown 阅读器,可能会遇到一模一样的诉求:用户粘贴一段 Mermaid 代码,希望在预览区看到图,但你不希望为此去维护三套 WebView 壳。这篇文章没有完整贴出全部源码,但会把渲染管线的分层思路、2048 组对拍的具体设计、以及我在对拍中真实踩过的五个坑全部摊开讲清楚。
1. 原生渲染的动机:WebView 到底在哪几个环节拖了后腿
1.1 跨端一致性的幻觉
WebView 看起来是“一套代码到处跑”的最优解,但真正把 Android、iOS、Desktop 三端跑起来,你会发现跨端一致性更多是幻觉。Android 的 WebView 版本碎片化非常严重,系统自带的 WebView 和 Chrome 稳定版未必同版本,用户手机厂商还会魔改内核。同样是执行mermaid.initialize({ startOnLoad: true }),在 Android 7 自带 WebView 和 Android 13 的 Chrome WebView 上,渲染结果会有差异:字体宽高不同、自动换行位置不同、SVG 的text-anchor处理也有可能不同。iOS 的 WKWebView 是另一个独立世界,虽然 JavaScript 引擎一致,但本地 HTML 加载策略、缓存管理、滚动手势承接,各自都有各自的脾气。
桌面端看起来最省心,实际最麻烦。JVM 上跑 Compose Desktop 时,要用 JavaFX WebView 或者 JDK 里内置的 WebView 组件,可 Linux 服务器或嵌入式设备上经常没有图形库,WebView 初始化直接失败。Compose Multiplatform 的核心价值是让 UI 逻辑同一套代码跑遍所有平台,WebView 恰好是这套逻辑里最不方便迁移的一块,它把每一端的差异都原样暴露给你。
1.2 尺寸测量与主题控制的失控
原生开发里,一个TextView有多少像素高,你可以在布局阶段直接测量。WebView 不是,内容高度要等页面加载完成之后通过 JS 回调拿到,这个异步过程在 Android 上经常要几百毫秒。放进 Compose 的Column或LazyColumn里,你会遇到三类问题:第一,页面加载过程中先白屏,再闪现内容,阅读体验很差;第二,内容高度变化后需要重新请求布局,导致滚动位置跳动;第三,WebView 拦截触摸事件,列表滑动和图表区域的手势冲突很难调。
还有一个很隐蔽的痛点:主题。WebView 默认白底黑字,在深色模式下非常刺眼。你想让它跟随 Compose 的MaterialTheme,就得通过 JS 接口把 CSS 变量传进去,再等字体加载完成,继续回调通知 Compose 修正高度。这种“双端通信”在 demo 里跑得很顺,真正上线之后,每端都要修几个边界条件,维护成本远高于预期。
1.3 为什么最终选了 Kotlin + Compose Multiplatform
选择原生渲染不是因为性能一定比 WebView 快,而是因为三个更实际的原因:一是布局结果可以通过 Compose 的Canvas直接参与手势、动画和无障碍系统,节点点击、缩放、拖拽都能和 App 现有交互统一;二是文本测量走的是平台自带的TextMeasurer,字体、换行、间距和界面其它部分保持一致,不会出现 WebView 里字体偏大或偏小的问题;三是不依赖外部 JS 引擎和网络资源,离线可用,安装包里也不用塞一个几十 MB 的浏览器内核。
但原生渲染把一个重要问题摆上了台面:mermaid.js 是经过大量用户和长期迭代验证的参考实现,我凭什么说自己画得对?于是就有了后面 2048 组对拍的设计。对拍不是炫技,它是给自定义渲染器装上一双“客观的眼睛”。
2. 从 Mermaid 文本到画布坐标:一套可对拍的中间表示
2.1 把语法拆成 AST,而不是直接去画
我第一个冲动是用正则表达式去解析graph TD和A-->B,然后按遇到顺序排节点。前 10 个用例会过,第 11 个复杂用例就乱了。后来我改成了标准的“词法 - 语法 - 语义”三层结构:先用 Lexer 把A["文本"] -->|label| B拆成 token,再用 Parser 生成 AST,最后由一个 Compiler 把 AST 编译成布局引擎能消费的图结构。
sealed interface FlowchartNode { val id: String data class Rect( override val id: String, val label: String, val shape: Shape = Shape.RoundRect ) : FlowchartNode data class Diamond( override val id: String, val label: String ) : FlowchartNode data class Subgraph( override val id: String, val label: String, val children: List ) : FlowchartNode } data class FlowchartEdge( val from: String, val to: String, val label: String? = null, val arrow: Boolean = true ) sealed interface Shape { object Rect : Shape object RoundRect : Shape object Diamond : Shape object Circle : Shape }这套 AST 同时做两件事:给原生布局器消费,以及序列化成 JSON 给对拍脚本使用。对拍的关键不是把两张位图拿来比“像不像”,而是比结构数据。如果中间表示不可序列化,后面的对拍方案就无从谈起。
2.2 用布局引擎把 AST 变成坐标表
AST 只表达了“谁连谁”,不包含坐标。坐标计算是布局引擎的职责。第一步是把图变成有向无环图的层级化结构,也就是从graph TD的 TD 方向出发,将节点分层,再用边连接不同层。这一步核心是拓扑排序。为了让结果更接近 mermaid.js,我用了一个带权重的 Kahn 算法变体,以下是一个简化版本:
class LayeredGraphBuilder { fun build(edges: List): List> { val indegree = mutableMapOf() val nodes = mutableSetOf() edges.forEach { e -> nodes += e.from nodes += e.to indegree[e.from] = indegree.getOrDefault(e.from, 0) indegree[e.to] = indegree.getOrDefault(e.to, 0) + 1 } val queue = ArrayDeque() indegree.filterValues { it == 0 }.forEach { (id, _) -> queue.add(id) } val layers = mutableListOf>() val visited = mutableSetOf() while (queue.isNotEmpty()) { val sameLayerNodes = mutableListOf() repeat(queue.size) { val id = queue.removeFirst() if (visited.contains(id)) return@repeat visited.add(id) sameLayerNodes.add(id) edges.filter { it.from == id }.forEach { e -> val newIndegree = indegree.getValue(e.to) - 1 indegree[e.to] = newIndegree if (newIndegree == 0) queue.add(e.to) } } layers += sameLayerNodes.toSet() } return layers } }别把这套代码直接当生产实现,它只是用于对拍前期的粗筛。真实项目里还要处理环、跨层边、回边,以及子图在父图中的嵌套排列。坐标的最终生成还要考虑每层最大高度、层间距、节点内边距,这些计算会直接影响最终的可读性。
2.3 让中间表示可序列化,是对拍的前提
我给原生渲染器定义了四个可序列化输出,这四个字段同时是参考端和原生端的契约:
nodeBounds:每个节点的 id、x、y、width、height;edgePoints:每条边的折线或贝塞尔控制点;textLines:每个 label 的实际换行结果;graphSize:整体宽度和高度。
对拍脚本只比较这四样,不比较“像素像不像”。这样设计还有一个额外收益:如果你要调整布局算法,比如把节点间距从 20 改成 30,只需要改LayeredGraphBuilder的坐标展开部分,绘制层不用动。中间表示像一层稳定的接口,把解析、布局、绘制三件事解耦开。
3. 布局算法与 Compose 绘制细节:不是把坐标画出来就完事
3.1 分层布局:怎么确定节点在第几层
坐标计算分三步。第一步,层级分配,通过拓扑排序决定每个节点在哪一层。第二步,层内排序,同一层的节点按“连接数”和“原始出现顺序”排序,目的是减少边的交叉。第三步,坐标展开,给定每层的最大高度和层间距,计算每个节点的中心坐标。
这里有个常见误区:只做层级分配,不做层内排序。如果不排序,两个节点之间有边但边要横穿多个无关节点时,图会乱得没法看。mermaid.js 内部对层内排序做了很多优化,这也是为什么它对复杂图的排布看起来比较整齐。
为了对拍,我把 mermaid.js 的默认间距也挖出来做了参照。一般的 flowchart 默认节点间距在 10 到 20 像素左右,层间距则根据TD或LR方向改变。这些数值我没有办法拿到官方精确文档,就从参考 SVG 里反推过,大概率不是精确值,但对拍时要宽容处理,因为间距属于渲染器的自由裁量范围。
3.2 边、箭头和折线:曲线不是拿来装饰的
Compose Canvas 里画一条带箭头的线,看起来一句话的事,实际上要拆成三步:第一,确定边在节点边界上的起点和终点,不能从节点矩形左上角直接出发,否则线会戳进节点框里;第二,确定折线的拐点,跨层时多数实现用正交折线,而不是直接画一条直线;第三,绘制箭头,箭头方向由最后一段切向量决定,尖端要准确落在目标节点边界上。
下面是一个简化版绘制片段:
fun DrawScope.drawEdge(start: Offset, end: Offset, arrow: Boolean) { val bend = min(32.dp.toPx(), abs(end.x - start.x) / 2) val path = Path().apply { moveTo(start.x, start.y) cubicTo( start.x + bend, start.y, end.x - bend, end.y, end.x, end.y ) } drawPath(path, color = Color(0xFF607080), style = Stroke(width = 2.dp.toPx())) if (arrow) { val angle = atan2(end.y - start.y, end.x - start.x) val arrowSize = 8.dp.toPx() val p1 = Offset( end.x - arrowSize * cos(angle - PI / 6).toFloat(), end.y - arrowSize * sin(angle - PI / 6).toFloat() ) val p2 = Offset( end.x - arrowSize * cos(angle + PI / 6).toFloat(), end.y - arrowSize * sin(angle + PI / 6).toFloat() ) val arrowPath = Path().apply { moveTo(end.x, end.y) lineTo(p1.x, p1.y) lineTo(p2.x, p2.y) close() } drawPath(arrowPath, color = Color(0xFF607080)) } }这里最容易翻车的是bend计算。跨多层的长边如果bend固定不变,曲线会在长边上变得非常平,箭头角度和节点边界对不上。对拍时这类几何问题很难用肉眼发现,通常会表现为edgePoints差几个像素,但不影响视觉。等到用户真的放大看箭头时,又会觉得“哪里怪怪的”。
3.3 Canvas 绘制与文本测量:为什么文本经常溢出
节点宽高不能写死,必须根据 label 的实际测量结果动态计算。Compose 里用TextMeasurer测量多行文本,拿到文本宽高之后,还要加上上下左右 padding 才是节点的最终宽高。
文本测量结果受字体、字号、密度影响很大,这也是原生渲染器和 WebView 渲染最明显的差异。WebView 默认字体和 Compose 默认字体通常不是同一款,相同文案在两边测出来的宽度会相差 5% 到 10%。所以我在节点绘制里专门留了一个fontScale参数,默认是 1.0,对拍时统一设为参考端使用的值。如果直接用系统字体去对比,永远对不上,这不是布局算法的锅,而是字体选择不一致。
4. 2048 组对拍方案设计:参考实现、样本生成与误差判定
4.1 参考渲染器选型:为什么用 mermaid.js 的中间输出,而不是像素
对拍的核心是“同一份输入,两个渲染器各自产出一个可比较的结果”。mermaid.js 最后输出 SVG,但 SVG 里只有像素。像素比较在字体、缩放、抗锯齿的影响下会产生大量假阳性,所以我没有拿截图直接比对。
mermaid.js 内部是有中间布局过程的,它能返回每个节点的 bbox 和每条边的 path。我用一个 Node 脚本调用这些内部接口,把输出落盘成 JSON。每张测试图都生成这样的结构:
{ "nodes": [ { "id": "A", "x": 40, "y": 60, "width": 100, "height": 40 } ], "edges": [ { "from": "A", "to": "B", "points": [[90, 90], [110, 120]] } ], "graph": { "width": 240, "height": 160 } }参考端有了这个 JSON,原生端也输出同样的结构,对拍脚本再去比较这两个 JSON。中间输出对比能避免字体、抗锯齿、截图像素这些干扰因素,直接锁定布局逻辑本身。
4.2 样本库:2048 组是怎么凑出来的
2048 这个数字不是随便拍的。我的样本库分成五层,每一层的目标都不一样:
| 层级 | 数量 | 覆盖内容 |
|---|---|---|
| 基础语法 | 512 | 节点、边、箭头、label,覆盖graph TD和graph LR两种方向 |
| 语义结构 | 512 | 条件分支、循环、菱形节点、子图、classDef样式 |
| 异常输入 | 512 | 空 label、长中文、特殊字符 ID、指向不存在节点的边 |
| 组合场景 | 512 | 随机组合前面的语法,构造多路径、多层、多子图的复杂图 |
| 真实手工 | 100 | 从文档和 issue 里捞出来的“人类会写的 Mermaid”,区别于程序生成的规规矩矩的样例 |
程序生成样本时,我用的是“语法模板 + 随机填充”的方式。如果不用随机填充,程序会倾向于生成同一种简单模式,边界条件覆盖不到。手工 100 组非常重要,真实用户写 Mermaid 时经常使用不规范语法,比如不写引号、混用-->和---、在 label 里放括号,这些在模板生成样本里很难覆盖。
4.3 坐标归一化与容差:不同布局算法怎么比才公平
刚开始我直接拿原生坐标和参考坐标比,发现同样的graph TD,mermaid.js 的根节点起点在坐标原点附近,我的布局器却从某个固定 padding 开始,整体偏移大几十像素。这其实是“原点约定”不同,不是布局错误。所以我把两组坐标都做了 min-max 归一化,也就是把坐标值缩放到 0 到 1 的区间,再去比较相对位置。
容差方面,我用的两级判定。第一级是结构级,节点的相对位置关系必须一致,比如 A 必须在 B 的左上方,子图必须包含子节点。第二级是数值级,归一化后的坐标差不能超过 0.08,也就是 8%;整体宽高比偏离不能超过 15%。
实际跑下来的数据,2048 组里第一次通过率大概是 71%。剩下的 29% 里,大部分是文本换行和字体宽度差异导致的,真正布局逻辑错误占比并不高。这说明如果不用对拍,我可能永远找不到那 29% 里的问题,因为它们在小样本测试里看起来都正常。
4.4 对拍结果分类:不是只有“过/不过”
我不建议把对拍结果简单存成“全过/不全过”。每跑一轮,我会输出一个报告,把用例分成五类:
- 通过:结构和数值都在容差内;
- 警告:结构一致,但数值超差 5% 到 8%;
- 疑似:参考端解析报错,原生端能渲染;
- 失败:结构不一致,或原生端解析报错;
- 跳过:样本库里明确标记为“不纳入本轮”的用例。
“疑似”这个分类非常有用。参考端解析报错不代表原生端错了,它可能是 mermaid.js 的语法限制。但如果你把这类用例标成通过,后面某次升级 mermaid.js 或者升级原生解析器后,行为变了,你很难快速定位。对拍的价值不在于让所有用例都过,而在于每一步变更都有记录。
5. 对拍暴露的问题和我的修复记录:印象最深的五个案例
5.1 菱形节点在中文 label 下被压扁
样本里放入A{"是否继续?"}之后,参考端的菱形节点会按 label 宽度自适应,我的Diamond节点宽高比固定为 1.4 : 1。中文 label 一长,文字直接溢出节点边界。这是一个典型的“绘制层正确但定位层错误”的问题。修复方式是先把 label 的测量结果传入Diamond的几何计算,再按最小宽高比例约束:
val diamondWidth = max(labelWidth * 1.6f, textHeight * 2.2f) val diamondHeight = diamondWidth * 0.8f如果只看坐标,这个问题发现不了。节点中心坐标是准的,但 bbox 宽高不同,对拍时节点大小一对比就暴露了。
5.2 子图的边界计算偏差
写subgraph sg1[Group]然后塞几个节点,这是文档里非常常见的用法。我的布局器最开始把 subgraph 当成普通容器,只在最后用子节点坐标的包围盒来画框。这样参考端 subgraph 的 padding 和原生端不一致,多个嵌套子图时偏移还会叠加。
修复思路是:先对 subgraph 做递归布局,再用子节点布局结果加固定 padding 生成 subgraph 的外边界,并且让 subgraph 在父图中作为一个可参与排列的实体存在。这个逻辑放在LayeredGraphBuilder里,和普通节点排列共用同一套代码,减少重复。
5.3 无箭头边的终点计算:参考实现默认预留间距
有一条边A --- B,没有箭头。参考输出里这条边的终点停在 B 节点边界前几像素的位置;我的实现把终点直接画在 B 的中心点上。参考端其实对无箭头边做了“终点缩进”,等价于把终点往回推一段距离。
修复后我在 CI 里加了一条回归规则:arrow = false的边终点坐标统一向起点方向收缩 6 像素。这个细节如果不去做大量对拍,完全想不到。它直接造成两种渲染方案在视觉上的差异,但对坐标比较来说,差异非常小。
5.4 节点 ID 含特殊字符导致解析失败
Mermaid 语法支持带引号的节点 ID,比如A["hello (world)"]。我写 tokenizer 时,一开始用空格和括号做分隔正则,导致(、)、"被错误截断。对拍用例里放了 50 个带特殊字符的 ID,全部在参考端正常解析、原生端直接报错。
最后的修复是停止使用简单正则,把 tokenizer 改成状态机,逐个字符扫描。这个坑看起来基础,实际跑样本时一定会炸。程序员很容易在写解析器时低估输入多样性的程度。
5.5 坐标一致但渲染面积不一致:被坐标比较掩盖的问题
有一类用例的节点坐标完全在容差内,但整张图的总面积差一倍。原因是那组样本用了classDef控制节点宽度,布局引擎没有感知到样式里的width属性,样式只影响绘制层。坐标归一化之后,单点偏差不大,整体图幅却完全不同。
后来我在节点 IR 里增加了一个styleWidth字段,布局和绘制共用同一条数据链路。这也是为什么对拍要记录“整体宽高比”而不只是节点坐标。只有把面积、宽高比这些整体指标纳入比较,才能发现这种局部对齐但整体失真的问题。
6. 我为什么不建议拿原生渲染去做 1:1 像素复刻
项目收尾后,我沉淀下来的结论是:Kotlin + Compose Multiplatform 原生渲染 Mermaid 的定位,不是替代 mermaid.js,而是给不需要完整生态、只需要把流程讲清楚的产品一个更可控的渲染层。2048 组对拍是为了把“看起来差不多”变成“偏差有据可查”,不是为了追求逐像素一致。逐像素一致在跨字体、跨平台的现实里成本非常高,而且不一定值得。
如果你也想在项目里用这套思路,我最大的建议是:先把对拍的报告格式定义好,再写渲染器。报告格式定了,样本库才能定;样本库定了,你就会清晰地意识到哪些语法该支持、哪些干脆说不支持。Mermaid 语法面很大,原生渲染器必须有边界;有边界才有对拍的意义。
最后说一个可以立刻落地的技巧:在 CI 里跑对拍时,执行顺序要稳定,结果输出到独立的snapshots/pending/目录,不要直接覆盖基线。等人工确认后再把它移动到基线目录。这样每次重构布局代码,都能快速看到哪些用例的“理解”被改变了。我在这个项目里保留了全部 2048 组样本和 3 个基线版本,每次改动布局算法,只看 diff 就能确定风险点,这是整个项目里我认为最值回票价的一步。