取消按钮背后:安全停止抽帧、相机恢复与训练

0 阅读6分钟

磁刻技术实录 · 第 27 篇 · 工程实践 · 技术基线 2026-10-31

我们正在做的「磁刻(Cike)」,是一款鸿蒙原生的生活记录与个性化创作应用。用户可以把照片做成数字冰箱贴,在洞洞板上自由摆放、组合自己的生活片段,也可以把喜欢的内容做成桌面卡片,用来回看、互动和分享。

在空间记录上,我们引入了 3D Gaussian Splatting(3DGS,三维高斯泼溅),把拍摄的视频重建为可以转动、靠近观察的三维场景。为此,我们重构了视频重建链路,实现自己的 C++ 训练核心,并结合鸿蒙原生的媒体处理、模型查看和分享能力,希望让用户保存一段经历时,也能留下当时的空间关系。

空间重建可能持续较长时间,取消必须是一个可靠的产品操作。我们分享合作式停止的处理,是因为按钮状态改变以后,底层计算未必已经退出。只有明确资源何时不再被使用,磁刻才能安全清理输入并准备下一次任务。

取消按钮看起来只有一个点击事件,背后却涉及多个执行环境:ArkTS 页面、解码 Worker、原生相机恢复和训练线程。磁刻的重建流程把取消定义为停止请求,并等待任务实际退出后再清理输入。这能避免用户退出页面后,后台仍在读取已删除文件。

以下代码按当前实现整理,保留关键运算与控制顺序;省略导入、日志和完整类型,部分辅助函数采用说明性命名,片段需结合上下文阅读。

长任务的取消通常是协作式的。工作线程在可检查位置读取标志并退出,而不是被任意强杀。这样可以执行必要清理,但响应时间取决于检查点之间的工作量。描述取消性能时,应说明测试阶段和样本,而不是承诺所有情况立即完成。

取消任务的安全顺序

图 1:先停止使用资源,再释放资源

任务代次阻止晚到结果污染页面

服务为每次重建维护 generation。取消时递增代次,后续异步返回检查自己是否仍属于当前任务。即使某个媒体调用无法立刻中断,晚到结果也不能继续触发训练、保存或页面跳转。

代次检查保护宿主流程,但不等于后台资源已经释放。因此它需要与原生停止请求配合。仅仅忽略旧结果,可能仍然留下持续运行的训练和大量内存占用。

输入文件由最后使用者决定清理时机

相机恢复和训练都读取 RGB 文件。超时后服务先调用原生取消,再等待 Promise 结束,最后进入统一清理。若用超时 Promise 竞争直接跳出外层,原生任务可能仍在读文件,finally 却已经删除它们。

可以把安全顺序概括为:请求取消、等待结算、清理输入。Worker 的媒体资源也应释放提取器和文件,并通过明确消息报告结束或错误。不同执行环境需要自己的停止协议。

超时与取消共享机制,保留不同原因

相机恢复按帧预算设置有界超时,训练则有独立上限。超时通过相同原生取消接口结束计算,但页面应该显示超时原因,而不是误报用户取消。当前服务保存超时标记,在原生返回后优先解释真正的停止原因。

进度查询失败也可能触发停止,以避免页面持续显示不可信状态。这时错误文案应指向进度读取问题,不能改写成训练数学失败。保持原因分类有助于后续统计和排错。

超时先请求取消,随后等待原生真正退出

const timeout = setTimeout(() => {
  timedOut = true;
  cancelVideoCameraRecovery(workPath);
}, timeoutMs);
try {
  await recoverVideoCameras(workPath, times);
  if (timedOut) throw new Error('相机恢复超时');
} finally {
  clearTimeout(timeout);
  // 外层在此 Promise 已结束后,才清理 RGB 输入。
}

超时回调只是发出请求。C++ 可能还在读取图片、匹配特征或完成一个不可中断的计算单元。如果使用 Promise.race 让业务先行返回,然后立即删除工作目录,就可能把正常取消变成文件读取失败,甚至触发更深层的不一致。

等待原生 Promise 结束,会让取消不是瞬间完成,但资源关系更加清楚。UI 可以先显示“正在停止”,表达请求已接收,而不是宣布工作已经结束。等底层确认退出后,再删除它拥有的输入和临时资源。

三种结束原因不能混淆

图 2:相同的停止机制可以对应不同用户语义

保存阶段需要定义竞争规则

取消可能恰好发生在模型已写出、尚未正式提交时。原生任务通过单一生命周期原子状态协调取消与完成:提交前接受的取消可以撤销本次输出,完成状态已经提交后,成功结果占优。

这种规则让用户和上层流程有一致结果。否则可能同时出现“已取消”与一个被加入列表的模型,或者成功提示后文件又被异步清理。产物提交点必须与任务终态绑定。

计费也要遵守所有权交接

在重建阶段,页面负责失败或取消后的收口;完整模型交给后台持久化后,责任转移给协调流程。页面退出不能再重复退款。当前代码明确区分是否已经交接,避免一个 chargeId 同时被两个路径结束。

验证应覆盖每个阶段退出、超时、取消与成功同时发生、晚到计费结果以及重新发起任务。检查不跳转旧页面、不删除已有产物、不残留临时文件,并记录真实任务停止时间。

原生侧在安全边界观察取消标志

void Cancel() {
    Lifecycle expected = Lifecycle::Active;
    lifecycle.compare_exchange_strong(expected, Lifecycle::CancelRequested);
}
for (int iteration = 0; iteration < iterations; ++iteration) {
    if (CancellationRequested()) return CancelledResult();
    TrainOneIteration();
    if (CancellationRequested()) return CancelledResult();
    PublishProgress(iteration);
}

原子状态使取消与完成可以明确竞争。取消只允许从活动状态进入,已经完成的任务不会因迟到的点击重新变成取消。示例以迭代为观察点,真实实现还会在保存、数据遍历等安全位置检查;检查频率决定响应速度的一部分。

不能随意强杀正在修改共享结构的线程。合作式退出允许当前单元完成,再统一清理内存与文件。验证取消时应覆盖抽帧、相机恢复、训练与保存多个阶段,特别是保存完成与取消同时发生的边界,而不是只测试进度刚开始时的一次点击。

取消设计体现了应用对用户时间与设备资源的尊重。按钮只是入口,真正的能力是让跨线程工作在明确边界内结束,并把结果、资源和业务状态一起收口。