乐于分享
好东西不私藏

Qt 插件化导航栈:页面、路由与业务解耦

Qt 插件化导航栈:页面、路由与业务解耦

FIELD NOTE / HIGH SIGNAL

Qt 插件化导航栈:页面、路由与业务解耦

围绕 Qt 插件化导航栈设计:页面、路由与业务模块如何彻底解耦 拆出判断框架、执行路径和可复用代码片段。

一个典型的失控现场:页面 A 直接创建页面 B,页面 B 又持有业务模块 C;返回时,三个对象都在修改同一份状态。最终,导航顺序、对象生命周期和业务数据被绑成一团。

问题通常不在 QWidget 或 QML 本身,而在于系统没有明确回答三件事:谁决定去哪里、谁创建页面、谁拥有业务状态。

先看目标架构:依赖只向内收敛

插件化导航栈的核心,不是把页面塞进动态库,而是建立稳定的依赖方向。页面、路由和业务模块分别承担展示、调度与能力提供,三者通过最小契约协作。

推荐的五层结构

01
Shell:持有窗口、导航容器和全局 UI 框架。
02
Navigation Core:维护路由表、导航栈和页面生命周期。
03
Page Contract:定义页面工厂、参数和返回结果。
04
Business Plugin:注册路由,并提供业务服务与页面工厂。
05
Infrastructure:提供日志、存储、网络和事件总线等基础能力。

依赖规则:Shell 只认识导航接口,导航核心只认识页面契约,业务插件只在注册阶段暴露能力。任何页面都不应直接包含另一个业务模块的具体类。

第一层:让路由成为稳定契约

很多项目把路由写成字符串,然后在各处调用 createWidget。这样虽然能跳转,却没有解决参数类型、返回值和路由归属问题。更稳妥的做法,是把一次导航定义为一份完整请求。

01
routeId:全局唯一,但不暴露页面类名。
02
parameters:进入页面时的不可变参数快照。
03
presentation:push、replace、modal 等展示策略。
04
resultChannel:页面关闭后返回结果的通道。

下面是一组可落地的 C++ 接口。导航核心不依赖具体页面,只处理请求、工厂和页面句柄。

CODE LABCODE
01#include <QHash>
02#include <QPointer>
03#include <QVariantMap>
04#include <QWidget>
05#include <functional>
06#include <memory>
07
08struct RouteRequest {
09    QString routeId;
10    QVariantMap parameters;
11    enum class Presentation { Push, Replace, Modal } presentation
12        = Presentation::Push;
13};
14
15class IPageContext {
16public:
17    virtual ~IPageContext() = default;
18    virtual const QVariantMap& parameters() const = 0;
19    virtual void finish(const QVariantMap& result = {}) = 0;
20};
21
22using PageFactory = std::function<QWidget*(IPageContext&, QWidget*)>;
23
24class IRouter {
25public:
26    using ResultHandler = std::function<void(const QVariantMap&)>;
27    virtual ~IRouter() = default;
28    virtual bool registerRoute(const QString& routeId,
29                               PageFactory factory) = 0;
30    virtual bool navigate(const RouteRequest& request,
31                          ResultHandler onResult = {}) = 0;
32    virtual bool back(const QVariantMap& result = {}) = 0;
33};

关键变化:调用方只表达“我要进入某个能力”,不再负责构造页面,也不需要知道该页面属于哪个插件。

第二层:页面工厂隔离创建与生命周期

页面不应由调用方 new,也不应长期存放在全局单例中。导航核心在执行路由时调用工厂,创建页面上下文和页面实例,再把二者作为同一个栈帧管理。

一个栈帧至少包含什么

01
路由标识与进入参数快照。
02
页面对象的受控指针。
03
页面上下文及返回回调。
04
所属插件标识,便于卸载检查。
05
页面状态策略:临时、缓存或可恢复。

业务插件只在加载阶段注册工厂。工厂内部可以获取本插件的服务,但页面创建完成后,导航核心不需要理解这些服务。

CODE LABCODE
01class OrderPlugin final : public QObject {
02public:
03    bool start(IRouter& router, OrderService& service) {
04        return router.registerRoute(
05            QStringLiteral("order/detail"),
06            [&service](IPageContext& context, QWidget* parent) {
07                const auto orderId =
08                    context.parameters().value("orderId").toString();
09                return new OrderDetailPage(
10                    orderId, service, context, parent);
11            });
12    }
13};
14
15RouteRequest request;
16request.routeId = QStringLiteral("order/detail");
17request.parameters.insert("orderId", QStringLiteral("A1024"));
18
19router.navigate(request, [](const QVariantMap& result) {
20    const bool changed = result.value("changed").toBool();
21    if (changed) {
22        // 只处理业务结果,不访问目标页面对象。
23    }
24});

需要避免:工厂返回页面后,插件再私下保存页面裸指针。否则导航核心销毁页面时,插件仍可能访问已经失效的对象。

第三层:用状态边界解决多页面冲突

页面状态冲突通常来自两类混用:把“业务真相”放进页面,把“页面草稿”放进全局服务。解法不是增加更多信号,而是先划定状态所有权。

01
业务状态:订单、用户、权限等事实数据,由业务服务持有。
02
导航状态:当前路由、参数、栈顺序,由导航核心持有。
03
页面状态:滚动位置、选中项、未提交表单,由当前栈帧持有。
04
跨页结果:通过 finish 返回,不通过直接修改上一个页面。

推荐的数据流

页面读取参数 → 调用业务服务 → 在本地编辑草稿 → 提交服务 → 通过结果通道通知上一页刷新。上一页根据业务标识重新查询,而不是接收另一个页面的内部对象。

如果页面需要恢复,可让页面实现独立的状态序列化接口,由导航核心保存 QVariantMap。这样恢复机制仍然不需要认识具体控件。

第四层:插件卸载必须服从导航栈

动态插件最危险的时刻不是加载,而是卸载。只要栈里还有该插件创建的页面、工厂闭包或回调,卸载动态库就可能留下悬空函数地址。

卸载前的检查顺序

01
禁止插件继续接收新的导航请求。
02
查找并关闭该插件拥有的全部栈帧。
03
断开结果回调、事件订阅和异步任务。
04
注销路由工厂与业务服务。
05
确认相关 QObject 已销毁,再卸载动态库。

工程上可以把插件状态设计为 Active、Draining、Stopped。进入 Draining 后拒绝新页面,只处理存量页面退出。相比直接 unload,这种两阶段关闭更容易验证,也更适合存在异步任务的桌面应用。

测试重点:同一路由重复进入、页面中途返回、异步回调晚到、插件卸载时仍有页面、replace 后旧页面销毁、返回结果无人接收。

结尾:按四步改造现有项目

不要从拆动态库开始。先把导航关系从页面代码中抽离,再逐步建立插件边界。

01
第一步:统计所有页面跳转,删除页面之间的直接 new 和具体类引用。
02
第二步:建立 IRouter、RouteRequest、IPageContext 和 PageFactory 四个最小契约。
03
第三步:让导航核心统一持有页面栈帧,并明确业务、导航、页面三类状态的所有权。
04
第四步:最后再接入插件注册、停用与卸载流程,并补齐生命周期测试。

验收标准:任意业务插件被移除后,其他模块仍能编译;任意页面被替换后,调用方无需修改;任意一次返回都通过结果契约完成;任意状态都能明确指出唯一所有者。做到这四点,页面、路由与业务模块才算真正解耦。

阅读建议:先看每节标题,再回到代码块或清单部分直接执行。