HarmonyOS 6实战:从视频编解码到渲染过程,一文了解鸿蒙音视频数据流向

HarmonyOS 6实战:从视频编解码到渲染过程,一文了解鸿蒙音视频数据流向

前阵子我们写了两篇视鸿蒙视频加载的博客,大家的评价还不错。反馈最多的就是生动形象,但其实,虽然我有一定音视频开发基础,但是翻越鸿蒙官网文档时,读起来也是比较晦涩的,处于Demo 能跑,代码也能看懂个大概,但很多术语也是"会用,不真懂"。

比如:

  • Surface 到底是什么?
  • NativeWindowBuffer 有什么区别?
  • 为什么官方文档总在强调 Surface 模式
  • 为什么一提相机、录屏、游戏渲染,很多文章都说"优先考虑 Surface"?

后来继续翻官方文档,突然意识到一件事:

Surface 模式真正厉害的地方,不是"接口高级",而是它更贴合真实的视频数据流向。

简单说:

如果你的视频数据不是从文件里一点一点读出来的,而是从"设备"实时产生出来的,那么 Surface 模式就会特别顺手。

比如:

  • 相机实时拍摄
  • 屏幕录制
  • 游戏 / OpenGL / Vulkan 实时渲染

这几个场景有个共同点:

数据本来就在图形系统或设备流水线上流动。

这时候如果你硬要把数据拽回 CPU 内存,再塞给编码器或显示模块,过程就会多很多冤枉路。

所以今天这篇文章,我想借助官网的教学代码,把我对鸿蒙音视频的理解讲透:

  1. Surface 模式Buffer 模式 到底区别在哪

  2. 为什么相机、录屏、GPU 渲染天然适合 Surface

  3. XComponent / OpenGL / Vulkan 等渲染路径到底有什么区别


视频编解码、视频渲染的区别

很多人会把它们搞混,因为平时聊「看视频」的时候,这两个事儿是串在一起发生的。但底层逻辑上,它们干的是完全不同的活。

我用最直白的方式说清楚:

概念 干的事 输入 输出
编解码 压缩 / 解压缩 原始画面(YUV) / H.264文件 H.264文件 / 原始画面(YUV)
渲染 把画面画到屏幕上 原始画面(YUV/RGB) 屏幕上你看到的图像

编解码管的是「体积」和「存储/传输」。为什么要编码?因为原始视频太大了,一秒钟几十兆,不压缩根本存不下、传不动。

渲染管的是「显示」。解码出来的画面,你得把它画到屏幕上才能看见。画多大?画在哪个位置?要不要旋转、加滤镜?这些都是渲染的事。

举个例子

场景:你刷抖音

  1. 服务器把 H.264 视频传给你 → 网络传输
  2. 解码器 把 H.264 解成 YUV 画面 → 这是解码
  3. 解码后的 YUV 画面交给 GPU/显示系统,画到屏幕上 → 这是渲染

没有解码,你看不了视频;没有渲染,解码出来你也看不见。

场景:你用手机拍视频

  1. 摄像头拍到原始画面 → 采集
  2. 取景框里实时显示这个画面 → 这是渲染
  3. 点录制,原始画面被编码器 压缩成 H.264 → 这是编码
  4. 存到手机里

渲染让你看到自己在拍什么,编码让视频能存下来。

看视频时:解码 + 渲染(先解出来,再画上去)

拍视频时:编码 + 渲染(一边存,一边预览)

视频编辑时:解码 → 渲染(预览)→ 编码(导出)

很多场景下它们确实同时出现,但各干各的活,不是一回事。

编解码 = 压缩/解压缩,管体积;渲染 = 画到屏幕,管显示。

编码器 Surface 模式、Buffer 模式是什么意思?

如果你现在只想记住一句话,请记这个:

数据从哪里来,决定你更适合用 Surface 还是 Buffer。

更具体一点:

  • 相机 / 屏幕 / GPU 实时产生的数据,更适合 Surface 模式
  • 文件里的原始帧、你自己手里已经拿到的 YUV 数据,更适合 Buffer 模式

再接地气一点:

  • Surface 模式:更像"把一个画面通道直接接给下游模块"
  • Buffer 模式:更像"我自己拿着一块内存,把像素数据拷进去,再交给下游"

Surface 是什么

如果你把它想成"屏幕"也不完全对。

更准确一点,它像是一个"图像生产和消费的接口面板"。

谁往这个面板上写图像,谁就是生产者;谁从这个面板上取图像,谁就是消费者。

在图形/媒体系统里,常见的生产者可能是:

  • 相机
  • 解码器
  • OpenGL
  • Vulkan
  • 屏幕采集模块

常见的消费者可能是:

  • 编码器
  • 显示系统
  • XComponent
  • OpenGL / Vulkan 的纹理系统

所以 Surface 的本质不是"一个组件",而是一个图像交接站

NativeWindow 是什么

在 HarmonyOS / OpenHarmony 这套体系里,OHNativeWindow 经常就是应用层拿来和 Surface 打交道的"把手"。

你可以粗暴理解成:

  • Surface 是图像通道
  • NativeWindow 是操作这个通道的句柄

官方文档里对视频解码也明确提到了一个核心点:Surface 输出是通过 OHNativeWindow 来传递输出数据的,可以和其他模块对接,比如 XComponent

这句话特别关键,因为它直接解释了为什么这个项目能把解码后的画面送到 XComponent,或者送进 NativeImage 再让 OpenGL / Vulkan 去接管。

Buffer 又是什么

Buffer 就好理解多了。

它本质上是一块数据内存。

如果你走 Buffer 模式,通常意味着:

  1. 你能拿到共享内存地址
  2. 你要自己处理像素内容
  3. 你需要自己决定什么时候写入、什么时候释放

所以 Buffer 更像"自己扛着一箱像素数据到处跑"。

而 Surface 更像"把货直接从传送带送到下一个工位"。


为什么相机、录屏、GPU 渲染特别适合 Surface 模式

这部分是最容易一下子想明白的。

场景一:相机拍视频

你打开抖音、快手、小红书,点击录制,画面来自哪里?

来自相机。

相机预览帧是实时产生的,不是从文件里读的。

如果走 Surface 模式,思路会很顺:

  1. 编码器给你一个 Surface
  2. 你把这个 Surface 交给相机
  3. 相机直接往这个 Surface 里写图像
  4. 编码器直接从这个 Surface 取数据编码

这套链路的好处是:

  • 少一次 CPU 参与
  • 少一次内存拷贝
  • 少一次额外分配
  • 理论上更省电、更低发热

如果不用 Surface,会怎样?

你就得:

  1. 从相机拿一帧 YUV
  2. 拷到自己的内存
  3. 再塞给编码器

这就相当于本来可以走传送带的货,你非要自己搬一趟。

场景二:屏幕录制

录屏的本质是什么?

是把屏幕上正在显示的内容变成视频。

这类数据本来就在图形系统里流动。如果能直接在图形链路里交接给编码器,就很自然。

官方文档在 AVScreenCapture 的介绍里提到,录屏能力会通过图形图像服务捕获屏幕数据,再进行编码封装。

这里我做一个明确标注:

下面这句是基于官方文档和工程经验的推断。

对录屏这类"图形系统实时生成画面"的场景,Surface 思路天然更合理,因为它更符合"画面在图形栈里直接流动"的模型,而不是把每一帧先拖回 CPU 再处理。

场景三:OpenGL / Vulkan / 游戏画面

游戏或者特效视频的画面,很多时候是 GPU 渲染出来的。

这时候你要做录制或编码,最怕什么?

最怕把 GPU 上的结果再拉回 CPU。

为什么?

因为这很贵。

你可以把它想成:

  • 本来画面已经在 GPU 工厂生产线上了
  • 结果你非要把成品先搬回仓库点一遍数
  • 然后再送回另一个 GPU 车间

这当然浪费。

所以对 GPU 实时渲染场景来说,Surface 模式的优势会更明显。


Surface 模式 vs Buffer 模式区别

这里我结合官方文档,把核心差异翻译成更容易理解的版本。

数据交付方式不同、数据不同

  • Surface 输出:通过 OHNativeWindow 把图像往下传
  • Buffer 输出:通过共享内存把图像数据给你

这两种模式,不是"一个高级一个低级",而是"交货方式不同"。

在 Buffer 模式下,你通常可以拿到:

  • buffer 的地址
  • 实际像素数据
  • 数据信息

在 Surface 模式下,你更多拿到的是:

  • buffer 的描述信息
  • 对应的 surface / window 交接能力

但你未必能像 Buffer 模式那样直接拿像素地址去随便改。

生命周期控制不同

官方文档对视频解码给了一个很重要的区别:

  • Surface 模式 下,可以选择调用 OH_VideoDecoder_FreeOutputBuffer() 丢弃输出帧,不送显
  • Buffer 模式 下,应用必须调用 OH_VideoDecoder_FreeOutputBuffer() 释放数据

为什么会有这个差异?

因为 Surface 模式更像"图像已经交给显示链路了",你可以选择显示,也可以选择丢掉。

而 Buffer 模式更像"这块数据在你手上",你必须自己负责释放。

解码器在 Surface 模式下必须先绑窗口

官方文档还强调了一个关键点:

在视频解码的 Surface 模式 下,解码器就绪前必须先调用 OH_VideoDecoder_SetSurface() 设置 OHNativeWindow

启动之后,开发者可以:

  • 调用 OH_VideoDecoder_RenderOutputBuffer() 显示并释放解码帧
  • 或调用 OH_VideoDecoder_RenderOutputBufferAtTime() 在指定时间点显示并释放

如果你要做音画同步或控制播放节奏,官方建议优先考虑 RenderOutputBufferAtTime

这一点,和我们当前仓库里的实现是可以直接对上的。


解码出来的画面,怎么交给渲染管线?

上面给的几个场景:

  • 相机拍摄
  • 屏幕录制
  • OpenGL 渲染

它们主要讲的是:编码器输入侧的 Surface 模式

也就是:

text 复制代码
相机 / 屏幕 / GPU
  -> Surface
  -> 编码器

而接下来我们讲解渲染过程:解码器输出侧的 Surface 模式

也就是:

text 复制代码
视频文件
  -> 解复用
  -> 解码器
  -> Surface / NativeWindow
  -> XComponent 或 OpenGL / Vulkan

别看一边是"编码输入",一边是"解码输出",它们背后的核心思想其实是一样的:

让图像尽可能在图形系统内部直接流动,减少不必要的 CPU 介入和内存搬运。

而图像渲染,也就是我们的入门XComponent 路线:最直接,也最适合做第一站理解

这条路径最简单:

text 复制代码
文件 -> 解复用 -> 解码 -> 直接写到 XComponent 对应的 NativeWindow

它几乎不做额外加工,所以特别适合用来建立第一层理解:

  • 我的视频从哪里来
  • 解码器怎么工作
  • 画面为什么能出来

XComponent 满足不了需求时,搭建NAPI 层

我们参考官网的cpp文件实现:

cpp 复制代码
static napi_value Init(napi_env env, napi_value exports)
{
    NativeXComponentSample::PluginManager::GetInstance()->RenderConfig(env, exports);
    napi_property_descriptor desc[] = {
        {"playNative", nullptr, NativeXComponentSample::PluginRender::StartPlayer, nullptr, nullptr, nullptr, napi_default, nullptr},
        {"stopNative", nullptr, NativeXComponentSample::PluginRender::StopPlayer, nullptr, nullptr, nullptr, napi_default, nullptr}
    };
    napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
    return exports;
}

这里暴露了两类能力:

  • playNative / stopNative
  • RenderConfig

前者负责播放控制,后者负责拿到 XComponent 对应的 native 能力并注册回调。

这一步可以理解成:

ArkTS 负责"发指令",C++ 负责"接管现场"。


分叉点:解码器输出的 Surface 绑给谁?

下面是一个经典的函数, PluginRender::StartPlayer()

cpp 复制代码
PluginRender *render = PluginRender::GetInstance(type);
if (render != nullptr) {
    if (type == "OpenGL") {
        sampleInfo.window = render->openGLRenderThread_->GetNativeImageWindow();
    } else if (type == "Vulkan") {
        sampleInfo.window = render->vulkanRenderThread_->GetNativeImageWindow();
    } else {
        sampleInfo.window = render->nativeWindow;
    }
}
int32_t ret = Player::GetInstance().Init(sampleInfo);
Player::GetInstance().Start();

它决定了三条路径的本质差异:

  • XComponent:直接把解码结果写到页面窗口
  • OpenGL:把解码结果写到 NativeImage 的窗口
  • Vulkan:把解码结果写到 NativeImage 的窗口

所以你会发现:

播放器主流程其实没怎么变,变的是解码结果的交付对象。


cpp 复制代码
int32_t ret = Configure(sampleInfo);
...
if (sampleInfo.window != nullptr) {
    int ret = OH_VideoDecoder_SetSurface(decoder_, sampleInfo.window);
    if (ret != AV_ERR_OK) {
        OH_LOG_ERROR(LOG_APP, "Set surface failed, ret: %{public}d", ret);
        return AVCODEC_SAMPLE_ERR_ERROR;
    }
}
...
ret = SetCallback(codecUserData);
...
ret = OH_VideoDecoder_Prepare(decoder_);

把这段代码和官方文档放在一起看,就非常通了。

官方文档说:

  • Surface 模式下,解码器就绪前要先 SetSurface
  • 启动后再选择 RenderOutputBufferRenderOutputBufferAtTime

项目里就是这么做的:

  1. 先给解码器绑定 window
  2. Prepare
  3. Start
  4. 最后在输出线程里释放 / 送显

这时候你就会明白,OH_VideoDecoder_SetSurface() 不是一个普通配置项,它其实是在回答一个很核心的问题:

"这帧解码出来的图,到底要交给谁?"


第一次读官网案例,很多人可能有疑问:

"不是都已经有解码器了吗?为什么还要自己开线程?"

答案是:因为解码器本身就是回调驱动的,线程是为了把回调变成稳定的消费流水线。

初始化和启动逻辑:

cpp 复制代码
videoDecoder_ = std::make_unique<VideoDecoder>();
demuxer_ = std::make_unique<Demuxer>();
int32_t ret = demuxer_->Create(sampleInfo_);
...
ret = CreateVideoDecoder();
...
ret = videoDecoder_->Start();
...
videoDecInputThread_ = std::make_unique<std::thread>(&Player::VideoDecInputThread, this);
videoDecOutputThread_ = std::make_unique<std::thread>(&Player::VideoDecOutputThread, this);

你可以把它想成两个工人:

  • 输入线程负责喂压缩帧
  • 输出线程负责拿解码结果并送显
cpp 复制代码
CodecBufferInfo bufferInfo = videoDecContext_->inputBufferInfoQueue.front();
videoDecContext_->inputBufferInfoQueue.pop();
...
demuxer_->ReadSample(demuxer_->GetVideoTrackId(), reinterpret_cast<OH_AVBuffer *>(bufferInfo.buffer),
                     bufferInfo.attr);

int32_t ret = videoDecoder_->PushInputBuffer(bufferInfo);

就是标准动作:

  • 从 demuxer 读压缩数据
  • 塞给 decoder
cpp 复制代码
CodecBufferInfo bufferInfo = videoDecContext_->outputBufferInfoQueue.front();
videoDecContext_->outputBufferInfoQueue.pop();
...
int32_t ret = videoDecoder_->FreeOutputBuffer(bufferInfo.bufferIndex, true);
...
std::this_thread::sleep_until(lastPushTime + std::chrono::microseconds(sampleInfo_.frameInterval));

注意这里的 true

它表示:

释放输出 buffer 的同时,把这帧真正渲染出去。

这就和官方文档里说的"Surface 模式下可以选择送显或丢帧"完全对上了。


回调队列这块,特别适合理解成"取号叫号系统"

cpp 复制代码
void SampleCallback::OnNeedInputBuffer(OH_AVCodec *codec, uint32_t index, OH_AVBuffer *buffer, void *userData) {
    CodecUserData *codecUserData = static_cast<CodecUserData *>(userData);
    std::unique_lock<std::mutex> lock(codecUserData->inputMutex);
    codecUserData->inputBufferInfoQueue.emplace(index, buffer);
    codecUserData->inputCond.notify_all();
}

void SampleCallback::OnNewOutputBuffer(OH_AVCodec *codec, uint32_t index, OH_AVBuffer *buffer, void *userData) {
    CodecUserData *codecUserData = static_cast<CodecUserData *>(userData);
    std::unique_lock<std::mutex> lock(codecUserData->outputMutex);
    codecUserData->outputBufferInfoQueue.emplace(index, buffer);
    codecUserData->outputCond.notify_all();
}

如果你觉得"回调 + 队列 + 条件变量"很抽象,那我换个说法:

  • 解码器说:"我现在有空 buffer,谁来喂我?"
  • 输入线程说:"行,我来"
  • 解码器说:"我现在有输出帧了,谁来处理?"
  • 输出线程说:"行,我来"

这不就是医院取号叫号系统吗?

这样一想,整个模型就不神秘了。


渲染过程进阶------OpenGL 纹理化

OpenGL Shader编写、纹理化是最常见的一类应用。通过片段着色器(Fragment Shader),可以逐像素地修改画面的颜色、亮度等,实现各种艺术效果。例如基础色彩调整:实时调整视频的亮度、对比度、饱和度、色相等,(视频美颜)。

或者实现风格化滤镜或光效:应用预设的算法,瞬间改变视频的视觉风格,例如灰度、复古、怀旧(Sepia)、冷暖色调等。模拟如发光(Glow/Lighting)、光晕(Bloom)、热浪扭曲(Heat Shimmer)等复杂的光影效果,增强画面的氛围感

OpenGL 这条路的关键思路是:

text 复制代码
解码器
  -> NativeImage
  -> 外部纹理
  -> Shader
  -> XComponent

这比直接送 XComponent 多了一层"纹理化"的过程。

OpenGLRenderThread.cpp

cpp 复制代码
nativeImage_ = OH_NativeImage_Create(-1, GL_TEXTURE_EXTERNAL_OES);
...
nativeImageWindow_ = OH_NativeImage_AcquireNativeWindow(nativeImage_);
...
nativeImageFrameAvailableListener_.onFrameAvailable = &OpenGLRenderThread::OnNativeImageFrameAvailable;
ret = OH_NativeImage_SetOnFrameAvailableListener(nativeImage_, nativeImageFrameAvailableListener_);

这一步的意义是:

  • 解码器可以把图像写进 NativeImage
  • OpenGL 可以把它当成 GL_TEXTURE_EXTERNAL_OES 使用

于是图像就从"解码结果"变成了"可被 GPU 采样的纹理"。

再把图像更新成纹理

cpp 复制代码
OH_NativeImage_AttachContext(nativeImage_, nativeImageTexId_);

int32_t ret = OH_NativeImage_UpdateSurfaceImage(nativeImage_);
...
ret = OH_NativeImage_GetTransformMatrix(nativeImage_, matrix_);
...
DrawVideoImage();

这几句连起来看,非常有代表性:

  1. NativeImage 绑到 OpenGL 上下文
  2. 把最新图像刷到纹理
  3. 取变换矩阵
  4. 真正绘制

特效场景喜欢 OpenGL

因为一旦画面成了纹理,你能做的事情就多了。

比如:

  • 灰度
  • 镜像
  • 色调调整
  • 模糊
  • LUT 滤镜
  • 贴纸
  • 转场

所以 OpenGL 路线不是"更绕",而是"给你多了一个可编程加工环节"。


渲染过程进阶------Vulkan 底层操作GPU 图像渲染

这部分我其实并不太理解官网的介绍,我以我之前做3D开发的经验做个补充,Vulkan、OpenGL是两种不同的底层驱动,是不同的渲染协议,Vulkan更适合高复杂度精细化场景,它能让你榨干硬件性能,特别适合复杂的3A级游戏(但是用在视频渲染上,我反倒想不起什么好的应用场景)。

追求快速开发、需要兼容大量旧设备,选 OpenGL。它足够简单,且拥有无可比拟的兼容性和海量的学习资料。

Vulkan 路线比 OpenGL 更像"全手动搭舞台",我对于Vulkan的了解甚少,所以不做展开介绍。

cpp 复制代码
nativeWindow_ = nativeWindow;
CreateInstance();
vkExample::utils::LoadVulkanFunctions(instance);
CreateSurface();
PickPhysicalDevice();
CreateLogicalDevice();
vkExample::utils::LoadVulkanFunctions(device);

createSwapChain();
createRenderPass();
createFrameBuffersAndImages();
createVertexBuffer();
createUniformBuffer();

这段代码特别像在准备演出现场:

  • 先搭实例
  • 再连窗口 surface
  • 再选设备
  • 再建交换链
  • 再建 render pass

这也是 Vulkan 为什么让人觉得"繁琐"的原因。

它不是故意麻烦你,而是把很多默认行为都显式交给你了。

cpp 复制代码
ret = OH_NativeImage_AcquireNativeWindowBuffer(nativeImage_, &inBuffer, &fenceFd1);
ret = OH_NativeBuffer_FromNativeWindowBuffer(inBuffer, &nativeBuffer);
...
vulkanRenderContext_->hwBufferToTexture(nativeBuffer, matrix_);
vulkanRenderContext_->render();

OpenGL 路线里,图像主要是"更新成纹理后直接采样"。

Vulkan 路线里,图像更像是"把底层原生缓冲区导进 Vulkan 的资源体系里"。

总结

很多人卡在音视频和图形开发,不是因为不会写代码,而是因为术语把人吓住了。

其实你把这些词换成熟悉的画面,就没那么难了:

  • Surface:图像交接站
  • NativeWindow:交接站的把手
  • Buffer:你自己扛着走的数据箱子
  • OpenGL:可编程渲染流水线
  • Vulkan:更底层、更手动的 GPU 调度系统

在教程的时候,脑子里不要思考"某个 API 怎么调",而是"同一帧视频图像,如何走进不同的渲染世界"。思考数据流向

  • 先看谁发起播放
  • 再看谁决定走哪条渲染路径
  • 再看解码器怎么把图像交出去
  • 最后看 OpenGL / Vulkan 怎么接手

如果你以后再看到 Surface 模式 这四个字,别再把它当成冷冰冰的接口术语。

你可以直接把它翻译成:

"让图像在图形系统内部直接流动,少绕 CPU 的弯路。"

当你这么理解之后,当前这个项目、相机录制、录屏、OpenGL 特效、Vulkan 渲染,就会突然连成一张完整的地图。

相关推荐
云_杰2 小时前
手把手教你玩转HDS沉浸光感效果
华为·harmonyos·ui kit
HwJack203 小时前
HarmonyOS 开发终结“盲盒式”调试:用 hiAppEvent 的 Watcher 接口拿捏应用行为监控
华为·harmonyos
互联网散修3 小时前
鸿蒙实战:用 want.param 实现视频播放器跨端迁移续播
华为·音视频·harmonyos·跨端迁移续播
特立独行的猫a4 小时前
HarmonyOS / OpenHarmony 平台三方库移植:使用 vcpkg 移植 libzen(ZenLib)和 libmediainfo 实践指南
harmonyos·移植·三方库·libmediainfo·libzen·openharmnoy
枫叶丹44 小时前
【HarmonyOS 6.0】ArkWeb 私有网络访问控制接口详解
开发语言·网络·华为·harmonyos
HwJack204 小时前
告别冷启动“白屏焦虑”:HarmonyOS应用 aboutToAppear 高性能优化全攻略
华为·性能优化·harmonyos
互联网散修4 小时前
鸿蒙实战:分布式数据对象实现本地、网络视频跨端迁移续播
分布式·harmonyos·跨端迁移
前端不太难5 小时前
鸿蒙游戏中的 Service 层应该怎么拆?
游戏·状态模式·harmonyos