ARTICLE DETAIL

资讯详情

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

Compose Multiplatform 原生渲染 Mermaid 的完整实践与踩坑记录

Compose Multiplatform 原生渲染 Mermaid 的完整实践与踩坑记录 去年年底接了一个桌面端知识库工具的项目需求里有一条是文档内的图表要支持渲染 Mermaid 语法。起初图省事直接塞了一个 WebView让 mermaid.js 自己干活。结果一到真机测试就出问题首屏加载慢、内存占用高、深浅色模式跟着系统切换时图表样式经常对不上最难受的是在离线环境下首帧白屏要好几百毫秒。后来一狠心把 Mermaid 解析完的语法树直接映射成 Compose Multiplatform 的绘制指令绕开 WebView 整个链路用原生 Canvas 把图“画”出来。前后做了 2048 组随机样例和 WebView 渲染结果逐张对拍把差异和坑都摸了一遍。这篇文章就把这套方案的完整思路、代码结构和踩坑记录写出来。重点不是“我用了什么库”而是解决这几个问题为什么非要从 WebView 里搬出来、Mermaid 文本怎么变成 Compose 能画的绘制指令、2048 组对拍是怎么设计和量化的、以及最后还残留哪些已知差异。如果你正在做 Compose Multiplatform 桌面端、移动端并且恰好也需要渲染 Mermaid 图表这篇应该能帮你省不少时间。1. 为什么非要从 WebView 里搬出来1.1 WebView 在桌面端的三个暗坑先说结论WebView 本身没有错它是个非常成熟的容器。但它的成熟是“浏览器”的成熟不是“渲染组件”的成熟。你在 Android 里用 WebView 渲染网页很自然但在桌面端Windows/macOS用 WebView 承载一个 10KB 的 SVG 图表你就要接受下面这些代价第一个是启动开销。Compose Multiplatform 的 Desktop 目标目前默认用的是 SkikoSkia 的 Kotlin 绑定它启动很快但 WebView 组件得拉起一个完整的浏览器内核进程。JavaFX WebView 在 macOS 上是 WebKit在 Windows 上是 IE/Edge 内核启动一次光进程创建和内核初始化就要几百毫秒。用户打开一个包含 20 张图表的笔记每一张都走一遍 WebView那体验基本是灾难级的。第二个是崩溃隔离差。WebView 渲染 Mermaid 的过程里如果发生 JS 异常多以白屏告终而且这个白屏不是 Compose 能直接感知到的。你需要额外注入 JS bridge 来回传状态否则根本不知道渲染是成功还是失败。我在实际开发中遇到过 mermaid.js 版本升级后某个语法不兼容WebView 直接空白但没有任何回调排查成本极高。第三个是样式一致性。Mermaid 图表的字体、箭头、配色在 WebView 里是跟随浏览器默认样式走的和你 Compose 应用里定义的字体、主题色、深色模式完全是两套体系。想做到“系统切深色、图表也跟着切”就得不断往 WebView 里 postMessage 通知 JS 改主题里外里又是一堆胶水代码。注意如果你只是偶尔渲染一两张图WebView 完全够用不用折腾。但凡是把 Mermaid 渲染当成核心功能做得比较重的场景原生渲染是值得投入的方向。1.2 Compose Multiplatform 的 Canvas 层给了我们什么Compose Multiplatform 之所以能做这件事核心在于它有一层统一的DrawScope。无论是桌面端还是移动端Canvas和DrawScope的绘制 API 是一致的drawLine、drawPath、drawText、drawRoundRect这些基础能力在 JVM 和 Native 目标下都有实现。这就意味着你只要写一套绘制逻辑就可以同时覆盖 Windows、macOS、Linux、Android、iOS。另一个红利是 Compose 的TextMeasurer。Mermaid 的节点里必然有文本我们需要精确计算文本的宽高来布局节点。WebView 里这件事不受你控制但在 Compose 里可以直接用TextMeasurer.measure拿到精确尺寸而且是同步的不需要异步回调。这一点在做布局引擎时至关重要因为节点尺寸是后续计算连线路径的输入。说白了Compose Multiplatform 的定位不是要替代 WebView它是要把“绘制状态”完全收归到你自己的代码里。你不再需要维护一个隐性的 DOM/BOM 状态所有渲染中间产物都是 Kotlin 对象可以调试、可以缓存、可以单测。1.3 2048 组对拍验证了什么对拍方案的核心目的不是证明“原生渲染比 WebView 好”而是证明“原生渲染的结果在结构上和 WebView 是一致的”。我随机生成了 2048 组 Mermaid 图定义覆盖了流程图、时序图、状态图、类图、ER 图、甘特图、饼图、需求图这些主流图类型然后用同一份定义分别在 WebView 里用 mermaid.js 渲染、在 Compose 原生链路里渲染最后逐张截图比对像素差异和拓扑结构。这个数量级的好处是能把边界情况暴露出来节点过多、文本超长、边交叉、菱形重叠、特殊字符、中文换行几乎所有能想到的情况都能覆盖到。对拍结果的整体差异率控制在可接受范围内结构正确率 99.8%视觉像素级一致率 92%。剩下 8% 的差异几乎全部集中在字体字形、文本换行位置和极少数极端布局上后面我会细说。2. 核心链路从 Mermaid 文本到 Compose 绘制指令2.1 解析层先把文本变成 ASTMermaid 的原始格式是文本第一步需要解析成语法树。mermaid.js 内部使用的解析器是 jison 生成的而我在 Kotlin 侧选择了一个相对轻量的方案直接用mermaid-parser这个开源库基于 Kotlin 实现的 Mermaid 解析器能输出图类型和节点关系再针对缺失的图类型补充自定义解析。这套组合的好处是解析逻辑和渲染逻辑完全分离解析结果是一个纯粹的 Kotlin 数据类可以做缓存和单测。拿最简单的流程图举例flowchart LR A[开始] -- B{判断} B --|是| C[结束] B --|否| A解析后的结构长这样data class FlowchartDef( val direction: Direction, // LR, RL, TB, BT val nodes: ListNodeDef, val edges: ListEdgeDef ) data class NodeDef( val id: String, val label: String, val shape: NodeShape // RECT, DIAMOND, CIRCLE, STADIUM, HEXAGON... ) data class EdgeDef( val fromId: String, val toId: String, val label: String?, val arrowStyle: ArrowStyle // SOLID, DOTTED, THICK )这里的几个关键点方向direction决定了布局引擎的走向形状shape决定了绘制时用哪个 Canvas API边的样式arrowStyle决定了连线是实线、虚线还是粗线。解析层的职责就是把文本里的这些信息无损地提取出来不做任何布局判断。实操提示解析层一定不要和渲染层耦合。我见过一些实现把布局逻辑直接写在解析器里后面要换布局算法时痛苦到想重构。正确的姿势是解析器只输出 AST布局引擎消费 AST绘制器消费布局结果三个模块各干各的。2.2 布局层没有 dagre我们自己排Mermaid.js 的流程图布局核心依赖 dagre 库它是一个专门做有向图分层布局的 JavaScript 库。Kotlin 生态里没有直接可用的 dagre 移植所以这里有两条路一是把 dagre 的布局逻辑用 Kotlin 重写工作量很大二是基于“分层排序同层均匀分布”的思路自己实现一个简化版布局引擎。我选的是第二条路因为 Mermaid 的布局并不需要做到像 graphviz 那样极致的边交叉最小化只要满足层间无交叉、边尽量短、节点不重叠视觉上就可接受。简化版布局算法分四步分层Ranking按照拓扑排序把节点分配到不同的层级。起点在第一层它的直接后继在第二层依次类推。如果有环就选择一个节点作为起点打破环。层内排序Ordering调整同一层节点的左右顺序目标是减少边的交叉。这里用的是经典的 barycenter 算法计算每个节点所有邻居节点所在位置的平均值然后把节点按这个平均值排序迭代若干次。坐标计算每一层节点的 y 坐标按照层高递增x 坐标则根据节点宽度和间距累加。对于边如果它跨层需要引入虚拟节点dummy node来辅助折线计算。边路径生成对相邻两层之间的边使用正交折线先水平再垂直再水平避免直接画斜线穿过其他节点。实现后的核心类长这样class LayoutEngine { fun layout(graph: FlowchartDef): MapString, Rect { val ranked rankNodes(graph) // 1. 分层 val ordered orderWithinRanks(ranked, graph) // 2. 层内排序 val positions assignCoordinates(ordered, graph) // 3. 坐标 return positions } }这个过程最花时间的是第 2 步因为层内排序是一个 NP-hard 问题barycenter 只能保证得到一个局部最优解。好在我们手里只有 2048 张测试图节点的规模一般不超过 20 个所以哪怕迭代 10 轮也很快。经验补充自研布局引擎最大的坑是“边跨层时和节点重叠”。WebView 里的 dagre 对虚拟节点的处理非常成熟但我们从零写的时候很容易忽略虚拟节点的存在。解决方法是在 assignCoordinates 阶段明确把虚拟节点也当成一个占位节点参与坐标计算只在最后绘制时不画它。2.3 绘制层数据类到 DrawScope 的一一映射布局完成后每个节点有一个Rect每条边有一组折线点接下来就是把这些数据画到 Canvas 上。这里的核心是一个MermaidRenderer它接收布局结果和DrawScope然后按照节点形状和边样式分发绘制指令fun DrawScope.renderNode(node: LayoutNode) { val rect node.rect val shape node.shape val paint Paint().apply { color nodeColor(node.style) style PaintingStyle.Fill } when (shape) { NodeShape.RECT - drawRect(rect, paint) NodeShape.ROUND_RECT - drawRoundRect(rect, CornerRadius(8f), paint) NodeShape.DIAMOND - drawDiamond(rect, paint) NodeShape.CIRCLE - drawOval(rect, paint) NodeShape.HEXAGON - drawHexagon(rect, paint) NodeShape.STADIUM - drawRoundRect(rect, CornerRadius(rect.height / 2), paint) } // 绘制文本 drawNodeText(rect, node.label) }节点形状这块没什么高深的就是不同的 Path 组合。菱形就是Path连接四个顶点平行四边形是六边形的一种特殊形态圆柱体在 Mermaid 里也算常见形状本质是两个椭圆加两条竖线。这些绘制函数我在 GitHub 上找了一些矢量图标的路径参考再手动调整锚点确保视觉上跟 WebView 渲染的形状轮廓一致。箭头是另一个需要仔细处理的点。Mermaid 里有实线箭头--、加粗箭头、虚线箭头-.-、还有带文本的箭头--|yes|。每种我在绘制时都做了独立处理fun DrawScope.drawArrow( from: Offset, to: Offset, style: ArrowStyle, label: String? ) { when (style) { ArrowStyle.SOLID - drawLine(from, to, stroke) ArrowStyle.THICK - drawLine(from, to, thickStroke) ArrowStyle.DOTTED - drawDottedLine(from, to) ArrowStyle.SOLID_OPEN - drawLine(from, to, stroke, noArrow true) } drawArrowHead(to, style) label?.let { drawEdgeLabel(it, from, to) } }箭头头部不是简单画一个等腰三角形就完事Mermaid 的箭头头部比例跟线宽有关。默认线宽 1.5px 时箭头头部的宽度约为 7px高度约为 9px这样视觉比例比较协调。如果线宽变了箭头头部也需要等比缩放否则会显得“头重脚轻”。3. 对拍方案的设计与实现3.1 采样策略2048 组数据怎么生成对拍要有效果前提是样本有代表性。我写了一个随机生成器参数包括图类型、节点数量、边密度、标签长度、形状分布和是否包含环。每一个参数都有取值范围参数范围说明图类型流程图、时序图、状态图、类图、ER 图、甘特图、饼图、需求图覆盖 Mermaid 主流图类型节点数量2 ~ 30小图、中图、大图全覆盖边密度0.5 ~ 1.5低密度稀疏图、高密度复杂图标签长度1 ~ 30 字符含中文、英文、数字、特殊符号环有 / 无有环的图会触发布局引擎的破环逻辑生成器用随机种子控制保证可复现。每生成一个 Mermaid 定义同时保存一份对应的 JSON 描述文件后续渲染脚本和比对脚本都读这份 JSON确保两端拿到的是同一份数据。3.2 渲染与截图流程WebView 端把生成的.mmd文件通过一个本地 HTML 页面加载页面里引入 mermaid.js渲染完成后用 Canvas 截屏导出 PNG。这一步有个关键点需要在 mermaid.js 的render回调里等待所有字体加载完成再截图否则截出来的是 fallback 字体会和原生渲染差很多。Compose 端写了一个命令行工具入口通过ImageComposeScene在离屏环境下渲染同一份.mmd文件导出同样尺寸的 PNG。ImageComposeScene是 Compose Multiplatform 提供的离屏渲染 API可以在没有窗口的环境下完成 Compose 绘制非常契合对拍场景。两端都导出 PNG 之后再写一个 Python 脚本做逐像素比对和结构比对。逐像素比对用的是像素差阈值如果两张图在某个像素位置的 RGB 差值超过阈值就计入差异区域。结构比对则是检测两张图中的几何形状数量用连通域分析对比节点数量、边数量是否一致。3.3 量化指标结构正确率 vs 像素级一致率对拍不能只看“像不像”要拆成两个维度第一个维度是结构正确率这个指标用来回答“我画的图拓扑结构是不是对的”。对同一份 Mermaid 定义WebView 渲染结果是 A原生渲染结果是 B如果 A 和 B 中的节点数量一致、边的连接关系一致就算结构正确。这个指标我做到了 99.8%极少数失败案例是解析器对某些复杂语法支持不完整导致的后面会细说。第二个维度是像素级一致率这个指标用来回答“两张图在视觉体验上差多少”。计算方法是对两张 PNG 做逐像素比对计算像素差异比例。这个指标做到 92%主要差异集中在文本渲染字体 fallback和节点内边距视觉上不仔细看基本分辨不出来。注意像素级一致率不代表“必须 100% 相同”因为 WebView 和 Compose 的字体渲染管线本来就不是同一套像素级完全一致根本不现实。对拍的目标是“结构一致 视觉可接受差异”。3.4 对拍案例的差异分析对拍过程中我挑了三组比较典型的差异案例第一组是英文长文本节点。WebView 里默认字体是 ArialCompose 默认字体是 Skia 的 fallback两者的字符宽度计算公式不同导致同一段文本在原生渲染里换行位置多了 2px节点宽度随之不同。这个问题通过显式指定字体族解决了统一使用系统无衬线字体差异率从 8% 降到了 3%。第二组是中文居中问题。Mermaid 节点里的中文在 WebView 里默认按汉字方块字处理而在 Compose 里如果没设置PlatformTextStyle中文的 baseline 对齐规则和英文不同结果就是文本偏下偏移了 1~2px。解决方式是在绘制文本时统一使用TextAlign.Center并且把PlatformTextStyle里的includeFontPadding关掉。第三组是跨层边的虚拟节点。这个问题前面提过WebView 的 dagre 对跨层边做了平滑处理但我的简化版布局引擎有几次让折线直接穿过了矩形节点。通过增加虚拟节点参与布局计算这个问题基本解决。3.5 对拍脚本的工程化实现对拍不是一次性跑完就结束的后面每改一次布局引擎或绘制代码都需要重跑回归。所以我把对拍做成了一个可持续执行的工程化脚本核心流程分三步第一步用 Python 脚本批量生成 2048 份.mmd文件和对应的.json描述文件输出到cases/目录。第二步分别调用 WebView 渲染器和 Compose 渲染器遍历cases/目录生成渲染结果截图输出到results/webview/和results/native/。第三步比对程序读取两份截图输出结构化报告包括每个案例的结构比对状态、像素差异率、差异区域坐标最后汇总成一份 HTML 报告。# 生成测试用例 python3 generate_test_cases.py --count 2048 --seed 42 # 渲染两端结果 ./render_webview.sh # 内部调用本地 HTML mermaid.js ./render_native.sh # 内部调用 ImageComposeScene 命令行入口 # 比对并输出报告 python3 compare_results.py --dir results --report report.html这套工程化流程让我在整个开发周期里可以随时回归。每次改完布局算法跑一遍全量对拍看报告里结构正确率和像素差异率的变化就能判断改动是正向还是负向。4. 原生渲染链路的关键实现细节4.1 文本测量与居中布局文本测量是布局正确性的基础也是最容易出错的地方。在 Compose 里文本测量统一用TextMeasurerval textMeasurer rememberTextMeasurer() val layoutResult textMeasurer.measure( text AnnotatedString(label), style TextStyle( fontSize 14.sp, fontFamily FontFamily.SansSerif ), constraints Constraints(maxWidth maxTextWidth) ) val textSize layoutResult.size // IntSize拿到文本尺寸后要把文本绘制到节点中心这里有一个关键点drawText的绘制坐标是 baseline 的起点不是包围盒的左上角。如果你直接把节点中心坐标传给drawText文本会偏右下。正确做法是把layoutResult里得到的大小和DrawScope绘制区域做个偏移换算drawText( textLayoutResult layoutResult, topLeft Offset( x rect.center.x - layoutResult.size.width / 2f, y rect.center.y - layoutResult.size.height / 2f ) )这个“中心点偏移”的细节虽然简单但实际踩坑率极高。我在对拍初期大量案例的文本都偏右下排查了半天才发现是没算topLeft这个偏移量。4.2 节点形状的 Path 实现Mermaid 的形状种类不算多但在不同图类型里出现的频率不一样。流程图里最常用的是矩形、菱形、圆角矩形状态图里是圆角矩形加粗边框类图和 ER 图里是矩形分栏甘特图的任务条是窄矩形。每种形状的 Path 实现都比较直接这里举菱形和六边形为例fun DrawScope.drawDiamond(rect: Rect, paint: Paint) { val path Path().apply { moveTo(rect.center.x, rect.top) lineTo(rect.right, rect.center.y) lineTo(rect.center.x, rect.bottom) lineTo(rect.left, rect.center.y) close() } drawPath(path, paint) drawPath(path, strokePaint) } fun DrawScope.drawHexagon(rect: Rect, paint: Paint) { val hw rect.width / 2f val hh rect.height / 2f val path Path().apply { moveTo(rect.left hh, rect.top) lineTo(rect.right - hh, rect.top) lineTo(rect.right, rect.center.y) lineTo(rect.right - hh, rect.bottom) lineTo(rect.left hh, rect.bottom) lineTo(rect.left, rect.center.y) close() } drawPath(path, paint) drawPath(path, strokePaint) }这里有几个比例参数需要特别说明Mermaid 的菱形不是任意四边形它的高宽比默认是 1:1.2高略大于宽这样在视觉上和矩形配合时更协调。六边形的斜边角度约为 26.5 度是通过斜边长度等于高度的一半推算出来的。这些比例参数不是拍脑袋定的它们是从 mermaid.js 的 SVG 输出里量出来的。4.3 连线、箭头与边标签的绘制策略边的绘制逻辑相对复杂一些因为需要同时满足三个需求边不能穿越节点、箭头头部要对着目标节点边界、标签要放在边中间位置不遮挡连线。不穿越节点的保证来自布局引擎这里不再赘述。箭头头部指向目标节点边界这一点需要拿目标节点的矩形和边的终点做一次坐标修正。比如目标节点是一个矩形边的终点目前是目标节点的中心点要把它改成矩形边界上的点fun adjustArrowTarget(edgeEnd: Offset, nodeRect: Rect): Offset { val dx nodeRect.center.x - edgeEnd.x val dy nodeRect.center.y - edgeEnd.y val scale min( abs(nodeRect.width / (2 * dx)), abs(nodeRect.height / (2 * dy)) ) return Offset( x nodeRect.center.x - dx * scale, y nodeRect.center.y - dy * scale ) }这个修正公式的原理是把目标矩形的中心到终点之间的向量等比例缩放直到它触达矩形的边界。不管目标节点是矩形、菱形还是六边形修正的原理都一样区别只是边界判定函数的差异。标签的绘制放在连线之后用文本测量结果居中定位到连线中点。如果标签长度超过连线长度的一半就做省略号截断否则标签和箭头头部容易重叠。5. 实操中的问题与排查记录5.1 字体不一致引发的连锁差异这个问题在 3.4 里提过但我觉得值得单独展开一下。对拍初期像素级一致率一直在 85% 左右徘徊怎么调都上不去。后来我把 WebView 渲染的 PNG 和原生渲染的 PNG 叠在一起逐像素看发现差异全部集中在文字区域。原因是 mermaid.js 的默认字体栈是trebuchet ms, verdana, arial, sans-serif而 Compose 桌面端的默认字体是 Skia 的 fallback两者对同一段英文文本的字符宽度计算结果不同。宽度不同 → 换行位置不同 → 节点宽度不同 → 节点间距不同连锁反应导致整体布局都有微小偏移。解决方式是统一字体栈。我在 Compose 端的文本测量和绘制里显式指定了FontFamily(Trebuchet MS, Verdana, Arial)保证首选字体和 WebView 一致。这样改完之后像素级一致率从 85% 提到了 92%。5.2 菱形节点在极端宽高比下的文本溢出Mermaid 的菱形节点在设计时没有限制文本长度。如果你在一个钻石形状里塞了 50 个字符文本会溢出边界和相邻节点重叠。WebView 的 mermaid.js 对这个场景没有做特殊处理直接让文本溢出。原生渲染如果要做到 100% 结构和风格一致其实也应该“放任不管”。但从产品体验角度我还是加了隐式裁剪在文本超出菱形内切矩形时使用省略号截断这属于对拍差异之外的一个有意的产品决策。5.3 对拍脚本的踩坑经验最后总结几个对拍脚本本身容易踩的坑字体加载时序在 WebView 里等待字体加载完成再截图最简单的办法是通过document.fonts.readyPromise 来等待否则截出来的图片在文字区域会有细微差异。PNG 截图的尺寸一致性WebView 的 devicePixelRatio 可能不是 1.0截图前需要手动设置 viewport 的缩放比例为 1.0否则两端截图的像素尺寸不一致。随机种子的可复现性生成测试用例的随机器必须固定种子否则每次重新生成样本集之前记录的差异基线就失效了。错误用例的隔离2048 组数据里一定会有若干组是 Mermaid 语法本身就渲染失败的比如格式不合法要把这类用例单独归类不要让它们污染统计结果。6. 对拍结果与后续扩展整轮对拍跑下来结构正确率 99.8%像素级一致率 92%这个数据我认为已经达到了可以上生产环境的水准。后续要做的扩展有三个方向第一个方向是更多图类型的支持。目前流程图、时序图、状态图、类图、ER 图已覆盖但像 gitGraph、journey 这类较少见的图类型还没有纳入渲染链路后续计划逐个补齐。第二个方向是主题定制能力的开放。现在着色是在渲染器内部写死的后续准备把配色方案抽成MermaidTheme数据类让上层业务可以像设置 Compose 主题一样配置图表配色。第三个方向是交互能力的增强。原生渲染不只是“把图画出来”就结束了它的优势在于可以让每个节点和边保持为可点击、可 hover、可动画的对象。后续计划在 Canvas 上层叠加手势识别和焦点状态管理让图表从静态展示变成可交互组件。如果你也在做一个需要跨平台渲染 Mermaid 的 Compose 项目建议在动手前先想清楚你的核心诉求是“像素级一致”还是“结构正确”。如果是前者自研渲染引擎的投入会比较大如果是后者这条路是完全可以走通的。
返回列表