news 2026/9/13 8:37:23

uniapp Android视频录制:videoRec原生插件原理与实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
uniapp Android视频录制:videoRec原生插件原理与实践

简介:面向uniapp打包Android端视频录制需求的开发者,专门解决uni.chooseVideo在Android端的调用限制。插件以aar形式集成,支持nvue页面中通过videoRec组件快速调用,可自由切换前置/后置摄像头,录制不设时长限制,并配有回调返回临时路径,适合需要自定义录制时长或原生能力扩展的中高级uniapp项目。压缩包共5个文件,核心为Android camera-release.aar,另含package.json配置和README说明文档,总大小仅107KB,接入门槛低。已有3018人学习下载,对于在uni.chooseVideo无法满足需求、希望通过原生插件补齐视频录制功能的场景,能帮助开发者快速建立插件接入思路,并提示摄像头、录音、存储权限判断等关键注意事项。

1. 为什么uniapp的chooseVideo不够用,我拆了这个videoRec插件

做过Android端视频录制的同学应该都有体会:uni.chooseVideo在iOS上表现尚可,到了Android上就成了玄学——部分机型调用系统相机后返回的临时路径偶尔以content://开头,uniapp的plus.io转换经常失败;更麻烦的是很多国产ROM对系统相机的录制时长做了隐性限制,用户录到一分半钟直接被系统掐断,你连个错误回调都拿不到。这个videoRec插件解决的问题很直接:它把Android原生Camera2的录制能力封装成一个nvue组件,通过<videoRec>标签直接渲染在页面上,录制过程不走系统相机界面,所有帧数据在应用内处理,绕开了content://协议带来的路径转换问题,也没有时长限制。适合那些对录制时长、画面比例、结束时机有强控制诉求的场景,比如考勤打卡、实名认证、日志上报这类需要用户录制一段完整视频的功能。

插件结构是标准的unicloud原生插件打包方式:videoRec.zip里包含camera-release.aarpackage.jsonREADME.md。AAR是Android原生模块的编译产物,包含了摄像头的预览、录制、回调全部逻辑;nvue页面通过组件标签与它通信。下面按我实际接入时的顺序,从AAR内部结构一路讲到权限申请,最后给出几个排查崩溃的经验。

2. 插件结构、AAR与nvue组件的关联方式

2.1 解开videoRec.zip后,先看package.json

{ "name": "videoRec", "version": "1.0.0", "platforms": [ { "name": "Android", "aar": "camera-release.aar" } ] }

这个package.json是uni原生插件的声明文件,dcloud插件市场打包时靠它识别AAR文件位置。如果你要自己修改插件后本地打包,注意aar字段对应的路径必须是相对于package.json的相对路径。.DS_Store是macOS的元数据文件,直接删掉或忽略,不影响构建。

2.2 AAR内部都装了什么

解压camera-release.aar后你会看到classes.jar和若干资源目录。核心逻辑在classes.jar里,通过反编译可以看到有几个关键类:

  • VideoRecView:继承FrameLayout,负责Camera2预览和MediaRecorder录制
  • VideoRecModule:继承UniModule,暴露给js层的API入口
  • VideoRecComponent:继承UniComponent,负责把VideoRecView实例化为nvue原生组件

camera-release.aar编译时用的Android SDK版本必须和你项目的compileSdkVersion匹配。我用到的是Android 10(API 29),如果你的项目compileSdk是34,那没问题;但如果项目还在28以下,建议先升级,否则编译器会报Failed to transform camera-release.aar

2.3 nvue组件和原生View的绑定原理

uniapp的nvue页面走的是原生渲染引擎,Weex规范里规定:自定义原生组件通过<组件名>标签映射到Android端的View类。映射关系写在插件内置的dcloud_uniplugins.json中:

{ "nativePlugins": [ { "plugins": [ { "type": "component", "name": "videoRec", "class": "com.example.videorec.VideoRecComponent" } ] } ] }

name字段就是你在nvue页面里写的标签名<videoRec>class是组件类的全限定名。VideoRecComponent内部会实例化VideoRecView,然后把它添加到Weex的视图树中。理解这个链路后,你就知道为什么这个插件只能在nvue页面用,不能用vue页面——vue页面走的是webview渲染,无法直接挂载原生View。

提示:如果自定义标签在页面上不显示,优先检查dcloud_uniplugins.json是不是被打进了assets目录,以及class路径是否和AAR里的包名一致。

3. 在nvue页面里接入videoRec:代码、参数与回调时序

3.1 最小可运行的nvue页面

创建一个page_rec.nvue文件,内容如下:

<template> <view class="container"> <videoRec class="video" ref="rec" @onTel="onTel"> </videoRec> <view class="controls"> <button @click="startRecord">开始录制</button> <button @click="stopRecord">结束录制</button> </view> </view> </template> <script> export default { methods: { startRecord() { this.$refs.rec.startRecord && this.$refs.rec.startRecord() }, stopRecord() { this.$refs.rec.stopRecord && this.$refs.rec.stopRecord() }, onTel(e) { // e.detail 里返回录制视频的临时路径 console.log('录制完成,路径:', e.detail) } } } </script> <style> .container { flex: 1; } .video { flex: 1; background-color: #000; } .controls { flex-direction: row; justify-content: space-around; padding: 20px; } </style>

运行后页面上会出现一个全黑的原生相机预览区域。@onTel是组件回调事件,注意在nvue里原生组件触发事件时,参数放在e.detail中,和普通vue组件的$emit略有差异。如果回调里直接打印e对象,Android端拿到的其实是一个UniRecord包装对象,取e.detail才是插件返回的数据。

3.2 原生端startRecord/stopRecord做了什么

视频录制基于MediaRecorder实现,调用时序必须是:

// 伪代码,展示核心调用顺序 private void startRecord() { mediaRecorder = new MediaRecorder(); camera.unlock(); mediaRecorder.setCamera(camera); mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); mediaRecorder.setVideoSource(MediaRecorder.VideoSource.CAMERA); mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); mediaRecorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264); mediaRecorder.setOutputFile(currentVideoPath); mediaRecorder.prepare(); mediaRecorder.start(); }

这里最容易出错的是顺序:必须先setAudioSourcesetVideoSource,且setCamera必须在setAudioSource之前调用。如果先设置输出格式再关联摄像头,部分机型会抛IllegalStateException。另外setOutputFile传的是应用私有目录路径,不要传content://或公共目录,否则MediaRecorder在prepare()阶段就会失败。

停止录制时,需要把操作放在子线程,因为MediaRecorder.stop()在低性能设备上可能耗时数十毫秒:

public void stopRecord() { if (mediaRecorder == null) return; try { mediaRecorder.stop(); } catch (RuntimeException e) { // stop()失败通常是因为没有有效数据(录制时长太短) // 此时必须重置,否则下次start会崩溃 mediaRecorder.reset(); } mediaRecorder.release(); mediaRecorder = null; // 通过UniJSBridge通知前端 mUniComponent.fireEvent("onTel", recordResult); }

mediRecorder.stop()如果录制时间小于1秒,很多手机会抛RuntimeException。插件内部做了catch处理,但你自己的业务层最好也做个最短时长的校验:把开始时间戳和回调时间戳做差,小于600ms就提示用户“按住录制至少一秒”,而不是直接保存文件。

3.3 组件暴露的参数:通过attrs控制前后摄像头

README里没有详细写参数,但通过阅读AAR源码可以看到组件支持一个cameraType属性:

<videoRec class="video" ref="rec" cameraType="front" @onTel="onTel"> </videoRec>

cameraType可取值frontback,默认back。组件初始化时会在VideoRecView.onAttachedToWindow()里根据这个属性打开对应的摄像头ID:

cameraId = "front".equals(cameraType) ? findFrontCameraId() : findBackCameraId();

如果你的业务需要在录制过程中切换前后摄像头,不能直接修改components的属性,因为原生View不会监听属性变化。正确的做法是给组件加一个switchCamera()方法,在nvue调用:

this.$refs.rec.switchCamera && this.$refs.rec.switchCamera()

注意:切换摄像头必须在未录制状态下调用。正在录制时切换会先触发stopRecord再重新start,两端之间有个几十毫秒的gap,视频会出现黑帧。我一般会在UI层禁止用户在录制状态点击切换按钮。

4. 权限判断的完整链路:摄像头、录音、存储与动态申请

4.1 Android 6.0+的动态权限模型

插件要正常工作需要三个权限:CAMERARECORD_AUDIOWRITE_EXTERNAL_STORAGE(Android 9及以下)。Android 6.0以上必须在运行时申请,Android 10及以上WRITE_EXTERNAL_STORAGE在targetSdk 29下仍需申请,但targetSdk 30及以上系统会忽略这个权限——除非你显式声明requestLegacyExternalStorage="true"

uniapp在pages.json里可以配置原生权限,但那只负责在打包时写入AndroidManifest.xml,运行时还是得靠uni.authorize或原生插件自己去要。这个videoRec插件不提供权限申请能力,所以页面逻辑里要自己判断。

4.2 用plus.android实现权限判断与申请

在nvue页面中可以通过plus.android调用原生API,写一个公共授权方法:

checkAndRequestPermission() { return new Promise((resolve, reject) => { const permissions = [ 'android.permission.CAMERA', 'android.permission.RECORD_AUDIO', 'android.permission.WRITE_EXTERNAL_STORAGE' ] const main = plus.android.runtimeMainActivity() const PackageManager = plus.android.importClass('android.content.pm.PackageManager') const pm = main.getPackageManager() const deniedList = [] for (let i = 0; i < permissions.length; i++) { const granted = pm.checkPermission(permissions[i], main.getPackageName()) if (granted !== PackageManager.PERMISSION_GRANTED) { deniedList.push(permissions[i]) } } if (deniedList.length === 0) { resolve() return } // 调用系统权限弹窗 main.requestPermissions(deniedList, 1001) // 监听授权结果 plus.globalEvent.addEventListener('pause', function onPause() { // 系统权限弹窗会触发onPause,用户操作后返回 setTimeout(() => { // 二次确认,真正判断权限 const grantedAfter = checkPermissions(permissions) grantedAfter ? resolve() : reject(new Error('PERMISSION_DENIED')) }, 300) }) }) }

这段代码的核心逻辑是:先通过PackageManager.checkPermission检查权限是否已授予,如果没有,调用Activity的requestPermissions弹出系统授权框。注意requestPermissions是异步的,授权结果通过Activity的onRequestPermissionsResult回调返回,但nvue里拿不到这个回调,只能用plus.globalEvent.addEventListener('pause')来监听弹窗关闭的时机,因为权限弹窗会触发当前Activity的pause生命周期。

建议不要依赖uni.getSetting去判断权限,在nvue原生渲染层,uni对象的部分API不保证可用。更稳妥的方式是用上面的plus.android方案,或者干脆在插件内部做权限判断——这也是原生插件相对合理的做法,不过我目前拿到的AAR并没有把权限逻辑内置,需要业务层处理。

4.3 权限被拒后的引导策略

如果用户点击了“拒绝”,下一次再申请会直接在系统层被拦住,不会再弹窗。这种情况需要跳转到应用设置页:

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(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) const uri = Uri.parse('package:' + main.getPackageName()) intent.setData(uri) main.startActivity(intent)

这段代码跳转到当前应用的系统设置页面,用户手动开启权限后返回应用。注意从设置页返回时,应用进程可能没有被杀死,但页面会触发onShow,这时候需要再次调用checkAndRequestPermission()确认权限状态,否则用户可能已经开了权限,插件却还停留在“未授权”的UI态。

5. 进阶:录制时长控制、码率参数与常见崩溃排查

5.1 参数动态化:在nvue层扩展组件方法

AAR自带的录制参数是固定的,默认分辨率720p、码率4Mbps、帧率30fps。如果产品要求1080p,或者录像上传服务有限流,需要动态调整。在原生组件里可以暴露一个setRecorderConfig方法:

public void setRecorderConfig(JSONObject config) { if (config.has("maxDuration")) { maxDuration = config.optInt("maxDuration", 0); // 单位毫秒,0为不限制 } if (config.has("videoBitrate")) { videoBitrate = config.optInt("videoBitrate", 4 * 1024 * 1024); } }

nvue侧调用时用$refs.rec.setRecorderConfig({ maxDuration: 60000, videoBitrate: 8 * 1024 * 1024 })。设置maxDuration后,MediaRecorder.setMaxDuration会在达到时长时自动停止并触发MediaRecorder.OnInfoListener,你需要在这个listener里手动回调onTel,否则前端永远收不到完成事件。常见做法是在listener中调用一次fireEvent("onTel"),并把e.detail里加上一个"reason": "timeout"字段,方便业务层区分是用户点击结束还是自动结束。

5.2 排查崩溃:从logcat日志定位问题

接入过程中典型的崩溃有两类。

第一类是java.lang.RuntimeException: start failed。这通常是MediaRecorder启动时没有权限,或者摄像头被其他应用占用。排查方法:

adb logcat -s MediaRecorder: E AndroidRuntime: E

看到Camera is being used by another app的日志,基本可以确认是权限弹窗还没点“允许”就触发了startRecord()。解决办法是在checkAndRequestPermission()的resolve回调之后再开启录制按钮,不要允许用户跳过授权直接点击。

第二类是java.lang.IllegalStateException。这个集中在切换前后摄像头时。原因多半是switchCamera()里没有先stopRecord()就重设了CameraDevice,导致底层状态机混乱。我在自己的项目里是这样处理的:

public void switchCamera() { if (isRecording) { stopRecord(); // 等待100ms让MediaRecorder完全释放 SystemClock.sleep(100); } releaseCamera(); openCamera(cameraId == FRONT ? BACK : FRONT); }

第三类是视频文件无法播放。检查文件路径是否有中文或空格,MediaRecorder.setOutputFile不支持含中文的路径。另外,存储权限如果没被授予,文件虽然能创建,但写入会被系统拦截,stop()后文件大小为0。验证方法很简单:

adb pull /sdcard/Android/data/com.your.app/files/video/xxx.mp4 ./ ffprobe xxx.mp4

5.3 录制结果校验与路径转换

onTel返回的临时路径可能是file://或纯路径,建议统一处理:

onTel(e) { let path = e.detail && e.detail.path ? e.detail.path : e.detail if (path.startsWith('file://')) { path = path.replace('file://', '') } // 检查文件是否存在且大小大于0 plus.io.resolveLocalFileSystemURL(path, entry => { entry.getMetadata(meta => { if (meta.size > 0) { // 上传或继续处理 uni.uploadFile({ url: 'https://your-server.com/upload', filePath: path }) } }) }) }

这里有个细节:Android 10及以上,即使拿到临时路径,如果你把文件传给第三方SDK(比如七牛、阿里云OSS),SDK内部可能因为File对象访问受限而失败。稳妥做法是先复制到应用私有目录:

cp /sdcard/Android/data/{package}/files/video/xxx.mp4 /data/data/{package}/files/video/copy.mp4

在nvue里用plus.io操作时注意resolveLocalFileSystemURL的路径分隔符,Android端不支持\\,一律用/

5.4 集成时的最后一个坑:proguard混淆规则

如果项目开了混淆,务必在proguard-rules.pro里加入:

-keep class com.example.videorec.** { *; } -keep class com.taobao.weex.** { *; }

否则Release包运行时会出现Component class com.example.videorec.VideoRecComponent not found的诡异崩溃——这是典型的混淆把类名改掉了,而dcloud_uniplugins.json里的类路径还是原名字符串。Debug包一切正常,Release包白屏,优先怀疑这个。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 8:32:39

Spring Boot 3.5.12依赖管理与版本冲突解决

1. Spring Boot 3.5.12依赖版本管理详解作为Java开发者最常用的企业级框架&#xff0c;Spring Boot的依赖管理一直是项目配置中的核心环节。3.5.12版本作为当前GA&#xff08;General Availability&#xff09;的稳定版本&#xff0c;其POM文件中的依赖版本定义直接影响着项目的…

作者头像 李华
网站建设 2026/9/13 8:29:50

Codex Agent 实战:从安装配置到 GPT-6 Astra 的智能体化演进

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 8:29:35

MATLAB下PSO优化PID参数:从整定原理到工程实现

简介&#xff1a;这是一份基于粒子群优化算法的PID控制器参数整定MATLAB程序包&#xff0c;面向自动控制领域的研究生、工程师以及智能优化算法初学者&#xff0c;用于解决PID比例、积分、微分三个参数难以手动准确调整的问题。压缩包内共6个文件&#xff0c;其中3个m脚本文件分…

作者头像 李华
网站建设 2026/9/13 8:28:45

Activated LoRA技术在大语言模型中的创新应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华