欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_KLite

本文是「鸿蒙 PC 移植」系列的又一篇。这次的主角不是某个开发工具,而是一个对性能、格式覆盖面、音画同步都最挑剔的品类——本地媒体播放器。

目标:把 K-Lite Codec Pack + MPC-HC 在 Windows 上的核心能力——全格式解码、字幕、多音轨、播放列表、倍速——在 HarmonyOS PC(2in1/平板)上复刻出来,而且零权限、单 HAP、不打包任何闭源运行时。

最终效果先看图:这是一个原生 ArkUI 界面 + 原生 C++ 引擎(libyingxia.so)的应用,FFmpeg 7.1.1 与字幕链以静态库形式链入,APK 体积 19MB。

播放效果

项目名「影匣」,仓库 [ohos_KLite],下文按「为什么不能直接移植 → 怎么取舍 → 关键实现(含代码)→ 一个真实的暂停 bug → QA」展开。


在这里插入图片描述

一、为什么 K-Lite 「直接移植」

K-Lite 常被叫「解码器包」,但它并不是一个开源项目,而是一个 Windows 平台的二进制分发聚合包:解码层是 LAV Filters(GPL-2.0,把 FFmpeg 包装成 DirectShow COM 组件),播放层是 MPC-HC(GPL-3.0,MFC/Win32 桌面播放器),外加 madVR、xy-VSFilter 等生态组件,安装包的本质工作是写注册表、注册 COM 滤镜。

把它搬上鸿蒙,有五个「搬不动」的地方:

不可移植点上游依赖鸿蒙侧的现实
界面框架MFC / Win32 消息循环没有 Win32 API,只有 ArkUI 声明式 UI
编解码接入DirectShow COM 滤镜图没有 COM,也没有全局滤镜注册表
视频渲染Direct3D 9/11、EVR、madVR提供 EGL / GLES3 + NativeWindow,要自己画
音频输出DirectSound / WASAPI提供 OH_AudioRenderer 回调式接口
分发方式系统级安装、写注册表单 HAP 沙箱,禁止子进程和未打包动态库

而且 K-Lite 整包本身是闭源分发物。结论:代码一行都搬不了,能搬的只有「能力」。 所以先定义能力清单:全格式解码、字幕、多音轨、播放列表、倍速、零配置可用——然后逐项找归宿。

启动界面:点击右上角打开文件

二、迁移策略:原生宿主 + 集成运行时(T1)

我对每个组件做了归属决策,这是整张表的精华:

组件 / 能力上游来源归属在影匣中的形态
主界面、播放列表、面板MPC-HC(MFC)重写Index.ets(ArkUI)
播放编排、时钟、队列、同步MPC-HC 骨架重写player_engine.cpp(三线程流水线)
解封装 / 解码LAV Filters → FFmpeg交叉编译集成libavformat/codec/... 5 个静态库
字幕排版libass交叉编译集成libass.a
字体栅格化 / 文本整形 / 双向文字FreeType / HarfBuzz / FriBiDi交叉编译集成3 个静态库
DirectShow 滤镜图系统删除直接调 FFmpeg C API
视频呈现EVR / madVR替换EGL + GLES3 + XComponent
音频呈现DirectSound / WASAPI替换OH_AudioRenderer
文件访问Win32 / 注册表替换系统picker + fd + 自定义 AVIO

这条路线我叫它 T1——原生宿主 + 集成运行时:界面与系统能力是原生的,解码与字幕内核来自上游开源库的源码交叉编译,二者在 NAPI 边界处缝合。它比「套壳」重,但换来可审计的依赖、可控的性能和真正的原生体验;比「全量重写」轻,是因为解码器的格式覆盖面(几百种组合)根本不是一个人能重写出来的——这恰恰是 FFmpeg 三十年积累的价值,拿来主义要拿得心安理得。

整体架构一图流:

在这里插入图片描述

三、零权限的文件访问:picker + fd + 自定义 AVIO

鸿蒙应用不能像 Windows 程序那样遍历磁盘,我也不想申请媒体库权限。方案是:系统 picker 用户授权 → 拿 fd → 引擎自持 → 喂给 FFmpeg 的自定义 AVIO。

ArkTS 侧只有几行:

import { picker, fileIo as fs } from '@kit.CoreFileKit';

const docPicker = new picker.DocumentViewPicker();
const uris = await docPicker.select({
  maxSelectNumber: 20,
  fileSuffixFilters: ['.mp4', '.mkv', '.avi', '.mov', '.flv', '.ts', ...]
});
const file = fs.openSync(uris[0], fs.OpenMode.READ_ONLY);
const stat = fs.statSync(uris[0]);
openByFd(file.fd, stat.size);   // 引擎内部 dup 自持
fs.closeSync(file.fd);          // ArkTS 侧可以立即关闭

C++ 侧把 fd 包成 FFmpeg 的 AVIOContext,读回调用 pread 做无状态随机读,seek 回调支持 AVSEEK_SIZE(MP4 的 moov 在文件尾部时,探测阶段必须知道文件长度):

int PlayerEngine::FdRead(void* opaque, uint8_t* buf, int bufSize) {
    PlayerEngine* self = static_cast<PlayerEngine*>(opaque);
    ssize_t n = pread(self->ownFd_, buf, (size_t)bufSize, (off_t)self->ioPos_);
    if (n <= 0) return AVERROR_EOF;
    self->ioPos_ += n;
    return (int)n;
}

int64_t PlayerEngine::FdSeek(void* opaque, int64_t offset, int whence) {
    PlayerEngine* self = static_cast<PlayerEngine*>(opaque);
    int64_t target = -1;
    if (whence == SEEK_SET)      target = offset;
    else if (whence == SEEK_CUR) target = self->ioPos_ + offset;
    else if (whence == SEEK_END) target = self->fileSize_ + offset;
    else if (whence == AVSEEK_SIZE) return self->fileSize_;
    if (target < 0) return -1;
    self->ioPos_ = target;
    return target;
}

int PlayerEngine::OpenFd(int fd, int64_t fileSize) {
    ownFd_ = dup(fd);                 // 引擎自己持有一份
    uint8_t* iobuf = (uint8_t*)av_malloc(64 * 1024);
    avio_ = avio_alloc_context(iobuf, 64 * 1024, 0, this, FdRead, nullptr, FdSeek);
    avio_->seekable = 1;
    fmt_ = avformat_alloc_context();
    fmt_->pb = avio_;
    int err = avformat_open_input(&fmt_, "", nullptr, nullptr);
    // ... find_stream_info、选流、开解码器、起线程
}

这个设计有两个好处:全程零权限(不申请任何 ohos.permission.*),且不复制文件、不产生临时文件,直接对原位置大文件随机读。

启动界面:点击右上角打开文件

四、FFmpeg 交叉编译:configure 参数就是文档

FFmpeg 7.1.1 要针对 aarch64-linux-ohos 编成静态库。核心 configure(完整脚本在仓库 scripts/build_ffmpeg_ohos.sh):

./configure \
    --prefix="$OUT" \
    --arch=aarch64 --target-os=linux --enable-cross-compile \
    --cross-prefix="$NDK_BIN/aarch64-linux-ohos-" \
    --cc="$NDK_BIN/aarch64-linux-ohos-clang" \
    --host-cc="$NDK_BIN/aarch64-linux-ohos-clang" \
    --sysroot="$NDK/sysroot" \
    --extra-cflags="-fPIC -D__MUSL__ -O2" \
    --enable-static --disable-shared \
    --disable-programs --disable-doc \
    --disable-avdevice --disable-avfilter --disable-network \
    --disable-autodetect --disable-asm \
    --enable-swscale --enable-swresample --enable-protocol=file \
    --enable-demuxer=matroska,mov,avi,flv,mpegts,mp3,flac,wav,ogg,aac \
    --enable-decoder=h264,hevc,av1,vp9,mpeg4,aac,mp3,flac,vorbis,opus,ac3,dts,subrip,ass,mov_text,webvtt \
    --disable-encoders --disable-muxers

几个用血泪换来的细节:

  1. NDK 路径不能带空格。SDK 默认装在 C:\Program Files\...,而 configure 对 --sysroot 的引号处理并不可靠。用目录联接绕开:mklink /J C:\ohos-ndk "C:\Program Files\...\native"。
  2. --host-cc 指向交叉编译器。裁剪配置下没有任何宿主工具需要构建,这样能绕过宿主 C11 编译器探测。
  3. --disable-asm 或链接时 -Wl,-Bsymbolic 二选一。FFmpeg 的 aarch64 汇编用 adrp 直指同库内符号,静态链进 .so 时会报 relocation ... preemptible。我的 CMakeLists.txt 里保留了 -Wl,-Bsymbolic,静态库之间则用 --start-group/--end-group 解决反向依赖:
target_link_options(yingxia PRIVATE "-Wl,-Bsymbolic")
target_link_libraries(yingxia PUBLIC
  libace_napi.z.so libhilog_ndk.z.so libEGL.so libGLESv3.so libohaudio.so
  "-Wl,--start-group"
    ${FFMPEG}/lib/libavformat.a ${FFMPEG}/lib/libavcodec.a
    ${FFMPEG}/lib/libswscale.a   ${FFMPEG}/lib/libswresample.a
    ${FFMPEG}/lib/libavutil.a
    ${SUBS}/lib/libass.a ${SUBS}/lib/libharfbuzz.a
    ${SUBS}/lib/libfribidi.a ${SUBS}/lib/libfreetype.a
  "-Wl,--end-group")
  1. 白名单裁剪。解码器/解封装器用显式 --enable-* 白名单,encoder/muxer 全关——播放器不产出媒体文件,包体和 LGPL 合规性同时受益。

字幕链(FreeType/FriBiDi/HarfBuzz/libass)用 meson 交叉编译,有两个高频坑值得单列:

  • meson 在交叉构建中按目标机(system = 'linux')的语义校验 --prefix,Windows 盘符路径会被判为「非绝对路径」报错,所以 prefix 要写 /ffass-install,装完再把 .pc 文件里的 prefix 改回 Windows 路径;
  • FriBiDi 的 Unicode 表由构建机程序生成,必须装 mingw gcc 并提供 native file(且 native file 里不能写 [host_machine] 段,写了整个文件会被静默忽略);
  • libass 必须 -Dfontconfig=disabled -Drequire-system-font-provider=false,否则 configure 直接失败——鸿蒙既没有 fontconfig 也没有 DirectWrite。字体问题的解法见第六节。

五、音频输出与音画同步:时钟只有一种真相

音频我固定输出 48kHz / 双声道 / S16LE,任何采样率、声道布局、位深的源都先经 swr_convert 归一化。这让时钟模型极简:

int64_t PlayerEngine::ClockUs() {
    if (audioIdx_ >= 0 && audio_->Valid() && state_.load() != kIdle) {
        return posBaseUs_ + audio_->PlayedUs();   // 音频主时钟
    }
    std::lock_guard<std::mutex> clk(clockMtx_);
    if (state_.load() != kPlaying) {
        return posBaseUs_;                        // 暂停/无音轨:固定值
    }
    float sp = speed_.load();
    return posBaseUs_ + (int64_t)((av_gettime() - wallAnchorUs_) * sp);  // 墙钟回退
}

OH_AudioRenderer 是回调式拉取,回调签名必须精确匹配,否则运行时崩:

int32_t OnWriteDataCb(OH_AudioRenderer* renderer, void* userData,
                      void* buffer, int32_t length) {
    AudioOutput* self = static_cast<AudioOutput*>(userData);
    return self->FillBuffer(buffer, length);
}

// 构建参数
OH_AudioStreamBuilder_SetSamplingRate(builder, 48000);
OH_AudioStreamBuilder_SetChannelCount(builder, 2);
OH_AudioStreamBuilder_SetSampleFormat(builder, AUDIOSTREAM_SAMPLE_S16LE);
OH_AudioStreamBuilder_SetRendererInfo(builder, AUDIOSTREAM_USAGE_MUSIC);

音频回调每取走一批 PCM 就累计 writtenFrames_,PlayedUs() 由它换算——已写采样数就是时钟,天然包含倍速(OH_AudioRenderer_SetSpeed 让消费变快,计数自然增长变快),视频线程只要拿着 PTS 和 ClockUs() 比较等待即可,不需要任何系数换算。无音轨文件则回退墙钟,暂停时把进度固化进 posBaseUs_。

倍速按钮因此只需要一行 NAPI 调用,音画不会漂移。

六、中文字幕:libass + 内存字体,不依赖任何字体服务

libass 在 Linux 上依赖 fontconfig,在 Windows 上依赖 DirectWrite,鸿蒙两者都没有。构建时关掉系统字体提供器,运行时把系统字体文件读进内存注册给 libass:

void PlayerEngine::LoadSystemFont() {
    // OHOS 系统字体目录探测(含中文字重文件)
    const char* candidates[] = {
        "/system/fonts/HarmonyOS_Sans_SC_Medium.ttf",
        "/system/fonts/HarmonyOS_Sans_SC.ttf",
        "/system/fonts/HarmonyOS_Sans_SC_Bold.ttf",
        "/system/fonts/NotoSansCJK-Regular.ttc",
        "/system/fonts/DroidSansFallback.ttf",
    };
    for (const char* path : candidates) {
        FILE* f = fopen(path, "rb");
        if (f == nullptr) continue;
        // ... 读入内存 ...
        ass_add_font(assLib_, (char*)"sans", (char*)data, (int)sz);
        fontLoaded_ = true;
        break;
    }
}

void PlayerEngine::AssInit() {
    assLib_ = ass_library_init();
    LoadSystemFont();
    assRender_ = ass_renderer_init(assLib_);
    ass_set_fonts(assRender_, nullptr, "HarmonyOS Sans SC",
                  ASS_FONTPROVIDER_NONE, nullptr, 1);
    ass_set_shaper(assRender_, ASS_SHAPING_COMPLEX);   // HarfBuzz 复杂整形
}

不打包字体文件(省体积)、不依赖系统字体服务(规避 ROM 差异),字幕渲染每帧用 ass_render_frame 拿位图链,逐像素 src-over 混进 RGBA 再上屏。

两个语义坑必须记住:

  1. FFmpeg 的 AVSubtitleRect.ass 带 Dialogue: 前缀,而 ass_process_chunk 期望前缀之后的字段串,喂之前要剥离;
  2. ASS_Image.color 是 0xAARRGGBB,其中 AA=00 表示完全不透明、FF 表示全透明,和常规 RGBA 相反,混合前要取反。

字幕轨与音轨的切换都由工作线程原子应用(UI 只写 pendingAudioSwitch_ 这类请求变量),换解码器、清残留包、重建重采样器,播放位置保持连续。

字幕面板:内嵌字幕轨开关与外挂字幕

七、一个真实的 bug:暂停后「声音没了、进度不动,画面还在播」

这是本次移植里最有教学价值的一个 bug。现象:点暂停后,声音停了、进度条停了,但画面还在继续放,甚至像快进。

三个缺陷叠加:

缺陷一:视频线程根本没停。 暂停分支只是睡 50ms,睡完继续取包、解码、渲染:

// 修复前
while (running_.load()) {
    if (state_.load() == kPaused) {
        av_usleep(50000);        // ← 睡完继续往下跑!
    }
    AVPacket* pkt = vq_.PopWait();
    // ... 解码、渲染 ...
}

缺陷二:同步闸门在暂停时失效。 帧等待条件写的是 state_ == kPlaying && ...,一旦暂停,前半条件为假,等待被整个跳过,解出来的帧不再受时钟约束、全速刷屏:

// 修复前:暂停时直接跳过等待
while (running_.load() && state_.load() == kPlaying && ClockUs() + 20000 < ptsUs) {
    av_usleep(5000);
}

缺陷三:时钟被重复计数。 音频路径下 ClockUs() = posBaseUs_ + PlayedUs(),而 Pause() 把完整时钟值写进了 posBaseUs_(它本该只是 seek 基准偏移)。时钟瞬间翻倍——恢复播放后画面要狂奔「追赶」这个错误时间。

修复分三步。先加一个工作线程暂停闸门,video / audio 线程在取包后、解码前都过闸门:

void PlayerEngine::WaitIfPaused() {
    // 暂停期间工作线程在此等待;running_ 变 false 时立即退出,保证 Teardown 能 join
    while (running_.load() && state_.load() == kPaused) {
        av_usleep(20000);
    }
}

再把帧等待条件改为「暂停就等,恢复后才按时钟等」:

// 修复后:state_ == kPaused 时一直等,绝不因暂停跳过等待
while (running_.load() &&
       (state_.load() == kPaused || ClockUs() + 20000 < ptsUs)) {
    av_usleep(5000);
}

最后修时钟语义——有音轨时只停 renderer,PlayedUs() 冻结让时钟自然静止,绝不动 posBaseUs_:

int PlayerEngine::Pause() {
    if (state_.load() != kPlaying) return -1;
    const bool audioClock = (audioIdx_ >= 0 && audio_->Valid());
    if (audioClock) {
        audio_->Pause();          // renderer 停止回调 → PlayedUs() 冻结
    }
    if (!audioClock) {
        // 只有墙钟路径才固化进度锚点。
        // 注意 ClockUs() 内部会取 clockMtx_,必须先算再上锁,避免 std::mutex 自死锁
        int64_t now = ClockUs();
        std::lock_guard<std::mutex> clk(clockMtx_);
        posBaseUs_ = now;
        wallAnchorUs_ = av_gettime();
    }
    state_ = kPaused;
    return 0;
}

顺手还修了一个连带问题:audio 线程暂停时如果继续解码推 PCM,环形缓冲写满后会丢弃最旧数据,暂停点之后的音频被反复覆盖,恢复时丢音、音画错位。所以 audio 线程同样过闸门。

这次修复沉淀出三条「历史约束」,已写进仓库文档:暂停闸门必须覆盖所有工作线程;同步等待条件必须包含暂停态;音频路径的 posBaseUs_ 是偏移不是绝对时间。

八、桌面体验:列表、连播与触控热区

列表面板与自动连播

PC 形态上做了几件让「像桌面播放器」成立的小事:一次批量选 20 个文件入列表、播完自动连播下一集(EOF 后引擎置 ended_,UI 轮询驱动 800ms 后切下一集)、进度条拖动用三态隔离(拖动中显示 dragMs,松手才 seek,避免回跳)、播放/暂停按钮直接查引擎实时状态而不是轮询缓存,避免快速连点时状态错乱。按钮高度 32–36vp,保证触屏命中。

九、踩坑速查表

错误 / 现象阶段根因与解法
00308018 Unknown Error构建IDE 注入的 NODE_OPTIONS safe-delete 垫片拖死 hvigor 子进程的 fs.rmSync;构建前清掉 NODE_OPTIONS 与 CODEBUDDY_SAFE_DELETE_*
9568320 no signature file安装产出了 unsigned.hap:signingConfigs 声明了但 products[0] 没加 "signingConfig": "default"
no build machine compilermesonnative file 里写了 [host_machine] 段,或 mingw gcc 不在 PATH
prefix value ... must be an absolute pathmeson交叉构建按 linux 语义校验 prefix,改用 /ffass-install,装完修 .pc
Either DirectWrite ... or Fontconfig ... is requiredlibass加 -Drequire-system-font-provider=false
relocation ... preemptible链接aarch64 汇编直指同库符号,加 -Wl,-Bsymbolic
中文显示方框运行/system/fonts 候选路径与设备不匹配,按 hilog 提示补候选表
画面全黑无报错运行XComponent 参数写成了 library,正确的是 libraryname

十、QA

Q1:为什么不直接用系统 AVPlayer / AVCodec?
系统接口省事,但格式覆盖面受系统实现约束,字幕渲染与多音轨的控制粒度也不够。K-Lite 的核心价值就是「什么都能播」,要守住这个能力上限,就得自己持有解码内核。硬解(OH_AVCodec)已在规划中,作为软解的加速路径而非替代。

Q2:应用需要什么权限?
零权限。没有任何 requestPermissions 节点,文件访问全部经系统 picker 由用户显式授权,fd 由引擎 dup 自持;FFmpeg 构建时连 network 都禁了,应用无网络能力。

Q3:FFmpeg 静态链接进 .so,LGPL 合规吗?
FFmpeg 以 LGPL 配置构建(禁用 GPL 组件与编码器),静态链入的是动态库 libyingxia.so。仓库提供了完整的交叉编译脚本、CMakeLists.txt 链接顺序与头文件,替换任意版本重新执行构建即可重链,满足「用户可替换库版本重新链接」的要求。

Q4:为什么字幕要自己编 libass,系统没有字幕渲染吗?
系统没有暴露可用的 ASS 渲染能力,而 libass 是唯一成熟的开源 ASS/SRT 排版实现。配合 HarfBuzz 整形与 FriBiDi 双向文字,特效字幕、中英混排、阿拉伯语字幕都能正确渲染;字体用内存注册方案规避了 fontconfig/DirectWrite 缺失的问题。

Q5:暂停之后画面必须完全静止吗?实现上有什么讲究?
必须,这也是踩过坑之后固化的设计。video/audio 线程在「循环开头、取包后、解码帧前」都过 WaitIfPaused() 闸门,帧等待条件包含暂停态;时钟侧,音频路径靠 renderer 停止让 PlayedUs() 自然冻结,绝不能把完整时钟值写进 posBaseUs_——否则时钟翻倍,恢复播放后画面会狂奔追赶错误时间。详细代码见第七节。

Q6:性能如何?4K 能播吗?
当前是纯软解 + CPU 字幕混合,1080p 主流编码流畅。有背压机制(视频队列 96 包、音频 256 包)兜底内存。4K/高码率要等 OH_AVCodec 硬解接入(P3 规划,失败自动回退软解)。

Q7:想自己编译运行,需要什么?
仓库 ohos_KLite 已随附预编译静态库(经 Git LFS 跟踪,clone 前先装 git-lfs),用 DevEco Studio 打开、本地自动签名后直接 Run 即可;想从源码重编依赖,照 README.OpenHarmony_CN.md 的 scripts/ 配方执行。注意签名配置不入库,各人在 DevEco 里生成。

Q8:网络流、直播源支持吗?
不支持,构建时禁用了 network 与 avdevice,这是刻意的取舍:单机播放器先把本地体验做扎实,网络能力涉及合规与流媒体协议栈,不在当前范围。

十一、结语

这次移植最大的收获是想清楚了一件事:跨平台移植的本质不是搬代码,而是搬能力,并为每个能力找到它在目标平台上的正确归宿。 界面该重写就重写(ArkUI 的声明式写法反而比 MFC 舒服),内核该集成就集成(FFmpeg 的格式积累没有替代品),系统能力该替换就替换(picker+fd、OH_AudioRenderer、EGL 都是正路)。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐