目录
前言
一、缝在接口上
二、四个角色
三、对象适配器与类适配器
四、C++:把两家 SDK 收成同一种帧源
4.1 Target:播放器唯一认识的类型
4.2 Adaptee:假定改不了的 SDK
4.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 收成同一种帧源
示例按 C++17 编写,可以用下面任一命令编译:
g++ -std=c++17 -Wall -Wextra -o frame_adapter frame_adapter.cpp
cl /nologo /std:c++17 /EHsc /utf-8 /W4 frame_adapter.cpp
MSVC 需要 /utf-8,否则源文件里的中文注释可能触发 C4819。下面是其中的核心类型,灰度转换、NetworkCamSdk 和 main 在 examples/frame_adapter.cpp 里,与这里的采集卡路径同一结构。
4.1 Target:播放器唯一认识的类型
struct Frame {
int width = 0;
int height = 0;
std::vector<std::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::vector<std::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_cast<std::size_t>(width) * static_cast<std::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、B,Alpha 仍是 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::vector<std::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::vector<std::uint8_t> rgb24;
};
std::vector<Raw> 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::vector<std::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_ptr<CaptureCardSdk>,在构造函数里 std::move 进来。

图 4:播放器没有直接碰到 pullBuffer,RGB24 到 RGBA 的转换发生在适配器内部
4.4 播放器只认接口
void playFrames(const std::function<bool(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_cast<int>(frame.rgba.at(0)) << ","
<< static_cast<int>(frame.rgba.at(1)) << ","
<< static_cast<int>(frame.rgba.at(2)) << ","
<< static_cast<int>(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,交给 playFrames:
NetworkCamSdk camFn;
playFrames(
[&camFn](Frame& out) {
std::vector<std::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::vector<std::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
2×2 rgba0=(255,0,0,255)
frames=1 eof
object adapter / network
2×2 rgba0=(10,10,10,255)
frames=1 eof
class adapter / capture
2×2 rgba0=(255,0,0,255)
frames=1 eof
function adapter / network
2×2 rgba0=(10,10,10,255)
frames=1 eof
四条路径的首像素都符合预期:RGB24 的红补上 Alpha 成为 (255,0,0,255),灰度 10 铺成 (10,10,10,255)。第二帧不存在,统一打印 eof。
播放器要单测时,再提供一个不碰硬件的 Target 实现即可:
class FakeSource : public IFrameSource {
public:
explicit FakeSource(std::vector<Frame> frames) : frames_(std::move(frames)) {}
bool read(Frame& out) override {
if (index_ >= frames_.size()) {
return false;
}
out = frames_[index_++];
return true;
}
private:
std::vector<Frame> 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(frozen=True)
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 没有继承任何帧源基类。它有 read,play(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++ 的前三段一致,闭包路径用断言检查首像素,文本适配器通过后打印 ok:
object adapter / capture
2×2 rgba0=(255, 0, 0, 255)
frames=1 eof
object adapter / network
2×2 rgba0=(10, 10, 10, 255)
frames=1 eof
class adapter / capture
2×2 rgba0=(255, 0, 0, 255)
frames=1 eof
ok
5.4 标准库里的文本适配器
io.TextIOWrapper 把二进制流翻译成 str 的文件接口,是 Python 里很典型的适配器:底层缓冲的 read 返回 bytes,包装之后 read 返回 str。
def demo_text_wrapper() -> str:
raw = io.BytesIO(b"hello\\n")
text = io.TextIOWrapper(raw, encoding="utf-8")
try:
return text.read()
finally:
text.detach()
这里有一个和 C++ 引用生命期同类的问题。TextIOWrapper.close() 默认会把底层二进制流一起关掉。底层流的主人若是调用方,就在用完文本接口之后 detach(),把所有权还回去。示例断言返回值是 "hello\\n"。
顺带一提:标准库里名字带 Adapter 的类,不一定是这个模式。logging.LoggerAdapter 在原有日志接口上附加上下文,接口本身没有换成另一套,它更接近后面要说的装饰器。看类做了哪一种翻译,再决定它属于哪一种结构。
六、翻译可以接成链
有时一次翻译到不了播放器要的终点。采集卡给出 RGB24,算法模块要 YUV,渲染要 RGBA。可以叠两层适配器,但每一层的 Target 必须不同,而且每一层只做一种布局转换。

图 5:pullBuffer → 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,不依赖采集卡。以后文件解复用如果也能产出 IYuvSource,RGBA 这层适配器可以复用。
若某一层的入口和出口都是 IFrameSource,它就不再是适配器。在 read 前后打时间戳、叠加一道滤镜,接口没变,那是装饰器。图 5 底部那句话就是这条分界。
双向适配器——一个类同时把 A 译成 B、把 B 译成 A——偶尔出现在两套都已发布、还必须互相调用的接口之间。它会把生命期和错误码拧在一起。能拆成两个单向适配器时,测试和维护都更清楚。
七、标准库早就在用这种结构
C++ 容器适配器是同一结构的一个极简形式。std::stack、std::queue、std::priority_queue 不自己管理一块新的内存布局,它们持有一个序列容器,对外给出另一套操作。下面是 stack 的简化示意,不是标准库原文:
template <typename T, typename Container = std::deque<T>>
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::vector<T>,翻译层不变,底层容器可以换。这就是对象适配器:对外的方法集合和被适配者不同,内部用组合转发。
迭代器适配器做的是另一类翻译。std::reverse_iterator 把“向前”译成“向后”,std::back_insert_iterator 把赋值译成 push_back。早期的 bind1st 一类函数适配器,现在一般由 lambda 和 std::bind 承担,也就是第四节里 playFrames 那种写法。
Python 侧除了 io.TextIOWrapper,socket.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::function,Python 可以用闭包。方法变多、需要多态容器时,再收成类。
(4). 适配器可以叠,每一层的目标接口要不同。接口没变的那一层,是装饰器或代理。
(5). 播放器只依赖 Target。设备差异留在适配器里,测试用假的 IFrameSource 就能把播放循环跑起来。
网硕互联帮助中心




评论前必须登录!
注册