Android Camera2采集并编码为H.264
前言
本篇博文主要讲述的是基于Android原生MediaCodec通过Camera2 API进行图像数据采集并编码为H.264的实现过程,如果对此感兴趣的不妨驻足观看,也欢迎大家大家对本文中描述不当或者不正确的地方进行指正。如果对于Camera2预览还不熟悉的可以观看博主上一篇博文:Android 基于Camera2 API进行摄像机图像预览。
MediaCodec简介
MediaCodec 主要是用于对音视频进行编解码。它通常与 MediaExtractor、MediaMuxer、Surface 和 AudioTrack 等组件一起使用。MediaCodec 支持硬件加速,可以利用设备的硬件资源来提高编解码的性能。
1、MediaCodec 编解码流程
MediaCodec 采用异步方式处理数据,使用一组输入输出缓冲区(ByteBuffer)。编解码流程大致如下:
-
请求一个空的输入缓冲区,填充满数据后传递给 MediaCodec 处理。
-
MediaCodec 处理完数据后,将结果输出至一个空的输出缓冲区中。
-
从 MediaCodec 获取输出缓冲区的数据,消耗掉里面的数据后,释放回编解码器。
具体流程可以参考下图:
2、MediaCodec 生命周期
从上图可以看出MediaCodec 的生命周期包括三种状态:Stopped、Executing、Released。
-
Stopped,分三种子状态:
-
Configured,MediaCodec实例创建后,调用configure方法后就进入了Configured状态
-
Uninitialized,MediaCodec实例被创建后,在调用configure方法前都处于该状态;
-
Error,MediaCodec遇到错误时进入该状态,通常可能是队列操作返回错误或异常导致的;
-
-
Executing,分三种状态
-
Flushed,在调用start()方法后MediaCodec立即进入Flushed子状态,此时MediaCodec会拥有所有的缓存。可以在Executing状态的任何时候通过调用flush()方法返回到Flushed子状态;
-
Running,一旦第一个输入缓存(input buffer)被移出队列,MediaCodec就转入Running子状态,这种状态占据了MediaCodec的大部分生命周期。通过调用stop()方法转移到Uninitialized状态;
-
End of Stream,将一个带有end-of-stream标记的输入buffer入队列时,MediaCodec将转入End-of-Stream子状态。在这种状态下,MediaCodec不再接收之后的输入buffer,但它仍然产生输出buffer直到end-of-stream标记输出
-
-
Released,当使用完MediaCodec后,必须调用release()方法释放其资源。调用 release()方法进入最终的Released状态;
编码实现
老规矩,依然需要对MediaCodec进行封装,在封装之前我们需要先设计下对应的接口,而根据上面了解到的MediaCodec生命周期中的几个状态我们需要对其进行记录,方便对外获取,因此接口设计如下:
interface ICodec {enum class State{IDLE,START,STOP,CLOSE}fun start()fun stop()fun close()fun getState():State
}interface IVideoEncoder: ICodec
这里的close对应的是MediaCodec的release接口。ICodec设计思想是考虑到后续会扩展出不止VideocEncoder还会有Decoder因此只包含了最原始的几个接口设计,针对Encoder和Decoder区别性的接口可以基于ICodec进行扩展继续添加,因为IVideoEncoder暂时没有需要额外添加的接口所以只是单纯的继承自ICodec,后面有需要再添加即可。
接着让我们再编写一个VideoCodec类并实现IVideoEncoder接口,开始我们的编码实现。
class VideoEncoder(private var params:VideoEncParams = VideoEncParams()):IVideoEncoder {private var state = ICodec.State.IDLEprivate var enccoder:MediaCodec? = nullprivate var inputSurface:Surface? = nullprivate var encodeThread:Thread? = nullprivate var callback:EncoderCallback? = nullfun setEncoderCallback(callback:EncoderCallback){this.callback = callback}interface EncoderCallback{fun onCallback(data:ByteArray,frameFlags:Int)}
VideoEncoder实现了IVideoEncoder接口并且构造时需要传入VideoEncParams,VideoEncParams我们等下再看,inputSurface是编码输入源,encodeThread用于异步进行编码,callback则是对外提供编码后数据的回调,回调并不仅仅只包含编码后的帧数据,还包含有一个Int类型的frameFlags,这个frameFlags可以理解为编码这一帧的类型,例如SPS帧或者关键帧等,现在我们回过头来看下传入的VideoEncParams:
class VideoEncParams(var mime:String = "video/avc",var codecWidth:Int = 1920,var codecHeight:Int = 1080,var bitRate:Int = 2048,var fps:Int = 30,var keyInterval:Float = 1f,var rotation:Int = 90
)
参数貌似有点多,但实际编码过程中的传参根据需要可能会更多,只是我这里罗列的这些参数已经满足我们当前示例的需要了,这些参数的意义等下在配置到编码器的时候我们再详细解释,继续往下。
override fun start() {if(state == ICodec.State.START) returnstate = ICodec.State.STARTif(enccoder == null){enccoder = MediaCodec.createEncoderByType(params.mime).apply {configure(createMediaFormat(),null,null,MediaCodec.CONFIGURE_FLAG_ENCODE)}inputSurface = enccoder?.createInputSurface()}enccoder?.start()encodeThread = Thread(encodeTask).apply { start() }}
这里我们实现了IVideoEncoder的第一个接口函数,也就是我们启动编码器的接口,函数的第一行代码做了一个保护,防止多次调用同一个编码器的start,如果全局变量enccoder为null就通过MediaCodec.createEncoderByType函数进行创建,创建时需要传入一个String类型的参数,这里我们传入的是params中保存的mime,上面我们也看到了这里的mime是“video/avc”。之后调用编码器configure函数进行了初始化配置,通过上面MediaCodec的生命周期可知,MediaCodec在编解码之前必须要先通过configure函数进行配置,才可以正常使用。
而这里的configure需要传入的参数有点多,我们一个一个看:
第一个参数类型是MediaFormat,createMediaFormat()函数的实际作用就是这里对VideoEncParams进行了转换将其转换成了MediaFormat对象。
第二个参数类型为Surface,是设置解码器的显示Surface我们这里用的是编码器,因此直接设置为null。
第三个参数类型为MediaCrypto,主要是与媒体数据加解密有关,我们这边不需要因此也设置为null。
第四个参数类型为int,设置为MediaCodec.CONFIGURE_FLAG_ENCODE配置MediaCodec为编码器。
现在我们来看下createMediaFormat()函数实现。
private fun createMediaFormat(): MediaFormat {var mediaFormat = MediaFormat.createVideoFormat(params.mime,params.codecWidth,params.codecHeight)//设置比特率mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE,params.bitRate)//设置帧率mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE,params.fps)//设置颜色模式mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT,MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)//设置关键帧频率 帧/秒mediaFormat.setFloat(MediaFormat.KEY_I_FRAME_INTERVAL,params.keyInterval)//设置摄像机旋转角度mediaFormat.setInteger(MediaFormat.KEY_ROTATION,params.rotation)LogPrint.debug("createMediaFormat mediaFormat:$mediaFormat")return mediaFormat}
第一行代码是创建MediaFormat对象,MediaFormat可以理解为媒体数据的描述信息,通过createVideoFormat(),意思是创建一个与视频相关的描述信息对象,这里依然传入了mime,之后两个参数依次传入了需要编码视频的宽高。
MediaFormat设置媒体描述信息的方式是以键值对的方式传入的,键是字符串,值可以是int、long、float、String或ByteBuffer。
这里代码都有注释,就不再额外赘述。
让我们继续回到start()函数,当configure配置结束之后,通过enccoder获取到了一个Surface(inputSurface),该Surface可以理解为编码器的输入队列。
当编码器创建就绪之后,就创建了一个encodeThread线程,那么encodeTask的实现逻辑就是编码的关键逻辑了。
private var encodeTask:Runnable = Runnable {//编码输出信息的对象,赋值由MediaCodec实现var bufferInfo = MediaCodec.BufferInfo()//当前可用的ByteBuffer索引var outputBufferIndex:Intvar encBuf:ByteArraytry {//死循环获取,也可以对MediaCodec设置callback获取while (state == ICodec.State.START) {//获取下一个可用的输出缓冲区,如果存在可用,返回值大于等于0,bufferInfo也会被赋值,最大等待时间是100000微秒,其实就是100毫秒outputBufferIndex = enccoder?.dequeueOutputBuffer(bufferInfo, 100000)!!//如果当前输出缓冲区没有可用的,返回负值,不同值含义不一样,有需要做判定即可if (outputBufferIndex < 0) {continue;}val outputBuffer: ByteBuffer = enccoder?.getOutputBuffer(outputBufferIndex)!!//调整数据位置,从offset开始。这样我们一会儿读取就不用传offset偏差值了。outputBuffer.position(bufferInfo.offset)//改完位置,那肯定要改极限位置吧,不然你数据不就少了数据末尾长度为offset的这一小部分?这两步不做也可以,get的时候传offset也一样outputBuffer.limit(bufferInfo.offset + bufferInfo.size)encBuf = ByteArray(bufferInfo.size)//获取编码数据outputBuffer.get(encBuf, 0, bufferInfo.size)callback?.onCallback(encBuf, bufferInfo.flags)//释放数据,不释放就一直在,MediaCodec数据满了可不行enccoder!!.releaseOutputBuffer(outputBufferIndex, false)}}catch (e:Exception){enccoder?.release()enccoder = nullinputSurface?.release()inputSurface = nullLog.e("VideoEncoder","VideoEncoder error:",e)}}
这块代码稍微有点多,我们来逐行看下,第一行代码是创建了一个BufferInfo对象,该对象主要是用来描述编码数据信息。第二行代码是当前MediaCodec输出buffer的索引位置,不理解的可以再回头看下上面给出的MediaCodec编解码流程图,第三行代码创建了一个ByteArray对象encBuf用于保存MediaCodec编码后的数据。
再往下就是核心的编码代码了,while循环中,通过MediaCodec的dequeueOutputBuffer获取当前可用的编码完成后的Buffer索引,第一个参数是上面我们创建的BufferInfo对象,第二个参数是等待时间,如果获取成功,MediaCodec会将缓存信息保存到BufferInfo对象中,并且返回Buffer的索引。
enccoder?.getOutputBuffer(outputBufferIndex)!!通过index获取输出Buffer。再之后是根据bufferInfo中的描述信息获取正确的缓存数据并保存到我们上面定义的encBuf中。
callback?.onCallback(encBuf, bufferInfo.flags)拿到编码数据之后通过该接口同步出去,这里这个代码可能有点不太合理,因为callback?.onCallback外部使用者如果使用不当可能会影响编码,理论上应该用队列同步出去会更好,这里我们先这样,后续我再进行优化。
while循环最后一行代码就是释放掉当前获取的输出Buffer数据,不释放的话MediaCodec缓存满了可能会出现异常。
再最后如果编码过程中出现异常,就会释放掉当前的编码器,按照生命周期其实通过MediaCodec.reset()也可以,当前这里为了方便,就直接释放了,等下次再start()重新创建即可。
至此编码就已经全部完成了,接下来让我们再加如一些其他的函数,使得VideoCodec更加完善。
override fun stop() {if(state != ICodec.State.START) returnstate = ICodec.State.STOPencodeThread?.interrupt()enccoder?.stop()encodeThread = null}override fun close() {stop()state = ICodec.State.CLOSEenccoder?.release()inputSurface?.release()}override fun getState(): ICodec.State {return state}fun getInputSurface():Surface?{return inputSurface}
stop()和close()分别是停止解码器和释放解码器,stop()之后通过start()还能继续进行编码但是调用close()之后就不能再调用start(),否则就会报错,需要重新创建VideoEncodec进行编码。getState()没什么好说的,就只是返回当前编码器的状态,getInputSurface()将编码器的输入Surface(理解为队列)返回。
现在我们编码器就已经编写完成了,让我们看看如何跟我们的CameraWrapper进行组合编码Camera画面。让我们继续编写一个新的类CameraEncoder,通过这个类让我们把VideoEncodec和CameraWrapper组合起来,用以实现Camera画面编码为H.264。
class CameraEncoder :VideoEncoder.EncoderCallback{private var cameraWrapper: CameraWrapperprivate var videoEncoder:VideoEncoderprivate var callback:VideoEncoder.EncoderCallback? = nullconstructor(context:Context){cameraWrapper = CameraWrapper(context)videoEncoder = VideoEncoder()videoEncoder.setEncoderCallback(this)}fun start(surfaceView: SurfaceView){videoEncoder.start()cameraWrapper.setEncoderSurface(videoEncoder.getInputSurface()!!)cameraWrapper.startPreview(surfaceView)}fun stop(){videoEncoder.stop()cameraWrapper.stopPreview()cameraWrapper.setEncoderSurface(null)}fun close(){cameraWrapper.release()videoEncoder.close()}fun setEncoderCallback(callback:VideoEncoder.EncoderCallback){this.callback = callback}override fun onCallback(data: ByteArray,flags:Int) {callback?.onCallback(data,flags)}
}
因为代码量不多,而且没有什么难度,所以就直接全部贴出来了,在构造函数中同时创建了VideoCodec和CameraWrapper对象,对外的编码数据并不是通过VideoEncoder的回调直返回的,而是通过CameraEncoder的回调间接返回。
启动时先后启动了videoEncoder编码和cameraWrapper预览,特殊一点就是将videoEncoder中的输入Surface也就是我们上面说的可以理解为输入队列的哪个Surface传递到cameraWrapper这样在cameraWrapper预览时会自动关联起来。
停止时也是一样两个对象依次停止,不过在停止之后设置了cameraWrapper在启动时传入的Surface为空,其实也可以不用置空,但个人感觉这样可能会更好一点。
close()就不多说,与上面描述的start和stop逻辑一致。
至此我们Camera画面采集并编码为H.264就已经完成了。将编码后的数据保存到文件,然后通过VLC即可观看。
总结
Camera2与MediaCodec的结合在Android平台上提供了一种强大的视频处理解决方案。Camera2 API负责高效地从摄像头采集原始视频帧,而MediaCodec API则负责将这些帧实时编码为H.264格式,这是目前最广泛支持的视频编码标准之一。这种组合不仅利用了硬件加速来提高编码性能,减少CPU负担,还确保了视频的高质量输出和良好的兼容性。通过精确控制编码参数,可以根据应用需求调整视频的比特率、帧率和分辨率,实现定制化的视频录制和处理。总的来说,Camera2与MediaCodec的协同工作为开发者提供了一个灵活、高效的工具,用于创建和处理高质量的视频内容。
代码依然比较粗糙,但作为启蒙(用词貌似不当),应该是够了,后续随着博主的继续学习将会继续完善,感谢大家观看。
相关文章:

Android Camera2采集并编码为H.264
前言 本篇博文主要讲述的是基于Android原生MediaCodec通过Camera2 API进行图像数据采集并编码为H.264的实现过程,如果对此感兴趣的不妨驻足观看,也欢迎大家大家对本文中描述不当或者不正确的地方进行指正。如果对于Camera2预览还不熟悉的可以观看博主上…...

DHCP和DNS
DHCP(动态主机配置协议)和DNS(域名系统)是计算机网络中两个重要的协议,它们在网络的管理和使用中发挥着关键作用。 DHCP(动态主机配置协议) 基本功能 自动分配IP地址:DHCP允许网…...

ONES 功能上新|ONES Project 甘特图再度升级
ONES Project 甘特图支持展示工作项标题、进度百分比、依赖关系延迟时间等信息。 应用场景: 在使用甘特图规划项目任务、编排项目计划时,可以对甘特图区域进行配置,展示工作项的工作项标题、进度百分比以及依赖关系延迟时间等维度,…...

<工具 Claude Desktop> 配置 MCP server 连接本地 SQLite, 本机文件夹(目录) 网络驱动器 Windows 11 系统
也是在学习中... 起因: 抖音博客 艾克AI分享 他的视频 #143《Claude开源MCP彻底打破AI的信息孤岛》 提到: Claude开源的MCP太强了,视频后面是快速演示,反正看了好几遍也没弄明白。菜单都不一样,感觉用的不是同一家 Claude. 探…...

GIT的使用方法以及汉化方法
1.下载git软件,可以从官网下载 下载后默认安装即可。 2.找到一个文件夹,或者直接打开gitbash gitbash可以使用cd指令切换目录的 打开后输入 git clone https:[git仓库的网页]即可克隆仓库 就是这个地址 克隆后即可使用代码 如果忘记了命令可以使用 -…...

公因子的数目
给你两个正整数 a 和 b ,返回 a 和 b 的 公 因子的数目。 如果 x 可以同时整除 a 和 b ,则认为 x 是 a 和 b 的一个 公因子 。 输入:a 12, b 6 输出:4 解释:12 和 6 的公因子是 1、2、3、6 。 class Solution {pu…...

数据结构(三)——双向链表的介绍以及实现
前言 前面两期数据结构的文章我们介绍了顺序表和单向链表,那么本篇博文我们将来了解双向链表,作为最好用的一种链表,双向链表有什么特殊之处呢,接下来就让我们一起了解一下吧。 下面是前两篇数据结构的文章: 数据结…...

Webpack开发模式及处理样式资源
一、开发模式介绍 开发模式顾名思义就是我们开发代码时使用的模式。 这个模式下我们主要做两件事: 编译代码,使浏览器能识别运行 开发时我们有样式资源、字体图标、图片资源、html 资源等,webpack 默认都不能处理这些资源,所以我…...

leetcode--设计链表
707.设计链表 你可以选择使用单链表或者双链表,设计并实现自己的链表。 单链表中的节点应该具备两个属性:val 和 next 。val 是当前节点的值,next 是指向下一个节点的指针/引用。 如果是双向链表,则还需要属性 prev 以指示链表中的…...

【MySQL】:数据库操作
MySQL 数据库基础理论 2.1 数据库系统概述 介绍数据库系统的基本概念、发展历程、分类及 MySQL 在其中的地位与特点。 2.2 MySQL 数据库体系结构 解析 MySQL 的整体架构,包括服务器层与存储引擎层的功能与交互机制,重点探讨 InnoDB、MyISAM 等存…...

刷蓝桥杯历年考题(更新至15届~)
第十五届 CA组省赛 AcWing5980.训练士兵 方法一:树状数组:O(nlogn) self-complete /*先枚举组团,后分析每个士兵,有一个特点,组团费用是固定的,那当然是让所有士兵一块训练,训练完的士兵也不会有损失当还…...

AI与BI的火花:大语言模型如何重塑商业智能的未来
大家好,我是独孤风。 在当今这个数据驱动的时代,企业对于信息的需求如同对于氧气的需求一般至关重要。商业智能(BI)作为企业获取、分析和呈现数据的关键工具,正在经历一场深刻的变革,而这一变革的催化剂正是…...

Qt 详解QtNFC 读写模式
文章目录 Qt NFC 读写模式详解1. NFC 读写模式简介1.1 什么是 NFC 读写模式?主要功能: 1.2 常见应用场景 2. Qt NFC 读写模式原理3. 配置 QtNFC 模块4. NFC 读写操作实现4.1 NFC 标签读取代码示例功能解析 4.2 NFC 标签写入代码示例功能解析 5. 使用注意…...

增删改查文档
列表 : 列表包含 : 模糊查找 分页 列表jsp页面 : 一 :导入外部文件 (举例 : 用户点进来就可以看到菜单,这是预加载属于,使用文档就绪函数实现) 二 : body 上 ① : 文档就绪函数 ${ function() //获取条件查询的字段 //组装对象 //调用文档就绪函数 } ② : 封装ajax方…...

C语言蓝桥杯2023年省赛真题
文章目录 持续更新中...第一题题目描述输入格式输出格式样例输出提示 2 第二题题目描述 第三题题目描述输入格式输出格式样例输入样例输出 第四题题目描述输入格式输出格式样例输入样例输出提示 第四题题目描述输入格式输出格式样例输入样例输出提示 第五题题目描述输入格式输出…...

Python迭代器-大数据量的处理
一 生成器的实际使用(大量数据的导出) #分批导出数据然后分批写入excel import pandas as pd import openpyxl from openpyxl.utils.dataframe import dataframe_to_rowsdef execute_query(query):# 假设这是执行 SQL 查询的函数# 返回查询结果passdef …...

自动化包括态交互与感交互,而智能化包括势交互与知交互
“自动化包括态交互与感交互,而智能化包括势交互与知交互”交互框架将交互过程划分为不同类型,有助于更清晰地理解自动化和智能化的本质及其在未来agent应用中的差异与联系。 1. 自动化:态交互与感交互 自动化主要关注的是高效、无差错地执行…...

VideoBooth: Diffusion-based Video Generation with Image Prompts
VideoBooth: Diffusion-based Video Generation with Image Prompts 概括 文章提出了一个视频生成模型VideoBooth,输入一张图片和一个文本提示词,即可输出保持图片中物体且符合文本提示词要求的视频。 方法 粗-细两阶段设计:1)…...

模拟简单的iOT工作流
没有实际接触过iOT的流程,应该实际使用比这个接口返回要复杂,只是演示~希望能参与实际的接口接入,而不是只展示个假数据。 启动RabbitQ 使用的是3.8.5 启动命令 RabbitMQ Service - start RabbitMQ Command Prompt rabbitmqctl start_app …...

C++学习0.2: RAII
引用: 【代码质量】RAII在C编程中的必要性_raii 在c中的重要性-CSDN博客 C RAII典型应用之lock_guard和unique_lock模板_raii lock-CSDN博客 前言: 常用的线程间同步/通信(IPC)方式有锁(互斥锁、读写锁、自旋锁)、…...

k8s,进一步理解Pod
比如,凡是调度、网络、存储,以及安全相关的属性,基本上是Pod 级别的。 这些属性的共同特征是,它们描述的是“机器”这个整体,而不是里面运行的“程序”。比如,配置这个“机器”的网卡(即&#…...

MFC图形函数学习13——在图形界面输出文字
本篇是图形函数学习的最后一篇,相关内容暂告一段落。 在图形界面输出文字,涉及文字字体、大小、颜色、背景、显示等问题,完成这些需要系列函数的支持。下面做简要介绍。 一、输出文本函数 原型:virtual BOOL te…...

【Canvas与雷达】点鼠标可暂停金边蓝屏雷达显示屏
【成图】 【代码】 <!DOCTYPE html> <html lang"utf-8"> <meta http-equiv"Content-Type" content"text/html; charsetutf-8"/> <head><title>点鼠标可暂停金边蓝屏雷达显示屏 Draft1</title><style typ…...

React第十二节组件之间通讯之发布订阅模式(使用pubsub-js插件)
组件之间通讯常用方案 1、通过props 2、通过context 3、通过发布订阅模式 4、通过Redux 后面会有专栏介绍 1、安装 pubsub-js 插件 yarn add pubsub-js 常用的事件 a、发布事件:传入一个自定义事件名称(name),以及要发布的消息内…...

Vue3安装 运行教程
本文是综合了所有vue安装教程而成 更细化 更简略 希望对各位读者有所帮助! Vue安装 1. Vue-cli脚手架安装 安装vue的方式有很多 我们这里选择npm方式安装vue npm方式 npm方式安装vue,详细介绍见下文。 1.node.js安装和配置 安装npm 需要安装note.js&…...

MySQL:约束constraint
约束就是表中数据的限制条件. 表在设计的时候加入约束的目的是为了保证表中记录的完整性和有效性,如用户表有些列的值(手机号)不能为空,有些列的值(身份证号)不能重复。 主键约束(primary key) PK MySQL主…...

使用Rufus制作Ubuntu需要注意
在使用Rufus制作Ubuntu启动盘并进行BIOS设置时,需要注意以下几点: 关闭RST(英特尔 快速存储技术):在BIOS设置中,如果电脑启用了RST功能,需要将其关闭。因为Ubuntu可能无法检测到硬盘&a…...

探索Go语言的高级特性:性能分析与安全性
Go语言性能分析与安全性 引言 Go语言因其高效的并发特性、简洁的语法和强大的工具链而受到广泛欢迎。在实际开发中,性能分析和安全性是需要特别关注的两个方面。本文将深入探讨Go语言中的性能分析工具和安全性考虑,帮助开发者编写高效、安全的Go应用程…...

SearchSploit配合gcc的使用
渗透测试中,SearchSploit是一个非常有用的工具,用于在Exploit数据库中搜索漏洞利用代码。其使用方法如下: 安装SearchSploit:首先确保你的系统中已经安装了Kali Linux,因为SearchSploit是Kali Linux的一部分。如果没有…...

无人机设计:云台挂载!
一、无人机云台挂载设置 安装与固定 将云台固定到无人机的挂载点上,通常需要使用专用的固定架和螺丝等工具。 确保云台与无人机之间的连接牢固,避免在飞行过程中出现松动或脱落的情况。 连接与调试 将云台与无人机之间的连接线缆(如电源…...