“好的架构不是没有东西可以再加,而是没有东西可以再减。” —— Antoine de Saint-Exupery
上一章我们搞清楚了 CrossPoint Reader 怎么让两个任务协作运行。但一个阅读器不止一个界面——有启动画面、首页、文件浏览器、阅读界面、设置页、WiFi 连接页…这些”页面”怎么组织?怎么切换?怎么返回? CrossPoint Reader 的回答是:把 Android 的 Activity 模型搬到 MCU 上。

8.1 为什么需要页面框架

假设你不用任何框架,直接在 loop() 里用 if-else 管理所有页面:
void loop() {
  if (currentPage == PAGE_BOOT) {
    // 启动画面逻辑...
  } else if (currentPage == PAGE_HOME) {
    // 首页逻辑...
  } else if (currentPage == PAGE_READER) {
    // 阅读界面逻辑...
  } else if (currentPage == PAGE_SETTINGS) {
    // 设置页逻辑...
  } else if (currentPage == PAGE_FILE_BROWSER) {
    // 文件浏览器逻辑...
  }
  // ... 还有十几个页面
}
这种写法有几个严重问题:
  • 代码膨胀:所有页面的逻辑堆在一个函数里,几千行起步
  • 耦合紧密:每个页面的变量混在一起,改一个容易影响另一个
  • 返回困难:从设置页返回首页容易,但如果设置页是从阅读器里打开的呢?返回时应该回到阅读器,而不是首页
  • 数据传递:一个页面打开另一个页面并等待它返回结果,用全局变量传递既丑陋又危险
这些问题,Android 在十几年前就解决了——用 Activity 机制。CrossPoint Reader 借鉴了这套思想,在 ESP32 上实现了一个轻量级的 Activity 框架。

8.2 Activity 基类:每个页面的”模板”

CrossPoint Reader 中,每一个页面都是 Activity 基类的子类。基类定义了页面的完整生命周期:
// src/activities/Activity.h
class Activity {
 public:
  virtual void onEnter() {}     // 进入页面时调用(初始化)
  virtual void onExit() {}      // 离开页面时调用(清理)
  virtual void loop() {}        // 每帧调用(处理输入)
  virtual void render(RenderLock&&) {} // 绘制画面

  void requestUpdate();         // 告诉渲染任务"我需要重绘"
  void startActivityForResult(std::unique_ptr<Activity> activity,
                              ActivityResultHandler callback);
};
每个方法的职责:
方法何时调用做什么
onEnter()页面被推入栈顶时加载数据、初始化状态、请求首次绘制
onExit()页面被移出栈顶时释放资源、保存状态
loop()主任务每次循环时处理按键输入、更新状态
render()渲染任务收到通知时把当前页面的画面绘制到帧缓冲区
requestUpdate()页面需要重绘时主动调用通过任务通知唤醒渲染任务

与 Android Activity 生命周期的对比

如果你熟悉 Android 开发,可以做这样的映射:
Android Activity              CrossPoint Activity
─────────────                ─────────────────
onCreate()  ─┐
onStart()    ├──────────→    onEnter()
onResume()  ─┘

onPause()   ─┐
onStop()     ├──────────→    onExit()
onDestroy() ─┘

(无直接对应)                  loop()    ← MCU 特有,轮询输入
(无直接对应)                  render()  ← 独立渲染任务调用
Android 的生命周期之所以分得更细(6 个回调),是因为它需要处理窗口可见性、前后台切换、内存回收等复杂场景。在 MCU 上,同一时刻只有一个页面在运行,不存在”后台”的概念,所以 onEnter()onExit() 两个回调就够了。 简洁,是在有限资源下的最佳策略。

8.3 Activity 栈:页面的”浏览历史”

CrossPoint Reader 用一个栈(Stack)来管理所有打开的页面:
// src/activities/ActivityManager.h
class ActivityManager {
  Activity* currentActivity = nullptr;
  std::vector<std::unique_ptr<Activity>> activityStack;  // 页面栈

  void goHome();
  void goToReader(const std::string& path);
  void goToSettings();
};
栈的特点是后进先出(LIFO)——最后打开的页面,最先被关闭。这完美契合了用户对”返回”的直觉:按返回键,应该回到上一个页面。 让我们用一个真实的操作流程来看栈的变化:
操作 1:开机 → 启动画面
┌─────────────┐
│ BootActivity │ ← 栈顶(当前显示)
└─────────────┘

操作 2:启动完成 → 进入首页(替换栈顶)
┌─────────────┐
│ HomeActivity │ ← 栈顶
└─────────────┘

操作 3:用户打开一本书 → 推入阅读页
┌────────────────┐
│ ReaderActivity  │ ← 栈顶
├────────────────┤
│ HomeActivity    │
└────────────────┘

操作 4:阅读时打开设置 → 推入设置页
┌───────────────────┐
│ SettingsActivity   │ ← 栈顶
├───────────────────┤
│ ReaderActivity     │
├───────────────────┤
│ HomeActivity       │
└───────────────────┘

操作 5:用户按返回 → 弹出设置页,回到阅读
         ┌───────────────────┐
弹出 →   │ SettingsActivity   │  被销毁
         └───────────────────┘
┌────────────────┐
│ ReaderActivity  │ ← 栈顶(回到这里)
├────────────────┤
│ HomeActivity    │
└────────────────┘
这和你手机上按返回键的行为一模一样,也和浏览器的”后退”功能如出一辙。 std::unique_ptr<Activity> 保证了每个 Activity 对象的内存由栈来管理——当一个页面从栈中被弹出时,unique_ptr 会自动释放该 Activity 占用的内存,不需要手动 delete

8.4 页面切换机制

接下来看 ActivityManager 最核心的代码——loop() 方法。它处理页面的切入和切出:
// src/activities/ActivityManager.cpp
void ActivityManager::loop() {
  while (pendingAction != PendingAction::None) {
    auto action = pendingAction;
    pendingAction = PendingAction::None;

    if (action == PendingAction::Push) {
      if (currentActivity) currentActivity->onExit();
      activityStack.push_back(std::move(pendingActivity));
      currentActivity = activityStack.back().get();
      currentActivity->onEnter();
    }
    else if (action == PendingAction::Pop) {
      currentActivity->onExit();
      auto result = currentActivity->getResult();
      activityStack.pop_back();
      currentActivity = activityStack.back().get();
      if (resultHandler) resultHandler(result);
    }
  }
  if (currentActivity) currentActivity->loop();
}
这段代码不长,但每一行都值得细品。

Push 操作(打开新页面)

if (action == PendingAction::Push) {
  if (currentActivity) currentActivity->onExit();   // 1. 通知旧页面"你被覆盖了"
  activityStack.push_back(std::move(pendingActivity)); // 2. 新页面入栈
  currentActivity = activityStack.back().get();      // 3. 更新当前页面指针
  currentActivity->onEnter();                        // 4. 通知新页面"轮到你了"
}
注意 std::move(pendingActivity) —— 这里用了 C++ 的移动语义,把 Activity 对象的所有权从临时变量转移到栈中,避免了不必要的拷贝。

Pop 操作(返回上一个页面)

else if (action == PendingAction::Pop) {
  currentActivity->onExit();                         // 1. 通知当前页面"你要关了"
  auto result = currentActivity->getResult();        // 2. 取出返回值
  activityStack.pop_back();                          // 3. 从栈中移除(自动释放内存)
  currentActivity = activityStack.back().get();      // 4. 栈顶变成新的当前页面
  if (resultHandler) resultHandler(result);           // 5. 把返回值传给父页面
}

为什么用 pendingAction 而不是直接切换

你可能注意到了:页面切换不是立即执行的,而是先设置一个 pendingAction 标志,在下一次 loop() 开头才真正执行。 这是一个重要的设计决策。如果一个 Activity 在自己的 loop() 方法里调用了 startActivity(),而 startActivity() 直接执行切换,那就会在 loop() 执行到一半的时候突然把当前 Activity 给销毁了——后续的代码可能会访问到已经被释放的内存,引发崩溃。 pendingAction 延迟执行,可以保证当前 Activity 的 loop() 完整执行完毕后,再安全地进行切换。这是嵌入式开发中常见的”延迟操作”模式。

循环处理多个挂起动作

注意外层是 while 而不是 if。这意味着如果一个 onEnter() 内部又触发了页面切换(比如启动页在 onEnter() 里判断应该直接跳转到首页),可以在同一轮循环中连续处理多个切换动作,不需要等到下一轮 loop() 调用。

8.5 startActivityForResult:打开子页面并等待结果

有时候,一个页面需要打开另一个页面来完成某个选择,然后把选择结果带回来。举个例子:
用户在阅读 EPUB 时点击了一个脚注链接。阅读器需要弹出一个”脚注选择页面”让用户确认是否跳转。用户点击”确认”后,脚注选择页面关闭,把目标位置传回阅读页面,阅读页面跳转到对应位置。
这就是 startActivityForResult 模式:
// 父页面:打开子页面,并注册回调
void ReaderActivity::openFootnote(const std::string& href) {
  auto footnoteActivity = std::make_unique<FootnoteActivity>(href);
  startActivityForResult(std::move(footnoteActivity),
    [this](const ActivityResult& result) {
      if (result.isConfirmed()) {
        jumpToPosition(result.getTargetHref());  // 使用子页面返回的结果
      }
    }
  );
}
流程是:ReaderActivity 调用 startActivityForResult() -> FootnoteActivity 被推入栈 -> 用户做出选择 -> FootnoteActivity 设置 result 并请求 Pop -> ActivityManager 弹出 FootnoteActivity,调用回调函数 -> ReaderActivity 收到 result,跳转到脚注位置。 如果你熟悉 Android 开发,这就是 startActivityForResult() + onActivityResult() 的 MCU 版本。不同的是,Android 用 requestCode + Intent 传递数据,CrossPoint Reader 用 C++ lambda 回调——更直接,更简洁。

8.6 HomeActivity 实例:一个完整的页面长什么样

理论讲够了,让我们看一个完整的 Activity 实现——首页 HomeActivity。这是用户开机后看到的第一个正式页面。

onEnter():初始化

// src/activities/home/HomeActivity.cpp
void HomeActivity::onEnter() {
  Activity::onEnter();
  loadRecentBooks(metrics.homeRecentBooksCount);
  requestUpdate();
}
三件事:
  1. 调用父类的 onEnter()(基础初始化)
  2. 加载最近阅读的书籍列表
  3. 调用 requestUpdate() 请求首次绘制——这会通过任务通知唤醒渲染任务

loop():处理用户输入

void HomeActivity::loop() {
  buttonNavigator.onNext([this, menuCount] {
    selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
    requestUpdate();
  });
  if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
    if (selectorIndex < recentBooks.size()) {
      onSelectBook(recentBooks[selectorIndex].path);
    }
  }
}
loop() 在主任务的每一轮循环中被调用,它处理两种输入:
  • 导航键(上/下):移动选择光标。ButtonNavigator 封装了上下导航的逻辑,按下导航键后更新选中索引并请求重绘。
  • 确认键:如果当前选中的是一本书,打开阅读界面。
注意 requestUpdate() 的调用时机——只有当界面确实需要变化时才请求重绘,而不是每次 loop() 都重绘。这对墨水屏来说尤其重要:不必要的刷新不仅浪费时间,还会让屏幕频繁闪烁,影响用户体验。

render():绘制画面

void HomeActivity::render(RenderLock&&) {
  renderer.clearScreen();
  GUI.drawHeader(renderer, ...);
  GUI.drawRecentBookCover(renderer, ...);
  GUI.drawButtonMenu(renderer, ...);
  renderer.displayBuffer();
}
render() 在渲染任务中被调用(回忆上一章的内容),它做的事情很纯粹:
  1. 清空帧缓冲区
  2. 画页面头部(标题栏、状态图标)
  3. 画最近阅读的书籍封面
  4. 画底部按钮菜单
  5. 把帧缓冲区的内容推送到墨水屏
注意参数 RenderLock&&——这是一个右值引用,意味着 render() 接管了这把锁的所有权。当 render() 函数结束时,锁会自动释放。

完整的生命周期实例

把三个方法串起来,HomeActivity 的一生是这样的:onEnter()(加载数据、首次绘制) -> loop() 循环响应按键 -> 用户选择一本书 -> onExit()(保存状态) -> ReaderActivity 入栈 -> 用户在阅读器按返回 -> HomeActivity 重新回到栈顶 -> onEnter() 再次被调用(重新加载列表,因为最近阅读可能有变化)。

8.7 按键映射系统

CrossPoint Reader 有七个物理按键,但不同用户可能习惯不同的按键布局。有人习惯左手翻页,有人习惯右手翻页。按键映射系统解决了这个问题。
// src/MappedInputManager.cpp
bool MappedInputManager::mapButton(
    const Button button,
    bool (HalGPIO::*fn)(uint8_t) const) const {
  switch (button) {
    case Button::Back:        return (gpio.*fn)(SETTINGS.frontButtonBack);
    case Button::Confirm:     return (gpio.*fn)(SETTINGS.frontButtonConfirm);
    case Button::PageBack:    return (gpio.*fn)(side.pageBack);
    case Button::PageForward: return (gpio.*fn)(side.pageForward);
  }
}
这段代码做了一层逻辑按键到物理按键的映射
  • Activity 代码里使用的是逻辑按键Button::Back(返回)、Button::Confirm(确认)、Button::PageBack(上一页)、Button::PageForward(下一页)。
  • 这些逻辑按键对应的物理按键由用户在设置中配置。SETTINGS.frontButtonBackSETTINGS.frontButtonConfirm 存储了用户选择的物理按键编号。
  • (gpio.*fn)(buttonId) 是 C++ 的成员函数指针调用语法——fn 可以是 isPressedwasPressedwasReleased 中的任意一个,实现了灵活的按键状态查询。
这样设计的好处是:所有 Activity 都不需要关心用户的按键配置。它们只说”确认键被按了吗”,至于确认键是哪个物理按键,由 MappedInputManager 在中间翻译。
物理层           映射层                    逻辑层
─────           ─────                    ─────
BTN_LEFT   →  ┌──────────────────┐  →  Button::Back
BTN_RIGHT  →  │ MappedInputManager│  →  Button::Confirm
BTN_UP     →  │                  │  →  Button::PageBack
BTN_DOWN   →  └──────────────────┘  →  Button::PageForward
用户可以在设置中调换左右键的功能,而 Activity 的代码不需要改动一行。这是关注点分离(Separation of Concerns)的又一个好例子。

8.8 开机到首页的完整流程

让我们把整条链路串起来——从按下电源键到看到首页界面:
setup() → 初始化 GPIO/SPI/I2C → 挂载 SD 卡 → 加载设置
       → ActivityManager.begin()(创建渲染任务)
       → 推入 BootActivity

BootActivity.onEnter()
  → 显示 CrossPoint Logo
  → 判断唤醒原因(正常开机 / 深度睡眠恢复)
  → 切换到 HomeActivity

HomeActivity.onEnter()
  → 加载最近阅读的书籍列表和封面
  → requestUpdate() → 渲染任务绘制首页

进入正常循环:loop() → gpio.update() → activityManager.loop()
  → 等待用户操作...
从用户按下电源键到看到首页界面,整个过程在一两秒内完成。每一步都是前面几章学过的知识的组合:GPIO 检测按键、SPI 驱动墨水屏、SD 卡读取书籍、FreeRTOS 调度任务、Activity 管理页面。 CrossPoint Reader 中有十多个 Activity(BootActivityHomeActivityReaderActivityFileExplorerActivitySettingsActivityWiFiActivityWebServerActivityOPDSActivityOTAActivity 等),每一个都遵循同样的模式:实现 onEnter()onExit()loop()render() 四个方法。框架保证了它们被正确调用、正确销毁、正确切换。写一个新页面的开发者不需要关心任务调度、栈管理、内存释放——只需要专注于自己页面的逻辑。 这就是框架的价值。

本章要点

  1. CrossPoint Reader 借鉴 Android 的 Activity 模型,在 MCU 上实现了一套轻量级页面管理框架。每个页面是一个 Activity 子类,拥有 onEnter()onExit()loop()render() 四个生命周期方法。
  2. Activity 栈用后进先出的方式管理页面层级。Push 打开新页面,Pop 返回上一个页面。std::unique_ptr 保证了内存的自动管理。
  3. 页面切换使用 pendingAction 延迟执行模式,确保当前 Activity 的 loop() 完整执行后再安全切换,避免访问已释放的内存。
  4. startActivityForResult 模式允许父页面打开子页面并通过回调接收返回结果,类似 Android 的同名 API。
  5. 按键映射系统(MappedInputManager)在物理按键和逻辑按键之间添加了一层抽象,实现了用户自定义按键布局,同时让 Activity 代码与硬件按键配置彻底解耦。
  6. 从开机到首页的完整流程串联了本书前几章的所有知识:硬件初始化 -> FreeRTOS 任务创建 -> BootActivity -> HomeActivity -> 等待用户输入。

下一章预告:第 9 章我们将深入渲染引擎——在只有几百 KB 内存的芯片上,怎么把漂亮的中文字体画到墨水屏上?分块帧缓冲是什么?两遍渲染又是怎么回事?