首页 / 资讯中心 / 文章详情

uniapp三端保存图片实战:H5下载与App相册权限

uniapp三端保存图片实战:H5下载与App相册权限 ★ FEATURED ARTICLE
1. 先把保存图片这件事拆开看做 uniapp 项目做到保存图片到本地这一步基本都卡过。表面上看是一个动作用户点一下按钮图片进了相册或者下载目录。但真正写起来你会发现H5、Android、iOS 三端的保存根本不是一个东西甚至连本地这个词在三端指的位置都不一样。H5 端的本地是浏览器的下载目录或者文件系统App 端的本地是系统相册。你把这三端塞进同一个函数里最后一定是满屏的条件编译和一堆莫名其妙的失败回调。我这篇东西只覆盖三端H5、Android、iOS。小程序、快应用那些不在讨论范围里因为小程序的保存逻辑是另一套涉及域名白名单、授权弹窗、临时文件有效期混在一起讲反而把水搅浑。下面这套方案是我在几个实际项目里迭代出来的覆盖三种常见图片来源网络 URL、base64 字符串、已经落在本地的文件路径。读完之后你应该能做到一个统一的saveImage(source)入口三端各走各的路径返回值结构一致失败原因能区分开用户拒绝授权之后有引导。先说清楚一个最容易踩的认知坑H5 端没有保存到相册这个能力。浏览器不是原生应用它拿不到相册的写入权限。H5 能做的只有两件事——触发一次文件下载或者在新窗口里把图片展示出来让用户长按保存。iOS 自带浏览器下载完的图片会进文件App 的下载目录不会自动进相册用户还得自己点一次存储图像。这一点在需求评审阶段就要跟产品讲明白否则上线之后一定有人来问为什么苹果手机点了保存相册里没有。Android 上的浏览器相对好一些下载的图片通常能在相册里看到但也不能保证取决于具体浏览器和系统版本。App 端就简单直接得多图片落到临时目录再调系统接口写进相册。麻烦的地方在于权限、路径转换和文件格式。Android 从 6.0 开始动态权限从 10 开始分区存储从 13 开始媒体权限又换了一套iOS 的隐私描述漏配一个 key整个保存动作直接失败而且报错信息还很不友好。这些坑我一个一个踩过来下面按端拆开讲。2. 一套入口三端分发条件编译怎么用才不出岔子2.1 统一封装的目录结构和函数签名我习惯把这类能力单独放一个文件比如utils/save-image.js对外只暴露一个saveImage函数。函数签名设计成saveImage(source, options)返回Promiseresolve的时候给一个对象包含实际落地的路径或者方式reject的时候给一个带code的错误对象。这样做的好处是业务层完全不用关心平台差异try/catch一把梭。// utils/save-image.js export const SAVE_ERROR { UNSUPPORTED: unsupported, INVALID_SOURCE: invalid_source, DOWNLOAD_FAILED: download_failed, PERMISSION_DENIED: permission_denied, USER_CANCEL: user_cancel, WRITE_FAILED: write_failed } export function saveImage(source, options {}) { if (!source || typeof source ! string) { return Promise.reject(makeError(SAVE_ERROR.INVALID_SOURCE, 图片来源不合法)) } const opts { fileName: img_${Date.now()}, timeout: 30000, ...options } // #ifdef H5 return saveByH5(source, opts) // #endif // #ifdef APP-PLUS return saveByApp(source, opts) // #endif // #ifndef H5 || APP-PLUS return Promise.reject(makeError(SAVE_ERROR.UNSUPPORTED, 当前平台未实现)) // #endif } function makeError(code, message, extra) { const err new Error(message || code) err.code code Object.assign(err, extra || {}) return err }#ifndef H5 || APP-PLUS这个写法很多人不知道可以用它的意思是既不是 H5 也不是 App 平台用来兜底防止哪天代码跑到别的端上直接崩。注意条件编译在.js文件里用//在.vue的template里要用!-- --在style里用/* */写错一个符号整段代码就失效了而且不会报错非常隐蔽。2.2 条件编译的三个细节第一个细节条件编译是编译期裁剪不是运行期判断。也就是说// #ifdef H5里面的代码在打 App 包的时候压根不会进产物所以你可以在里面放心用document、window、fetch这些只在浏览器存在的对象不用加 typeof 判断。但反过来如果你在 H5 分支里调用了 uni 的 App 专属 API编译到 App 端时那段代码被裁掉了本地调试没问题一打正式包才发现逻辑没了。第二个细节#ifdef后面跟的是平台标识多个平台用||连接写#ifdef H5 || APP-PLUS。#ifndef是取反语义是这些平台都不包含。曾经有人写成#ifdef H5 APP-PLUS这个在 uniapp 里没有意义两个平台不可能同时成立代码永远不会被编译进去。第三个细节条件编译块内部不要跨块声明变量。比如你在#ifdef H5里const a 1在#ifdef APP-PLUS里又用a编译到 App 端会直接报a is not defined。我的习惯是每个平台分支都写完整、独立的实现函数公共逻辑参数校验、错误码定义放在外面平台分支里不共享任何中间变量。3. H5端download 属性的水比想象中深3.1 同源直链与 base64一行代码能搞定的情况先说最省事的两种情况。第一种是图片和你的页面同源那直接创建一个a标签设置href和download属性模拟点击就完事。第二种是图片本身就是 base64 的 data URLdata URL 天然同源同样可以直接下载。function triggerDownload(href, fileName) { const a document.createElement(a) a.href href a.download fileName a.rel noopener a.style.display none document.body.appendChild(a) a.click() document.body.removeChild(a) }看着很简单但这里有两个隐藏条件。第一个download属性只对同源 URL生效。图片如果挂在 CDN 上域名和你的页面不一致浏览器会直接忽略download属性a.click()的行为退化成在当前页跳转到图片地址。用户看到的结果是页面被一张图片顶掉了体验极差。第二个文件名里的扩展名要和生产出来的内容对得上你给一个.png的名字去下载一张 jpg部分系统相册和图片查看器会打不开。所以我的做法是先判断图片来源再决定走哪条路function saveByH5(source, opts) { return new Promise((resolve, reject) { const isBase64 /^data:image\//.test(source) if (isBase64) { const ext (source.match(/^data:image\/(\w)/) || [])[1] || png triggerDownload(source, ${opts.fileName}.${ext}) resolve({ way: download, path: browser }) return } downloadByBlob(source, opts) .then(resolve) .catch(() { openInNewTab(source) resolve({ way: new-window, path: browser, hint: 请长按图片保存 }) }) }) }这里的策略是base64 走直接下载网络图片先尝试 fetch 成 Blob 再下载fetch 失败就降级到新窗口打开让用户长按。三层兜底绝大多数场景都能覆盖。3.2 跨域图片fetch 转 Blob 才是正解跨域图片的download属性失效绕过的办法是把图片内容拿到本地来变成一个同源的blob:URL再触发下载。核心就是 fetch URL.createObjectURLfunction downloadByBlob(url, opts) { return new Promise((resolve, reject) { if (typeof fetch ! function) { reject(makeError(SAVE_ERROR.UNSUPPORTED, 浏览器不支持 fetch)) return } fetch(url, { mode: cors, credentials: omit }) .then(res { if (!res.ok) throw new Error(HTTP ${res.status}) return res.blob() }) .then(blob { if (!blob.size) throw new Error(空文件) const objectUrl URL.createObjectURL(blob) const ext pickExt(url, blob.type) triggerDownload(objectUrl, ${opts.fileName}.${ext}) setTimeout(() URL.revokeObjectURL(objectUrl), 2000) resolve({ way: blob, size: blob.size }) }) .catch(reject) }) } function pickExt(url, mime) { const mimeMap { image/jpeg: jpg, image/png: png, image/webp: webp, image/gif: gif, image/bmp: bmp } if (mime mimeMap[mime]) return mimeMap[mime] const m /\.(jpe?g|png|webp|gif|bmp)(\?|#|$)/i.exec(url) return m ? m[1].toLowerCase().replace(jpeg, jpg) : png }关键在于mode: cors。绝对不能写mode: no-cors虽然那样不会报错但拿到的是一个不透明响应opaque responseblob.size是 0blob.type是空字符串下载下来就是个 0 字节的坏文件还特别难排查——因为控制台里一切正常fetch也 resolve 了。我第一次遇到这个问题时盯着代码看了半小时最后是打开下载目录发现文件大小是 0 才反应过来。这套方案成立的前提是图片服务器返回了 CORS 响应头至少要有Access-Control-Allow-Origin。如果图片在你自己的服务器上加上这个头就行如果图片在第三方服务器上且没有 CORS那就没辙了只能走服务端代理——让后端提供一个带Content-Disposition: attachment的下载接口前端直接location.href跳过去。这是最稳妥的方案缺点是给后端增加了流量压力。还有一个细节URL.revokeObjectURL要在下载触发之后再调而且最好延迟一点。有些浏览器在a.click()之后是异步去读 Blob 的立刻 revoke 会导致下载中断。我用setTimeout延迟 2 秒释放实测下来稳定。3.3 用户手势丢失异步下载被浏览器拦下的真实原因这个问题非常隐蔽我踩过一次线上事故。现象是用户点击保存按钮电脑上一切正常手机上尤其是 iOS 自带浏览器偶发下载无反应重试几次有时候又能成功。根本原因是浏览器要求文件下载必须在用户手势的同步调用栈里触发。现代浏览器为了防止恶意页面偷偷下载一堆文件对a.click()这类行为有严格限制。我的代码里是先fetch再下载fetch是异步的等到.then回调执行的时候用户手势的有效期已经过了通常只有几百毫秒更多浏览器是按同步调用栈判断的于是click()被静默拦截不报错什么都不发生。解决办法有两个。第一个是预取在用户还没点击之前比如页面加载完、或者图片列表渲染完就把图片 fetch 成 Blob 缓存起来用户点击时直接同步触发下载。这个方案体验最好缺点是会提前消耗流量图片多的时候不合适。第二个是开窗占位在用户点击的同步栈里先window.open()一个空白窗口等 fetch 完成后再把窗口重定向到 blob URL。这样新窗口是用户手势触发的不会被拦截。function downloadWithPlaceholder(url, opts) { const win window.open(, _blank) return downloadByBlob(url, opts) .then(res { // 需要在 downloadByBlob 里把 objectUrl 一并返回 if (win) win.location.href res.objectUrl return res }) .catch(err { if (win) win.close() throw err }) }不过开窗方案在移动端体验一般用户会看到一闪而过的白页。我在实际项目里更多用的是预取 合理提示如果预取没完成直接降级到新窗口展示图片提示用户长按保存。虽然多一步操作但至少不会让用户觉得点了没反应。3.4 canvas 重绘方案与它的适用边界还有一个常被提到的方案把图片画到 canvas 上再toDataURL()拿到 base64 去下载。这个方案的适用场景其实是图片需要二次处理的时候比如加水印、裁剪尺寸、转换格式。如果只是为了跨域下载它并不比 fetch 方案更好反而多一层风险。function toDataUrlByCanvas(src) { 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) try { resolve(canvas.toDataURL(image/png)) } catch (e) { reject(makeError(SAVE_ERROR.DOWNLOAD_FAILED, 画布被污染)) } } img.onerror () reject(makeError(SAVE_ERROR.DOWNLOAD_FAILED, 图片加载失败)) img.src src }) }必须设置img.crossOrigin anonymous而且服务端同样要返回 CORS 头。如果没设置图片能正常显示因为img本身不受同源限制但 canvas 会被标记为污染tainted调用toDataURL()时直接抛SecurityError。这个错误的提示信息在不同浏览器里还不一样有的说 Tainted canvases may not be exported有的干脆只给个空错误。另外toDataURL()会把图片转成 PNG 编码的 base64一张 3MB 的 jpg 转出来可能变成 8MB 的数据移动端内存直接飙上去低端机有概率崩标签页。所以这个方案我只在明确需要二次处理、且图片尺寸可控的场景用。3.5 最后一道防线新窗口打开 长按保存所有方案都失败的时候跨域且无 CORS、fetch 被拦、浏览器内核老旧就老老实实新窗口打开图片让用户长按保存。function openInNewTab(url) { const win window.open(url, _blank) if (!win || win.closed || typeof win.closed undefined) { // 弹窗被拦截退化为当前页跳转 window.location.href url } }这条路径虽然土但兼容性最好任何浏览器都支持。代价是用户要多操作一步所以我会在页面上给一个明确的提示比如长按图片可保存到本地。提示文案要说人话别写请使用系统原生能力保存用户看不懂。需要再强调一遍iOS 上通过浏览器下载的图片不会进相册。它会进系统的文件应用用户在文件里找到图片后还要手动存储图像才会进相册。这条链路很长一定要在交互层面提前告知或者在保存成功后弹一个说明弹窗。我在一个项目里没写这个提示结果上线后客诉量直接翻倍全是保存失败的反馈其实图片已经下载好了只是用户不知道去哪找。4. App端下载、落盘、入库三步走4.1 网络图片走 uni.downloadFileApp 端的逻辑链是固定的先把图片变成本地临时文件再调uni.saveImageToPhotosAlbum写进相册。saveImageToPhotosAlbum的filePath参数只认本地路径你传一个https://开头的网络地址进去必然失败并且错误信息通常只有一句 fail 或者 file not found完全没有指向性。function saveByApp(source, opts) { return new Promise((resolve, reject) { if (/^data:image\//.test(source)) { base64ToLocalPath(source) .then(filePath saveToAlbum(filePath)) .then(resolve) .catch(reject) return } if (/^(_doc|_downloads|file):/.test(source) || source.startsWith(/)) { saveToAlbum(source).then(resolve).catch(reject) return } uni.downloadFile({ url: source, timeout: opts.timeout, success: res { if (res.statusCode ! 200) { reject(makeError(SAVE_ERROR.DOWNLOAD_FAILED, 下载失败 ${res.statusCode})) return } saveToAlbum(res.tempFilePath).then(resolve).catch(reject) }, fail: err reject(makeError(SAVE_ERROR.DOWNLOAD_FAILED, err.errMsg, { raw: err })) }) }) }uni.downloadFile下载下来的tempFilePath是有扩展名的形如_doc/uniapp_temp/download/xxx.jpg这一点很重要因为后续保存到相册时系统会依赖扩展名判断文件类型。如果服务端返回的 Content-Type 是image/jpeg但 URL 没有后缀临时文件可能就没有后缀保存到相册时 iOS 会失败。这种情况我的处理方式是把uni.downloadFile换成uni.getFileSystemManager().downloadFile新版基础库支持指定文件路径和文件名或者在下载完成后手动重命名补上扩展名。另外要说明一点App 端的uni.downloadFile没有域名白名单限制不像小程序需要在后台配置合法域名。但 App 端有它自己的问题——如果 URL 是 http 而不是 httpsAndroid 9 以上默认禁止明文传输会直接失败。这个错误在开发阶段不一定暴露因为开发基座可能配置了usesCleartextTraffic真机正式包才会翻车。4.2 base64 必须先落盘plus.io 写文件全过程base64 图片在 App 端调用saveImageToPhotosAlbum是无效的必须先写成真实文件。这里用plus.io是最稳的兼容性覆盖所有版本。function base64ToLocalPath(dataUrl) { return new Promise((resolve, reject) { const match /^data:(image\/\w);base64,([\s\S]*)$/.exec(dataUrl) if (!match) { reject(makeError(SAVE_ERROR.INVALID_SOURCE, base64 格式不正确)) return } const mime match[1] const ext mime.split(/)[1].replace(jpeg, jpg) let binary try { binary atob(match[2]) } catch (e) { reject(makeError(SAVE_ERROR.INVALID_SOURCE, base64 解码失败)) return } plus.io.requestFileSystem( plus.io.PRIVATE_DOC, fs { const fileName image_${Date.now()}.${ext} fs.root.getFile( fileName, { create: true }, entry { entry.createWriter( writer { writer.onwrite () { // 关键一步转成系统绝对路径 const absPath plus.io.convertLocalFileSystemURL(entry.fullPath) resolve(absPath) } writer.onerror err reject(makeError(SAVE_ERROR.WRITE_FAILED, 写入失败, { raw: err })) writer.writeAsBinary(binary) }, err reject(makeError(SAVE_ERROR.WRITE_FAILED, 创建写入器失败, { raw: err })) ) }, err reject(makeError(SAVE_ERROR.WRITE_FAILED, 创建文件失败, { raw: err })) ) }, err reject(makeError(SAVE_ERROR.WRITE_FAILED, 获取文件系统失败, { raw: err })) ) }) }这里有几个必须注意的点。atob解出来的是二进制字符串不是 Uint8ArraywriteAsBinary正好接收二进制字符串所以两边能对上。如果你用new Uint8Array(...)去传会写出一堆乱码。plus.io.PRIVATE_DOC是应用的私有文档目录卸载应用才会清空比PUBLIC_DOCUMENTS更适合做临时中转因为它不需要额外的存储权限。新版 HBuilderX 还提供了uni.getFileSystemManager()在 App 端也能用接口风格和小程序一致写法上是const fs uni.getFileSystemManager() fs.writeFile({ filePath: ${plus.io.convertLocalFileSystemURL(_doc)}/image_${Date.now()}.png, data: base64Str, encoding: base64, success: res resolve(res), fail: reject })它支持encoding: base64直接写不用手动atob代码更短。但这个 API 在低版本基座上不存在如果你的项目要兼容老用户还是得保留plus.io的写法。我的做法是运行时判断typeof uni.getFileSystemManager function有就走新接口没有就降级到plus.io。4.3 路径转换最容易翻车的一步上面代码里plus.io.convertLocalFileSystemURL(entry.fullPath)这一行是整段代码的灵魂也是最多人栽跟头的地方。entry.fullPath拿到的是_doc/image_1690000000000.png这样的相对路径。这个路径plus.io自己认识uni.saveImageToPhotosAlbum不认识。Android 的相册接口需要/storage/emulated/0/Android/data/.../doc/image_xxx.png这种绝对路径iOS 需要/var/mobile/Containers/Data/Application/.../Documents/image_xxx.png这样的路径。你不做转换直接传进去Android 上大概率报文件不存在iOS 上直接静默失败。convertLocalFileSystemURL就是干这个转换的把_doc/xxx这类虚拟路径映射成当前平台的真实绝对路径。我在一个项目里漏了这一行测试同学在 Android 上报的 bug 描述是保存相册无反应但控制台没报错查了大半天才定位到。另外uni.downloadFile返回的tempFilePath本身就是可以直接用的完整路径不需要再转换这一点别搞混了。4.4 saveImageToPhotosAlbum 的调用与结果判定保存动作本身的代码不长但失败原因必须分类否则用户拒绝授权之后你的错误提示是保存失败用户完全不知道该去设置里开权限。function saveToAlbum(filePath) { return new Promise((resolve, reject) { if (!filePath) { reject(makeError(SAVE_ERROR.INVALID_SOURCE, 文件路径为空)) return } uni.saveImageToPhotosAlbum({ filePath, success: () resolve({ way: album, path: filePath }), fail: err { const msg (err.errMsg || ).toLowerCase() if (msg.includes(auth) || msg.includes(deny) || msg.includes(permission)) { reject(makeError(SAVE_ERROR.PERMISSION_DENIED, 未获得相册权限, { raw: err })) } else if (msg.includes(cancel)) { reject(makeError(SAVE_ERROR.USER_CANCEL, 用户取消)) } else if (msg.includes(not found) || msg.includes(no such file)) { reject(makeError(SAVE_ERROR.WRITE_FAILED, 文件不存在请检查路径, { raw: err })) } else { reject(makeError(SAVE_ERROR.WRITE_FAILED, err.errMsg || 保存失败, { raw: err })) } } }) }) }判定的依据是errMsg里的关键词。不同平台、不同基座版本errMsg的措辞会变所以用includes做模糊匹配别用精确比较。我见过的措辞包括saveImageToPhotosAlbum:fail auth deny、fail authorize no response、fail:用户未授权、fail permission denied等等。匹配的时候统一toLowerCase()处理一下更稳。还有个细节值得说iOS 上第一次调用saveImageToPhotosAlbum会弹系统授权框如果用户点不允许这次调用会走fail。但有些机型在弹框期间调用会被挂起用户点完才回调如果中间用户切走了应用回调可能丢失。所以按钮要做防抖和 loading 状态防止用户连点导致多次弹框堆叠。5. 权限配置与拒绝后的引导5.1 Android 权限声明与系统版本差异Android 的存储权限这几年变化很大得按版本分开说。6.0 以下现在基本见不到安装时就授予。6.0 到 9.0 需要运行时申请WRITE_EXTERNAL_STORAGE不申请就写不了相册。10 开始引入分区存储写相册走的是 MediaStore理论上不需要WRITE_EXTERNAL_STORAGE了。13 开始引入READ_MEDIA_IMAGES但这个权限主要管读取写入相册仍然不需要。所以manifest.json里我的配置是这样的{ app-plus: { distribute: { android: { permissions: [ uses-permission android:name\android.permission.READ_EXTERNAL_STORAGE\/, uses-permission android:name\android.permission.WRITE_EXTERNAL_STORAGE\/, uses-permission android:name\android.permission.READ_MEDIA_IMAGES\/ ], minSdkVersion: 21 } } } }这是兜底配置高版本系统上这些权限要么被忽略要么自动降级不会造成副作用。但如果你的应用 targetSdkVersion 打到了 33 以上又没声明READ_MEDIA_IMAGES在某些定制系统上读取相册缩略图会失败。声明全一点成本几乎为零。需要说明的是uni.saveImageToPhotosAlbum在 Android 上不会主动触发权限弹窗部分版本的基座除外如果你的应用还没有存储权限它会直接 fail。所以更稳的做法是在调用前先检查并申请一次权限// #ifdef APP-PLUS if (plus.os.name Android) { const result await new Promise(resolve { plus.android.requestPermissions( [android.permission.WRITE_EXTERNAL_STORAGE], res resolve(res), err resolve(err) ) }) // granted0 表示已授予其他值走引导逻辑 } // #endif这段代码在新系统上可能会返回权限不存在之类的错误因为权限本身被移除了这种情况直接放行即可不要卡在权限检查上。5.2 iOS 隐私描述缺失会被直接拒绝iOS 这块是最容易一刀切死人的地方。应用第一次调用相册相关接口时系统会查找Info.plist里的隐私描述文案没配就直接崩溃或者静默拒绝而且不会弹框。开发者很容易误以为是代码写错了。uniapp 里不需要直接改Info.plist在manifest.json里配就行云打包会自动注入{ app-plus: { distribute: { ios: { privacyDescription: { NSPhotoLibraryAddUsageDescription: 用于将图片保存到您的相册, NSPhotoLibraryUsageDescription: 用于读取相册中的图片, NSCameraUsageDescription: 用于拍摄照片 } } } } }NSPhotoLibraryAddUsageDescription是添加到相册的权限NSPhotoLibraryUsageDescription是读取相册的权限两个是分开的。只保存不读取的话理论上只需要第一个。但实际上saveImageToPhotosAlbum的底层实现有时会同时触发读取权限检查所以两个都配上最保险。文案内容也有讲究。只说需要访问相册是不够的审核阶段容易被要求补充说明而且用户看到这种模糊文案拒绝率会明显升高。写清楚用于把您生成的图片保存到相册用户的接受度高很多。5.3 用户点了不允许怎么办用户拒绝授权之后再次调用不会再弹框直接 fail。这时候唯一的出路是引导用户去系统设置里手动打开。function openAppSetting() { // #ifdef APP-PLUS if (plus.os.name Android) { const main plus.android.runtimeMainActivity() const Intent plus.android.importClass(android.content.Intent) const Settings plus.android.importClass(android.provider.Settings) const Uri plus.android.importClass(android.net.Uri) const intent new Intent() intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) intent.setData(Uri.fromParts(package, main.getPackageName(), null)) main.startActivity(intent) } else { const UIApplication plus.ios.importClass(UIApplication) const app UIApplication.sharedApplication() const url plus.ios.invoke(NSURL, URLWithString:, app-settings:) app.openURL(url) } // #endif }Android 跳的是应用详情页用户在里面能找到权限入口。iOS 跳的是应用设置页里面直接就有照片权限开关。这两个跳转在真机上要单独测模拟器上行为不完全一致。跳之前最好弹一个自定义弹窗说清楚保存图片需要相册权限是否前往设置用户点确认再跳别直接跳走那样很突兀。这里有个体验上的建议不要在用户第一次点保存失败后就立刻引导去设置。更合理的流程是——首次调用让系统自然弹框用户拒绝后本次流程提示保存失败请允许相册权限后重试用户再点一次保存此时才弹引导跳转设置的对话框。这样用户有个心理准备不会觉得被强制跳转。6. 常见问题排查表6.1 按现象反查的速查表下面这张表是我这几年攒下来的基本覆盖了能遇到的绝大多数问题。排查的时候按现象找比按原因找快得多。现象高概率原因排查动作处理办法H5 点击后页面被图片顶掉跨域图片download属性被忽略看图片域名和页面域名是否一致改用 fetch 转 Blob 下载H5 下载得到 0 字节文件误用了mode: no-cors打印blob.size和blob.type改成mode: cors服务端配合加头H5 移动端点保存无任何反应异步下载丢失用户手势看 console 有无拦截警告改为预取 Blob 或同步开窗占位H5 canvas 导出报 SecurityError图片未设 crossOrigin 或服务端无 CORS检查img.crossOrigin和响应头补 CORS或换 fetch 方案App 保存相册报 file not found传了_doc/xxx相对路径打印传给saveImageToPhotosAlbum的路径用convertLocalFileSystemURL转换App 保存相册无反应且无报错iOS 隐私描述缺失检查manifest.json的 privacyDescription补齐两个相册相关 key 重新打包App 首次能存第二次开始失败用户拒绝授权且不再弹框检查 errMsg 是否含 auth/deny引导跳系统设置手动开启Android 9 图片下载必失败图片是 http 明文地址看 URL 协议换 https或配置明文传输白名单base64 存进去是乱码写入时传了 Uint8Array检查writeAsBinary的参数类型传atob解出的二进制字符串部分机型相册里图片不显示图片是 webp 或文件名无扩展名看图片实际格式和临时文件名服务端统一转 jpg/png表格里的每一条我几乎都亲自踩过。挑两条展开说。base64 存进去是乱码这个本质上是编码层面的混淆atob返回的字符串每个字符代表一个字节0-255writeAsBinary就是按这个约定写入的。如果你先把字符串转成 Uint8Array 再传等于把每个字节又拆了一遍写出来的文件自然是乱的。判断方法很简单——写完之后用图片查看器打开看看如果是纯色块或者打不开基本就是这个原因。部分机型相册里图片不显示这条也很典型。webp 格式在 Android 高版本上原生支持但在相册应用里可能显示不出来因为相册的缩略图生成器不一定认识 webp。iOS 对 webp 的支持更晚。所以如果图片来源不可控比如用户上传的服务端保存的时候统一转成 jpg 是最省心的宁可损失一点画质也别在格式上折腾。6.2 几个不那么常见但很致命的坑第一个文件名里的特殊字符。H5 端用a.download指定文件名时如果名字里带/、\、:、*、?、、、、|这些字符部分浏览器会直接丢弃整个文件名用 URL 的最后一段代替甚至有的直接不下载。如果文件名来自用户输入比如按商品名命名一定要做一次清洗把这些字符替换掉。第二个图片尺寸过大导致内存溢出。App 端uni.downloadFile下载一张 20MB 的原图解压到内存里可能占用上百 MB低端 Android 机有概率直接闪退。我在一个电商项目里就遇到过用户点保存商品详情图图片是 4000×6000 的原图连续点几次应用就没了。后来改成服务端提供一份压缩过的缩略图用于保存问题消失。如果你的业务允许建议保存的时候走 CDN 的图片处理参数拿一张宽度 1080 的版本够用且稳。第三个并发保存。用户手快连点五次会触发五次下载五次写相册相册里出现五张一模一样的图而且中间可能有一次失败。加一个简单的锁就行let saving false export function saveImageWithLock(source, options) { if (saving) { return Promise.reject(makeError(busy, 正在保存中请稍候)) } saving true return saveImage(source, options).finally(() { saving false }) }finally在部分老版本环境的 polyfill 里可能不存在保险起见用then加catch各写一遍重置逻辑。第四个H5 端的 blob URL 泄漏。每次下载都createObjectURL一次如果忘记revokeObjectURL这些 Blob 会一直占着内存不释放用户在一个列表页反复保存几十张图之后标签页内存能涨到几百 MB。我的习惯是在triggerDownload之后setTimeout释放延迟时间取 2000ms兼顾了下载的异步性和内存回收。7. 几个实测细节供你抄作业我在多个项目里把这套逻辑跑下来有几个细节是文档里不会写的但直接影响成败。H5 端的降级提示要写死在一个统一的 toast 里。不要在四个降级分支里各写一套文案用户看到的信息会不一致排查问题的时候也分不清是哪一层触发的。我现在的做法是所有 H5 分支失败都归到同一个SAVE_ERROR.DOWNLOAD_FAILED业务层统一提示保存失败请长按图片手动保存。简单、明确、不误导。App 端的临时文件可以主动清理。_doc目录下的中转文件会越积越多虽然有上限但长期使用会占用不少空间。我在保存成功之后会调用plus.io.resolveLocalFileSystemURL拿到 entry然后entry.remove()删掉。注意只删自己创建的那些image_xxx文件别误删了别的业务文件命名前缀要足够独特。iOS 保存成功后的提示要注意措辞。iOS 上保存到相册之后图片进的是最近项目而不是某个自定义相簿用户如果在相簿标签页里找是找不到的。提示语写已保存到相册最近项目中比保存成功更有用能省掉一批客诉。Android 13 以上的权限检查要能容错。plus.android.requestPermissions传READ_MEDIA_IMAGES时如果系统版本低于 13这个权限常量是不存在的调用会抛异常。所以要在调用前判断plus.os.version或者用 try/catch 包住异常了就走无权限路径直接保存。我倾向于后一种简单粗暴且不会因为版本判断写错而卡死。测试一定要用真机而且要用相册里有大量照片的真机。模拟器的相册是空的保存行为正常真机上相册有几千张照片的时候写入耗时明显变长如果此时用户切走应用回调可能不触发。所以保存按钮的 loading 状态至少要给到 5 秒的容忍度别设个 1 秒就超时提示失败。最后分享一个排查思路三端的问题定位方式完全不同。H5 端看 Network 面板和 Console重点看请求的响应头和 blob 的大小App 端看plus.io的路径和errMsg原文重点确认路径是不是绝对路径、格式对不对iOS 端还要额外确认打包后的Info.plist里那几个NSPhotoLibrary*的 key 到底有没有进去有时候manifest.json写对了但打包配置被覆盖产物里就是没有。这个我遇到过一次配置写在app-plus.distribute.ios下面但项目里另一个配置文件把它覆盖了查了一整天才定位到。所以每次调整权限配置之后我都会用工具解包看一下最终的Info.plist确认无误再提测。
阅读完成 · 觉得有帮助?
咨询建站