Flutter 与 avox 的视频帧交互:从 Texture 到原生窗口

产品与实现·约 3620 字

Flutter 写 UI,C++ 引擎管解码和 GPU 管线,视频画面交给谁画?我从纹理直通起步,九天后整链退回原生窗口。这篇把两个方案都拆开:碎块的真因、七种同步方案的失败、以及原生窗口的代价清单。

泛音是一个 Flutter 界面的全平台播放器,底下是我自己写的 C++ 音视频引擎 avox。于是有一个绕不开的问题:引擎解码、做 GPU 处理,画出来的视频帧,怎么显示到屏幕上?

在 Android 和 iOS 上,Flutter 有 PlatformView,平台视图是现成的。桌面上没有答案——Windows 至今没有官方的 PlatformView 支持,这是个挂了很多年的老问题。摆在面前的两条路:把引擎的输出变成 Flutter 纹理,塞进合成树;或者绕过 Flutter 的合成器,自己开一个原生窗口。我从纹理起步,九天后把整条纹理链删掉,退回原生窗口。下面是这两个方案的完整过程。

纹理方案:看起来最顺的路

最初的版本甚至没有纹理:引擎渲染完,shim 把帧按行拷成 BGRA 交给 Dart 画。1080p 够用,4K 就不行了,这笔账当时算得很清楚。于是上了零拷贝的 GPU 链:引擎用 Vulkan 合成完,把结果放进跨 API 的 D3D11 共享纹理;shim 的桥线程把它镜像到一块注册给 Flutter 的 GPU 纹理;Dart 侧只需要把 texture id 喂给 Texture widget。实测比 CPU 读回省 17% 的 CPU 占用,Android 也很快跟进了外接纹理直通。

当时选纹理的理由,现在看依然成立:外部贴图是 Flutter 的一等公民,一个 Texture widget 就能进合成树,变换、手势、生命周期全都免费;平台差异可以关进「每平台一份贴图桥」的盒子里;而且 Windows 没有官方 PlatformView,纹理是当时唯一的通道。我甚至主动把引擎自建的原生渲染窗口藏起来,让启动只留一个窗口——现在回想,这个动作像是给后来的路打了埋伏。

崩坏:治不了的碎块

问题以最普通的方式出现:有些片源在播放中画面会出现乱块,以及可能出现的轻微卡。

排查走了一段弯路。先怀疑片源坏,推翻;发现共享纹理的 fence 有重复 signal 同值的缺陷,修成原子单调取号,治好了 1 秒花屏;镜像缓冲从单张加到三张、五张,出块率下降但稳态仍在;给引擎加帧闸(消费端确认后才写下帧),碎块没治好,卡顿反而加重。这些全是治标。

真因靠二分法锁定:同进程跨设备的共享纹理并发。它的边界很清楚:原生窗口测试(引擎用 Vulkan 写、直接呈现到窗口)从来没有出现过,同一块纹理跨进程导入也是干净的——它无关 D3D/Vulkan 的 fence,也不在跨 API 共享本身。坏的只有一种形态:把 Flutter 的 ANGLE 合成器拉进同一条链,当第三个使用者。此时消费端有两级——ANGLE 在采样这块纹理,shim 的镜像 blit 也在和引擎的下一帧写入赛跑,两级都没有可靠的同步;碎块的内容稳定地来自相邻帧,不是随机噪声。实测:出现问题的片源,在顺序播放时失序指标超限的帧占 21%,肉眼可见的碎块 8%;seek 之后是 32% 和 14%。

变量隔离做了两刀:三套宿主、同一文件、唯一变量是「是否开启纹理导出」——开着就坏,关着就净;跨进程导入,干净。结论于是很干脆——病灶在这条链的多消费者并发结构上,修不如绕。

还有一笔前瞻账:8K 的 RGBA8 帧是 132.7MB,这条链每一帧要跳三次。就算同步问题能修,带宽也不可持续。

原生窗口:绕过 Flutter 的合成器

新方案的形状,Windows 参考已有实现,核心代码约两百行:建一个顶层视频窗,黑底无框、不吃激活焦点,用 SetWindowPos 塞进 Flutter 主窗的正后方,与主窗 z 序贴合;Flutter 主窗用 SetWindowCompositionAttribute 做整窗真透明——这是个未公开的 API,Windows Terminal 同款用法;Flutter 在视频区域只画一个透明洞,播放控件以真实 alpha 压在视频上。视频窗完全不吃输入,所以 hit-test 天然无感;z 序被别的窗口插队,有 300 毫秒的看门狗定时重钉;最小化动画 DWM 处理不了两个重叠窗口,用三步绕法(关透明、藏窗、延迟无激活恢复)。

macOS 和 iOS 走官方 PlatformView,视图里自建 CAMetalLayer,把层指针交给 shim;Android 用 AndroidView 加 SurfaceView,JNI 挂 ANativeWindow。Linux 暂时保留 CPU 读回的旧形状,是全平台唯一的例外。

效果立竿见影:尖刀环境同机同片 A/B,纹理组碎块复现,直渲组全净;真机实播,卡顿和乱块双消,开播到首帧 246 毫秒,8K HEVC 能播。

实际用下来,这套方案比预想的好:播放栏、控件的显隐全部正常,Flutter UI 叠在视频上下也都正常,真 alpha 压在画面上——不像绕开合成器的偏方,更像一个官方支持的原生渲染中间层。道理不复杂:受限的只有视频面本身,界面仍然是普通的 Flutter 组件树。边界也集中在视频面:它在合成树之外,Flutter 的动画和变换作用不到它;视频矩形跟随有一帧迟滞,所以视频节点被约束不进滚动列表;PrintWindow 截屏抓不到透出的视频;整窗透明依赖一个未公开 API,是评估后接受的取舍。窗口自己不出输入,反而省掉了 hit-test 处理。

单车道:不留开关

验证通过后我做了一个决定:不留回退开关。纹理直通整条删除,「GPU 直通」的设置项、它的本地化词条、启动时的环境变量注入,连同 shim 里的纹理符号处置,一起收掉;shim 收到无窗口的创建请求直接拒建。现在产品里只剩一条渲染链,以后每一个渲染 bug,都不需要先回答「发生在哪条链」。

那九天不算浪费——fence、镜像环、帧闸这些功课,最后都变成了「为什么不能这么做」的判断依据。

复杂度没有消失,只是转移

纹理方案的复杂度在帧同步协议;原生窗口方案的复杂度在窗口生命周期。三个例子。

一个是 0×0。尖刀探针页一切正常,真实壳层里视频窗却是 0×0、控件全隐。用自动播放钩子复刻打开链、沿 RenderObject 祖先链逐级测量,找到根因:壳层的 Stack 是 loose fit,基础层在播放时被 Offstage 隐藏、量出 0×0,把 Positioned.fill 的播放页一起拖塌成零宽。修法是 StackFit.expand,一行。

一个是旋转黑屏(Android)。竖屏有画面,转横屏有机率黑屏、有声无画。真因:旋转时平台视图整个重建、换了新的 SurfaceView,而某些系统上旧面的 surfaceDestroyed 回调可以不来;挂面闸只比较播放器句柄,新来的面被当成重复请求吞掉,引擎抱着已死的旧窗口轮询出 0×0,恒黑。修法:句柄与 Surface 对象任一变化即重挂。同路径测试,修前两次全黑,修后四次全出画。

一个是换道崩溃(macOS)。播放中 HDR 切换渲染车道时,objc_retain 踩到上一部片子的、已经释放的 CAMetalLayer。根因:插件只在视图创建时注册指针,销毁时无人注销;shim 只挡空值,挡不住悬垂。修了三层:dealloc 时条件注销、shim 侧用弱引用槽做活体闸、Dart 侧开片时清换道状态防抢跑。

还有一类纯经验:矩形定位必须在挂窗之前完成——「子窗和交换链的尺寸钉在挂窗瞬间,晚了画面只有一角」。

这件事的另一半,是 HDR

回头看,放弃纹理不只是修碎块。Flutter 的合成树里,纹理永远是 SDR 的——合成器没有 HDR 通路。想让杜比视界的高光真的顶出屏幕,像素必须从引擎自己的呈现面出去:Windows 的 PQ 交换链,或 Apple 的 EDR Metal 层。所以「视频走原生窗口」是 HDR 的前置条件;我后来基于它做了两条渲染车道——车道按输出方式分,不按解码方式分:HDR 直通输出时硬解帧直接交平台原生呈现,其余场合(HDR 片在普通屏上也在内)一律走 Vulkan 处理链,播放中拆面换道约 120 毫秒——那是另一篇文章的主题。

什么时候 Texture 依然是好选择

低帧率、小尺寸、需要参与 UI 变换的画面——缩略图、预览、图表——Texture 至今仍是最短路径,我的自检页也还在用它验证 GPU 互操作能力。全屏、高帧率、高色深的视频面,原生窗口是我验证过的唯一稳定答案。一个粗糙但实用的信号:当你发现自己在给纹理写第七种同步方案时,该换路了。

引擎 avox 在 GitHub 开源,本文的窗口管理、GPU 管线都在里面。系列其他篇:产品介绍从开源引擎 avox 到成品:全平台播放器「泛音」。

话题标签

Flutter、音视频、图形渲染、C++、桌面开发、GPU、播放器、开源

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