Skip to main content

· 应用驱动的引擎评估(admin + Windows XP 复盘)

这份文档的视角与 13 · 能力缺口分析 不同: 13 是「对标主流引擎,哪些该做而没做」;本文是「两个真实应用跑下来,引擎在哪些地方真的 扛住了、在哪些地方真的把人卡住了」。前者靠对比清单,后者靠实践证据。

证据来源:ice-web-components 的案例 (六页后台 examples/admin.html、全屏桌面 examples/windows-xp.html 含 8 个子应用、 掌机 examples/arcade.html 两块卡带),以及它们配套的 5 套浏览器回归(170 项断言) 与 582 条组件库单测。 复盘的引擎版本:1.4.1;文档写于 2026-09-12(当天补了 §2.1 的游戏场景对照组)。

两个案例是什么量级

案例内容规模
admin(后台)仪表盘 / 订单 / 履约 / 商品 / 客户 / 设置 六页;侧边栏二级菜单、面包屑、抽屉、弹窗、下拉、级联、日期、自动完成、气泡确认、消息通知、引导、分栏、图片预览、瀑布流式页面布局单页 canvas,页内控件数百
Windows XP(桌面)壁纸(程序化生成)、桌面图标、任务栏 + 托盘时钟、开始菜单、可拖动/最小化/最大化/缩放的窗口;7 个应用:我的电脑、我的文档、记事本、画图、扫雷、IE(真的 fetch 网页)、显示属性单页 canvas,多窗口叠加,扫雷高级 30×16

两者的共同点:没有 DOM 组件兜底,所有像素、命中、焦点、滚动、弹层都由引擎 + 组件库完成。

结论速览

能力维度判定关键证据
渲染原语与绘制质量✅ 足够矩形/圆/椭圆/多边形/路径/折线/文本/图片 + 变换 + 子树不透明度 + 阴影;XP 的窗口边框、扫雷格子、画图笔迹都靠它
事件与命中✅ 足够hitTest + 事件总线 + pointer/touch/wheel 归一化 + 拖拽指针捕获;分栏、窗口拖动、落笔、点格子全通
浮层体系(工具层)✅ 很好弹窗/抽屉/下拉/气泡/引导聚光/图片预览共用 ICEOverlayManager,「永远在最上 + 不参与业务命中」只实现一次
裁剪与滚动🟡 够用但要自己搭clipChildren 是引擎给的(滚动视口、cover 裁剪、水印裁切都靠它);但滚动本身是组件级实现ICEScrollPane),嵌套滚动 / scrollIntoView 要各写一遍
脏矩形 + HiDPI + 视口缩放✅ 足够引擎文档的实测(拖动实体局部重绘 0→20 次、渲染 −31%/帧、局部 ≡ 全量 0 差异);XP 的 Retina 发虚一行 init({ dpr }) 解决
布局(measure/arrange 协议)🟡 有原语、缺协议见 §3-3:requestLayout / getPreferredSize 在,但「尺寸变了要重排」没有统一语义,组件要自己补钩子
文本🟡 引擎有,组件没用满引擎有 wrap/maxLines/ellipsis/grapheme 与编辑态(含 IME);我们组件库却手写 keydown,导致中文输不进去(见 §2)
栈序(zIndex)🟡 脆弱只有「创建顺序 = zIndex」,两个案例反复手写 raise() 抬子树;无 stacking context / bringToFront()
命中与容器的语义🟡 缺开关容器默认参与命中 → 「后创建的容器吃掉子控件点击」这一族 bug 反复出现(见 §3-4)
序列化 / 插件 / a11y 原语✅ 有,但案例未验证registerType / ICE.use / getAccessibilityTree 都在;本次未做整场景往返与 DOM 镜像

一句话:作为「在 canvas 上做 Swing 风格控件」的地基,引擎够强也够稳;作为「通用 UI 引擎」, 它处在内核扎实、上层协议未定型的阶段 —— 短板集中在「失效通知」「命中语义」「栈序语义」三处协议。

一个诚实的发现:引擎已有能力,下游没用上

这次复盘最有价值的一条,不是「引擎缺什么」,而是「引擎有什么我们没吃到」:

引擎已具备我们实际的做法后果
ICEText.startEditing():挂透明 <input>支持 compositionend(IME)、光标与选区组件库的 ICETextField 自己手写 keydown 单字符追加中文输入法打不进去(只能 setValue),是「能不能当产品用」的硬伤
createLinearGradient / createRadialGradient / createConicGradient(可序列化的渐变对象)XP 标题栏用 8–24 条色带拼渐变;壁纸绕道离屏 canvas 生成 PNG 再喂 ICEImage代码更绕、色带在缩放时有轻微 banding;暴露出的是能力发现问题
getAccessibilityTree() / setFocusedComponent() 契约(14两个案例一行都没接canvas UI 的键盘/读屏故事仍是半截

结论:需要一层「组件库该用引擎哪些能力」的对照清单(或示例),否则能力存在也不会被用。 ice-web-components 已在 docs/guides/ 里补了主题、表单、浮层、布局等专题,但文本编辑态与渐变 这两个明确的能力应当写进最显眼的位置(建议进 01-runtime 或组件库的 quick start)。

追加:游戏场景的对照组(2026-09-12 实测)

上面是「静态页面」的复盘。同一天又拿游戏场景做了一次对照 —— 一台小游戏掌机 (ice-web-components/examples/arcade.html:俄罗斯方块 + 贪吃蛇,后来还塞进 XP 桌面当第八个 应用)。结果把「能力发现」问题暴露得更彻底:第一版游戏的引擎能力利用率接近于零。

引擎能力游戏第一版补课之后
绘制原语 / 路径 / 渐变0 次(棋盘 = 200~400 个 ICEWidget 拼格子)新增 ICETileMap:整块棋盘 1 个节点,在 doRender() 里用 ctx 自绘(含数字标签层)
动画(tween / fadeIn / scaleIn0 次(手搓 flash 衰减、手搓 accumulator)消行 / 吃食物 pulse(内部 tween)、换卡带 fadeIn、GAME OVER scaleIn
主题 token(registerTheme面板用 token、游戏美术写死 hexICE_ARCADE_THEME + ICE_ARCADE_PALETTE,方块 / 蛇 / 食物全部进 token
浮层体系(ICEOverlayManager0 次(暂停 / 结束提示是手搓半透明块)排行榜 = ICEModal + ICETable + ICEScrollPane
组合能力(ICEWindow页面孤岛XP 桌面里开一个 ICEWindow 跑同一台掌机(复用同一套模型与 ICETileMap
组件使用面6 个组件+4 个(ICETileMap / ICEModal / ICETable / ICEScrollPane
质量证据23 项浏览器断言170 项(qa:arcade 39 + qa:xp 40 + 其余三套)

这次暴露的不是「引擎缺能力」,而是「引擎的能力没有在合适的场景被想起来」:游戏是最吃 「自绘 + 动画」的场景,却恰恰最容易写成「堆组件」。

追加:一轮「按清单补课」的结果(2026-09-12 晚)

§2.1 给出「哪些能力没被用起来」之后,组件库按一份清单做了系统性补课,引擎侧的能力第一次 被大面积用上(不是零散用,而是每一类都有真实场景 + 浏览器断言):

引擎能力补课后的实际用法证据
ICEText 的编辑态思路(透明 <input> + compositionend组件库自己也做了一层「原生输入替身」(ICENativeInput):聚焦挂透明 input、IME 组字、回写 change中文输入从此真的能打进去19 条单测 + qa:admin 4 条真中文输入断言
自绘(doRender + ctx)ICETileMap:整块棋盘 1 个节点(10×20 / 20×20 / 4×4 三种棋盘共用)15 条单测 + qa:arcade 断言「1 个节点」
子树不透明度 / tween / 缩放换卡带 fadeIn、GAME OVER scaleIn、消行与吃食物 pulseqa:arcade / qa:gallery
clipChildren + 滚动ICEVirtualList 与表格「可滚动模式」:一万行只渲染 12~15 个节点;固定列用冻结层裁切17 条单测 + 5 条真滚轮/拖拽断言
getAccessibilityTree()mountICEAccessibilityMirror():把快照渲染成透明但真实的 DOM,点它 = 激活画布组件、focus 也能映射回来9 条单测 + 3 条真断言(1615 个语义元素)

这一轮最有价值的产出不是代码,而是「引擎已有能力 → 真实场景 → 可复现证据」这条闭环: 每一类能力都配了单测(纯逻辑)与浏览器断言(真鼠标 / 真键盘 / 真滚轮),所以「用上了」 是可验证的事实,而不是自我感觉。组件库侧的最终规模:98 个测试套件 / 705 条单测 / 198 项浏览器断言,覆盖 6 个示例页。

顺带记一个引擎侧的真实坑(属于「文档该写清楚」,不是功能缺失):ICEComponent.doRender() 会把 CTM 换成「世界 → 设备」去画调试包围盒,所以子类在 super.doRender() 之后自绘时, 坐标空间已经不是组件局部了 —— 内容会跑到画布左上角、看着像被缩放。引擎留了 applyActiveTransform() 专门取回本渲染通道的完整变换,组件库已按它修好(ICETileMap), 并用假 ctx 的单测把坐标约定守住。

给引擎的建议:与其让人记住「super 之后要 applyActiveTransform()」,不如提供一个 protected renderContent() 模板方法 —— 由基类在正确的局部 CTM 下调用它,子类只写内容、 永远踩不到这个坑(ICEImage 现在用的是「根本不调 super」的变通写法,可读性差)。

真实卡住过我们的短板(按影响排序)

指针捕获的坐标语义:拖拽目标必须是「可交互节点」(P1,归属:引擎文档)

现象:组件库做看板卡片拖拽时,draggable: true 拖起来落点不跟手 —— 不管指针移到哪, 每次 mousemove 拿到的 offsetX/offsetY 都是按下那一刻的值(探针里 5 次移动全是卡片 中心的 86,60)。同一个写法在表格行拖拽、树节点拖拽上却完全正常。

根因:那张卡片刻意写成 interactive: false(当时想「只展示、不接收点击」),于是 mousedown 命中的是看板根节点而不是卡片;引擎的指针捕获随后派发的事件里,坐标不再逐帧更新。 把卡片改成 interactive: true 就恢复正常。

为什么值得写进文档:这是「命中语义」那条已知短板的拖拽版本,但更隐蔽 —— 它不报错、事件照样派发,只是坐标是旧的。引擎目前没有任何地方说明:

只有可交互节点才能作为拖拽目标;非交互目标上的捕获事件不保证坐标刷新。

建议(按性价比):

  1. mousedown/mousemove/mouseup 与指针捕获的文档里写明这条契约(一行字就能省掉半天排查);
  2. 更好:非交互目标要么也能拿到逐帧坐标,要么在 mousedown 命中非交互节点时不建立捕获 (让事件继续走普通派发,坐标自然是对的);
  3. 最理想:引擎提供 startPointerDrag(component, handlers) 之类的原语,把 「按下 → 捕获 → 每帧坐标 → 松开」这段各家都在手写、各家都可能写错的流程收进引擎 —— 组件库这轮在表格(列宽 / 行)、树、看板三处各写了一遍,已经是第四次了。

配套的组件库侧经验:拖拽的落点计算与数据搬运都抽成了纯函数 (computeDropTarget / computeTreeDropTarget / moveItem / moveTreeNode / moveKanbanCard), 交互层只剩「读指针、画指示线、松手调函数」—— 这条分层是这次能快速定位到引擎问题的前提。

文本编辑态没接通(P0,归属:组件库)

引擎侧已实现编辑态与 IME;组件库另起炉灶。修法明确:ICETextField 复用 ICEText.startEditing(), 把 caret/选区/IME 交给引擎。这是本次评估里性价比最高的一条。

文本溢出要上层自己算(P1,归属:引擎)

画布文本不裁剪也不自动省略,长值会直接压到相邻元素上(描述列表的「Intel Pentium 4 2.4GHz」压住 下一列的标签)。组件库最终自己写了 truncateTextLines() 并在每个文本组件里手工调用。 引擎现在有 wrap/maxLines/ellipsis,但那是排版能力,不等于容器内溢出策略; 建议在 ICEText 暴露 overflow: 'clip' | 'ellipsis' 的盒子语义,让「盒子内必不溢出」成为引擎保证。

布局失效没有协议(P1,归属:引擎)

症状是同一族 bug 反复出现:

现象根因
ICETable 改宽度后列宽不重算、单元格文字重叠__render() 只在 setData/排序时跑,改 state.width 不会触发重排
ICEDescriptions 值列宽度算成 0、文字消失同上(列宽依赖宽度)
ICESplitter 构造期夹取丢掉请求尺寸,容器变大后无法恢复尺寸夹取写进了状态,没有「重新 measure」的机会
ICEDescriptions/ICETimeline 重排时新旧内容叠加__render() 不清空旧子节点 + 重排被额外触发

引擎目前只有 setState 后的 __afterStateMerge(sizeChanged) 回调,且不会自动让父容器重排子级。 我们最终在 4 个组件里各写一遍同类钩子。建议:

  1. __afterStateMerge 升格为正式 public 契约onResize/onMeasure 之类的文档化命名);
  2. 给容器加「子级尺寸变化 → 自动 requestLayout」的默认行为(现在是可选);
  3. 若要更彻底,做 measure/arrange 两阶段getPreferredSize() 已在,缺 arrange 的统一下发)。

容器默认命中(P1,归属:引擎)

纯布局节点(表单容器、间距容器、栅格、分栏)默认 interactive: true,又比内部控件后创建 (zIndex 更高)→ 命中检测先撞上容器,内部控件收不到事件。表现为「输入框点不进去」「焦点环不出现」 「getFocused() 是 null」。我们靠约定(一律 interactive: false)和一次修复(ICESplitter 默认关闭命中) 缓解,但每个新组件都可能再踩。

建议给引擎加类 CSS pointer-events 的语义:布局/装饰节点默认不参与命中,需要时显式打开; 或者至少让 ICEGroup(容器)默认 interactive: false、由具体控件自行开启。

栈序只有创建顺序(P1,归属:引擎)

「晚创建 = 在上层」对小型场景好用,但在 admin / XP 这种多层 UI 里会不断需要「把这个子树抬上来」: 卡片内容、悬浮按钮、任务栏、浮动按钮组、工具层……两个案例里 raise(node, z) 出现了十几次。 建议提供 bringToFront()/sendToBack() 或引入 stacking context(子树的 zIndex 相对父容器)。

滚动是组件级实现(P2,归属:引擎)

ICEScrollPane 自己实现视口、滚动条、滚轮。带来两个成本: 嵌套滚动要手工协调;浏览器里常见的 scrollIntoView、滚动到锚点、惯性滚动都要重做。 本次为浏览器回归专门写了 scrollIntoView() 帮手,说明这个动作在应用侧很常见。

其余(P2,按需)

  • 阴影/模糊等视觉效果:XP 的图标标签需要文本阴影(浅色壁纸上白字会飘);引擎有图元阴影,无文本阴影、无 backdrop blur。
  • 图片ImageCache 有,但缺 9-slice、圆角/路径遮罩、按视口选缩放级别。
  • 整场景序列化往返registerType 在,但没做过「存下整个 admin/XP 场景再读回来」的验证;文档里也缺 schema 版本迁移的例子。
  • 每帧预算/诊断:脏矩形有实测数据,但应用侧看不到「这帧重绘了哪些区域、花了多久」,排查性能只能靠猜。

如果只做三件事

优先做什么为什么谁来做
1组件库接入 ICEText 编辑态(IME / 光标 / 选区 / 复制粘贴)中文输不进去是「能不能用」的硬伤,而引擎那半已经写好ice-web-components
2引擎补「布局失效协议」:正式 resize/measure 契约 + 容器自动重排子级一次消掉本次 4 个同类 bug 的根因ice-render
3引擎补命中与栈序语义pointerEvents: 'none' 风格的开关 + bringToFront()/sendToBack()这两个是「写 UI 时会不会反复踩坑」的分水岭ice-render

维护约定

  • 有新案例/新版本就补一节:每条结论都要求「现象 + 证据(哪个案例的哪个文件/哪次修复)+ 归属」, 不接受无证据的判断;
  • 13 的分工:13 负责「对标清单 + P0/P1/P2 进度」,本文负责「实践反推 + 归属」; 两条结论冲突时,以本文的实践证据为准,并回写 13 的对应行(13 的 §1 已经是双列对照表,支持滚动更新);
  • 修完之后:把该条从 §3 移到本文顶部的「已关闭」小节(本节先留空,等第一条被关闭时启用)。