1. 先想清楚:小程序里录制视频到底有几条路可走
微信小程序录制视频,听上去是个很朴素的需求——用户点一下按钮开始录,再点一下结束,拿到文件去上传。真动手做的时候你会发现,光"用什么组件去录"这个问题,就有三种截然不同的答案,而且它们的能力边界、适配成本、能踩的坑完全不一样。我见过不少团队在项目启动会上拍脑袋说"用 camera 组件录呗",结果做到一半发现录制时长被卡死在 30 秒,又要推倒重来。
所以这一节我不急着贴代码,先把三条路径摆到台面上,说清楚每条路适合什么样的业务。
1.1 camera 组件 + CameraContext:把控制权握在自己手里
这是最"原生"的一条路。页面上直接放一个camera组件,它就是一个实时的取景器,画面直接渲染在页面里;再通过wx.createCameraContext()拿到上下文对象,调用startRecord()和stopRecord()控制录制。整个过程中用户看到的界面是你自己写的,按钮长什么样、加不加计时器、要不要美颜遮罩(这个得另做),统统由你决定。
它最大的优势是交互自由度高。比如你想做一个"按住说话"式的短视频打卡功能,需要在录制过程中实时显示已录秒数、在后置摄像头和前置摄像头之间切换、还要在画面角落叠一个引导文案——这些用 camera 组件都能实现。另外它是一个"页面内取景"的形态,用户不需要跳出你的小程序,操作路径最短。
代价也很明确:它是一个原生组件,层级最高,普通view盖不住它(虽然新基础库在部分机型上支持同层渲染,但兼容老版本还是要靠cover-view);录制时长有硬性上限,官方给的是 30 秒左右,超时会走timeoutCallback回调;而且在开发者工具里的表现和真机差异极大,基本必须真机调试。
1.2 wx.chooseMedia 调起系统相机:一行代码换来的省事
第二条路是直接把活儿交给系统。调用wx.chooseMedia({ mediaType: ['video'], sourceType: ['camera'] }),微信会拉起手机自带的相机应用,用户用系统相机录完,视频以临时文件的形式回传给小程序。你只需要处理返回值就行。
这条路的好处是实现成本极低,交互体验也符合用户习惯——用户对系统相机太熟悉了。而且maxDuration参数可以设到 60 秒,比 camera 组件的限制更宽松,录像的清晰度、防抖、对焦这些也都由系统相机负责,画质普遍比组件录制更好。
缺点同样明显:你完全控制不了这个中间过程。用户点进系统相机之后会不会切到相册、会不会取消、录了多久、横屏还是竖屏,你都只能被动接收结果。做 C 端内容类产品时,这种"跳出感"对转化率是有影响的。另外系统相机界面在不同安卓机型上长得千奇百怪,品牌定制 ROM 还会加各种滤镜按钮,体验一致性很难保证。
1.3 三条路径横向对比与选型建议
还有第三条路,就是页面里嵌一个web-view,用 H5 的MediaRecorder或者getUserMedia来录。这条路我不太推荐,因为web-view内的摄像头权限链路在小程序环境里相当绕,兼容性判断复杂,而且录出来的文件格式在不同机型上五花八门,后端的转码压力会明显增加。除非你的项目里 H5 那套东西本来就很成熟,否则不建议在新项目里走这条路。
把前两条路放在一起对比,决策逻辑就清楚了:
| 对比维度 | camera 组件 + CameraContext | wx.chooseMedia 调系统相机 |
|---|---|---|
| 单次录制上限 | 约 30 秒,超时触发 timeoutCallback | 默认 10 秒,最大可设 60 秒 |
| 界面自定义程度 | 完全自定义 | 系统相机界面,无法干预 |
| 画质 | 由 resolution 属性控制,三档 | 由系统相机决定,通常更好 |
| 前后置切换 | 代码控制,可实时切换 | 用户自己操作 |
| 开发者工具支持 | 预览勉强能用,录制基本不可用 | 可用(会拉起调试环境的模拟选择) |
| 主要适用场景 | 打卡、身份核验、短视频、连续录制 | 一次性上传、表单附件、大文件 |
我的经验是:**如果录制是产品的核心交互,用 camera 组件;如果录制只是表单里的一个附件入口,用 chooseMedia。**别为了"看起来更专业"硬上 camera 组件,30 秒的时长限制会在需求评审后期突然变成致命问题。
2. 上线前的合规与配置:这一步漏了,真机上直接失败
代码写得再漂亮,配置没做对,真机上一按录制就报错——这是最近一两年最容易翻车的地方,而且报错信息往往很含糊,让人一头雾水。
2.1 用户隐私保护指引必须声明对应权限
微信对小程序调用摄像头、麦克风、相册这些敏感能力做了强约束。你必须在小程序管理后台的「设置」-「服务内容声明」-「用户隐私保护指引」里,如实勾选并声明会用到的权限类型。录制视频这个场景,至少要声明这三项:
- 摄像头(对应
scope.camera) - 麦克风(对应
scope.record,录制带声音的视频需要) - 相册(仅写入)(对应
scope.writePhotosAlbum,如果你提供"保存到手机相册"按钮)
漏声明的典型表现是:wx.authorize或者startRecord直接失败,errMsg里出现和隐私协议相关的关键词。更麻烦的是,这类失败在开发者工具里往往不出现(工具走的是调试通道),只有真机才暴露,很容易拖到提测阶段才发现。
注意:隐私协议审核通过后不是立即全网生效,审核通过到生效之间有一个时间窗口,建议在开发早期就把这块配置做掉,别等到发版前一天。
2.2 scope.camera 与 scope.record 的授权时机
小程序的权限模型是"先申请、再使用"。camera组件本身在你把它渲染到页面上时就会触发授权弹窗,但那个弹窗的触发时机不受你控制,用户体验并不好。更稳妥的做法是在用户点击"开始录制"按钮时主动调用wx.authorize,让授权和用户的操作意图绑定在一起,通过率会高很多。
// 主动申请摄像头 + 麦克风权限 function ensurePermission() { return new Promise((resolve, reject) => { wx.authorize({ scope: 'scope.camera', success: () => { wx.authorize({ scope: 'scope.record', success: resolve, fail: reject }); }, fail: reject }); }); }这里有个细节:wx.authorize一旦被用户拒绝过,后续调用会直接进 fail 回调,不会再弹窗。所以你必须区分"首次拒绝"和"已经被永久拒绝",后者的正确做法是引导用户去设置页手动打开:
wx.getSetting({ success: (res) => { if (res.authSetting['scope.camera'] === false) { // 用户之前拒绝过,引导去设置页 wx.showModal({ title: '需要摄像头权限', content: '请在设置中打开摄像头权限后重试', success: (m) => { if (m.confirm) wx.openSetting(); } }); } } });这段判断逻辑看起来啰嗦,但没有它,你的用户会卡在一个"点了没反应"的死胡同里,客诉电话就来了。
2.3 基础库版本、页面配置与开发者工具的正确用法
camera组件的录像能力和基础库版本强相关。startRecord/stopRecord这一对 API 是从基础库 2.7.0 开始提供的,低于这个版本调用会直接报"is not a function"。所以project.config.json或者后台的低版本兼容设置里,建议把最低基础库定在 2.10.0 以上,给一些老版本的行为差异留出缓冲。
页面配置方面,camera组件需要整屏或者接近整屏的空间,通常会把所在页面的navigationStyle设成custom,自己做导航栏。这里有个所有人都踩过的坑:自定义导航栏要考虑状态栏高度,也就是wx.getSystemInfoSync().statusBarHeight(新版本推荐用wx.getWindowInfo())。而这个高度是逻辑像素 px,不是 rpx,直接写进样式里要做单位换算,否则在不同机型上要么被顶部挖孔挡住,要么留出一大块空白。
至于开发者工具,我的建议是:只用它来验证页面结构和数据流,凡涉及录制的功能一律真机调试。工具里的摄像头预览依赖电脑摄像头,录制链路经常是残缺的,stopRecord返回的临时文件也可能异常。用工具的"真机调试"或者直接扫码预览,才是靠谱的验证方式。
3. 核心 API 逐个拆解:从取景到落盘
这一节是整篇内容里最需要逐字看的部分。我把camera组件和 CameraContext 的关键配置项拆开讲,并说清楚每个参数背后的取舍。
3.1 camera 组件属性清单与取值逻辑
camera组件上真正需要你关心的属性其实就几个,但每一个都有讲究:
| 属性 | 可选值 | 实际影响 |
|---|---|---|
| mode | normal / scanCode | 录像场景保持 normal,扫码模式会禁用录制能力 |
| device-position | back / front | 默认后置,切换需要重新绑定数据并重建组件 |
| flash | auto / on / off / torch | torch 是常亮补光,录像场景比 on 更实用 |
| resolution | low / medium / high | 直接影响输出体积,默认 medium |
| frame-size | small / medium / large | 影响onCameraFrame的回调帧尺寸,纯录像场景可忽略 |
flash这个属性值得单独说一句。on表示拍照或录像瞬间闪一下,但录像是个持续过程,"闪一下"意义不大;torch才是录像场景该用的值,它让补光灯在整个录制过程中常亮。做夜间打卡功能的时候,这个区别决定了用户能不能看清画面。
另外,device-position切换时不要天真地改个变量就完事。实测下来,部分安卓机型直接切换会出现画面卡死或者绿屏,稳妥做法是给 camera 组件加一个wx:if配合key让它彻底重建一次。代价是有零点几秒的黑屏,但比卡死强。
3.2 startRecord / stopRecord 的参数与回调时序
startRecord的调用非常简单,几乎没有必填参数,但它有两个容易忽略的回调:
const ctx = wx.createCameraContext(); ctx.startRecord({ // 超过 30 秒,或者录制被系统中断(来电、锁屏、切后台)时触发 timeoutCallback: (res) => { console.log('自动结束:', res); // 注意:这里可能已经有可用的视频文件了,需要主动调 stopRecord 取结果 }, success: () => { console.log('录制已启动'); }, fail: (err) => { console.error('启动失败', err); } });timeoutCallback是我认为整个 API 里最容易被低估的一个。它不是"失败回调",而是"录制被结束"的通知。用户接了个电话、锁了屏、切到微信聊天再回来,都会触发它。如果业务上不做处理,用户回来看到的界面还停留在"录制中"状态,计时器一直在跑,但实际上视频早就断了。正确做法是在这个回调里立刻调用stopRecord收尾,把已有内容保存下来,并给用户一个明确的提示。
stopRecord的返回值才是真正的产出:
ctx.stopRecord({ success: (res) => { // res.tempThumbPath 视频首帧图,可直接当封面用 // res.tempVideoPath 视频临时文件路径 console.log(res.tempThumbPath, res.tempVideoPath); }, fail: (err) => { console.error('停止录制失败', err); } });这里有两个必须记住的点。第一,tempVideoPath是临时文件路径,小程序重启或者系统清理缓存之后就可能失效,想长期保留必须主动持久化。第二,这两个路径都是本地路径,不能直接丢给<video>组件的src做网络播放以外的操作——本地播放是可以的,但要分享出去就必须先上传。
3.3 分辨率与码率的取值推演
很多人问"录出来的视频为什么这么大",其实体积是可以提前算出来的,公式很简单:
文件体积 ≈ 码率 × 时长 ÷ 8
以 720p 常用码率 2 Mbps 为例,30 秒的录像体积约等于2 × 30 ÷ 8 = 7.5 MB。如果开到 1080p、码率拉到 8 Mbps,同样 30 秒就是 30 MB。这个数字直接决定了你的上传策略和后端存储成本。
| resolution | 大致分辨率 | 参考码率 | 30 秒体积 | 适用场景 |
|---|---|---|---|---|
| low | 480p 左右 | 约 0.5 Mbps | 约 1.9 MB | 打卡签到、纯内容识别 |
| medium | 720p 左右 | 约 2 Mbps | 约 7.5 MB | 通用场景,性价比最高 |
| high | 1080p 左右 | 约 8 Mbps | 约 30 MB | 需要看清细节的展示类内容 |
我的建议是默认用 medium,只在有明确画质诉求时开 high。理由很实在:小程序的上传走的是wx.uploadFile,移动网络下 30 MB 的文件失败率相当高,用户等待时间也长,中间切个后台就前功尽弃。真需要高清,不如降低时长,比如把 30 秒拆成 10 秒,用清晰度换体积。
4. 手把手实操:一个可以直接抄的录像页
前面讲了原理,这一节给完整可运行的实现,从页面结构一路写到上传和保存相册。你可以直接拿去改。
4.1 页面结构与样式骨架
先看 WXML。核心思路是把camera铺满屏幕,所有可交互元素用cover-view叠在上面。
<!-- pages/record/record.wxml --> <view class="page"> <!-- 有权限时渲染取景器,wx:if 保证切换摄像头时可重建 --> <camera wx:if="{{authed}}" class="camera" mode="normal" resolution="{{resolution}}" device-position="{{devicePosition}}" flash="{{flash}}" binderror="onCameraError" bindstop="onCameraStop" > <!-- 原生组件内部的子节点必须是 cover-view / cover-image --> <cover-view class="hud"> <cover-view class="timer">{{mmss}}</cover-view> <cover-view class="dot" wx:if="{{recording}}"></cover-view> </cover-view> </camera> <!-- 无权限时的引导层 --> <view class="placeholder" wx:else> <text class="ph-title">需要摄像头权限才能录制</text> <view class="ph-btn" bindtap="openSetting">去开启</view> </view> <!-- 控制条,普通 view 即可(在 camera 外层) --> <view class="toolbar"> <view class="tool-btn" bindtap="switchCamera"> <text>翻转</text> </view> <view class="record-btn {{recording ? 'recording' : ''}}" bindtap="toggleRecord" > <view class="inner"></view> </view> <view class="tool-btn" bindtap="toggleFlash"> <text>{{flash === 'torch' ? '补光开' : '补光关'}}</text> </view> </view> </view>样式部分有两个经验点。第一,.camera用position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;铺满,别用100%,在某些机型上百分比高度会算不准。第二,控制条要加padding-bottom: env(safe-area-inset-bottom),否则在全面屏手机上按钮会被底部横条压住。
/* pages/record/record.wxss */ page { background: #000; } .camera { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; } .hud { position: absolute; top: 120rpx; left: 0; width: 100%; display: flex; justify-content: center; } .timer { color: #fff; font-size: 32rpx; padding: 8rpx 24rpx; background: rgba(0,0,0,.45); border-radius: 999rpx; } .toolbar { position: fixed; left: 0; bottom: 0; width: 100%; display: flex; align-items: center; justify-content: space-around; padding: 40rpx 0 calc(40rpx + env(safe-area-inset-bottom)); background: rgba(0,0,0,.35); } .record-btn { width: 140rpx; height: 140rpx; border-radius: 50%; border: 6rpx solid #fff; display: flex; align-items: center; justify-content: center; } .record-btn .inner { width: 100rpx; height: 100rpx; border-radius: 50%; background: #ff3b30; transition: all .2s; } .record-btn.recording .inner { width: 56rpx; height: 56rpx; border-radius: 12rpx; }那个.recording状态下内圈从圆形变方块的小动画,是模仿主流录像 App 的视觉语言,用户一看就知道"现在是在录",不需要额外文案。
4.2 逻辑层完整代码
逻辑部分的关键是状态管理要跟录制状态严格对齐。计时器、按钮样式、timeoutCallback、页面卸载,这四处任何一处漏掉都会导致状态错乱。
// pages/record/record.js const app = getApp(); let cameraCtx = null; let timer = null; Page({ data: { authed: false, recording: false, seconds: 0, mmss: '00:00', devicePosition: 'back', flash: 'off', resolution: 'medium' }, onLoad() { this.checkAuth(); }, onUnload() { // 页面销毁必须停录制、清计时器,否则摄像头会一直占用 this.cleanup(); }, onHide() { // 切后台或跳到其他页面,主动收尾,避免录制被系统中断后状态不一致 if (this.data.recording) { this.stopRecord(); } this.cleanup(); }, checkAuth() { wx.getSetting({ success: (res) => { const cam = res.authSetting['scope.camera']; if (cam === true) { this.setData({ authed: true }); this.initCtx(); } else if (cam === undefined) { // 从未申请过 wx.authorize({ scope: 'scope.camera', success: () => { this.setData({ authed: true }); this.initCtx(); }, fail: () => { this.setData({ authed: false }); } }); } else { this.setData({ authed: false }); } } }); }, initCtx() { if (!cameraCtx) cameraCtx = wx.createCameraContext(); }, openSetting() { wx.openSetting({ success: (res) => { if (res.authSetting['scope.camera']) { this.setData({ authed: true }); this.initCtx(); } } }); }, toggleRecord() { if (this.data.recording) { this.stopRecord(); } else { this.startRecord(); } }, startRecord() { this.initCtx(); cameraCtx.startRecord({ timeoutCallback: () => { // 录制被系统中断或超过 30 秒,主动收尾 wx.showToast({ title: '录制已自动结束', icon: 'none' }); this.stopRecord(); }, success: () => { this.setData({ recording: true, seconds: 0, mmss: '00:00' }); timer = setInterval(() => { const s = this.data.seconds + 1; this.setData({ seconds: s, mmss: `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` }); }, 1000); }, fail: (err) => { console.error('startRecord fail', err); wx.showToast({ title: '启动录制失败', icon: 'none' }); } }); }, stopRecord() { if (!this.data.recording) return; this.clearTimer(); cameraCtx.stopRecord({ success: (res) => { this.setData({ recording: false, seconds: 0, mmss: '00:00' }); this.handleVideo(res.tempVideoPath, res.tempThumbPath, this.data.seconds); }, fail: (err) => { console.error('stopRecord fail', err); this.setData({ recording: false }); } }); }, handleVideo(videoPath, thumbPath, duration) { // 这里先落一份本地持久化,防止后续上传失败导致文件丢失 const fs = wx.getFileSystemManager(); const localPath = `${wx.env.USER_DATA_PATH}/rec_${Date.now()}.mp4`; fs.saveFile({ tempFilePath: videoPath, filePath: localPath, success: (r) => { console.log('本地已保存', r.savedFilePath); }, fail: (e) => { console.warn('本地保存失败,不影响上传', e); } }); wx.showLoading({ title: '处理中' }); wx.uploadFile({ url: 'https://your-domain.com/api/record/upload', // 需在小程序后台配置为合法域名 filePath: videoPath, name: 'file', formData: { duration: String(duration), thumb: thumbPath || '' }, header: { Authorization: `Bearer ${app.globalData.token || ''}` }, success: (res) => { wx.hideLoading(); try { const data = JSON.parse(res.data); if (data.code === 0) { wx.showToast({ title: '上传成功' }); } else { wx.showToast({ title: data.msg || '上传失败', icon: 'none' }); } } catch (e) { wx.showToast({ title: '返回数据异常', icon: 'none' }); } }, fail: (err) => { wx.hideLoading(); console.error('upload fail', err); wx.showToast({ title: '网络异常,稍后重试', icon: 'none' }); } }); }, clearTimer() { if (timer) { clearInterval(timer); timer = null; } }, cleanup() { this.clearTimer(); if (this.data.recording) this.setData({ recording: false, seconds: 0, mmss: '00:00' }); }, switchCamera() { // 部分安卓机型直接改值会卡死,用 wx:if 重建组件更稳 this.setData({ authed: false, devicePosition: this.data.devicePosition === 'back' ? 'front' : 'back' }); wx.nextTick(() => this.setData({ authed: true })); }, toggleFlash() { this.setData({ flash: this.data.flash === 'torch' ? 'off' : 'torch' }); }, onCameraError(e) { console.error('camera error', e.detail); wx.showToast({ title: '摄像头异常', icon: 'none' }); }, onCameraStop() { // 摄像头被系统关闭(权限被收回、被其他应用占用等) this.cleanup(); } });注意handleVideo里我先做了一次本地持久化再上传。这个顺序是刻意的:上传可能因为网络失败,而tempVideoPath指向的临时文件在用户下次打开小程序时可能就没了。先落本地,用户至少还能在"我的录制"里找到草稿,体验上差别很大。
4.3 录制完的上传、回放与保存到相册
上传这一步有几个细节必须提前准备好。首先是上传域名白名单,wx.uploadFile的url必须在「开发管理」-「开发设置」-「服务器域名」的uploadFile合法域名里配好,否则真机直接失败,工具里还可能因为勾了"不校验合法域名"而看不出来。
回放就简单了,把上传成功后服务端返回的 URL 丢给video组件即可:
<video src="{{playUrl}}" controls show-center-play-btn />如果用户还想要一份到手机相册,那就需要scope.writePhotosAlbum:
wx.saveVideoToPhotosAlbum({ filePath: localPath, // 本地文件路径,不是网络地址 success: () => wx.showToast({ title: '已保存到相册' }), fail: (err) => { if (err.errMsg.indexOf('auth') > -1) { wx.showModal({ title: '需要相册权限', content: '请在设置中允许保存到相册', success: (m) => m.confirm && wx.openSetting() }); } } });一个常见误解:用户以为"保存到相册"是复制一份,实际上安卓上某些机型保存后相册需要几秒钟刷新才能看到,如果用户立刻切到相册发现没有,会以为失败了。所以在成功提示里加一句"可在相册的最近项目中查看",能省掉很多解释成本。
5. 踩坑实录:文档里不会写的那些细节
这一节全部来自真实项目,每一条我都至少被坑过一次。
5.1 黑屏、无声、回调不触发
黑屏通常有三个原因。最常见的是wx:if反复切换导致组件还没准备好就被重建,解决办法是给操作加一点延迟,或者干脆只切一次。第二个原因是页面在onShow后立刻渲染 camera,这时候权限判断还没回来,组件其实是"无权限状态",表现就是黑屏。第三个是切后台再回来,部分安卓机型摄像头资源没有正确释放,需要在onHide里彻底停掉录制、必要时重建组件。
录制无声的问题更隐蔽。它跟scope.record的授权状态强相关。如果用户在系统层面拒绝了麦克风,camera组件不会报错,照样录,但录出来的是静音视频。这种问题在测试阶段极难发现——因为测试机的权限大多是开着的。建议在进入录制页时,把scope.camera和scope.record一起申请,并且在界面上明确告知"需要麦克风才能录制声音"。
回调不触发的情况多半是时序问题。比如用户快速连点两次录制按钮,第一次startRecord还没回调,第二次stopRecord就发出来了,两个操作的时序错乱,最后谁都不返回。解决办法是加一个操作锁,recording状态切换过程中禁用按钮,并在 UI 上给出视觉反馈。
5.2 授权被拒之后的兜底流程
授权被拒是小程序的常态,不是异常。一套完整的兜底流程应该长这样:
- 判断
authSetting['scope.camera']的值是undefined(没问过)还是false(问过被拒) - 是
undefined就走wx.authorize弹窗 - 是
false就展示自定义引导页,说明为什么需要这个权限,并提供"去设置"按钮 - 从设置页返回时(
onShow里重新getSetting)刷新页面状态
第三步的文案很有讲究。别写"请开启摄像头权限"这种冷冰冰的话,写"为了录制打卡视频,需要访问你的摄像头,视频仅用于打卡存档,不会上传到其他位置"。用户对隐私的敏感度很高,说明用途能显著提升开启率。
5.3 iOS 与 Android 的差异清单
| 差异点 | iOS 表现 | Android 表现 |
|---|---|---|
| 组件重建 | 相对温和,黑屏时间短 | 部分机型需要更长准备时间 |
| 摄像头切换 | 基本正常 | 少数机型切换后花屏,需重建组件 |
| 文件体积 | 同分辨率下略大 | 同分辨率下略小,编码器差异 |
| 相册保存 | 即时可见 | 部分机型需等待相册刷新 |
| 视频方向 | 一般正确 | 少数机型输出旋转 90 度,需服务端兜底 |
最后一行那个"旋转 90 度"的问题值得重点提一下。它的根因是部分安卓机型的摄像头传感器方向与容器写入的旋转元数据不一致,小程序侧拿不到足够的元信息去修正。稳妥的做法是在服务端做一次转码,读取视频的旋转标记并统一校正。虽然增加了后端成本,但比让用户在客户端看到横着的视频强太多。
6. 常见问题速查表
把上面的经验整理成一张表,遇到问题直接对照排查,比翻文档快得多。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| startRecord 报 is not a function | 基础库版本过低 | 检查基础库是否低于 2.7.0,提升最低版本要求 |
| 真机提示隐私相关错误 | 未在后台声明隐私权限 | 检查「用户隐私保护指引」是否勾选摄像头、麦克风 |
| 录制界面正常但没有声音 | scope.record 被拒 | 申请麦克风权限,检查系统级开关 |
| 30 秒自动结束 | 触发了 timeoutCallback | 这是组件限制,考虑拆分为多段录制 |
| 上传一直失败 | 域名未加入 uploadFile 白名单 | 检查服务器域名配置,注意区分 request 和 uploadFile |
| 上传成功但播放不了 | 服务端返回的 URL 不可访问 | 检查对象存储的读写权限和跨域配置 |
| 视频体积异常大 | resolution 设为 high | 降到 medium,或缩短录制时长 |
| 切后台回来界面卡死 | 录制被中断但状态未同步 | onHide 主动 stopRecord,onShow 重置状态 |
| 视频画面旋转 | 安卓机型传感器方向差异 | 服务端转码统一校正,或读取元数据前端旋转 |
| 开发者工具正常真机异常 | 工具与真机环境差异 | 一律以真机调试结果为准 |
关于"录制后怎么把视频拿出来"这个问题,很多人第一反应是找小程序的临时目录。这里要说明白:**小程序的临时文件对用户是不可见的,只有通过wx.saveVideoToPhotosAlbum保存到系统相册,用户才能在相册里看到。**如果你把视频上传到了自建的对象存储服务,那用户要在小程序外的环境获取,就必须通过你的业务后台提供下载入口——比如在小程序里生成一个下载链接,用户复制到浏览器打开。这条链路涉及域名白名单和文件访问权限,建议在需求阶段就明确下来。
7. 录制之后:压缩、存储与工程化封装
录制只是开头,真正影响项目成败的是后面这一串环节。这一节聊三个我踩过较多坑的方向。
7.1 体积控制与转码策略
前面算过,1080p 录 30 秒就有 30 MB,这对移动网络是很不友好的。小程序端能做的主要是控制resolution和限制时长,真正的压缩得放在服务端或者云函数里做。常见的做法是:客户端按 medium 录制保证可用性,服务端收到后用转码工具统一压到 720p、1 Mbps 左右再归档,这样存储成本和带宽成本都能降下来。
这里有个取舍要提前想清楚。如果你做的是身份核验类场景,压得太狠会影响识别准确率,那就不能为了省成本无脑压。判断标准很简单:这个视频未来会不会被"看",还是只被"机器读"。只被机器读的视频,保留关键帧和足够分辨率即可,不必保留完整码率。
7.2 临时文件的生命周期管理
tempVideoPath指向的文件会在小程序退出后被系统清理,这个特性决定了你不能把"录制完成"当成"任务完成"。我的做法是三层兜底:
第一层,录制成功立刻用FileSystemManager.saveFile存到wx.env.USER_DATA_PATH目录下,这个目录是小程序自己的持久化空间,重启后依然存在。但它有容量上限,具体数值随基础库版本调整,所以绝对不能当硬盘用,必须配合定期清理。
第二层,上传成功后立刻删除本地副本,避免空间被撑满。删除用fs.unlink,并且在删除前确认服务端返回了可用的资源标识。
第三层,上传失败的记录进一个"待重试队列",存到wx.setStorageSync里,下次打开小程序时尝试补传。这套机制对弱网环境下的用户体验提升非常明显,做表单类产品时几乎是标配。
7.3 把录制能力封装成组件复用
如果项目里多个页面都要录制,别复制粘贴。封装成一个自定义组件,对外暴露bind:success、bind:error两个事件和start()、stop()两个方法就够了,内部的权限申请、组件重建、计时器管理、本地持久化统统藏在组件里。
封装的收益不只是少写代码。更重要的是把"摄像头是单例资源"这个约束收拢到一个地方管理。你想想,如果两个页面同时持有 camera 上下文,用户快速跳转时就会出现资源争抢,表现出来就是画面黑一下、或者录制失败。组件化之后,入口唯一,这类问题从根上就不容易发生。
组件对外的事件设计我建议用detail传一个结构化的对象,而不是直接传路径:
this.triggerEvent('success', { localPath: savedFilePath, remoteUrl: res.data.url, duration: seconds, thumb: thumbPath, size: fileSize });这样上层页面拿到的是一个完整的结果对象,不用再去猜哪个字段是什么,后面要加字段也不用改签名。这种细节在项目初期看不出价值,等到第五个页面接入的时候,你会庆幸当初这么设计了。