Technical Overview

FPlayer Desktop

跨平台局域网流媒体采集与推流软件。把屏幕、摄像头、视频文件组合成一路视频流,供同一网络下的其他设备实时观看。

语言 C++17 界面 Qt 6.10 媒体引擎 FFmpeg 8.1 代码量 ~26,000 行(自有 C++,不含第三方库)
Chapter 01

画面从哪来

FPlayer 支持三种画面来源:本地视频文件、摄像头、电脑屏幕。每一种都有不同的技术实现路径。

先理解:电脑是怎么"看到"画面的

在深入三种采集方式之前,需要先讲清楚一个最基本的问题:一幅画面在电脑里到底长什么样?

电脑屏幕上显示的每一幅画面,都是由大量小方块组成的。每一个小方块叫做一个像素。如果你凑近屏幕仔细看,甚至能看到一个个像素的格子。1920×1080 的分辨率,就是宽 1920 个像素、高 1080 个像素——总共约 207 万个像素排列成一个巨大的矩阵。

每个像素只做一件事:发出一种颜色。而所有颜色都可以用三个数字来描述——红(R)、绿(G)、蓝(B)。这不是随便选的:人眼视网膜上恰好有三种感知颜色的细胞,分别对红光、绿光、蓝光最敏感。所以用红绿蓝三种光的混合,就能模拟出人能看到的几乎所有颜色。电脑屏幕上的每一个像素,其实就是三个超微型灯泡——一个红的、一个绿的、一个蓝的,靠调节各自的亮度来混合出不同颜色。

每个数字的范围是 0 到 255。0 表示"这个颜色完全不发光",255 表示"开到最亮"。举个例子:纯红色 = (255, 0, 0),纯白色 = 三色全开到 255 = (255, 255, 255),纯黑色 = 三色全关 = (0, 0, 0),灰色 = 三色等量一半 = (128, 128, 128)。

所以一幅 1920×1080 的静态图片,在电脑内部的存储形式就是 207 万组 (R, G, B) 数字。每秒连续播放 30 张这样图片,就是 30fps 的视频。画面采集——无论是读视频文件、拿摄像头数据、还是抓取屏幕——本质上都是获取这些 (R, G, B) 数值的过程。

RGB像素结构:每个像素由红绿蓝三个子像素组成 R G B 1 个像素(放大) 1920×1080 像素矩阵 每个格子是一个像素 = 一组(R,G,B)数值

▲ 左:一个像素放大后的 RGB 三色子像素结构。右:1920×1080 画面 = 207 万个像素组成的大矩阵,每个像素存储一组 (R,G,B) 数值。

采集方式一:播放本地视频文件

这是最直观的方式——从硬盘上打开一个 .mp4 或 .mkv 文件,把里面的画面一帧帧读出来。

但实际做起来不简单,因为视频文件里的数据是压缩过的。我们刚才算过,1920×1080、每秒 30 帧的原始画面,一秒钟就是 186MB,一分钟超过 10GB。你的硬盘根本存不下几部电影,网络也传不动。所以存储和传输之前,视频必须被压缩——这个压缩的过程叫编码。播放时要做的第一件事就是解码——把压缩数据还原成可以显示的画面。

补充知识:视频是怎么被压缩的?

视频压缩的核心思路是:相邻的两帧画面之间,绝大部分内容是一样的。比如一段访谈节目,背景的墙壁、桌子、灯光从头到尾不变,只有主持人的嘴巴和手势在动。如果每一帧都完整存一遍"墙壁是什么颜色、桌子是什么颜色……",那绝大多数的存储空间都被浪费在重复内容上了。

编码器很聪明:它只记录"从上一帧到这一帧,哪些像素变了、变成了什么"。一帧画面中,可能只有 1% 的像素真的发生了变化。解码的时候,解码器拿着上一帧的完整画面,再加上这 1% 的"变化量",就能算出当前帧的完整画面。这就是为什么视频可以压缩几百倍、画质却几乎看不出差别。

视频帧间压缩原理:I帧完整存储,P帧只存与上一帧的差异 帧间预测压缩原理 I 帧(关键帧) 完整 存储全部像素 P 帧(预测帧) 只存"圆右移了10px" P 帧(预测帧) 只存"又右移了10px" I 帧:独立存储完整画面,是解码的锚点。通常每隔 1~2 秒出现一次。 P 帧:只存储与前一帧的差异("圆向右移了 10 个像素")。数据量通常只有 I 帧的 1/10~1/50。 解码时:播放器拿到 I 帧 → 还原完整画面;拿到 P 帧 → 用上一帧 + 差异量算出当前帧。

▲ I 帧存完整画面(大),P 帧只存变化量(小)。这就是视频能压缩几百倍的核心原理。

解码之后还有一个问题:怎么让声音和画面对上?视频文件里的画面数据和音频数据是分开存的(因为它们的压缩方式完全不同)。播放时需要根据每段数据上的时间戳——就是记录"这一帧画面应该在视频的第几秒第几毫秒出现""这一段音频应该在什么时刻播放"的标记——来同步画面和声音。如果时间戳没处理好,就会出现"画面比声音慢半拍"的情况,这叫做"音画不同步"。

FPlayer 的文件播放功能有两套实现:

  • FFmpeg 后端:自研播放器,约 1265 行代码。内部把工作拆成三个可以同时进行的任务——一个专门从文件里读取压缩数据,一个专门把压缩数据还原为画面,一个专门处理音频——让它们互不等候、各跑各的。这种"几个任务同时做"的安排叫做多线程,本章后面会详细解释。这个后端支持精确到毫秒的进度跳转、0.5 到 2 倍速播放(音频也会同步变速)、循环播放等精细控制。
  • Qt 6 后端:直接调用 Qt 框架自带的播放器。代码简单,但功能受限——能播什么格式取决于操作系统装了哪些解码器。Windows 自带了一些常见格式的解码器,但如果遇到小众格式可能就播不了。

这里出现了两个名字——FFmpegQt。FFmpeg 是一个开源工具集。"开源"的意思是代码是公开的,任何人都可以免费使用和修改。它几乎能处理所有音视频格式、支持所有流媒体协议、还能利用显卡做硬件加速。Qt 是一个做桌面软件界面的工具箱——它提供按钮、窗口、菜单这些"界面零件",让开发者不需要从零画一个按钮出来。Qt 也顺带提供了一些媒体播放和摄像头访问的能力。FPlayer 同时用这两套工具来实现同一个功能(播放文件),这就是"多后端"的由来——第四章会展开讲这种设计。

采集方式二:摄像头

摄像头的工作原理不复杂:镜头把环境光汇聚到感光芯片上 → 感光芯片把光信号转为电信号 → 芯片内部电路把电信号转为数字像素数据 → 通过 USB 线把数据传给电脑。

FPlayer 拿到摄像头数据之前需要做三件事:

  • 列出设备:电脑上可能连着多个摄像头——笔记本内置的、USB 外接的、甚至虚拟摄像头软件(比如 OBS 可以把自己模拟成一个摄像头,这样其他软件就能把 OBS 的画面当作摄像头画面来用)。FPlayer 自动搜索所有可用的摄像头,列出来给你选。在 Windows 上,它是通过微软提供的一套叫 DirectShow 的系统接口来搜索的;在 Linux 上,用的是 Linux 内核自带的 Video4Linux2(简称 v4l2)驱动框架。
  • 协商参数:每个摄像头都支持多种分辨率和帧率的组合。比如一个摄像头可能同时支持 1080p 每秒 30 帧、720p 每秒 60 帧、480p 每秒 120 帧。FPlayer 向摄像头询问"你能支持哪些模式",然后列出让你选。这个"询问-应答"的过程叫"格式协商"。
  • 持续读取:选定格式后,FPlayer 持续从摄像头读取画面帧。采集到的原始数据通常是 YUV 格式(一种比 RGB 更省空间的颜色表示方式,第三章会详细解释),被放入帧总线——一个各模块之间共享画面数据的中转区——然后再分两路走:一路送去屏幕给你看(预览),一路供推流或组合模式使用。

采集方式三:屏幕捕获

屏幕捕获就是把"显示器上正在显示的内容"作为画面来源。这比摄像头复杂,因为要抓的画面已经存在显卡里面了——关键不是去哪找画面,而是怎么最高效地拿出来。

前置概念:显卡和显存是什么?它们和 CPU、系统内存有什么区别?

你的电脑里有两个"大脑":CPU(中央处理器)和 GPU(图形处理器,也就是显卡的核心芯片)。它们各自配有自己专用的存储空间——CPU 用的是插在主板上的内存条(系统内存),GPU 用的是焊在显卡电路板上的显存(VRAM)。

CPU 和 GPU 的分工很明确:CPU 擅长做复杂但数量少的工作——逻辑判断、任务调度、网络通信,一次同时处理几十件事。GPU 擅长做简单但数量极多的工作——画面渲染、像素计算,几千个计算核心同时开工。所以显示器的画面是 GPU 算出来的,结果存在显存里。CPU 如果想碰这个画面数据,就需要通过一条叫 PCIe 总线的通道从显存把数据搬到系统内存——这条通道虽然很快,但比显存内部的数据搬运速度还是慢了很多。

这就引出了一个关键的性能问题:如果屏幕捕获的方式是"先把画面从显存搬到系统内存,CPU 处理一下,再搬回显存去做编码",那数据就在这条慢通道上走了两个来回,白白浪费时间和电量。最好的方式是什么?让数据从头到尾别离开显存。

CPU(少量大核心)vs GPU(大量小核心)架构对比 CPU(中央处理器) 4~16 个核心 每个核心很聪明,能处理复杂逻辑 核1 核2 核3 核4 适合:流程控制、逻辑判断 GPU(图形处理器) 数百~数千个核心 每个核心只会简单运算,但数量极多 适合:大量像素同时运算

▲ CPU 核心少但每个都强(复杂逻辑),GPU 核心极多但每个只做简单运算(大量像素同时处理)。

FPlayer 有三套屏幕捕获方案,性能和平台适配各有侧重:

  • Qt 6 方案:用 Qt 框架自带的 QScreenCapture。代码最简单,但在 Windows 上性能不是最优的——数据还是要经过 CPU 内存。
  • FFmpeg 方案:在 Windows 上用 gdigrab(微软最早期的图形设备接口——GDI——提供的屏幕抓取方式),在 Linux 上用 x11grab(Linux 上最常用的图形系统 X11 的抓屏工具)。兼容性非常好——几乎所有 Windows 和 Linux 系统都能用,但性能一般,数据必须从显存搬到系统内存。
  • DXGI 方案(仅 Windows):DXGI 是 DirectX Graphics Infrastructure 的缩写,属于微软 DirectX 图形技术栈的一部分。它允许程序直接在显存里访问 GPU 渲染好的画面,不需要把数据搬到系统内存。采集、缩放、编码全部在显卡内部完成——数据从头到尾没离开过显存。结果就是 CPU 占用几乎为零,帧率更高、延迟更低。这是 Windows 上抓屏的最优方案。

三套方案都支持:选择多个显示器中的某一个、自定义捕获哪个矩形区域、控制每秒抓多少帧、选择是否在画面中包含鼠标光标。

图 1 · 三种采集方式与数据流
flowchart TD
    subgraph 来源["三种画面来源"]
        FILE["视频文件 .mp4/.mkv"]; CAM["摄像头 USB/内置"]; SCR["屏幕 显示器画面"]
    end
    subgraph 后端["两种后端实现"]
        FF["FFmpeg 后端"]; QT["Qt 6 后端"]
    end
    subgraph 输出["统一输出"]
        YUV["转为 YUV 格式"]
    end
    FILE-->FF; FILE-->QT; CAM-->FF; CAM-->QT; SCR-->FF; SCR-->QT
    FF-->YUV; QT-->YUV
    style YUV fill:#cc785c,stroke:#cc785c,color:#fff
      

▲ 不论来源是什么、用哪个后端实现,最终都统一输出 YUV 格式画面帧。这是系统最重要的设计约定——让后续所有处理(编码、组合、推流)只面对一种数据格式。

采集之后:一帧画面同时做两件事

采集到的每一帧画面会同时走两条路:

  • 预览路径:画面被送到一个叫 FGLWidget 的显示控件。这个控件内部使用 OpenGL——一种让程序直接指挥显卡做图形计算的技术标准。OpenGL 让显卡把 YUV 格式的画面数据实时转换成 RGB 格式显示在屏幕上。显卡有几千个核心,可以同时处理几百上千万像素的转换,速度极快而且不占用 CPU 的计算时间。
  • 数据路径:画面被放入帧总线(FrameBus)。帧总线是程序中不同任务之间传递画面数据的一块共享存储区。采集任务把画面放进去,编码任务(如果正在推流的话)从里面取走。这个放入和取出的过程不是随意的——帧总线内部有一个叫的保护机制:一个任务正在往里写的时候,另一个任务不能同时读取;反之亦然。这就保证不会出现"读了一半数据就被覆盖"的坏数据情况。

两条路同时运转。即使你只是在预览画面(没有推流),采集、格式转换、显卡渲染这一整套流程也已经在运行了。

第一章要点

✓ 数字画面 = 像素矩阵 × (R, G, B) 三色数值。视频 = 连续播放的静态画面
✓ 视频文件里的数据是压缩过的,播放需要先解码。压缩靠"帧间预测"——只记录相邻帧之间的变化
✓ 摄像头通过 USB 把像素数据发给电脑,FPlayer 负责列出设备、协商参数、持续读取
✓ 屏幕捕获有三种方案:Qt(简单)、FFmpeg(通用)、DXGI(最快——数据不离开显存)
✓ 显卡有独立显存,PCIe 总线搬运数据比显存内部慢很多。DXGI 的高效来自数据全程留在显存里
✓ 采集到的每一帧同时走预览(送去屏幕显示)和数据(放入帧总线)两条路

Chapter 02

多画面怎么叠成一路

组合模式是 FPlayer 最具差异化的功能。多个画面来源在同一个画布上自由布局,最终合成一路视频流推出去。

先理解:一个程序怎么能同时做好几件事?

一个普通的程序默认是一步一步执行的:做完第一步,再做第二步,然后第三步。但在处理视频时,这种方式会导致严重的问题。

因为有三件事需要同时进行:采集画面(每秒必须抓满 30 帧,每 33 毫秒就要抓到一帧,不能耽搁)、编码压缩(每一帧都要做大量数学运算来压缩,计算量大)、网络发送(网速不确定,随时可能堵)。如果排队做,会变成"采集一帧 → 等编码完 → 等发送完 → 再采集下一帧"。采集线程本应该每 33 毫秒抓一帧,但编码可能要 20 毫秒、发送再要 10 毫秒——等全部做完,下一帧的抓取时间早过了。结果就是帧率根本达不到 30fps,画面卡顿。

什么是"线程"?

解决这个问题的方法是让程序内部创建多个"线程"。一个线程就是程序内部的一条独立执行路线。一个程序可以同时有多个线程在跑,每个线程各做各的事,操作系统会快速地在它们之间切换(快到人感觉不到切换),让它们看起来像在同时运行。

所以 FPlayer 把采集、编码、发送拆成三个线程:采集线程只管不停地抓画面;编码线程只管不停地压缩;发送线程只管不停地发数据。三条线各跑各的,快的不用等慢的。

单线程(排队做)vs 多线程(同时做) 单线程(排队做)— 慢 采集第1帧 编码第1帧 发送… 采集要等编码完、编码要等发送完…帧率根本达不了标 多线程(同时做)— 快 采集线程 编码线程 发送线程 三条路同时跑,互不等候

▲ 上:单线程排队做,每一步都要等上一步完成。下:三个线程各干各的,谁也不等谁。

多线程带来一个新问题:怎么防止数据被"抢坏"?

两个线程如果同时操作同一块数据就会出问题。假设采集线程正在往内存里写"这一帧第 100 万个像素是红色",写了一半;编码线程正好也开始从同一个位置读这一帧——它读到的前一半是旧数据(还没被覆盖),后一半是新数据(刚写的红色)。结果就是编码线程拿到一帧"半新半旧"的垃圾数据。

解决办法是"锁"(Mutex,互斥锁的缩写)。它的逻辑很简单:在读写共享数据之前,先去拿锁。如果锁是开着的,就锁上它,然后安全地读写数据,读完了解锁。如果锁已经被别人锁上了,就在旁边等着,等到解锁了再抢锁。这样一次只有一个线程在操作这段数据。

帧总线内部就大量使用了锁——每次采集线程往里写帧、或编码线程来取帧,都先上锁再操作。

Mutex 锁的工作机制 采集线程(写数据) 🔒 先上锁 ✏️ 写入帧数据 编码线程(想读数据) ⏳ 锁被占了,等着… 不能读(会被抢坏) 采集线程(写完了) 🔓 解锁 → 去抓下一帧 编码线程(等到了) 🔒 抢到锁 → 📖 安全读取

▲ 左:采集线程先上锁再写数据。右:编码线程发现锁被占了,在外面等。写完后解锁,编码线程抢到锁后再读取。

帧总线:采集和合成之间怎么交接

采集线程每抓到一帧画面,就放进帧总线(FrameBus)——一块被多个线程共享的存储区。然后采集线程立刻回去抓下一帧,不等任何人。需要用到画面帧的其他线程(比如组合模式中的编码/合成逻辑,或者推流的编码线程)定期来帧总线查看:有没有新帧?有就取走,没有就过一会儿再来。

帧总线的设计有三个要点:

  • 编号机制:每一帧都有一个不断增加的编号——采集线程每放一帧进来就编号加 1(第 42 帧、第 43 帧……)。取帧的线程会记住自己上次取到的是第几帧。下次来看的时候,如果编号没变(还是 42),说明没新帧,就不用白费力气。如果编号变了(现在是 43),说明新帧来了,取走。这个设计避免了重复处理同一帧。
  • 多个独立通道:实际由两套独立的帧总线组成——CameraFrameBus(摄像头帧总线)和 ScreenFrameBus(屏幕帧总线)。每套总线内部通过 QHash<QString, Channel> 按来源 ID 字符串管理多个通道(如"camera_0"、"screen_1"),不同通道的数据完全隔离。文件播放走独立的 Player 实例,不经过帧总线。后面要讲的组合模式,StreamFFmpeg 推流时会同从多个通道取帧。
  • 线程安全:帧总线里每个通道都有一把锁。采集线程往里写之前先锁住那个通道,写完了解锁。取帧的线程也是先锁住、再读取、再解锁。锁的"粒度"很细——只在真正读写的那一瞬间锁定(通常只有几微秒),锁完了立刻释放。所以基本不会出现"一个线程干等着另一个线程"的情况。
图 2 · 采集线程通过帧总线与其他线程交接帧数据
sequenceDiagram
    participant CAP as 采集线程
    participant BUS as 帧总线(多通道+锁)
    participant COMP as 编码/合成逻辑
    CAP->>CAP: 抓一帧 YUV 画面
    CAP->>BUS: 锁 CameraFrameBus 通道 → 写入帧(编号42) → 解锁
    Note over BUS: 新帧覆盖该通道旧帧
    CAP->>CAP: 立刻去抓下一帧
    COMP->>BUS: 锁通道 → 检查编号(42 > 上次41?)→ 拷贝 → 解锁
    BUS-->>COMP: 拿到编号42的帧
    COMP->>COMP: 用这帧参与合成
  

组合模式:多源混合推流

组合模式下,UI 层使用 QMdiArea(Qt 多文档界面)管理多个可拖拽、可缩放、可调整层级的子窗口,每个子窗口嵌入一个独立的采集源(摄像头/屏幕/文件)。当进行推流时:

  1. StreamFFmpeg 推流引擎从帧总线(CameraFrameBus / ScreenFrameBus)的各通道取最新帧
  2. 按图层的 Z 序——从最底层到最顶层,逐像素叠加合成。上层像素覆盖下层
  3. 每一层不一定铺满整个画布:可能摄像头画面只占左下角一小块、屏幕画面铺满背景、文件画面在右上角。StreamFFmpeg 合成逻辑根据每层的位置参数,准确地把像素写到画布的对应坐标
  4. 全叠完之后,得到一张完整画面——这就是最终要推流出去的画面
  5. 把这帧送去编码压缩,再走 RTMP 协议推送到服务器

每个素材源是完全独立控制的。如果你暂停了视频文件的播放,只是它对应的帧总线通道不再更新——编号停了,编码线程来取帧时发现编号没变,就不取这一路。这一路画面就"冻结"在最后一帧。摄像头和屏幕完全不受影响,继续正常更新帧。如果你替换掉一个素材源,只是把对应通道换成了另一个来源——其他通道完全不感知。这种"各管各的"独立设计,让组合模式的扩展性很强——未来要叠加字幕、水印、还是过渡效果,都是加一个新通道的事,不需要动任何现有通道的逻辑。

图 3 · 组合模式:三源合成一路
flowchart LR
    subgraph 来源["独立来源"]
        P1["视频文件"]; C1["摄像头"]; S1["屏幕"]
    end
    subgraph 通道["帧总线通道"]
        CH1["CameraBus
摄像头"]; CH2["ScreenBus
屏幕"]; CH3["Player实例
文件"] end subgraph 合成["合成 & 编码"] COMP["从底到顶逐层叠像素
→ 生成完整画面
→ 编码 → 推流"] end P1-->CH1; C1-->CH2; S1-->CH3 CH1-->COMP; CH2-->COMP; CH3-->COMP style COMP fill:#cc785c,stroke:#cc785c,color:#fff style CH1 fill:#efe9de,stroke:#e6dfd8,color:#141413 style CH2 fill:#efe9de,stroke:#e6dfd8,color:#141413 style CH3 fill:#efe9de,stroke:#e6dfd8,color:#141413
第二章要点

✓ 线程 = 程序内部的一条独立执行路线。多线程让采集、编码、发送同时跑,互不等候
✓ 锁 = 保护共享数据的机制,一次只让一个线程操作,防止读出半新半旧的垃圾数据
✓ 帧总线 = 多线程共享的画面中转区:采集线程往里放,合成/编码线程来取
✓ 递增编号防止重复处理,多通道让不同画面源互不干扰
✓ 组合 = StreamFFmpeg 从多通道同时取帧 → 按图层顺序从底到顶叠像素 → 生成完整画面

Chapter 03

画面怎么到达观众

画面采集好了、组合好了,接下来要压缩编码,然后通过网络发送出去。这是整个链路中计算量最大的环节。

推流和拉流

推流:一台设备持续地把编码压缩后的视频数据发送给另一台设备或服务器。FPlayer 桌面端做的是推流——把采集到的(或组合好的)画面压缩后发出去。拉流:一台设备从服务器持续拉取视频数据,然后解码播放。FPlayer 手机端做的是拉流。

两者的关键区别在于谁发起传输:推流是发送方主动发,拉流是接收方主动要。

数据在网络上是按什么规矩传的?

两台设备要在网络上传输数据,必须事先约定好一套规矩——数据包长什么样、谁先发谁后发、传错了怎么补救。这套规矩就叫协议

前置知识:数据在网络上是怎样传输的?

网络上传输的任何数据——网页、视频、文件——都会被拆成一个个"数据包"来发送。每个数据包就像是快递包裹:有寄件人地址(源 IP 地址)、收件人地址(目标 IP 地址)、还有包裹内容(数据本身)。IP 地址就是一串数字,用来在网络中标定一台设备——就像每栋房子有唯一的门牌号,每台连网的设备也有唯一的 IP 地址。

数据包到了目标设备之后,还需要知道"这个包是给哪个程序处理的?"——因为一台设备上可能同时开着浏览器、微信、FPlayer 等多个在联网的程序。这时候就需要端口号——一个数字标签,用来区分同一台设备上的不同程序。比如网页服务器通常用 80 端口(HTTP)或 443 端口(HTTPS),FPlayer 推流用的 RTMP 协议默认用 1935 端口。

所以一个完整的网络地址 = IP 地址(找到哪台机器)+ 端口号(找到哪个程序)。

网络数据包逐层封装结构 数据包的层层封装(发送时从内向外封装,接收时从外向内拆封) 第4层 · TCP 头:序号、校验码(保证数据顺序正确、不出错) 第3层 · IP 头:源 IP 地址(发件人)、目标 IP 地址(收件人) 第2层 · HTTP/RTMP 头:协议类型、内容长度、请求路径(/live/stream001) 第1层 · 实际数据:压缩后的音视频画面数据 ← 端口号 ← 地址 ← 协议 ← 内容

▲ 数据发送时,从内到外逐层封装(加信封);接收时从外到内逐层拆封。每层协议解决不同的问题。

流媒体领域常用协议:

  • RTMP:Adobe 公司十几年前设计的一种传输方式。它在推流端和服务器之间建立一条TCP 连接——TCP 是网络上两种主要传输方式之一(另一种叫 UDP),它的特点是保证数据一定送到、顺序一定不乱、如果中途丢了一个包会自动重发。相当于"挂号信"——寄到了才罢休。RTMP 靠这条 TCP 连接持续传视频数据,端到端延迟大约 1~3 秒,目前仍是推流的主力协议。缺点是使用 1935 端口,有些公司防火墙会拦截非标准端口。
  • HTTP-FLV:把 RTMP 格式的视频数据包装在 HTTP 格式里传输。HTTP 就是浏览器上网用的那个协议——你在地址栏输入网址时前面那个 "http://" 或 "https://"。所有防火墙都放行 HTTP(否则网页就上不了了),所以 HTTP-FLV 的网络穿透性比纯 RTMP 好,延迟同样在 1~3 秒。
  • HLS:Apple 公司设计的一种方式。它不传输持续的数据流,而是把视频切成一段一段几秒钟长的小文件(.ts 文件),然后用一个播放列表(.m3u8 文件)把这些小文件串起来。播放端就像下载普通文件一样,依次下载这些分片并按顺序播放。这种方式延迟最高(5~15 秒,因为必须攒够一个分片才能开始播),但有一个巨大的好处:兼容性极好——几乎所有手机、平板、浏览器都能直接播;而且这些分片文件可以被 CDN 缓存。CDN 是分布在世界各地的文件缓存服务器——把热门内容提前存放在离用户最近的服务器上,这样用户不用每次都去源服务器下载,速度快很多。

FPlayer 推流支持 RTMP / RTSP / SRT 三种协议(RTMP 最常用),拉流支持 RTMP / HTTP-FLV / HLS 三种。桌面端也具备拉流能力——可通过输入播放 URL 直接拉取远端流,并支持拉流同时录制。在服务器模式下,ZLMediaKit 会自动把收到的 RTMP 流转成 HTTP-FLV 和 HLS 两种格式同时输出。

先理解 YUV —— 画面在 FPlayer 内部的"通用语言"

在第一章讲 RGB 时我们说过,每个像素用 (R, G, B) 三个数值来表示颜色。但 FPlayer 内部不用 RGB 来存储和传输画面,而是用 YUV。为什么?

RGB 把每个像素的红、绿、蓝都完整存下来。一个 1920×1080 的画面,每个像素 3 个值,共约 6.2MB。YUV 的做法不同:它把"亮度"和"颜色"拆开了——Y 通道专门存亮度(明暗),U 和 V 通道存颜色信息。关键来了:在 YUV420 格式中,Y 每个像素都存,但 U 和 V 每 4 个像素才存一组。这样一帧只需要约 3.1MB——比 RGB 直接省了一半。

能这么省是因为人眼的生理特性:人眼视网膜上感知亮度的细胞(约 1.2 亿个视杆细胞)数量是感知颜色细胞(约 600 万个视锥细胞)的将近 20 倍。我们对"明暗"的敏感度远高于对"颜色细节"的敏感度。所以把颜色信息压缩到四分之一,人眼几乎看不出来。

RGB 和 YUV420 存储方式对比 RGB:每个像素存满 (R,G,B) R1 G1 B1 R2 G2 B2 R3 每个像素 = 3 个值,一帧 1920×1080 ≈ 6.2MB YUV420:颜色每 4 个像素才存一组 Y1 Y2 U+V(4像素共用) Y3 Y4 Y 每像素存(亮度),UV 每 4 像素存(颜色) 一帧 1920×1080 ≈ 3.1MB(省一半)

▲ RGB 每像素存满 3 个值;YUV420 中 Y(亮度)每个都存,UV(颜色)4 个像素共用一组。利用人眼对亮度比颜色敏感 20 倍的生理特性,省一半空间而不影响观看体验。

怎么把海量数据压到能传得动?

YUV 格式已经比 RGB 省了一半——1080p 30fps 大约 93MB/秒。但这还是太大,局域网里勉强跑,互联网上传不动。必须用编码再压缩几百倍。

FPlayer 支持两种编码标准:H.264(目前最通用,所有设备都支持,压缩比约 100:1 ~ 200:1)和 H.265(也叫 HEVC,H.264 的升级版,相同画质下码率能再省约一半,但编码更费计算力,只有较新的设备才支持硬件解码)。

编码有两种实现途径:

硬件编码:显卡芯片上有一块专用的硬件电路,只做视频压缩这一件事,别的什么都不干。因为压缩算法被直接刻在了电路上(不是靠软件一步步执行),所以速度极快、功耗极低,而且不占 CPU 和 GPU 的通用计算资源。这块专用电路在不同品牌的显卡上有不同名字——NVIDIA 的叫做 NVENC,AMD 的叫做 AMF,Intel 核显的叫做 QSV(Quick Sync Video)。缺点是参数调节的灵活度不如软件编码——比如码率范围、某些高级画质参数可能调不了。

软件编码:用 CPU 运行一个编码程序。因为编码算法是软件代码,所有参数都可以自由调节,画质上限更高。但缺点也明显——CPU 要同时处理操作系统、FPlayer 界面、网络通信等一堆事情,再来一个极高负载的编码任务,很容易占满 CPU,导致整个电脑变慢。FPlayer 用的软件编码器是 libx264——H.264 标准的一个开源软件实现,是全世界应用最广的软件编码器之一。

FPlayer 启动推流时,会自动按优先级探测可用的编码器:

图 4 · 编码器自动选择
flowchart TD
    START["需要编码"] --> NV{"有 NVIDIA 显卡?"}
    NV -->|"有"| NVENC["NVENC 硬件编码"]
    NV -->|"无"| AMD{"有 AMD 显卡?"}
    AMD -->|"有"| AMF["AMF 硬件编码"]
    AMD -->|"无"| INTEL{"有 Intel 核显?"}
    INTEL -->|"有"| QSV["QSV 硬件编码"]
    INTEL -->|"无"| SW["libx264 CPU 软件编码
任何电脑都能用"] style NVENC fill:#cc785c,stroke:#cc785c,color:#fff style AMF fill:#cc785c,stroke:#cc785c,color:#fff style QSV fill:#cc785c,stroke:#cc785c,color:#fff style SW fill:#e8e0d2,stroke:#d9d0c0,color:#141413

这套策略对用户完全透明——你不需要知道自己电脑是什么显卡,软件自动找最好的方案。好显卡用硬件加速,没有独显用核显,连核显都没有就老老实实用 CPU——保证在任何电脑上都能正常推流。

推流的六种内部处理路径

FPlayer 的推流模块收到推流指令后,根据画面来源自动选择内部处理方式:

画面来源怎么处理什么时候用
视频文件(不需调参数)流拷贝:不解码不重新编码,直接把文件中的压缩数据原封不动搬运到网络流。速度最快,画质无损。快速分享原画质视频
视频文件(需调参数)转码:完全解码 → 重新设分辨率/码率 → 重新编码 → 推送。灵活但慢。文件太大需降画质
摄像头直接从设备取流 → 编码 → 推送纯摄像头直播
屏幕直接从屏幕抓取画面 → 编码 → 推送桌面演示
摄像头/屏幕(已在预览中)不重复开设备(摄像头不能同时被两个程序占用),从帧总线取预览帧 → 编码 → 推送预览已在运行,直接复用
组合模式从帧总线多通道同时取帧 → 合成 → 编码 → 推送多画面混合直播

"流拷贝"(行业内叫 remux)和"转码"(行业内叫 transcode)有本质区别:流拷贝只换"外包装"——视频容器格式从 .mp4 变成 RTMP 网络流——但里面的压缩数据完全没动。所以速度只受硬盘读取速度限制,画质完全无损。转码则要把压缩数据完全解开再重新压缩——可以改变分辨率、码率、编码格式,但每次重新压缩都会有质量损失(就像反复保存 JPEG 图片,每次都会更模糊一点)。

声音是怎么处理的

推流不只有画面。FPlayer 处理声音的整个流程叫"音频管线"——"管线"在编程中的意思是数据经过的一系列按顺序排列的加工步骤。音频管线分五步:

  1. 采集:在 Windows 上使用 WASAPI 接口直接捕获电脑正在播放的声音。WASAPI 是 Windows Vista 以后内置在操作系统里的音频子系统——相当于操作系统替你做了一套"录音"的底层机制,应用程序只需要调用它提供的接口,不需要自己去跟声卡硬件打交道。同时可以采集麦克风输入。
  2. 混音:如果同时开了系统声音和麦克风,在这一步把两路混合成一路。需要处理音量平衡(系统声音和麦克风音量可能相差很大)、采样率不一致(系统声音可能是 48000Hz、麦克风是 44100Hz)的问题。Hz 即赫兹,表示每秒对声音波形取多少次样——数值越高声音越保真。
  3. 格式统一:不同来源的音频可能有不同的声道数(单声道 vs 立体声)和采样率,在这一步统一成编码器需要的格式。
  4. AAC 编码:把原始音频压缩成 AAC 格式。AAC(Advanced Audio Coding,高级音频编码)是目前最通用的音频压缩格式,所有手机、电脑、浏览器都支持。
  5. 音视频交错:这是防止"音画不同步"的关键一步。音频帧和视频帧各自都带着时间戳,发送时不是"先把画面全发完再发声音"(那样播放端就会先看到画面没声音、然后突然有声音没画面),而是按时间戳交替排列:视频帧1 → 音频帧1 → 音频帧2 → 视频帧2 → 音频帧3 → 视频帧3。交错发送后,播放端按时间戳把画面和声音配对还原,就得到了同步的视听体验。
音频管线:声音数据经过的五步处理 ① 采集 系统音+麦克风 ② 混音 多路合一路 ③ 格式统一 采样率+声道 ④ AAC编码 压缩音频 ⑤ 交错发送 防音画不同步 交错发送顺序(按时间戳排列) 视频帧1 音频1 音频2 视频帧2 音频3 视频帧3 音频4 视频帧4 音频5

▲ 上:音频管线的五个处理步骤。下:音视频交错发送的顺序——视频帧和音频帧按时间戳交替排列,蓝色=视频,绿色=音频。

第三章要点

✓ 协议 = 网络传输的规矩。RTMP 低延迟推流;HLS 兼容性最好但延迟高(切片方式);HTTP-FLV 介于两者
✓ 数据在网络上以数据包形式传输,IP 地址找机器、端口号找程序
✓ 硬件编码 = 显卡上的专用电路做压缩,快且省电;软件编码 = CPU 跑程序,灵活但慢
✓ 编码器自动降级:NVENC → AMF → QSV → libx264,有好的先用好的
✓ 六种推流路径,流拷贝 vs 转码:不碰压缩数据(快且无损)vs 重新压缩(灵活但有损)
✓ 音频管线五步:采集 → 混音 → 格式统一 → AAC 编码 → 音视频交错(防不同步)

Chapter 04

代码是怎么组织的

整个桌面端的代码分成 7 层,只遵循一条核心规则:只有 runtime 层知道所有后端的存在。

先讲清楚两个概念

接口 —— 一份"功能清单"

写程序就像盖房子。如果你把厨房、卧室、电线、水管全部混在一起砌——以后想换一个水龙头,就得砸墙。所以盖房子要分成结构、水电、装修等不同工种,每个工种只按规定的方式和其他工种对接。软件也是一样——要分模块,模块之间通过接口对接。

接口是什么?就是一份"你必须能做什么"的清单。比如"播放器接口"可能写:必须有播放、暂停、快进、调音量四个功能。至于这四个功能具体怎么实现——是用 FFmpeg 解码再渲染,还是调用系统自带的播放器——接口不管。接口只管"有没有"。

定义接口的好处:使用播放器的那些代码,只依赖这份清单,不依赖清单是怎么被实现的。以后想换一种播放方式,只要新的实现也满足接口的要求,所有使用它的代码一行都不用改。这种做法的核心价值是"解耦"——让软件的不同部分彼此不知道对方内部的细节,从而可以独立修改和替换。

FPlayer 定义了四个核心接口:IPlayer(播放器)、ICamera(摄像头)、IScreenCapture(屏幕捕获)、IStream(推拉流)。

后端 —— 接口的多种实现

同一个接口可以有多个后端——就是对于同一份"功能清单",有几种不同的实现方式。FPlayer 的"播放器"接口就有两个后端:用 FFmpeg 自己写的(功能最强、支持格式最全)和用 Qt 自带的(代码最简单、集成最方便)。

你要一个播放器的时候,拿到的只是一个"实现了 IPlayer 接口的对象"——你可以调它的播放、暂停、快进,但它内部到底是 FFmpeg 还是 Qt,你不知道,也不需要知道。这种设计叫"策略模式":同一个功能有多种实现策略,可以在运行时按配置选用哪一种。FPlayer 为什么要多后端?因为不同场景的最优解不一样——随手放个视频用 Qt 够简单,要全格式支持和精准控制用 FFmpeg,Windows 上高速抓屏用 DXGI。

七层架构

图 5 · 七层架构
graph TD
    APP["app/ 启动层"]; WIDGET["widget/ 界面层"]; SERVICE["service/ 业务层"]
    RUNTIME["runtime/ 工厂层
← 唯一切换点"] BACKEND["backend/ 后端层
FFmpeg | Qt6 | DXGI"] API["api/ 接口层"]; COMMON["common/ 工具层"] APP-->WIDGET; WIDGET-->SERVICE; SERVICE-->RUNTIME RUNTIME-->BACKEND; BACKEND-->API; API-->COMMON WIDGET-->API; SERVICE-->API style RUNTIME fill:#cc785c,stroke:#cc785c,color:#fff style BACKEND fill:#e8e0d2,stroke:#d9d0c0,color:#141413 style API fill:#efe9de,stroke:#e6dfd8,color:#141413
  1. app/ 启动层 — ~140 行

    程序的入口。做的事:初始化环境 → 读命令行参数确定用哪个后端 → 创建主窗口 → 启动事件循环。事件循环是桌面程序运行的基础:程序启动后进入一个循环,不断问"有人点按钮了吗?有新画面帧到了吗?有网络数据来了吗?"——每发现一件事就调用对应的处理代码。这就是为什么你点按钮程序能立刻响应——事件循环一直在那等着。

  2. widget/ 界面层 — ~8,800 行

    所有你看得到的按钮、弹窗、菜单、视频画面。核心组件:CaptureWindow(主窗口,管理三种采集模式切换 + 组合模式布局 + 推流设置)、FVideoView(视频显示控件,内部根据后端类型自动选择用 OpenGL 渲染还是 Qt 原生控件显示)、AiChatDialog(AI 对话窗口)、ImagePoolSidebar(截图管理侧栏)。

  3. service/ 业务层 — ~1,800 行

    不做具体音视频处理,只做决策。比如你点"开始推流",这一层判断:推的是摄像头还是屏幕还是文件还是组合画面?→ 组织推流参数 → 找 runtime 创建对应的推流对象 → 启动推流。

  4. runtime/ 工厂层 — ~190 行(最关键)

    整个项目唯一知道所有后端存在的一层。它根据配置决定创建 FFmpeg 后端还是 Qt 后端还是 DXGI 后端。以后想加一个新后端——比如基于 GStreamer 或 MediaFoundation 的后端——只需实现 api/ 中的接口并在这一层注册,其他层完全不用动。这就是"把创建决策集中在一个地方"的价值。

  5. backend/ 后端层 — ~13,800 行(真正干活)

    五个子模块:media_ffmpeg(FFmpeg 播放/采集)、media_qt6(Qt 播放/采集)、stream_ffmpeg(推拉流,最复杂)、desktopcapture_dxgi(显卡直接抓屏,仅 Windows)、net_qt6(局域网设备发现,空桩,默认关闭)。

  6. api/ 接口层 — ~340 行

    只有四个头文件(.h 文件——在 C++ 中用来声明"有哪些东西可以用"而不写具体实现的文件),每个定义一个纯接口。刻意不引用任何 FFmpeg 或 Qt 的内部头文件——保持接口"纯净"。因为如果接口层依赖了 FFmpeg,所有使用接口的层也会被间接传染依赖 FFmpeg,那就失去了可替换后端的意义。

  7. common/ 工具层 — ~1,200 行

    FGLWidget(OpenGL 渲染画面)、FrameBus(线程间传递帧数据)、DesignTokens(统一视觉配色)。

技术选型:为什么选这几样

技术它是什么为什么用它
C++17编译型语言。写好的代码不是直接运行,而是先由一个叫"编译器"的程序把代码翻译成 CPU 能直接执行的机器指令。这个过程叫编译。编译后的程序运行速度极快,因为 CPU 不需要边运行边翻译——指令已经是它的母语了。音视频每秒钟要处理几百万个像素的数据,计算量极大,必须用效率最高的方式。FFmpeg 和 Qt 本身也是 C++ 写的,三者之间调用无任何翻译开销。
Qt 6.10跨平台桌面开发框架。框架比"库"更进一步——它不仅提供现成的功能,还规定了程序的结构。你按它的结构来写代码,它帮你处理大量底层细节。跨平台的意思是:同一套代码编译后可以在 Windows 和 Linux 上得到功能相同的程序。不需要分别为 Windows 和 Linux 写两套界面代码。内置信号槽机制:一个组件发生了一件事(比如按钮被点击),可以自动通知关心这件事的其他组件。这比"不断轮询检查按钮有没有被点"高效得多。
FFmpeg 8.1开源音视频工具集。开源意味着代码公开、免费使用。覆盖编解码、流媒体协议、采集设备、显卡硬件加速等一切音视频相关能力。行业事实标准——VLC 播放器、OBS 推流、剪映转码都在用它。用 FFmpeg 意味着不需要从零写编解码器和协议——这些是极复杂的工作,全世界只有极少数团队能做好。
CMake构建系统。管理"源代码 → 可运行程序"的全流程——编译每个文件、把编译结果链接成完整程序、复制需要的资源文件、打包成安装包。C++ 生态的标准工具。一条命令生成 Windows 安装包(.exe/.msi)或 Linux 安装包(.deb),不用为不同平台写不同脚本。
第四章要点

✓ 接口 = 功能清单("必须有什么功能"),后端 = 清单的实现("功能怎么做的")
✓ 解耦 = 让代码的不同部分不知道彼此的细节,从而可以独立修改和替换
✓ 七层架构中,只有 runtime 知道所有后端存在——"创建什么"的决策集中在一个地方
✓ C++ 是编译型语言(先翻译再运行,快);Qt 是跨平台框架;FFmpeg 是音视频行业标准

Chapter 05

服务端在做什么

FPlayer 可以脱离服务器独立使用——推流端直接把画面发给拉流端。但加上服务端后,就能一对多分发、自动转格式、统一管理地址。

为什么需要一个服务器?

直连模式(不经过服务器)有三个局限:

  • 只能一对一——每个观众都需要推流端亲自发一份
  • 不能自动转格式——推流端发的是 RTMP,如果观众想用 HLS 在手机上播,推流端做不了这个转换
  • 没有地址管理——观众必须知道推流端的精确 IP 地址和端口号。推流端的 IP 一旦变了(比如 DHCP 重新分配了地址),观众就连不上了

服务端做的事情就是:推流端只管"把画面发给服务器",剩下的——拷贝给多个观众、转换格式、管理播放地址——全部由服务器负责

两个独立组件:Gateway 和 ZLMediaKit

FPlayer 的服务端由两个完全独立运行的程序组成,分工明确:

Gateway:管"谁在播、去哪看"

Go 语言编写的一个 HTTP 接口服务。它完全不碰音视频数据,只管理"元数据"——也就是关于流的信息(谁在播、叫什么名字、观众应该去哪看)。"元"的意思是"关于数据的数据"——就像图书馆的索引卡片不是书本身,而是告诉你书在哪个书架上。

它的工作流程:

  • 推流端来请求:"我要推流了,叫 live/stream001" → Gateway 在内存里记下这条记录,然后返回推流用的 RTMP 地址和观众用的播放地址
  • 拉流端来请求:"我想看 live/stream001" → Gateway 查到这条记录,返回播放地址列表
  • 推流结束 → 通知 Gateway → 标记会话已停

Gateway 用内存来存会话数据,没有用数据库。优点是部署简单——不需要额外安装数据库软件;代价是服务重启后之前的记录会丢失。这是一项有目的的技术取舍:先跑通核心功能快速验证价值,持久化可以等以后需要时再加(比如引入 Redis 这类内存数据库,或 MySQL 这类传统数据库)。

ZLMediaKit:管"把流转成不同格式发给不同人"

C++ 编写的高性能流媒体服务器。它的核心能力是协议转换——端进来一路 RTMP 推流,同时输出 RTMP、HTTP-FLV、HLS 三种格式。观众根据自己的设备和网络条件选择最合适的格式来拉流。

ZLMediaKit 使用动态端口分配——启动时不写死端口号,而是问操作系统"哪些端口现在没人用?",选一个空闲的来用。为什么这样做?因为同一台机器上可能同时跑着多个程序都在用网络端口,如果写死了端口号,很容易跟别人的端口冲突——就像一个小区不能有两家用同样的门牌号。动态探测空闲端口就能避免这个冲突。这也使得多个 ZLMediaKit 实例可以并行运行在同一台机器上。

图 6 · 服务端:控制面和数据面分离
flowchart TD
    subgraph 控制面["控制面(Gateway · Go)"]
        API1["创建/查询/停止会话"]
    end
    subgraph 数据面["数据面(ZLMediaKit · C++)"]
        CONVERT["收 RTMP → 转 FLV/HLS → 输出"]
    end
    PUSH["桌面端推流"] -->|"① 申请会话"| API1
    API1 -->|"② 返回推流地址"| PUSH
    PUSH -->|"③ 推送画面"| CONVERT
    PLAY["观众拉流"] -->|"④ 查询地址"| API1
    API1 -->|"⑤ 返回播放地址"| PLAY
    PLAY -->|"⑥ 拉取画面"| CONVERT
    style API1 fill:#cc785c,stroke:#cc785c,color:#fff
    style CONVERT fill:#e8e0d2,stroke:#d9d0c0,color:#141413
      

▲ Gateway(控制面)只管"元数据",不碰一帧视频。ZLMediaKit(数据面)只管"媒体数据",不关心谁在管理这些流。两者独立运行、独立升级、故障互不影响。

这种"把管理和执行分开"的设计叫"控制面与数据面分离",在大型系统中非常常见。一个日常例子:你上网时,DNS 服务器(把域名翻译成 IP 地址的服务)就是"控制面"——它只告诉你去哪个 IP 找网站;真正的网页数据是从那个 IP 的 Web 服务器(数据面)直接传给你的。DNS 挂了不影响已经打开的网页;网页服务器挂了影响不到 DNS 的翻译功能。FPlayer 的服务端借鉴了这个成熟的设计模式。

被问到"为什么不用 WebRTC?"——可以这样答

"现阶段 RTMP 在局域网 1~3 秒延迟完全够用,FFmpeg 对 RTMP 的支持经过了十几年验证。WebRTC 的核心优势在于公网 NAT 穿透和亚秒级延迟——未来扩展到公网或需要互动连麦时,通过 api/ 层的 IStream 接口实现新的流后端即可,上层业务代码不动。CMake 中已预留了 WebRTC 后端的编译开关(默认 OFF),但具体实现待后续版本完成。"

(NAT 穿透:家庭和公司网络通常用 NAT 技术让多台设备共享一个公网 IP 上网。好处是节省 IP 地址,坏处是外网无法主动连接内网设备——别人找不到你。WebRTC 内置了一套复杂的协议来解决"怎么让两台都在内网里的设备互相找到对方并建立连接"这个问题。)

第五章要点

✓ 服务端的价值:一对多分发、协议自动转换、播放地址统一管理
✓ Gateway:管元数据(谁在播、去哪看),不碰媒体数据。用内存存储降低部署复杂度
✓ ZLMediaKit:管媒体数据(转格式、分发),动态端口分配避免冲突
✓ 控制面与数据面分离:独立运行、独立升级、故障隔离
✓ 通过 api/ 抽象接口,未来扩展新协议(如 WebRTC)时上层代码不动

Wrap-up

可以用这些话介绍 FPlayer

"FPlayer 是一个局域网流媒体系统。桌面端用 C++ 和 FFmpeg 做采集、合成和推流;服务端用 Go 管会话、用 ZLMediaKit 做多协议分发;手机端做拉流播放。整套系统可以纯局域网运行,不依赖互联网。"
—— 30 秒介绍
"桌面端最有意思的是多后端架构——同一个功能有三套实现,都遵循同一套接口,可以随时切换。内部用多通道帧总线在采集线程和编码线程之间交接画面数据——各干各的,互不阻塞。这让组合模式成为可能:多个画面源独立运行、最后合成一路推出去。服务端做了控制面与数据面分离——Gateway 管会话、ZLMediaKit 管分发,两边独立运作。架构还给 WebRTC 留好了完整扩展点,未来上公网低延迟只需填代码。"
—— 2 分钟介绍

三个可以主动提出的战略观点

多后端架构是扩展的基础

api/ 层定义纯虚接口(IPlayer/ICamera/IStream),backend/ 层实现具体后端。新增协议只需实现接口并注册,上层零改动。CMake 已预留 WebRTC 编译开关。

组合模式的通道架构可扩展性很强

每个源独立通道,控制完全解耦。加字幕、水印、过渡效果就是加一个新通道,不动现有逻辑。

"不完整"是有目的的产品取舍

内存存会话、局域网设备发现还是空桩、无鉴权——先跑通核心链路验证价值,再逐步加固。api/ 层已为扩展预留接口。