前言
在上一篇文章中,我们完成了 HarmonyOS 开发环境的搭建和编译系统的配置。本文将深入介绍视频渲染部分的适配工作,这是云应用客户端的核心功能之一。
本文涉及的技术栈:
- Native Window API(HarmonyOS 特有)
- EGL(OpenGL ES 渲染上下文)
- OpenGL ES 3.0(GPU 渲染)
- FFmpeg(软解码)
- YUV420P → RGB 转换
一、Android vs HarmonyOS 渲染架构对比
1.1 Android 渲染流程
Surface (Java)
↓
ANativeWindow (JNI)
↓
EGL Surface
↓
OpenGL ES 渲染
1.2 HarmonyOS 渲染流程
XComponent (ArkTS)
↓
SurfaceId → OH_NativeWindow (N-API)
↓
EGL Surface
↓
OpenGL ES 渲染
主要差异:
- 窗口获取方式 :HarmonyOS 通过
SurfaceId创建 Native Window - API 命名 :
OH_NativeWindow_*vsANativeWindow_* - 头文件路径 :
native_window/external_window.hvsandroid/native_window.h
二、ArkTS 层:XComponent 配置
2.1 XComponent 基本配置
在 HarmonyOS 中,使用 XComponent 来提供 Native 渲染表面:
typescript
// ControlPage.ets
@Entry
@Component
struct ControlPage {
private xComponentController: XComponentController = new XComponentController()
private nativeWindowPtr: number = 0
private dlcaPlayerId: number = -1
build() {
Column() {
XComponent({
id: 'video_surface',
type: XComponentType.SURFACE,
controller: this.xComponentController
})
.width('100%')
.height('100%')
.backgroundColor('#000000')
.onLoad(() => {
console.log('[ControlPage] XComponent loaded')
this.initNativeWindow()
})
}
}
initNativeWindow() {
try {
// 获取 Surface ID
const surfaceId = this.xComponentController.getXComponentSurfaceId()
console.log('[ControlPage] Surface ID:', surfaceId)
// 调用 N-API 创建 Native Window
this.nativeWindowPtr = dlcaPlayer.createNativeWindowFromSurfaceId(Number(surfaceId))
console.log('[ControlPage] Native window ptr:', this.nativeWindowPtr)
// 初始化播放器
this.initPlayer()
} catch (e) {
console.error('[ControlPage] Failed to init native window:', (e as Error).message)
}
}
}
关键点:
XComponentType.SURFACE:提供 Native 渲染能力getXComponentSurfaceId():获取表面 ID,用于创建 Native WindowonLoad回调:XComponent 准备好后才能获取 Surface ID
2.2 监听尺寸变化
typescript
XComponent({...})
.onAreaChange((oldArea, newArea) => {
this.localWidth = Number(newArea.width)
this.localHeight = Number(newArea.height)
console.log('[ControlPage] Area changed:', this.localWidth, 'x', this.localHeight)
// 通知 Native 层更新视口
if (this.nativeWindowPtr > 0) {
dlcaPlayer.updateViewport(this.dlcaPlayerId, this.localWidth, this.localHeight)
}
})
三、N-API 层:创建 Native Window
3.1 N-API 接口实现
cpp
// dlca_player_napi.cc
#include <native_window/external_window.h>
static napi_value CreateNativeWindowFromSurfaceId(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value args[1];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
// 获取 Surface ID
uint64_t surfaceId;
napi_get_value_int64(env, args[0], (int64_t*)&surfaceId);
// 创建 Native Window
OHNativeWindow* window = OH_NativeWindow_CreateNativeWindowFromSurfaceId(surfaceId);
if (!window) {
LOGE("Failed to create native window from surface id: %llu", surfaceId);
napi_value result;
napi_create_int64(env, 0, &result);
return result;
}
LOGI("Created native window: %p from surface id: %llu", window, surfaceId);
// 返回指针(作为 int64)
napi_value result;
napi_create_int64(env, (int64_t)window, &result);
return result;
}
// 注册 N-API 函数
EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports) {
napi_property_descriptor desc[] = {
{"createNativeWindowFromSurfaceId", nullptr, CreateNativeWindowFromSurfaceId,
nullptr, nullptr, nullptr, napi_default, nullptr},
// ... 其他接口
};
napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
return exports;
}
EXTERN_C_END
关键 API:
OH_NativeWindow_CreateNativeWindowFromSurfaceId():从 Surface ID 创建窗口- 返回的是
OHNativeWindow*指针,通过 N-API 传递给 ArkTS
3.2 窗口属性查询
cpp
// 查询窗口尺寸
int32_t width = 0, height = 0;
OH_NativeWindow_NativeWindowHandleOpt(window, GET_BUFFER_GEOMETRY, &width, &height);
LOGI("Native window size: %dx%d", width, height);
四、Native 层:EGL 初始化
4.1 GLView 架构设计
cpp
// GLViewBase.h - 跨平台抽象基类
class GLViewBase : public View {
public:
virtual bool InitEgl() = 0;
virtual bool InitTexture() = 0;
virtual void HandleNewFrame(EventOneVideoFrameDecoded* data) = 0;
protected:
EGLDisplay mEglDisplay;
EGLSurface mEglSurface;
EGLContext mEglContext;
int mWidth, mHeight;
};
// GLViewOHOS.h - HarmonyOS 特定实现
class GLViewOHOS : public GLViewBase {
public:
GLViewOHOS(CloudClient* ctx, OHNativeWindow* window);
bool InitEgl() override;
bool InitTexture() override;
private:
OHNativeWindow* mNativeWindow;
};
4.2 EGL 初始化实现
cpp
// GLViewOHOS.cpp
#include <EGL/egl.h>
#include <EGL/eglext.h>
#include <GLES3/gl3.h>
bool GLViewOHOS::InitEgl() {
// 1. 获取 EGL Display
mEglDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (mEglDisplay == EGL_NO_DISPLAY) {
LOGE("eglGetDisplay failed");
return false;
}
// 2. 初始化 EGL
EGLint major, minor;
if (!eglInitialize(mEglDisplay, &major, &minor)) {
LOGE("eglInitialize failed");
return false;
}
LOGI("EGL version: %d.%d", major, minor);
// 3. 选择配置
EGLint configAttribs[] = {
EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT,
EGL_RED_SIZE, 8,
EGL_GREEN_SIZE, 8,
EGL_BLUE_SIZE, 8,
EGL_ALPHA_SIZE, 8,
EGL_DEPTH_SIZE, 0,
EGL_STENCIL_SIZE, 0,
EGL_NONE
};
EGLConfig config;
EGLint numConfigs;
if (!eglChooseConfig(mEglDisplay, configAttribs, &config, 1, &numConfigs)) {
LOGE("eglChooseConfig failed");
return false;
}
// 4. 创建窗口表面
mEglSurface = eglCreateWindowSurface(mEglDisplay, config,
mNativeWindow, nullptr);
if (mEglSurface == EGL_NO_SURFACE) {
LOGE("eglCreateWindowSurface failed: 0x%x", eglGetError());
return false;
}
// 5. 创建 OpenGL ES 上下文
EGLint contextAttribs[] = {
EGL_CONTEXT_CLIENT_VERSION, 3,
EGL_NONE
};
mEglContext = eglCreateContext(mEglDisplay, config,
EGL_NO_CONTEXT, contextAttribs);
if (mEglContext == EGL_NO_CONTEXT) {
LOGE("eglCreateContext failed");
return false;
}
// 6. 绑定上下文
if (!eglMakeCurrent(mEglDisplay, mEglSurface, mEglSurface, mEglContext)) {
LOGE("eglMakeCurrent failed");
return false;
}
// 7. 查询表面尺寸(关键!)
eglQuerySurface(mEglDisplay, mEglSurface, EGL_WIDTH, &mWidth);
eglQuerySurface(mEglDisplay, mEglSurface, EGL_HEIGHT, &mHeight);
LOGI("EGL surface size: %dx%d", mWidth, mHeight);
// 8. 设置视口
glViewport(0, 0, mWidth, mHeight);
return true;
}
重点难点分析:
-
黑屏问题:viewport 未正确设置
cpp// 错误:使用固定值或未初始化的变量 glViewport(0, 0, 1, 1); // ❌ 导致黑屏 // 正确:从 EGL Surface 查询实际尺寸 eglQuerySurface(mEglDisplay, mEglSurface, EGL_WIDTH, &mWidth); eglQuerySurface(mEglDisplay, mEglSurface, EGL_HEIGHT, &mHeight); glViewport(0, 0, mWidth, mHeight); // ✅ -
配置选择
- 必须指定
EGL_OPENGL_ES3_BIT(使用 OpenGL ES 3.0) - 颜色深度至少 8 位
- 对于视频渲染,不需要深度缓冲和模板缓冲
- 必须指定
-
线程安全
- EGL 上下文与线程绑定
- 渲染必须在同一线程进行
五、OpenGL ES 纹理渲染
5.1 纹理初始化
cpp
// GLViewOHOS.cpp
bool GLViewOHOS::InitTexture() {
// 创建 Sprite2D(渲染精灵)
mVideoFrameSprite = std::make_shared<Sprite2D>(ctx_);
// 初始化着色器和几何体
if (!mVideoFrameSprite->Init(false)) { // false = 不翻转 Y 轴
LOGE("Failed to init sprite");
return false;
}
// 创建 YUV 纹理
GLuint textures[3]; // Y, U, V
glGenTextures(3, textures);
for (int i = 0; i < 3; i++) {
glBindTexture(GL_TEXTURE_2D, textures[i]);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
}
mVideoFrameSprite->SetTexture(textures, 3);
mVideoFrameSprite->SetTexturePixfmt(ETextureFormat::kYUV420P);
LOGI("Texture initialized successfully");
return true;
}
5.2 YUV420P 着色器
顶点着色器:
glsl
#version 300 es
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec2 aTexCoord;
out vec2 TexCoord;
uniform mat4 model;
void main() {
gl_Position = model * vec4(aPos, 1.0);
TexCoord = aTexCoord;
}
片段着色器(YUV → RGB):
glsl
#version 300 es
precision mediump float;
in vec2 TexCoord;
out vec4 FragColor;
uniform sampler2D texY;
uniform sampler2D texU;
uniform sampler2D texV;
void main() {
float y = texture(texY, TexCoord).r;
float u = texture(texU, TexCoord).r - 0.5;
float v = texture(texV, TexCoord).r - 0.5;
// BT.709 色彩空间转换
float r = y + 1.5748 * v;
float g = y - 0.1873 * u - 0.4681 * v;
float b = y + 1.8556 * u;
FragColor = vec4(r, g, b, 1.0);
}
色彩空间转换矩阵:
- BT.709(HD):大多数现代视频使用
- BT.601(SD):老旧视频标准
- 使用错误的矩阵会导致颜色偏差
5.3 帧渲染流程
cpp
void GLViewOHOS::HandleNewFrame(EventOneVideoFrameDecoded* evData) {
if (!mVideoFrameSprite) {
LOGE("Sprite not initialized");
return;
}
// 1. 设置纹理格式(如果变化)
if (evData->MCPixfmt != mCurrentPixfmt) {
mVideoFrameSprite->SetTexturePixfmt(evData->MCPixfmt);
mCurrentPixfmt = evData->MCPixfmt;
LOGI("Texture format changed to: %d", (int)evData->MCPixfmt);
}
// 2. 调整精灵大小(如果视频分辨率变化)
if (evData->Width != mVideoWidth || evData->Height != mVideoHeight) {
mVideoFrameSprite->Resize(evData->Width, evData->Height);
mVideoWidth = evData->Width;
mVideoHeight = evData->Height;
LOGI("Video size changed to: %dx%d", mVideoWidth, mVideoHeight);
}
// 3. 上传纹理数据
mVideoFrameSprite->SetTextureData(
evData->Data,
evData->DataSize,
evData->Width,
evData->Height
);
// 4. 渲染
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
mVideoFrameSprite->Draw();
// 5. 交换缓冲区
eglSwapBuffers(mEglDisplay, mEglSurface);
}
六、FFmpeg 软解码集成
6.1 解码器初始化
cpp
// FFmpegDecoder.cpp
bool FFmpegDecoder::Init(cloudapp::Frame_Type codecType, EDecodeMode::Type mode) {
// 查找解码器
AVCodecID codecId = AV_CODEC_ID_NONE;
if (codecType == cloudapp::Frame_Type_H264) {
codecId = AV_CODEC_ID_H264;
} else if (codecType == cloudapp::Frame_Type_H265) {
codecId = AV_CODEC_ID_HEVC;
}
const AVCodec* codec = avcodec_find_decoder(codecId);
if (!codec) {
LOGE("Codec not found: %d", codecId);
return false;
}
// 创建解码上下文
mCodecCtx = avcodec_alloc_context3(codec);
if (!mCodecCtx) {
LOGE("Failed to allocate codec context");
return false;
}
// 设置解码选项
mCodecCtx->thread_count = 4; // 多线程解码
mCodecCtx->thread_type = FF_THREAD_FRAME;
// 打开解码器
if (avcodec_open2(mCodecCtx, codec, nullptr) < 0) {
LOGE("Failed to open codec");
return false;
}
LOGI("Decoder initialized: %s", codec->name);
mInited = true;
return true;
}
6.2 解码流程
cpp
int FFmpegDecoder::Decode(std::shared_ptr<cloudapp::Message> msg) {
auto frame = &msg->frame();
// 1. 准备数据包
AVPacket* packet = av_packet_alloc();
packet->data = (uint8_t*)frame->data().data();
packet->size = frame->data().size();
packet->pts = frame->frame_index();
// 2. 发送到解码器
int ret = avcodec_send_packet(mCodecCtx, packet);
if (ret < 0) {
LOGE("Error sending packet to decoder: %d", ret);
av_packet_free(&packet);
return -1;
}
// 3. 接收解码帧
while (ret >= 0) {
AVFrame* avFrame = av_frame_alloc();
ret = avcodec_receive_frame(mCodecCtx, avFrame);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
av_frame_free(&avFrame);
break;
}
if (ret < 0) {
LOGE("Error receiving frame from decoder: %d", ret);
av_frame_free(&avFrame);
return -1;
}
// 4. 转换为应用层帧格式
EventOneVideoFrameDecoded* evData = new EventOneVideoFrameDecoded();
evData->Width = avFrame->width;
evData->Height = avFrame->height;
evData->MCPixfmt = ETextureFormat::kYUV420P; // 关键:设置格式
// 复制 YUV 数据
int ySize = avFrame->width * avFrame->height;
int uvSize = ySize / 4;
evData->DataSize = ySize + uvSize * 2;
evData->Data = new uint8_t[evData->DataSize];
memcpy(evData->Data, avFrame->data[0], ySize); // Y
memcpy(evData->Data + ySize, avFrame->data[1], uvSize); // U
memcpy(evData->Data + ySize + uvSize, avFrame->data[2], uvSize); // V
// 5. 投递到渲染线程
mCloudClient->view_->PostEvent(kEventOneVideoFrameDecoded, evData);
av_frame_free(&avFrame);
}
av_packet_free(&packet);
return 0;
}
关键点:
- 必须设置
evData->MCPixfmt = ETextureFormat::kYUV420P - YUV 平面数据连续存储:Y → U → V
- 解码是异步的,使用
send_packet/receive_frame模型
七、常见问题与解决方案
7.1 黑屏问题排查
问题表现: XComponent 显示黑屏,没有视频画面
排查步骤:
-
检查 Native Window 是否创建成功
cppOHNativeWindow* window = OH_NativeWindow_CreateNativeWindowFromSurfaceId(surfaceId); LOGI("Native window: %p", window); // 应该非空 -
检查 EGL 初始化
cppLOGI("EGL display: %p", mEglDisplay); LOGI("EGL surface: %p", mEglSurface); LOGI("EGL context: %p", mEglContext); // 都应该非空 -
检查 viewport 设置
cppGLint viewport[4]; glGetIntegerv(GL_VIEWPORT, viewport); LOGI("Viewport: %d,%d %dx%d", viewport[0], viewport[1], viewport[2], viewport[3]); // 宽高应该与屏幕匹配,不应该是 1x1 -
检查是否收到解码帧
cppvoid HandleNewFrame(EventOneVideoFrameDecoded* evData) { LOGI("Received frame: %dx%d format=%d", evData->Width, evData->Height, (int)evData->MCPixfmt); // 应该持续打印 }
7.2 颜色异常问题
问题表现: 视频颜色偏绿、偏红或灰暗
可能原因:
-
UV 平面顺序错误
cpp// 错误:U 和 V 顺序颠倒 memcpy(evData->Data + ySize, avFrame->data[2], uvSize); // V memcpy(evData->Data + ySize + uvSize, avFrame->data[1], uvSize); // U // 正确 memcpy(evData->Data + ySize, avFrame->data[1], uvSize); // U memcpy(evData->Data + ySize + uvSize, avFrame->data[2], uvSize); // V -
色彩空间矩阵错误
- 使用 BT.601 矩阵解码 BT.709 视频会导致颜色偏差
- 根据视频元数据选择正确的矩阵
7.3 性能问题
问题表现: 视频播放卡顿、延迟高
优化方案:
-
使用多线程解码
cppmCodecCtx->thread_count = 4; mCodecCtx->thread_type = FF_THREAD_FRAME; -
减少内存拷贝
cpp// 使用共享内存或 DMA buffer // 避免在 CPU 和 GPU 之间频繁拷贝 -
调整帧缓冲队列大小
cpp// 适当增加队列大小,平滑网络抖动 common::Queue<cloudapp::Message> mVideoPacketQueue(30);
八、总结
本文详细介绍了 HarmonyOS 视频渲染的完整实现流程:
- ArkTS 层:使用 XComponent 提供 Native 渲染表面
- N-API 层:从 Surface ID 创建 Native Window
- EGL 层:初始化 OpenGL ES 渲染上下文
- OpenGL 层:YUV 纹理渲染和色彩空间转换
- FFmpeg 层:H.264/H.265 软解码
关键难点:
- viewport 尺寸必须通过
eglQuerySurface查询 - YUV 平面顺序和对齐要求
- 色彩空间转换矩阵的选择
- 多线程渲染的同步问题
下一篇预告:
下一篇将介绍触摸输入的适配,包括坐标系转换、Windows SendInput 协议适配等内容。
参考资料
作者: Frame Not Work
日期: 2026年6月
系列文章: HarmonyOS 6.1 云应用客户端适配实战