跳转至内容
  • 版块
  • 最新
  • 标签
  • 热门
  • 世界
  • 用户
  • 群组
皮肤
  • 浅色
  • Brite
  • Cerulean
  • Cosmo
  • Flatly
  • Journal
  • Litera
  • Lumen
  • Lux
  • Materia
  • Minty
  • Morph
  • Pulse
  • Sandstone
  • Simplex
  • Sketchy
  • Spacelab
  • United
  • Yeti
  • Zephyr
  • 深色
  • Cyborg
  • Darkly
  • Quartz
  • Slate
  • Solar
  • Superhero
  • Vapor

  • 默认(Flatly)
  • 不使用皮肤
折叠
品牌标识

一键科技

  1. 主页
  2. 博客
  3. 给宏编辑器加一个图形化模式:两天、25 轮迭代,和一次 0xC00000FD

给宏编辑器加一个图形化模式:两天、25 轮迭代,和一次 0xC00000FD

已定时 已固定 已锁定 已移动 博客
图形化编程块编辑器开发日志Qt6
1 帖子 1 发布者 34 浏览
  • 从旧到新
  • 从新到旧
  • 最多赞同
回复
  • 在新帖中回复
登录后回复
此主题已被删除。只有拥有主题管理权限的用户可以查看。
  • A
    A
    admin
    编写于 最后由 编辑
    #1

    「加个图形化编程页麻烦吗?」

    这是 10 月 20 日中午我收到的一句话。当时的回答是:不麻烦,但得先看清手里有什么——因为图形化编辑器最容易做成的样子,是画得挺好看、生成出来的脚本跑不了。

    两天之后,它长这样:块可以拖、可以拼、能撤销、能复制粘贴、能按 Ctrl 滚轮缩放,切回文本模式看到的就是块生成的规范化脚本,编译、校验、保存、烧录全部复用原来的链路。

    键鼠助手 5.0 的图形化编程界面:左侧宏目录,右侧块画布

    成品:左边是宏目录,右边是块画布。中间那段 当 … 执行 是容器块,条件由值块拼成(count 小于 3 且 鼠标是否按下 1),下面跟着延时、赋值、移动、点击,最后一段是自定义函数 on_timer1 的定义块。

    这篇文章记录这两天的实际过程:做了哪些决定、踩了哪些坑、时间花在哪。文末有一张按开发日志回填的时间账。


    一、先别写代码,先盘点

    动手之前先回答一个问题:这个「Lua 宏」到底是不是 Lua。

    盘完的结论是——不是。它是一个封闭的小 DSL,解析器同时支持两套方言:

    • Lua 风格(对外文档化的语法):local x = v、if..then..elseif..else..end、while..do..end、for i=a,b,step..do..end、function f(p)..end、do..end、return、break、-- 注释;
    • 遗留 BASIC 风格(老脚本兼容):If/Then/EndIf、While/Wend、For/Next、Sub/End Sub、Dim、Goto、Rem、Call、Exit Sub。

    内置能力面是两张静态表:15 条命令 + 9 个函数,每条都带分类、参数模式、参数种类、参数个数上下限、说明文本。

    这个盘点值钱的地方在于它直接给出了方案:块调色板不用手写,可以从命令表派生;而唯一的缺口是 AST → 文本生成器(unparser),得新写。

    盘点还顺手圈出一条红线:内置函数的编号是编译器按命令表声明顺序写进字节码的 uint16 操作数,只能在表末尾追加,不能插入或删除。所以图形化这一层绝不能碰编译链路,它只能是「块树 → 文本 → 原有 parser/compiler」的上面一层。

    一个决定性的判断:图形化只是入口,不是新引擎。这个边界守住了,后面所有工作都只在一个 UI 模块里发生,设备端字节码、VM、函数编号零改动。

    二、第二条决断:不塞 Web 引擎

    做块编辑器最省事的路是塞一个 Web 引擎进去,跑现成的 Blockly。这条路被否掉了,理由很实际:

    • 安装包会凭空涨几十兆,启动慢一截;
    • 多一个运行时的版本问题(Qt WebEngine 的 Chromium 更新、显卡驱动兼容);
    • 复制粘贴、字体渲染、高分屏缩放全部隔着两层,和原生控件的行为对不齐。

    于是决定:原生 Qt 自绘,视觉和几何照 Blockly 移植。代价是每一根连接齿、每一段圆角都要自己算;收益是这套东西完全可控——后来做缩放时,「所有几何都在逻辑坐标里」这件事能成立,正是因为画布是我自己的。

    三、布局和绘制必须分开

    一开始是很朴素的一层:一个函数量尺寸、一个函数画。很快就撞墙了——插入线要落点、命中测试要知道点到哪个参数、内嵌的编辑框要贴到某个字段上,这些全都要「尺寸从哪来」。

    所以最终分成三层:

    层 职责 为什么不合并
    block_render_info 纯几何与文案,算出一棵布局树 命中测试、插入线、内嵌控件几何全都复用它,不能只有绘制时才存在
    block_drawer 只按布局树画,不改任何状态 绘制无副作用,缩放时才能只换一个变换矩阵
    block_render_constants 齿高、圆角、行高、内衬这些形状常量 改一版视觉只动一处

    撞墙的具体形态是「凹凸连接件不吻合」:上一版齿的曲率半径和缺口对不上,块叠起来有一条白缝。最后是对着参考实现的源码逐段校正几何参数才解决——这也是第一天晚上 20:25 那条日志的由来。

    右侧的块目录直接复用了同一套绘制代码,所以「面板里长什么样」和「拖进画布后长什么样」不会有第二份实现:

    右侧块目录:每个条目就是一个缩小的块,带参数名与说明

    块目录的条目不是截图,也不是另画的示意图——它调用的是画布那套 block_render_info + block_drawer,只是画在一个小画布上。

    四、真相只能有一个

    命令表原本只有一个 displayName,那是文本模式里真实可键入的调用名,不能改。但块面上要显示中文名,右侧目录也要中文名。

    一开始的写法是在块的渲染里硬编码一套中文。这套东西立刻就脱轨了:同一个命令,块面上叫「按下按键」,目录里叫「按下」,诊断里又叫「按键按下」。改一处,另两处不知道。

    改成:命令表上加一个 chineseName,块面、目录、提示共用;文本模式继续用 displayName。一处声明,三处消费。

    同样的思路后来用在表达式上,而且更极端:

    • 槽里的文本是唯一真相,AST 是它的一次投影,槽文本变了才重建;
    • 拖一个值块进来,是替换子树,不是往字符串里追加。

    第二条是血的教训。最早为了省事,「把函数拖进条件槽」实现成往槽的文本里拼字符串,结果 a + 1 拖两次变成 a + 1 + 1,而且撤销回去再看,表达式树已经和文本对不上了。结构化之后这类问题整类消失:拖拽只发生在树的两颗子树之间,文本只是它的输出。

    五、剪贴板:不要用文本当中间格式

    Ctrl+C / Ctrl+V 的第一版想法很自然:把选中的块生成脚本文本,粘贴时再解析回来。这条路走不通,因为文本层丢了信息:

    • 「可选参数槽未启用」和「槽里是空的」在文本里长得一样;
    • if 分支的启用状态、被折叠的分支,文本表达不了;
    • 往返一次,块的类型信息(哪一段是用户子程序调用、哪一段是自定义函数)就退化成函数名。

    于是剪贴板走的是「整棵块树序列化」:自定义 MIME 类型 application/x-kma-blocks,内容是 JSON,带格式版本号。同时副带一份等价脚本文本——这样粘到记事本里也讲得清,从纯文本粘回来时走 parser 兜底,至少不会丢得莫名其妙。

    一个可以复用的结论:块的复制粘贴不要走「块 → 文本 → 块」。文本是给人看的产物,不是传输格式。

    六、那圈看不见的选中框

    多选本身不难:按住 Ctrl 点一下切换选中,选中集合是个保序去重的数组,主选中恒等于第一个(粘贴位置、调色板新块都跟着它)。真正的坑在「看不见」。

    选中框最初用的是主题的高亮色。逻辑上完全正确——主题换肤自动跟随。但在浅色主题下,那个高亮是淡蓝的 1px 细线,压在蓝色的块体上,肉眼几乎无法分辨。用户反馈是「按了 Ctrl 点,不知道有没有选中」。

    最后改成固定配色:外圈琥珀 3px,内圈深棕 1px 做内衬,深浅主题下都压得住。教训是主题色不等于可见色,选中这种强反馈的视觉不要交给定主题去决定。

    七、缩放:所有几何只能有一套坐标系

    这是整个两天里最值得写的一段。

    缩放最直觉的做法是「把所有尺寸都乘一下系数」。这么做会在三个地方同时崩:命中测试、拖拽落点、内嵌编辑框的位置。因为一旦有两套坐标,任何一处忘了换算,就是「看着点在 A 上、选中了 B」。

    所以定了一条规矩:布局、命中、拖拽,全部在逻辑坐标里算;缩放只作用在两个入口——绘制的 QPainter::scale(),和把鼠标位置换算成逻辑坐标的那一个函数。这条规矩守住之后,缩放本身变得很简单。

    真正难的是那些「看起来该跟着缩,但其实不能缩」的东西:

    • 布局宽度取视口宽度,且与缩放无关。这条是「真缩放」和「只改字号」的分界线。如果布局宽度写成「视口宽 ÷ zoom」,那缩放只是在改字号,块永远不会横向溢出,也就永远不需要横向滚动;
    • 内嵌的输入框和下拉框是子控件,不缩放。所以既要按缩放换算它们的位置和尺寸,又要按缩放调字号,两处都得乘;
    • 滚动条的单位是逻辑像素。滚到一半改变缩放时,要先记住「当前视口顶端的逻辑坐标」,重排之后按这个锚点恢复,否则内容会突然跳走。

    滚轮以光标为锚点缩放,也是同一个套路:算出光标下的逻辑点,缩放后反解出新的滚动偏移,让那个点停在原地。

    再加两条写进代码注释的自我约束:

    • 布局逻辑宽度有下限(560),窗口极窄时不至于把块挤成一条竖线;
    • 缩放范围 40%~300%,一格 1.15 倍。连续滚动是乘方关系,所以是平滑的,一格一档也不会跳。

    八、两个让我半夜还在查的 bug

    1. 300% 缩回 100% 直接崩:0xC00000FD

    现象是必崩:放大到 300% 再缩回 100%,进程退出码 0xC00000FD——栈溢出。

    原因很朴素:refreshLayout() 末尾调了 resize(),而 resize() 会同步回调 resizeEvent,回调里又调 refreshLayout()。放大时内容尺寸单调增长,递归很快就耗尽了栈。

    修法是加一个重入闸(refreshing_),进入布局就先置位、退出再复位,回调进来直接返回。

    值得说的是为什么它没被测出来:早期的截图测试窗口尺寸是固定的,缩放也只在同一个尺寸内做,递归深度刚好没到阈值。是「在真实窗口里来回缩」这个动作把它逼出来的,不是断言。

    2. 一个测试程序完全没有输出

    另一个更折磨人:新写的缩放测试程序一启动就死,连 main 第一行的 printf 都没有。

    排查绕了很久,最后是靠「第一行都没打印」这个线索反推的——不是逻辑崩,是进程在第一次输出之前就没了。罪魁是这一行:

    std::setvbuf(stdout, nullptr, _IOLBF, 0);
    

    想把 stdout 改成行缓冲,size 传了 0。这在 MSVC 的 CRT 里会直接把进程打死。给一个真实缓冲区就好了。

    记录下来是因为这条非常有欺骗性:现象是「程序什么都不做就退出」,看起来像链接错了、像静态初始化崩了、像 Qt 插件找不到,唯独不像一行日志相关的调用。

    九、回归测试搬回仓库

    这套东西的正确性没法靠眼睛看——块的几何、命中、嵌入、撤销,随便改一行都可能悄悄坏掉。之前这些验证散在构建目录下的临时程序里,改一次代码要手工找一遍入口。

    这次把它们收进仓库,做成一件可重复的事:

    • 一份用例清单是唯一真相(类型 | 可执行文件 | 模式 | 最少断言数 | 参数形式 | 说明),两个平台的脚本共读同一份;
    • 三种类型:断言型(12 个,判定依据)、冒烟型(7 个,只要求不崩)、截图型(8 个,默认不跑,出图人工比对);
    • 最少断言数是必需的防御:模式名写错会走到默认分支静默返回 0,看起来「通过」,其实是没跑;
    • 两个平台各一个脚本,退出码统一:0 全通过 / 1 有用例失败 / 2 环境或构建失败。失败不中断,最后统一汇总。

    跑通的结果是 19 通过 / 0 失败 / 8 跳过,截图模式 8/8 出了 18 张图。

    跨平台脚本这部分,坑比功能代码还多:

    坑 表现 处置
    批处理文件存成 UTF-8 中文 Windows 上按系统代码页逐行解析,行边界错乱,报「xxx 不是内部或外部命令」 存成 ANSI/GBK
    用 for /f 读 UTF-8 清单 中文重配对把分隔符吞掉,整行被静默跳过(用例凭空少三条) 先按 UTF-8 读、只取纯 ASCII 列,生成工作清单
    用 find 计数 PATH 里装了 GNU find,/c 被当成 C 盘递归扫描,CPU 打满 计数交给专用脚本
    shell 里 kill -0 判存活 僵尸进程仍然返回成功,每条用例都等满超时 改判进程状态里的 Z

    十、148 处硬编码文案

    最后一件收尾的事:英文界面下,块编辑器整片是中文。因为绘制层是命名空间里的自由函数,没有 QObject,拿不到 tr(),所以文案全是硬编码字面量。

    处置分两步:

    1. 造一个只有 tr() 的壳类(Q_DECLARE_TR_FUNCTIONS),上下文固定,调用点统一写 BlockText::tr(...)。148 处字面量批量替换,只换函数名,跨行拼接的参数原样保留;替换前先确认这些字面量没有一处参与比较、持久化或生成脚本——否则翻译会让脚本跟着变语言。
    2. 重扫翻译文件、补齐译文、重新生成 .qm。结果是两边各 478 条、0 条未完成。

    顺带发现两件事值得写下来:

    • 旧翻译文件严重滞后,中文界面此前有一部分实际显示的是英文(表格列名、设备类型这些)。这次一并补上;
    • 提取工具必须一次给全量源目录。只对单个目录跑,工具会把其余 478 条判成「已废弃」直接删掉——我被清空过两次,靠备份才恢复。这是那种「操作成功、结果正确、数据没了」的典型。

    十一、时间账

    下面这张表是按开发日志的推进节点回填的,不是秒表计时,误差大概在半小时量级。所谓「一轮」= 一次完整的改→编→验证→截图确认。

    日期 时段 内容 轮数
    10-20 12:35 起约 45 分钟 盘点 DSL、定边界、确认唯一缺口 —
    10-20 下午约 4.5 小时 块模型 / 文档 / 画布 / 页面 + AST 转文本 1
    10-20 傍晚约 2.5 小时 视觉移植:C 形嘴部、齿形吻合、块上直改 3
    10-20 晚间约 2.5 小时 渲染层按参考源码逐段校正、拖动、撤销重做 3
    10-21 上午约 2 小时 表达式结构化:值块、且/或/非、取负、拖放替换 5
    10-21 中午约 3.5 小时 对齐、分隔条、右栏分类、形态开关、值块选中 5
    10-21 下午约 2.5 小时 定位、右键分流、死按钮、图标定稿 4
    10-21 晚间约 3.5 小时 剪贴板、多选、缩放 3
    10-21 21:00–22:30 回归整合 + 两个平台的一键脚本 1
    10-21 22:30–23:10 文案翻译收尾 1
    合计 两个工作日,净投入约 24 小时 约 25 轮

    产出的体量:33 个文件、+10617 / −539 行。其中纯功能代码大概占七成,剩下是测试、翻译资源和文档。

    如果把时间按性质拆开,比例大概是这样:

    类别 占比 具体是什么
    写功能 ~55% 模型、布局、绘制、交互、表达式
    排障 ~20% 栈溢出、隐式共享深拷贝、坐标没换算
    构建与验证 ~15% 编译、跑回归、看截图、来回缩
    测试与文案 ~10% 用例、脚本、翻译

    十二、回头看

    真正花时间的从来不是「把块画出来」。画块是第一天的下午就完成的事。

    耗时间的是三件事:

    1. 坐标系。只要有两套坐标,就一定会有一处忘了换算。把它压到一套,缩放从「到处乘以系数」变成了两个入口的改动。
    2. 真相只有一个。块的文案、槽的文本、剪贴板的格式,每一处都有「省事的双份实现」诱惑,而每一处双份最后都脱轨了。
    3. 验证要能重复。改一行就可能悄悄坏掉的模块,靠眼睛看是靠不住的;把验证做成一条命令,才有勇气去动第二十五轮。

    至于「两天」这个数字——它更像两天 × 大约十二小时,中间还有不少时间花在等编译和重跑回归上。真要说有什么经验,是那条最初的红线救了整件事:图形化只是入口,不碰编译链路。守住这条线,才敢在两天里迭代二十五轮。


    本文涉及客户名称、内部路径、内网地址与版本标识的细节已做脱敏处理。

    1 条回复 最后回复
    0

    你好!看起来您对这段对话很感兴趣,但您还没有一个账号。

    厌倦了每次访问都刷到同样的帖子?您注册账号后,您下次访问时都将自动回到上次浏览的位置,并可选择接收新回复的通知(通过电子邮件或推送通知)。您还可以收藏帖子、为帖子点赞,以此向其他社区成员表达您的感谢。

    有了你的建议,这篇帖子会更精彩哦 💗

    注册 登录
    回复
    • 在新帖中回复
    登录后回复
    • 从旧到新
    • 从新到旧
    • 最多赞同


    粤ICP备2023136510号-1
    • 登录

    • 没有帐号? 注册

    • 登录或注册以进行搜索。
    Powered by NodeBB Contributors
    • 第一个帖子
      最后一个帖子
    0
    • 版块
    • 最新
    • 标签
    • 热门
    • 世界
    • 用户
    • 群组