屏幕管理与导航框架:Screen与Container的概念、屏幕栈管理、导航栏与页面切换

做嵌入式GUI开发,说白了就是在有限的资源下,把界面切换这件事做到流畅、不崩溃。我见过太多项目,前期图省事,所有页面都堆在一个Screen里,结果后期加需求时,代码改得想哭。

这一章,我们来聊聊TouchGFX里最核心的导航体系。你搞懂了Screen和Container的区别,学会了屏幕栈怎么玩,后面那些花哨的页面切换、路由设计,都是水到渠成的事。

Screen与Container:两个最基础的概念

很多新手会问:Screen和Container到底有啥区别?我刚开始做TouchGFX时也迷糊过。简单说——Screen是页面,Container是组件。

特性 Screen Container
生命周期 有完整的setup/teardown 随父Screen创建销毁
内存占用 独立分配,切换时可能释放 嵌入在Screen内
导航能力 可以push/pop到屏幕栈 不能独立导航
典型用途 主页面、设置页、弹窗 导航栏、列表项、状态指示器

你想想看,一个Screen就像一页PPT,翻过去就没了。而Container是PPT里的一个图表,它跟着页面走。我在一个智能家居项目里,把顶部的状态栏做成了Container,这样每个Screen都能复用,改一次全改,省了不少事。

核心原则:Screen负责页面级切换,Container负责界面内复用。别把Container当Screen用,也别把Screen当Container用。

屏幕栈(Screen Stack)管理

屏幕栈,是TouchGFX导航的基石。它的工作方式,跟函数调用栈一模一样。

你从主页进入设置页,主页被压入栈底,设置页在栈顶。再从设置页进入WiFi配置页,设置页被压住,WiFi页在栈顶。按返回键时,WiFi页弹出销毁,设置页重新显示。

我画了一张图,帮你理解这个流程:

屏幕栈操作流程 屏幕栈(Screen Stack)操作流程 初始状态 主页 Push 设置页 主页 设置页 Push WiFi页 主页 设置页 WiFi页 Pop WiFi页 主页 设置页 已销毁 💡 关键点 • Push:新Screen入栈,旧Screen保留在栈中(不销毁) • Pop:栈顶Screen出栈并销毁,上一个Screen恢复显示

嗯,这里要注意:栈顶的Screen是唯一活跃的。栈里的其他Screen虽然还在内存里,但不会处理任何事件。我见过有人把大量数据放在Screen的成员变量里,结果栈深了以后内存爆了——这就是没搞清楚Screen的生命周期。

避坑指南:我曾经在一个医疗设备项目里,连续push了5个Screen,每个Screen都加载了高清图片。结果在低端MCU上直接内存溢出。后来我改用Container嵌套+懒加载,才把问题解决。记住:屏幕栈不是无限深的,RAM有多少,你自己心里要有数。

导航栏与页面切换

导航栏,是用户跟你的界面交互最频繁的地方。TouchGFX里实现导航栏,我推荐用Container来做。

为什么?因为导航栏通常在每个页面都存在,但内容不同。用Container,你可以把导航栏的骨架写好,然后通过虚函数让每个Screen定制自己的按钮行为。

来看一段代码,这是我常用的导航栏基类:

// NavigationBar.hpp
class NavigationBar : public Container {
public:
    NavigationBar();
    virtual ~NavigationBar() {}

    // 子类实现这个方法,配置自己的按钮
    virtual void setupNavigation() = 0;

    // 通用的返回按钮
    void setBackButtonCallback(GenericCallback<>& callback) {
        backButton.setAction(callback);
    }

protected:
    Button backButton;
    TextArea titleText;
};

// MainScreenNavigationBar.cpp
class MainScreenNavigationBar : public NavigationBar {
public:
    MainScreenNavigationBar() {
        titleText.setTypedText(TypedText(T_MAIN_TITLE));
        // 设置返回按钮不可见(主页不需要返回)
        backButton.setVisible(false);
    }

    void setupNavigation() override {
        // 主页特有的导航配置
    }
};

这样做的好处很明显:导航栏的布局、样式、动画都在一个地方维护。哪个Screen需要导航栏,直接组合进去就行。

自定义导航控制器

TouchGFX自带的ScreenManager能满足基本需求,但复杂项目里,我建议你自己写一个导航控制器。

为什么?因为默认的导航器不支持页面间传参、不支持拦截返回事件、不支持动画定制。你想想看,一个电商App,从商品列表到详情页,总得传个商品ID吧?默认的导航器做不到。

我分享一个我常用的导航控制器模式:

class NavigationController {
public:
    // 单例
    static NavigationController& getInstance() {
        static NavigationController instance;
        return instance;
    }

    // 带参数的页面跳转
    template<typename T>
    void pushScreenWithData(uint16_t screenId, T* data) {
        // 保存数据到参数栈
        paramStack.push_back(reinterpret_cast<void*>(data));
        // 执行跳转
        pushScreen(screenId);
    }

    // 返回并带回数据
    template<typename T>
    void popScreenWithResult(T* result) {
        // 把结果传给上一个Screen
        if (!screenStack.empty()) {
            auto prevScreen = screenStack[screenStack.size() - 2];
            prevScreen->onResult(result);
        }
        popScreen();
    }

    // 拦截返回键
    bool onBackPressed() {
        if (screenStack.empty()) return false;
        auto currentScreen = screenStack.back();
        // 让当前Screen决定是否允许返回
        return currentScreen->onBackPressed();
    }

private:
    std::vector<Screen*> screenStack;
    std::vector<void*> paramStack;
};

个人经验:我习惯把导航控制器做成单例,这样任何地方都能调用。但要注意线程安全——TouchGFX是单线程的,所以不用担心竞态。另外,参数栈我用vector来存void指针,用的时候强转回来。虽然有点C风格,但在嵌入式环境里,这比用any或variant省资源得多。

复杂页面路由设计

当你的项目有几十个页面时,路由设计就变得至关重要。我见过最乱的代码,页面跳转逻辑散落在各个按钮的回调里,想理清调用关系,得把整个代码库翻一遍。

我的做法是:用路由表统一管理。

路由表本质上是一个映射:页面ID → 创建函数。这样跳转时只需要传一个ID,具体创建哪个Screen,由路由表决定。

// RouteTable.hpp
using ScreenCreator = std::function<Screen*()>;

class RouteTable {
public:
    static RouteTable& getInstance() {
        static RouteTable instance;
        return instance;
    }

    void registerRoute(uint16_t screenId, ScreenCreator creator) {
        routes[screenId] = creator;
    }

    Screen* createScreen(uint16_t screenId) {
        auto it = routes.find(screenId);
        if (it != routes.end()) {
            return it->second();
        }
        // 找不到就返回一个错误页
        return new ErrorScreen();
    }

private:
    std::map<uint16_t, ScreenCreator> routes;
};

// 在main.cpp里注册路由
void setupRoutes() {
    RouteTable::getInstance().registerRoute(SCREEN_MAIN, []() { return new MainScreen(); });
    RouteTable::getInstance().registerRoute(SCREEN_SETTINGS, []() { return new SettingsScreen(); });
    RouteTable::getInstance().registerRoute(SCREEN_WIFI, []() { return new WiFiScreen(); });
    // ... 几十个路由注册
}

这样做的好处,我列一下:

  • 解耦:按钮回调只需要知道目标页面的ID,不需要知道具体类名
  • 可维护:所有页面跳转关系集中在一处,改路由不影响业务逻辑
  • 可测试:可以轻松替换某个页面为Mock版本,方便单元测试
  • 支持懒加载:页面只在被路由到时才创建,不占内存

核心思路:把「跳转到哪里」和「怎么跳转」分开。路由表只负责「创建哪个Screen」,导航控制器负责「怎么压栈、怎么传参」。各司其职,代码才清爽。

最后说一句,导航框架这东西,没有银弹。你项目的RAM有多大、页面有多少、交互有多复杂,直接决定了你该用哪种方案。但不管怎么选,Screen和Container的区别、屏幕栈的原理、路由表的思想,这些底层的东西是通用的。搞懂了它们,你就能搭出适合自己的导航框架。