avox 本体与插件的不同平台编译:动态还是静态,平台说了算

产品与实现·约 6924 字

同一个引擎,五个平台,同一批插件:Windows 上是 DLL,iOS 上必须并成一个二进制;Android 里给连接加密的那套库,被四个模块各带一份。这篇讲模块整合这道题在各平台的答案,以及答案后面跟着的坑。

这篇讲最不出画面、但最花时间的一层——引擎的可选能力和第三方依赖,怎么在五个平台上编译、打包、部署。

先说问题形状。引擎里除了解码、渲染、音频这些主干,还有一批可选能力:特效字幕 overlay(叠在画面上的独立渲染层)、蓝光原盘直读、磁力边下边播、语音识别、离线翻译、SMB/WebDAV 远程源。它们的共同点是要拖大三方库——libass、libbluray、libtorrent、sherpa-onnx、libsmb2——而且不是每个平台都要、都用得上。为了这些可选件把主干依赖拖满,不划算;全砍掉又不行。所以它们被做成插件模块:一个模块一个目录、一份导出函数、一个注册器和一句 ABI(二进制接口)版本号。

先看五个平台各自的答案

同一份插件源码有两种落地形态:DYNAMIC 编成独立动态库,STATIC 并进主库。引擎这边的约定是,切换形态只需要把模块名从一个列表挪到另一个列表,业务代码零改动。但五个平台最终被迫选出来的答案并不一样:

平台引擎本体形态插件形态
Windowsavox.dllplugins/ 目录下的 .dll,首次需要时扫描加载
macOS静态库 libavox.a,链进 shim dylib.dylib 随包发布,按需加载
iOS全体并成单一归档,进一个 framework dylib无动态插件,全部静态并档
Androidlibavox.so.so 与主库同在 APK 原生库目录,同一套扫描加载
Linuxlibavox.so同 Windows 形

这张表是后面所有坑的源头:每个平台的插件形态不是选出来的,是被平台的分发方式先定死的。

Windows:动态库世界里要自己管的几件事

依赖的搜索路径。 插件自己也拖依赖:推理插件要 onnxruntime.dll 和 DirectML.dll,字幕插件要 ass-9.dll 和 fribidi-0.dll。做法是让插件自包含——依赖 DLL 也复制进 plugins/ 目录;加载时先把 plugins/ 目录加进进程的 DLL 搜索路径,再用带「从动态库自己的目录找依赖」旗标的加载接口打开。这样插件和它的依赖永远是一窝,整个目录挪到哪都能用。发布包里带哪几个插件由一份白名单定:引擎构建目录里建了十几个模块,随播放器发布的只有六个模块加七个依赖 DLL,共十三个文件——没列入的连编译产物都不进包。

崩在加载期不许连坐。 插件的 DllMain(动态库加载时自动执行的入口)里如果有代码崩了,常规后果是把整个播放器一起带走。这里的加载用 Windows 的结构化异常机制包了一层:插件加载失败或加载期崩溃,记一条日志、把这个模块标记为不可用,播放器继续跑,顶多对应能力降级。

ABI 握手。 插件和引擎分开编译,结构错位是致命的。每个插件导出一个 ABI 版本号,与引擎手里的对不上就拒绝加载。跨动态库的对象所有权定成「插件 new、宿主 delete」,前提是全链路统一静态 CRT(运行时库):这种配置下各模块的分配最终都落到进程默认堆上,跨模块释放才对得上——这是实测出来的性质,不是 Windows 的书面承诺。配套的禁令是标准库容器(字符串、数组)不许跨动态库传递:它的析构是内联的,会在错误的 CRT 副本里释放内存,调试构建下直接断言崩溃——所以插件接口只回原始指针和 C 字符串。再往上一层,引擎对外 API 对象的生命周期干脆不交给调用者写:创建是 avox 里的工厂函数,销毁靠接口的虚析构,设计上没有成对的销毁函数;给其他语言的绑定(SWIG 生成的 Python / C# / Node 封装)把每个创建函数标成「返回值归目标语言所有」,由对方的垃圾回收在收走代理对象时替它调删除。唯一例外是借用指针——个别对象(比如 AI 助手的会话)的生命周期必须嵌套在宿主里,故意不标所有权,因为垃圾回收的顺序不确定,标了反而悬垂。插件编译还有一条铁律:绝不导出主库的导出宏,否则虚表会被当成导出符号,跨动态库直接错乱。另外每个插件加载后要过一次「能力探测」——运行期条件不满足(比如模型文件没下)时,插件自己报告不可用,而不是崩溃。缺文件的降级路径是设计的一部分。

一条更硬的纪律:FFmpeg 的 bin 和 lib 必须同批。 这是子模块整合里最贵的课。Windows 上 FFmpeg 以 dll 加导入库分发,导入库是「函数名到 DLL 内序号」的胶水。一次重新构建 FFmpeg(解码器从 62 个调到 164 个),新增的导出函数插在中间,后面所有函数的序号顺移;而导入库没有跟着换批,宿主按旧序号调用的字幕释放函数,实际跳进了 TAK(一种无损音频格式)解码器的解析函数——字幕一开就崩,连崩三次,同一个签名。教训固化成五条:重编时 bin 和 lib 同批产出、一起部署;只换 dll 不重链宿主必炸;核对时用全表 diff——只比对实际用到的序号区间会漏,恰好漏掉出事的那一个,就会误判「无错位」;根治是不用序号:拿当前 dll 按函数名重新生成导入库,之后 dll 增删导出都不再错位;其他按序号导入 av 库的模块(mk_api 等)同查一遍。

macOS:静态核加动态插件,符号账要两头算

mac 上引擎本体是静态库,插件是动态库,而且插件真的随包发布、按需加载——这一档没有 iOS 的禁令,但要结清的账更多。

一头是插件欠宿主的。插件用「未定义符号运行时再找」的方式编译,加载时用符号只对自己可见的方式打开,于是插件依赖的库,其符号对插件不可见,插件必须自己把依赖解析干净——远程源插件就在这套加载方式下报过未定义符号,修法是在插件内部完成解析,而不是指望宿主替它找到。

另一头是宿主欠插件的。引擎静态链进 shim(引擎与界面之间的胶水层)时,链接器只按引用拉成员,引擎里那些「只有插件才用得到」的函数不会被拉进来;插件加载时按名找它们,找不到就整个模块不可用。修法是反过来数:把每个随包插件自己的未定义符号列出来,在链接命令行上逐个强制保留,一个不漏——有过一次「语音识别插件不可用」,查下来就是插件要的一个引擎方法压根没被链进宿主。

还有一道比链接更靠外的门:签名。macOS 有代码门禁,GUI 进程运行期去加载一个未签名、或被改过的动态库,会被直接拒绝(报错还伪装成文件不存在)。所以这套工程里 shim 走的是「随 CocoaPods 内嵌、启动即载」的形态,不是运行期加载;部署脚本在构建流程内被调用时只做编译加拷贝、不做签名,签名统一交给 Xcode 的签名阶段——否则「签完之后再追加文件」的顺序会把签名作废,动态库全部加载失败。同类事故还有两起:动态库的 ID 默认写成构建树的绝对路径,不改写的话,引擎按「自己在哪」推导出的插件目录会指到构建树里去,插件全找不到(字幕插件就这么翻的车);App 包里混进残留文件(运行时写出的 Windows 风格路径文件、Finder 的伴生文件)会破坏包签名封条,之后所有动态库加载一律失败,上传苹果时还会连吃两个校验错误。

这些账结完,mac 上插件随包发布、按需加载,蓝光原盘、倍速这些插件腿都能用——同一个引擎,到 iOS 就完全是另一条路。

iOS:没有动态插件的位置

iOS 这一档在插件名录里是「全部移除或改为静态并档」,工程注释给的理由是商店不允许加载第三方可执行代码;另外 iOS 上动态插件的产物本来就装不进包,这条路没打通。并档之后,三个坑依次出现。

第一个坑:链接器会悄悄丢符号。 静态归档里的成员没有被引用,链接器就把它丢掉;而插件的注册器是一个全局构造函数对象,天然「没人引用」,于是编译了但没注册。修法是一张保活表:插件的注册宏自动生成一个 keep 符号,构建时为每个静态模块加一个编译定义,核心的保活表里取一次这些符号的地址,把它们拖进链接闭包。加一个静态模块固定三步:开定义、加一行登记。

第二个坑:主执行文件的导出符号会被商店处理削光。 引擎和 shim 原来链在 App 的主执行文件里,界面侧靠 dlsym(运行期按名字取函数)拿入口。TestFlight 早期一个构建实测:主执行文件的导出表里 pvx_ 前缀的符号一个不剩——界面、浏览、付费这些纯 Dart 功能都正常,一按播放全哑火;同一个包里 MoltenVK 等 framework 的导出却都活着。修法是把引擎加 shim 整链挪进一个自有 framework 的 dylib,商店处理不动 framework 的导出表。验收也写成了一条命令:出包后数一遍导出符号,应约 99 个 pvx_ 且包含 ABI 版本那一个。

第三个坑:资源包会被当成嵌套代码。 引擎要读的资源(着色器、字体、证书)原来打成 avox.bundle,商店校验把 .bundle 里的每个文件当嵌套代码要求独立签名,连 .py/.mm/.md 都点名拒,四次被拒(错误码 90035);本地又没有分发私钥,签名托管在云上。修法是把 bundle 改成无后缀的目录名,走 framework 资源封条;引擎侧改成旧名查不到就自动落新名 avox_res(含 avox.bundle/fonts 等子路径形态),两端兼容。

mac 和 iOS:同一个 Apple,两条相反的路

把这两档摆在一起看,有意思的是坑的方向正好相反。mac 上动态插件是常态,麻烦都在「谁欠谁符号」:插件欠宿主解析,宿主欠插件保留,两头的账都要显式结清。iOS 上动态插件不存在,整个 App 只有一份二进制,麻烦反而出在「链接器太勤快」:没人引用的注册器成员会被静默丢掉,要用保活表把插件拽住。一边要「补着钉」,一边要「拉着留」。

其余几处对照:

  • 出包形态。 mac 同时产出两种:一份合并静态归档,一份随 pod 内嵌、启动即载的动态库——两种都不走「运行期加载文件型动态库」,绕的正是代码门禁;iOS 只有一份合并归档,进框架动态库。
  • 预编译件的分片。 iOS 分真机、模拟器两套预编译库,各并各的归档;脚本早期用裸的目录搜索找库,按目录顺序把模拟器对象并进了真机归档,链接期才炸;后来所有查找按平台锁死路径。mac 没有这个分叉。
  • 资源包。 mac 上 avox.bundle 照常打进包、照常读;iOS 被 90035 逼着改成无后缀目录。
  • 唯一的例外。 全静态的 iOS 上,MoltenVK 必须动态:volk 有一组全局函数指针变量,MoltenVK 又导出同名函数,两份并进同一次静态链接会在重定位阶段炸掉,只能以动态框架形态内嵌、由引擎自己加载。这条恰好是全篇主题的另一面:同一个名字,在一次链接里只允许出现一次。
  • 部署纪律。 mac 的构建产物只是出包位,装到用户应用目录的那份才是真正在跑的,覆盖安装时合并旧文件会破坏签名封条(出过一次:构建树已是新的,用户手里还是旧包);iOS 由 Xcode 统一签名、命令行直装设备,没有这一步。

一个例子:同一种 TLS,四个模块各带一份

插件整合里最能说明「平台决定答案」的,是 TLS(给连接加密的那层协议,https 靠它)。要联网的模块不止一个:FFmpeg 打开 https 流、远程源插件走 WebDAV/SMB、种子插件连 tracker、AI 模块发 HTTPS 请求;而手里有两套 TLS 实现——OpenSSL,和 WebRTC 归档自带的 BoringSSL。多个模块要 TLS、实现还有两套,这道题各平台答案完全不同。

Windows 最省事:OpenSSL 以两份 DLL 随包发布,一个进程一份,谁用谁链接。

要防的是「两套实现进同一个二进制」。mac 上撞过一次:静态链了 WebRTC(BoringSSL)的宿主二进制里又混进 OpenSSL 的库,运行期表现为野指针崩溃——两套 TLS 实现混在一起,BoringSSL 的对象被 OpenSSL 消费。修法不止是拆,还加了一道构建期护栏:链接完扫一遍符号表,发现 OpenSSL 符号就直接失败,把运行期崩溃提前到构建期。mac 的部署脚本还会按引擎归档里的未定义符号自动判断「哪一味」:出现 OpenSSL 的新名,就按 OpenSSL 味链静态库;否则按 BoringSSL 味处理,用 WebRTC 归档供符号。

iOS 反过来,一个二进制里放不下两套,只能选一家:选 BoringSSL,它已经在 WebRTC 归档里,不额外增重。OpenSSL 只借头文件,符号全由 WebRTC 归档供给。差的三处 API(BoringSSL 砍掉的兼容控制层,和一个改名函数)补了一个小的兼容单元,按数值手工钉回转发。连这个单元的编译都要钉顺序:两套头互相打架——BoringSSL 头把兼容层的宏钉成「不存在」的雷(要补的恰是它),OpenSSL 3 的头又把若干设置函数做成宏,照着转发就变成自递归。解法是先解宏、手工声明 BoringSSL 侧的函数原型,再钉头文件搜索顺序。

Android 的答案又是第三种:各带各的,互不打扰。数过一个 APK:libavox.so、libavformat.so、libavox_remote.so、libavox_torrent.so,四个动态库里都能查到 TLS 实现各嵌了一份——主库和 FFmpeg 把符号藏在二进制内部,既不导出也没有外部依赖;两个插件带导出表,但加载时用只对自己可见的方式,不打架。Android 上主库还会统一再藏一道静态库内部符号——两个静态依赖(sentencepiece 和 sherpa-onnx)各自打包过一份不同版本的 protobuf(一种数据序列化格式),不藏就会撞。

代价是「设置不共享」:在一个模块里配好的信任根,另一个模块看不见。信任根(CA,签发证书的机构)只能改走文件系统:编译期把证书默认目录烤进 TLS 实现(指向应用私有目录),运行期把随包的 CA 证书合成一个 bundle,写到那个目录的 cert.pem,再配两个环境变量兜底。手机上 https 全部失败那次,排查下来踩了三层:实现编译期的默认路径是桌面形态、手机上根本不存在;某版实现加载默认路径时「文件腿」失败会提前返回,只设目录变量等于没设;播放 HLS(分片流)时,分片连接是另开的,父连接的证书参数不往下传——这层最终不靠补丁,靠上面那套默认路径补齐让分片连接一并受益(曾试过在分片层单独补传,后撤回)。三层修完,手机上的 https 才活。

同一道题,三种答案:能共享就共享一份(Windows 的 DLL、iOS 并档后选定一家);不能共享就隔离,各带一份、跨模块状态改走文件(Android);实在要凑在一个二进制里,就补兼容层(iOS 的三处 API)。

再往下:子模块、预编译件与胶水层

第三方依赖本身也有一层整合。引擎仓的 3rdparty/ 是 git submodule(挂在主仓里的子仓指针):zlmediakit、fdk-aac、freetype、sherpa-onnx、sentencepiece、g2o、eigen、volk、cpp-httplib、godot-cpp。一部分大件预编译产物不入库:各平台的 onnxruntime、iOS 的 MoltenVK 和 Vulkan SDK、模拟器用的 FFmpeg 片;新机器从旧机补齐,缺了对应能力自动跳过或降级。每个依赖一族查找脚本,按平台写死目录名——mac 和 iOS 必须分开,否则 iOS 的 arm64 静态库在 mac 上链接会直接报平台不符。

构建脚本自带新鲜度检查:引擎产物比源码旧,轻则警告,重则硬失败。Android 打包那条是硬失败,防的就是「修了引擎源码,打出去的包还带着旧解码器」——一次 seek 花屏复现,根因正是它。

最后是胶水层自己。界面与引擎之间的 shim 是独立于 Flutter 构建的工程,每个平台一份构建树,由各自的部署脚本负责编译——Flutter 的构建命令不会替你编它。纯转发的函数实现收在一个共享文件里,五端引用同一份;只有平台特有的部分(窗口句柄、纹理桥、GPU 车道)才留在各端文件。给 Dart 这一侧又是另一种生命周期风格:句柄和资源都住在 shim 里,释放靠宿主显式调用配对的 pvx_*_free——C API 没有垃圾回收,成对释放就是唯一规则。另外 shim 和引擎是严格的一对:引擎头文件更新后 shim 必须跟着重编,虚表错位是崩溃,不是报错。

回到开头那句话:插件做动态还是静态,不是技术偏好,是被平台分发方式先定死的。定死之后每一道坑都是它的派生题——动态的世界里,管搜索路径、崩溃连坐、ABI,和两头都要结清的符号账;静态的世界里,管链接器丢符号、资源签名,和「一个二进制里只允许一种实现」。

引擎 avox 在 GitHub 开源。系列其他篇:产品介绍从开源引擎 avox 到成品:全平台播放器「泛音」。

本文原发于 知乎(2026 年 10 月);产品进展以 产品页 为准。