把 Visio 和 Axure 塞进一个 1.5 MB 的 exe:Visioxure 的架构与取舍

0. 先给结论

Visioxure 是一个「原生壳 + Web 内核」的 Windows 桌面应用:C++17/Win32 负责窗口、文件和 .vsdx 编解码,WebView2 里的原生 JS + Canvas 2D 负责编辑器和原型运行时。

它没有 Electron 的 100 MB Chromium,也没有自己从头写一套 2D 图元库。整条链路里我认为最值得抄的三件事是:

  1. 一份事实源:文档模型只存在于前端 JSON,后端完全无状态。
  2. 一条渲染管线 :编辑器、PNG、SVG、PDF、HTML 导出、原型预览共用同一份 drawScene。
  3. 用像素断言兜住"看着对":导出与画布的一致性靠离屏采样断言,而不是靠肉眼截图比对。

下面分别是它们怎么落地,以及踩过的坑。


1. 形态:为什么是 WebView2 + Canvas 2D

摆在面前的三条路:

方案 体积 代价
Electron 100 MB+ / 150 MB 内存 为了一个画图工具背一个 Chromium
纯原生 Direct2D 自绘图元库 极小 文本排版、输入法、光标、滚动、DPI 全得自己写------这才是真正的无底洞
原生壳 + WebView2(本项目) 1.46 MB 依赖系统 WebView2;只有 Windows

选第三条的核心理由不是"Web 前端好写",而是图形编辑器真正难的从来不是画矩形,而是文本折行、字体回退、输入法、命中测试、缩放下的描边宽度、DPI 对齐。这些 WebView2 已经替我解决了一大半,剩下的是我能控制的量。

至于为什么是 Canvas 2D 而不是 DOM 元素画形状:一次拖拽要重算几千个图元的命中与变换,DOM 的布局与合成开销不可控;而 Canvas 2D 的「形状 → 像素」只需一个 Path2D 级别的思维模型,缩放就是把 zoom 乘进坐标里(sx = x * zoom + ox),没有第二套坐标系。

单文件分发是这样做到的:

text 复制代码
build.ps1
  └─ pack_resources.py  扫描 frontend/ → 生成 resources.rc(30 个资源,数字 ID)
  └─ MSVC 把 .rc 编进 exe
运行时
  └─ VirtualFS 从 exe 资源段读出文件内容(内存里)
  └─ WebResourceRequested 拦截 https://tauricpp.app/* → 按 MIME 返回 VirtualFS 里的字节

结果是:磁盘上没有 .js 文件,临时目录里也没有------前端代码没有"被拷走"的落地形态。开发时用 build.ps1 -DevMode 关掉嵌入,直接从磁盘读 frontend/,改完刷新即可,不必重新编译。


2. 职责边界:前端是唯一事实源,后端无状态

text 复制代码
┌──────────────── WebView2(编译期嵌入)───────────────┬────── C++17 ──────┐
│ model.js      文档 JSON(唯一事实源)                 │ main.cpp 命令表    │
│ renderer.js   Canvas 2D 渲染 / drawScene             │ vsdx/package  OPC  │
│ tools.js      选择 / 变换 / 连接线 / 就地文本          │ vsdx/writer   JSON→XML │
│ interact.js   交互运行时(预览与导出共用)             │ vsdx/reader   XML→JSON │
└──────────────────────┬──────────────────────────────┴───────────────────┘
                       │ invoke(cmd) ⇄ emit(event)(11 条 Bridge 命令)

后端只做四类事:OPC zip 编解码、原生对话框、最近文件、写盘。11 条命令里没有一条碰文档结构:

cpp 复制代码
bridge.test        app.set_title      app.get_info     app.open_url
file.open_dialog   file.save_dialog   app.recent_files log_error
vsdx.save          vsdx.load          export.write

这个边界带来的好处很直接:

  • 撤销、导出、交互、测试全在一个语言栈里(JS),改一个字段不需要同时改 C++ 的结构体;
  • C++ 侧无状态,不存在"两边各持一份模型、谁覆盖谁"的并发与生命周期问题;
  • 保存就是把 Model.serialize() 的字符串丢给 vsdx.save,读取就是把 vsdx.load 返回的 JSON 丢给 Model.load。

代价是文档要过一次 Bridge。但 800×1100 的原型文档通常不到 1 MB,invoke 的序列化开销在这个量级可以忽略;真到了几十 MB 的图纸,我们再谈增量同步------现在不值得为它引入复杂度。


3. 撤销:快照式,而不是命令模式

这是我最不"教科书"的一个选择:

js 复制代码
run(label, fn) {
  const before = VX.Model.serialize();   // 全量 JSON
  const v0 = view();                     // 当前页 + 选中
  fn();
  const after = VX.Model.serialize();
  if (after !== before) push(label, before, v0);   // 没变化就不入栈
}
  • 全量快照,上限 200 步;
  • 每个快照同时记录视图上下文(当前页 + 选中 id),回放时一起恢复;
  • 拖拽类操作用 begin() / commit(label):开始时记一次,结束时比一次,没变化就丢弃;
  • 回放走 Model.load(json),再把仍然存在的形状重新选中。

为什么不用命令模式(每个操作实现 undo())?因为这个编辑器到 1.0 已经有几十种编辑操作:拖拽、缩放、旋转、布尔、组合、解组、连接线重粘附、手动折线、文字编辑、图层、对齐分布、面板状态切换、变量改名......每一种都要配一个逆操作,漏一个就是"撤销后文件坏了"。而全量快照只用了 20 行代码,就让所有新操作天然可撤销------新增功能时我不需要再想撤销这件事。

代价是内存和序列化时间,对策有三条:before !== after 去重、上限 200 步、拖拽过程只在开始/结束各序列化一次(不是每帧)。


4. 渲染管线里的三个细节

4.1 最小描边宽度只属于编辑态

缩放 20% 时,1.5 px 的线细到看不见。所以编辑态有一个下限:

js 复制代码
function strokeWidth(w, zoom, exported) {
  if (exported) return Math.max(w || 0, 0.01);   // 导出:真实世界线宽
  return Math.max(w || 0, 1 / (zoom || 1));      // 编辑:至少 1 屏幕像素
}

这个下限如果照搬进导出,就会出事:2x / 3x 的 PNG 是靠 zoom = 2 / 3 放大实现的,同一条世界线宽会被不同的下限夹住,导致倍率越高线越细 。所以 opts.exported 一开,下限直接失效。

4.2 字体回退链:中文不能排在前面

js 复制代码
return `${bold}${size}px '${family}', Arial, Helvetica, 'Microsoft YaHei', sans-serif`;

把 Microsoft YaHei 提到前面,拉丁字母的字距会明显变宽,文本行宽和折行位置就跟 Visio 差一截。所以顺序是"原字体 → Arial → Helvetica → 雅黑",让拉丁字优先落到西文字体,缺字才落到 CJK。

4.3 别让一个不存在的方法带走整帧

ctx.roundRect 在老版本 WebView2 里没有,而直接调用不存在的绘制方法是抛异常 ------异常发生在 drawScene 中间,意味着"这一帧后面的形状全部不画"。表现出来就是"画着画着有些形状消失了",非常难查。

现在所有这种地方都是特性探测 + 手写兜底路径(arcTo 拼圆角)。同类教训还有一例:拖动连接线端点后整条线消失------根因是布线返回了空值,moveTo(undefined, NaN) 抛异常中断了整帧。


5. VSDX:以 Visio 兼容为壳,装自己的语义

.vsdx 本质是 OPC(一个 zip)+ VisioML。要"Visio 能打开我存的、我能打开 Visio 存的",绕不开四件事:

5.1 坐标系要翻两次

Visio 原点在左下、Y 向上 ,PinX/PinY 是形状中心点而不是左上角,单位是英寸。内部模型是左上原点、Y 向下、单位 px(96 dpi)。于是写入时:

text 复制代码
PinX = (x + w/2) / 96      PinY = (pageH - y - h/2) / 96

读取时反过来,还要处理 LocPin 带来的局部坐标换算。这是最容易写错、也最容易"看起来对但旋转 90° 后全错"的一段,我给它配了专门的 round-trip 断言。

5.2 真实 Visio 文件的三道坎

  1. 样式表继承 :大量形状的 FillForegnd 值是 F="Inh",真实颜色在 StyleSheets 里,还要按 FillStyle/LineStyle/TextStyle 三组分别继承。
  2. 母版几何合并 :Masters/Master 里的几何是局部坐标,实例只给 PinX/PinY/Width/Height;不合并就只剩空框。
  3. 1-D 连接线 :连接线不是矩形,是 BeginX/BeginY + EndX/EndY 加上页面级 Connects/Connect。

还有圆弧:ArcTo 的 A 是弓高 ,EllipticalArcTo 的 A/B 是弧上一点。我的解法是"起点 + 弧上一点 + 终点 → 三点定圆 → 按 15° 采样成折线"。圆角矩形和流程图的胶囊形(Visio 的"开始/结束"框)全靠它才不变直角。

5.3 自己的语义放哪:User 段

Visio 的 Shape/PageSheet 允许挂自定义单元格(User.xxx)。于是"Visioxure 私有语义"全部住在这里:

text 复制代码
User.VXId         形状 id(跨软件维持引用关系)
User.VXGroup      组合链(因为写出去的是展开后的独立形状)
User.VXGlue       连接线粘附的端点
User.VXRoute      手写折线
User.VXInteractions / VXPanel      交互与动态面板状态
User.VXVars / VXGlobalVars         页面变量 / 全局变量
User.VXNotes / VXLinks / VXScripts 注释 / 链接 / 脚本
User.VXGrid       页面网格样式(小 / 大 / 网点 / 无)

关键细节:除了值 V 还要写公式 F 。Visio 只认公式,纯 V 的自定义单元格在 Visio 里另存一次就会被清成 0。所以文本/JSON 类一律写带引号的字符串公式(内部引号双写),数字类写裸数字。

这条边界的承诺我写进了文档:不承诺母版与主题的写回保真(那是无底洞),只承诺互开不炸、私有数据往返不丢。

5.4 两个把我坑惨的几何语义

坑一:子路径开合不能从"有没有填充"推断。

渲染器曾经用"有填充就闭合"来决定路径,于是形状库里那批无填充的多边形 (三角、梯形、菱形、箭头)全缺一条边。根因是把两个正交的语义绑在一起了。现在的模型里显式声明 path.closed[],缺省闭合;.vsdx 读写同步保留该语义,并用像素探针双向守住。

坑二:局部坐标的图形必须锚在"设计尺寸"上。

菱形/三角/梯形/布尔结果的几何存在 path.subpaths(局部坐标)。拖动下边手柄只改包围盒 h,图形照原样画------表现成"选框变低了但图形没变"。修法是把局部坐标归到 path.design = {w, h},缩放系数只由 Geometry.pathScale 一处提供,渲染、命中、多边形化、writer 四个消费端都调它,谁也不许自己算。


6. 导出:四种产物,一套渲染

导出是这套架构收益最明显的地方。四种产物的生成都在前端,因为只有前端有画布和渲染器:

产物 做法
PNG 离屏 canvas 按 scale 放大 → 调同一个 drawScene → toDataURL
SVG 同一套几何与样式字段,输出 <path> / <text><tspan>,圆角/虚线/箭头逐项对齐画布
PDF 极简 PDF writer:每页渲成 JPEG → DCTDecode 嵌入,1 px = 0.75 pt
HTML 文档 JSON + renderer.js + interact.js + 一个小外壳,全部内联成单文件

落盘链路刻意做得"笨":

js 复制代码
前端产物(base64 或文本)
  → file.save_dialog  只问路径
  → export.write      解一次 base64 写盘,不关心格式
  → 没有 Bridge(浏览器直开 / E2E)时退化成 <a download>

<a download> 那条退化路径不是为了用户,是为了测试:E2E 在静态服务里跑,能直接拿到产物内容做像素断言。

一个真实 bug:导出的页面为什么不是白的

症状很典型:编辑器里页面是白的,导出的 HTML 整页发灰。

根因在渲染器:

js 复制代码
// 旧代码:exported 一开,页面底色和「编辑器装饰」一起跳过
if (!(opts && opts.exported)) {
  shadow(); fillStyle = page.background; fillRect(...);   // 底色 + 阴影 + 描边
}

而 HTML 运行时给 drawScene 传的正是 exported: true(因为要去掉阴影和边框),于是页面底色也没画 ,露出了运行时预铺的舞台灰 #e9ebee。

修法不是再加一层 if,而是把三件事拆成正交开关:

  • exported:关掉阴影、页面描边、最小线宽下限;
  • paintBg:是否铺页面底色(默认跟随 exported,HTML 运行时显式传 true);
  • grid:是否画网格(导出永远 false)。

并在 E2E 里补了一条断言:导出 HTML 的空白区域,每个通道都要 ≥ 250。这样下次谁再"顺手"跳过底色,测试会先发现,而不是等用户说"颜色不对"。


7. 测试:三层,越靠下越便宜

层 工具 跑一次 守什么
C++ 单元 vsdx_test + ctest 秒级 JSON → VSDX → JSON 逐字段 round-trip(特殊字符、透明填充、图层、变量、网格、错误输入)
纯逻辑 headless Node + vm 秒级 模型 / 渲染调用序列 / 布线,用假 CanvasRenderingContext2D 录调用
端到端 Playwright(复用本机 Edge,不下载内核) 十几秒 88 项主干断言 + 65 项导出断言,含像素探针

几个实践:

  • 中间那层最被低估 。这一轮做"页面网格四档",我先写了一个 14 条的 Node harness:直接 vm.runInThisContext 加载 model.js 和 renderer.js,用一个记录型 2D context 断言"大网格描了两种颜色的线、网点只 fill 不 stroke、grid:false 时一条线都不画"。不用起浏览器,改一行跑一次。
  • 像素断言比截图比对便宜 。"这个位置到底画没画出来"用 getImageData 采样就够了,不需要视觉回归基线;check_stencils.js / check_path_resize.js 都是这么守住几何语义的。
  • 截图只用来给人看。CI 里截图比对会因为字体版本抖动,而像素探针只问一个确定的问题:这个坐标是不是墨色。

8. 还没做的(技术债清单)

  • 大文档性能:现在是单 canvas + dirty 全量重绘,没有静态层离屏缓存、没有空间索引,500+ 形状没验过;
  • 正交布线还是"固定引导段 + 曼哈顿折线",A* 避障未做;
  • 图片资产(Foreign + media)读写未做,模型里连 assets[] 都还没启用;
  • 中继器、组件库(母版/实例)未做;
  • 参考线只存在内存里,保存后丢(User.VXGuides 排在 A5);
  • 只有一份亮色主题;关窗未保存确认(app.close_confirm)还没接。

这些在 CODEPLAN.md 里按 S/A/B/C 排了优先级,每条都带验收标准------写下来比记在脑子里更不容易自欺。


9. 小结

如果只留三句话:

  1. 混合形态是划算的:原生壳给你文件、窗口、系统集成;Web 内核给你文本、输入法、图形栈。1.46 MB 换掉一个 Chromium,值。
  2. 事实源只能有一个:前端 JSON 是模型,后端只做编解码与 IO,两边就不会漂。
  3. 一致性是可以测的:同一份渲染器 + 正交开关 + 像素断言,把"导出看起来一样"变成"导出的每个像素都可验证"。

仓库:github.com/Emsoro/Visioxure / gitee.com/masonwu21/Visioxure。下一篇想写的是"怎么把 Visio 母版的局部几何合并进自己的模型",如果正好你也在这条路上,欢迎来对答案。

相关推荐
QianhangQianping1 小时前
一块大屏的数据在浏览器里怎么变成画面:背景、子屏、前景三层结构
前端·低代码·架构·编辑器·数据可视化·大屏可视化
每天一道题3 小时前
微服务为什么会发生级联故障?从 Timeout、Retry 到熔断、限流与 Backpressure
微服务·云原生·架构
充值内卷3 小时前
Visio、Axure、draw.io、ProcessOn、墨刀、Figma:六款工具哪个该干哪件事
axure·draw.io·processon
ESDWAN3 小时前
SD-WAN 是什么?工作原理、适用场景与选型要点
开发语言·网络·数据库·架构·php
Figo_Cheung4 小时前
Figo基于结构空间算法假设(SSAH)的生成式大模型优化范式与工程实现边界研究——提出适应AGI架构演化的自适应拓扑感知表征机制
算法·架构·agi
FPGA小迷弟4 小时前
FPGA 与无线电:从软件定义无线电到 5G 基带的硬件实现之路
5g·fpga开发·架构·dsp开发·fpga·无线电
你看我名字4 小时前
Kafka 架构、消息可靠性与生产运维:从分区模型到故障治理
运维·架构·kafka
Decay__4 小时前
03_MySQL 存储引擎与存储管理 —— InnoDB 深入、表空间/ibd、binlog/redo/undo 与双写缓冲
运维·数据库·sql·mysql·架构
bksczm4 小时前
MySQL程序架构
数据库·mysql·架构