Day 27:插件架构模式(Plugin Architecture)

C++/Qt 每日设计模式 · 架构模式阶段 · 2026-09-17

架构模式 依赖倒置 运行期发现 QPluginLoader ABI 契约

01

今日主题

一句话定义:插件架构模式(Plugin Architecture)是指宿主程序(Host)在编译期只依赖一个稳定的抽象接口(契约/SDK),把具体功能实现放进独立的动态库(Plugin)里,由加载器(Loader)在运行期扫描、按元数据识别、实例化并接入宿主——宿主发布之后一行代码不用改、一次不用重编,用户装上一个 .so / .dll 功能就出现,删掉那个文件功能就干净消失。

它不是一个「类级」的 GoF 模式,而是一种架构级组合:依赖倒置原则(DIP)+ 运行期发现 + 元数据契约 + 生命周期隔离。你昨天学的抽象工厂、工厂方法、策略、中介者,在插件架构里全都会以「零件」的形式再次出现;插件架构是它们的装配方式。

本篇定位:Day 24/25/26 解决的是「已有的模块之间怎么分工、怎么说话」。今天要回答一个更激进的问题:模块能不能在编译期之后才存在?如果宿主编译时完全不知道未来会有哪些功能,它靠什么调用一个还不存在的类?答案是把「知道具体类型」这件事推到运行期,用接口 + 元数据 + 加载器三件套换回来。这篇先给 4 个文件、约 90 行纯 C++17 的手写插件骨架(接口 / 注册表 / 两个插件 / 宿主,可真实编译运行并给出输出),再用 Qt6 官方三件套 Q_DECLARE_INTERFACE + Q_PLUGIN_METADATA + QPluginLoader 搭一个完整最小工程(含 CMakeLists.txt),最后讲清插件最难的两件事:ABI 稳定与加载失败隔离。

Web 架构师的类比入口:插件 ≈ Chrome 扩展 / VS Code Extension / Webpack 的 plugin 与 loader / Node 的 require('./x.js') / jQuery 插件。但 Web 生态有两大便利我们这里没有:JS 是动态语言且有 GC,模块可以随便 require,对象不用谁删;C++ 里既没有运行时反射(拿不到「这个类有哪些方法」),也没有 GC(dlclose 之后残留指针就是悬空指针崩溃)。所以我们必须显式定义 ABI 契约:一个纯虚基类 + 一个接口标识字符串(IID)+ 一个稳定的导出符号,并且亲手管理加载/卸载的生命周期——这正是 Qt 的 Q_DECLARE_INTERFACE / Q_PLUGIN_METADATA / QPluginLoader 三件套替你标准化的东西。

02

为什么需要这个模式

先看一段非常典型的「没有插件的桌面客户端」代码。假设我们在写一个图像编辑器,滤镜功能是核心卖点:

// ❌ 无模式的图像编辑器(节选):所有滤镜硬编码在宿主里
#include <QImage>
#include <QString>

enum class FilterType { None, Invert, Blur, Sepia };   // 每加一个滤镜,这里改一次

QImage applyFilter(const QImage& src, FilterType type)
{
    switch (type) {                                    // 宿主必须知道每个滤镜的实现细节
        case FilterType::Invert:  /* 30 行反相代码,散落在宿主里 */ break;
        case FilterType::Blur:    /* 60 行高斯模糊代码 */ break;
        case FilterType::Sepia:   /* 25 行怀旧色调代码 */ break;
        case FilterType::None:    return src;
    }
    return src;
}

// 菜单构造:字符串列表也要跟着手改
QStringList filterNames() { return { "反相", "模糊", "怀旧" }; }

这段代码在「只有三个滤镜、只有一个团队、只发一个版本」时完全能用,甚至是最省事的选择。但产品一旦长大,它同时踩中五个坑:

  1. 违反单一职责(SRP):宿主既要管窗口、管文档、管撤销栈,还要管每个滤镜的像素算法。模糊算法调参是「图像算法」的知识,不该住在「应用框架」里。
  2. 违反开闭原则(OCP):新增一个滤镜,必须回头修改 enum、switch、filterNames() 三处已测试过的代码。每次扩展都在「修改」而不是「新增」。
  3. 违反依赖倒置(DIP):高层模块(主程序)依赖低层细节(具体滤镜实现),而不是双方依赖抽象。宿主 #include 了每一个滤镜的头文件。
  4. 发布粒度错位:改一个滤镜公式 → 重编整个工程 → 重新打包 120MB 安装包 → 全部用户重新下载。而插件模式下,用户只需替换一个 200KB 的 .so。
  5. 扩展性归零:客户的私有滤镜、第三方开发者、公司内部的实验功能,都只能塞进主干分支——客户定制版最后会退化成 N 个长期维护的分支。

还有两个不那么显眼、但在 C++ 桌面开发里非常致命的点:故障隔离与启动成本。硬编码时,任何一个滤镜里的段错误都会拖垮整个进程;而所有滤镜代码都在启动时被加载进内存,功能越多启动越慢。插件架构让「加载」变成一个可以被跳过、被延后、被隔离的独立阶段——加载失败只打印一行警告,宿主照常启动。

判断信号:当你的项目同时满足「功能集合会持续增长」+「不同客户/不同版本功能集合不同」+「希望第三方或非核心团队扩展」这三条时,插件架构是正解。反之,如果你的应用只有固定 5 个功能、只发一个内部版本,那么本节开头那段 switch 就是最优解——插件架构是一种为变化付出的架构成本,没有变化就不该付这个钱。

03

核心思想

通俗讲:宿主定「卡口」,插件做「镜头」。机身只需要知道卡口的直径、法兰距和触点协议,不需要知道面前装的是 35mm 定焦还是 70-200 长焦;镜头坏了换一支,机身不动。

四个生活类比,分别对应插件架构的四个关键性质:

把类比翻译成工程语言,插件架构由三个不可省略的要素组成:

  1. 稳定契约(Stable Contract):一个纯虚接口(含虚析构),构成宿主与插件之间唯一的编译期依赖。它通常被打包成独立的 *_api.h / SDK 头文件,由宿主团队维护。它只允许追加方法,不允许修改已有方法签名、不允许改虚函数顺序——否则所有已编译的插件立即 ABI 不兼容,必须全部重编。
  2. 元数据(Metadata):插件的身份证——实现了哪个接口(IID 字符串)、插件名、版本、作者、依赖。关键价值在于:宿主可以在不实例化、不执行插件代码的前提下先读元数据做筛选与版本校验(Qt 里 QPluginLoader::metaData() 就是干这个的)。
  3. 加载器(Loader):负责发现(扫目录)、识别(校验 IID / 接口身份)、实例化(创建对象或调用导出函数)、注册(挂到注册表供业务取用)、隔离(失败就跳过并记录日志)、卸载(保证对象先死、库后卸)。

一句话记住它和普通「接口编程」的区别:普通接口编程把依赖推迟到「运行时多态」,插件架构把依赖推迟到「加载时发现」——宿主编译时,插件类根本不存在于它的翻译单元里。

04

UML / 角色关系

结构上,插件架构是「宿主 + 抽象接口 + N 个运行期实体」的星型关系。注意图中最重要的那条线:宿主编译期只依赖接口,插件也是,两者之间没有任何编译期依赖;唯一的联系是运行期由加载器建立的对象指针。

         ┌─────────────────────────────────────────────────────────┐
         │                      宿主 Host App                      │
         │  ┌──────────────┐      ┌──────────────────────────────┐  │
         │  │ PluginMgr    │─────▶│  PluginRegistry              │  │
         │  │ (加载器)    │ 注册  │  id → 工厂函数 / 实例         │  │
         │  └──────┬───────┘      └───────────────┬──────────────┘  │
         └─────────┼──────────────────────────────┼────────────────┘
                   │ QPluginLoader                │ 依赖(编译期,仅接口)
                   │ 运行期 dlopen / 读元数据      ▼
    ┌───────────────────────┐        ┌────────────────────────────────┐
    │ plugin_invert.so      │        │  ImageFilterInterface          │
    │   └ InvertFilterPlugin│───────▶│   + name() / id()              │
    └───────────────────────┘ 实现    │   + apply(QImage)              │
    ┌───────────────────────┐ (编译期) │   + 虚析构 + IID 元数据         │
    │ plugin_tint.so        │────────▶└────────────────────────────────┘
    │   └ TintFilterPlugin  │
    └───────────────────────┘
角色职责变化频率实现要点
抽象接口 / 契约 定义「插件能做什么」,是宿主与插件之间唯一的编译期依赖 极低(改它就是改 ABI,所有插件重编) 纯虚方法 + virtual ~;参数只用稳定类型(Qt 类型或 POD/C 类型)
具体插件类 实现接口;内部实现可以随便改,只要不改接口 高(每个插件独立迭代、独立发布) Qt:QObject + Q_INTERFACES + Q_PLUGIN_METADATA
元数据 插件身份证:IID、名称、版本、依赖;供宿主在不实例化时筛选校验 随插件版本变化 JSON 文件(Q_PLUGIN_METADATA FILE)或 QJsonObject 字面量
加载器 Loader 扫描目录、加载动态库、校验接口身份、实例化、注册、隔离失败、卸载 低(属于框架基础设施) QPluginLoader + QLibrary::isLibrary() + qobject_cast
注册表 / 宿主业务 按 id 或能力取用插件,只面向接口编程,不认识任何具体类 中 宿主代码里不出现任何插件头文件,符号层面完全解耦

一个反直觉但很重要的推论:接口是「宿主 SDK」的一部分,不是插件的一部分。它必须独立成文件、独立成版本号,因为它有两个使用者(宿主与所有插件)。如果你把接口头文件放在宿主的业务目录里,插件开发者就不得不拉取整个宿主源码树——那一刻插件架构的解耦价值就消失了一半。

05

最小 C++ 示例(C++17,可编译)

下面 4 个文件构成最小可运行骨架:接口、注册表、两个插件、宿主。为了在单个终端里一条命令就能编译运行,我把两个插件放进了同一个可执行文件(真实场景它们是独立的 .so);但请注意代码里那条关键分界线——main.cpp 没有 include 任何插件头文件,它只声明了两个 extern "C" 入口符号,这就是「编译期解耦」在源码层面的样子。

// ============================================================
// filter_api.hpp —— 宿主与插件共享的「契约」(等价于插件的 SDK 头文件)
// ============================================================
#pragma once

#include <memory>
#include <string>
#include <vector>

class IImageFilter {
public:
    virtual ~IImageFilter() = default;                  // 必须:通过基类指针销毁派生对象才安全

    [[nodiscard]] virtual std::string name() const = 0; // 显示名(给用户看)
    [[nodiscard]] virtual std::string id() const = 0;   // 稳定 id(给注册表/配置文件用)

    // 处理一批像素:返回新容器,不修改入参 —— 值语义,避免跨模块共享可变状态
    [[nodiscard]] virtual std::vector<int> apply(const std::vector<int>& pixels) const = 0;
};

using FilterPtr = std::unique_ptr<IImageFilter>;        // 所有权明确:谁创建谁销毁


// ============================================================
// filter_registry.hpp —— 宿主侧注册表:id → 工厂函数
// ============================================================
#pragma once

#include "filter_api.hpp"

#include <algorithm>
#include <functional>
#include <iostream>
#include <string>
#include <unordered_map>
#include <vector>

class FilterRegistry {
public:
    using Creator = std::function<FilterPtr()>;         // 工厂:延迟创建,宿主决定何时实例化

    // 注册失败(id 重名)只告警不抛异常:一个坏插件不能阻断宿主启动
    bool add(const std::string& id, Creator creator)
    {
        const auto inserted = creators_.emplace(id, std::move(creator)).second;
        if (!inserted) {
            std::cerr << "[warn] 插件 id 重复,忽略: " << id << '\n';
        }
        return inserted;
    }

    [[nodiscard]] FilterPtr create(const std::string& id) const
    {
        const auto it = creators_.find(id);
        return (it == creators_.end()) ? nullptr : it->second();   // 缺席=返回 nullptr,不是异常
    }

    [[nodiscard]] std::vector<std::string> ids() const
    {
        std::vector<std::string> out;
        out.reserve(creators_.size());
        for (const auto& entry : creators_) {
            out.push_back(entry.first);
        }
        std::sort(out.begin(), out.end());                // 稳定有序输出,便于测试和 UI 展示
        return out;
    }

    [[nodiscard]] std::size_t size() const noexcept { return creators_.size(); }

private:
    std::unordered_map<std::string, Creator> creators_;
};


// ============================================================
// invert_filter.cpp —— 插件 1:反相(真实场景中它是独立的 plugin_invert.so)
// 此文件不 include 任何宿主业务头文件,只依赖契约 filter_api.hpp
// ============================================================
#include "filter_api.hpp"
#include "filter_registry.hpp"

namespace {
// 具体类放进匿名命名空间:符号不外泄,杜绝与其他插件重名冲突
class InvertFilter final : public IImageFilter {
public:
    [[nodiscard]] std::string name() const override { return "反相"; }
    [[nodiscard]] std::string id() const override { return "invert"; }

    [[nodiscard]] std::vector<int> apply(const std::vector<int>& pixels) const override
    {
        std::vector<int> out;
        out.reserve(pixels.size());                      // 一次分配到位,避免反复扩容
        for (const int p : pixels) {
            out.push_back(255 - p);                      // 8bit 通道反相
        }
        return out;                                      // 返回局部变量,靠 NRVO 零拷贝
    }
};
} // namespace

// 插件入口:extern "C" 关闭 C++ 名字修饰,宿主 dlsym 时才能按固定名字找到它
extern "C" void register_invert_plugin(FilterRegistry& registry)
{
    registry.add("invert", []() -> FilterPtr { return std::make_unique<InvertFilter>(); });
}


// ============================================================
// tint_filter.cpp —— 插件 2:调色
// ============================================================
#include "filter_api.hpp"
#include "filter_registry.hpp"

namespace {
class TintFilter final : public IImageFilter {
public:
    [[nodiscard]] std::string name() const override { return "调色"; }
    [[nodiscard]] std::string id() const override { return "tint"; }

    [[nodiscard]] std::vector<int> apply(const std::vector<int>& pixels) const override
    {
        std::vector<int> out;
        out.reserve(pixels.size());
        for (const int p : pixels) {
            out.push_back((p * 3) / 4 + 20);              // 简化色调映射:0.75p + 20(整数运算)
        }
        return out;
    }
};
} // namespace

extern "C" void register_tint_plugin(FilterRegistry& registry)
{
    registry.add("tint", []() -> FilterPtr { return std::make_unique<TintFilter>(); });
}


// ============================================================
// main.cpp —— 宿主:只认识 IImageFilter 与 FilterRegistry
// 编译:g++ -std=c++17 -O2 -Wall main.cpp invert_filter.cpp tint_filter.cpp -o filter_demo
// ============================================================
#include "filter_api.hpp"
#include "filter_registry.hpp"

#include <iostream>
#include <string>
#include <vector>

// 宿主只「声明」插件入口,不 include 插件实现 —— 这就是编译期解耦。
// 真实场景中这两行等价于 QPluginLoader::instance() 或 dlsym 拿到的函数指针;
// 每个插件是独立 .so,符号自然互不冲突。
extern "C" void register_invert_plugin(FilterRegistry& registry);
extern "C" void register_tint_plugin(FilterRegistry& registry);

int main()
{
    FilterRegistry registry;

    // ① 「加载」阶段:等价于扫描 plugins/ 目录,把每个 .so 的入口都调一遍
    register_invert_plugin(registry);
    register_tint_plugin(registry);

    std::cout << "已加载插件 (" << registry.size() << "):";
    for (const std::string& id : registry.ids()) {
        std::cout << ' ' << id;
    }
    std::cout << '\n';

    const std::vector<int> src{0, 100, 200, 255};

    // ② 「使用」阶段:业务代码只面向接口编程,不在乎插件是谁写的、什么时候装的
    for (const char* const id : {"invert", "tint", "unknown"}) {   // 用 const char* 避免临时 string
        FilterPtr filter = registry.create(id);
        if (!filter) {                                   // 插件缺失是正常分支,不是异常
            std::cout << "未找到插件: " << id << '\n';
            continue;
        }
        const std::vector<int> out = filter->apply(src);
        std::cout << filter->name() << "(" << id << ") -> ";
        for (const int v : out) {
            std::cout << v << ' ';
        }
        std::cout << '\n';
    }
    return 0;                                            // 四个 unique_ptr 已全部随作用域析构
}

程序真实输出:

已加载插件 (2): invert tint
反相(invert) -> 255 155 55 0
调色(tint) -> 20 95 170 211
未找到插件: unknown

几个刻意的设计选择,值得逐条看懂:

06

Qt 实战示例(Qt6 + C++17,完整工程)

手写 dlopen / extern "C" 能跑,但真正做产品要自己解决:跨平台文件扩展名(.so/.dll/.dylib)、库类型校验、接口身份识别、元数据读取、QObject 父子关系与信号槽、卸载时机。Qt 用三个宏 + 一个类把这一整套标准化了——这也是为什么「Qt 桌面客户端的插件架构」几乎是 QPluginLoader 的同义词。

文件 1:接口契约(宿主与插件共享)

// filter_plugin_api.h —— 独立成 SDK,宿主与所有插件共同 include
#pragma once

#include <QImage>
#include <QString>
#include <QtPlugin>

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

    virtual QString name() const = 0;                  // 显示名
    virtual QString id() const = 0;                    // 稳定 id,用于配置持久化
    virtual QImage apply(const QImage& input) const = 0;
};

// IID:接口的唯一身份标识(惯例是 "域名/版本" 格式)。
// QPluginLoader 加载后会比对插件记录的 IID 与这里是否一致,
// 从而实现「类型安全的跨模块接口查询」—— 这是 C++ 里没有反射时最实用的替代方案。
#define ImageFilterInterface_iid "com.jankerli.demo.ImageFilterInterface/1.0"

Q_DECLARE_INTERFACE(ImageFilterInterface, ImageFilterInterface_iid)

文件 2/3:插件实现(编译成独立 .so)

// plugins/invertfilterplugin.h
#pragma once

#include "filter_plugin_api.h"
#include <QObject>

class InvertFilterPlugin final : public QObject, public ImageFilterInterface
{
    Q_OBJECT
    // Q_PLUGIN_METADATA:插件的「身份证」,编译进库的元数据段
    //  - IID  : 声明实现了哪个接口(与 Q_DECLARE_INTERFACE 的宏名一致)
    //  - FILE : 附带一个 JSON 文件作为可读元信息(名称/版本/作者/依赖)
    Q_PLUGIN_METADATA(IID ImageFilterInterface_iid FILE "invertfilter.json")
    Q_INTERFACES(ImageFilterInterface)

public:
    explicit InvertFilterPlugin(QObject* parent = nullptr) : QObject(parent) {}

    QString name() const override;
    QString id() const override;
    QImage apply(const QImage& input) const override;
};
// plugins/invertfilterplugin.cpp
#include "invertfilterplugin.h"

QString InvertFilterPlugin::name() const { return QStringLiteral("反相"); }
QString InvertFilterPlugin::id() const   { return QStringLiteral("invert"); }

QImage InvertFilterPlugin::apply(const QImage& input) const
{
    if (input.isNull()) {
        return {};                                     // 输入非法直接返回空图,不崩
    }
    QImage out = input.convertToFormat(QImage::Format_ARGB32);   // 统一格式再逐像素处理

    for (int y = 0; y < out.height(); ++y) {
        QRgb* line = reinterpret_cast<QRgb*>(out.scanLine(y));   // 每行只取一次 scanLine(性能关键)
        for (int x = 0; x < out.width(); ++x) {
            const QRgb c = line[x];
            line[x] = qRgb(255 - qRed(c), 255 - qGreen(c), 255 - qBlue(c));
        }
    }
    return out;
}
// plugins/invertfilter.json —— Q_PLUGIN_METADATA 的元数据文件
{
    "name": "invert",
    "version": "1.0.0",
    "author": "jankerli",
    "description": "逐像素 255-v 反相滤镜"
}

文件 4:加载器(插件管理器)

// src/pluginmanager.h
#pragma once

#include <QList>
#include <QPluginLoader>
#include <QString>

#include <memory>
#include <vector>

class ImageFilterInterface;

class PluginManager
{
public:
    // 扫描 dir 下所有动态库并尝试加载,返回成功加载的插件数
    int loadAll(const QString& dir);

    QList<ImageFilterInterface*> filters() const { return filters_; }

private:
    QList<ImageFilterInterface*> filters_;                        // 借用指针:所有者是 loader_
    std::vector<std::unique_ptr<QPluginLoader>> loaders_;         // RAII:析构自动 unload
};
// src/pluginmanager.cpp
#include "pluginmanager.h"
#include "filter_plugin_api.h"

#include <QDebug>
#include <QDir>
#include <QFileInfo>
#include <QLibrary>

int PluginManager::loadAll(const QString& dir)
{
    int loaded = 0;
    const QDir pluginDir(dir);

    // QDir::Files 只取文件;QLibrary::isLibrary() 跨平台判断是否为合法库
    // (Linux .so / Windows .dll / macOS .dylib,Qt 内部维护这份规则)
    const QFileInfoList entries = pluginDir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot);

    for (const QFileInfo& info : entries) {
        if (!QLibrary::isLibrary(info.absoluteFilePath())) {
            continue;                                  // 不是动态库(可能是 json/说明文档)直接跳过
        }

        auto loader = std::make_unique<QPluginLoader>(info.absoluteFilePath());

        QObject* root = loader->instance();             // 此刻才真正 dlopen + 读元数据
        if (!root) {
            // 加载失败(缺少依赖、架构不匹配、ABI 不符…):记录并继续,绝不中断宿主启动
            qWarning() << "[plugin] 加载失败:" << info.fileName() << loader->errorString();
            continue;
        }

        // qobject_cast 会校验 Q_PLUGIN_METADATA 里的 IID 是否与本接口一致:
        // 别人的插件(实现了别的接口)在这里会被安全地拒之门外,而不是踩内存。
        auto* filter = qobject_cast<ImageFilterInterface*>(root);
        if (!filter) {
            qWarning() << "[plugin] 接口不匹配,跳过:" << info.fileName();
            loader->unload();
            continue;
        }

        qInfo() << "[plugin] 已加载:" << filter->id() << filter->name() << info.fileName();
        filters_.append(filter);
        loaders_.push_back(std::move(loader));          // 必须保留 loader:工厂卸载=插件对象全失效
        ++loaded;
    }
    return loaded;
}

文件 5:宿主 main.cpp

// src/main.cpp
#include "filter_plugin_api.h"
#include "pluginmanager.h"

#include <QColor>
#include <QCoreApplication>
#include <QDebug>
#include <QDir>

int main(int argc, char* argv[])
{
    QCoreApplication app(argc, argv);

    // 插件目录约定:可执行文件同级 plugins/,也允许命令行覆盖(便于调试与安装器定制)
    const QString pluginDir = (argc > 1)
        ? QString::fromLocal8Bit(argv[1])
        : QCoreApplication::applicationDirPath() + QStringLiteral("/plugins");

    PluginManager manager;
    const int count = manager.loadAll(pluginDir);
    qInfo() << "共加载插件:" << count << "个,目录:" << QDir::toNativeSeparators(pluginDir);

    // 造一张 2x1 的图当测试输入
    QImage src(2, 1, QImage::Format_ARGB32);
    src.setPixel(0, 0, qRgb(10, 20, 30));
    src.setPixel(1, 0, qRgb(200, 210, 220));

    // 宿主业务:遍历所有插件,统统只通过接口调用 —— 一个插件头文件都没 include
    for (ImageFilterInterface* filter : manager.filters()) {
        const QImage out = filter->apply(src);
        qInfo() << filter->name()
                << QColor(out.pixel(0, 0)).name()
                << QColor(out.pixel(1, 0)).name();
    }
    return 0;
}

文件 6:CMakeLists.txt(宿主与插件一起构建)

cmake_minimum_required(VERSION 3.16)
project(ImageFilterDemo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)                 # 让 CMake 自动为 Q_OBJECT / Q_PLUGIN_METADATA 跑 moc
find_package(Qt6 REQUIRED COMPONENTS Core Gui)

# ---------- 宿主程序 ----------
add_executable(imagefilter_host
    src/main.cpp
    src/pluginmanager.cpp
    src/filter_plugin_api.h
)
target_include_directories(imagefilter_host PRIVATE src)
target_link_libraries(imagefilter_host PRIVATE Qt6::Core Qt6::Gui)

# ---------- 插件动态库(MODULE = 只被加载、不被链接的动态库) ----------
add_library(invertfilterplugin MODULE
    plugins/invertfilterplugin.cpp
    plugins/invertfilterplugin.h
    src/filter_plugin_api.h
)
target_include_directories(invertfilterplugin PRIVATE src)
target_link_libraries(invertfilterplugin PRIVATE Qt6::Core Qt6::Gui)

# 插件统一输出到 build/plugins/,与宿主约定的插件目录一致
set_target_properties(invertfilterplugin PROPERTIES
    LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

# 更贴近 Qt 生态的写法(可选,等价于上面两段):
# qt_add_plugin(invertfilterplugin CLASS_NAME InvertFilterPlugin
#               SOURCES plugins/invertfilterplugin.cpp)

构建与运行:

cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
./build/imagefilter_host            # 或 ./build/imagefilter_host /path/to/plugins

真实输出(qInfo 打印到 stderr):

[plugin] 已加载: "invert" "反相" "libinvertfilterplugin.so"
共加载插件: 1 个,目录: /path/to/build/plugins
"反相" "#f5ebe1" "#372d23"      # 10,20,30 -> 245,235,225 (#f5ebe1);200,210,220 -> 55,45,35 (#372d23)

三个宏各自解决了什么(面试与实战都会问):
① Q_DECLARE_INTERFACE(I, IID):告诉 Qt「这个纯虚类是一个可查询的接口,它的身份是 IID」,底层通过 qobject_interface_iid<I>() 特化承载 IID。
② Q_PLUGIN_METADATA(IID ... FILE ...):把 IID 与元数据 JSON 写进插件的元对象数据,并导出 qt_plugin_query_metadata / qt_plugin_instance 两个 C 链接符号——宿主就是靠它们在不链接插件的前提下取到实例。
③ Q_INTERFACES(I):把接口登记进 moc 生成的元对象,使 qobject_cast<I*> 能对插件对象做接口级转换(校验 IID),而不是只能转到具体类。

07

代码执行流程

最小 C++ 版(4 个文件)的执行时序:

  1. 宿主启动:main() 构造 FilterRegistry,此时注册表是空表——宿主对「有哪些滤镜」一无所知。
  2. 发现与加载:宿主调用 register_invert_plugin(registry) / register_tint_plugin(registry)。这一步在真实插件里等价于遍历 plugins/、dlopen 每个 .so、dlsym 到入口函数。执行的是插件自己的代码,但它只拿到一个注册表引用——它仍然不知道宿主是干什么的。
  3. 注册工厂(延迟创建):插件入口把 "invert" → lambda 塞进注册表。此刻没有任何滤镜对象被创建,内存里只有几个函数对象。这是插件架构「加载轻、启动快」的关键。
  4. 按需实例化 + 多态调用:宿主业务按 id 调 registry.create("invert"),注册表的工厂被调用,std::make_unique<InvertFilter>() 构造具体对象并以 unique_ptr<IImageFilter> 返回;随后 filter->apply(src) 通过虚表分发到插件的实现。unknown 返回 nullptr,宿主走「插件缺失」分支继续执行。
  5. 析构:离开作用域,unique_ptr 逆序销毁插件对象。真实插件里这一步必须先于 dlclose 完成——对象还活着而库已经卸载,就是悬空虚表指针崩溃。

Qt 版(QPluginLoader)的执行时序,比上面多出「元数据校验」与「接口身份识别」两步:

  1. applicationDirPath() + "/plugins" 得到插件目录(安装器只需要往这个目录丢文件)。
  2. QDir::entryInfoList(QDir::Files) 列出候选文件,QLibrary::isLibrary() 过滤出真正的动态库(跨平台扩展名由 Qt 维护)。
  3. 为每个库构造 QPluginLoader,调用 instance():内部完成 dlopen → 读取元数据段 → 校验 Qt 版本与 IID → 调用 qt_plugin_instance 构造插件 QObject。失败则返回 nullptr 且 errorString() 给出原因,循环 continue 继续下一个——坏插件不影响宿主。
  4. qobject_cast<ImageFilterInterface*>(root) 做接口级转换,IID 不匹配就 unload() 并跳过,保证不会把「别人的插件」当自己的用。
  5. 成功的插件进入 filters_,同时 unique_ptr<QPluginLoader> 进入 loaders_ 保活。loader 早于插件对象释放 = 插件代码在对象还活着时被卸载,所以必须把 loader 的生命周期顶到最长。
  6. 宿主业务遍历 filters(),只用 ImageFilterInterface* 调用 apply();PluginManager 析构时先销毁 filters_ 中的引用关系、再逐个销毁 loader,自动完成卸载。

把两条时序叠在一起,就能看出插件架构的本质:「编译期写死的调用关系」被拆成了两个可以独立演进、独立失败、独立发布的阶段——「加载/发现」属于框架,写得极其保守;「实现/算法」属于插件,可以随便迭代。

08

为什么这样设计

插件架构看起来"只是把类拆到不同的 .so 里",但它实际同时改善了六个工程维度,这也是它值得付出复杂度代价的原因:

一、解耦点:编译期依赖被压缩到唯一的一个抽象上

在坏代码里,宿主 #include 了每一个滤镜实现,依赖图是「宿主 → 具体类 × N」。在插件架构里,依赖图变成「宿主 → 接口 ← 插件」,宿主与插件之间连锁链接关系都不存在:宿主可执行文件里找不到 InvertFilter 这个符号,插件的 .so 里也不需要链到宿主的任何符号(除了那个接口头文件提供的纯虚定义)。这种「零 include、零链接」的解耦程度,是任何类级模式都达不到的。

二、扩展点与开闭原则(OCP)

新增功能 = 新增一个动态库。宿主代码、宿主构建脚本、宿主已发布的二进制——全都不用改。这正是 OCP 最强形式的落地:"对扩展开放"发生在宿主编译之后,"对修改关闭"精确到了二进制文件。对比坏代码:加一个滤镜要改 enum、switch、字符串表三处已测试代码,属于典型的"以修改换扩展"。

三、依赖倒置(DIP)与控制反转(IoC)

DIP 说「高层模块不应依赖低层模块,二者都应依赖抽象」。插件架构还多做了一步:抽象由高层(宿主)定义并随 SDK 发布,插件的实现必须服从这个由宿主规定的契约。这就是控制反转——插件把"我这块能力长什么样"的决定权交给了宿主框架,而不是相反。回顾 Day 3 抽象工厂的直觉:"谁定义接口,谁掌握主动权"。

四、组合优于继承

宿主扩展功能的方式不是继承(class MyEditorWithBlur : public ImageEditor——那会改宿主类型体系),而是运行期把一批 IImageFilter* 组合进注册表/管道。宿主类型体系封闭不变,功能集合在运行期自由组合。

五、故障隔离与稳定性

一个插件加载失败(缺依赖、架构不匹配、接口版本不符)只产生一行警告日志,宿主照常启动可用;而在单体里,这类问题会变成"应用打不开"。虽然插件内部崩溃仍会带走整个进程(除非另开进程做进程外插件),但加载阶段与发现阶段的失败已经被彻底隔离——这是桌面软件交付质量的关键差异。

六、可测试性与并行开发

宿主测试时不需要真实的插件:FilterRegistry registry; registry.add("fake", ...) 塞一个哑实现即可,完全不碰文件系统和动态库加载。插件自己也能被纯单元测试覆盖——它只依赖一个接口头文件,不需要拉起整个应用。同时,契约冻结后,宿主团队与插件团队(甚至第三方的插件作者)可以各按自己的发布节奏并行演进。

一句话概括设计动机:把「谁会扩展我、怎么扩展我」这两件宿主编译期无法预知的事,转译成一份运行期可读的契约(接口 + 元数据),从而让宿主从"一个程序"变成"一个平台"。代价是:编译器的静态检查能力被削弱,一致性保障要由你自己建立的版本校验与诊断体系补回来。

09

不使用会怎样

不做插件化,短期内不会有任何可见问题——这是它最危险的地方。真正的成本在功能数量、客户数量、团队人数各自翻倍之后集中爆发:

症状具体表现后期代价
enum / if-else 持续膨胀 每加一个功能就要改 enum、switch、显示名字符串表、可能的权限判断表;一个滤镜的改动出现在 4~6 个文件里 合并冲突高频;漏改一处就是线上 bug(比如新滤镜没出现在菜单里)
类与编译单元膨胀 所有算法代码住在同一棵源码树、同一个链接目标里,任何头文件改动触发全量重编 构建时间从 30 秒涨到 20 分钟;"改一行、等半小时",迭代速度被物理限制
耦合与复用归零 算法代码顺手就调了 QMessageBox、日志单例、配置单例、宿主 UI 类型 算法无法独立测试、无法换界面复用、无法给 CLI 版本使用
发布地狱 改一个系数 → 重编 → 重打包 120MB 安装包 → 全量用户重新下载升级 热修不可能;客户现场问题只能等下一个大版本
故障扩散 任意一个算法越界/空指针直接终止整个进程;所有功能在启动时全部加载 可用性差;启动时间随功能数线性增长,功能越多越"重"
生态为零 第三方、客户私有功能、公司内部实验特性只能塞进主干分支 定制版退化成 N 个长期维护分支,回归成本随客户数线性增长

注意最后一行:插件的真正价值往往不是"代码更整洁",而是"商业模式的可组合性"——基础版 + 专业插件 + 客户定制插件,可以是同一个宿主二进制、同一个安装器、同一套更新通道。

10

何时使用

适合的场景(3-5 个)

  1. IDE / 编辑器 / 设计工具类产品:功能集合天然持续增长、且大量功能不必常驻(Qt Creator、Photoshop 滤镜、音频工具的 VST)。
  2. 跨客户的定制化桌面客户端:同一套基座,不同客户装不同插件目录;私有功能的代码不进主干。
  3. 格式 / 驱动 / 设备协议扩展点:图像格式(JPEG、SVG、RAW)、数据库驱动、导出格式、新增型号的仪器协议——宿主定契约,扩展随硬件或格式演进而增。
  4. 大型团队的并行开发:模块边界清晰、发布节奏不同(核心每月发、算法团队每周发),插件化让两边互不阻塞。
  5. 平台型产品战略:希望第三方或合作伙伴参与生态建设(VS Code 的商业模式核心就是插件市场)。

不适合的场景(2-4 个)

  1. 功能集合固定的内部工具:一共五个功能、一个团队、随时整包发版——一句 switch 比一整套加载器便宜得多,也更容易静态检查和调试。
  2. 接口尚未稳定的早期产品:接口每天都在改,插件就每天都在重编,"独立发布"的收益为负,只剩下复杂度。先冻结接口,再谈插件。
  3. 极致性能的热路径:跨库调用无法内联、难以 LTO、虚调用跳转成本更高;每分钟要跑百万次的算法核心不要走插件边界。
  4. 小型脚本/单体工具:没有部署收益(本来就是一个文件),插件反而引入版本兼容、诊断、安装路径等一堆运维问题。

过度设计提醒:插件架构的本质是用「把编译期错误变成运行期错误」换取「运行期可装配」。编译器从此不再帮你看住跨模块边界,你必须自己补上:① 接口版本号与 IID 校验;② 加载失败的可诊断日志(loader->errorString() 必须落到用户可见的日志/关于对话框里);③ 插件目录的安装与权限约定;④ 卸载路径的测试覆盖。这四件事没规划好之前上插件,用户看到的现象只会是"功能莫名其妙消失了"。

11

与其他模式的区别

插件架构不是"又一个模式",而是若干模式的架构级装配。面试里最容易被追问的就是它跟抽象工厂、策略、桥接的分界。

模式它解决的核心问题与插件架构的分界
工厂方法 / 抽象工厂 把「创建哪个具体产品」从使用者代码里搬走,创建一族相关对象 它们的调用方编译期就知道工厂接口与产品接口(通常在同一个工程里)。插件架构在它之上加了"运行期发现 + 元数据 + ABI 稳定"三件事;插件内部极其常用抽象工厂(返回接口指针的工厂)。
策略模式 一族算法可互相替换,替换粒度是"对象" 策略的替换通常仍由宿主在编译期决定可选集合(DI 或工厂给出)。插件架构的替换粒度是"二进制文件",可选集合在宿主编译后仍可变。
桥接模式 让抽象与实现两个维度独立变化,避免类爆炸 桥接把两个维度都编译在一起,是"设计内的维度分解";插件是"发布边界外的装配",两个维度可以来自不同团队、不同发布周期。
服务定位器 / 依赖注入 解决「对象怎么拿到依赖」,而不是「依赖从哪来」 它们常被用作插件架构的取用手段:注册表就是简化版服务定位器。插件架构回答的是依赖的"来源与装配时机"。
观察者 / 事件总线(Day 18 / 26) 模块间松耦合通信 两者是天然的搭档:插件加载完成后,第一步往往是向宿主的事件总线注册自己的能力或订阅事件(Day 26 的结尾留的就是这个伏笔)。

一句话记住分界:抽象工厂、策略、桥接是"编译期多态"(编译器认识所有类型);插件架构是"编译期未知 + 运行期装配"(编译器一个插件类型都不认识)。

12

Qt 源码中的体现

这不是"硬套",而是 Qt 自身最重要的架构手法之一:Qt 框架主体就是一个宿主程序,几乎所有平台相关和格式相关的能力都是插件。

一处需要说清、避免记混的细节:QStyleFactory 内置的 Fusion / Windows 样式是编译进 Qt 的,只有额外样式走插件路径;同理 JPEG 支持在官方二进制发行里常以插件形式存在(imageformats/libqjpeg.so)。也就是说,同一套插件机制,Qt 既用它做"可选能力",也用它做"静态替代品"——判断某个能力是不是插件,看它的名字出现在 plugins/ 子目录里,而不是看它属于哪个模块。

13

面试常见问题

Q1:插件架构和"面向接口编程"到底差在哪?

面向接口编程解决的是「调用方不知道实现类是谁」(类级解耦,靠多态);插件架构额外解决三件事:「调用方编译时不知道存在哪些实现」「实现从哪来(动态库)」「实现什么时候来、什么时候走(生命周期)」。因此它必须补上 ABI 稳定契约、运行期发现(扫描 + 元数据)、加载/卸载生命周期管理——这三样是面向接口编程不需要考虑的。

Q2:为什么插件接口只能追加虚函数,不能改顺序或签名?

C++ 的多态靠虚函数表(vtable)的固定偏移实现:调用点编译时就把"第几个槽位"写死了。修改虚函数顺序、在中途插入或删除虚函数、改变参数类型或返回值类型,都会让已编译插件对象的 vtable 布局与宿主预期错位——轻则调用到错误函数,重则崩溃。所以插件接口必须遵循两条纪律:只追加(追加到末尾)、版本化(Qt 的惯例是 IID 里带版本,如 .../1.0,必要时让 1.0 与 1.1 接口并存,加载时分别匹配)。

Q3:QPluginLoader::instance() 和 QLibrary::resolve() 有什么区别?

QLibrary::resolve() 是机制:按符号名字取一个函数指针,本质是跨平台的 dlsym,协议、版本校验、实例管理都要你自己写(我们第 ⑤ 节的 extern "C" 入口就是这一层的对手戏)。QPluginLoader 是框架:建立在 QLibrary 之上,额外提供元数据读取(metaData())、Qt 版本与 IID 校验、约定的 qt_plugin_instance 实例入口、静态插件统一处理(staticInstances()),以及与元对象系统集成后的接口级安全转换(qobject_cast)。选哪个取决于你要不要 Qt 替你管协议与兼容性。

Q4:插件卸载为什么容易崩溃?正确姿势是什么?

根本原因是对象生命周期与代码生命周期绑错了:插件对象还活着(它的 vtable 指针还指向插件代码段),而库已经被卸载,任何一次虚调用都会跳到已释放的内存。安全做法:① 保证「先析构所有插件对象,再销毁/卸载 loader」——本示例用 filters_(引用)与 loaders_(RAII 所有者)明确这个顺序;② 卸载后立即把接口指针置空,绝不复用;③ 插件内部避免跨模块单例与长期持有的宿主引用;④ 卸载前断开所有跨模块信号连接。实践中最省事的策略是"启动即加载、退出即进程结束",直接不走卸载路径——Qt Creator 这类大型应用也是按需选择,不必为了"卸载干净"把复杂度堆到不可维护。

Q5:插件之间需要互相通信,应该怎么设计?

不要让插件互相 include(那会重新制造网状依赖,并让签名/版本问题指数级放大)。正确做法是让宿主当唯一的中介:插件只向宿主的注册表声明能力、向宿主的总线发布/订阅事件(Day 26),由宿主的业务代码决定编排顺序(比如"滤镜管道"里谁先谁后)。这样插件之间保持"互不相识",宿主是唯一了解全局的协调者——这正是中介者模式(Day 16)在架构层的复用。

14

今日练习(15-30 分钟)

需求:复制第 ⑤ 节的最小示例到 /tmp/plugin_priority_demo/,然后完成两件事:
① 新增第三个插件「自动对比度」(id 用 autocontrast,算法自定,比如把像素线性拉伸到 0~255 全区间);
② 让宿主在"应用全部滤镜"时,按插件自己声明的顺序执行——「自动对比度」必须先跑,「反相」必须最后跑,并且要求宿主业务代码里不允许出现任何硬编码的 id 顺序数组。

提示一:顺序信息属于"插件自己的知识",所以不要放在宿主里。给契约接口加一个 virtual int priority() const = 0;(数值小的先执行),让每个插件在自己类里回答"我排第几";宿主只做 std::stable_sort 后依次 apply()。做完后请体会:以后新增插件不需要动宿主一行代码——这就是 OCP 在插件场景下的样子(对比"改宿主里的顺序数组",你就明白为什么顺序该由插件声明)。

提示二:注意 priority() 是改契约的行为——这意味着已编译的旧插件与新宿主 ABI 不兼容。真实项目里这一步应该做成"新接口版本"(IImageFilter2 或 IID 加版本号),并在加载时校验;练习里可以先用一个 qWarning/std::cerr 打印"发现旧版插件,跳过"来模拟这个过程。做完这一小步,你对"接口只增不改"的理解会比读十篇文章更实在。

(练习只给需求与提示,不给完整答案——请自己动手写完并用 g++ -std=c++17 -Wall 编译通过,观察输出顺序是否与 priority 一致。)

15

今日总结

一句话记忆:插件架构 = 宿主定卡口(稳定接口 + 元数据)、插件做镜头(独立动态库)、加载器在运行期把两者接上——宿主编译时完全不知道插件的存在,扩展因此发生在二进制层面而不是源码层面。

代码特征信号(看到这些就该想到插件架构)

  1. Q_DECLARE_INTERFACE(I, "xxx/1.0") + Q_PLUGIN_METADATA(IID ... FILE ...) + Q_INTERFACES(I) 三个宏成组出现,并配一个 *.json 元数据文件。
  2. 构建系统里出现 MODULE 类型的库目标或 qt_add_plugin(),且输出目录被固定到宿主可执行文件同级的 plugins/。
  3. 宿主侧出现「扫描目录 → QLibrary::isLibrary 过滤 → QPluginLoader::instance() → qobject_cast<IXxx*>」这一固定套路,并伴随"失败只记录日志不中断"的容错分支。
  4. 存在一个「名字/能力 → 工厂」的注册表容器,业务代码按字符串或能力取实现,注册表里存的是工厂而非实例(延迟创建)。
  5. 有一份独立发布的接口头文件(SDK)与它的版本号,宿主与所有插件都 include 它,而不 include 彼此;跨模块边界只用稳定类型(Qt 类型或 POD)。

今日三点心法

明日预告:Day 28 —— 撤销/重做架构(Undo/Redo & QUndoStack)

今天我们让宿主能接入"编译期还不存在的功能"。但插件装进来之后,用户就会开始点它们——于是立刻撞上桌面客户端的第二道必答题:用户点错了,怎么撤销?而且撤销必须能回滚到任意一步、能重做、能感知"文档已被修改"、能在撤销过程中同步多条 UI 状态。明天我们把这个需求拆到架构层:命令模式(Day 14)的"可撤销命令" + 栈式历史 + 事务合并(mergeWith 合并连续拖动),并在 Qt 里用官方三件套 QUndoCommand / QUndoStack / QUndoView 实现一个带撤销栈的编辑器骨架,含「脏标记与保存点」「宏命令一次撤销一组操作」「撤销栈与插件注册表的配合」三个实战点。