HarmonyOS 6.1 云应用客户端适配实战(二):Native Window 视频渲染

前言

在上一篇文章中,我们完成了 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 渲染

主要差异:

  1. 窗口获取方式 :HarmonyOS 通过 SurfaceId 创建 Native Window
  2. API 命名OH_NativeWindow_* vs ANativeWindow_*
  3. 头文件路径native_window/external_window.h vs android/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 Window
  • onLoad 回调: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;
}

重点难点分析:

  1. 黑屏问题: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);  // ✅
  2. 配置选择

    • 必须指定 EGL_OPENGL_ES3_BIT(使用 OpenGL ES 3.0)
    • 颜色深度至少 8 位
    • 对于视频渲染,不需要深度缓冲和模板缓冲
  3. 线程安全

    • 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 显示黑屏,没有视频画面

排查步骤:

  1. 检查 Native Window 是否创建成功

    cpp 复制代码
    OHNativeWindow* window = OH_NativeWindow_CreateNativeWindowFromSurfaceId(surfaceId);
    LOGI("Native window: %p", window);  // 应该非空
  2. 检查 EGL 初始化

    cpp 复制代码
    LOGI("EGL display: %p", mEglDisplay);
    LOGI("EGL surface: %p", mEglSurface);
    LOGI("EGL context: %p", mEglContext);
    // 都应该非空
  3. 检查 viewport 设置

    cpp 复制代码
    GLint viewport[4];
    glGetIntegerv(GL_VIEWPORT, viewport);
    LOGI("Viewport: %d,%d %dx%d", viewport[0], viewport[1], viewport[2], viewport[3]);
    // 宽高应该与屏幕匹配,不应该是 1x1
  4. 检查是否收到解码帧

    cpp 复制代码
    void HandleNewFrame(EventOneVideoFrameDecoded* evData) {
        LOGI("Received frame: %dx%d format=%d", 
             evData->Width, evData->Height, (int)evData->MCPixfmt);
        // 应该持续打印
    }

7.2 颜色异常问题

问题表现: 视频颜色偏绿、偏红或灰暗

可能原因:

  1. 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
  2. 色彩空间矩阵错误

    • 使用 BT.601 矩阵解码 BT.709 视频会导致颜色偏差
    • 根据视频元数据选择正确的矩阵

7.3 性能问题

问题表现: 视频播放卡顿、延迟高

优化方案:

  1. 使用多线程解码

    cpp 复制代码
    mCodecCtx->thread_count = 4;
    mCodecCtx->thread_type = FF_THREAD_FRAME;
  2. 减少内存拷贝

    cpp 复制代码
    // 使用共享内存或 DMA buffer
    // 避免在 CPU 和 GPU 之间频繁拷贝
  3. 调整帧缓冲队列大小

    cpp 复制代码
    // 适当增加队列大小,平滑网络抖动
    common::Queue<cloudapp::Message> mVideoPacketQueue(30);

八、总结

本文详细介绍了 HarmonyOS 视频渲染的完整实现流程:

  1. ArkTS 层:使用 XComponent 提供 Native 渲染表面
  2. N-API 层:从 Surface ID 创建 Native Window
  3. EGL 层:初始化 OpenGL ES 渲染上下文
  4. OpenGL 层:YUV 纹理渲染和色彩空间转换
  5. FFmpeg 层:H.264/H.265 软解码

关键难点:

  • viewport 尺寸必须通过 eglQuerySurface 查询
  • YUV 平面顺序和对齐要求
  • 色彩空间转换矩阵的选择
  • 多线程渲染的同步问题

下一篇预告:

下一篇将介绍触摸输入的适配,包括坐标系转换、Windows SendInput 协议适配等内容。

参考资料


作者: Frame Not Work

日期: 2026年6月

系列文章: HarmonyOS 6.1 云应用客户端适配实战

相关推荐
2023自学中2 小时前
imx6ull 开发板 推流ov5640数据,虚拟机用 ffplay 拉流播放
linux·音视频·嵌入式·开发板
天天进步20152 小时前
Python全栈项目--基于深度学习的视频目标跟踪系统
python·深度学习·音视频
G_dou_2 小时前
Flutter三方库适配OpenHarmony【coin_flip】抛硬币动画项目完整实战
flutter·harmonyos
再见6583 小时前
HarmonyOS NEXT 实战:从零开发一款「随笔记」应用
华为·harmonyos
EasyDSS4 小时前
私有化音视频系统/视频高清直播点播EasyDSS重塑企业视频门户新生态
音视频
再见6584 小时前
HarmonyOS NEXT 实战:从零开发一个专业秒表应用
华为·harmonyos
byte轻骑兵5 小时前
【LE Audio】CAP精讲[13]: Central侧LE连接建立全流程解析
人工智能·音视频·cap·le audio·低功耗音频
EasyDSS5 小时前
视频直播点播/音视频点播/云点播/云直播EasyDSS一体化音视频平台赋能企业数字化转型
音视频
想你依然心痛6 小时前
HarmonyOS 6(API 23)实战:打造“光码智学舱“——AI编程学习新范式
学习·ar·ai编程·harmonyos·智能体