标签Harmony os、ArkTS、MediaLibraryKit、FileUri、临时文件PNG 已经生成不代表它已经进入系统相册。ImagePacker 返回的是内存中的ArrayBuffer媒体创建对话框需要一个可访问的文件 URI应用必须先把字节落到自己的缓存目录关闭文件句柄再通过 FileUri 交给系统。保存成功后这份缓存文件又应及时删除。真正容易遗漏的是失败路径写文件失败、URI 转换失败、用户取消相册对话框、媒体服务抛错、临时文件删除失败。若清理只写在成功分支连续取消会在 cacheDir 留下多份高清 PNG若清理异常覆盖了保存结果用户明明已经保存成功页面却显示失败。本文以拼豆制图的savePatternToGallery为主线构建一条“生成 → 写缓存 → 关闭句柄 → 转 URI → 系统确认 → finally 清理”的完整文件事务。本文重点解决如何生成稳定、安全且不冲突的临时文件名。为什么缓存路径必须来自UIAbilityContext.cacheDir。CREATE、READ_WRITE、TRUNC 三个打开标志分别解决什么问题。为什么文件句柄要在交给媒体能力之前关闭。FileUri 如何把应用路径转换成系统可消费身份。相册创建描述如何与 PNG 文件一致。为什么临时删除必须位于外层 finally且不能覆盖原始结果。页面何时提交收藏和最近生成记录才符合业务语义。保存链路有两个完成条件一次导出包含“应用内文件完成”和“系统相册接收完成”两个节点节点完成信号失败后的状态缓存 PNG 写入writeSync完成且句柄关闭不应打开相册对话框系统相册创建showAssetsCreationDialog正常返回页面才可报告已保存缓存文件只是传输媒介不是最终用户结果。把writeSync成功直接显示为“已保存到相册”会在用户取消对话框时产生假成功。同样相册完成后缓存删除失败不应改变“相册已接收”的事实。两个结果需要分开记录。文件名先约束业务 ID当前文件名函数为privatestaticfileName(pattern:Pattern):string{constsafeIdpattern.id.replace(/[^a-zA-Z0-9_-]/g,_);returnpindouzhitu_${safeId}_${Date.now()}.png;}只允许英文字母、数字、下划线和连字符其余字符替换为_。时间戳让同一图纸连续导出不会直接覆盖上一份缓存文件。文件名不使用图纸标题因为标题可能包含斜杠、冒号、空格或很长中文标题仍可以单独传给媒体资产描述磁盘身份则保持可控。还应限制 safeId 最大长度防止外部导入的异常 ID 生成过长路径。缓存路径必须从应用上下文派生服务接收UIAbilityContextstaticasyncsavePatternToGallery(context:common.UIAbilityContext,pattern:Pattern):Promisestring{constfileNamePatternExportService.fileName(pattern);constfilePath${context.cacheDir}/${fileName};// 后续保存}不要硬编码用户目录或共享存储路径。cacheDir明确表达文件的临时性质也让应用拥有写入边界。页面通过当前组件获取 contextconstcontextgetContext(this)ascommon.UIAbilityContext;awaitPatternExportService.savePatternToGallery(context,pattern);context 只传给服务不存进 Pattern。业务模型仍能脱离 Ability 生命周期存在。PNG 必须先在内存中完整生成服务先调用constpngBufferawaitPatternExportService.renderPng(pattern);renderPng已经完成 1632×1868 画布、PixelMap 和 ImagePacker 处理返回标准 PNG 字节。文件层不再理解网格、坐标或颜色图例只负责原样写入。将渲染与落盘拆开有两个好处渲染失败时不会留下半个文件文件写入测试也可以使用固定小缓冲区不必每次绘制 4900 个格子。打开标志要保证旧文件不残留当前打开方式constfilefileIo.openSync(filePath,fileIo.OpenMode.CREATE|fileIo.OpenMode.READ_WRITE|fileIo.OpenMode.TRUNC);三个标志的职责CREATE不存在时创建。READ_WRITE允许当前写入方式使用文件描述符。TRUNC若同名文件已存在先截断旧内容。虽然时间戳降低了重名概率TRUNC仍能防止测试中固定文件名或时钟碰撞时短 PNG 后面残留旧文件尾部。文件句柄必须用独立 finally 关闭写入代码形成最小作用域constfilefileIo.openSync(filePath,openMode);try{fileIo.writeSync(file.fd,pngBuffer);}finally{fileIo.closeSync(file.fd);}writeSync抛错时也会进入 finally。文件描述符不应该一直开到相册对话框结束因为系统界面等待时间由用户决定可能持续很久。在转换为 FileUri 前关闭句柄也能保证写入已收口不让媒体能力读取一份仍处于应用写入阶段的文件。写入结果要验证完整字节数更严格的实现应读取writeSync返回值constwrittenfileIo.writeSync(file.fd,pngBuffer);if(written!pngBuffer.byteLength){thrownewError(PNG 写入不完整${written}/${pngBuffer.byteLength});}只要少一个字节就不应继续打开系统相册。部分文件可能仍能显示缩略图却在完整解码或底部区域出现损坏。验证阶段还可以重新读取文件头确认 PNG 签名存在再进入媒体创建流程。普通路径通过 FileUri 转为系统身份文件关闭后执行consturifileUri.getUriFromPath(filePath);路径是应用文件系统视角URI 是系统能力之间传递的身份。不要手工拼接file://也不要把缓存路径直接当成相册资产 URI。FileUri 转换失败应保留为“临时文件身份转换失败”此时还没有完成相册保存但缓存文件已经存在外层 finally 必须负责删除。相册资产描述要与 PNG 保持一致服务获取媒体 helper 后打开系统确认consthelperphotoAccessHelper.getPhotoAccessHelper(context);awaithelper.showAssetsCreationDialog([uri],[{title:fileName.replace(.png,),fileNameExtension:png,photoType:photoAccessHelper.PhotoType.IMAGE}]);URI 数组与描述数组必须一一对应。当前只保存一张所以两边长度都是 1扩展批量导出时不能只增加 URI 而漏掉描述。fileNameExtension、photoType与实际 ImagePacker 输出保持一致。若未来改成其他格式生成、文件名和媒体描述必须原子更新。当前成功后清理仍有失败路径空洞现有顺序是awaithelper.showAssetsCreationDialog(...);try{fileIo.unlinkSync(filePath);}catch(_){}如果对话框抛错或用户取消以异常形式返回代码不会到达unlinkSync缓存 PNG 会留下。连续取消可能积累多份 11.63 MiB 原始画布编码后的文件。因此临时清理应覆盖相册成功、失败和取消而不能只跟在 await 后面。外层 finally 才能保证缓存清场更完整的结构是constfilePath${context.cacheDir}/${fileName};try{awaitwritePngFile(filePath,pngBuffer);consturifileUri.getUriFromPath(filePath);awaitsaveUriWithDialog(context,uri,fileName);returnfileName;}finally{try{fileIo.unlinkSync(filePath);}catch(cleanupError){// 记录安全的清理信息不覆盖主结果}}即使writePngFile只创建了部分文件finally 也会尝试清理。若文件根本不存在清理失败由窄 catch 吸收或记录。关键是不要在 cleanup catch 中抛出新的“删除失败”覆盖原始相册错误否则排查会看到错误的阶段。清理失败与保存失败是两种不同结果可以定义内部结果interfaceGallerySaveResult{fileName:string;gallerySaved:boolean;tempCleaned:boolean;}面向用户的主结果由gallerySaved决定。tempCleaned false属于维护信号可在不包含用户路径的安全日志中记录并在下次启动时清理过期缓存。若相册已经接收文件删除缓存失败不能让页面显示“保存失败”反之缓存删除成功也不能把相册取消变成成功。页面忙碌状态阻止重复写入页面已经有独立导出守卫if(this.isExporting){return;}this.isExportingtrue;try{awaitPatternExportService.savePatternToGallery(context,pattern);}finally{this.isExportingfalse;}两个导出按钮都使用同一个isExporting能防止用户同时创建多个大画布与缓存文件。按钮文案也改为“生成中…”。守卫只负责并发不证明保存成功。最终状态必须等系统相册 await 正常完成后再提交。收藏与最近记录要明确提交时机当前页面在调用相册前执行this.savePatternRecord(pattern);awaitPatternExportService.savePatternToGallery(context,pattern);因此用户取消相册保存后图纸仍会加入收藏与最近生成记录。这可能是产品选择也可能是事务顺序不符合文案。若业务定义为“成功导出后自动收藏”应改成awaitPatternExportService.savePatternToGallery(context,pattern);this.savePatternRecord(pattern);若定义为“点击导出即保存应用内记录”则应把提示写清不要让用户以为取消会回滚应用内记录。代码顺序必须反映真实产品语义。验证要主动制造每个文件阶段失败建议覆盖场景预期正常保存相册出现 PNG缓存文件删除对话框取消页面显示取消缓存文件删除写入失败不打开相册句柄关闭残片删除URI 转换失败缓存删除保留正确阶段错误媒体服务错误缓存删除可立即重试unlink 失败不覆盖已经成功的相册结果快速双击只生成一个缓存文件同一图连续导出文件名不同相册结果完整还要回读相册中的 PNG 尺寸和尾部图例防止“文件存在”掩盖部分写入问题。常见保存故障沿文件事务定位现象先看哪里常见根因修复方向相册里没有图但页面说成功成功提交点写缓存后提前提示等系统对话框完成取消多次后缓存增长外层 finallyunlink 只在成功后执行所有路径统一清理第二次 PNG 尾部乱码打开标志旧文件未截断加TRUNC并校验写入数媒体能力读不到文件句柄与 URI文件仍在写或 URI 手拼先关闭再用 FileUri删除失败覆盖保存成功cleanup catch清理异常向外抛分离主结果与清理结果文件名包含异常字符pattern.id直接使用业务标题白名单规范化 ID取消后仍加入收藏页面调用顺序记录在 await 之前提交按产品语义调整顺序快速点击出现多个对话框isExporting没有并发守卫入口统一禁用排查时按 PNG buffer → 文件写入 → 句柄关闭 → FileUri → 相册对话框 → 临时清理逐层前进。每个阶段都有独立完成信号不要用一个“保存失败”覆盖全部细节。小结相册保存不是把 ArrayBuffer 写到一个路径就结束而是一段短文件事务。拼豆制图先在cacheDir生成安全文件名用 CREATE、READ_WRITE、TRUNC 写入完整 PNG并在独立 finally 关闭句柄随后通过 FileUri 把路径交给系统相册。真正完整的实现还要把临时文件删除提升到外层 finally覆盖成功、取消和错误同时保证清理失败不覆盖相册主结果。页面的isExporting防止并发收藏与最近记录则按明确业务语义选择提交时机。这样相册、缓存和应用内状态才不会在一次取消后各自留下不同结论。