ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

微信小程序拍照与保存到相册:授权、filePath与失败码排查

微信小程序拍照与保存到相册:授权、filePath与失败码排查 一个人站在仓库门口手里拿着刚拆箱的货要把外包装拍一张照片回传后台系统或者做的是校园跑腿类的小程序骑手到店取件时需要拍一张已取货的凭证。这类需求最后都会落到同一个技术命题上微信小程序怎么调起摄像头拍照怎么处理相机和相册的授权拍完之后怎么把照片存进用户手机本地相册。看起来是三个孤立的小问题实际上它们串在一条链路上任何一环没处理好用户看到的就是点了没反应拍完没保存又弹权限框了这种体验。这篇内容面向的是已经能写基础小程序页面、但在原生能力调用上还不太有把握的开发者同时也兼顾刚接手类似需求、想直接抄一份可用代码的人。我会把两条拍照路线讲清楚把授权被拒绝之后该怎么引导讲透把保存到相册时 filePath 的规则和常见失败码一条条列出来最后给一份可以直接复制进项目的封装代码。里面不少细节是我在实际项目里被真机打脸之后才记住的文档上不会写得那么细。1. 拍照功能的两条技术路线选错了后面全是麻烦小程序的拍照能力本质上只有两条路一条是调用微信封装的媒体选择接口借系统相机另一条是自己在页面里挂一个camera组件搭取景框。这两条路在授权、UI 呈现、可控制程度上完全不同很多开发者上来就直接写camera组件结果发现层级、机型适配一堆问题其实他这个需求用前者十分钟就能搞定。1.1 wx.chooseMedia借系统相机的手省事但边界清晰wx.chooseMedia是现在官方推荐的媒体选择接口它替换了老的wx.chooseImage。调用之后小程序会把控制权交给系统弹出系统的相机界面或者相册选择界面用户拍完或选完小程序拿到的是一组临时文件信息。它最大的优点是不需要申请scope.camera权限。因为拍照这个动作是系统相机完成的小程序只是接收结果微信层面不认为你在调用摄像头。这一点特别容易被误解——我见过不少人在chooseMedia之前先去wx.authorize({ scope: scope.camera })纯属多余还会平白无故给用户弹一个莫名其妙的权限框掉授权率。但它的边界也很明显你控制不了取景界面的样子。没有自定义的取景框、没有实时预览上的水印蒙层、没有自定义的快门按钮也不能在拍照过程中做实时的人脸框或扫码识别。如果你的产品经理说我们要在取景框里画一个身份证轮廓引导用户对齐那这条路走不通只能换camera组件。1.2 camera 组件自己搭取景框控制力强但要还债camera组件是一个原生组件你把它写进 wxml页面里就会出现一块实时摄像头预览区域。你在它上面可以叠自己的 UI可以自定义快门按钮可以在预览期间持续拿到画面做处理。代价是三个第一它需要scope.camera授权多一个权限环节第二它是原生组件层级最高普通view盖不住它得用cover-view第三它在开发者工具里的表现一直不太可靠很多版本里是黑屏或者直接不渲染你只能靠真机预览来验证。1.3 到底怎么选一张对照表说清楚我一般用下面这张表来快速决策基本上三十秒就能定方向。对比维度wx.chooseMediacamera 组件是否需要 scope.camera不需要需要取景界面能否自定义不能能可叠加 cover-view开发者工具支持支持良好支持较差建议真机能否实时拿到画面帧不能可以配合监听能力是否受原生组件层级影响不受受需用 cover-view典型场景上传凭证、头像、聊天图片身份证对齐、扫码、带水印取证一句话总结业务只要一张照片用chooseMedia业务要一个特定样子的拍照过程才上camera组件。市面上八成的需求都属于前者。2. 用 wx.chooseMedia 拿到照片参数、临时文件与它的寿命确定了路线之后第一件事是把照片拿到手。这一步代码不多但参数含义和拿到的对象结构必须搞清楚不然下一步保存到相册就要出问题。2.1 最小可用代码与每个参数的实际含义先看一段能直接跑的代码wx.chooseMedia({ count: 1, mediaType: [image], sourceType: [camera], sizeType: [compressed], camera: back, success(res) { const file res.tempFiles[0] console.log(临时路径, file.tempFilePath) console.log(文件大小, file.size) console.log(类型, file.fileType) }, fail(err) { console.log(取消或失败, err) } })逐个说count: 1是允许选择的文件数量上限。拍照场景一般就是 1但如果你的需求是连续拍三张不同角度的货损照片可以设成 3系统相机会支持连拍模式。mediaType: [image]限定只处理图片。写成[image, video]的话用户在系统界面里可以切到录像拿到的东西里会多出duration字段。sourceType: [camera]是关键。只写camera就是直接拉系统相机写成[album, camera]会先弹一个拍照 / 从相册选择的半屏菜单。如果你的业务只需要拍照一定要只写 camera少给用户一个犹豫的机会。sizeType: [compressed]我强烈建议保留压缩。用户现在的手机随手一张就是 4000x3000、体积 5MB 起步压缩之后通常能降到几百 KB上传速度和流量都友好很多。只有做票据 OCR 识别、需要保留细节的场景才考虑original。camera: back指定默认用后置摄像头。做自拍类功能才改成front。2.2 tempFilePath 到底存在哪什么时候会消失这是最容易被忽略的一点chooseMedia返回的tempFilePath是一个临时文件路径指向的是小程序运行期间的一个临时目录不是用户手机相册里的真实文件。临时文件的生命周期由微信托管官方说法是小程序本次启动期间有效但实际表现是小程序被彻底销毁比如用户杀掉进程、或者后台驻留超过一定时间被回收之后这些临时文件可能会被清理。所以如果你的业务是拍完之后先存本地明天再统一上传只靠tempFilePath是靠不住的得用wx.getFileSystemManager().saveFile()把它转存到小程序的永久文件目录const fs wx.getFileSystemManager() fs.saveFile({ tempFilePath: file.tempFilePath, success(res) { console.log(永久路径, res.savedFilePath) } })这里有个容量问题要提前规划小程序的本地文件目录有大小上限官方给的是 10MB不同基础库可能略有差异存几张原图就满了。所以转存之前一定要压缩或者存完之后及时用removeSavedFile清理。2.3 压缩与 sizeType别让用户用流量给你传原图有人会问既然要压缩为什么不在拿到原图之后自己用 canvas 压一遍因为sizeType: [compressed]的压缩是微信在系统层面做的速度比在小程序里跑 canvas 快得多也不会出现低端机上 canvas 卡死的情况。我做过一个对比测试一张 4.2MB 的后置原图走compressed之后是 320KB 左右肉眼在手机屏幕上看几乎没有差别但如果这张图是要做文字识别比如拍身份证、拍发票压缩之后边缘会糊识别率会下降。这种场景我一般设sizeType: [original]然后在上传前用 canvas 做一次可控的等比缩放把长边限制在 1600px 左右既能保住可读性又能压住体积。注意sizeType不是所有机型都严格生效个别安卓机型在compressed下返回的依然是原图。所以上传前最好再判断一次file.size超过阈值就自己再压一遍不要把宝全押在这个参数上。3. 自己搭取景框camera 组件的属性、层级坑与真机差异如果需求确实要求自定义取景界面那就得走camera组件这条路。这一块坑比较集中我按实际接入的顺序讲。3.1 组件挂载与相机上下文的获取最基础的写法是这样camera device-positionback flashoff resolutionhigh binderroronCameraError bindinitdoneonCameraReady stylewidth: 100%; height: 100%; cover-view classshutter bindtaptakePhoto拍摄/cover-view /camera几个属性值得说明device-position控制前置还是后置做扫码类功能必须用back否则用户会对着一堆乱码发愁。resolution取low/medium/high。别默认就上high低端安卓机上高分辨率预览会明显发热掉帧我之前做过一个连续取景的页面high模式下老机型跑五分钟就烫手后来改成medium肉眼观感几乎没差。flash除了on/off/auto之外还有一个torch值表示常亮补光。仓库、地库这种弱光环境下拍货损照片torch比闪光灯实用得多用户能实时看到补光效果。这个值很多人不知道。bindinitdone是相机初始化完成的事件用它来隐藏相机加载中的占位图比用setTimeout猜时间靠谱得多。拍照要用相机上下文const ctx wx.createCameraContext() takePhoto() { ctx.takePhoto({ quality: high, success: (res) { console.log(照片路径, res.tempImagePath) }, fail: (err) { console.log(拍照失败, err) } }) }quality有high/normal/low三档默认normal。这里要跟前面的sizeType区分开quality影响的是takePhoto输出的照片分辨率档位不是压缩级别。3.2 cover-view 层级问题与同层渲染的现状camera是原生组件早期它的层级永远在最上面你用普通view写一个快门外框它会被相机画面盖住点都点不到。所以覆盖在上面的 UI 必须用cover-view和cover-image。cover-view的限制挺多支持的 CSS 属性有限不能随便用flex的一些花哨写法圆角、阴影的表现也不如普通 view。所以常见做法是——能用普通 view 画的静态元素就别放在相机上面只在必须叠加的位置用cover-view比如快门按钮、提示文案、四个角的取景框。近几年微信在推进原生组件同层渲染camera在多数新机型加新基础库上已经可以正常被普通view覆盖了。但我个人的建议还是别偷懒除非你明确只支持某个基础库版本以上否则统一用cover-view是最稳的兼容老设备和老版本基础库带来的收益远大于写起来的那点麻烦。3.3 takePhoto 的质量参数与错误回调takePhoto的fail回调里返回的错误信息我在真机上遇到过几类errMsg里包含auth deny用户拒绝了scope.camera这时不能直接重试要走引导流程下一章细讲。包含camera not ready组件还没初始化完就点了快门。解决办法是配合bindinitdone控制快门的可点击状态初始化完成前给按钮加禁用样式。包含system permission denied这是微信 App 本身没有拿到手机系统的相机权限跟小程序的 scope 是两码事。这种情况小程序里头没法解决只能引导用户去手机系统的应用管理里给微信开权限。最后这类错误特别容易被误判成小程序的授权没做然后你去疯狂调openSetting用户点进去发现小程序权限都是开的一头雾水。所以拿到错误之后先看errMsg里有没有system相关的关键词再决定引导用户去哪。4. 授权链路才是翻车重灾区scope、拒绝链路与 openSetting拍照和保存这条链路上代码写错的人不多授权流程做错的人一大把。我见过最典型的失败案例是用户第一次点保存到相册小程序直接弹系统授权框用户顺手点了拒绝之后再点保存什么都没发生连错误提示都没有。这就是没做拒绝后的兜底引导。4.1 scope.camera 与 scope.writePhotosAlbum 是两个独立开关先厘清概念小程序里跟这条链路相关的权限有这几个权限标识什么时候需要覆盖范围scope.camera使用 camera 组件取景时仅当前小程序scope.writePhotosAlbum调用保存图片/视频到相册时仅当前小程序scope.album读取部分场景下选择相册图片仅当前小程序系统相机权限微信 App 访问硬件相机微信这个 App 整体wx.chooseMedia走系统相机时小程序层面不消耗scope.camera但保存那一步一定会碰scope.writePhotosAlbum。这两个是独立的授权项用户可能给了相机权限但没给相册权限反之亦然。判断的时候一定要分开判断别用一个变量代表全部。4.2 一套标准的授权检查顺序我封装的逻辑基本都是这个顺序先在非侵入的前提下探明状态再决定动作function checkAuth(scope) { return new Promise((resolve) { wx.getSetting({ success(res) { const setting res.authSetting if (setting[scope] true) { resolve(granted) } else if (setting[scope] false) { resolve(rejected) // 曾经拒绝过只能走 openSetting } else { resolve(unset) // 从未询问过 } }, fail() { resolve(unset) } }) }) }三种状态对应三种完全不同的处理granted直接干活不要再调wx.authorize那不会弹框纯浪费一次异步。unset可以调wx.authorize({ scope })主动弹框但我建议不要直接弹先弹一个自己的说明弹窗告诉用户接下来要保存照片到你的相册需要相册权限用户点好的之后再调authorize。这一步的转化率差别很大直接弹系统框的拒绝率明显更高。rejectedwx.authorize不会再弹框了调用也只会直接走 fail。唯一的路是wx.openSetting。4.3 openSetting 的调用限制与引导话术设计wx.openSetting有个硬性约束必须由用户主动点击行为触发。你在页面onLoad里调它或者在一个定时器里调它都会直接失败。所以正确的姿势是引导用户点一个按钮。我的标准写法是两层引导async function ensureAlbumAuth() { const state await checkAuth(scope.writePhotosAlbum) if (state granted) return true if (state unset) { return new Promise((resolve) { wx.showModal({ title: 需要相册权限, content: 保存照片需要你授权访问相册我们只写入不读取, confirmText: 去授权, success: (r) { if (!r.confirm) return resolve(false) wx.authorize({ scope: scope.writePhotosAlbum, success: () resolve(true), fail: () resolve(false) }) } }) }) } // rejected 状态 return new Promise((resolve) { wx.showModal({ title: 相册权限已被关闭, content: 请在设置里打开保存到相册否则照片无法保存, confirmText: 去设置, success: (r) { if (!r.confirm) return resolve(false) wx.openSetting({ success: (s) { resolve(!!s.authSetting[scope.writePhotosAlbum]) }, fail: () resolve(false) }) } }) }) }关于在showModal的success回调里调openSetting是否算用户点击行为实践中是可以正常调起的因为确认按钮本身就是一次用户操作。但如果你遇到了调起失败的情况退路是把引导做成页面上的一个可见按钮让用户直接在页面上点这样百分之百安全。提示content里那句只写入不读取很有用。用户拒绝相册权限多数是担心隐私明确告诉他小程序只能写入不能读取能显著降低拒绝率。这不是话术技巧是事实——scope.writePhotosAlbum本身就只给写入能力。5. 把照片落进系统相册filePath 的规则与失败码排查照片拿到了授权也过了最后一步是保存。这一步接口简单但失败原因五花八门。5.1 saveImageToPhotosAlbum 只认本地文件路径wx.saveImageToPhotosAlbum({ filePath: tempFilePath, success() { wx.showToast({ title: 已保存到相册, icon: success }) }, fail(err) { console.log(err.errMsg) } })规则要记牢filePath必须是本地文件路径也就是tempFilePath、savedFilePath、wx.env.USER_DATA_PATH下的文件路径这类。不能是网络地址。你从后端拿到的https://...jpg直接塞进去一定失败得先wx.downloadFile下载成本地临时文件。不能是 base64 字符串。这块下面单独说。路径最好带正确的后缀名。部分安卓机型会按后缀判断文件类型tempFilePath一般自带.jpg或.png但如果你自己往USER_DATA_PATH写文件时把后缀写错了保存就会失败。我踩过一次写成了xxx.tmpiOS 上能存安卓上直接报文件类型不支持。5.2 网络图、base64、canvas 图的三种转换姿势网络图wx.downloadFile({ url: https://example.com/a.jpg, success(res) { if (res.statusCode 200) { wx.saveImageToPhotosAlbum({ filePath: res.tempFilePath }) } } })别忘了在后台配置downloadFile合法域名这个漏配会让下载直接失败而且错误提示不太直观。base64 图不能直接保存必须先用文件系统管理器写成本地文件。这里要特别注意——写入时一定要把 base64 里的data:image/png;base64,前缀去掉只留纯数据部分否则写出来的文件是坏的能写成功但保存到相册会失败。const fs wx.getFileSystemManager() const base64Data rawBase64.replace(/^data:image\/\w;base64,/, ) const filePath ${wx.env.USER_DATA_PATH}/temp_${Date.now()}.png fs.writeFile({ filePath, data: base64Data, encoding: base64, success() { wx.saveImageToPhotosAlbum({ filePath }) } })canvas 生成的图比如给照片加个时间水印再保存先用wx.canvasToTempFilePath导出临时文件再拿去保存。注意如果用的是 Canvas 2D导出时要传canvas实例而不是老的canvasId混用会报找不到画布。5.3 失败码对照表与逐条排查思路errMsg里能拿到的信息就那么多我整理了一份实际遇到过的对照表errMsg 关键词真实原因处理方式auth deny / auth denied用户拒绝了相册权限走 openSetting 引导cancel用户在系统弹窗里点了取消不做提示静默返回即可file not found临时文件已被清理或路径写错重新拍照检查路径有效性invalid file type后缀名不对或文件内容损坏检查后缀base64 前缀是否剥离privacy permission is not authorized隐私协议未配置去后台配置用户隐私保护指引system permission denied微信 App 没有系统相册权限引导用户去手机系统设置排查的时候有个小技巧先把errMsg完整打印出来再说不要只截取一部分去搜。微信的错误信息里经常把saveImageToPhotosAlbum:fail这样的前缀和具体原因拼在一起看到后半段才能定位。6. 真机实测踩坑清单以及隐私协议这条隐形红线这部分是我在项目里真正被折腾过的几个点文档里一般不会写这么细。6.1 开发者工具与真机的行为差异第一个差异是saveImageToPhotosAlbum。在开发者工具里这个接口的行为跟真机不一样有的版本会弹出一个选择本地保存目录的对话框有的版本直接返回成功但什么都没发生。所以保存功能一定要在真机上验证工具里跑通了不代表上线没问题。第二个差异是camera组件。前面提过工具里对它的支持一直不稳黑屏是家常便饭。我现在的习惯是只要涉及camera直接连真机调试工具里只用来写结构。第三个差异是授权弹窗。开发者工具里权限管理可以在清缓存 → 授权里手动重置真机上用户一旦拒绝就只能走openSetting。所以测试拒绝链路的时候一定要在真机上手动点一次拒绝看看引导流程是不是真的能走通。6.2 隐私保护指引没配置接口会直接死给你看这是近几年新增的一道坎也是我见过最多人卡住的地方。微信要求小程序在管理后台配置《用户隐私保护指引》声明你用到了哪些涉及用户隐私的能力。拍照相关的摄像头和保存相关的相册仅写入都在声明范围内。如果没配置真机上调用相关接口会直接失败errMsg里带privacy字样。更麻烦的是开发者工具里可能表现正常一到真机就挂排查起来很费时间。配置路径是小程序管理后台 → 设置 → 服务内容声明 → 用户隐私保护指引在里面填写你收集的信息类型和用途。填写时用途要写得具体比如用于拍摄商品照片并保存至用户相册比笼统写用于功能实现更容易通过审核。另外基础库版本也要注意隐私相关弹窗机制在不同基础库版本上的表现不一样新版本基础库是默认开启校验的。如果你的项目锁定了比较老的基础库版本建议在app.json里显式开启隐私校验开关在开发者工具里提前把隐私弹窗的交互走一遍。这里有个容易搞混的点地理位置、蓝牙这类接口需要在app.json的requiredPrivateInfos字段里额外声明但摄像头和相册不在这个列表里它们走的是后台的隐私指引配置两条路容易记反。6.3 几个机型上的怪异表现说几个具体的部分安卓机型保存成功后系统的相册 App 里不会立刻刷新用户切到相册看是空的以为没保存成功回头又来点一次。规避办法是保存成功后不要只弹 toast可以顺便把图片路径转成一个页面内的预览图让用户当场看到这张图已经存了。连续快速点击保存按钮在低端机上可能触发两次调用第二次因为文件已被处理而失败弹出一个莫名其妙的错误。所以保存按钮一定要加节流或者禁用状态。拍照时如果 App 被切到后台再切回来camera组件在部分机型上会停止预览画面卡住。这时候要监听相机的停止事件重新初始化组件或者直接给用户一个重新拍摄的按钮别让他在一个卡死的画面上反复点快门。7. 把上面这些揉成一个可复用的拍照保存工具讲了这么多最后给一份可以直接放进项目的封装。我的思路是把整条链路拆成三个原子函数授权检查、拍照、保存每个函数只管一件事组合起来用。7.1 封装思路拆开是为了复用拆成三个的好处是不同业务可以自由组合。比如你的需求只是从相册选图然后保存到另一个位置那就跳过拍照那一步如果需求是拍完直接上传不存相册那就只用前两步跳过保存。把三者耦合在一起写成一个大函数后面改需求就要推倒重来。三个函数都返回 Promise调用方用async/await串起来代码读起来跟自然语言差不多。7.2 完整代码// utils/photo.js const SCOPE_ALBUM scope.writePhotosAlbum const SCOPE_CAMERA scope.camera function getAuthState(scope) { return new Promise((resolve) { wx.getSetting({ success: (res) { const v res.authSetting[scope] resolve(v true ? granted : v false ? rejected : unset) }, fail: () resolve(unset) }) }) } function guideToSetting(scope, tip) { return new Promise((resolve) { wx.showModal({ title: 权限未开启, content: tip, confirmText: 去设置, success: (r) { if (!r.confirm) return resolve(false) wx.openSetting({ success: (s) resolve(!!s.authSetting[scope]), fail: () resolve(false) }) } }) }) } async function ensureScope(scope, tip) { const state await getAuthState(scope) if (state granted) return true if (state rejected) return guideToSetting(scope, tip) return new Promise((resolve) { wx.authorize({ scope, success: () resolve(true), fail: () guideToSetting(scope, tip).then(resolve) }) }) } // 拍照返回临时文件路径 async function takePhoto() { const ok await ensureScope(SCOPE_CAMERA, 请在设置中开启相机权限后再拍摄) if (!ok) throw new Error(camera auth denied) return new Promise((resolve, reject) { wx.chooseMedia({ count: 1, mediaType: [image], sourceType: [camera], sizeType: [compressed], camera: back, success: (res) resolve(res.tempFiles[0].tempFilePath), fail: (err) reject(err) }) }) } // 保存到相册 async function saveToAlbum(filePath) { const ok await ensureScope(SCOPE_ALBUM, 请在设置中开启保存到相册权限) if (!ok) throw new Error(album auth denied) return new Promise((resolve, reject) { wx.saveImageToPhotosAlbum({ filePath, success: () resolve(true), fail: (err) reject(err) }) }) } module.exports { takePhoto, saveToAlbum, ensureScope }7.3 怎么在页面里调用const { takePhoto, saveToAlbum } require(../../utils/photo.js) Page({ data: { saving: false, preview: }, async onShoot() { try { const path await takePhoto() this.setData({ preview: path }) await this.doSave(path) } catch (e) { if (e.errMsg e.errMsg.indexOf(cancel) -1) return wx.showToast({ title: 拍摄失败请重试, icon: none }) } }, async doSave(path) { if (this.data.saving) return this.setData({ saving: true }) try { await saveToAlbum(path) wx.showToast({ title: 已保存到相册, icon: success }) } catch (e) { console.log(保存失败, e) wx.showToast({ title: 保存失败, icon: none }) } finally { this.setData({ saving: false }) } } })几个细节说明一下。saving这个标志位是为了防止连点前面提过它的必要性。cancel的过滤也很重要用户在系统相机里点返回不应该弹拍摄失败那是正常操作弹提示只会让人觉得这个功能不稳定。preview用来在页面上显示刚拍的照片用户能立刻看到结果这比单纯弹一个 toast 的体验好得多也顺便解决了部分安卓机型相册不实时刷新的心理落差。还有个小扩展点如果你想让用户在拍完后裁剪一下再保存可以在takePhoto和saveToAlbum中间插一步用 canvas 做等比裁剪把裁剪结果通过canvasToTempFilePath导出成新路径再传进saveToAlbum。整个链路不用改加一个函数就够了——这也就是当初把三个环节拆开的价值。最后再分享一个我在实际项目里坚持的做法所有跟权限相关的失败都要在页面留一个可见的重新授权入口而不是只靠弹窗。因为总有用户手滑点了拒绝之后把弹窗也关掉了页面里如果没有一个可以重新触发的地方他就只能卸载重进。这个入口的代码量很小但能省掉一批客诉。
返回列表