ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Compose Multiplatform原生绘制Mermaid:2048组对拍打造可度量的渲染管线

Compose Multiplatform原生绘制Mermaid:2048组对拍打造可度量的渲染管线 入坑 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: ListString ) : 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: ListFlowchartEdge): ListSetString { val indegree mutableMapOfString, Int() val nodes mutableSetOfString() 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 ArrayDequeString() indegree.filterValues { it 0 }.forEach { (id, _) - queue.add(id) } val layers mutableListOfSetString() val visited mutableSetOfString() while (queue.isNotEmpty()) { val sameLayerNodes mutableListOfString() repeat(queue.size) { val id queue.removeFirst() if (visited.contains(id)) returnrepeat 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、heightedgePoints每条边的折线或贝塞尔控制点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 TDmermaid.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 就能确定风险点这是整个项目里我认为最值回票价的一步。
返回列表