🎬 第十三章 FFmpeg直播综合案例实战
📄 一句话概括

那两个类不解决「怎么调库」,只解决「谁负责收尾」。 沙箱没有编译器,所以这一节讲设计,再用 Python 把同一个思路跑一遍。

📝 本节和前面几节的关系

13.3 把推流的字节亲手送出去了,这一节把代码收进类里。 这是全书唯一一节「只讲设计不跑 C++」的地方,原因在第一节就说清。

一、沙箱没有编译器

先把这个前提摆出来,省得读者以为下面的代码跑过。 工具 本机


cl 没有 g++ 没有 gcc 没有 clang++ 没有 make 没有 cmake 没有

六个全没有,所以这一节不编那两个类,只讲它们为什么长成那样。 讲完之后用一个 Python 的最小封装跑一遍,逻辑是一回事。 为什么敢这么说:封装类要解决的四个问题(下一节那张表)与语言无关, Python 里同样存在,只是有些由语言代劳了。 把 Python 版写出来跑一遍,等于把设计验了一遍。

二、那两个类各管一半

教材里的两个类,分工是一刀切的。 类 管什么 对应的 C 接口


第一个 初始化与错误 注册、错误码转文字 第二个 一条流的开与读 打开、找流、读包、解码

为什么这么分:初始化只做一次,流可以开很多条。 把「全局的」与「每条的」分开,才不会有「关一条流把全局也关了」这种事。 这个分法还有一个好处:第一个类是单例,第二个类可以随便复制(真实实现里是禁止复制、 只许移动,但语义上是「一条流一个对象」)。两类东西的生命周期不一样,就不该塞进一个类里。

三、封装类要解决的四个问题

抄这类代码之前,先看它是不是真解决了这四件事。 要解决 不封装会怎样


资源释放 中途异常就漏,句柄越攒越多 错误码变异常 每步都要 if 判返回值 状态合法 没开就读、关了还写 拷贝与移动 两个对象指同一份,双释放

第四条是 C++ 独有的:默认的拷贝构造会把指针复制一份, 两个对象析构时各释放一次,就是双重释放——这种崩溃现场离出错的地方很远,最难查。 标准做法是把拷贝构造与赋值都删掉,只留移动(移动之后源对象置空,不会再释放一次)。 这一条是「封装」与「包一层」的分界线:只包不封的类,恰恰会把双释放的坑留在外面。 Python 里没有双释放(有引用计数兜着),但「谁负责关」这个设计问题是同一个, 所以下面那个 Python 版仍然要显式写 close。

四、用 Python 写一遍同一个思路

一个类、四件事:打开、读帧、关闭、异常安全。 项 值


读到的帧数 50 块内还开着吗 True 块外还开着吗 False 总耗时 8.6 毫秒

with 退出时一定调 close,就算中间抛异常也一样。 这正是 C++ 里析构函数要做的事,Python 用上下文管理器做到了同一件事。 注意「块内还开着、块外已经关了」这两行: 它们证明关闭发生在离开 with 的那一刻,不是等到程序结束。 这个「确定的时机」就是 RAII 的全部价值——不靠垃圾回收,靠作用域。

五、忘了关会怎样

这一条要真跑,因为它最能说明问题。 时刻 文件在不在 字节


写完 20 帧没 close 不在 0 对照组 close 之前 在 0 对照组 close 之后 在 2071

不 close 的那一份,文件压根没建出来——因为数据全在库的缓冲区里,一个字节都还没往下写。 忘了关不会报错,只会悄悄留下一个不存在、或者空的文件。 对照组更值得看:close 之前 0 字节、之后 2071 字节, 那 2071 字节全是 close 写下去的——包括收尾的结束标记与索引,也包括之前攒着的整段数据。 所以「写完就能用」这个直觉在流式写文件上是不成立的: mp4 那类容器更狠,索引在最后,没 close 的文件根本播不了。 这也是为什么「录制中断」经常留下一个坏文件。

六、这一节能给的结论

那两个类的代码在书里,值得抄,但抄之前先想清四件事。 想什么 答案该是什么


谁释放 对象自己,用析构或 with 错误怎么传 统一转成异常,别返回码 状态怎么查 内部记一个,别靠指针判空 能不能拷贝 不能,删掉只留移动

这四条想不清,封装类只会把 bug 藏得更深。 因为它把「哪一步出的错」变成了「对象状态不对」。 13.5 与 13.6 换到另一个库(OpenCV),但你会看到同样的痕迹: Mat 自己管内存、numpy 数组与帧共享内存谁负责复制。 「谁负责这块内存」是这一整章反复出现的主题。

七、动手 Demo:两个封装类要解决什么,用 Python 写一遍同一个思路

先查六个编译器在不在,再写一个带 with 的最小封装读 50 帧,最后比一次关与不关。

import shutil
import av

for n in ("cl", "g++", "gcc", "clang++", "make", "cmake"):
    print(n, "有" if shutil.which(n) else "没有")

class RtStream(object):
    def __init__(self, path, mode="r"):
        self.path, self.mode, self.c, self.n = path, mode, None, 0

    def __enter__(self):
        self.c = av.open(self.path, self.mode)
        return self

    def __exit__(self, *a):
        self.close()
        return False

    def frames(self):
        st = self.c.streams.video[0]
        for pk in self.c.demux(st):
            if pk.size == 0:
                break
            for fr in pk.decode():
                self.n += 1
                yield fr

    def close(self):
        if self.c is not None:
            self.c.close()
            self.c = None

with RtStream("tiny.mp4") as rs:
    n = sum(1 for _ in rs.frames())
    print(n, rs.c is not None)      # 块内:开着
print(rs.c is not None)              # 块外:已关

# 忘了 close:文件连建都没建出来
o = av.open("noclose.flv", "w", format="flv")
vs = o.add_stream("libx264", rate=25)
vs.width, vs.height, vs.pix_fmt = 160, 120, "yuv420p"
for i in range(20):
    f = av.VideoFrame(160, 120, "yuv420p")
    f.pts = i
    for op in vs.encode(f):
        o.mux(op)
import os
print(os.path.exists("noclose.flv"), os.path.getsize("noclose.flv"))

实跑输出:

--------------------------------------------------------------------
两个封装类要解决什么,用 Python 写一遍同一个思路
--------------------------------------------------------------------

**① 沙箱没有 C++ 编译器**

  工具        本机
  --------------------
  cl          没有
  g++         没有
  gcc         没有
  clang++     没有
  make        没有
  cmake       没有

**所以这一节不编那两个类,只讲它们为什么长成那样。**
**讲完之后用一个 Python 的最小封装跑一遍**,逻辑是一回事。

**② 那两个类各管一半**

  类         管什么             对应的 C 接口
  ----------------------------------------------------------
  第一个     初始化与错误       av_register/av_strerror
  第二个     一条流的开与读     avformat_open_input 一串

**为什么这么分**:**初始化只做一次,流可以开很多条。**
**把「全局的」与「每条的」分开,才不会有「关一条流把全局也关了」这种事。**

**③ 封装类真正要解决的四件事**

  要解决           不封装会怎样
  ---------------------------------------------------
  资源释放         中途异常就漏,句柄越攒越多
  错误码变异常     每步都要 if 判返回值
  状态合法         没开就读、关了还写
  拷贝与移动       两个对象指同一份上下文,双释放

**第四条是 C++ 独有的**:
**默认的拷贝构造会把指针复制一份,两个对象析构时各释放一次,就是双重释放。**
**标准做法是把拷贝构造与赋值都删掉,只留移动。**
**Python 里没这个问题,但「谁负责关」这个设计问题是同一个。**

**④ 用 Python 写一遍同一个思路**

  项               值
  -----------------------------
  读到的帧数       50
  块内还开着吗     True
  块外还开着吗     False
  总耗时           9.9 毫秒

**`with` 退出时一定调 `close`,就算中间抛异常也一样。**
**这正是 C++ 里析构函数要做的事**,**Python 用上下文管理器做到了同一件事。**

**⑤ 忘了关会怎样:真跑一次**

  时刻                       文件在不在     字节
  --------------------------------------------------
  写完 20 帧、还没 close     不在           0
  close 之前(对照组)       在             0
  close 之后(对照组)       在             2071

**不 close 的那一份,文件压根没建出来**——**因为数据全在库的缓冲区里,一个字节都还没往下写。**
**这一点最能说明封装类的价值**:**忘了关不会报错,只会悄悄留下一个不存在、或者空的文件。**
**对照组也一样**:close 之前 0 字节,close 之后 2071 字节,
**那 2071 字节全是 close 写下去的**——**包括收尾的结束标记与索引,也包括之前攒着的整段数据。**

**⑥ 这一节能给的结论**

**那两个 C++ 类的代码在书里,值得抄,但抄之前先想清四件事**:
**谁释放、错误怎么传、状态怎么查、能不能拷贝。**
**这四条想不清,封装类只会把 bug 藏得更深。**

六个编译器全没有,所以这一节只讲设计,不编 C++。 不 close 的文件 0 字节:数据全在缓冲区,close 才真正往下写。

30 秒实验

  1. 在 frames() 里抛一个异常,看文件句柄有没有被放掉(提示:__exit__ 照样执行)。
  2. 把 __exit__ 的返回值改成 True,看异常会不会被吞掉。
  3. 对比 close 前后同一个 flv 的字节数,算清收尾那段占多少。

常见误区

  1. 以为封装类只是把函数包一层。 它要解决的是生命周期与状态。
  2. 写完就以为文件能用。 不 close,数据还在缓冲区里。
  3. 让类可拷贝。 两个对象指同一份上下文,析构时双释放。
自测三问(点开对答案) 1. 两个类为什么按「全局/每条」来分? —— 初始化只做一次、流可以很多条,生命周期不同就不该塞一个类里。 2. 为什么封装类必须禁拷贝? —— 默认拷贝会复制指针,两个对象各释放一次就是双释放。 3. 不 close 会怎样? —— 不报错,但数据留在缓冲区,文件可能是空的甚至不存在。

相关笔记