把 K-Lite「什么都能播」搬上鸿蒙 PC:影匣播放器的 FFmpeg + libass 原生移植实录
欢迎加入开源鸿蒙 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
几个用血泪换来的细节:
- NDK 路径不能带空格。SDK 默认装在
C:\Program Files\...,而 configure 对--sysroot的引号处理并不可靠。用目录联接绕开:mklink /J C:\ohos-ndk "C:\Program Files\...\native"。 --host-cc指向交叉编译器。裁剪配置下没有任何宿主工具需要构建,这样能绕过宿主 C11 编译器探测。--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")
- 白名单裁剪。解码器/解封装器用显式
--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 再上屏。
两个语义坑必须记住:
- FFmpeg 的
AVSubtitleRect.ass带Dialogue:前缀,而ass_process_chunk期望前缀之后的字段串,喂之前要剥离; 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 compiler | meson | native file 里写了 [host_machine] 段,或 mingw gcc 不在 PATH |
prefix value ... must be an absolute path | meson | 交叉构建按 linux 语义校验 prefix,改用 /ffass-install,装完修 .pc |
Either DirectWrite ... or Fontconfig ... is required | libass | 加 -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 都是正路)。
更多推荐




所有评论(0)