
目录前言一、缝在接口上二、四个角色三、对象适配器与类适配器四、C把两家 SDK 收成同一种帧源4.1 Target播放器唯一认识的类型4.2 Adaptee假定改不了的 SDK4.3 对象适配器4.4 播放器只认接口4.5 类适配器对照4.6 跑出来的结果五、Python同一条缝另一套类型机制5.1 Protocol 与对象适配器5.2 类适配器包不进现成实例5.3 一个函数就写成闭包5.4 标准库里的文本适配器六、翻译可以接成链七、标准库早就在用这种结构八、同样是包一层问接口变了没有九、放在播放链路上十、总结前言播放器、编码器和视觉算法希望“下一帧”长得一样同一个函数名同一种像素布局同一种“没有更多数据”的表示。采集卡厂商不按这个习惯设计。它的 SDK 叫 pullBuffer吐出 RGB24。隔壁网络摄像机的 SDK 叫 fetchGray吐出灰度。两份头文件通常改不了播放器里也不该散落 if (vendor ...)。适配器模式填的就是这条缝。它把一个已经存在的类翻译成客户端正在使用的接口让两边在各自都不改源码的前提下协作。一句话适配器负责把调用方式和数据形状对齐。重连、排队、滤镜、解码流程留在适配器外面。本文用同一组数据走通 C 和 Python一张 2×2 的 RGB24首像素是红 (255,0,0)一张 2×2 的灰度图首像素亮度是 10。翻译之后播放器都只看见 RGBA。完整程序在 examples/frame_adapter.cpp 和 examples/frame_adapter.py。一、缝在接口上先把三种角色摆在一起看。图 1播放器只认 IFrameSource::read 和 RGBA两家 SDK 的方法名和缓冲布局各自一套客户端已经写死的约定是采集卡提供的是 pullBuffer 加一块 RGB24。网络摄像机提供的是 fetchGray 加一块单通道亮度。能力都是“给出一幅图”签名和内存布局对不上。这种时候有三条路(1). 改 SDK。厂商头文件、已经发布的动态库通常走不通。(2). 改播放器让它认识每一种 SDK。每接一家设备播放器就多一条分支测试矩阵跟着涨。(3). 在中间加一层翻译。播放器继续只依赖自己的接口每家 SDK 配一个适配器。第三条就是适配器。判断标准也很具体两边语义都还是“读出下一帧”差别主要在名字、参数顺序、字节布局和错误码。语义要是已经变了——比如一边给的是编码包一边要的是像素——那一层就不该叫适配器解码该单独成模块。二、四个角色《设计模式》里这组结构有四个参与者。放到上面的场景里对应关系是协作过程就一句话客户端调用适配器的 read适配器去调 SDK把结果装进 Frame 再交回去。图 2两个适配器都实现 IFrameSource各自持有一家已经创建好的 SDK图2是对象适配器适配器是一个独立对象里面放着被适配者。这也是 C 和 Python 里都应该默认采用的形式。播放器依赖的是接口所以同一套 play() 既能播采集卡也能播网络摄像机。换设备时替换适配器播放循环一个字都不用改。三、对象适配器与类适配器对象适配器用组合。类适配器用继承在 C 里就是让适配器同时继承 Target 和 Adaptee。图 3左边包住一个已经存在的 SDK 实例右边的适配器自己就是 SDK 的子类对象适配器多一次指针跳转换来的是生命期和替换自由。类适配器少一个对象代价是和具体 SDK 类焊在一起。厂商 SDK 很少为了让你覆盖行为去留虚函数类适配器那个“可以改写”的优点常常用不上。本文的 C 类适配器使用私有继承。公有多继承会让适配器同时暴露 read 和 pullBuffer调用方可以绕过翻译直接拿走 RGB24。私有继承把 SDK 的方法留在类内部对外只剩下 IFrameSource。限制还在它仍然不能包装一个已经构造好的 CaptureCardSdk。四、C把两家 SDK 收成同一种帧源示例按 C17 编写可以用下面任一命令编译g -stdc17 -Wall -Wextra -o frame_adapter frame_adapter.cpp cl /nologo /std:c17 /EHsc /utf-8 /W4 frame_adapter.cppMSVC 需要 /utf-8否则源文件里的中文注释可能触发 C4819。下面是其中的核心类型灰度转换、NetworkCamSdk 和 main 在 examples/frame_adapter.cpp 里与这里的采集卡路径同一结构。4.1 Target播放器唯一认识的类型struct Frame { int width 0; int height 0; std::vectorstd::uint8_t rgba; // width * height * 4 }; class IFrameSource { public: virtual ~IFrameSource() default; virtual bool read(Frame out) 0; };接口上只有一个纯虚函数。厂商名、SDK 句柄、像素格式枚举都不出现在 Target 上。这些信息一旦漏出去播放器就会重新长出分支。像素翻译是纯函数适配器调用它而不是把循环揉进类的成员里。这样 RGB24 和灰度两条路径可以分开测。Frame rgb24ToRgba(const std::vectorstd::uint8_t rgb24, int width, int height) { if (width 0 || height 0) { throw std::invalid_argument(invalid frame size); } const std::size_t pixels static_caststd::size_t(width) * static_caststd::size_t(height); if (rgb24.size() ! pixels * 3) { throw std::invalid_argument(RGB24 buffer size mismatch); } Frame out; out.width width; out.height height; out.rgba.resize(pixels * 4); for (std::size_t i 0; i pixels; i) { out.rgba[i * 4 0] rgb24[i * 3 0]; out.rgba[i * 4 1] rgb24[i * 3 1]; out.rgba[i * 4 2] rgb24[i * 3 2]; out.rgba[i * 4 3] 255; } return out; }grayToRgba 同构每个亮度复制到 R、G、BAlpha 仍是 255。尺寸不合法、缓冲长度和宽高不符属于调用契约被破坏用异常“这次没有帧”属于正常的流结束用 false。两种失败不要混成同一个返回值。4.2 Adaptee假定改不了的 SDK示例里的 SDK 用内存中的一幅图模拟设备避免文章依赖真实采集卡。翻译结构与接真设备时相同适配器不关心数据从哪来只关心函数名和缓冲布局。class CaptureCardSdk { public: CaptureCardSdk() { frames_.push_back(Raw{ 2, 2, {255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255}, }); } bool pullBuffer(std::vectorstd::uint8_t rgb24, int width, int height) { if (index_ frames_.size()) { return false; } const Raw raw frames_[index_]; width raw.width; height raw.height; rgb24 raw.rgb24; return true; } private: struct Raw { int width; int height; std::vectorstd::uint8_t rgb24; }; std::vectorRaw frames_; std::size_t index_ 0; };NetworkCamSdk::fetchGray 的形状一样缓冲换成 {10, 20, 30, 40}。两家 SDK 甚至连“没有帧”都已经用 bool 表示。真实现场经常不是这样错误码要在适配器边界收口// 示意厂商用错误码时Target 仍然只看见 true / false / 异常 bool CaptureCardAdapter::read(Frame out) { int code sdk_.pullBuffer(rgb24, width, height); if (code CaptureCardSdk::kEof) { return false; } if (code ! CaptureCardSdk::kOk) { throw SdkError(code); } out rgb24ToRgba(rgb24, width, height); return true; }播放器不包含 vendor/capture_card.h也就不会出现厂商错误码。4.3 对象适配器class CaptureCardAdapter : public IFrameSource { public: explicit CaptureCardAdapter(CaptureCardSdk sdk) : sdk_(sdk) {} bool read(Frame out) override { std::vectorstd::uint8_t rgb24; int width 0; int height 0; if (!sdk_.pullBuffer(rgb24, width, height)) { return false; } out rgb24ToRgba(rgb24, width, height); return true; } private: CaptureCardSdk sdk_; };NetworkCamAdapter 只把 pullBuffer 换成 fetchGray把 rgb24ToRgba 换成 grayToRgba。这里用引用是因为 main 同时拥有 SDK 和适配器引用不延长对方的生命期。适配器若比 SDK 活得久就是悬空引用。适配器需要独占设备时改成持有 std::unique_ptrCaptureCardSdk在构造函数里 std::move 进来。图 4播放器没有直接碰到 pullBufferRGB24 到 RGBA 的转换发生在适配器内部4.4 播放器只认接口void playFrames(const std::functionbool(Frame) read, const std::string label) { std::cout label \n; Frame frame; int count 0; while (read(frame)) { count; std::cout frame.width x frame.height rgba0( static_castint(frame.rgba.at(0)) , static_castint(frame.rgba.at(1)) , static_castint(frame.rgba.at(2)) , static_castint(frame.rgba.at(3)) )\n; } std::cout frames count eof\n; } void play(IFrameSource source, const std::string label) { playFrames([source](Frame frame) { return source.read(frame); }, label); }play 的参数类型是 IFrameSource。采集卡适配器、网络摄像机适配器、测试用的假帧源走的是同一个函数。Target 上如果真的只有一个操作可以不声明类直接把翻译写成 lambda交给 playFramesNetworkCamSdk camFn; playFrames( [camFn](Frame out) { std::vectorstd::uint8_t gray; int width 0; int height 0; if (!camFn.fetchGray(gray, width, height)) { return false; } out grayToRgba(gray, width, height); return true; }, function adapter / network);类形式的适配器仍然有它的位置Target 会增长成一组方法、适配器要保存格式协商的结果或者调用方已经在用 IFrameSource* 做多态容器。只有一个函数要翻译时std::function 或函数模板更短。4.5 类适配器对照class CaptureCardClassAdapter : public IFrameSource, private CaptureCardSdk { public: bool read(Frame out) override { std::vectorstd::uint8_t rgb24; int width 0; int height 0; if (!pullBuffer(rgb24, width, height)) { return false; } out rgb24ToRgba(rgb24, width, height); return true; } };pullBuffer 来自基类不是来自成员。调用方写 classAdapter.pullBuffer(...) 会编译失败因为继承是私有的。与此同时你也无法写出 CaptureCardClassAdapter adapter(alreadyOpenedCard)这个类没有可以接收外部 SDK 的构造函数它内部那台“采集卡”是基类子对象。4.6 跑出来的结果object adapter / capture 2x2 rgba0(255,0,0,255) frames1 eof object adapter / network 2x2 rgba0(10,10,10,255) frames1 eof class adapter / capture 2x2 rgba0(255,0,0,255) frames1 eof function adapter / network 2x2 rgba0(10,10,10,255) frames1 eof四条路径的首像素都符合预期RGB24 的红补上 Alpha 成为 (255,0,0,255)灰度 10 铺成 (10,10,10,255)。第二帧不存在统一打印 eof。播放器要单测时再提供一个不碰硬件的 Target 实现即可class FakeSource : public IFrameSource { public: explicit FakeSource(std::vectorFrame frames) : frames_(std::move(frames)) {} bool read(Frame out) override { if (index_ frames_.size()) { return false; } out frames_[index_]; return true; } private: std::vectorFrame frames_; std::size_t index_ 0; };这段示意需要 utility 里的 std::move。play(fake, test) 和 play(cardAdapter, device) 是同一条调用链。适配器把设备差异关在播放器外面测试就可以只构造 Frame。五、Python同一条缝另一套类型机制Python 没有 C 那种必须继承才能满足接口的要求。typing.Protocol 做的是结构匹配谁有符合签名的 read谁就能传给 play。适配器在这里依然有用因为对不上的是方法名和字节布局不是“缺一个基类”。数据与 C 示例相同。完整脚本是 examples/frame_adapter.py直接 python frame_adapter.py。5.1 Protocol 与对象适配器dataclass(frozenTrue) class Frame: width: int height: int rgba: bytes # width * height * 4 class FrameSource(Protocol): def read(self) - Optional[Frame]: ... class CaptureCardAdapter: def __init__(self, sdk: CaptureCardSdk) - None: self._sdk sdk def read(self) - Optional[Frame]: pulled self._sdk.pull_buffer() if pulled is None: return None rgb24, width, height pulled return rgb24_to_rgba(rgb24, width, height)CaptureCardAdapter 没有继承任何帧源基类。它有 readplay(source: FrameSource) 就接受它。SDK 返回 None 表示没有更多帧适配器保持这个约定不把 None 翻译成空 Frame。空 Frame 和“流结束”是两件不同的事。NetworkCamAdapter 同样包住一个已经存在的 NetworkCamSdk。对象适配器在 Python 里的意义和 C 一样设备往往是先打开的适配器后包上去。5.2 类适配器包不进现成实例class CaptureCardClassAdapter(CaptureCardSdk): def read(self) - Optional[Frame]: pulled self.pull_buffer() if pulled is None: return None rgb24, width, height pulled return rgb24_to_rgba(rgb24, width, height)这段能跑play(CaptureCardClassAdapter(), ...) 的首像素同样是 (255, 0, 0, 255)。它和 C 私有继承有一处重要差别Python 没有私有继承pull_buffer 仍然是公开方法。拿到类适配器的人可以直接读 RGB24翻译层就被绕开了。所以在 Python 里只要目标是“对外只留 read”就用组合把 SDK 放在 self._sdk。下划线只是约定真正防漏接口的办法是不把被适配者的方法转发出去。下面这种写法会把缝重新撕开def __getattr__(self, name): return getattr(self._sdk, name)播放器一旦能通过适配器摸到 pull_buffer后面的代码就会依赖厂商签名。适配器要显式写出自己承诺的那几个方法。5.3 一个函数就写成闭包def adapt_network_cam(sdk: NetworkCamSdk) - Callable[[], Optional[Frame]]: def read() - Optional[Frame]: fetched sdk.fetch_gray() if fetched is None: return None gray, width, height fetched return gray_to_rgba(gray, width, height) return read闭包捕获的是那台已经打开的摄像机和对象适配器持有 SDK 是同一件事只是没有再声明一个类。Target 以后若要加 stop()、format()再收成类不迟。脚本实际打印与 C 的前三段一致闭包路径用断言检查首像素文本适配器通过后打印 okobject adapter / capture 2x2 rgba0(255, 0, 0, 255) frames1 eof object adapter / network 2x2 rgba0(10, 10, 10, 255) frames1 eof class adapter / capture 2x2 rgba0(255, 0, 0, 255) frames1 eof ok5.4 标准库里的文本适配器io.TextIOWrapper 把二进制流翻译成 str 的文件接口是 Python 里很典型的适配器底层缓冲的 read 返回 bytes包装之后 read 返回 str。def demo_text_wrapper() - str: raw io.BytesIO(bhello\n) text io.TextIOWrapper(raw, encodingutf-8) try: return text.read() finally: text.detach()这里有一个和 C 引用生命期同类的问题。TextIOWrapper.close() 默认会把底层二进制流一起关掉。底层流的主人若是调用方就在用完文本接口之后 detach()把所有权还回去。示例断言返回值是 hello\n。顺带一提标准库里名字带 Adapter 的类不一定是这个模式。logging.LoggerAdapter 在原有日志接口上附加上下文接口本身没有换成另一套它更接近后面要说的装饰器。看类做了哪一种翻译再决定它属于哪一种结构。六、翻译可以接成链有时一次翻译到不了播放器要的终点。采集卡给出 RGB24算法模块要 YUV渲染要 RGBA。可以叠两层适配器但每一层的 Target 必须不同而且每一层只做一种布局转换。图 5pullBuffer→IYuvSource→IFrameSource每一跳的目标接口都变了class IYuvSource { public: virtual ~IYuvSource() default; virtual bool read(YuvFrame out) 0; }; class RawToYuvAdapter : public IYuvSource { // 持有 CaptureCardSdk把 RGB24 译成 YUV }; class YuvToRgbaAdapter : public IFrameSource { public: explicit YuvToRgbaAdapter(IYuvSource yuv) : yuv_(yuv) {} bool read(Frame out) override; // 只认识 IYuvSource private: IYuvSource yuv_; };第二层依赖的是 IYuvSource不依赖采集卡。以后文件解复用如果也能产出 IYuvSourceRGBA 这层适配器可以复用。若某一层的入口和出口都是 IFrameSource它就不再是适配器。在 read 前后打时间戳、叠加一道滤镜接口没变那是装饰器。图 5 底部那句话就是这条分界。双向适配器——一个类同时把 A 译成 B、把 B 译成 A——偶尔出现在两套都已发布、还必须互相调用的接口之间。它会把生命期和错误码拧在一起。能拆成两个单向适配器时测试和维护都更清楚。七、标准库早就在用这种结构C 容器适配器是同一结构的一个极简形式。std::stack、std::queue、std::priority_queue 不自己管理一块新的内存布局它们持有一个序列容器对外给出另一套操作。下面是 stack 的简化示意不是标准库原文template typename T, typename Container std::dequeT class Stack { public: void push(const T value) { data_.push_back(value); } void pop() { data_.pop_back(); } T top() { return data_.back(); } bool empty() const { return data_.empty(); } private: Container data_; };deque 两端都能插入。Stack 把这套接口收成只能从背端进出。使用方写 push / pop / top接触不到 push_front。把 Container 换成 std::vectorT翻译层不变底层容器可以换。这就是对象适配器对外的方法集合和被适配者不同内部用组合转发。迭代器适配器做的是另一类翻译。std::reverse_iterator 把“向前”译成“向后”std::back_insert_iterator 把赋值译成 push_back。早期的 bind1st 一类函数适配器现在一般由 lambda 和 std::bind 承担也就是第四节里 playFrames 那种写法。Python 侧除了 io.TextIOWrappersocket.makefile() 也是把套接字收成文件对象。调用方随后按文件接口读而不是按 recv 的缓冲区约定读。八、同样是包一层问接口变了没有适配器、外观、桥接、装饰器、代理画出来都像“外面多了一个类”。分开它们的办法是看接口以及这层是设计之初就在还是事后补上的。图 6左右两边的签名相同就是装饰器或代理签名不同才是适配器、外观或桥接桥接容易和适配器记混因为两者都用组合。差别在变化从哪来。桥接面对的是两个都会增长的层次。播放控制一边可能长出倍速、逐帧、只解关键帧渲染一边可能长出 OpenGL、Vulkan、D3D。让“倍速播放”直接继承“Vulkan 播放器”每加一种画法就要再复制一套控制逻辑。桥接的做法是播放抽象持有渲染实现接口两个层次各自派生用组合连上。这是搭结构的时候就决定的。适配器面对的是一块已经存在、签名不合的代码。第三方 GL 封装的函数名和你的渲染实现接口不一致时再写一个适配器把它接进桥的实现端。桥接负责两边能独立扩展适配器负责某一端的具体类能插进已经定下来的接口。九、放在播放链路上适配器值得写通常是因为边界不属于你或者已经发布、不能改签名厂商采集 SDK、另一组人冻结的帧接口、标准库的流类型。客户端接口已经稳定而且后面还会接第二家、第三家实现。下面这些情况加适配器只会多一层跳转接口和实现都在同一个仓库调用方也是你自己。直接改签名让生产者按 IFrameSource 输出。差异已经不是布局而是含义。SDK 给出编码包播放器要像素。解码、缓冲和线程模型单独成模块适配器最多站在解码器出口把 AVFrame 或厂商帧译成你的 Frame。适配器里出现重连、帧队列、音视频同步。那是连接管理和缓冲组件它们可以依赖 IFrameSource不必伪装成翻译层。色彩转换已经是一整条管线10bit、HDR、硬件色彩空间。RGB24 补 Alpha 可以留在适配器调用的纯函数里大管线单独测试适配器只调用它。一条比较稳的播放链路是厂商 SDK → 适配器得到统一的 Frame / 包→ 队列与时钟 → 解码或渲染 → 装饰器打点、字幕Qt 工程里这条缝出现在厂商采集和你自己的视频源之间。适配器实现你的帧源接口内部调用 SDK。界面刷新、滤镜链、倍速播放不要写进这个类。FFmpeg 工程里data、linesize、format、pts这些字段的对应属于适配avcodec_send_packet/avcodec_receive_frame那一段是解码流程宜放在解码器里由它在出口处调用一个很薄的帧适配。总之不过是C 还是Python谁创建被适配者谁就要说清楚谁最后销毁它。适配器持有引用或裸指针时调用方保证 SDK 活得更久适配器持有 unique_ptr 时设备随适配器一起释放。Python 的 TextIOWrapper.detach() 是同一约定的库函数版本。十、总结(1). 适配器翻译的是接口方法名、参数、数据布局、错误码。它不负责把一种业务能力变成另一种。(2). 默认写对象适配器用组合包住已经存在的实例。C 的类适配器即使用私有继承也包不进一台已经打开的设备Python 的子类还会把 SDK 方法露在外面。(3). Target 只有一个函数时C 可以用 lambda / std::functionPython 可以用闭包。方法变多、需要多态容器时再收成类。(4). 适配器可以叠每一层的目标接口要不同。接口没变的那一层是装饰器或代理。(5). 播放器只依赖 Target。设备差异留在适配器里测试用假的 IFrameSource 就能把播放循环跑起来。