ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Flutter视频编码库鸿蒙适配实战:从Android到OpenHarmony的迁移指南

Flutter视频编码库鸿蒙适配实战:从Android到OpenHarmony的迁移指南 flutter_quick_video_encoder 这个库在 Flutter 圈子里算是个低调但实用的工具它把视频逐帧编码这件事封装得足够简单让 Dart 层直接调用原生编码能力不用自己写平台通道。但把它搬到 OpenHarmony 上事情就没那么顺了——原生侧的实现要重写FFmpeg 的编译链路要重建帧数据的传递方式也得重新设计。我最近刚把一个短视频合成项目从 Android 迁到鸿蒙中间踩的坑足够写一篇完整的适配记录下面就把整个过程的思路、代码和那些文档里不会写的细节摊开讲。1. 先搞清楚 flutter_quick_video_encoder 到底在做什么1.1 逐帧编码的核心链路这个库的本质是一个帧进文件出的管道。你给它一系列 RGBA 或 YUV 格式的原始帧数据它按指定帧率、码率、分辨率编码成 H.264 视频文件。整个链路拆开看是四段帧数据准备、编码器初始化、逐帧送入、文件封装。在 Android 侧它底层用的是 MediaCodec 硬编码配合 MediaMuxer 做 MP4 封装。iOS 侧走的是 VideoToolbox 加 AVAssetWriter。这两个平台都有成熟的系统级编码 API所以库的 Dart 层接口设计得很薄——基本上就是 createEncoder、encodeFrame、finish 三个核心方法。问题在于 OpenHarmony 没有直接对标 MediaCodec 的公开编码接口。鸿蒙的多媒体框架提供了 AVCodec 相关的 native API但它的调用方式和 Android 差异不小尤其是 Surface 输入模式和 Buffer 输入模式的切换逻辑跟 Android 的思维习惯完全不同。1.2 为什么选这个库而不是自己写有人可能会问既然要适配鸿蒙为什么不干脆自己写一个编码插件。我的判断是如果项目里已经有大量业务代码依赖 flutter_quick_video_encoder 的接口重写意味着所有调用点都要改回归测试成本太高。适配的方案是保持 Dart 层接口不变只替换鸿蒙侧的原生实现这样业务层零改动。另外这个库的帧数据格式约定比较清晰输入支持 RGBA8888 和 YUV420输出统一是 MP4。这种明确的契约让适配工作有章可循不用去猜它的内部行为。1.3 适配工作的边界需要明确的是这次适配不涉及 Dart 层逻辑的修改也不改变库的公开 API。工作范围集中在三块鸿蒙原生插件的工程结构搭建、编码器 native 实现的编写、以及 Flutter 与鸿蒙之间的帧数据通道打通。下面按这个顺序展开。2. 鸿蒙原生插件的工程骨架怎么搭2.1 目录结构与模块划分Flutter 的鸿蒙适配目前走的是 ohos 目录体系。一个标准的插件工程在鸿蒙侧需要包含这几个部分flutter_quick_video_encoder/ ohos/ entry/ src/main/ ets/ components/ FlutterQuickVideoEncoderPlugin.ets cpp/ video_encoder.cpp video_encoder.h napi_init.cpp module.json5 build-profile.json5 CMakeLists.txt这里的关键是 ets 目录放 ArkTS 层的插件注册代码cpp 目录放 native 编码实现。module.json5 里要声明插件的能力和权限CMakeLists.txt 负责把 native 代码编译成动态库。我一开始把编码逻辑全写在 ArkTS 层结果发现帧数据的拷贝开销太大一秒钟 30 帧 1080p 的 RGBA 数据量是 30 × 1920 × 1080 × 4 ≈ 248MBArkTS 层处理这个量级的数据会明显卡顿。后来改成 ArkTS 只做通道转发真正的编码在 native 层完成性能才达标。2.2 module.json5 里的权限声明视频编码涉及文件写入需要在 module.json5 里声明存储权限{ module: { requestPermissions: [ { name: ohos.permission.READ_MEDIA, reason: $string:read_media_reason, usedScene: { abilities: [EntryAbility], when: inuse } }, { name: ohos.permission.WRITE_MEDIA, reason: $string:write_media_reason, usedScene: { abilities: [EntryAbility], when: inuse } } ] } }注意鸿蒙的权限模型比 Android 严格WRITE_MEDIA 属于受限权限需要在应用市场申请对应权限等级。如果只是写入应用沙箱目录可以不用申请这个权限直接用 context.filesDir 路径即可。我建议优先用沙箱路径省去权限申请的麻烦。2.3 CMakeLists.txt 的编译配置native 编码库依赖鸿蒙的 multimedia 框架CMake 配置里要链接对应的 socmake_minimum_required(VERSION 3.4.1) project(flutter_quick_video_encoder) set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR}) include_directories(${NATIVERENDER_ROOT_PATH} ${NATIVERENDER_ROOT_PATH}/include) add_library(quick_video_encoder SHARED video_encoder.cpp napi_init.cpp) target_link_libraries(quick_video_encoder PUBLIC libace_napi.z.so libnative_media_codecbase.so libnative_media_core.so libnative_media_venc.so libnative_media_vdec.so libhilog_ndk.z.so)这里链接的 libnative_media_venc.so 就是鸿蒙的视频编码 native 库。踩过的坑是不同版本的 SDK 里这个库的名字可能有变化我在 API 11 上用的是 libnative_media_venc.so早期版本可能叫 libavcodec_ndk.z.so。编译报找不到库的时候先去 SDK 的 native 目录下确认实际的文件名。3. 鸿蒙视频编码 native 实现的关键细节3.1 编码器初始化的参数映射flutter_quick_video_encoder 在 Dart 层传入的参数包括 width、height、frameRate、bitRate、outputPath。这些参数要映射到鸿蒙 OH_VideoEncoder 的配置结构上。鸿蒙的编码器配置分两步先创建 encoder再配置 format。核心代码如下OH_AVCodec *CreateVideoEncoder(int width, int height, int frameRate, int bitRate) { OH_AVCodec *encoder OH_VideoEncoder_CreateByMime(OH_AVCODEC_MIMETYPE_VIDEO_AVC); if (encoder nullptr) { OH_LOG_Print(LOG_APP, LOG_ERROR, 0xFF00, Encoder, Create encoder failed); return nullptr; } OH_AVFormat *format OH_AVFormat_Create(); OH_AVFormat_SetIntValue(format, OH_MD_KEY_WIDTH, width); OH_AVFormat_SetIntValue(format, OH_MD_KEY_HEIGHT, height); OH_AVFormat_SetIntValue(format, OH_MD_KEY_PIXEL_FORMAT, AV_PIXEL_FORMAT_SURFACE); OH_AVFormat_SetIntValue(format, OH_MD_KEY_FRAME_RATE, frameRate); OH_AVFormat_SetLongValue(format, OH_MD_KEY_BITRATE, bitRate); OH_AVFormat_SetIntValue(format, OH_MD_KEY_VIDEO_ENCODE_BITRATE_MODE, BITRATE_MODE_VBR); OH_AVFormat_SetIntValue(format, OH_MD_KEY_PROFILE, AVC_PROFILE_BASELINE); OH_AVErrCode ret OH_VideoEncoder_Configure(encoder, format); OH_AVFormat_Destroy(format); if (ret ! AV_ERR_OK) { OH_VideoEncoder_Destroy(encoder); return nullptr; } return encoder; }几个参数选择的理由像素格式用 AV_PIXEL_FORMAT_SURFACE 是因为 Surface 模式的性能比 Buffer 模式好尤其在高分辨率下码率模式选 VBR 而不是 CBR是因为短视频场景下画面复杂度变化大VBR 能在简单画面省码率、复杂画面保质量Profile 选 Baseline 是为了兼容性Main 和 High 在某些低端设备上可能不支持。3.2 帧数据从 Dart 到 native 的传递路径这是整个适配里最绕的部分。Dart 层的帧数据是 Uint8List要经过 Flutter 的 MethodChannel 传到 ArkTS再从 ArkTS 传到 native。如果每一帧都走 MethodChannel序列化和反序列化的开销会拖垮性能。我的方案是用共享内存。具体做法是native 层创建一个共享缓冲区把地址通过 MethodChannel 返回给 Dart 层Dart 层用这个地址直接写入帧数据然后发一个轻量的帧就绪通知给 native 层触发编码。// native 层创建共享缓冲区 void* CreateSharedBuffer(int size) { void* buffer malloc(size); return buffer; } // 获取缓冲区地址供 Dart 层使用 napi_value GetBufferAddress(napi_env env, napi_callback_info info) { napi_value result; napi_create_bigint_uint64(env, reinterpret_castuint64_t(g_frameBuffer), result); return result; }Dart 层拿到地址后通过 ffi 的 Pointer 直接写数据import dart:ffi; final address await channel.invokeMethodint(getBufferAddress); final pointer PointerUint8.fromAddress(address); final frameBytes frameData.buffer.asUint8List(); pointer.asTypedList(frameBytes.length).setAll(0, frameBytes); await channel.invokeMethod(encodeFrame);提示共享内存方案需要严格管理生命周期。缓冲区在编码器创建时分配在编码器销毁时释放。如果 Dart 层在编码器销毁后还往缓冲区写数据会直接崩溃。我在 Dart 层加了一个 _isEncoderAlive 标志位来防止这种情况。3.3 Surface 模式下的帧提交鸿蒙的 Surface 模式编码需要先获取一个 input Surface然后把帧数据渲染到这个 Surface 上。这一步跟 Android 的 MediaCodec createInputSurface 逻辑类似但 API 调用方式不同OHNativeWindow *nativeWindow nullptr; OH_VideoEncoder_GetSurface(encoder, nativeWindow); // 启动编码器 OH_VideoEncoder_Start(encoder); // 每帧提交 OHNativeWindow_RequestBuffer(nativeWindow, buffer); // 将帧数据拷贝到 buffer memcpy(buffer-virAddr, frameData, frameSize); OHNativeWindow_FlushBuffer(nativeWindow, buffer);实测下来Surface 模式在 1080p 30fps 的场景下 CPU 占用率比 Buffer 模式低约 15%但延迟会高 1-2 帧。如果是实时性要求高的场景比如直播推流Buffer 模式更合适如果是离线合成比如短视频导出Surface 模式更省电。3.4 编码输出的回调处理编码器每编完一帧会通过回调返回码流数据需要把这些数据写入文件。鸿蒙的回调注册方式OH_VideoEncoder_RegisterCallback(encoder, { .onError OnEncoderError, .onStreamChanged OnStreamChanged, .onNeedInputBuffer OnNeedInputBuffer, .onNewOutputBuffer OnNewOutputBuffer }, nullptr);OnNewOutputBuffer 回调里拿到的是编码后的 H.264 裸流需要自己封装成 MP4。这里我没有用鸿蒙的 muxer而是直接集成了一个轻量的 MP4 封装库因为鸿蒙的 muxer API 在 API 11 上还不够稳定偶尔会出现文件头写入异常的问题。4. 适配过程中踩过的坑和排查过程4.1 编码器创建失败但错误码不明确第一次跑通的时候OH_VideoEncoder_CreateByMime 返回了 nullptr但日志里没有任何有用信息。排查过程是这样的先确认 MIME 类型是否正确鸿蒙支持的是 OH_AVCODEC_MIMETYPE_VIDEO_AVC 和 OH_AVCODEC_MIMETYPE_VIDEO_HEVC我用的 AVC 没问题。然后检查 SDK 版本发现我用的 API 11 SDK 里视频编码功能需要设备支持对应的硬件编码器。用 hdc shell 连上设备执行hidumper -s 3001 -a codec查看设备的编解码能力列表发现测试用的开发板只支持 HEVC 编码不支持 AVC。换成 HEVC 后编码器创建成功。这个坑的教训是鸿蒙设备的硬件编码能力差异很大不能假设所有设备都支持 AVC。稳妥的做法是在创建编码器前先查询设备能力或者做一个 AVC/HEVC 的降级策略。4.2 帧数据错位导致的画面撕裂编码出来的视频前几帧正常后面开始出现画面撕裂和颜色错乱。这个问题排查了两天最后定位到是共享缓冲区的读写没有同步。Dart 层写入帧数据的速度和 native 层读取的速度不一致导致 native 层读到了半新半旧的数据。解决方案是加一个简单的双缓冲机制native 层准备两个缓冲区Dart 层写入 buffer A 时 native 层读 buffer B写完后交换。用一个原子标志位来同步状态。std::atomicint g_writeIndex{0}; void* g_buffers[2]; // Dart 层写入前检查 bool CanWrite() { return !g_encoding.load(); } // native 层编码完成后释放 void OnFrameEncoded() { g_encoding.store(false); }4.3 文件写入权限导致的静默失败编码过程没有任何报错但输出文件是 0 字节。这个问题查了很久最后发现是文件路径的问题。我一开始用的是/data/local/tmp/output.mp4这个路径在鸿蒙上普通应用没有写入权限但文件创建操作没有返回错误只是写入时静默失败。改成应用沙箱路径后正常final context await getApplicationContext(); final outputPath ${context.filesDir}/output.mp4;注意鸿蒙的沙箱路径和 Android 不一样不是 /data/data/包名/files而是通过 context.filesDir 动态获取。硬编码路径在鸿蒙上基本都会出问题。4.4 编码器释放时的崩溃在页面退出时销毁编码器偶发崩溃。日志显示是 OH_VideoEncoder_Destroy 在编码线程还在运行时被调用。修复方式是在销毁前先停止编码器并等待编码线程退出void DestroyEncoder(OH_AVCodec *encoder) { if (encoder nullptr) return; OH_VideoEncoder_Stop(encoder); // 等待编码线程结束 if (g_encodeThread.joinable()) { g_encodeThread.join(); } OH_VideoEncoder_Destroy(encoder); encoder nullptr; }这个问题的本质是生命周期管理编码器的 Stop 和 Destroy 之间必须确保没有正在进行的编码操作。我在 Dart 层也加了对应的保护dispose 方法里先 await 最后一帧的编码完成再调用 native 的销毁。5. 性能调优与实测数据5.1 不同分辨率下的编码耗时在鸿蒙开发板RK3568上实测的数据分辨率帧率编码耗时/帧CPU占用输出码率720p30fps8ms22%2.5Mbps1080p30fps18ms38%5Mbps1080p60fps16ms55%8Mbps4K30fps45ms72%20Mbps720p 和 1080p 30fps 都能做到实时编码4K 在开发板上比较吃力编码耗时超过了帧间隔33ms会出现丢帧。如果目标设备是手机级别的芯片4K 应该能跑到实时。5.2 内存占用的优化初始版本在 1080p 下内存占用峰值到了 180MB主要消耗在帧缓冲区和编码器的内部缓冲。优化措施把帧缓冲区从每帧分配改成预分配复用减少了约 60MB 的峰值占用。编码器的 OH_AVFormat 配置里把 OH_MD_KEY_VIDEO_ENCODER_MAX_B_FRAMES 设为 0禁用 B 帧减少了编码器的参考帧缓冲。最终 1080p 下内存峰值控制在 95MB 左右。5.3 首帧延迟的优化从调用 createEncoder 到第一帧编码完成初始版本耗时约 320ms。优化后降到 150ms 左右。主要的优化点是编码器创建和 Surface 获取并行执行不用等编码器完全就绪再获取 Surface第一帧用关键帧I 帧编码避免等待参考帧。6. 适配后的接口兼容性验证6.1 Dart 层接口保持不变适配的核心原则是业务层零改动。验证方式是拿原有的测试用例直接跑确认 createEncoder、encodeFrame、finish 三个方法的签名和行为完全一致。唯一需要注意的是 finish 方法的返回值Android 版本返回的是文件路径字符串鸿蒙版本也保持一致。6.2 边界情况的处理几个需要特别处理的边界帧数据为空时直接跳过不调用编码器。编码器未初始化时调用 encodeFrame 返回错误码而不是崩溃。finish 调用后再次调用 encodeFrame 要返回明确的错误。输出路径的父目录不存在时自动创建。这些边界在 Android 版本里可能不是问题但鸿蒙的文件系统行为有差异必须显式处理。6.3 多实例并发的限制鸿蒙的硬件编码器数量有限同时创建多个编码器实例会失败。实测在开发板上最多同时创建 2 个 1080p 编码器。如果业务场景需要并发编码建议在应用层做队列管理串行使用编码器。我在项目里加了一个简单的编码器池最多缓存 2 个实例超出时排队等待。这个逻辑放在 Dart 层实现native 层不做并发控制。7. 后续可以继续深挖的方向硬件编码在不同鸿蒙设备上的兼容性还有不少工作要做。目前只测了 RK3568 开发板和一台鸿蒙手机其他芯片平台比如麒麟系列的表现还不确定。建议在编码器创建失败时增加一个软件编码的降级路径用 FFmpeg 的软编兜底虽然性能差一些但能保证功能可用。另外音频编码这块还没碰。flutter_quick_video_encoder 本身只处理视频但实际项目里音视频合成是刚需。鸿蒙的音频编码 API 和视频编码是分开的需要单独适配最后在封装层做音视频同步。这个工作量不小等有实际需求的时候再搞。我在这个适配过程中最大的体会是鸿蒙的多媒体框架还在快速迭代API 的稳定性和文档完整度跟 Android 比还有差距。很多问题只能靠看源码和抓日志来解决社区里能参考的案例也有限。但好处是鸿蒙的 native API 设计思路跟 Android 比较接近有 Android 媒体开发经验的人上手不会太困难主要成本在熟悉 API 细节和踩设备兼容性的坑。
返回列表