uni-app 鸿蒙 App 保存图片到相册:排坑全记录
项目:Vue 3 + uni-app 3.0 App,编译目标 HarmonyOS App
环境:HBuilderX 5.x + DevEco Studio 5.0 + 鸿蒙真机
场景:将页面上的 Base64 二维码图片保存到系统相册
一、需求背景
项目有一个「数据同步」页面,生成绑定二维码后,用户需要将二维码保存到系统相册,然后在微信小程序中扫码同步数据。
在 Android/iOS 上,这段代码很简单:
// APP-PLUS 端(Android / iOS)
const bitmap = new plus.nativeObj.Bitmap('qrcode-bitmap');
bitmap.loadBase64Data(base64Str, () => {
bitmap.save('_doc/qrcode.png', {}, () => {
plus.gallery.save('_doc/qrcode.png', () => {
uni.showToast({ title: '已保存到相册' });
});
});
});
但到了一切都不一样的鸿蒙端,噩梦开始。
二、六次失败的尝试
尝试 1:直接用 plus.nativeObj.Bitmap
// 直接搬 Android 代码
const bitmap = new plus.nativeObj.Bitmap(...);
结果:直接报错。 plus 对象在鸿蒙上完全不可用。DCloud 官方文档明确说明:
由于性能原因,目前 uniapp 项目编译到 HarmonyOS 时,plus 对象不可用。
结论:此路不通。
尝试 2:getFileSystemManager().writeFile + _doc/ 路径
放弃 plus,改用 uni-app 的文件系统 API:
const base64Data = base64Str.replace(/^data:image\/\w+;base64,/, '');
const filePath = '_doc/bind_qrcode.png';
const fs = uni.getFileSystemManager();
fs.writeFile({
filePath,
data: base64Data,
encoding: 'base64',
success: () => {
uni.saveImageToPhotosAlbum({ filePath, ... });
}
});
结果:errCode: 1300002,提示路径不存在。
_doc/ 在 Android/iOS 的 plus.io 中能正确映射到应用沙箱,但鸿蒙的 getFileSystemManager 的路径翻译层有 bug,不认这个前缀。
尝试 3:去掉前缀,用纯文件名
const filePath = `bind_qrcode.png`; // 无任何前缀
结果:errCode: 1300002,同样的错误。纯文件名也不是有效路径。
尝试 4:unifile://cache/ 协议路径
查阅 uni-app 官方文档,发现 getFileSystemManager 的 filePath 参数被要求是「绝对路径,形如 unifile://cache/...」:
const filePath = `unifile://cache/bind_qrcode.png`;
结果:errCode: 1300013(Permission denied),换了种方式拒绝。
unifile://cache/ 在理论上是正确的写法,但鸿蒙 JSVM 桥接层把它翻译到了一个没有写权限的目录。
尝试 5:全局 uni.writeFile
发现 getFileSystemManager().writeFile 和全局 uni.writeFile 是两个不同的 API——前者走 JSVM 直连 ArkTS(有路径翻译 bug),后者底层是 plus.io 桥接(理论上支持 _doc/):
uni.writeFile({
filePath: '_doc/bind_qrcode.png',
data: base64Data,
encoding: 'base64',
success: () => { ... }
});
结果:TypeError: uni.writeFile is not a function。 鸿蒙端根本不存在这个全局 API。
尝试 6:UTS 原生插件
既然 JS 层面的 API 全都不行,那就写一个 UTS 插件,直接调用鸿蒙原生 API:
// utssdk/app-harmony/index.uts(伪代码)
import fs from '@ohos.file.fs';
import photoAccessHelper from '@kit.MediaLibraryKit';
export const saveBase64ToAlbum = async (base64: string) => {
// 1. 解码 base64 → ArrayBuffer
// 2. 写入 context.cacheDir 临时文件
// 3. photoAccessHelper.showAssetsCreationDialog() 弹出保存弹窗
// 4. 复制临时文件到授权 URI
};
在 vite.config.js 中配置别名为 @/uni_modules/,导入即可。
结果:Cannot find module: @dcloudio/uni-uts-v1。 UTS 插件依赖 @dcloudio/uni-uts-v1 这个编译运行时包,而项目中没有安装它。这个包带有特定版本号的后缀,说明是 DCloud 的内测/内部版,手动添加后还可能引入其他兼容性问题。
这条路能走通,但环境依赖太重,不适合轻量级的需求。
三、根因分析:为什么 getFileSystemManager 在鸿蒙上不工作
这是整个排坑过程中最重要的发现——不是路径格式的问题,而是架构层的硬伤。
uni API 的执行路径
你的 JS 代码
↓
┌─────────────────────────────────────────┐
│ uni.getFileSystemManager() │ ← JS 层 API
│ ↓ │
│ 【桥接层:JSVM → ArkTS 文件系统】 │ ← ❌ 这一层有 bug
│ ↓ │
│ @ohos.file.fs (鸿蒙原生文件 API) │
└─────────────────────────────────────────┘
uni-app 在鸿蒙上的架构是:Vue/JS 代码运行在一个嵌入的 JSVM(JavaScript 虚拟机) 中。getFileSystemManager().writeFile 是一个 JS API,需要经过一层桥接把调用翻译成 ArkTS 的文件操作。
问题就出在这层桥接上——路径翻译不完善:
| 你写的路径 | 桥接层的表现 | 错误码 |
|---|---|---|
_doc/xxx.png | 不翻译,当纯文件名 | 1300002(路径不存在) |
xxx.png(纯文件名) | 不翻译 | 1300002(路径不存在) |
unifile://cache/xxx.png | 翻译到无权限目录 | 1300013(权限拒绝) |
而 UTS 插件的原理就是绕过了这层桥接。UTS 代码编译后直接变成 ArkTS,不经过 JSVM 翻译,直接用 context.cacheDir(系统原生 API 返回的绝对路径)写文件。但由于 UTS 的编译依赖链太重,不适合这个场景。
四、最终方案:Canvas 绕行
所有直接操作文件的路径都被堵死了。换个思路——既然文件系统走不通,就用渲染引擎自带的导出能力。
页面上已经用 <image> 标签渲染了二维码,加一个隐藏的 <canvas>,把二维码画上去,再用 canvasToTempFilePath 导出临时文件路径,最后用 saveImageToPhotosAlbum 保存。
这个方案完全不碰文件系统 API,canvas 的临时路径由系统内部生成。
4.1 模板改动
在二维码图片旁边加一个隐藏 canvas:
<!-- 隐藏 canvas,仅用于鸿蒙端导出图片到相册 -->
<canvas
canvas-id="qrcode-export-canvas"
style="position: fixed; left: -9999px; top: -9999px; width: 240px; height: 240px;"
></canvas>
4.2 保存逻辑
// #ifdef APP-HARMONY
// 鸿蒙端:所有 writeFile 写法都有路径翻译 bug
// 绕过文件系统:把二维码画到 hidden canvas → canvasToTempFilePath → saveImageToPhotosAlbum
const ctx = uni.createCanvasContext('qrcode-export-canvas');
ctx.drawImage(qrImageBase64.value, 0, 0, 240, 240);
ctx.draw(false, () => {
uni.canvasToTempFilePath({
canvasId: 'qrcode-export-canvas',
width: 240,
height: 240,
success: (res) => {
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
uni.showToast({ title: '已保存到相册', icon: 'success' });
},
fail: (err) => {
const msg = (err && err.errMsg) || '';
uni.showToast({
title: msg.includes('auth') || msg.includes('permission')
? '请允许相册权限后重试'
: '保存失败',
icon: 'none'
});
}
});
},
fail: (err) => {
console.error('[BindQRCode] canvasToTempFilePath 失败:', err);
uni.showToast({ title: '保存失败', icon: 'none' });
}
});
});
return;
// #endif
4.3 执行流程
二维码 Base64(页面已渲染的 <image>)
↓ ctx.drawImage()
Hidden Canvas(240×240,位于屏幕外)
↓ ctx.draw(false, callback)
Canvas 渲染完成
↓ uni.canvasToTempFilePath()
临时文件路径(系统内部生成,不经过 JSVM 文件系统桥接)
↓ uni.saveImageToPhotosAlbum()
系统相册
不需要任何文件写入、不需要路径翻译、不需要插件依赖。
五、权限配置
保存图片到相册需要在 harmony-configs/entry/src/main/module.json5 中声明权限:
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.WRITE_IMAGEVIDEO",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
},
{
"name": "ohos.permission.READ_IMAGEVIDEO",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
}
同时需要在 harmony-configs/entry/src/main/resources/base/element/string.json 中提供权限申请说明文案:
{
"string": [
{
"name": "Reason_SaveImage",
"value": "用于将二维码保存到系统相册"
}
]
}
权限声明是通过
harmony-configs/注入的,不能用manifest.json配置。详见《uni-app 鸿蒙 App 自定义配置全攻略》。
六、失败路径对比
| 序号 | 方案 | 写法 | 结果 | 原因 |
|---|---|---|---|---|
| 1 | plus.nativeObj.Bitmap | plus.gallery.save(...) | plus 不可用 | 鸿蒙不支持 plus API |
| 2 | getFileSystemManager + _doc/ | fs.writeFile({filePath:'_doc/xxx'}) | 1300002 | 路径不翻译 |
| 3 | 无前缀文件名 | fs.writeFile({filePath:'xxx.png'}) | 1300002 | 路径不翻译 |
| 4 | unifile://cache/ | fs.writeFile({filePath:'unifile://cache/xxx'}) | 1300013 | 翻译到无权限目录 |
| 5 | 全局 uni.writeFile | uni.writeFile({...}) | not a function | 鸿蒙端不存在此 API |
| 6 | UTS 插件 | import { fn } from '@/uni_modules/...' | 缺少 uni-uts-v1 | 编译依赖链太重 |
| 7 | Canvas 绕行 | drawImage → canvasToTempFilePath → saveImageToPhotosAlbum | ✅ 成功 | 绕过文件系统 |
七、核心教训
鸿蒙 ≠ Android。
plus.*全家桶在鸿蒙上完全不可用,需要为鸿蒙端单独写一套保存逻辑。uni.getFileSystemManager().writeFile在鸿蒙上有路径翻译 bug。无论怎么换路径格式(_doc/、unifile://、纯文件名),底层桥接层都无法正确翻译到鸿蒙沙箱路径。这是 HBuilderX 的已知问题。不要和文件系统较劲,用渲染引擎的能力。Canvas 的
toTempFilePath由系统内部生成临时文件,完美绕过 JSVM 的文件系统桥接层。UTS 插件理论上是最优解,但实际落地时依赖链太重(需要
@dcloudio/uni-uts-v1特定版本),不如 Canvas 方案轻量。权限声明走
harmony-configs/。manifest.json的app-harmony.distribute字段不被构建系统处理,模块权限必须在module.json5中声明。
本文记录了 uni-app 鸿蒙 App 中「保存 Base64 图片到相册」这一看似简单的需求背后隐藏的 7 个坑。核心发现是 getFileSystemManager 的 JSVM 桥接层存在路径翻译缺陷,最终通过 Canvas 渲染引擎的导出能力绕过了整个文件系统问题。