桥接模式:抽象和实现如何分别变化

上一篇讲了外观模式。

它解决的问题是:

复杂子系统不应该直接裸露给调用方,而应该通过一个统一、高层、易用的入口来提供服务。

比如诊断子系统、OTA 子系统、仿真平台、数据采集系统,都可以通过一个 Facade 把复杂流程收起来,让业务层不必直接面对一堆底层模块。

但真实工程里,还有另一类结构问题。

这类问题不是"子系统太复杂",也不是"接口不兼容",而是:

一个对象有两个变化方向,而且这两个方向会分别变化。

比如车企软件里很常见的场景:

  • 诊断业务会变化,底层通信方式也会变化
  • 控制逻辑会变化,硬件平台也会变化
  • OTA 升级流程会变化,数据传输通道也会变化
  • 数据采集任务会变化,存储和上报方式也会变化
  • HMI 业务组件会变化,渲染平台也会变化
  • 仿真场景类型会变化,仿真引擎实现也会变化

这时如果还靠继承一层层扩展,很容易出现类数量膨胀。

比如:

text 复制代码
CanDiagnosticService
DoipDiagnosticService
SerialDiagnosticService
SimulationDiagnosticService

CanSecureDiagnosticService
DoipSecureDiagnosticService
SerialSecureDiagnosticService
SimulationSecureDiagnosticService

CanFlashDiagnosticService
DoipFlashDiagnosticService
SerialFlashDiagnosticService
SimulationFlashDiagnosticService

你会发现,类名越来越长,继承树越来越乱。

真正的问题不是"类不够多"。

而是:

抽象和实现被绑在了一棵继承树里,导致两个变化方向互相拖累。

桥接模式,就是为了解决这个问题。


一、先从一个车企场景说起

假设我们现在要做一个诊断服务。

业务层希望使用这样的能力:

cpp 复制代码
ReadDid()
WriteDid()
ReadDtc()
ClearDtc()

也就是说,上层关心的是诊断业务动作。

但底层通信方式可能有很多种:

  • CAN
  • DoIP
  • 串口
  • 仿真通道
  • 远程诊断通道

如果一开始系统很简单,只有 CAN,你可能会写:

cpp 复制代码
class CanDiagnosticService {
public:
    std::vector<uint8_t> ReadDid(uint16_t did);
    bool WriteDid(uint16_t did, const std::vector<uint8_t>& data);
};

后来支持 DoIP,于是又写:

cpp 复制代码
class DoipDiagnosticService {
public:
    std::vector<uint8_t> ReadDid(uint16_t did);
    bool WriteDid(uint16_t did, const std::vector<uint8_t>& data);
};

再后来支持串口,又写一个:

cpp 复制代码
class SerialDiagnosticService {
public:
    std::vector<uint8_t> ReadDid(uint16_t did);
    bool WriteDid(uint16_t did, const std::vector<uint8_t>& data);
};

这时你会发现,很多诊断业务逻辑其实是重复的。

比如读 DID 的请求格式可能都是:

text 复制代码
0x22 + DID

写 DID 的请求格式可能都是:

text 复制代码
0x2E + DID + Data

区别只是底层怎么发出去。

CAN 有 CAN 的发送方式。

DoIP 有 DoIP 的发送方式。

串口有串口的发送方式。

仿真通道有仿真通道的发送方式。

如果你把"诊断业务"和"通信实现"写死在一个类里,就会出现一个问题:

每新增一种诊断业务,要在每种通信实现里改一遍。

每新增一种通信实现,又要把所有诊断业务复制一遍。

这就是两个变化方向被绑死之后的典型后果。


二、为什么直接继承会出问题?

很多人一开始会觉得:

那就继承啊。

基类写公共逻辑,子类实现不同通信方式,不就行了吗?

简单场景下确实可以。

但当变化方向不止一个时,继承很快会变得笨重。


1. 类数量会按组合膨胀

假设诊断服务有三种业务类型:

  • 普通诊断
  • 安全诊断
  • 刷写诊断

底层通信有四种实现:

  • CAN
  • DoIP
  • 串口
  • 仿真

如果用继承硬组合,可能会变成:

text 复制代码
NormalCanDiagnosticService
NormalDoipDiagnosticService
NormalSerialDiagnosticService
NormalSimulationDiagnosticService

SecureCanDiagnosticService
SecureDoipDiagnosticService
SecureSerialDiagnosticService
SecureSimulationDiagnosticService

FlashCanDiagnosticService
FlashDoipDiagnosticService
FlashSerialDiagnosticService
FlashSimulationDiagnosticService

这还只是 3 × 4。

如果再加一个"远程诊断通道",或者再加一个"售后诊断服务",类数量还会继续增长。

这不是设计变复杂了。

这是变化维度被错误地揉在了一起。


2. 抽象层被实现细节污染

诊断服务本来应该关心的是:

  • 读 DID
  • 写 DID
  • 读 DTC
  • 清 DTC
  • 切会话
  • 安全访问

但如果它和 CAN、DoIP、串口这些实现细节绑在一起,业务类里就会不断出现底层细节:

cpp 复制代码
if (useCan) {
    // 按 CAN 帧发送
} else if (useDoip) {
    // 按 DoIP 报文发送
} else if (useSerial) {
    // 按串口协议发送
}

这段代码的问题不在于 if-else 本身。

真正的问题是:

诊断业务层开始关心通信实现细节了。

这会让抽象层变脏。


3. 新增实现时,抽象层也被迫变化

假设系统已经支持 CAN 和 DoIP。

后来项目要求支持"仿真通道"。

如果诊断业务和通信实现没有分离,你可能要改很多类:

  • 普通诊断类
  • 安全诊断类
  • 刷写诊断类
  • 测试诊断类
  • 售后诊断类

但从业务语义上看,新增仿真通道并不应该影响诊断业务本身。

这说明原来的结构把不该耦合的东西耦合在一起了。


4. 两个变化方向的生命周期不同

诊断业务的变化,通常来自需求。

比如:

  • 新增一个 DID 读取流程
  • 新增安全访问策略
  • 新增刷写前检查
  • 新增响应校验逻辑

通信实现的变化,通常来自平台和环境。

比如:

  • 从 CAN 切到 DoIP
  • 从实车切到仿真
  • 从本地通道切到远程通道
  • 从供应商 A SDK 切到供应商 B SDK

这两个变化方向不一定同时发生。

如果它们被绑在同一棵继承树里,那么任何一边变化,都可能牵动另一边。

这就是桥接模式要解决的根本问题。


三、桥接模式到底是什么?

桥接模式可以这样理解:

把抽象部分和实现部分分离,使它们可以独立变化。

再说得直白一点:

不要把两个变化方向硬塞进同一棵继承树里,而是把其中一个变化方向抽成接口,让另一个方向通过组合去使用它。

比如诊断服务这个例子里,有两个变化方向:

text 复制代码
变化方向一:诊断业务抽象
变化方向二:通信实现方式

桥接之后,结构就会变成:

text 复制代码
DiagnosticService
  ↓ 持有
ITransport
  ↑
CanTransport / DoipTransport / SerialTransport

诊断服务不再关心底层到底是 CAN 还是 DoIP。

它只关心:

我有一个能 Send 和 Receive 的通信实现。

底层通信实现也不关心上面到底是普通诊断、安全诊断还是刷写诊断。

它只负责:

把数据按自己的方式发出去,再把响应收回来。

这就是桥接。

这里的"桥"不是一个具体的桥类。

它更像一种结构关系:

抽象层通过组合,桥接到实现层。


四、桥接模式解决的核心问题

桥接模式最核心的价值有三个。


1. 让抽象和实现分别变化

抽象层可以扩展自己的业务能力。

比如:

text 复制代码
DiagnosticService
SecureDiagnosticService
FlashDiagnosticService

实现层也可以扩展自己的底层实现。

比如:

text 复制代码
CanTransport
DoipTransport
SerialTransport
SimulationTransport

它们之间通过一个稳定接口连接。

这样新增一种业务抽象,不需要复制所有通信实现。

新增一种通信实现,也不需要改所有业务抽象。


2. 避免继承层次爆炸

桥接模式不是不用继承。

而是不把多个变化维度都压进继承里。

它更常见的做法是:

一边用继承表达抽象层扩展;

另一边用接口和组合表达实现层变化。

这样结构会更平。

类数量也更可控。


3. 让依赖关系更清楚

没有桥接时,代码可能是:

text 复制代码
诊断业务类 直接依赖 CAN / DoIP / Serial 细节

有了桥接之后,代码变成:

text 复制代码
诊断业务类 依赖 ITransport 抽象
ITransport 由不同通信实现提供

这就让边界更清楚:

  • 诊断业务负责业务流程
  • 通信实现负责数据传输
  • 两者通过接口协作

工程里很多复杂性,都是因为边界不清楚。

桥接模式就是帮你把边界重新切开。


五、桥接模式的核心角色

桥接模式通常有几个角色。

1. Abstraction:抽象

也就是高层业务抽象。

比如:

text 复制代码
DiagnosticService
ControlService
OtaService
DataCollectionTask
HmiWidget

它定义的是调用方真正关心的业务能力。


2. RefinedAbstraction:扩展抽象

也就是抽象层的进一步扩展。

比如:

text 复制代码
SecureDiagnosticService
FlashDiagnosticService
RemoteDiagnosticService

它们可以在高层业务上继续扩展,而不需要关心底层实现细节。


3. Implementor:实现接口

也就是底层实现的统一接口。

比如:

text 复制代码
ITransport
IHardwareDriver
IStorageBackend
IRenderBackend
IUpgradeChannel

它不是业务接口,而是抽象层需要依赖的实现能力。


4. ConcreteImplementor:具体实现

也就是真正完成底层工作的类。

比如:

text 复制代码
CanTransport
DoipTransport
SerialTransport
SimulationTransport

或者:

text 复制代码
BoschBrakeDriver
ContinentalBrakeDriver
MockBrakeDriver

它们实现同一个 Implementor 接口。


5. Client:调用方

调用方使用 Abstraction。

它通常不需要直接关心 ConcreteImplementor。

当然,在对象创建阶段,系统可能会通过工厂或配置来决定具体使用哪个实现。


六、桥接模式的结构

可以用一个简化结构理解:

text 复制代码
Client
  ↓
Abstraction
  ↓ 持有
Implementor
  ↑
ConcreteImplementorA
ConcreteImplementorB
ConcreteImplementorC

用诊断例子表示就是:

text 复制代码
业务层
  ↓
DiagnosticService
  ↓ 持有 ITransport
CanTransport / DoipTransport / SimulationTransport

用 UML 简化表示:
#mermaid-svg-ScEAWOEESQNPD2fL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ScEAWOEESQNPD2fL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ScEAWOEESQNPD2fL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ScEAWOEESQNPD2fL .error-icon{fill:#552222;}#mermaid-svg-ScEAWOEESQNPD2fL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ScEAWOEESQNPD2fL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ScEAWOEESQNPD2fL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ScEAWOEESQNPD2fL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ScEAWOEESQNPD2fL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ScEAWOEESQNPD2fL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ScEAWOEESQNPD2fL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ScEAWOEESQNPD2fL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ScEAWOEESQNPD2fL .marker.cross{stroke:#333333;}#mermaid-svg-ScEAWOEESQNPD2fL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ScEAWOEESQNPD2fL p{margin:0;}#mermaid-svg-ScEAWOEESQNPD2fL g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-ScEAWOEESQNPD2fL g.classGroup text .title{font-weight:bolder;}#mermaid-svg-ScEAWOEESQNPD2fL .cluster-label text{fill:#333;}#mermaid-svg-ScEAWOEESQNPD2fL .cluster-label span{color:#333;}#mermaid-svg-ScEAWOEESQNPD2fL .cluster-label span p{background-color:transparent;}#mermaid-svg-ScEAWOEESQNPD2fL .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ScEAWOEESQNPD2fL .cluster text{fill:#333;}#mermaid-svg-ScEAWOEESQNPD2fL .cluster span{color:#333;}#mermaid-svg-ScEAWOEESQNPD2fL .nodeLabel,#mermaid-svg-ScEAWOEESQNPD2fL .edgeLabel{color:#131300;}#mermaid-svg-ScEAWOEESQNPD2fL .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-ScEAWOEESQNPD2fL .label text{fill:#131300;}#mermaid-svg-ScEAWOEESQNPD2fL .labelBkg{background:#ECECFF;}#mermaid-svg-ScEAWOEESQNPD2fL .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-ScEAWOEESQNPD2fL .classTitle{font-weight:bolder;}#mermaid-svg-ScEAWOEESQNPD2fL .node rect,#mermaid-svg-ScEAWOEESQNPD2fL .node circle,#mermaid-svg-ScEAWOEESQNPD2fL .node ellipse,#mermaid-svg-ScEAWOEESQNPD2fL .node polygon,#mermaid-svg-ScEAWOEESQNPD2fL .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ScEAWOEESQNPD2fL .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-ScEAWOEESQNPD2fL g.clickable{cursor:pointer;}#mermaid-svg-ScEAWOEESQNPD2fL g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-ScEAWOEESQNPD2fL g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-ScEAWOEESQNPD2fL .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-ScEAWOEESQNPD2fL .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-ScEAWOEESQNPD2fL .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-ScEAWOEESQNPD2fL .dashed-line{stroke-dasharray:3;}#mermaid-svg-ScEAWOEESQNPD2fL .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-ScEAWOEESQNPD2fL #compositionStart,#mermaid-svg-ScEAWOEESQNPD2fL .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ScEAWOEESQNPD2fL #compositionEnd,#mermaid-svg-ScEAWOEESQNPD2fL .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ScEAWOEESQNPD2fL #dependencyStart,#mermaid-svg-ScEAWOEESQNPD2fL .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ScEAWOEESQNPD2fL #dependencyStart,#mermaid-svg-ScEAWOEESQNPD2fL .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ScEAWOEESQNPD2fL #extensionStart,#mermaid-svg-ScEAWOEESQNPD2fL .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ScEAWOEESQNPD2fL #extensionEnd,#mermaid-svg-ScEAWOEESQNPD2fL .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ScEAWOEESQNPD2fL #aggregationStart,#mermaid-svg-ScEAWOEESQNPD2fL .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ScEAWOEESQNPD2fL #aggregationEnd,#mermaid-svg-ScEAWOEESQNPD2fL .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ScEAWOEESQNPD2fL #lollipopStart,#mermaid-svg-ScEAWOEESQNPD2fL .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ScEAWOEESQNPD2fL #lollipopEnd,#mermaid-svg-ScEAWOEESQNPD2fL .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ScEAWOEESQNPD2fL .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-ScEAWOEESQNPD2fL .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ScEAWOEESQNPD2fL .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ScEAWOEESQNPD2fL .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ScEAWOEESQNPD2fL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} bridge
DiagnosticService
-transport: ITransport
+ReadDid(did)
+WriteDid(did, data)
SecureDiagnosticService
+ReadSecureDid(did)
ITransport
+Connect()
+Send(data)
+Receive()
CanTransport
+Connect()
+Send(data)
+Receive()
DoipTransport
+Connect()
+Send(data)
+Receive()

这张图里最关键的是:

DiagnosticService 不继承 CanTransport,也不直接写死 DoIP。

它只是持有一个 ITransport

这就是桥接模式的核心。


七、一个 C++ 示例:诊断业务和通信实现解耦

先定义底层通信实现接口。

cpp 复制代码
#include <cstdint>
#include <iostream>
#include <memory>
#include <string>
#include <vector>

class ITransport {
public:
    virtual ~ITransport() = default;

    virtual bool Connect() = 0;
    virtual bool Send(const std::vector<uint8_t>& data) = 0;
    virtual std::vector<uint8_t> Receive() = 0;
};

然后定义几个具体通信实现。

cpp 复制代码
class CanTransport : public ITransport {
public:
    bool Connect() override {
        std::cout << "connect by CAN\n";
        return true;
    }

    bool Send(const std::vector<uint8_t>& data) override {
        std::cout << "send CAN frame, size=" << data.size() << "\n";
        return true;
    }

    std::vector<uint8_t> Receive() override {
        std::cout << "receive CAN response\n";
        return {0x62, 0xF1, 0x90, 'V', 'I', 'N'};
    }
};

class DoipTransport : public ITransport {
public:
    bool Connect() override {
        std::cout << "connect by DoIP\n";
        return true;
    }

    bool Send(const std::vector<uint8_t>& data) override {
        std::cout << "send DoIP payload, size=" << data.size() << "\n";
        return true;
    }

    std::vector<uint8_t> Receive() override {
        std::cout << "receive DoIP response\n";
        return {0x62, 0xF1, 0x90, 'V', 'I', 'N'};
    }
};

接下来定义高层诊断抽象。

cpp 复制代码
class DiagnosticService {
public:
    explicit DiagnosticService(std::unique_ptr<ITransport> transport)
        : m_transport(std::move(transport)) {}

    virtual ~DiagnosticService() = default;

    bool Connect() {
        return m_transport->Connect();
    }

    virtual std::vector<uint8_t> ReadDid(uint16_t did) {
        std::vector<uint8_t> request = {
            0x22,
            static_cast<uint8_t>((did >> 8) & 0xFF),
            static_cast<uint8_t>(did & 0xFF)
        };

        if (!m_transport->Send(request)) {
            return {};
        }

        return m_transport->Receive();
    }

    virtual bool WriteDid(uint16_t did, const std::vector<uint8_t>& data) {
        std::vector<uint8_t> request = {
            0x2E,
            static_cast<uint8_t>((did >> 8) & 0xFF),
            static_cast<uint8_t>(did & 0xFF)
        };

        request.insert(request.end(), data.begin(), data.end());

        return m_transport->Send(request);
    }

protected:
    ITransport& Transport() {
        return *m_transport;
    }

private:
    std::unique_ptr<ITransport> m_transport;
};

这里 DiagnosticService 只关心诊断请求怎么组。

它不关心请求是通过 CAN 发出去,还是通过 DoIP 发出去。

底层实现通过 ITransport 被桥接进来。

如果要扩展一种安全诊断服务,可以这样写:

cpp 复制代码
class SecureDiagnosticService : public DiagnosticService {
public:
    explicit SecureDiagnosticService(std::unique_ptr<ITransport> transport)
        : DiagnosticService(std::move(transport)) {}

    bool UnlockSecurity(uint8_t level) {
        std::vector<uint8_t> request = {0x27, level};

        if (!Transport().Send(request)) {
            return false;
        }

        auto response = Transport().Receive();
        return !response.empty() && response[0] == 0x67;
    }

    std::vector<uint8_t> ReadSecureDid(uint16_t did) {
        if (!UnlockSecurity(0x01)) {
            std::cout << "security unlock failed\n";
            return {};
        }

        return ReadDid(did);
    }
};

调用方可以这样使用:

cpp 复制代码
int main() {
    {
        std::unique_ptr<ITransport> transport =
            std::make_unique<CanTransport>();

        DiagnosticService service(std::move(transport));

        service.Connect();
        auto vin = service.ReadDid(0xF190);
    }

    {
        std::unique_ptr<ITransport> transport =
            std::make_unique<DoipTransport>();

        SecureDiagnosticService service(std::move(transport));

        service.Connect();
        auto vin = service.ReadSecureDid(0xF190);
    }

    return 0;
}

你会发现:

  • 普通诊断服务可以搭配 CAN
  • 普通诊断服务也可以搭配 DoIP
  • 安全诊断服务可以搭配 CAN
  • 安全诊断服务也可以搭配 DoIP

但我们不需要写:

text 复制代码
CanDiagnosticService
DoipDiagnosticService
CanSecureDiagnosticService
DoipSecureDiagnosticService

这就是桥接模式带来的结构收益。


八、再看一个更贴近工程的例子:控制逻辑和硬件平台解耦

桥接模式在车企软件里很常见。

比如制动控制。

上层可能有不同控制逻辑:

text 复制代码
NormalBrakeController
SportBrakeController
EnergyRecoveryBrakeController

底层可能有不同硬件平台:

text 复制代码
BoschBrakeHardware
ContinentalBrakeHardware
MockBrakeHardware

如果直接继承组合,很容易变成:

text 复制代码
NormalBoschBrakeController
NormalContinentalBrakeController
SportBoschBrakeController
SportContinentalBrakeController
EnergyRecoveryBoschBrakeController
EnergyRecoveryContinentalBrakeController

类数量一下就膨胀。

更合理的做法是:

text 复制代码
BrakeController
  ↓ 持有
IBrakeHardware
  ↑
BoschBrakeHardware / ContinentalBrakeHardware / MockBrakeHardware

这样控制逻辑和硬件实现就可以分别变化。

新增一个控制策略,不需要复制所有硬件适配代码。

新增一个硬件平台,也不需要重写所有控制逻辑。

这就是桥接模式在工程里的实际意义:

把两个独立变化的维度拆开,让它们通过接口组合在一起。


九、这个例子到底好在哪里?

1. 诊断业务不再绑定通信实现

DiagnosticService 只负责诊断业务。

它知道怎么组请求、怎么解释响应、什么时候需要安全访问。

但它不知道底层是 CAN 还是 DoIP。

这让诊断业务逻辑更干净。


2. 通信实现可以独立扩展

如果未来新增一个仿真通道,只需要实现:

cpp 复制代码
class SimulationTransport : public ITransport {
    // ...
};

原来的 DiagnosticService 不需要改。

如果未来新增一个远程诊断通道,也一样。


3. 两个方向可以自由组合

你可以用:

text 复制代码
普通诊断 + CAN
普通诊断 + DoIP
安全诊断 + CAN
安全诊断 + DoIP
刷写诊断 + DoIP
刷写诊断 + 仿真通道

组合关系可以在创建阶段决定。

这比提前写死一堆子类更灵活。


4. 更适合测试

桥接之后,你可以写一个测试用的实现:

cpp 复制代码
class MockTransport : public ITransport {
    // ...
};

然后用它测试诊断业务逻辑。

这样测试 DiagnosticService 时,不需要真的连 CAN 卡,也不需要真的连 ECU。

这对单元测试非常有价值。


十、车企项目里,哪些地方适合桥接模式?

1. 诊断业务与通信通道解耦

比如:

text 复制代码
DiagnosticService
  ↓
ITransport
  ↑
CanTransport / DoipTransport / SerialTransport / SimulationTransport

诊断业务关注 UDS 流程。

通信实现关注底层收发。

两者适合桥接。


2. 控制逻辑与硬件平台解耦

比如:

text 复制代码
BrakeController
  ↓
IBrakeHardware
  ↑
BoschHardware / ContinentalHardware / MockHardware

控制逻辑可能随着业务策略变化。

硬件平台可能随着车型、供应商、项目变化。

这两个维度不应该互相污染。


3. OTA 流程与传输通道解耦

OTA 业务可能包括:

  • 检查版本
  • 校验包
  • 进入升级模式
  • 分片传输
  • 校验刷写结果
  • 失败回滚

但传输通道可能是:

  • 车端本地文件
  • TBOX 下载
  • 以太网传输
  • 诊断刷写通道
  • 仿真测试通道

OTA 流程和传输实现也适合桥接。


4. 数据采集任务与存储后端解耦

数据采集任务关心:

  • 采哪些信号
  • 采样频率是多少
  • 什么时候开始
  • 什么时候停止
  • 是否过滤异常值

存储后端可能是:

  • 本地文件
  • SQLite
  • 云端服务
  • Kafka
  • 测试桩

这也是典型的两个变化维度。


5. HMI 业务组件与渲染平台解耦

HMI 业务组件可能是:

  • 车速表
  • 电量显示
  • 告警弹窗
  • 导航卡片

渲染平台可能是:

  • Qt
  • Android
  • Web
  • 自研渲染引擎

如果业务组件和渲染实现写死在一起,跨平台会非常痛苦。

桥接模式可以让业务组件依赖渲染接口,而不是直接依赖某个平台。


十一、桥接模式和适配器模式有什么区别?

这两个模式都像是在"接一层"。

但它们的意图完全不同。


适配器模式

适配器解决的是:

已有类的接口和当前系统需要的接口不兼容,怎么接进来。

它通常是事后补救。

比如老系统已经有一个 LegacyCanChannel,但新系统需要 ITransport

于是写一个 LegacyCanAdapter

重点是:

把旧接口转换成新接口。


桥接模式

桥接解决的是:

抽象和实现有两个变化方向,怎么让它们分别变化。

它通常是设计阶段就主动拆分。

比如诊断业务和通信实现都可能变化。

于是让 DiagnosticService 持有 ITransport

重点是:

把两个变化维度拆开。


一句话区分

适配器:接口不兼容,所以接一下。

桥接:两个维度都会变,所以拆开来。

如果你面对的是一个已经存在的老接口,那更像适配器。

如果你面对的是两个会长期独立变化的维度,那更像桥接。


十二、桥接模式和装饰器模式有什么区别?

装饰器模式也常用组合。

桥接模式也常用组合。

但它们解决的问题不同。


装饰器模式

装饰器关注的是:

不改原对象,动态增加额外能力。

比如:

text 复制代码
LoggingTransportDecorator
RetryTransportDecorator
MetricsTransportDecorator

它的重点是能力增强。


桥接模式

桥接关注的是:

抽象层和实现层不要绑死,让它们分别变化。

比如:

text 复制代码
DiagnosticService
  ↓
ITransport
  ↑
CanTransport / DoipTransport

它的重点是维度拆分。


一句话区分

装饰器:在原能力外面叠加能力。

桥接:把两个变化方向拆成两套结构。

如果你是在给对象加日志、重试、统计,通常是装饰器。

如果你是在拆"业务抽象"和"底层实现",通常是桥接。


十三、桥接模式和策略模式有什么区别?

桥接模式和策略模式也很像。

因为它们都经常表现为:

text 复制代码
一个类持有一个接口
运行时传入不同实现

但关注点不一样。


策略模式

策略模式解决的是:

一个行为有多种算法或策略,运行时可以替换。

比如:

  • 能量回收策略
  • 路径规划策略
  • 告警处理策略
  • 排序策略
  • 校验策略

它关注的是某个行为怎么做。


桥接模式

桥接模式解决的是:

抽象和实现是两个独立变化的层次,需要解耦。

比如:

  • 诊断业务 vs 通信实现
  • 控制逻辑 vs 硬件平台
  • 数据采集任务 vs 存储后端
  • HMI 组件 vs 渲染引擎

它关注的是结构层次怎么拆。


一句话区分

策略模式更像"换算法"。

桥接模式更像"拆维度"。

如果只是一个方法有多种算法,优先考虑策略模式。

如果是两个对象层级都可能独立扩展,桥接模式更合适。


十四、桥接模式最容易被滥用在哪里?

桥接模式很好,但也容易被写复杂。

1. 只有一个变化方向,也强行桥接

如果你的系统里只有一种实现,而且短期内也不会有第二种实现,就没必要提前抽一层。

比如只有一个固定通信通道,还强行写:

text 复制代码
ITransport
AbstractTransport
ConcreteTransport
TransportBridge

这就不是设计,是加戏。

设计模式不是越早用越好。

它应该用在真实复杂性出现的地方。


2. Implementor 接口设计得太贴某个实现

桥接的关键是实现接口要稳定。

如果你设计的 ITransport 长这样:

cpp 复制代码
virtual void SendCanFrame(int canId, const uint8_t* data, int len) = 0;

那它其实已经偏向 CAN 了。

未来 DoIP、串口、仿真通道接进来都会很别扭。

好的 Implementor 接口应该表达抽象层真正需要的底层能力,而不是某个具体实现的细节。


3. 把桥接层写成万能中转层

有些代码会把桥接接口越写越大。

比如一个 ITransport 里塞满:

text 复制代码
SendCanFrame()
SendDoipPacket()
OpenSerialPort()
StartSimulation()
UploadToCloud()

这就失去了桥接的意义。

接口应该稳定、清晰、聚焦。

如果一个接口什么都想表达,最后它就什么都表达不好。


4. 抽象层和实现层边界没切对

桥接最难的不是写代码。

而是判断:

哪些属于抽象层?

哪些属于实现层?

比如诊断服务里:

  • 组 UDS 请求,属于诊断业务
  • 通过 CAN 还是 DoIP 发送,属于通信实现
  • 安全访问流程,通常属于诊断业务
  • TCP 建链细节,属于 DoIP 实现

边界切错之后,桥接结构就会变得很别扭。


5. 对象创建阶段混乱

桥接之后,对象通常需要组合起来。

比如:

text 复制代码
DiagnosticService + CanTransport
SecureDiagnosticService + DoipTransport

如果这些组合关系到处手写,也会乱。

所以桥接模式经常会和工厂、配置中心、依赖注入一起使用。

桥接负责结构解耦。

工厂负责对象组装。

这两个不是冲突关系。


十五、工程中更推荐的用法

1. 先识别两个变化维度

使用桥接前,先问:

这里是不是真的有两个独立变化方向?

比如:

text 复制代码
业务类型会变
底层实现也会变

如果答案是肯定的,再考虑桥接。

不要看到组合和接口就觉得是桥接。

桥接的核心是"两个维度分别变化"。


2. 抽象层按业务语义设计

抽象层要面向调用方。

比如诊断服务应该暴露:

text 复制代码
ReadDid()
WriteDid()
ReadDtc()
ClearDtc()

而不是暴露一堆底层发送细节。

抽象层越贴近业务,调用方越舒服。


3. 实现层按稳定能力设计

实现层接口要表达底层公共能力。

比如通信实现可以抽象成:

text 复制代码
Connect()
Send()
Receive()

不要把某个具体协议的细节直接写进公共接口。

否则桥接接口会变得很难复用。


4. 优先组合,而不是多层继承

桥接模式的核心就是组合。

cpp 复制代码
class DiagnosticService {
private:
    std::unique_ptr<ITransport> m_transport;
};

它不是通过继承 CanTransport 获得能力。

而是通过持有 ITransport 来使用能力。

这也是"组合优于继承"的一个典型落地。


5. 创建逻辑可以交给工厂

桥接本身不负责决定用哪个实现。

比如到底用 CAN 还是 DoIP,可能来自:

  • 配置文件
  • 车型平台
  • 运行环境
  • 测试参数
  • 工厂方法
  • 依赖注入容器

这部分不要硬塞进业务类。

可以交给工厂或配置层。


6. 测试时分别测试两个维度

桥接之后,测试也应该更清楚。

你可以分别测试:

  • 诊断业务是否正确组包
  • CAN 通信实现是否正确发送
  • DoIP 通信实现是否正确发送
  • 诊断业务能否配合 MockTransport 工作

这比一上来测试一堆组合类更容易定位问题。


十六、桥接模式的优缺点

优点

第一,抽象和实现解耦。

业务层不再直接绑定底层实现。

第二,两个方向可以独立扩展。

新增业务抽象和新增底层实现,不需要互相复制。

第三,避免继承层次爆炸。

多个变化维度不再被硬压进一棵继承树。

第四,更容易测试。

抽象层可以配合 Mock 实现测试。

实现层也可以单独测试。

第五,更适合平台化代码。

车企软件里经常有多车型、多平台、多供应商、多环境,桥接模式很适合处理这类变化。


缺点

第一,结构会比直接写类更复杂。

多了一层接口和组合关系,新人一开始可能不容易理解。

第二,接口边界设计要求更高。

如果抽象层和实现层切分不合理,代码会更别扭。

第三,对象创建会更复杂。

需要在创建阶段决定抽象和实现如何组合。

第四,不适合简单场景。

如果只有一个实现、一个业务类型,强行桥接只会增加负担。


十七、使用桥接模式前,先问这 6 个问题

1. 这里是否真的有两个变化方向?

比如业务会变,底层实现也会变。

如果只有一个方向会变,桥接可能不是最合适的模式。


2. 这两个变化方向是否应该独立演进?

如果新增一个底层实现,不应该影响业务抽象。

如果新增一个业务抽象,也不应该复制底层实现。

这时桥接才有价值。


3. 现在的继承结构是否已经开始膨胀?

如果你已经看到大量组合类名,比如:

text 复制代码
A1B1
A1B2
A2B1
A2B2

那就要警惕了。

这很可能说明你把两个维度塞进了一棵继承树。


4. 抽象层是否能稳定表达业务语义?

桥接不是为了抽接口而抽接口。

抽象层应该让调用方觉得自然。

比如:

cpp 复制代码
diagService.ReadDid(0xF190);

就比:

cpp 复制代码
diagService.SendRawBytes(...);

更符合诊断业务语义。


5. 实现层接口是否足够通用?

实现层接口应该能覆盖不同实现。

如果接口明显偏向某个具体实现,后面接其他实现时就会很痛苦。


6. 创建和组合关系是否有明确归属?

桥接后,对象需要被组装。

谁来决定:

text 复制代码
SecureDiagnosticService + DoipTransport

这个问题要想清楚。

不要让组合逻辑散落在业务代码各处。


十八、总结

桥接模式解决的是:

抽象和实现都有可能变化时,如何让它们分别变化,而不是互相绑死。

它不是为了让代码多一层接口。

它真正想解决的是:

  • 两个变化维度被揉在一起
  • 继承层次随着组合数量膨胀
  • 抽象层被底层实现细节污染
  • 新增实现时需要修改大量业务类
  • 新增业务时需要复制大量底层实现代码
  • 平台化、多车型、多供应商场景下扩展困难

一句话概括:

桥接模式的重点不是"多写一个接口",而是"把两个独立变化的维度拆开"。

在车企软件里,它很适合这些场景:

  • 诊断业务与通信通道解耦
  • 控制逻辑与硬件平台解耦
  • OTA 流程与传输通道解耦
  • 数据采集任务与存储后端解耦
  • HMI 业务组件与渲染平台解耦
  • 仿真业务流程与仿真引擎解耦

但它也不适合所有场景。

如果系统只有一个实现维度,或者变化点还没有出现,就不要为了"像设计模式"而提前桥接。

设计模式真正有价值的地方,不是名字背得熟,而是:

你能不能看见变化的方向,并让代码结构顺着变化方向生长。

如果上一篇外观模式提醒我们:

不要把"明明应该由系统边界统一承担的复杂流程",丢给每一个业务调用方自己去拼。

那么这一篇桥接模式提醒我们:

不要把"明明会分别变化的抽象和实现",硬绑在同一棵继承树里。

如果这篇对你有帮助,欢迎点赞、转发、关注。

我们下一篇继续拆设计模式。