From 695a4c8639202973fd8e02129110ab07c3e1bfc3 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Mon, 24 Aug 2026 14:27:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E5=AE=8C=E5=96=84=E6=97=A7?= =?UTF-8?q?=E7=89=88=20CanvasContext=20=E8=B7=A8=E7=AB=AF=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E5=B9=B6=E7=BB=9F=E4=B8=80=E8=A7=A6=E6=91=B8=E6=89=8B?= =?UTF-8?q?=E5=8A=BF=E4=B8=8E=20label=20=E6=BF=80=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 旧版 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 逐触点 记账的模型。自定义组件的宿主节点接入同一条链路,由 ComponentHost 用 useTouchEvents 安装,不再 靠原生 click 派发 tap —— click 排在祖先的合成 tap 之后,宿主上的 catchtap 拦不住祖先的 bindtap。 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) --- .../com/didi/dimina/api/media/ImageApi.kt | 363 ++++++- .../com/didi/dimina/common/PathUtils.kt | 24 +- .../kotlin/com/didi/dimina/core/MiniApp.kt | 6 + .../api/media/ImageApiCanvasValidationTest.kt | 462 +++++++++ .../common/PathUtilsStorageConfinementTest.kt | 46 + docs/API-Reference.md | 2 + docs/Mini-Game.md | 4 + docs/README.md | 8 +- docs/canvas-architecture.md | 86 ++ docs/touch-event-architecture.md | 159 +++ fe/packages/common/src/core/callback.js | 4 +- fe/packages/common/src/core/canvas-limits.js | 55 + fe/packages/common/src/core/dom-contract.js | 20 + fe/packages/common/src/index.js | 17 + .../__tests__/canvas-component-path.spec.js | 82 ++ .../src/common/compatibility-reference.js | 2 + fe/packages/compiler/src/common/utils.js | 2 +- .../compiler/src/core/view-compiler.js | 2 +- .../__tests__/bare-canvas-gesture.spec.js | 476 +++++++++ .../__tests__/canvas-event-payload.spec.js | 130 +++ .../__tests__/canvas-id-scope.spec.js | 318 ++++++ .../__tests__/click-owner-tap.spec.js | 123 +++ .../__tests__/component-alignment.spec.js | 16 +- .../__tests__/component-host-gesture.spec.js | 147 +++ .../component-host-structure.spec.js | 4 +- .../container-event-current-target.spec.js | 256 +++++ .../gesture-detach-on-unmount.spec.js | 158 +++ .../gesture-reinstall-during-sequence.spec.js | 178 ++++ .../__tests__/label-activation.spec.js | 461 +++++++++ .../components/__tests__/slider.spec.js | 49 + .../touch-gesture-regressions.spec.js | 543 ++++++++++ .../__tests__/touch-tap-contract.spec.js | 497 +++++++++ fe/packages/components/index.js | 108 +- fe/packages/components/src/common/events.js | 132 ++- .../components/src/common/labelActivation.js | 68 ++ .../src/common/touchGesturePrimitives.js | 179 ++++ .../components/src/common/touchGestures.js | 568 ++++++++++ .../components/src/common/useTapEvents.js | 13 - .../components/src/common/useTouchEvents.js | 221 ++-- .../src/component/button/Button.vue | 44 +- .../src/component/canvas/Canvas.vue | 195 +++- .../checkbox-group/CheckboxGroup.vue | 6 +- .../src/component/checkbox/Checkbox.vue | 42 +- .../component-host/ComponentHost.vue | 19 +- .../components/src/component/form/Form.vue | 8 +- .../components/src/component/image/Image.vue | 22 +- .../components/src/component/input/Input.vue | 11 + .../components/src/component/label/Label.vue | 47 +- .../component/movable-area/MovableArea.vue | 8 +- .../src/component/navigator/Navigator.vue | 19 +- .../picker-view-column/PickerViewColumn.vue | 14 +- .../src/component/picker-view/PickerView.vue | 3 + .../src/component/picker/Picker.vue | 14 +- .../src/component/radio-group/RadioGroup.vue | 6 +- .../components/src/component/radio/Radio.vue | 38 +- .../src/component/slider/Slider.vue | 38 +- .../src/component/switch/Switch.vue | 50 +- .../components/src/component/text/Text.vue | 13 +- .../src/component/textarea/Textarea.vue | 3 +- .../components/src/component/view/View.vue | 22 +- .../__tests__/canvas-export-limits.spec.js | 104 ++ .../__tests__/canvas-export-quality.spec.js | 58 ++ .../__tests__/canvas-replay-actions.spec.js | 224 ++++ .../canvas-replay-draw-queue.spec.js | 265 +++++ .../canvas-replay-draw-state.spec.js | 249 +++++ .../canvas-replay-export-pixels.spec.js | 336 ++++++ .../canvas-replay-regressions.spec.js | 231 +++++ .../canvas-replay-resilience.spec.js | 165 +++ .../__tests__/canvas-replay-test-utils.js | 304 ++++++ .../__tests__/mini-game-runtime.spec.js | 48 + fe/packages/render/__tests__/runtime.spec.js | 677 ++++++++++++ .../render/src/core/canvas-export-limits.js | 37 + fe/packages/render/src/core/runtime.js | 973 ++++++++++++++---- .../service/__tests__/api-common.spec.js | 52 + .../canvas-context-non-finite-guard.spec.js | 415 ++++++++ .../canvas-context-nonfinite-gaps.spec.js | 166 +++ .../__tests__/canvas-legacy-drawing.spec.js | 445 ++++++++ .../__tests__/canvas-legacy-lifecycle.spec.js | 169 +++ .../canvas-legacy-text-style.spec.js | 450 ++++++++ .../__tests__/canvas-pixel-transport.spec.js | 140 +++ fe/packages/service/__tests__/canvas.spec.js | 564 +++++++++- fe/packages/service/package.json | 3 +- fe/packages/service/src/api/common/index.js | 32 +- .../src/api/core/ui/canvas/canvas-context.js | 446 ++++++++ .../src/api/core/ui/canvas/canvas-node.js | 771 +++++++++++--- .../src/api/core/ui/canvas/canvas-style.js | 187 ++++ .../service/src/api/core/ui/canvas/index.js | 187 +++- fe/packages/service/src/core/loader.js | 3 +- .../ets/Bridges/CanvasExportJobRegistry.ets | 92 ++ .../DMPContainerBridgesModule+Canvas.ets | 271 ++++- .../DMPContainerBridgesModule+File.ets | 4 - .../DMPContainerBridgesModule+Image.ets | 4 - .../DMPContainerBridgesModule+Screen.ets | 4 - .../DMPContainerBridgesModule+Video.ets | 4 - .../ets/Bridges/DMPContainerBridgesModule.ets | 10 + .../DMPContainerBridgesModule+Http.ets | 4 - .../ets/Bundle/Util/DMPFilePathResolver.ets | 2 +- .../ets/Bundle/Util/DMPFileUrlConvertor.ets | 31 +- harmony/dimina/src/main/ets/DApp/DMPApp.ets | 9 + .../Render/SameLayer/DMPVideoComponent.ets | 4 +- .../Render/UrlIntercepter/UrlInterceptor.ets | 3 +- .../src/test/DMPCanvasCallback.test.ets | 44 + .../test/DMPCanvasExportJobRegistry.test.ets | 72 ++ .../test/DMPCanvasExportWriteQueue.test.ets | 68 ++ .../src/test/DMPFileUrlConvertor.test.ets | 31 + harmony/dimina/src/test/List.test.ets | 8 + .../DMPServiceRenderOrderTests.swift | 65 ++ iOS/dimina.xcodeproj/project.pbxproj | 8 + iOS/dimina/DiminaKit/App/DMPApp.swift | 5 + .../Container/Api/Media/ImageAPI.swift | 380 +++++++ .../DiminaKit/Container/DMPChannelProxy.swift | 4 +- .../DiminaKit/Render/DMPWebViewPublish.swift | 9 +- iOS/dimina/DiminaKit/Service/DMPService.swift | 8 +- .../CanvasTempFileValidationTests.swift | 349 +++++++ shared/jsapp/wx92269e3b2f304afc/config.json | 4 +- .../wx92269e3b2f304afc/wx92269e3b2f304afc.zip | Bin 144499 -> 145111 bytes shared/jsapp/wx9f8c2d7e6a5b4c3d/config.json | 4 +- .../wx9f8c2d7e6a5b4c3d/wx9f8c2d7e6a5b4c3d.zip | Bin 5822 -> 5822 bytes shared/jsapp/wxe5f52902cf4de896/config.json | 4 +- .../wxe5f52902cf4de896/wxe5f52902cf4de896.zip | Bin 312102 -> 300055 bytes shared/jssdk/config.json | 4 +- shared/jssdk/main.zip | Bin 171848 -> 186434 bytes 122 files changed, 14892 insertions(+), 929 deletions(-) create mode 100644 android/dimina/src/test/java/com/didi/dimina/api/media/ImageApiCanvasValidationTest.kt create mode 100644 android/dimina/src/test/java/com/didi/dimina/common/PathUtilsStorageConfinementTest.kt create mode 100644 docs/canvas-architecture.md create mode 100644 docs/touch-event-architecture.md create mode 100644 fe/packages/common/src/core/canvas-limits.js create mode 100644 fe/packages/common/src/core/dom-contract.js create mode 100644 fe/packages/compiler/__tests__/canvas-component-path.spec.js create mode 100644 fe/packages/components/__tests__/bare-canvas-gesture.spec.js create mode 100644 fe/packages/components/__tests__/canvas-event-payload.spec.js create mode 100644 fe/packages/components/__tests__/canvas-id-scope.spec.js create mode 100644 fe/packages/components/__tests__/click-owner-tap.spec.js create mode 100644 fe/packages/components/__tests__/component-host-gesture.spec.js create mode 100644 fe/packages/components/__tests__/container-event-current-target.spec.js create mode 100644 fe/packages/components/__tests__/gesture-detach-on-unmount.spec.js create mode 100644 fe/packages/components/__tests__/gesture-reinstall-during-sequence.spec.js create mode 100644 fe/packages/components/__tests__/label-activation.spec.js create mode 100644 fe/packages/components/__tests__/touch-gesture-regressions.spec.js create mode 100644 fe/packages/components/__tests__/touch-tap-contract.spec.js create mode 100644 fe/packages/components/src/common/labelActivation.js create mode 100644 fe/packages/components/src/common/touchGesturePrimitives.js create mode 100644 fe/packages/components/src/common/touchGestures.js delete mode 100644 fe/packages/components/src/common/useTapEvents.js create mode 100644 fe/packages/render/__tests__/canvas-export-limits.spec.js create mode 100644 fe/packages/render/__tests__/canvas-export-quality.spec.js create mode 100644 fe/packages/render/__tests__/canvas-replay-actions.spec.js create mode 100644 fe/packages/render/__tests__/canvas-replay-draw-queue.spec.js create mode 100644 fe/packages/render/__tests__/canvas-replay-draw-state.spec.js create mode 100644 fe/packages/render/__tests__/canvas-replay-export-pixels.spec.js create mode 100644 fe/packages/render/__tests__/canvas-replay-regressions.spec.js create mode 100644 fe/packages/render/__tests__/canvas-replay-resilience.spec.js create mode 100644 fe/packages/render/__tests__/canvas-replay-test-utils.js create mode 100644 fe/packages/render/src/core/canvas-export-limits.js create mode 100644 fe/packages/service/__tests__/canvas-context-non-finite-guard.spec.js create mode 100644 fe/packages/service/__tests__/canvas-context-nonfinite-gaps.spec.js create mode 100644 fe/packages/service/__tests__/canvas-legacy-drawing.spec.js create mode 100644 fe/packages/service/__tests__/canvas-legacy-lifecycle.spec.js create mode 100644 fe/packages/service/__tests__/canvas-legacy-text-style.spec.js create mode 100644 fe/packages/service/__tests__/canvas-pixel-transport.spec.js create mode 100644 fe/packages/service/src/api/core/ui/canvas/canvas-context.js create mode 100644 fe/packages/service/src/api/core/ui/canvas/canvas-style.js create mode 100644 harmony/dimina/src/main/ets/Bridges/CanvasExportJobRegistry.ets create mode 100644 harmony/dimina/src/test/DMPCanvasCallback.test.ets create mode 100644 harmony/dimina/src/test/DMPCanvasExportJobRegistry.test.ets create mode 100644 harmony/dimina/src/test/DMPCanvasExportWriteQueue.test.ets create mode 100644 harmony/dimina/src/test/DMPFileUrlConvertor.test.ets create mode 100644 iOS/DiminaKitTests/DMPServiceRenderOrderTests.swift create mode 100644 iOS/diminaTests/CanvasTempFileValidationTests.swift diff --git a/android/dimina/src/main/kotlin/com/didi/dimina/api/media/ImageApi.kt b/android/dimina/src/main/kotlin/com/didi/dimina/api/media/ImageApi.kt index 969811ca..1940e5ca 100644 --- a/android/dimina/src/main/kotlin/com/didi/dimina/api/media/ImageApi.kt +++ b/android/dimina/src/main/kotlin/com/didi/dimina/api/media/ImageApi.kt @@ -12,6 +12,7 @@ import com.didi.dimina.api.AsyncResult import com.didi.dimina.api.BaseApiHandler import com.didi.dimina.api.NoneResult import com.didi.dimina.common.ApiUtils +import com.didi.dimina.common.LogUtils import com.didi.dimina.common.PathUtils import com.didi.dimina.common.Utils import com.didi.dimina.common.MediaFileUtils @@ -23,28 +24,266 @@ import org.json.JSONArray import org.json.JSONObject import java.io.File import java.io.FileOutputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.Base64 import java.util.UUID +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +/** + * canvasToTempFilePath hands the same result to success/fail and to complete: mini programs read + * res.errMsg and res.tempFilePath inside complete, and complete without a result hands them + * undefined. + */ +internal fun canvasTempFileFailure(reason: String): AsyncResult = AsyncResult( + value = JSONObject().apply { put("errMsg", "canvasToTempFilePath:fail $reason") }, + completeCarriesResult = true, +) + +// Internal bridge safety ceiling, not a WeChat Canvas API limit. It bounds the +// extra native allocation and temp-file write after the data URL crosses the bridge. +internal const val MAX_CANVAS_IMAGE_BYTES = 32 * 1024 * 1024 +internal const val MAX_CANVAS_BASE64_CHARS = (MAX_CANVAS_IMAGE_BYTES * 4 / 3) + 8 + +// 排队中的每个请求都各自持有一份 base64 副本,单次上限只约束其中一份。允许「一份在写盘、 +// 一份在等」,再多就是纯堆积。预算是在把字符串交给后台协程之前判的,被拒的那份随即可回收。 +internal const val MAX_IN_FLIGHT_CANVAS_EXPORTS = 2 +internal const val MAX_PENDING_CANVAS_BASE64_CHARS = + MAX_IN_FLIGHT_CANVAS_EXPORTS.toLong() * MAX_CANVAS_BASE64_CHARS + +/** + * 同一个小程序同时只做一次解码加写盘,单次解码上限已经是 32 MB。锁只约束解码与写盘阶段, + * 排队中的每个请求仍各自持有自己那份 base64 字符串,所以这里同时按 app 记账,把还没轮到的 + * 总量也框住。预留同时绑定发起时的代次,退出途中到达的请求不会被算进新一代。 + */ +internal class CanvasExportReservation internal constructor( + val appId: String, + val generation: Long, + val chars: Int, + internal val mutex: Mutex, + internal var payload: String?, +) { + internal var started = false + internal var cancelled = false + internal var job: Job? = null +} + +internal object CanvasExportQueue { + private data class AppState( + var generation: Long = 0, + val jobs: LinkedHashSet = linkedSetOf(), + // 锁按 appId 唯一,跨代次共用。销毁只取消还没开始的任务,已经进入临界区的旧任务会继续 + // 解码和写盘;锁一旦按代次分片,新 runtime 的任务就会拿到另一把锁,和旧任务并发各占一份位图。 + val mutex: Mutex = Mutex(), + ) + + private val monitor = Any() + private val states = mutableMapOf() + + private fun state(appId: String) = states.getOrPut(appId) { AppState() } + + /** + * [expectedGeneration] 是调用方在请求进入容器那一刻记下的代次。记账和代次比对必须在同一个 + * monitor 里完成:调用线程与推进代次的主线程是并发的,分两次读会让退出途中到达的请求被记成 + * 新一代的,写出来的文件既不会交付也不会被清理,还白占新 runtime 的并发名额。 + */ + fun reserve( + appId: String, + chars: Int, + payload: String? = null, + expectedGeneration: Long? = null, + ): CanvasExportReservation? = synchronized(monitor) { + val state = state(appId) + if (expectedGeneration != null && state.generation != expectedGeneration) return@synchronized null + val pendingChars = state.jobs.sumOf { it.chars.toLong() } + if (state.jobs.size >= MAX_IN_FLIGHT_CANVAS_EXPORTS + || pendingChars + chars > MAX_PENDING_CANVAS_BASE64_CHARS) return@synchronized null + CanvasExportReservation( + appId = appId, + generation = state.generation, + chars = chars, + mutex = state.mutex, + payload = payload, + ).also(state.jobs::add) + } + + fun attach(reservation: CanvasExportReservation, job: Job) = synchronized(monitor) { + val active = state(reservation.appId).jobs.contains(reservation) && !reservation.cancelled + if (active) reservation.job = job else job.cancel() + } + + private fun begin(reservation: CanvasExportReservation): String? = synchronized(monitor) { + val active = state(reservation.appId).jobs.contains(reservation) && !reservation.cancelled + if (!active) return@synchronized null + reservation.started = true + reservation.payload.also { reservation.payload = null } + } + + suspend fun run(reservation: CanvasExportReservation, block: suspend (String) -> T): T? = + reservation.mutex.withLock { + val payload = begin(reservation) ?: return@withLock null + block(payload) + } + + fun finish(reservation: CanvasExportReservation) { + synchronized(monitor) { + reservation.payload = null + state(reservation.appId).jobs.remove(reservation) + } + } + + fun invalidate(appId: String) = synchronized(monitor) { + val state = state(appId) + state.generation++ + val pending = state.jobs.filterNot { it.started } + for (reservation in pending) { + reservation.cancelled = true + reservation.payload = null + reservation.job?.cancel() + state.jobs.remove(reservation) + } + } + + fun currentGeneration(appId: String): Long = synchronized(monitor) { state(appId).generation } + fun isCurrent(appId: String, generation: Long): Boolean = synchronized(monitor) { + state(appId).generation == generation + } + fun pendingChars(appId: String): Long = synchronized(monitor) { + state(appId).jobs.sumOf { it.chars.toLong() } + } +} + +/** + * 导出发起时属于某一代 runtime。小程序退出或重启后,回调已经没有接收方,写出去的文件也不会 + * 有任何人来取,所以结算时必须按代次判断,而不是看这个 appId 现在是不是又活着。 + */ +internal object CanvasExportGeneration { + fun current(appId: String): Long = CanvasExportQueue.currentGeneration(appId) + + @androidx.annotation.MainThread + fun invalidate(appId: String) { + CanvasExportQueue.invalidate(appId) + } +} + +/** + * 结算这次导出该不该交给小程序:发起它的那一代 runtime 已经不在时就不该交。 + */ +internal fun shouldDeliverCanvasExport(appId: String, generation: Long): Boolean = + CanvasExportQueue.isCurrent(appId, generation) + +/** + * 丢弃一次没有接收方的导出:已经发布的文件不会有人来取,留着就是永久占用临时目录。 + */ +internal fun discardPublishedCanvasFile(tempRoot: File, result: JSONObject) { + val publishedName = File(result.optString("tempFilePath")).name + if (publishedName.isEmpty()) return + // 只按文件名在临时目录里删,虚拟路径不参与拼接:结算路径不该有能被入参左右的目录跳转。 + runCatching { File(tempRoot, publishedName).delete() } +} + +/** + * 把“这一代是否仍存活”与实际回调放在同一个主线程结算点。runtime 销毁也在主线程更新代次, + * 因而两者只能按先后完整执行,不会再出现后台检查通过、等待切主线程期间 owner 已失效的窗口。 + * dispatcher 作为参数只用于让单元测试确定性地暂停这个结算点;生产始终使用 Main.immediate。 + */ +internal suspend fun deliverCanvasExport( + appId: String, + generation: Long, + tempRoot: File?, + result: JSONObject, + deliveryDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate, + dispatch: (JSONObject) -> Unit, +): Boolean = withContext(deliveryDispatcher) { + if (!shouldDeliverCanvasExport(appId, generation)) { + tempRoot?.let { discardPublishedCanvasFile(it, result) } + return@withContext false + } + dispatch(result) + true +} + +/** + * 按同步路径(MiniApp.invokeAPI)的契约派发结果:errMsg 以 ":ok" 结尾走 success,否则走 fail, + * complete 无论如何都发且携带同一个 result。canvas 的写盘挪到后台后没有人再替它做这件事, + * 所以这段是手写的,单独可测。 + */ +internal fun dispatchCanvasResult( + params: JSONObject, + result: JSONObject, + responseCallback: (String) -> Unit, +) { + try { + if (result.optString("errMsg").endsWith(":ok")) { + ApiUtils.invokeSuccess(params, result, responseCallback) + } else { + ApiUtils.invokeFail(params, result, responseCallback) + } + } finally { + ApiUtils.invokeComplete(params, responseCallback, result) + } +} + +internal fun canvasTempFileSuccess(tempFilePath: String): AsyncResult = AsyncResult( + value = JSONObject().apply { + put("tempFilePath", tempFilePath) + put("errMsg", "canvasToTempFilePath:ok") + }, + completeCarriesResult = true, +) + /** * Author: Doslin */ class ImageApi : BaseApiHandler() { private companion object { const val SAVE_IMAGE_TO_PHOTOS_ALBUM = "saveImageToPhotosAlbum" + const val SAVE_CANVAS_TEMP_FILE = "saveCanvasTempFile" const val PREVIEW_IMAGE = "previewImage" const val COMPRESS_IMAGE = "compressImage" const val CHOOSE_IMAGE = "chooseImage" const val CHOOSE_MESSAGE_FILE = "chooseMessageFile" const val GET_IMAGE_INFO = "getImageInfo" const val PREVIEW_MEDIA = "previewMedia" + + val SAFE_APP_ID = Regex("^[A-Za-z0-9._-]+$") + val STRICT_BASE64 = Regex("^[A-Za-z0-9+/]*={0,2}$") + + // 每个页面是自己的 DiminaActivity(navigateTo 走 startActivity),而 QuickJS 按 appId 共享。 + // 绑 activity.lifecycleScope 的话,用户在写盘途中返回上一页就会取消协程,success/fail/complete + // 一个都不发,等 complete 的小程序永远挂住。写盘的真正归属是 JS 引擎而不是页面,所以用独立 + // scope;引擎已销毁时迟到的回调由 JsCore.postMessage 丢弃。 + // 同步路径上派发回调抛错会被 MiniApp.invokeAPI 接住转成 fail,进程照常活着。挪进协程之后 + // 没有处理器的未捕获异常会走 Thread.uncaughtExceptionHandler 直接崩宿主,所以这里补一个。 + val canvasIoScope = CoroutineScope( + SupervisorJob() + Dispatchers.IO + + CoroutineExceptionHandler { _, error -> + LogUtils.e("ImageApi", "canvas export failed: $error") + }, + ) } override val apiNames = - setOf(SAVE_IMAGE_TO_PHOTOS_ALBUM, PREVIEW_IMAGE, COMPRESS_IMAGE, CHOOSE_IMAGE, CHOOSE_MESSAGE_FILE, - GET_IMAGE_INFO, PREVIEW_MEDIA) + setOf( + SAVE_IMAGE_TO_PHOTOS_ALBUM, + SAVE_CANVAS_TEMP_FILE, + PREVIEW_IMAGE, + COMPRESS_IMAGE, + CHOOSE_IMAGE, + CHOOSE_MESSAGE_FILE, + GET_IMAGE_INFO, + PREVIEW_MEDIA, + ) override fun handleAction( activity: DiminaActivity, @@ -54,6 +293,8 @@ class ImageApi : BaseApiHandler() { responseCallback: (String) -> Unit, ): APIResult { return when (apiName) { + SAVE_CANVAS_TEMP_FILE -> saveCanvasTempFile(activity, appId, params, responseCallback) + SAVE_IMAGE_TO_PHOTOS_ALBUM -> { val filePath = params.optString("filePath") if (PathUtils.isLegalPath(filePath)) { @@ -452,4 +693,122 @@ class ImageApi : BaseApiHandler() { } + private fun saveCanvasTempFile( + activity: DiminaActivity, + appId: String, + params: JSONObject, + responseCallback: (String) -> Unit, + ): APIResult { + fun failure(reason: String) = canvasTempFileFailure(reason) + + // 这条调用来自 render 的 WebView JavaBridge 线程(DiminaWebView.invoke → Bridge 的 + // handleApiInvocation 一路没有线程跳转),而代次推进在主线程。发起时属于哪一代必须当场 + // 记下,等校验完这最长 44 MB 的字符串再去读,中途退出的请求就会被当成新一代的。 + val generation = CanvasExportGeneration.current(appId) + + val dataURL = params.optString("dataURL") + if (dataURL.isEmpty()) return failure("dataURL is required") + val fileType = params.optString("fileType", "png") + if (fileType != "png" && fileType != "jpg") return failure("invalid file type") + if (!isValidCanvasAppId(appId)) return failure("invalid appId") + + val prefix = Regex("^data:image/(png|jpeg|jpg);base64,").find(dataURL) + if (dataURL.startsWith("data:") && prefix == null) return failure("invalid dataURL") + val declaredType = prefix?.groupValues?.get(1) + if (declaredType != null && declaredType != fileType && !(declaredType == "jpeg" && fileType == "jpg")) { + return failure("file type mismatch") + } + val base64Data = prefix?.let { dataURL.substring(it.range.last + 1) } ?: dataURL + if (base64Data.isEmpty() || base64Data.length > MAX_CANVAS_BASE64_CHARS + || base64Data.length % 4 != 0 || !STRICT_BASE64.matches(base64Data)) { + return failure(if (base64Data.length > MAX_CANVAS_BASE64_CHARS) "data too large" else "base64 decode failed") + } + + // 解码最多 32 MB 的 base64 再落盘是这条链上唯一的重活,放在调用线程上会占住 render 的 + // JavaBridge 线程,后面的 render 消息全部排在它后面。参数校验很便宜,留在调用线程上以 + // 保持同步失败语义。 + val context = activity.applicationContext + // 排队中的每个请求都各自留着自己那份 base64,等轮到它才释放。预算判在启动协程之前: + // 到了协程里再拒,这份副本已经被闭包捕获,拒绝就省不下内存了。 + val reservation = CanvasExportQueue.reserve(appId, base64Data.length, base64Data, generation) + if (reservation == null) { + // 拒绝已经发生,这里再查一次原因不影响记账,只为了让失败可诊断。 + return if (!CanvasExportQueue.isCurrent(appId, generation)) { + failure("runtime unavailable") + } else { + failure("too many pending exports") + } + } + val exportJob = canvasIoScope.launch { + // appTempRoot 自己也做 canonicalFile 与 createDirectories,并且会对符号链接抛错。 + // 它必须留在这个 try 里:抛出去就没有人再发 complete,小程序会永远等下去。 + val tempRoot = runCatching { PathUtils.appTempRoot(context, appId) }.getOrNull() + val result = try { + CanvasExportQueue.run(reservation) { reservedPayload -> + writeCanvasTempFile(requireNotNull(tempRoot), reservedPayload, fileType) + } ?: return@launch + } catch (cancellation: CancellationException) { + // 代次失效会取消排队中的任务。取消不是写盘失败,吞掉它会让协程继续往下派发 + // 一个本不该存在的结果,也会让 invokeOnCompletion 收不到取消。 + throw cancellation + } catch (_: Exception) { + canvasTempFileFailure("write failed").value + } + deliverCanvasExport( + appId = appId, + generation = reservation.generation, + tempRoot = tempRoot, + result = result, + ) { deliveredResult -> + dispatchCanvasResult(params, deliveredResult, responseCallback) + } + } + CanvasExportQueue.attach(reservation, exportJob) + exportJob.invokeOnCompletion { CanvasExportQueue.finish(reservation) } + return NoneResult() + } + + /** + * Decodes the canvas data URL payload and publishes it into the app temp directory. Returns the + * same result shape the synchronous validation failures use, so both paths reach the mini + * program identically. + */ + internal fun writeCanvasTempFile(tempRoot: File, base64Data: String, fileType: String): JSONObject { + val imageBytes = try { + Base64.getDecoder().decode(base64Data) + } catch (_: IllegalArgumentException) { + return canvasTempFileFailure("base64 decode failed").value + } + if (imageBytes.isEmpty() || imageBytes.size > MAX_CANVAS_IMAGE_BYTES || !matchesImageType(imageBytes, fileType)) { + return canvasTempFileFailure( + if (imageBytes.size > MAX_CANVAS_IMAGE_BYTES) "data too large" else "invalid image data" + ).value + } + + var cleanupFile: File? = null + return try { + val stagingFile = File.createTempFile(".canvas_", ".tmp", tempRoot) + cleanupFile = stagingFile + stagingFile.outputStream().use { it.write(imageBytes) } + val publishedFile = File(tempRoot, "canvas_${UUID.randomUUID()}.$fileType") + Files.move(stagingFile.toPath(), publishedFile.toPath(), StandardCopyOption.ATOMIC_MOVE) + cleanupFile = publishedFile + canvasTempFileSuccess(PathUtils.pathToVirtual(publishedFile)).value + } catch (_: Exception) { + cleanupFile?.delete() + canvasTempFileFailure("write failed").value + } + } + + + internal fun isValidCanvasAppId(appId: String): Boolean = + SAFE_APP_ID.matches(appId) && appId != "." && appId != ".." + + internal fun matchesImageType(bytes: ByteArray, fileType: String): Boolean = when (fileType) { + "png" -> bytes.size >= 8 && bytes.copyOfRange(0, 8).contentEquals( + byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + ) + "jpg" -> bytes.size >= 3 && bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte() + else -> false + } } diff --git a/android/dimina/src/main/kotlin/com/didi/dimina/common/PathUtils.kt b/android/dimina/src/main/kotlin/com/didi/dimina/common/PathUtils.kt index d4e642a7..f6df026f 100644 --- a/android/dimina/src/main/kotlin/com/didi/dimina/common/PathUtils.kt +++ b/android/dimina/src/main/kotlin/com/didi/dimina/common/PathUtils.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import android.webkit.MimeTypeMap import java.io.File +import java.nio.file.Files /** * Author: Doslin @@ -27,10 +28,29 @@ object PathUtils { } fun appUserRoot(context: Context, appId: String): File = - File(context.filesDir, "$FILE_SYSTEM_DIR/${validatedAppId(appId)}/$USER_DIR").apply { mkdirs() } + confinedStorageRoot(context.filesDir, validatedAppId(appId), USER_DIR) fun appTempRoot(context: Context, appId: String): File = - File(context.cacheDir, "$FILE_SYSTEM_DIR/${validatedAppId(appId)}/$TEMP_DIR").apply { mkdirs() } + confinedStorageRoot(context.cacheDir, validatedAppId(appId), TEMP_DIR) + + internal fun confinedStorageRoot(platformRoot: File, appId: String, leaf: String): File { + val canonicalPlatformRoot = platformRoot.canonicalFile + val directories = listOf( + File(canonicalPlatformRoot, FILE_SYSTEM_DIR), + File(canonicalPlatformRoot, "$FILE_SYSTEM_DIR/$appId"), + File(canonicalPlatformRoot, "$FILE_SYSTEM_DIR/$appId/$leaf"), + ) + directories.forEach { directory -> + require(!Files.isSymbolicLink(directory.toPath())) { "application storage contains a symbolic link" } + Files.createDirectories(directory.toPath()) + require(!Files.isSymbolicLink(directory.toPath())) { "application storage contains a symbolic link" } + val canonicalDirectory = directory.canonicalFile + require(canonicalDirectory.path.startsWith(canonicalPlatformRoot.path + File.separator)) { + "application storage escapes platform sandbox" + } + } + return directories.last().canonicalFile + } fun appTempFile(context: Context, appId: String, relativePath: String): File = confinedFile(appTempRoot(context, appId), relativePath) diff --git a/android/dimina/src/main/kotlin/com/didi/dimina/core/MiniApp.kt b/android/dimina/src/main/kotlin/com/didi/dimina/core/MiniApp.kt index d6090142..e392cb72 100644 --- a/android/dimina/src/main/kotlin/com/didi/dimina/core/MiniApp.kt +++ b/android/dimina/src/main/kotlin/com/didi/dimina/core/MiniApp.kt @@ -445,6 +445,7 @@ class MiniApp private constructor() { * * @param appId The ID of the MiniProgram to clear resources for */ + @androidx.annotation.MainThread fun clear(appId: String) { updateCheckRegistry.reset(appId) synchronized(this) { @@ -460,6 +461,9 @@ class MiniApp private constructor() { localNetworkApi.clearApp(appId) deviceNetworkApi.clearApp(appId) fileApi.clearApp(appId) + // 正在后台写盘的 canvas 导出属于这一代 runtime;换代之后它的回调没有接收方, + // 已经发布的文件也不会有人来取。 + com.didi.dimina.api.media.CanvasExportGeneration.invalidate(appId) // Detach this generation immediately so a rapid reopen creates a fresh runtime, but place // native engine destruction behind lifecycle messages already queued by Bridge.destroy(). @@ -477,6 +481,7 @@ class MiniApp private constructor() { /** * Clears all API resources and callbacks for all MiniPrograms */ + @androidx.annotation.MainThread fun clearAll() { updateCheckRegistry.resetAll() synchronized(this) { @@ -492,6 +497,7 @@ class MiniApp private constructor() { jsCoreMap.clear() jsCoresToDestroy.forEach { (appId, jsCore) -> LogUtils.d(tag, "Scheduling JsCore destruction for appId: $appId") + com.didi.dimina.api.media.CanvasExportGeneration.invalidate(appId) jsCore.destroyAfterMessages() } diff --git a/android/dimina/src/test/java/com/didi/dimina/api/media/ImageApiCanvasValidationTest.kt b/android/dimina/src/test/java/com/didi/dimina/api/media/ImageApiCanvasValidationTest.kt new file mode 100644 index 00000000..04f98be5 --- /dev/null +++ b/android/dimina/src/test/java/com/didi/dimina/api/media/ImageApiCanvasValidationTest.kt @@ -0,0 +1,462 @@ +package com.didi.dimina.api.media + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.json.JSONObject +import java.io.File +import java.util.Base64 +import java.util.ArrayDeque +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull + +class ImageApiCanvasValidationTest { + private val api = ImageApi() + + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun rejectsTraversalAndSeparatorsInCanvasAppId() { + assertFalse(api.isValidCanvasAppId("../other-app")) + assertFalse(api.isValidCanvasAppId("foo/bar")) + assertFalse(api.isValidCanvasAppId("foo\\bar")) + assertFalse(api.isValidCanvasAppId("..")) + assertTrue(api.isValidCanvasAppId("wx92269e3b2f304afc")) + } + + @Test + fun canvasImageSignatureMustMatchFileType() { + val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + val jpeg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()) + val fake = byteArrayOf(0, 0, 0) + + assertTrue(api.matchesImageType(png, "png")) + assertFalse(api.matchesImageType(png, "jpg")) + assertTrue(api.matchesImageType(jpeg, "jpg")) + assertFalse(api.matchesImageType(jpeg, "png")) + assertFalse(api.matchesImageType(fake, "png")) + } + + @Test + fun canvasTempFileResultsCarryTheSameErrMsgIntoComplete() { + val success = canvasTempFileSuccess("difile://tmp/canvas.png") + val failure = canvasTempFileFailure("write failed") + + assertTrue(success.completeCarriesResult) + assertEquals("canvasToTempFilePath:ok", success.value.getString("errMsg")) + assertEquals("difile://tmp/canvas.png", success.value.getString("tempFilePath")) + assertTrue(failure.completeCarriesResult) + assertEquals("canvasToTempFilePath:fail write failed", failure.value.getString("errMsg")) + } + + + @Test + fun writesDecodedCanvasBytesAndLeavesNoStagingFileBehind() { + val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + val root = tempFolder.newFolder("tmp") + + val result = api.writeCanvasTempFile(root, Base64.getEncoder().encodeToString(png), "png") + + assertEquals("canvasToTempFilePath:ok", result.getString("errMsg")) + val written = root.listFiles().orEmpty() + assertEquals(1, written.size) + assertTrue(written[0].name.endsWith(".png")) + assertTrue(png.contentEquals(written[0].readBytes())) + } + + @Test + fun rejectsPayloadWhoseSignatureDoesNotMatchTheRequestedTypeWithoutWriting() { + val jpeg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()) + val root = tempFolder.newFolder("tmp") + + val result = api.writeCanvasTempFile(root, Base64.getEncoder().encodeToString(jpeg), "png") + + assertEquals("canvasToTempFilePath:fail invalid image data", result.getString("errMsg")) + assertEquals(0, root.listFiles().orEmpty().size) + } + + // 写盘挪到后台后,success/fail/complete 由 canvas 自己派发,不再走 MiniApp.invokeAPI。 + // 该契约是:errMsg 决定走 success 还是 fail,complete 总是发且拿到同一个 result。 + @Test + fun okResultGoesToSuccessThenCompleteWithTheSameResult() { + val params = callbackParams() + val result = canvasTempFileSuccess("dm:///tmp/canvas_x.png").value + val calls = mutableListOf>() + + dispatchCanvasResult(params, result) { calls += parseCallback(it) } + + assertEquals(listOf("success-id", "complete-id"), calls.map { it.first }) + assertTrue(calls.all { it.second == "canvasToTempFilePath:ok" }) + } + + @Test + fun failResultGoesToFailThenCompleteWithTheSameResult() { + val params = callbackParams() + val result = canvasTempFileFailure("write failed").value + val calls = mutableListOf>() + + dispatchCanvasResult(params, result) { calls += parseCallback(it) } + + assertEquals(listOf("fail-id", "complete-id"), calls.map { it.first }) + assertTrue(calls.all { it.second == "canvasToTempFilePath:fail write failed" }) + } + + // 小程序在 complete 里读 res.errMsg,所以 success 派发抛错也不能把 complete 一起吞掉。 + @Test + fun completeStillFiresWhenTheSuccessCallbackThrows() { + val params = callbackParams() + val result = canvasTempFileSuccess("dm:///tmp/canvas_x.png").value + val calls = mutableListOf() + + val thrown = runCatching { + dispatchCanvasResult(params, result) { payload -> + val id = parseCallback(payload).first + calls += id + if (id == "success-id") throw IllegalStateException("bridge is gone") + } + } + + assertEquals(listOf("success-id", "complete-id"), calls) + assertTrue(thrown.isFailure) + } + + // 单次上限只约束一次请求,导出改成后台执行之后并发几次就能把峰值叠起来;串行是那个上限之所以 + // 还成立的前提。 + @Test + fun serializesConcurrentCanvasExportsOfOneApp() = runBlocking { + val appId = "app-serial-${System.nanoTime()}" + val active = AtomicInteger(0) + val peak = AtomicInteger(0) + val reservations = (1..MAX_IN_FLIGHT_CANVAS_EXPORTS).map { + requireNotNull(CanvasExportQueue.reserve(appId, 4, "pay$it")) + } + + reservations.map { reservation -> + launch(Dispatchers.Default) { + CanvasExportQueue.run(reservation) { + val now = active.incrementAndGet() + peak.updateAndGet { seen -> maxOf(seen, now) } + delay(5) + active.decrementAndGet() + } + } + }.joinAll() + reservations.forEach { CanvasExportQueue.finish(it) } + + assertEquals(1, peak.get()) + } + + @Test + fun oneAppsExportDoesNotBlockAnother() = runBlocking { + val parkedApp = "app-parked-${System.nanoTime()}" + val otherApp = "app-other-${System.nanoTime()}" + val parked = CompletableDeferred() + val entered = CompletableDeferred() + val held = requireNotNull(CanvasExportQueue.reserve(parkedApp, 4, "held")) + val holder = launch(Dispatchers.Default) { + CanvasExportQueue.run(held) { + entered.complete(Unit) + parked.await() + } + } + entered.await() + + val other = requireNotNull(CanvasExportQueue.reserve(otherApp, 4, "other")) + withTimeout(2_000) { + CanvasExportQueue.run(other) { } + } + CanvasExportQueue.finish(other) + + parked.complete(Unit) + holder.join() + CanvasExportQueue.finish(held) + } + + // 销毁只取消还没开始的任务,已经进入临界区的旧任务会继续解码和写盘。锁一旦按代次分片,新 + // runtime 的任务就会拿到另一把锁立刻开始,两份最多 32 MB 的位图同时留在内存里。 + @Test + fun holdsANewGenerationExportUntilTheRunningOneReleases() = runBlocking { + val appId = "app-restart-${System.nanoTime()}" + val entered = CompletableDeferred() + val parked = CompletableDeferred() + val running = requireNotNull(CanvasExportQueue.reserve(appId, 4, "old")) + val holder = launch(Dispatchers.Default) { + CanvasExportQueue.run(running) { + entered.complete(Unit) + parked.await() + } + } + entered.await() + + CanvasExportQueue.invalidate(appId) + val restarted = requireNotNull(CanvasExportQueue.reserve(appId, 4, "new")) + val attempting = CompletableDeferred() + val newStarted = CompletableDeferred() + val newcomer = launch(Dispatchers.Default) { + attempting.complete(Unit) + CanvasExportQueue.run(restarted) { newStarted.complete(Unit) } + } + attempting.await() + + assertNull(withTimeoutOrNull(200) { newStarted.await() }) + + parked.complete(Unit) + holder.join() + withTimeout(2_000) { newStarted.await() } + newcomer.join() + CanvasExportQueue.finish(running) + CanvasExportQueue.finish(restarted) + } + + // 一次失败不能把后面排队的导出一起堵死。 + @Test + fun aThrowingExportReleasesTheQueue() = runBlocking { + val appId = "app-failing-${System.nanoTime()}" + val failing = requireNotNull(CanvasExportQueue.reserve(appId, 4, "boom")) + runCatching { + CanvasExportQueue.run(failing) { throw IllegalStateException("boom") } + } + CanvasExportQueue.finish(failing) + + val next = requireNotNull(CanvasExportQueue.reserve(appId, 4, "next")) + val ran = withTimeout(2_000) { + CanvasExportQueue.run(next) { true } + } + CanvasExportQueue.finish(next) + + assertEquals(true, ran) + } + + // 排队中的每个请求各自持有一份 base64 副本,单次上限只约束其中一份。连续入队时占用是累加的, + // 所以预算必须在把字符串交给后台之前判:拒绝之后那份副本才可回收。 + @Test + fun rejectsExportsThatWouldExceedThePendingBudget() { + val appId = "app-budget-${System.nanoTime()}" + val half = (MAX_PENDING_CANVAS_BASE64_CHARS / 2).toInt() + + val first = requireNotNull(CanvasExportQueue.reserve(appId, half, "first")) + val second = requireNotNull(CanvasExportQueue.reserve(appId, half, "second")) + assertNull(CanvasExportQueue.reserve(appId, half, "third")) + + CanvasExportQueue.finish(first) + val third = requireNotNull(CanvasExportQueue.reserve(appId, half, "third")) + CanvasExportQueue.finish(second) + CanvasExportQueue.finish(third) + } + + @Test + fun rejectsTheThirdPendingExportEvenWhenPayloadsAreTiny() { + val appId = "app-count-${System.nanoTime()}" + val first = CanvasExportQueue.reserve(appId, 12, "first") + val second = CanvasExportQueue.reserve(appId, 12, "second") + val third = CanvasExportQueue.reserve(appId, 12, "third") + + try { + assertTrue(first != null) + assertTrue(second != null) + assertNull(third) + } finally { + first?.let { CanvasExportQueue.finish(it) } + second?.let { CanvasExportQueue.finish(it) } + third?.let { CanvasExportQueue.finish(it) } + } + } + + @Test + fun invalidationReleasesReservationsThatHaveNotStarted() { + val appId = "app-reset-${System.nanoTime()}" + val half = (MAX_PENDING_CANVAS_BASE64_CHARS / 2).toInt() + + assertTrue(CanvasExportQueue.reserve(appId, half, "old1") != null) + assertTrue(CanvasExportQueue.reserve(appId, half, "old2") != null) + CanvasExportGeneration.invalidate(appId) + + val first = requireNotNull(CanvasExportQueue.reserve(appId, half, "new1")) + val second = requireNotNull(CanvasExportQueue.reserve(appId, half, "new2")) + CanvasExportQueue.finish(first) + CanvasExportQueue.finish(second) + } + + // 请求进入容器时属于哪一代必须当场记下。调用线程与推进代次的主线程是并发的,等校验完 + // 最长 44 MB 的 base64 再去读,退出途中到达的请求会被记成新一代,写出的文件既不交付也不删。 + @Test + fun rejectsAnExportWhoseRuntimeIsGoneBeforeItIsQueued() { + val appId = "app-stale-gen-${System.nanoTime()}" + val generation = CanvasExportGeneration.current(appId) + + CanvasExportGeneration.invalidate(appId) + + assertNull(CanvasExportQueue.reserve(appId, 8, "payload--", generation)) + assertEquals(0L, CanvasExportQueue.pendingChars(appId)) + val current = requireNotNull( + CanvasExportQueue.reserve(appId, 8, "payload--", CanvasExportGeneration.current(appId)), + ) + CanvasExportQueue.finish(current) + } + + @Test + fun invalidationCancelsOnlyQueuedJobsAndLetsTheNewRuntimeUseTheRemainingSlot() = runBlocking { + val appId = "app-running-reset-${System.nanoTime()}" + val running = requireNotNull(CanvasExportQueue.reserve(appId, 4, "old1")) + val queued = requireNotNull(CanvasExportQueue.reserve(appId, 4, "old2")) + val entered = CompletableDeferred() + val release = CompletableDeferred() + val runningTask = launch(Dispatchers.Default) { + CanvasExportQueue.run(running) { + entered.complete(Unit) + release.await() + } + } + entered.await() + + CanvasExportGeneration.invalidate(appId) + + assertTrue(queued.cancelled) + assertEquals(null, queued.payload) + val current = CanvasExportQueue.reserve(appId, 4, "new1") + assertTrue(current != null) + assertEquals(null, CanvasExportQueue.reserve(appId, 4, "new2")) + + release.complete(Unit) + runningTask.join() + CanvasExportQueue.finish(running) + assertTrue(CanvasExportQueue.reserve(appId, 4, "new2") != null) + } + + // 锁的身份就是串行本身:同一个小程序拿到的必须始终是同一把,上一轮结束和 runtime 换代都不能换。 + @Test + fun handsOutTheSameLockToEveryExportOfOneApp() { + val appId = "app-stable-lock-${System.nanoTime()}" + val first = requireNotNull(CanvasExportQueue.reserve(appId, 4, "first")) + val firstMutex = first.mutex + CanvasExportQueue.finish(first) + + val second = requireNotNull(CanvasExportQueue.reserve(appId, 4, "next")) + assertTrue(firstMutex === second.mutex) + + CanvasExportQueue.invalidate(appId) + val restarted = requireNotNull(CanvasExportQueue.reserve(appId, 4, "restarted")) + assertTrue(firstMutex === restarted.mutex) + + CanvasExportQueue.finish(second) + CanvasExportQueue.finish(restarted) + } + + @Test + fun oneAppsPendingExportsDoNotConsumeAnothersBudget() { + val fullApp = "app-full-${System.nanoTime()}" + val emptyApp = "app-empty-${System.nanoTime()}" + val whole = MAX_PENDING_CANVAS_BASE64_CHARS.toInt() + + val full = requireNotNull(CanvasExportQueue.reserve(fullApp, whole, "full")) + assertNull(CanvasExportQueue.reserve(fullApp, 1, "overflow")) + val other = requireNotNull(CanvasExportQueue.reserve(emptyApp, whole, "other")) + + CanvasExportQueue.finish(full) + CanvasExportQueue.finish(other) + } + + // 一次导出属于发起它的那一代 runtime。小程序退出重开后 appId 照旧,所以"这个 appId 是不是 + // 还活着"判不出迟到的结果该不该交付。 + @Test + fun refusesToDeliverAnExportIssuedByAPreviousRuntime() { + val generation = CanvasExportGeneration.current("app-gen") + assertTrue(shouldDeliverCanvasExport("app-gen", generation)) + + CanvasExportGeneration.invalidate("app-gen") + + assertFalse(shouldDeliverCanvasExport("app-gen", generation)) + assertTrue(shouldDeliverCanvasExport("app-gen", CanvasExportGeneration.current("app-gen"))) + } + + @Test + fun invalidationWhileMainDeliveryIsQueuedDropsTheCallbackAndPublishedFile() = runBlocking { + val appId = "app-queued-delivery" + val generation = CanvasExportGeneration.current(appId) + val tempRoot = tempFolder.newFolder() + val published = File(tempRoot, "canvas_orphan.png") + published.writeBytes(byteArrayOf(1, 2, 3)) + val result = canvasTempFileSuccess("/dimina/app/tmp/${published.name}").value + val mainDispatcher = QueuedDispatcher() + var delivered = false + + val delivery = launch(start = CoroutineStart.UNDISPATCHED) { + deliverCanvasExport( + appId = appId, + generation = generation, + tempRoot = tempRoot, + result = result, + deliveryDispatcher = mainDispatcher, + ) { + delivered = true + } + } + + assertEquals(1, mainDispatcher.pendingCount) + CanvasExportGeneration.invalidate(appId) + mainDispatcher.runNext() + delivery.join() + + assertFalse(delivered) + assertFalse(published.exists()) + } + + // 没有接收方的导出已经把文件写出去了,留着就是谁也取不到的永久占用。 + @Test + fun deletesThePublishedFileOfAnUndeliverableExport() { + val tempRoot = tempFolder.newFolder() + val published = File(tempRoot, "canvas_orphan.png") + published.writeBytes(byteArrayOf(1, 2, 3)) + val result = canvasTempFileSuccess("/dimina/app/tmp/${published.name}").value + + discardPublishedCanvasFile(tempRoot, result) + + assertFalse(published.exists()) + } + + // 失败的导出没有文件可删,也不能因为路径缺失就抛错打断结算。 + @Test + fun discardingAFailedExportIsANoOp() { + val tempRoot = tempFolder.newFolder() + + discardPublishedCanvasFile(tempRoot, canvasTempFileFailure("write failed").value) + } + + private fun callbackParams() = JSONObject().apply { + put("success", "success-id") + put("fail", "fail-id") + put("complete", "complete-id") + } + + private fun parseCallback(payload: String): Pair { + val body = JSONObject(payload).getJSONObject("body") + return body.getString("id") to body.optJSONObject("args")?.optString("errMsg") + } + + private class QueuedDispatcher : CoroutineDispatcher() { + private val tasks = ArrayDeque() + val pendingCount: Int get() = tasks.size + + override fun dispatch(context: kotlin.coroutines.CoroutineContext, block: Runnable) { + tasks.addLast(block) + } + + fun runNext() { + tasks.removeFirst().run() + } + } +} diff --git a/android/dimina/src/test/java/com/didi/dimina/common/PathUtilsStorageConfinementTest.kt b/android/dimina/src/test/java/com/didi/dimina/common/PathUtilsStorageConfinementTest.kt new file mode 100644 index 00000000..dfc68f52 --- /dev/null +++ b/android/dimina/src/test/java/com/didi/dimina/common/PathUtilsStorageConfinementTest.kt @@ -0,0 +1,46 @@ +package com.didi.dimina.common + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Test +import java.nio.file.Files +import java.util.concurrent.Callable +import java.util.concurrent.Executors + +class PathUtilsStorageConfinementTest { + @Test + fun createsTheSameConfinedRootUnderConcurrentFirstUse() { + val root = Files.createTempDirectory("dimina-storage-race-test").toFile() + val platformRoot = root.resolve("cache").apply { mkdirs() } + val executor = Executors.newFixedThreadPool(8) + + try { + val results = executor.invokeAll(List(32) { + Callable { PathUtils.confinedStorageRoot(platformRoot, "safe-app", "tmp") } + }).map { it.get().canonicalPath }.toSet() + assertEquals(setOf(platformRoot.resolve("dimina-file-system/safe-app/tmp").canonicalPath), results) + } finally { + executor.shutdownNow() + root.deleteRecursively() + } + } + + @Test + fun rejectsSymlinkedApplicationStorageBeforeCreatingLeafOutsideSandbox() { + val root = Files.createTempDirectory("dimina-storage-test").toFile() + val platformRoot = root.resolve("cache").apply { mkdirs() } + val outside = root.resolve("outside").apply { mkdirs() } + val storageRoot = platformRoot.resolve("dimina-file-system").apply { mkdirs() } + Files.createSymbolicLink(storageRoot.resolve("safe-app").toPath(), outside.toPath()) + + try { + assertThrows(IllegalArgumentException::class.java) { + PathUtils.confinedStorageRoot(platformRoot, "safe-app", "tmp") + } + assertFalse(outside.resolve("tmp").exists()) + } finally { + root.deleteRecursively() + } + } +} diff --git a/docs/API-Reference.md b/docs/API-Reference.md index a1fecbbb..8780f9c2 100644 --- a/docs/API-Reference.md +++ b/docs/API-Reference.md @@ -270,6 +270,8 @@ DMPApp.init(context, { apiNamespaces: ["myapp"] }) | | createOffscreenCanvas | ✓ | ✓ | ✓ | ✓ | | | createCanvas | ✓ | ✓ | ✓ | ✓ | | | createImage | ✓ | ✓ | ✓ | ✓ | +| | canvasGetImageData | ✓ | ✓ | ✓ | ✓ | +| | canvasPutImageData | ✓ | ✓ | ✓ | ✓ | | | canvasToTempFilePath | ✓ | ✓ | ✓ | ✓ | | 设备 - 小游戏触摸 | onTouchStart | ✓ | ✓ | ✓ | ✓ | | | offTouchStart | ✓ | ✓ | ✓ | ✓ | diff --git a/docs/Mini-Game.md b/docs/Mini-Game.md index 8a0cac95..961f5601 100644 --- a/docs/Mini-Game.md +++ b/docs/Mini-Game.md @@ -38,6 +38,10 @@ Android、iOS 和 HarmonyOS 同样在解析 `app-config.json` 后读取 `app.run - 支持小游戏 `wx.onShow` / `offShow`、`wx.onHide` / `offHide`,并复用现有错误事件、系统信息、网络、存储等共享 `wx` API。 - `GameGlobal` 与小游戏全局 `global` 在执行 `game.js` 前建立。 +同一个容器替换或重启小游戏 runtime 时,会在执行新的 `game.js` 前销毁旧 runtime 的 Canvas owner。旧上屏 canvas、事件监听、RAF、context、图片和 WebGL capability 都会释放;新 runtime 的第一次 `wx.createCanvas()` 再创建唯一的上屏 canvas。`resourceLoaded` 只更新当前 runtime 的能力信息,不会销毁刚由 `game.js` 创建的节点。页面卸载与显式退出走同一套 owner 清理,因此旧 runtime 的迟到 callback 或资源不能进入新 runtime。 + +Canvas 节点、状态、位图限制和 native 导出生命周期见 [Canvas 运行架构](./canvas-architecture.md)。 + 首版目标是运行以 Canvas 为主的小游戏入口,并非一次性覆盖微信小游戏的全部专属能力。开放数据域、游戏圈按钮、好友关系链、小游戏分包和独立 Worker 等专属接口目前未提供;业务应继续用 `wx.canIUse()` 做能力保护。 ## 本地验证 diff --git a/docs/README.md b/docs/README.md index c395461a..538268e5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,8 +13,10 @@ Dimina(星河小程序)由 DMCC 编译器、逻辑层与渲染层运行时 | 排查页面或组件初始化时序 | [生命周期](./Architecture-Lifecycle.md) | `service` / `render` 运行时源码与测试 | | 接入多小程序切换与后台保留 | [多小程序运行](./Multi-Mini-Program.md) | 跨小程序导航 API 与各端 AppManager | | 编译和运行微信小游戏 | [微信小游戏运行](./Mini-Game.md) | `game.js`、Canvas 与三端运行类型透传 | +| 排查 Canvas 节点、绘制顺序、内存限制或导出生命周期 | [Canvas 运行架构](./canvas-architecture.md) | compiler、service / render 与三端 native 导出实现 | | 接入内置包或远程更新 | [小程序包更新](./MiniProgram-Update.md) | 对应平台的 Bundle Loader 实现 | | 查看 WebSocket API、事件与平台限制 | [WebSocket 能力](./websocket-architecture.md) | 对应平台的 Manager 与校验源码 | +| 排查点击、长按或 `label` 激活的行为 | [触摸事件与手势](./touch-event-architecture.md) | `components` 的手势与组件源码 | | 参与前端框架开发 | [前端工程说明](../fe/README.md) | [贡献指南](../CONTRIBUTING.md) | ## 核心阅读路径 @@ -25,8 +27,10 @@ Dimina(星河小程序)由 DMCC 编译器、逻辑层与渲染层运行时 4. [能力参考](./API-Reference.md):模板标签、内置组件、API 和扩展 Bridge 的支持状态。 5. [多小程序运行](./Multi-Mini-Program.md):多运行时登记、前后台切换、返回协议与保留边界。 6. [微信小游戏运行](./Mini-Game.md):小游戏工程识别、编译产物、Canvas 入口和当前边界。 -7. [小程序包更新](./MiniProgram-Update.md):内置包、远程 manifest、校验、安装与 `wx.getUpdateManager()`。 -8. [WebSocket 能力](./websocket-architecture.md):公开 API、`SocketTask` 方法、事件数据、连接规则与平台限制。 +7. [Canvas 运行架构](./canvas-architecture.md):legacy / node / 小游戏入口、DOM 归属、操作队列、内存预算与导出生命周期。 +8. [小程序包更新](./MiniProgram-Update.md):内置包、远程 manifest、校验、安装与 `wx.getUpdateManager()`。 +9. [WebSocket 能力](./websocket-architecture.md):公开 API、`SocketTask` 方法、事件数据、连接规则与平台限制。 +10. [触摸事件与手势](./touch-event-architecture.md):手势事件的合成规则、传播与 `catch`、`label` 激活和容器事件归属。 ## 平台接入 diff --git a/docs/canvas-architecture.md b/docs/canvas-architecture.md new file mode 100644 index 00000000..4e5edea0 --- /dev/null +++ b/docs/canvas-architecture.md @@ -0,0 +1,86 @@ +# Canvas 运行架构 + +[文档中心](./README.md) · [能力参考](./API-Reference.md) · [微信小游戏运行](./Mini-Game.md) + +Dimina 同时支持旧版 `wx.createCanvasContext()`、`` / `SelectorQuery.fields({ node: true })`,以及小游戏和离屏 Canvas。下面四条入口最终共用渲染层的真实 `HTMLCanvasElement`,但定位方式、调用协议和生命周期不同。 + +## 入口与节点模型 + +| 入口 | 定位方式 | 主要用途 | +| --- | --- | --- | +| `` + `wx.createCanvasContext()` | `canvas-id` 与当前页面或自定义组件实例 | 旧版批量绘图、像素读写和导出 | +| `` + SelectorQuery | `id` 查询后取得 `node` | Canvas 2D / WebGL 节点 API | +| `wx.createOffscreenCanvas()` | 逻辑层创建的 node id | 不显示在页面中的 2D / WebGL 绘制 | +| 小游戏 `wx.createCanvas()` | 当前小游戏 runtime | 第一个节点上屏,后续节点离屏 | + +新编译产物把 `` 统一编译为 `dd-canvas`。组件根节点承载小程序声明的 `id`、`dataset`、布局、边框和插槽,内部 `HTMLCanvasElement` 承载实际绘图。两者通过共享的非 `data-*` DOM property 显式关联,因此: + +- SelectorQuery 的尺寸、位置、`id` 和 `dataset` 来自组件根节点;`node` 来自内部 canvas。 +- 框架归属信息不会进入小程序事件的 `target.dataset`。 +- slot 中出现其他 canvas 或组件内部结构变化时,不会改变 `node` 的目标。 + +运行时仍识别旧编译包中的原生 ``。旧包与新基础库组合运行时,页面查询不会落到自定义组件的私有 canvas,自定义组件根节点本身就是 canvas 的情况也能被当前组件作用域解析。 + +## `canvas-id` 作用域 + +旧版 `canvas-id` 的唯一性范围是“页面 + 自定义组件实例”。相同组件的两个实例可以各自使用 `canvas-id="chart"`;同一实例内重复时只有成功登记的 canvas 可被解析。 + +登记由当前 `canvas-id` 和 `type` 共同决定: + +- 没有 `type` 的 legacy canvas 参与 `canvas-id` 登记。 +- 带 `type` 的节点不参与 legacy 登记,也不会被 `createCanvasContext()` 选中。 +- 动态修改 `canvas-id` 或 `type` 会释放旧登记并重新 claim;同一轮更新中等待旧 owner 让出后,候选节点会重新解析。 + +归属和 active 状态使用 DOM property,而不是 CSS selector 或 `dataset`。resolver 比较属性原值,因此特殊字符 id 不会改变查询语义,也不能注入联合 selector。 + +## 绘制顺序 + +legacy API 先按页面或组件作用域解析 `canvas-id`,再把操作交给真实 canvas 的队列: + +1. 同一作用域内的 lookup 串行,避免节点动态挂载或改 id 时后发请求越过先发请求。 +2. lookup 完成后立即释放作用域队列。 +3. 同一真实 canvas 的绘制、像素读写和导出串行;不同 canvas 互不阻塞。 + +`draw(reserve)` 的 `reserve` 只决定像素去留,不决定绘图状态:两种取值下新批次都从默认样式、变换和裁剪区开始,官方示例里第二批没重设 `fillStyle` 时画出来就是默认黑色。`reserve:false` 通过重建 backing store 一次清掉像素与状态;`reserve:true` 不能动 backing store,改由每批开头的一层基线 `save` 帧承载整批状态,下一批开始时弹回默认值而画面不动。批内未配平的 `restore()` 停在批边界,弹不走基线帧——真实 canvas 对空栈 `restore()` 同样是空操作。微信 iOS 的底层字号跨批次泄漏,两条重置路径都保留 `font`。 + +Canvas node API 在逻辑层积累 operation,flush 后由渲染层逐条结算。每条 operation 的异常独立返回,图片加载、像素 Promise、RAF callback 和状态反馈都绑定到 node owner;成功、失败、取消或销毁只允许一个终态。 + +Canvas 2D 状态以渲染层宿主 context 为最终权威。逻辑层提供同步的 optimistic getter,并以单调序号接收宿主 readback;迟到反馈不能覆盖新值。`save()` / `restore()`、`reset()` 和 backing size 变化会同步更新状态栈。宿主没有 `reset()` 时,渲染层通过重建同尺寸 backing store 清除像素、路径、clip、save stack 和绘图状态,再返回完整状态快照。 + +## 位图与传输预算 + +所有公开入口在分配或序列化前使用同一组限制,渲染层还会对旧基础库和直接 bridge 消息再次校验: + +| 边界 | 限制 | +| --- | --- | +| canvas 单边 | 最大 4096 像素 | +| RGBA backing store / 导出目标 | 最大 32 MiB,即 8,388,608 像素 | +| `canvasGetImageData` / `canvasPutImageData` JSON 传输 | 按每通道最坏四个字符计费,最大 2,097,152 像素 | +| native 导出 | 每个 app 最多 2 个未结算请求,并同时限制累计 Base64 载荷 | + +尺寸检查覆盖组件 `renderWidth` / `renderHeight`、布局同步、node width / height setter、离屏和小游戏创建、legacy 像素 API 与 `canvasToTempFilePath`。超限请求在创建输出 canvas、`getImageData()`、`Array.from()` 或 `toDataURL()` 之前失败。 + +`canvasToTempFilePath` 的缺省目标尺寸按源尺寸和设备 pixel ratio 计算,显式 `destWidth` / `destHeight` 保持调用方值;最终尺寸仍受上述单边和总像素限制。 + +## 导出与 runtime 生命周期 + +渲染层编码后,三端各自解码并发布临时文件,都不阻塞收到 bridge 消息的那个调用点:Android 在 `Dispatchers.IO`、iOS 在按 app 和 generation 划分的后台串行队列上执行;HarmonyOS 的 bridge 入口必须同步返回,因此校验留在同步段,解码与写盘交给主线程上的异步串行队列,配合异步文件 API 完成。每个请求都绑定 app owner 和 runtime generation: + +- 退出、重启或替换 runtime 时先推进 generation,并取消尚未开始的旧请求。 +- 已经开始的请求在真正结束前继续占用计数和字节预算,不能让新 runtime 绕过峰值限制。 +- 旧 generation 的结果不能投递给新逻辑层;如果文件已经发布,会立即删除。 +- success / fail 与 complete 在同一串行域结算,销毁不能插入“检查通过”和“发送 callback”之间。 + +页面卸载或小游戏替换时,逻辑层会向渲染层发送 node dispose。渲染层随后清理 DOM canvas、事件监听、RAF、context、图片和 node 所属资源;逻辑层同时清理 callback、WebGL capability 和上屏 / 离屏 canvas 引用。 + +## 源码入口 + +| 范围 | 文件 | +| --- | --- | +| 编译到 `dd-canvas` | `fe/packages/compiler/src/common/utils.js`(`tagWhiteList` 收录 `canvas`)、`fe/packages/compiler/src/core/view-compiler.js`(`dd-` 前缀规则) | +| 组件与 DOM contract | `fe/packages/components/src/component/canvas/Canvas.vue`、`fe/packages/common/src/core/dom-contract.js` | +| 公共位图预算 | `fe/packages/common/src/core/canvas-limits.js` | +| legacy API | `fe/packages/service/src/api/core/ui/canvas/index.js` | +| Canvas node proxy | `fe/packages/service/src/api/core/ui/canvas/canvas-node.js` | +| resolver、回放、导出与 node 资源 | `fe/packages/render/src/core/runtime.js` | +| native 导出 | Android `ImageApi.kt`、iOS `ImageAPI.swift`、HarmonyOS `DMPContainerBridgesModule+Canvas.ets` | diff --git a/docs/touch-event-architecture.md b/docs/touch-event-architecture.md new file mode 100644 index 00000000..3ca649ea --- /dev/null +++ b/docs/touch-event-architecture.md @@ -0,0 +1,159 @@ +# 触摸事件与手势 + +[文档中心](./README.md) · [架构总览](./Architecture-Diagram.md) · [能力参考](./API-Reference.md) + +Dimina 的手势事件由渲染层合成:组件在自己的根元素上安装一套触摸状态机,把原生 `touchstart` / `touchmove` / `touchend` / `touchcancel` 与指针序列翻译成小程序的 `tap`、`longpress`、`longtap`、`canceltap`,再经渲染层桥发往逻辑层。四端共用这一份实现,因为渲染层在 Android、iOS、HarmonyOS 和 Web 都运行在浏览器环境里。 + +本文以微信小程序当前的 [WXML 事件](https://developers.weixin.qq.com/miniprogram/dev/framework/view/wxml/event.html) 与 [`label`](https://developers.weixin.qq.com/miniprogram/dev/component/label.html) 文档为公开契约基准。文档未定义的部分(多指下每个触点的 `tap` 判定、label 对隐藏与禁用控件的处理、容器事件的归属)以微信开发者工具上的可复现行为为准,并在第 7 节集中列出。 + +## 1. 支持范围 + +### 1.1 手势事件 + +| 事件 | 触发条件 | 说明 | +| --- | --- | --- | +| `touchstart` | 手指或指针按下 | 原样转发原生序列的起点 | +| `touchmove` | 触点移动 | 位移是否超阈值不影响本事件 | +| `touchend` | 触点抬起 | 先于 `tap` 派发 | +| `touchcancel` | 序列被系统或页面中断 | 指针序列在窗口失焦时也走这里 | +| `tap` | 单指按下并抬起,期间未超过位移阈值、未被长按抑制 | 由触摸序列合成,不来自原生 `click` | +| `longtap` | 单指按住超过阈值 | 不抑制随后的 `tap`,属弃用兼容行为 | +| `longpress` | 单指按住超过阈值 | 节点绑定了 `longpress` 时抑制本次 `tap` | +| `canceltap` | 位移超过阈值,或抬手时已远离起点,或序列被取消 | 取消后的每条 `touchmove` 各一条;抬手复查与 `touchcancel` 只在还没发过时补一条 | + +### 1.2 绑定写法 + +| 写法 | 冒泡 | 是否实现 | +| --- | --- | --- | +| `bindtap`、`bind:tap` | 继续向上派发 | 是 | +| `catchtap`、`catch:tap` | 阻断同类事件继续向上派发 | 是 | +| `mut-bind:tap` | 互斥绑定 | 否,编译器识别该前缀但渲染层不派发 | +| `capture-bind:tap`、`capture-catch:tap` | 捕获阶段 | 否,编译器识别该前缀但渲染层不派发 | + +`catch` 按官方定义只做一件事——阻止事件冒泡,不改变浏览器的默认行为。 + +### 1.3 安装手势的节点 + +组件路径:`button`、`canvas`、`checkbox`、`image`、`label`、`movable-area`、`navigator`、`picker`、`radio`、`slider`、`switch`、`text`、`view` 共 13 个内置组件在根元素上安装。 + +指令路径:编译产物中保持为原生元素的 `canvas` 节点由 `c-event-node` 指令安装,与组件路径调用同一个 `attachTouchEvents`。 + +一个元素只允许存在一个手势所有者。组件的上下文比指令更完整(处理了插槽嵌套的节点信息,并携带相对坐标选项),因此组件可以从已安装的指令手中接管;指令不会反向抢回已被组件接管的元素。不能靠谁先安装来决定归属——Vue 的指令 `mounted` 先于同一实例的 `onMounted` 执行。 + +## 2. 事件数据 + +发往逻辑层的事件对象字段: + +| 字段 | 说明 | +| --- | --- | +| `type` | 事件类型 | +| `timeStamp` | 页面打开到事件触发经过的毫秒数 | +| `target` | 触发事件的节点的 `id`、`dataset`、`offsetLeft`、`offsetTop`(官方只定义 `id` 与 `dataset`) | +| `currentTarget` | `bind` / `catch` 绑定所在节点的同名字段 | +| `touches` | 当前屏幕上的所有触摸点 | +| `changedTouches` | 本次事件涉及的触摸点 | +| `detail` | 带坐标的手势事件(`tap`、`longpress`、`longtap`、`canceltap`)统一带 `x`、`y`,取 `pageX` / `pageY`;组件事件的 `detail` 由各组件按自身语义填充 | + +触摸点字段为 `identifier`、`clientX`、`clientY`、`pageX`、`pageY`、`screenX`、`screenY`、`force`。`canvas` 的触摸点额外带 `x`、`y`,即相对小程序声明的 canvas 根节点 border box 左上角的坐标,对应官方的 `CanvasTouch`;内部 backing canvas 位于边框内侧,不能用它作为坐标原点。 + +`tap` 使用序列起点的触摸点,`touchmove` 与 `touchend` 使用各自时刻的触摸点。 + +## 3. 手势规则 + +### 3.1 判定阈值 + +| 参数 | 默认值 | 作用 | +| --- | --- | --- | +| 长按阈值 | 350ms | 触发 `longtap` 与 `longpress` | +| 位移阈值 | 10px | 任一轴超过即判定为移动 | + +### 3.2 一次触摸序列 + +- `tap` 由触摸序列合成,不监听原生 `click`。`canvas` 这类只有触摸通道的节点因此同样能拿到 `tap`,长按也才能按官方语义抑制本次 `tap`。 +- 位移超过阈值后本次序列不再产生 `tap`,并从越过阈值的那一次 `touchmove` 起,之后每一条 `touchmove` 都各带一条 `canceltap`:同一个探针小程序在微信开发者工具上跑,两次越过阈值的移动记到两条 `canceltap`。`touchend` 终点复查与 `touchcancel` 这两条路径只在整段序列还没发过 `canceltap` 时补一条。 +- `touchend` 上按起点与终点再判一次距离:`touchmove` 可能一条都没到(被 `preventDefault` 吞掉,或系统把移动并进抬手),此时超过位移阈值改发 `canceltap`,不合成 `tap`。 +- `longpress` 触发后抑制本次 `tap`;抑制标记记录在原生事件上,祖先节点观察到同一次序列时也不再合成自己的 `tap`。`longtap` 不抑制。 +- `tap`、`longpress` 与 `longtap` 是单指手势。第二根手指加入时只取消长按计时器,本次序列不再触发 `longpress` 与 `longtap`;它不重置起点,也不影响第一根手指的 `tap` 判定——第一根手指原地抬起仍然产生一次 `tap`。多指下每个触点各自的终态见第 7 节。 +- 鼠标与触控笔经 `pointerdown` 合成 `identifier` 为 `0` 的单点触摸序列,与官方基础库在 PC 上的做法一致。移动与抬起监听在 `document` 上,指针移出节点后抬起仍能收口;窗口失焦按取消处理。 +- 原生 `click` 只作为兜底,覆盖程序化 `element.click()` 与键盘、无障碍触发。指针序列补发的 `click` 带非零 `detail`,此时 `tap` 已由触摸序列合成,一律放过。 +- `tap` 在 `touchend` 完整冒泡结束后的同一个微任务里统一派发。路径上每个所有者先登记任务再统一收口,因此处理器中同步移除 DOM 或 `document` 级的 `pointerup` 都不会让某个节点提前结束或漏发。 + +### 3.3 传播与原生滚动 + +- `catch` 只管理小程序事件的传播。所有事件都按类型在原生事件上打标记,只抑制祖先派发同一类型,不阻断原生冒泡:祖先仍能观察完整原生序列来推进自己的状态机,后代的 `catch:tap` 也不会顺带吃掉祖先的 `bindtouchend`。 +- 监听器的 `passive` 由该节点是否存在对应 `catch` 决定,`touchmove` 还叠加 `disable-scroll`;`bind` 与 `catch` 动态切换时精确重装监听器。 +- 是否阻止原生滚动与小程序事件传播分开裁决:存在 `catchtouchmove` 时,沿 `composedPath` 走到 catch 边界为止,若路径上存在该方向仍可滚动的容器则放行,否则阻止默认行为;`canvas` 的 `disable-scroll` 无条件阻止,不参与该裁决。滚动是否被阻止不影响位移、长按与 `canceltap` 的状态推进。 + +## 4. label 激活 + +`label` 的激活是一次定向调用,不是模拟点击。控件执行自己的动作,但不会因此获得一次自己的 `tap`,也不波及控件的祖先。 + +- 可激活的控件只有 `button`、`checkbox`、`input`、`radio`、`switch` 五种。`textarea` 和 `slider` 不在其内:查找会跳过它们,点在它们身上也不算“点在控件上”。 +- `for` 优先于内部控件;`for` 指向不存在的 id 时不回退到内部控件。 +- 内部有多个控件时取文档序第一个,隐藏的控件同样计入,禁用的控件也不跳过。 +- 守卫只看被点节点到 `label` 之间这一段:这段里存在可激活控件时不激活。`label` 外层套着的可激活控件不参与判断,因此 `button` 包 `label` 不会抑制内部控件的激活。 +- 勾选类控件被激活时执行自身动作,不补发 `tap`:`switch` 派发自己的 `change`,`checkbox` 与 `radio` 触发所在 group 的 `change`(归属见第 5 节),不在 group 内时只改自己的状态。`button` 被激活时会补发一次自己的 `tap` 且该 `tap` 沿组件树冒泡,祖先各多收到一次;补发通过一个冒泡的自定义事件交给路径上的手势所有者,与真实序列合成的 `tap` 一样受 `catch` 约束。禁用的 `button` 不开启这条通道。 +- 目标是真实 `input` 时激活即聚焦;`label` 在 `click` 阶段一律取消原生 `