Skip to content

feat(canvas): 完善旧版 CanvasContext 跨端能力并统一触摸手势与 label 激活 - #306

Draft
lbb00 wants to merge 1 commit into
didi:mainfrom
EchoTechFE:worktree-canvas-peripherals-pr
Draft

feat(canvas): 完善旧版 CanvasContext 跨端能力并统一触摸手势与 label 激活#306
lbb00 wants to merge 1 commit into
didi:mainfrom
EchoTechFE:worktree-canvas-peripherals-pr

Conversation

@lbb00

@lbb00 lbb00 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

目标

这次补齐旧版 wx.createCanvasContext()、Canvas node、小游戏 Canvas 在 Dimina 四端的运行链路,并把 canvas 使用的点击、触摸和 label 激活语义与微信小程序对齐。最终实现同时覆盖新编译包和已发布旧包,Canvas 的节点归属、绘制顺序、内存边界和 runtime 销毁都由框架层统一管理。

Canvas

  • 新编译包统一把 <canvas> 编译为 dd-canvas。组件根节点保留布局、iddataset 和事件语义,内部真实 canvas 供 SelectorQuery node、Canvas 2D / WebGL 和 legacy 回放使用;旧编译包中的原生 canvas 仍可由当前 runtime 解析。
  • legacy canvas-id 按页面或自定义组件实例隔离,动态修改 canvas-id / type 会释放并重新 claim。resolver 不跨组件边界、不选择重复冲突的隐藏节点,也不把 id 拼进 CSS selector。
  • 完善旧版绘图指令、跨批状态、save / restore、图片加载、像素读写与 PNG / JPEG 导出。Canvas node 补齐 ImageData、RAF、图片、2D 状态反馈和小游戏上屏 / 离屏生命周期。
  • 同一作用域只串行 DOM lookup;解析到真实 canvas 后按元素排队。同一 canvas 的绘制、像素与导出保持顺序,不同 canvas 互不阻塞。
  • 位图在分配前统一限制单边 4096 px、RGBA backing store / 导出目标 32 MiB;像素 JSON 传输按最坏编码峰值另行限制。Android、iOS、HarmonyOS 每个 app 最多保留 2 个未结算导出,并同时限制累计 Base64 载荷。
  • 导出任务绑定 app owner 和 runtime generation。退出或重启会取消未开始任务;已经发布但不能交付给当前 runtime 的文件会删除,旧 callback 不会进入新逻辑层。

手势与 label

taplongpresslongtapcanceltap 由统一触摸状态机合成,不再把浏览器 click 直接当作小程序 tap。它覆盖位移取消、touchend 终点复查、touchcancel、长按抑制、bind / catch 传播和动态监听器重装;程序化点击、键盘与无障碍触发仍由 click 兜底。

序列的取消语义按微信实测对齐:位移越过阈值后,之后每一条 touchmove 各带一条 canceltaptouchmove 一条都没到时(被 preventDefault 吞掉或系统把移动并进抬手),touchend 会按起点与终点再判一次距离并改发 canceltap,这两条补发路径只在整段序列还没发过 canceltap 时生效。第二根手指加入只取消长按计时器,longpress / longtap 本次序列不再触发,但不影响先落下那根手指的 tap。节点卸载时进行中的原生序列仍等真实 touchend / touchcancel 收口,不伪造终态,但长按计时器立即停表,不给已经离开树的节点补造 longpress / longtap

label 通过控件登记的激活入口定向激活 buttoncheckboxinputradioswitch,不再调用 target.click() 产生第二次 tap。容器事件的 currentTargetiddataset 归到实际绑定节点。组件化后 canvas touch 曾因以内层 canvas 为基准产生 4 px 偏移,本次已经修复:触摸点的 x / y 重新以小程序声明节点的 border box 为坐标原点。

兼容与边界

  • 用旧编译包配合新基础库验证过 legacy canvas;运行时还覆盖了组件根节点本身是 canvas、页面与组件同名 canvas、特殊字符 id 等旧包边界。
  • Canvas 2D 的复杂 CSS 值交给宿主 context 判定并回读。旧 WebKit 没有 context.reset() 时,通过 backing store reset 清理像素、路径、clip、save stack 和绘图状态。
  • 一个手势所有者只维护一条触摸序列,tapcanceltap 属于这条序列而不是逐个触点;多指同时按下时只有建立序列的那根手指产生终态事件,其余触点只透传原生 touch 事件。glass-easel 按 identifier 逐触点记账的模型本次不采用,微信公开文档未定义多指下的 tap 时机。
  • capture-bind / capture-catchmut-bind 仍未实现;本 PR 不扩大到捕获阶段和互斥事件模型。

完整设计见 Canvas 运行架构触摸事件与手势

验证

分支已 rebase 到 main 最新的 2d7b9ea(HEIF/HEIC 支持),冲突只有 harmony/dimina/src/test/List.test.etsiOS/dimina.xcodeproj/project.pbxproj 两处新增用例登记,按并集解决,双方用例都在下面的运行结果里。以下数字是 rebase 之后在 3cbf926 上重跑的:

  • FE:common、compiler、components、service、render、container-sdk、container 共 222 个测试文件、1723 个用例全部实际通过;pnpm lint、production build 和 git diff --check 通过。
  • Android:33 个测试类、266 个用例通过,0 失败 0 skip。
  • iOS:diminaTests 65 + DiminaKitTests 222 = 287 个测试通过,** TEST SUCCEEDED **
  • HarmonyOS:hvigorw --mode module -p module=dimina@default test 实际执行 hypium 本地单测,21 个测试类、203 个用例通过,0 failure 0 error 0 ignore(entry@default 1/1);entry HAP 构建通过。src/ohosTest 下目前只有模板用例,未在设备上执行。
  • JSSDK 由 pnpm generate:sdk 从 production dist 重新生成,包内 service.js(167,875 B)与 pageFrame.js(293,922 B)和 container dist 逐字节一致;JSApp ZIP 完整。
  • Android 35 Pixel 7 AVD、iOS Simulator、HarmonyOS 6.1.1 / API 24 Pura 90 emulator 的 legacy Canvas 页面链路均为 59/59,包括像素读写、PNG / JPEG 导出后立即读文件和视觉绘制;该轮真机链路在上述手势取消语义调整之前完成,之后的改动由 FE 用例和三端构建覆盖。

@lbb00
lbb00 force-pushed the worktree-canvas-peripherals-pr branch from c16375e to 9dd5b29 Compare August 13, 2026 15:04
@lbb00 lbb00 changed the title feat(canvas): complete legacy CanvasContext peripherals feat(canvas): 完善旧版 CanvasContext 跨端能力 Aug 13, 2026
@lbb00 lbb00 changed the title feat(canvas): 完善旧版 CanvasContext 跨端能力 feat(canvas): 完善微信小程序旧版 CanvasContext 跨端能力 Aug 13, 2026
@lbb00
lbb00 marked this pull request as ready for review August 14, 2026 06:38
@lbb00

lbb00 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

@dos1in review

Comment thread fe/packages/components/src/component/label/Label.vue
Comment thread fe/packages/components/src/component/label/Label.vue
@lbb00
lbb00 marked this pull request as draft August 17, 2026 11:50
@lbb00 lbb00 changed the title feat(canvas): 完善微信小程序旧版 CanvasContext 跨端能力 feat(canvas): 完善旧版 CanvasContext 跨端能力并统一触摸手势与 label 激活 Aug 17, 2026
@lbb00
lbb00 force-pushed the worktree-canvas-peripherals-pr branch 2 times, most recently from 35f9a8d to d58e3fe Compare August 18, 2026 09:11
@lbb00
lbb00 marked this pull request as ready for review August 18, 2026 09:21
@lbb00

lbb00 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

@dos1in 本来是想修改 Canvas 上的小问题,这下干脆把整个 Tap 事件按照微信小程序对齐了一遍。可以再 review 了

@dos1in

dos1in commented Aug 18, 2026

Copy link
Copy Markdown
Member
  1. [P1] canvas-id 的组件作用域被整页判重破坏

Canvas.vue#L49-L64

渲染层已经按 moduleId 支持不同组件作用域使用相同 canvas-id,并新增了对应测试;但这里仍在整个 .dd-page 内判重。两个相同自定义组件实例都包含 canvas-id="chart" 时,两边都会进入 isError 并被隐藏,组件作用域隔离实际上无法使用。请按组件实例作用域判重或移除整页判重,并增加通过真实 Canvas.vue 挂载两个组件实例的回归测试;当前 render 测试直接构造 DOM,绕过了这个守卫。

  1. [P1] 动态切换 bind/catch 会中断正在进行的手势

useTouchEvents.js#L26-L52
components/index.js#L221-L243

touchstart 处理器可能修改响应式事件绑定,例如动态启用 catchtouchmove。如果更新在 touchend 前到达,install()/updateCanvasGestures() 会立即 detach() 当前 owner,清掉活动序列、长按计时器和 tap 状态;新 owner 只看到 touchend,无法再生成本次 tap/longpress/canceltap。这与文档宣称支持动态切换相冲突。建议把监听器重配延迟到当前序列的 touchend/touchcancel 后,并覆盖组件路径和原生 canvas 指令路径。

  1. [P1] iOS 在主线程解码并写入最大 32 MB 图片

ImageAPI.swift#L120-L170

这条调用来自 WKScriptMessageHandler,当前同步执行 Base64 解码、签名校验、目录 I/O 和 atomic write,最大允许 32 MB。大画布导出会长时间占用 iOS 主线程,造成明显卡顿甚至 watchdog 风险。虽然返回类型是 DMPAsyncResult,实际工作仍在返回前完成。请把解码和文件写入移到后台队列,再通过原有 callback 单次回传结果。

  1. [P2] save/restore 的逻辑状态与微信 3.16.2 不一致

canvas-context.js#L288-L295

save() 需要改为使用 drawingState.push(this.state),不会克隆对象;本实现改为 cloneState(),测试也明确写了 “unlike the official implementation”。因此 save → setFontSize/setLineDash → restore 后,fontgetLineDash()measureText() 会与微信返回不同结果,应保持微信小程序实测语义。

@lbb00

lbb00 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

@dos1in 四条都改了,在 99db340

1. [P1] canvas-id 的组件作用域被整页判重破坏

判重改为按「页面 + 宿主组件实例 + canvas-id」在模块作用域的 Set 里登记(${info.bridgeId}|${info.moduleId}|${props.canvasId}),跟渲染层 getCanvasElement 的 moduleId 作用域对齐,整页 DOM 查询去掉了。出错的实例从不登记,因此 onUnmounted 只在登记成功时才归还 key,否则后来的重复实例卸载时会把先到者的登记删掉。

新增 fe/packages/components/__tests__/canvas-id-scope.spec.js(4 例),走真实 Canvas.vue 挂载:两个同组件实例各持一份同名 canvas-id 都可用;同一实例内重复仍报错且第一块保持可用;跨页面同 moduleId 互不影响;卸载归还的时机。

2. [P1] 动态切换 bind/catch 会中断正在进行的手势

重装监听器这件事保留(passive 只能在注册时决定),但推迟到当前序列以 touchend/touchcancel 收口之后。attachTouchEventsdetach() 新增 onDetached 回调,在监听器真正摘干净、__ddGestureDetach 让出之后才触发;finishPendingDetach 改为把收尾排进 enqueueAfterTouchEnd 的同一个微任务队列里,排在终态 tap 的派发之后——否则提前推进 generation 正好吃掉这一次的 tap。组件路径(useTouchEventsonUpdated)和原生 canvas 指令路径(components/index.jsswapCanvasGestures)走同一条逻辑;推迟期间到达的属性更新只更新 latestVNode,换 owner 时按最新的一份重新解析。

新增 fe/packages/components/__tests__/gesture-reinstall-during-sequence.spec.js(4 例):两条路径各自在 touchstart 里打开 catchtouchmove,tap 仍照发且重装发生在 touchend 之后;序列之间切换立即重装;一次序列内切换两次装的是最后一份。

3. [P1] iOS 在主线程解码并写入最大 32 MB 图片

解码 + 落盘抽成 ImageAPI.writeCanvasTempFile,放到 DispatchQueue.global(qos: .userInitiated),回主线程走原有 callback 单次回传。参数校验很便宜,留在原地保持同步失败语义。

同一个问题另外两端也有,一并改了(这两条不在 review 列表里):

  • Android handleAction 跑在主线程(JsCore 把 QuickJS 的每次 evaluate 都 post 到主 Looper),改为独立的 CoroutineScope(SupervisorJob() + Dispatchers.IO)。没用 activity.lifecycleScope:每个页面是自己的 DiminaActivity 而 QuickJS 按 appId 共享,用户在写盘途中返回上一页会取消协程,success/fail/complete 一个都不发,等 complete 的小程序永远挂住。回调切回主线程后由新增的 dispatchCanvasResult 按同步路径的契约派发(:ok 结尾走 success,否则 fail,complete 无论如何都发且带同一个 result)。
  • HarmonyOS saveCanvasTempFile 由 render 层发起、走主线程的模块表(worker 上只注册了 Http/Storage/File),改成 Base64Helperfs 的 Promise 接口。顺带两处:解码结果不保证独占整个 ArrayBuffer,而 fs.write 收的是整个 buffer,带偏移时会把视图之外的字节也写进去,改为只在确实带偏移时 slice();方法变成 async 之后 bridge 那层只接同步抛错的 try/catch 不再兜得住它,失败通知自己包了一层,避免变成没有日志的 unhandled rejection。

Android 新增 5 例、iOS 新增 3 例单测,覆盖真实写盘产物、签名不匹配时不落盘、success/fail 各自的 complete 载荷,以及 iOS 的重活确实不在调用线程上完成。

4. [P2] save/restore 的逻辑状态与微信不一致

save() 改回 drawingState.push(this.state)cloneState() 删掉。两条断言按微信实测语义反过来了:save → setFontSize → restore 之后 font 停在 24px sans-serifsave → setLineDash → restore 之后 getLineDash() 停在后来的值。

还有一处同源的偏离没动,想听下你的意见:微信的 setLineDash 存的是调用方传进来的那个数组本身,而不是拷贝——调用方之后修改这个数组,会穿透到 getLineDash() 的返回值。我们这边是 [...pattern],防御性拷贝。要完全对齐的话这条也该跟着改。


验证:

  • fepnpm test 全绿(components 16 文件 173 例、service 50 文件 666 例,含上面两个新 spec),pnpm lint 0 warning。
  • Android:./gradlew testDebugUnitTest 245 例 0 失败。
  • iOS:xcodebuild test 222 例 0 失败。
  • 真机/模拟器回归:canvas 59 例、label 8 例在 Android(实机)、iOS 模拟器、HarmonyOS 模拟器上各自全过。canvas 用例里包含改后的 save/restore 断言,三端都确认执行到了而不是被跳过。

@dos1in

dos1in commented Aug 19, 2026

Copy link
Copy Markdown
Member
  1. [P1] 动态修改 canvas-id 会让判重表失效
    Canvas.vue#L56-L81
    claimedCanvasIds 只在 onMounted 时登记、onUnmounted 时释放,但 props.canvasId 可以动态变化,模板中的真实 canvas-id 也会随之更新。
    例如第一块 canvas 从 before 改成 after 后:
  • before 仍被错误占用,新的 before canvas 会被隐藏。
  • after 没有登记,新的 after canvas 反而能同时显示。
    我添加了两条临时回归用例,分别验证“释放旧 ID”和“占用新 ID”,两条均稳定失败。建议监听 canvasId,在变化时原子地释放旧 key、校验并登记新 key,同时补真实 Canvas.vue 动态属性测试。
  1. [P1] HarmonyOS 的旧异步回调会穿透到重启后的新 JS Service
    Canvas bridge#L131-L177
    DMPBridges.ets#L56-L90
    DMPChannelProxyNext.ets#L106-L113
    saveCanvasTempFile 改为异步后,回调只保存了 appIndex。restartMiniProgram 会递增 runtimeGeneration 并替换 service/container,但仍复用相同的 DMPApp 和 appIndex。
    因此旧 runtime 发起的解码/写盘若在 restart 后完成,ContainerToService 会通过 appIndex 找到新 service,把旧 callback ID 投递进新 JS runtime,可能误触发同 ID 的新回调。
    建议创建任务时捕获 app 身份和 runtimeGeneration,回调前确认两者仍有效;失效时只清理临时文件,不再通知新 runtime,并补 restart/exit 与写盘并发的回归测试。

  2. [P1] 三端异步改造没有并发上限,32 MB 安全阈值可被并发绕过
    Android ImageApi.kt#L84-L95
    Android ImageApi.kt#L442-L458
    iOS ImageAPI.swift#L173-L202
    HarmonyOS Canvas bridge#L131-L177
    Android 的全局 IO scope、iOS 的 global queue 和 HarmonyOS 的独立 Promise 都没有 per-app/global 并发限制。
    单个上限请求会同时保留约 44 MB Base64 字符串、最多 32 MB 解码结果以及写盘缓冲;不同 canvas 并发几次即可达到数百 MB,导致移动端 OOM。当前 32 MB 上限只约束单次请求,不能实现注释所说的内存安全边界。
    建议按 app/runtime 使用串行或有界队列并提供背压,runtime 销毁时取消或失效其任务,并增加并发大图导出的峰值任务数及单次回调测试。

  3. [P2] setLineDash 建议继续对齐微信 3.16.2
    canvas-context.js#L331-L346
    3.16.2 保存的是调用方传入的数组引用;当前 [...pattern] 做了防御性复制。因此调用后再修改原数组时,getLineDash() 的结果不同。这里也建议一并对齐并补测试。

@lbb00

lbb00 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

第二轮四条已修(6f4e26e),修的过程中从这批改动本身又挖出五条,一并修掉。

你提的四条

  1. 动态改 canvas-id 让判重表失效 — 已修。只加 watch 不够:canvas-id 的 watch 是 pre-flush、onUnmounted 是 post-flush,同一次更新里「一方让出、另一方接手」时接手方先跑、看到的是旧表,被拒后没有任何路径重新评估,结论还会随模板书写顺序反转。改成判重表存 owner,让出时就地重试被挡下的实例,冲突 error 延到本次 flush 结算完再报。
  2. Harmony 旧回调穿透到重启后的新 service — 按你的建议加了 generation 比对。另两端逐个读过代码:Android 的 callback 闭包捕获具体 JsCore 实例、退出即换新对象(Bridge.kt:419RuntimeMessageQueue.kt:13-17);iOS 弱捕获 DMPContainer 且重启置 nil。这条只在 Harmony 成立。
  3. 异步改造没有并发上限 — 三端都做了按 appId 串行。这里更正我上一轮注释的一句过度声称:串行只把解码与写盘压回一次一个,排队中的请求仍各自持有自己那份 base64,注释已改。没加硬的深度上限——那会把「循环导出 10 张」变成失败,而排队项的主要占用在调用方那边已经生成了。你更想要背压的话我可以补。
  4. setLineDash 对齐微信 — 已改成保持调用方数组引用。它顺手打破了非有限数守卫(原本靠录制时深拷贝间接成立),守卫已下沉到 getActions() 这个唯一出口。

自查另外发现的五条

  • 页面作用域会静默画到组件的 canvas 上:判重改成按 moduleId 后同名 canvas 可合法共存,而 getCanvasElement 页面作用域直接按文档序取第一个、不校验归属。现在元素带 data-canvas-owner,先按归属精确命中,再回退。
  • Harmony 的 saveCanvasTempFile 不该是 async:它是 javaScriptProxy 的同步返回值,async 会让返回值变成 pending Promise 穿过代理,校验段抛错也会变成无日志的 unhandled rejection。已改回同步入口。
  • Harmony 写盘队列作用域错了:挂在模块实例上,而模块图随 runtime 重建。改成按 appId 的静态链。
  • 成功已交付后派发再抛错会删掉那个文件:结算混在管理文件生命周期的 try 里。已移出且只做一次。
  • Android 派发抛错从「转 fail」退化成「崩进程」:挪进协程后没有 handler。已补 CoroutineExceptionHandler

已知未修

runtime 在导出途中销毁时,Android/iOS 会把已写成的临时文件留在 tmp(回调本身正确丢弃)。Harmony 有 generation 可以顺手删,另两端没有等价 epoch,要检测得改 JsCore.postMessage 返回契约或 iOS 的 container 持有关系,都在本 PR 层级之外;且三端 tmp 对任何 API 都没有清扫,只给 canvas 加会变成特判。建议单独做「app 启动清扫 tmp」。这是我把写盘异步化打开的窗口,不是存量问题。

其余:单次文件 IO 无 deadline;Android/iOS 的 appId→锁表只增不减;iOS 的陈旧判定押在「DMPAppDMPContainer 唯一强持有者」这条隐式 ARC 不变量上。

验证

结果
fe 单测 1610 例全绿(compiler 325 / service 667 / components 181 / render 137 / container-sdk 276 / common 16 / container 8)
Android 248 例 0 失败;解包产物确认新增 handler 在内
iOS diminaTests 47 + DiminaKitTests 222,0 失败
HarmonyOS 删 build 目录冷编译;解包 hap 确认本轮代码在 ets/modules.abc(hvigor 增量会静默漏掉新增方法)
三端真机 label 8/8、旧版 canvas 59/59、canvas 压力 20/20,各 × Android 真机 / iOS 26.5 / HarmonyOS

新增行为测试都做了变异验证(去掉修复必须变红):让出重试 3 条、归属解析 2 条、getActions 守卫 1 条。

真机第一轮有两条红,都查了归属:setLineDash 那条是我们 harness 的断言写的是语义(注释里还写着 "deliberate deviation"),按微信实测行为改了断言;Harmony 的「draw() 不因不可达图片挂起」超了用例 20s 上限(渲染层自身超时 10s),重跑通过、此前 8 轮也通过,根因未查明

@dos1in

dos1in commented Aug 19, 2026

Copy link
Copy Markdown
Member
  1. [P1] 动态切换 Canvas type 会再次破坏 canvas-id 登记
    Canvas.vue#L116-L124
    登记逻辑只监听 canvasId,但是否参与旧版 canvas-id 判重还取决于 props.type:
  • 旧版 canvas 切换成 type="2d" 后,原 ID 仍被占用。
  • type="2d" 切换成旧版后,不会登记 ID,允许重复。
  • 原本因重复隐藏的旧版 canvas 切成 2D 后也仍保持隐藏。
    我补了两个临时测试,分别覆盖 legacy → 2D 和 2D → legacy,两条都稳定失败。建议同时监听 canvasId 和 type;进入 2D 时释放登记并清除旧错误,进入 legacy 时重新执行完整 claim。
  1. [P1] 内部 moduleId 被泄漏进小程序事件的 target.dataset
    Canvas.vue#L129-L135
    events.js#L145-L150
    归属信息使用 data-canvas-owner 存在真实 DOM 上,而事件序列化会复制 event.target.dataset。因此点击或触摸 canvas 时,业务收到:
    event.target.dataset.canvasOwner === moduleId
    这不是开发者声明的数据,并且暴露了内部组件实例 ID。我通过真实 Canvas.vue 挂载并点击内部 canvas,临时回归用例稳定失败。
    建议使用非 data-* 的内部属性、Symbol/WeakMap,或者在事件序列化前剔除框架字段;同时补 tap/touch 载荷测试。

  2. [P1] 32 MB 限制仍不能约束导出峰值内存
    canvas/index.js#L54-L74
    runtime.js#L2582-L2635
    Android queue#L49-L59
    当前只把 native 解码/写盘改成串行,但还有两个未封口窗口:

  • destWidth、destHeight 只校验有限数,没有尺寸或像素面积上限。代码会先创建原始 RGBA outputCanvas 并同步执行 toDataURL(),可能在进入 native 32 MB 检查前就占用数百 MB并阻塞渲染线程。
  • message.invoke() 后任务立即结束,不等待 native complete。调用方可以快速生成并入队多份最大约 44 MB 的 Base64;串行队列中的每个闭包仍保留自己的字符串,十次调用即可保留约 440 MB。
    “拒绝时字符串已经生成”不能完全消除这个问题:拒绝后 native 桥接副本可以释放;也可以在 FE 侧等待前一次 complete 后再生成下一份,而不是改变循环导出的成功语义。
    建议在创建 outputCanvas 前做安全的宽高/像素面积检查,并增加 completion-aware 的 per-app 背压或有界排队测试。
  1. [P1] Android/iOS runtime 销毁后会留下无人可访问的大文件
    Android ImageApi.kt#L470-L483
    Android ImageApi.kt#L505-L513
    iOS ImageAPI.swift#L197-L219
    导出期间 exit/restart 后:
  • Android/iOS 后台任务继续解码并发布最终文件。
  • 回调会因旧 JsCore 或弱引用 container 失效而被丢弃。
  • 已发布文件没有返回给任何 runtime,也没有清理路径。
    Harmony 已通过 generation 校验删除这种文件,Android/iOS 尚未处理。尤其 iOS 的所谓 tmp 位于 Documents/Dimina,不是系统临时目录,不能依赖进程结束自动清理。
    这由本 PR 的异步化直接打开,不建议只留给未来的全局清扫。应给任务绑定 runtime owner/generation,失效时取消待执行任务或删除最终文件,并增加 exit/restart 竞态测试。

@lbb00
lbb00 marked this pull request as draft August 19, 2026 06:00
@lbb00

lbb00 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

第三轮四条和后续独立复审发现的问题都已闭环,最终修复在 a107f73

  1. type 切换破坏 canvas-id 登记

    desiredKey() 同时读取 canvasIdtype,由 watchEffect 追踪完整依赖。legacy → 2D 会释放 claim,2D → legacy 会重新 claim,原本因重复隐藏的节点转成 2D 后会恢复。三种路径都走真实 Canvas.vue 回归测试。

  2. moduleId 泄漏进 target.dataset

    owner 改为共享的非 data-* DOM property,组件和 runtime 不再各写私有字面量。tap 与 touch 载荷测试都断言小程序 dataset 只含开发者声明字段;render 中另一处归属写入也一并改掉。

  3. 导出峰值内存

    FE 在创建 output canvas、getImageData()Array.from()toDataURL() 前统一检查单边和像素面积;超限测试明确断言不会进入分配或编码。Android、iOS、HarmonyOS 的 registry 同时限制每 app 未结算请求数和累计 Base64 字节,第三个请求在持有 native bridge 副本前拒绝。

    这里没有采用“FE 等前一次 complete 才生成下一份”的方案。那会把合法的循环导出强制串行并延长总耗时;真正持有 bridge Base64 闭包、负责释放 native 内存的是三端 registry,因此把有界背压放在这一层。当前选择是最多 2 个未结算请求,超出时明确失败,不做无界排队。

  4. Android / iOS 销毁后残留文件

    三端任务都绑定 owner / generation。runtime 销毁先推进 generation、取消未开始任务;已经开始的任务继续计入预算直到终态,但不能向新 runtime 投递。若旧任务已经发布文件,结算点会删除文件。回归覆盖旧 runtime 导出拒绝交付,以及不可交付文件被删除。

本轮另外新增了两项实现与验证:

  • canvas touch 的 4 px 坐标偏移已修复。 组件化后 relativeTo 曾错用 border 内侧的真实 canvas,触摸坐标因此整体少 4 px;现在明确改为开发者声明的 canvas 根节点 border box,回归测试会在恢复旧基准时从 (40, 40) 精确失败为 (36, 36)。HarmonyOS Pura 90 emulator 的页面探针也确认:根节点原点为 (32, 134.5714),触点 client - (x, y) 得到同一组值,没有再落到内层 canvas 的 4 px 内缩上。
  • 新增新编译包统一走 dd-canvas 的实现。 根节点负责布局 / dataset / 事件,内部 canvas 负责 node 与绘图。runtime 同时保留旧编译包的原生 canvas 路径;旧包 + 新基础库实测可用,测试还覆盖页面/组件同名 id、组件根 canvas 和特殊字符 id。

在此之后又做了一轮完整独立 review,继续修了 layout → backing store 预算绕过、动态 resolver、小游戏替换后的 render 资源残留、callback / RAF / ImageData 生命周期、iOS Unicode Base64 预算绕过,以及 Canvas2D setter/readback、save/restore/reset/resize 和旧 WebKit fallback。最终独立复查没有留下未闭环 finding。

验证:

  • FE 1683 个用例全部实际通过;lint、production build、diff-check 通过。
  • Android 261,iOS 279,全部 0 失败。
  • HarmonyOS unit task 和 HAP build 通过。
  • Android 35 Pixel 7 AVD、iOS Simulator、HarmonyOS 6.1.1 / API 24 Pura 90 emulator 的 legacy Canvas 页面链路各 59/59。
  • 最终 service bundle 已由固定 QuickJS 加载,SDK / JSApp 归档与最终 dist 一致。

@lbb00
lbb00 force-pushed the worktree-canvas-peripherals-pr branch 4 times, most recently from 7db3af6 to 9d08111 Compare August 26, 2026 03:15
@lbb00

lbb00 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

上一条之后又做了几轮独立复审,修复已经 amend 进同一个 commit(现为 9d08111),描述同步更新。

1. 多指下的 tap 判定

原实现在第二根手指按下时立刻抑制 tap 并派发 canceltap。这个时机微信公开文档没有定义,用探针小程序在微信开发者工具上实测:第一根手指原地抬起仍然产生 tap。现在多指只取消长按计时器(longpress / longtap 是单指手势),不重置起点,也不影响先落下那根手指的判定。

同时明确不采用 glass-easel 按 identifier 逐触点记 possibleTaps 的模型:一个手势所有者只维护一条触摸序列,多指同时按下时只有建立序列的那根手指产生 tap / canceltap,其余触点只透传原生 touchstart / touchmove / touchend。这条差异写进了触摸事件与手势 §7

2. canceltap 的条数与 touchend 终点复查

同一个探针实测:越过位移阈值后,之后每一条 touchmove 都各带一条 canceltap,不是整段序列只发一条。另外 touchmove 可能一条都不到(被 preventDefault 吞掉,或系统把移动并进抬手),因此 touchend 会按起点与终点再判一次距离,超过阈值改发 canceltap 而不合成 taptouchend 复查与 touchcancel 这两条补发路径只在整段序列还没发过 canceltap 时生效。回归测试断言序列为 ['touchmove', 'canceltap', 'touchmove', 'canceltap']

3. 节点卸载后仍会补造长按

longpress / longtap 由所有者自己的计时器合成。卸载时若原生序列仍在进行,所有者会延迟到真实 touchend / touchcancel 再摘除(不伪造终态),但此前没有停表,节点离开树之后仍可能收到一次 longpress。现在卸载路径带 nodeRemoved 标记并立即清计时器;bind / catch 变化引起的换 owner 不带这个标记,节点仍在树上,长按照常触发。新增 gesture-detach-on-unmount.spec.js 覆盖这两条路径。

4. HarmonyOS 编译回归

本 PR 给 DMPContainerBridgesModule 加了 protected currentAppId(),而 +Image+Video+ScreenNetwork/+Http 四个子类各有一份逐字相同的 private currentAppId()。ArkTS 不允许子类收窄继承成员的可见性,dimina:default@UnitTestArkTS 报 9 个 10505001。已删掉四处重复实现,统一继承基类。

5. JSSDK 重新生成

上面的手势改动落在 fe/packages/components 之后,shared/jssdk/main.zip 一度落后于源码。现已用 pnpm build + pnpm generate:sdk 从 production dist 重新生成,包内 service.jspageFrame.jsfe/packages/container/dist/assets 逐字节一致;config.jsonversionCode 也复位成基于 main 的 33,不再带本地反复生成累积的计数。

文档同步:触摸事件与手势第 1、3、6、7 节按上述语义重写;Canvas 运行架构补充了 draw(reserve) 只决定像素去留、不决定绘图状态的说明。

旧版 wx.createCanvasContext() 补齐三端能力。canvas-id 按「页面 + 自定义组件实例」登记,
归属与 active 状态走 DOM property,不进入小程序事件的 target.dataset。组件根节点承载小程序
声明的 id、dataset 和布局,内部 HTMLCanvasElement 承载绘图,两者用共享的 DOM contract 显式
关联,SelectorQuery 的 node 不会误取 slot 里的 canvas。

绘制、像素读写与导出按真实 canvas 元素串行,不同 canvas 互不阻塞;作用域内的节点 lookup 单独
排队,避免后发请求越过还在等节点挂载的先发请求。Canvas 2D 状态以渲染层宿主 context 为权威,
逻辑层提供同步 optimistic getter 并以单调序号接收 readback,迟到反馈不覆盖新值;宿主缺少
reset() 时重设同尺寸 backing store 完成全量重置。

位图与 JSON 传输预算收拢到 @dimina/common 一处,组件 renderWidth/renderHeight、布局同步、
node width/height setter、离屏与小游戏创建、legacy 像素 API 和 canvasToTempFilePath 共用同一
组限制,超限在分配输出位图与编码之前失败。

Android、iOS、HarmonyOS 的导出请求绑定 app owner 与 runtime generation:退出或重启先推进
generation 并取消尚未开始的请求,已开始的继续占用并发与字节配额,旧 generation 的结果不投递、
已发布的文件立即删除,success/fail 与 complete 在同一串行域结算。

draw(reserve) 只保留像素、不保留绘图状态:渲染层每批开一层基线 save 帧,下批开始时弹回默认
样式、变换与裁剪区,画面不动;批内未配平的 restore() 停在批边界,弹不走基线帧。

触摸手势统一由 touchGestures 合成 tap/longpress/longtap/canceltap,按 owner 记账触点,不再
依赖全局 event.touches;label 默认不抑制内层组件的激活态。取消语义按微信实测对齐:越过位移
阈值后每条 touchmove 各发一条 canceltap,touchend 再比一次起终点距离、超阈值改发 canceltap,
这两条补发路径只在整段序列还没发过 canceltap 时生效;额外触点只停长按计时器,不取消先落手指
的 tap。节点卸载时进行中的序列仍等真实终态收口,但立即停掉长按计时器,不给已离树的节点补造
longpress/longtap。一个手势所有者只维护一条序列,不采用 glass-easel 按 identifier 逐触点
记账的模型。

DMPContainerBridgesModule 上移 currentAppId() 为 protected,Image/Video/Screen/Http 四个
子类删除各自逐字相同的 private 实现:ArkTS 不允许子类收窄继承成员的可见性。

新增 docs/canvas-architecture.md、docs/touch-event-architecture.md 记录上述不变量。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lbb00
lbb00 force-pushed the worktree-canvas-peripherals-pr branch from 9d08111 to 3cbf926 Compare August 26, 2026 03:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants