背景
面对鸿蒙这一全新的生态,广大消费者在积极尝鲜的同时,家中不可避免会出现安卓设备和鸿蒙设备并存的现象,短期内可能不会形成全鸿蒙的生态环境。因此,在未来的一段时间内,鸿蒙设备和安卓设备共存的现象会比较普遍。那么为了给用户带来更加流畅的全场景体验,鸿蒙和安卓设备之间的交互就显得格外重要。
家庭合影美颜相机
家庭合影美颜相机应用是同时基于鸿蒙和安卓设备的应用,可以实现鸿蒙大屏借助安卓手机的能力进行美颜拍照的功能,其中安卓端使用了GitHub上的开源项目。具体来说,此应用能够将鸿蒙大屏拍摄的视频数据实时传输到安卓手机上;并在安卓端为其添加滤镜,再将处理后的视频数据传回到鸿蒙大屏进行渲染显示,从而达到鸿蒙大屏进行美颜拍照的功能,其效果可以参考下图1:
图1 家庭合影美颜相机应用的效果示意图
应用运行后的动态场景效果可以参考图2,图中上方横屏显示的是鸿蒙手机,下方竖屏显示的是安卓手机。此处需要说明的是,由于实验环境缺少搭载鸿蒙系统的大屏设备,因此我们使用鸿蒙手机替代大屏设备模拟实验场景。
图2 应用运行后的效果
应用成功运行后的效果如下:
- 在鸿蒙大屏设备上开启摄像头访问权限,点击主菜单界面的“点击发送大屏数据”按钮,即可将大屏拍摄到的视频数据通过RTP协议发送到安卓手机端。
- 在安卓手机端点击主菜单界面的“GLCAMERAVIEW”按钮,即可接收上述鸿蒙大屏传来的视频数据,并将视频数据显示在手机屏幕上。
- 安卓端在接收到视频后,会将数据实时渲染到手机屏幕上,用户可以选择给视频添加各种风格的滤镜;
- 安卓端会通过RTP协议将添加滤镜后的视频数据传输到鸿蒙端进行显示。
上述已经介绍过,此应用是同时基于鸿蒙和安卓设备的,因此在讲解此应用时不仅会包含鸿蒙相关知识,同时也会涉及到一些安卓方面的知识。此应用包含4个功能模块,可参考图3,分别是:视频编解码、通讯协议、美颜滤镜和视频渲染。其中每个模块都会涉及到不同的技术点,如视频编解码会涉及视频流格式和编解码器参数设置;通讯协议会涉及UDP、RTP协议等。后续我们的文章将会按不同模块进行讲解和发布,敬请期待!
图3 家庭合影美颜相机功能模块图
视频编解码应用案例解析
本期文章将介绍视频编解码模块,视频编解码是视频处理的基础,鸿蒙平台为我们提供了强大的视频处理能力。为了更具体地讲解该模块功能,我们将家庭合影美颜相机应用中涉及实现视频编解码的代码独立拆分出来形成了一个视频编解码Demo,将在后续进行效果展示和实现原理讲解。
下面以拆分出来的视频编解码模块Demo为例,先向大家讲解鸿蒙视频编解码的具体实现原理,再对鸿蒙和安卓两者视频编解码的原理差异进行分析。
1、运行效果和代码结构
视频编解码Demo的运行效果如图4所示。开始运行后,会获取摄像头的权限,然后会在界面中间的矩形区域显示摄像头拍摄到的画面,此时用户可以点击界面上的“开始编解码”按钮,即可在原始视频的下方的矩形区域中看到编解码后视频的渲染效果。
图4 视频编解码Demo运行效果图
接着介绍一下视频编解码Demo的代码结构,如图5所示。其中MainAbilitySlice类用于页面布局和实例化编解码器;我们还构建了VDEecoder类和VDDecoder类,前者用于视频编码并对编码过程进行监听,将编码后的数据送去解码,后者用于视频解码,对解码过程进行监听,输出解码后的数据。
图5 视频编解码Demo代码结构
2、实现流程解析
下面讲解此Demo实现视频编解码效果的具体实现流程,共分为7个步骤:
步骤1. 创建整体显示布局。
步骤2. 实例化编码类VDEncoder的对象并初始化编码器。
步骤3. 获取相机数据并将其加入编码队列。
步骤4. 初始化解码器。
步骤5. 设置Button监听事件,执行编码操作。
步骤6. 监听编码器,获取编码后的数据并送去解码。
步骤7. 执行解码操作。
(1)创建整体显示布局
在MainAbilitySlice中,定义用于控制编解码的Button按钮控件、用于显示编解码状态的Text文本控件、两个分别用于显示摄像头拍摄的视频和编解码后视频的SurfaceProvider画面渲染控件,并设置上述控件的相关属性,如图4效果所示。
(2)实例化编码类VDEncoder的对象并初始化编码器
实例化编码VDEncoder类对象,使用带有参数framerate的构造函数,其中framerate代表帧速率,此处设为15。
- VDEncoder vdEncoder = new VDEncoder(15);// 创建编码类对象
在构造函数中,需要进行编码器初始化操作,如设置编码器格式如图像大小、比特率、颜色格式、帧率、关键帧间隔时间、比特率模式等。需要注意的是,要选择合适的比特率、帧率等参数,不然极有可能出现编解码后视频显示不出来或显示效果异常的情况。在设置完各属性参数后,通过set()方法将上述格式属性配置到编码器对象中;再设置监听用于获取编码输出数据;使用start()方法控制编码器开始执行;并初始化自定义的单例线程池用于编码线程,由于摄像头获取到的数据会被按顺序放入视频队列YUVQueue中,因此需要使用线程来提高处理效率。
- public VDEncoder(int framerate){
- Format fmt = new Format();// 创建编码器格式
- fmt.putStringValue("mime", "video/avc");
- fmt.putIntValue("width", 640);// 视频图像宽度
- fmt.putIntValue("height", 480);// 视频图像高度
- fmt.putIntValue("bitrate", 392000);// 比特率
- fmt.putIntValue("color-format", 21);// 颜色格式
- fmt.putIntValue("frame-rate", framerate);// 帧率
- fmt.putIntValue("i-frame-interval", 1);// 关键帧间隔时间
- fmt.putIntValue("bitrate-mode", 1);// 比特率模式
- mCodec.setCodecFormat(fmt);// 设置编码器格式
- mCodec.registerCodecListener(encoderlistener);// 设置监听
- mCodec.start();// 编码器开始执行
- singleThreadExecutor = new SingleThreadExecutor();// 初始化自定义单例线程池
- }
(3)获取相机数据并将其加入编码队列
在正式开始编码之前,需要通过相机的图像监听事件ImageReceiver.IImageArrivalListener,获取实时返回的原生视频数据并将其存放在ByteBuffer类对象中,再逐个读取成byte数组的形式,存储在YUV_DATA中。
- private final ImageReceiver.IImageArrivalListener imagerArivalListener = new ImageReceiver.IImageArrivalListener() {
- @Override
- public void onImageArrival(ImageReceiver imageReceiver) {// 当相机开始运行后,用于监听,实时返回视频原始数据
- mLog.log("imagearival", "arrival");
- Image mImage = imageReceiver.readNextImage();// 用于读取视频画面
- if(mImage != null){
- ByteBuffer mBuffer;
- byte[] YUV_DATA = new byte[VIDEO_HEIGHT * VIDEO_WIDTH * 3 / 2];// 存放从相机获取的原始 YUV 视频数据
- ...
- // 从相机获取实时拍摄的视频数据,并将 Image 读取到的视频流数据存放在 mBuffer
- mBuffer = mImage.getComponent(ImageFormat.ComponentType.YUV_Y).getBuffer();
- // 从视频流mBuffer逐个读取成 byte 数组的形式,并存储在 YUV_DATA 中
- for(i=0;i< VIDEO_WIDTH * VIDEO_HEIGHT;i++){
- YUV_DATA[i] = mBuffer.get(i);
- }
- ...
- vdEncoder.addFrame(YUV_DATA);// 将视频数据 YUV_DATA 加入到队列等待编解码
- mImage.release();// 获取完视频数据之后及时释放
- return;
- }
- }
- };
(4)初始化解码器
通过VDEncoder类对象调用prepareDecoder()方法,初始化解码器,并将用于显示编解码后视频的SurfaceProvider对象作为入参传入方法中。
- vdEncoder.prepareDecoder(surfaceview2);
在VDEncoder类的prepareDecoder()方法中,先实例化解码VDDecoder类对象,并使用SurfaceProvider类对象surfaceview显示编解码后的视频,再调用start()方法控制开始解码。
- public void prepareDecoder(SurfaceProvider surfaceview){
- vdDecoder = new VDDecoder(surfaceview);// 创建解码类对象,并使用surfaceview显示解码后的视频
- vdDecoder.start();// 开始解码
- }
视频解码的初始化方法beginCodec()与编码初始化实现原理类似,也需要对各种格式进行配置,此处不再进行赘述,唯一不同之处是帧率和关键帧间隔时间的设置,具体含义可参考下面代码中的注释信息。
- private synchronized void beginCodec() {//初始化解码器各参数
- System.out.println("isSurfaceCreated = " + Boolean.toString(isSurfaceCreated));
- if (isSurfaceCreated) {
- isSurfaceCreated = false;
- Format fmt = new Format();// 创建解码器格式
- fmt.putStringValue("mime", "video/avc");
- fmt.putIntValue("width", 640);// 视频图像宽度
- fmt.putIntValue("height", 480);// 视频图像高度
- fmt.putIntValue("bitrate", 392000);// 比特率
- fmt.putIntValue("color-format", 21);// 颜色格式
- fmt.putIntValue("frame-rate", 30);// 帧率
- fmt.putIntValue("i-frame-interval", -1);// 关键帧间隔时间
- fmt.putIntValue("bitrate-mode", 1);// 比特率模式
- mCodec.setCodecFormat(fmt);// 设置解码器格式
- mCodec.registerCodecListener(decoderlistener);// 设置监听
- mCodec.start();// 解码器开始执行
- isMediaCodecInit = true;
- }
- }
(5)设置Button监听事件,执行编码操作
为整体显示布局中用于控制是否开始编解码的Button按钮设置onCilick()点击事件,调用VDEncoder类对象的start()方法控制开始编码。判断如果编码正在进行,则显示当前编码状态。
- button.setClickedListener(component -> {// 按钮被点击
- mLog.log("button", "start");
- vdEncoder.start();// 开始编码
- if(vdEncoder.isRuning){// 如果编码正在进行,显示当前编码状态
- text.setText("成功进行编解码,并显示在下方");
- }
- });
在具体执行编码操作的线程中,会先调用Codec类的getAvailableBuffer()方法在指定索引处获取编码时的可用缓冲区ByteBuffer,其中参数timeout表示用于填充有效数据的缓冲区索引;再创建缓冲区信息BufferInfo,注意ByteBuffer和BufferInfo要成对使用,调用setInfo()方法设置相关信息如偏移量、数据长度、时间戳和缓冲区类型;接着将数据通过put()方法放入缓冲区ByteBuffer中;并通过Codec类的WriteBuffer()方法将传入的ByteBuffer和BufferInfo进行处理。
- private void startEncoderThread() {
- singleThreadExecutor.execute(new Runnable() {
- @Override
- public void run() {
- byte[] data;
- while (isRuning) {
- try {
- data = YUVQueue.take();// 从队列中获取原相机得到的原生视频数据
- } catch (InterruptedException e) {
- e.printStackTrace();
- break;
- }
- // 将数据以 Buffer 和 BufferUnfo 的形式通过 Codec 类进行编码
- ByteBuffer buffer = mCodec.getAvailableBuffer(-1);
- BufferInfo bufferInfo = new BufferInfo();//与ByteBuffer成对使用
- buffer.put(data);//将数据放入缓冲区
- bufferInfo.setInfo(0, data.length, System.currentTimeMillis(), 0);//设置数据相关信息
- mCodec.writeBuffer(buffer, bufferInfo);//对缓冲区数据进行处理
- }
- }
- });
- }
(6)监听编码器,获取编码后的数据并送去解码
设置编码器监听事件,监听编码器行为。重写onReadBuffer()方法获取编码后的输出缓冲区ByteBuffer和缓冲区信息BufferInfo,通过ByteBuffer类对象调用get()方法获得输出数据,并存放在byte数组data中;再通过当前解码类对象vdDecoder调用toDecoder()方法,即可将完成编码后的视频数据送去解码。
- private Codec.ICodecListener encoderlistener = new Codec.ICodecListener() {
- // 用于监听编码器,获取编码完成后的数据
- @Override
- public void onReadBuffer(ByteBuffer byteBuffer, BufferInfo bufferInfo, int i) {
- byte[] data = new byte[bufferInfo.size];
- byteBuffer.get(data);// 从编码器的 byteBuffer 中获取数据
- mLog.log("pushdata", "encoded data:" + data.length);
- vdDecoder.toDecoder(data);// 通过解码类的 toDecoder()方法,将编码完成的视频数据送去解码
- }...
- };
(7)执行解码操作
通过decoder()方法执行解码操作,其原理和上述讲解过的编码原理相同。使用Codec类的getAvailableBuffer()方法在指定索引处获取解码时的可用缓冲区ByteBuffer;创建缓冲区信息BufferInfo,调用setInfo()方法设置相关信息并将数据放入缓冲区ByteBuffer、WriteBuffer()方法处理传入的ByteBuffer和BufferInfo。在完成解码之后,通过事件监听类获得输出数据,并按需对解码后的视频数据进行画面渲染显示等相关操作。
- private void decoder(byte[] video) {//解码器具体执行流程
- ByteBuffer mBuffer = mCodec.getAvailableBuffer(-1);
- BufferInfo info = new BufferInfo();//与ByteBuffer成对使用
- info.setInfo(0, video.length, 0, 0);//设置数据相关信息
- mBuffer.put(video);//将数据放入缓冲区
- mCodec.writeBuffer(mBuffer, info);//对缓冲区数据进行处理
- }
鸿蒙编解码器Codec和安卓编解码器MediaCodec的区别
MediaCodec类作为安卓多媒体基础框架的一部分,通过访问底层的媒体编解码器,即编解码器组件,来实现对于音视频的编解码功能。MediaCodec共支持4种数据类型,分别是原始的音、视频数据,和压缩后的音、视频数据。鸿蒙平台的Codec编解码类同样支持上述提到的4种数据类型,相比较安卓平台的MediaCodec类,二者区别主要体现在使用方式上,即输出数据的获取方式和Index缓冲区索引的使用。先来对比观察一下鸿蒙和安卓编解码实现原理的代码:
- //鸿蒙Codec编解码:
- private void decoder(byte[] video) {//将数据以 Buffer 和 BufferUnfo 的形式通过 Codec 类进行解码
- ByteBuffer mBuffer = mCodec.getAvailableBuffer(-1);
- BufferInfo info = new BufferInfo();
- info.setInfo(0, video.length, 0, 0);
- mBuffer.put(video);//数据放入
- mCodec.writeBuffer(mBuffer, info);
- }
- //鸿蒙监听类
- private Codec.ICodecListener decoderlistener = new Codec.ICodecListener() {
- // 用于监听编码器,获取解码完成后的数据
- @Override
- public void onReadBuffer(ByteBuffer byteBuffer, BufferInfo bufferInfo, int i) {
- byte[] bytes = new byte[bufferInfo.size];//自定义数组用来存放输出数据
- byteBuffer.get(bytes);// 从缓冲区的 byteBuffer 中获取数据
- }
- };
- //安卓MediaCodec编解码:
- ByteBuffer[] inputBuffers = mediaCodec.getInputBuffers();
- ByteBuffer[] outputBuffers = mediaCodec.getOutputBuffers();
- //放入处理数据
- int inputBufferIndex = mediaCodec.dequeueInputBuffer(-1);
- ByteBuffer inputBuffer = mediaCodec.getInputBuffer(inputBufferIndex);//获取编码器传入数据ByteBuffer
- inputBuffer.clear();//清除以前数据
- inputBuffer.put(PCMbuffer);//PCMbuffer需要编码器处理数据
- mediaCodec.queueInputBuffer(inputBufferIndex, 0, inputBuffer.limit(), 0, 0);//通知编码器,数据放入
- //处理完成数据
- int outputBufferIndex = mediaCodec.dequeueOutputBuffer(timeoutUs);
- while (outputBufferIndex >= 0) {
- outputBuffers = mediaCodec.getOutputBuffer(outputBufferIndex );//获取编码数据
- //outputBuffer 编码器处理完成的数据
- mediaCodec.releaseOutputBuffer(outputBufferIndex , false);//告诉编码器数据处理完成
- outputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 1000);//可能一次放入的数据处理会输出多个数据
- }
(1)获取输出数据的方式
先简单解释一下安卓中编解码器的原理,可结合图6理解。当请求(或接收)一个空的输入缓冲区(input buffers)时,先将待处理的数据填充到这个缓冲区,并将其发送到编解码器进行处理;然后编解码器将处理完成后的数据填充到空的输出缓冲区(output buffers);这样就可以请求(或接收)输出缓冲区中的数据,在数据获取完成之后再将缓冲区释放掉。 在请求输出缓冲区数据的过程中,通过while循环验证输出缓冲区的索引(outputBufferIndex)是否大于等于0,当满足上述条件时,表示可以读取输出缓冲区的数据,否则一直等待输出缓冲区的数据。
图6 安卓编解码器原理图(来源于网络,侵权必删)
在鸿蒙中,也需要使用输入缓存区(mBuffer)和输出缓存区(byteBuffer)来装载数据,但是在请求输出缓冲区数据的过程中,鸿蒙采用了编解码监听的方式。通过ICodecListener类来监听编解码器的数据输出,当可以从输出缓存区获取输出数据时,可在重写方法onReadBuffer(ByteBuffer byteBuffer, BufferInfo bufferInfo, int i)中获取数据,在数据获取完成之后再将缓冲区释放掉。
(2)Index缓冲区索引的使用
安卓与鸿蒙端的另一区别是,安卓在处理输入数据时还使用了一组对应的dequeueInputBuffer()和queueInputBuffer()方法用来处理输入数据流,标记缓冲区索引。其中,dequeueInputBuffer()用于返回输入缓冲区的索引;queueInputBuffer()用于告知编码器数据已经被放入指定的输入缓冲区中,这样才可以正确释放输入缓冲区。同理,在处理输出数据时也会使用一组实现原理相同的方法dequeueOutputBuffer()和queueOutputBuffer(),此处不进行赘述。
鸿蒙端并未采用上述缓冲区索引的概念,因此视频编解码的过程更加流畅精简。