ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

uniapp保存图片三端实战:H5下载、Android权限与iOS相册授权

uniapp保存图片三端实战:H5下载、Android权限与iOS相册授权

图片要保存到本地,这个需求看起来一句话就能说完,真动手做的时候往往要在 H5、Android、iOS 三个运行环境里各写一套逻辑,还要处理权限、路径、跨域、相册刷新这些零零碎碎的细节。uniapp 把这三种运行环境收在同一个工程里,好处是业务代码可以复用,麻烦的地方就在于同一个"保存图片"的动作,在浏览器里是触发下载,在 Android 上是写文件到相册,在 iOS 上又是请求照片库授权后写相册,三者底层完全不是一回事。这篇内容我想把这三端的实现链路拆开讲清楚:H5 端怎么绕过跨域把图片变成可下载的 blob,Android 端怎么处理不同系统版本的存储权限和相册刷新,iOS 端怎么在沙盒和照片库之间正确落盘,最后再给一套可以直接抄走的跨端封装。适合已经上手 uniapp、正在做海报保存、二维码保存、凭证图片下载这类功能的前端同学,也适合刚接触多端适配、想搞明白"为什么同一段代码三端表现不一样"的开发。

1. 先搞清楚三端"保存图片"到底差在哪

1.1 三个运行环境,三套文件系统逻辑

很多人第一次写这个功能会下意识去找一个"万能 API",结果翻文档翻到uni.saveImageToPhotosAlbum,以为这一条就能通吃,写完一跑,H5 端直接报"当前平台不支持"。问题不在于 API 设计得不好,而在于三个运行环境对"保存到本地"这件事的定义根本不同。

H5 运行在浏览器沙箱里,页面没有任何直接写磁盘的能力,你能做的只有发起一次下载请求,让浏览器自己决定文件落在哪里,用户点确认还是取消你都无法干预。Android 端的 App 是一个真正的原生进程,它有私有目录、有公共存储区、有系统相册媒体库,写入的位置不同权限要求就完全不同。iOS 更严格,应用被关在沙盒里,想往用户照片库写东西必须走系统提供的授权通道,用户点了拒绝你就彻底没戏。

所以正确的思路不是找万能 API,而是先想清楚"保存到哪里"这件事在三端各自对应什么。多数业务场景想要的其实是"用户在系统相册或下载目录里能看到这张图",那么 H5 对应浏览器下载目录,Android 对应相册媒体库,iOS 对应照片 App。这条主线定下来,后面的技术选型才有依据。

1.2 需求拆解:保存到哪里算"保存成功"

我在项目里会把需求拆成三个问题来问产品:一,图片最终要在哪里被用户看到,是相册、文件管理器还是应用内部?二,是否需要不经过用户操作就能保存?三,保存失败时要不要给用户可操作的补救路径?

这三个问题的答案直接决定方案。如果是"保存到相册",走系统相册接口;如果是"存到应用内部供后续上传",那走私有目录就够了,还省掉权限申请;如果必须静默保存不给任何提示,那 H5 端基本不可能实现,只能退化成展示图片引导用户长按。

我见过一些项目把"保存成功"的提示做成uni.showToast,结果 Android 上图片其实写进了应用私有目录,用户在相册里翻半天找不到,回来投诉功能有问题。这种坑的根源就是一开始没把"保存到哪里"定义清楚,代码写完才发现语义对不上。

1.3 一个容易被忽略的前置条件:图片从哪来

图片的来源同样影响链路长度。远端 URL、本地临时文件、base64 字符串,这三种来源在三端的处理路径完全不同。远端 URL 在 H5 端会撞上跨域,在 Android 和 iOS 端要先下载成本地临时文件;base64 在 H5 端可以直接塞进a标签的href,在 App 端必须先写成本地文件才能交给相册接口;本地临时文件相对最简单,但它的有效期很短,App 重启后可能就失效了。

我一般会在业务层统一约定:不管图片从哪来,先归一化成"一个可访问的本地路径"或者"一个可下载的远端 URL",再交给保存函数。这样上层的保存逻辑只需要处理两种输入,代码复杂度立刻降一个量级。

2. 方案选型:别一上来就写代码

2.1 官方 API 的能力边界对照

uniapp 官方在图片保存这块提供了几个不同层次的 API,能力边界差异很大,先把这张表记在心里,能省掉大量试错时间。

APIH5AndroidiOS说明
uni.saveImageToPhotosAlbum不支持支持支持保存到系统相册,App 端最常用
uni.downloadFile支持(受跨域限制)支持支持下载远端资源到临时目录
uni.saveFile不支持支持支持把临时文件转成持久化文件
uni.getFileSystemManager不支持支持支持文件读写,HBuilderX 3.5.3 起可用
plus.gallery.save不支持支持支持5+ 原生相册接口,粒度更细
a[download]或 Blob 下载支持不适用不适用H5 端标准做法

看到这张表基本就能明白,H5 是一条完全独立的技术路线,Android 和 iOS 可以共用大部分逻辑但权限和路径上还要分开处理。所以工程结构上我会把它拆成两个函数:saveImageH5和saveImageApp,用条件编译在入口处分流,App 内部再按osName做二次细分。

2.2 条件编译是整个方案的地基

uniapp 的条件编译在这里不是可选技巧,而是必需的工程手段。uni.getFileSystemManager这类 API 在 H5 端根本不存在,如果你把它写在公共模块里,H5 打包时不会报错,但运行时一调用就是undefined is not a function。所以我会把平台相关的代码全部包在条件编译注释里。

// #ifdef H5 export function saveImage(options) { return saveImageH5(options); } // #endif // #ifdef APP-PLUS export function saveImage(options) { return saveImageApp(options); } // #endif

这种写法有个细节要注意:条件编译是在编译期生效的,所以同一份源码在不同平台打包后,产物里只会保留对应的那一个函数。这比运行时判断process.env.UNI_PLATFORM更干净,也不会把无用代码带进包里。我在早期的项目里图省事用了运行时判断,结果 H5 包里混进了一堆plus.*的调用,虽然没执行但代码体积和可读性都被拖累了。

2.3 封装层的输入输出约定

统一封装的函数签名我固定成这样的形态:

/** * @param {Object} options * @param {string} options.url 远端图片地址,与 path 二选一 * @param {string} options.path 本地临时路径或 base64,与 url 二选一 * @param {string} [options.filename] 下载文件名,仅 H5 生效 * @returns {Promise<{path: string}>} */

返回值统一给一个path,H5 端返回空字符串,App 端返回实际落盘路径。这样调用方只需要关心"成功还是失败",不需要关心内部走了哪条链路。失败时统一抛出一个带code和message的 Error,业务层据此决定是提示"权限被拒绝"还是"网络异常"。

3. H5 端实操:浏览器里"保存图片"就是触发下载

3.1 URL 直链下载的最短路径

如果图片本身是一个同源的 URL,或者服务器已经在响应头里带了Content-Disposition: attachment,那最省事的做法就是创建一个隐藏的a标签,设置download属性后模拟点击。

function downloadByAnchor(url, filename) { const a = document.createElement('a'); a.href = url; a.download = filename || 'image'; a.style.display = 'none'; document.body.appendChild(a); a.click(); document.body.removeChild(a); }

这段代码在桌面浏览器上稳如老狗,但在移动端的表现就参差不齐了。iOS Safari 从 13 才开始比较完整地支持download属性,13 以前的版本会直接把图片在新标签页打开,用户得自己长按保存。国内不少安卓浏览器用的是定制内核,download属性可能被忽略,表现同样是打开图片预览页。

所以我会在调用前先做一次能力探测,而不是盲目执行:

function canUseDownloadAttr() { const a = document.createElement('a'); return typeof a.download !== 'undefined'; }

探测不通过就走后面的兜底方案。

3.2 跨域图片的 Canvas 中转方案

真实业务里的图片大多挂在 CDN 上,域名跟页面不同,这时候a[download]会失效——浏览器出于安全考虑不允许把跨域资源直接下载到本地。解决办法是把图片先画到 canvas 上,再通过toBlob导出成同源的 Blob 对象。

function downloadByCanvas(url, filename) { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); canvas.toBlob((blob) => { if (!blob) { reject(new Error('canvas 导出失败')); return; } const objectUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = objectUrl; a.download = filename || 'image.png'; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); resolve(); }, 'image/png'); }; img.onerror = () => reject(new Error('图片加载失败,请检查跨域配置')); img.src = url; }); }

这里的img.crossOrigin = 'anonymous'是关键,它让浏览器以匿名模式请求图片,不带 cookie,同时要求服务器响应Access-Control-Allow-Origin。如果服务器没配这个头,图片依然能加载出来,但 canvas 会被标记成"被污染",调用toBlob时抛SecurityError。这个错误在控制台里往往只显示一行Tainted canvases may not be exported,新手很容易一头雾水。

URL.revokeObjectURL那行放在setTimeout里不是随手写的。部分浏览器在a.click()之后立刻回收 Blob URL 会导致下载中断,加一个短延时能规避这个问题。这个延时我没有精确测量过具体需要多少,经验值 1000 毫秒足够保险。

3.3 base64 图片在 H5 的特殊处理

base64 图片在 H5 端反而是最好处理的,因为它天生就是同源的。直接把data:image/png;base64,...塞给a.href,download属性同样生效。

function downloadByBase64(base64, filename) { const a = document.createElement('a'); a.href = base64; a.download = filename || `image_${Date.now()}.png`; document.body.appendChild(a); a.click(); document.body.removeChild(a); }

不过有个隐蔽的坑:微信内置浏览器会拦截 base64 的下载行为,点击后没有任何反应,也不会报错。这种情况下只能退化成把图片渲染到页面上,提示用户长按保存。

3.4 微信内置浏览器的兜底策略

微信 WebView 对下载的支持一直很保守,a[download]和 Blob 下载都可能被静默拦截。我的做法是先判断 UA:

function isWechat() { return /micromessenger/i.test(navigator.userAgent); }

如果是微信环境,直接跳过下载尝试,把图片用img标签放大展示,配一句"长按图片保存到手机"。用户体验上其实并不差,微信用户对长按保存这个动作已经形成肌肉记忆了。

如果是微信里通过公众号 JSSDK 接入的场景,还可以调用wx.previewImage预览图片,用户在预览页右上角同样有保存选项。不过这条路径需要公众号配置和鉴权,门槛比纯 H5 高,我会在文档里当备选方案列出来,不强推。

还有一个小众但值得提的场景:部分安卓定制浏览器在下载时会弹出一个"是否保存"的原生对话框,用户点了取消,代码里是感知不到的。所以 H5 端的保存成功提示我一般写成"已触发下载,请在通知栏或下载列表查看",而不是"保存成功",避免误导。

4. Android 端实操:权限、分区存储与相册刷新

4.1 网络图片先落地:downloadFile 的正确用法

uni.saveImageToPhotosAlbum只接受本地文件路径,传 http URL 会直接失败。所以远端图片必须先下载到临时目录。uni.downloadFile就是干这个的,成功回调里拿到的tempFilePath在 Android 上是/storage/emulated/0/Android/data/包名/cache/...这样的绝对路径,可以直接交给后续的保存接口。

function downloadToTemp(url) { return new Promise((resolve, reject) => { uni.downloadFile({ url, success: (res) => { if (res.statusCode === 200) { resolve(res.tempFilePath); } else { reject(new Error(`下载失败,状态码 ${res.statusCode}`)); } }, fail: (err) => reject(new Error(err.errMsg || '网络异常')) }); }); }

这里有个容易被忽略的点:statusCode的判断不能省。服务端返回 404 时,downloadFile的成功回调依然会触发,tempFilePath指向的可能是一个包含错误页面的文件,后续保存到相册会得到一张裂图或者 HTML 文件。我踩过一次,排查了半天才发现是 CDN 上的图片被删了。

另外要注意,downloadFile下载的临时文件在应用重启后可能被系统清理,所以如果业务需要"保存后长期可访问",要么走uni.saveFile转成持久化文件,要么直接保存进相册。相册里的文件归系统媒体库管理,不受应用缓存清理影响。

4.2 权限申请的分版本策略

Android 的存储权限从 6.0 开始是运行时权限,从 10 开始引入了分区存储,两个变化叠加起来让这块代码必须分支处理。

Android 6.0 到 9.0:写外部存储需要WRITE_EXTERNAL_STORAGE权限,而且必须在运行时动态申请。这段我用plus.android调原生接口:

function requestAndroidStoragePermission() { return new Promise((resolve) => { const Build = plus.android.importClass('android.os.Build'); // Android 10 及以上走分区存储,保存自己的图片不需要额外权限 if (Build.VERSION.SDK_INT >= 29) { resolve(true); return; } plus.android.requestPermissions( ['android.permission.WRITE_EXTERNAL_STORAGE', 'android.permission.READ_EXTERNAL_STORAGE'], (result) => { const denied = result.deniedPresent.length + result.deniedAlways.length; resolve(denied === 0); }, () => resolve(false) ); }); }

deniedAlways和deniedPresent要分开看。前者表示用户勾选了"不再询问",后者表示这次拒绝了但下次还会弹。针对deniedAlways,正确做法是引导用户去设置页手动开启,而不是反复弹窗调系统接口——系统根本不会再弹。

function openAppSetting() { const main = plus.android.runtimeMainActivity(); const Intent = plus.android.importClass('android.content.Intent'); const Uri = plus.android.importClass('android.net.Uri'); const intent = new Intent(); intent.setAction('android.settings.APPLICATION_DETAILS_SETTINGS'); intent.setData(Uri.fromParts('package', main.getPackageName(), null)); main.startActivity(intent); }

Android 10 及以上的分区存储是个好消息,应用通过 MediaStore 写入自己的图片不需要任何存储权限,这也是我把版本判断放在最前面的原因——能少一次弹窗就少一次,用户体验上差别很大。

4.3 保存到相册的完整链路

链路串起来大概是这个样子:

async function saveImageToAlbumAndroid(url) { const granted = await requestAndroidStoragePermission(); if (!granted) { throw new Error('STORAGE_PERMISSION_DENIED'); } const tempPath = await downloadToTemp(url); return new Promise((resolve, reject) => { uni.saveImageToPhotosAlbum({ filePath: tempPath, success: () => resolve({ path: tempPath }), fail: (err) => reject(new Error(err.errMsg || '保存相册失败')) }); }); }

uni.saveImageToPhotosAlbum在 Android 上内部调用的是系统的媒体扫描插入逻辑,成功之后图片会出现在"相册"App 的最近项目里,不需要你手动发广播刷新媒体库。这一点跟直接用FileOutputStream写到公共目录不同,后者如果不发MediaScannerConnection.scanFile的广播,文件管理器里能看到但相册里不显示。官方 API 帮你处理了这一步,代价是灵活性差一些,比如没法自定义相册子目录的名字。

如果业务确实需要把图片归到一个固定命名的相册里,那就得用plus.gallery.save:

function saveToNamedAlbum(filePath, albumName) { return new Promise((resolve, reject) => { plus.gallery.save(filePath, () => resolve(), (err) => reject(err), albumName); }); }

这个接口的最后一个参数就是相册名,实测在大部分国产 ROM 上都能正确建目录,但在某些深度定制的系统上可能被忽略,图片还是落到默认相册。这种差异没法从文档里查到,只能真机测试。

4.4 存到应用私有目录的适用场景

有些业务根本不需要用户去相册里找图,比如生成一张图片后要立刻上传到服务器,或者存下来做本地缓存。这时候完全可以绕开权限,直接写应用私有目录。

uni.getFileSystemManager在 App 端的可用路径有几个固定前缀,_doc对应应用的文档目录,_downloads对应下载目录。写文件大概这样:

function writeToDocDir(base64Data, filename) { return new Promise((resolve, reject) => { const fs = uni.getFileSystemManager(); const filePath = `${uni.env.USER_DATA_PATH}/${filename}`; fs.writeFile({ filePath, data: base64Data, encoding: 'base64', success: () => resolve(filePath), fail: (err) => reject(new Error(err.errMsg || '写入失败')) }); }); }

uni.env.USER_DATA_PATH在 App 端会解析成实际的应用私有路径,Android 上大概是/storage/emulated/0/Android/data/包名/files/,iOS 上在沙盒的 Documents 里。这条路子不需要任何权限,写进去的文件也不会被系统清理,但用户通过文件管理器很难找到它,仅适合应用内部使用。

5. iOS 端实操:沙盒路径与相册授权

5.1 沙盒路径的三条路

iOS 应用运行在沙盒里,能写的目录就那么几个。uniapp 里常用的有三个:_doc对应 Documents 目录,_documents是同一位置的另一种写法,_downloads对应 Library/Downloads。写临时文件的时候我常用_doc,因为它在文件共享开启的情况下用户可以通过 iTunes 或者访达访问到,调试的时候方便。

function saveToSandbox(base64Data, ext) { const filename = `img_${Date.now()}.${ext || 'png'}`; const filePath = `_doc/${filename}`; return new Promise((resolve, reject) => { plus.io.resolveLocalFileSystemURL(filePath, (entry) => { entry.createWriter((writer) => { writer.onwrite = () => resolve(entry.toLocalURL()); writer.onerror = (e) => reject(new Error(e.message || '写入失败')); // 去掉 base64 的 data URI 前缀 const raw = base64Data.replace(/^data:image\/\w+;base64,/, ''); writer.writeAsBinary(raw); }, (err) => reject(new Error(err.message))); }, (err) => reject(new Error(err.message))); }); }

这段用的是 5+ 的plus.ioAPI,兼容性最好。如果要统一用uni.getFileSystemManager,代码会更简洁一些,但前提是 HBuilderX 版本够新。

注意writer.writeAsBinary接收的是纯二进制字符串,不能带data:image/png;base64,这个前缀,否则写出来的文件是损坏的。这个坑非常常见,图片保存成功但打开是坏的,八成就是前缀没剥干净。

5.2 相册授权的两种级别

iOS 的相册权限分得比 Android 细,NSPhotoLibraryUsageDescription是读写权限,NSPhotoLibraryAddUsageDescription是仅添加权限。用uni.saveImageToPhotosAlbum只需要添加权限就够了,但这两种描述必须提前在manifest.json的 iOS 配置里填好,否则打包时不会自动生成,运行时一调用相册接口就直接闪退或者被拒。

{ "app-plus": { "distribute": { "ios": { "privacyDescription": { "NSPhotoLibraryAddUsageDescription": "需要访问相册以保存图片", "NSPhotoLibraryUsageDescription": "需要访问相册以保存图片" } } } } }

这两个键名写错是另一个高频事故。我见过把NSPhotoLibraryAddUsageDescription拼成NSPhotoLibraryAddUsageDescriptions的,编译器不报错,上架审核的时候被系统直接拒掉,返回的原因列表里会带上具体的键名。

iOS 14 之后新增了"有限访问"的概念,用户可以选择只授权应用访问部分照片。对于保存场景来说,仅添加权限不受这个限制影响,用户授权后应用依然能写入新图片,只是读不到已有的照片。所以如果业务只是保存不读取,尽量只申请 Add 权限,被用户拒绝的概率会低不少。

用户拒绝授权之后,再调用相册接口会立即失败,不会二次弹窗。这时候要给用户一条明确的路径:

function gotoSystemSettingIos() { const UIApplication = plus.ios.importClass('UIApplication'); const app = UIApplication.sharedApplication(); const url = plus.ios.invoke(app, 'openURL:', plus.ios.invoke('NSURL', 'URLWithString:', 'app-settings:')); }

这段代码在 iOS 8 之后都能用,打开的是当前应用的设置页,用户可以在那里重新开关权限。

5.3 base64 转临时文件的统一处理

iOS 端有一个细节跟 Android 不太一样:uni.saveImageToPhotosAlbum的filePath参数在某些版本上要求必须是file://协议开头的路径,而在另一些版本上接受相对路径。为了保险,我会在调用前统一做一次路径规范化:

function normalizePath(path) { if (!path) return path; if (path.startsWith('file://')) return path; if (path.startsWith('_doc') || path.startsWith('_downloads')) { return plus.io.convertLocalFileSystemURL(path); } return path; }

plus.io.convertLocalFileSystemURL会把_doc/xxx.png这种相对路径转成file:///var/mobile/Containers/.../Documents/xxx.png这样的绝对路径。实测两个形式在多数机型上都能用,但转换一下更稳,尤其是在 iOS 上。

完整的 iOS 保存链路:

async function saveImageIos(source) { let filePath; if (/^https?:\/\//.test(source)) { filePath = await downloadToTemp(source); } else if (source.startsWith('data:image')) { filePath = await saveToSandbox(source); } else { filePath = source; } const normalized = normalizePath(filePath); return new Promise((resolve, reject) => { uni.saveImageToPhotosAlbum({ filePath: normalized, success: () => resolve({ path: normalized }), fail: (err) => { const msg = err.errMsg || ''; if (/denied|authorize/i.test(msg)) { reject(new Error('PHOTO_PERMISSION_DENIED')); } else { reject(new Error(msg || '保存失败')); } } }); }); }

errMsg的匹配我用了正则而不是精确相等,因为不同版本的 iOS 和不同版本的 HBuilderX 返回的文案不完全一样,用包含匹配更皮实。

6. 跨端统一封装与常见问题速查

6.1 一个 saveImage 函数收口三端

把前面几块拼起来,主入口大概长这样:

export async function saveImage(options) { const { url, path, filename } = options; // #ifdef H5 if (url) { if (isWechat()) { throw new Error('WECHAT_NO_DOWNLOAD'); } try { await downloadByCanvas(url, filename); } catch (e) { // 跨域失败时退化成直接开新窗口 window.open(url, '_blank'); } return { path: '' }; } if (path && path.startsWith('data:image')) { downloadByBase64(path, filename); return { path: '' }; } throw new Error('INVALID_SOURCE'); // #endif // #ifdef APP-PLUS const osName = uni.getSystemInfoSync().osName; if (osName === 'android') { return saveImageToAlbumAndroid(url || path); } if (osName === 'ios') { return saveImageIos(url || path); } throw new Error('UNSUPPORTED_PLATFORM'); // #endif }

入口层只做三件事:判断平台、归一化图片源、分发到具体实现。这样业务代码里只需要saveImage({ url: 'https://cdn.xxx.com/a.png' })一行,不需要关心底下发生了什么。

错误码我也做了一层统一,STORAGE_PERMISSION_DENIED、PHOTO_PERMISSION_DENIED、WECHAT_NO_DOWNLOAD这几个在业务层可以分别映射成不同的提示文案和引导按钮。比起直接抛原始错误信息,这种方式让 UI 层的处理逻辑清晰很多。

6.2 常见问题速查表

实打实踩过的坑我都整理在这张表里了,遇到问题先在表里对一遍,能省不少时间。

现象可能原因处理方式
H5 点击无反应微信内核拦截下载展示图片引导长按保存
H5 控制台报 Tainted canvases图片跨域且未配 CORS服务端加Access-Control-Allow-Origin
H5 下载的是 HTML 文件图片地址 404 返回了错误页下载前先校验statusCode
Android 相册里找不到图片图片写进了私有目录改用saveImageToPhotosAlbum
Android 权限弹窗不出现用户勾了"不再询问"引导跳转到应用设置页
Android 10 以上申请权限失败分区存储下不需要该权限版本判断前置,跳过申请
iOS 图片保存成功但打不开base64 前缀未剥离writeAsBinary前先剔除 data URI
iOS 调用相册接口闪退权限描述键名缺失或拼错检查 Info.plist 配置
相册保存成功但提示失败errMsg匹配过于严格用包含匹配而非精确相等

6.3 几个我踩过的坑与实操心得

第一个坑跟uni.downloadFile的临时路径有关。早期我直接把下载回来的tempFilePath存进 Vuex,想着后面还能复用,结果用户切到后台再回来,安卓上这个路径下的文件已经被系统清理掉了,保存相册时拿到的是一张 0 字节的空文件。后来的做法是临时路径用完即弃,需要持久保留就走uni.saveFile转存,或者干脆不保留,每次要保存都重新下载。

第二个心得是关于权限申请的时机。我一开始把权限申请放在页面onLoad里,用户还没点保存按钮就先弹窗,转化率极低——很多人一进页面看到要权限就退出去了。后来改成点击保存时再申请,用户有明确意图的时候同意率高很多。这个改动对整体成功率的影响比任何代码优化都大,值得专门拿出来说。

第三个坑是 iOS 上uni.saveImageToPhotosAlbum的filePath传相对路径时,部分机型上会失败但不报错,success回调正常触发,相册里却没有图。我排查了很久才定位到是路径格式问题,后来统一走convertLocalFileSystemURL转换就再没出过。这类"静默失败"最难查,因为代码逻辑看起来完全正确,只能靠真机对比才能发现。

还有一个经验是关于图片格式的。uni.saveImageToPhotosAlbum保存 webp 格式的图片时,在部分安卓机上会失败或者保存成无法打开的损坏文件,因为底层媒体扫描对 webp 的支持不完整。稳妥的做法是在业务层约束只保存 jpg 和 png,如果上游给的是 webp,先用 canvas 转一次再保存。这个转换在 H5 端顺手就能做,在 App 端要用uni.compressImage或者canvasToTempFilePath处理。

最后分享一个调试技巧。三端表现不一致的时候,我习惯先在电脑浏览器里验证 H5 逻辑,再用 Android 模拟器验证权限分支,最后上真机跑 iOS。模拟器上 iOS 的相册权限行为跟真机不完全一样,特别是"有限访问"这一档,模拟器上根本模拟不出来,必须真机测。项目里如果有多台不同系统版本的安卓测试机,尽量覆盖 Android 9、10、12 这三个大版本,基本能覆盖权限模型的所有分支。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进