“好的架构不是没有东西可以再加,而是没有东西可以再减。” —— Antoine de Saint-Exupery上一章我们搞清楚了 CrossPoint Reader 怎么让两个任务协作运行。但一个阅读器不止一个界面——有启动画面、首页、文件浏览器、阅读界面、设置页、WiFi 连接页…这些”页面”怎么组织?怎么切换?怎么返回? CrossPoint Reader 的回答是:把 Android 的 Activity 模型搬到 MCU 上。
8.1 为什么需要页面框架
假设你不用任何框架,直接在loop() 里用 if-else 管理所有页面:
- 代码膨胀:所有页面的逻辑堆在一个函数里,几千行起步
- 耦合紧密:每个页面的变量混在一起,改一个容易影响另一个
- 返回困难:从设置页返回首页容易,但如果设置页是从阅读器里打开的呢?返回时应该回到阅读器,而不是首页
- 数据传递:一个页面打开另一个页面并等待它返回结果,用全局变量传递既丑陋又危险
8.2 Activity 基类:每个页面的”模板”
CrossPoint Reader 中,每一个页面都是Activity 基类的子类。基类定义了页面的完整生命周期:
| 方法 | 何时调用 | 做什么 |
|---|---|---|
onEnter() | 页面被推入栈顶时 | 加载数据、初始化状态、请求首次绘制 |
onExit() | 页面被移出栈顶时 | 释放资源、保存状态 |
loop() | 主任务每次循环时 | 处理按键输入、更新状态 |
render() | 渲染任务收到通知时 | 把当前页面的画面绘制到帧缓冲区 |
requestUpdate() | 页面需要重绘时主动调用 | 通过任务通知唤醒渲染任务 |
与 Android Activity 生命周期的对比
如果你熟悉 Android 开发,可以做这样的映射:onEnter() 和 onExit() 两个回调就够了。
简洁,是在有限资源下的最佳策略。
8.3 Activity 栈:页面的”浏览历史”
CrossPoint Reader 用一个栈(Stack)来管理所有打开的页面:std::unique_ptr<Activity> 保证了每个 Activity 对象的内存由栈来管理——当一个页面从栈中被弹出时,unique_ptr 会自动释放该 Activity 占用的内存,不需要手动 delete。
8.4 页面切换机制
接下来看 ActivityManager 最核心的代码——loop() 方法。它处理页面的切入和切出:
Push 操作(打开新页面)
std::move(pendingActivity) —— 这里用了 C++ 的移动语义,把 Activity 对象的所有权从临时变量转移到栈中,避免了不必要的拷贝。
Pop 操作(返回上一个页面)
为什么用 pendingAction 而不是直接切换
你可能注意到了:页面切换不是立即执行的,而是先设置一个pendingAction 标志,在下一次 loop() 开头才真正执行。
这是一个重要的设计决策。如果一个 Activity 在自己的 loop() 方法里调用了 startActivity(),而 startActivity() 直接执行切换,那就会在 loop() 执行到一半的时候突然把当前 Activity 给销毁了——后续的代码可能会访问到已经被释放的内存,引发崩溃。
用 pendingAction 延迟执行,可以保证当前 Activity 的 loop() 完整执行完毕后,再安全地进行切换。这是嵌入式开发中常见的”延迟操作”模式。
循环处理多个挂起动作
注意外层是while 而不是 if。这意味着如果一个 onEnter() 内部又触发了页面切换(比如启动页在 onEnter() 里判断应该直接跳转到首页),可以在同一轮循环中连续处理多个切换动作,不需要等到下一轮 loop() 调用。
8.5 startActivityForResult:打开子页面并等待结果
有时候,一个页面需要打开另一个页面来完成某个选择,然后把选择结果带回来。举个例子:用户在阅读 EPUB 时点击了一个脚注链接。阅读器需要弹出一个”脚注选择页面”让用户确认是否跳转。用户点击”确认”后,脚注选择页面关闭,把目标位置传回阅读页面,阅读页面跳转到对应位置。这就是
startActivityForResult 模式:
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():初始化
- 调用父类的
onEnter()(基础初始化) - 加载最近阅读的书籍列表
- 调用
requestUpdate()请求首次绘制——这会通过任务通知唤醒渲染任务
loop():处理用户输入
loop() 在主任务的每一轮循环中被调用,它处理两种输入:
- 导航键(上/下):移动选择光标。
ButtonNavigator封装了上下导航的逻辑,按下导航键后更新选中索引并请求重绘。 - 确认键:如果当前选中的是一本书,打开阅读界面。
requestUpdate() 的调用时机——只有当界面确实需要变化时才请求重绘,而不是每次 loop() 都重绘。这对墨水屏来说尤其重要:不必要的刷新不仅浪费时间,还会让屏幕频繁闪烁,影响用户体验。
render():绘制画面
render() 在渲染任务中被调用(回忆上一章的内容),它做的事情很纯粹:
- 清空帧缓冲区
- 画页面头部(标题栏、状态图标)
- 画最近阅读的书籍封面
- 画底部按钮菜单
- 把帧缓冲区的内容推送到墨水屏
RenderLock&&——这是一个右值引用,意味着 render() 接管了这把锁的所有权。当 render() 函数结束时,锁会自动释放。
完整的生命周期实例
把三个方法串起来,HomeActivity 的一生是这样的:onEnter()(加载数据、首次绘制) -> loop() 循环响应按键 -> 用户选择一本书 -> onExit()(保存状态) -> ReaderActivity 入栈 -> 用户在阅读器按返回 -> HomeActivity 重新回到栈顶 -> onEnter() 再次被调用(重新加载列表,因为最近阅读可能有变化)。
8.7 按键映射系统
CrossPoint Reader 有七个物理按键,但不同用户可能习惯不同的按键布局。有人习惯左手翻页,有人习惯右手翻页。按键映射系统解决了这个问题。-
Activity 代码里使用的是逻辑按键:
Button::Back(返回)、Button::Confirm(确认)、Button::PageBack(上一页)、Button::PageForward(下一页)。 -
这些逻辑按键对应的物理按键由用户在设置中配置。
SETTINGS.frontButtonBack和SETTINGS.frontButtonConfirm存储了用户选择的物理按键编号。 -
(gpio.*fn)(buttonId)是 C++ 的成员函数指针调用语法——fn可以是isPressed、wasPressed、wasReleased中的任意一个,实现了灵活的按键状态查询。
8.8 开机到首页的完整流程
让我们把整条链路串起来——从按下电源键到看到首页界面:BootActivity、HomeActivity、ReaderActivity、FileExplorerActivity、SettingsActivity、WiFiActivity、WebServerActivity、OPDSActivity、OTAActivity 等),每一个都遵循同样的模式:实现 onEnter()、onExit()、loop()、render() 四个方法。框架保证了它们被正确调用、正确销毁、正确切换。写一个新页面的开发者不需要关心任务调度、栈管理、内存释放——只需要专注于自己页面的逻辑。
这就是框架的价值。
本章要点
- CrossPoint Reader 借鉴 Android 的 Activity 模型,在 MCU 上实现了一套轻量级页面管理框架。每个页面是一个 Activity 子类,拥有
onEnter()、onExit()、loop()、render()四个生命周期方法。 - Activity 栈用后进先出的方式管理页面层级。Push 打开新页面,Pop 返回上一个页面。
std::unique_ptr保证了内存的自动管理。 - 页面切换使用 pendingAction 延迟执行模式,确保当前 Activity 的
loop()完整执行后再安全切换,避免访问已释放的内存。 startActivityForResult模式允许父页面打开子页面并通过回调接收返回结果,类似 Android 的同名 API。- 按键映射系统(MappedInputManager)在物理按键和逻辑按键之间添加了一层抽象,实现了用户自定义按键布局,同时让 Activity 代码与硬件按键配置彻底解耦。
- 从开机到首页的完整流程串联了本书前几章的所有知识:硬件初始化 -> FreeRTOS 任务创建 -> BootActivity -> HomeActivity -> 等待用户输入。
下一章预告:第 9 章我们将深入渲染引擎——在只有几百 KB 内存的芯片上,怎么把漂亮的中文字体画到墨水屏上?分块帧缓冲是什么?两遍渲染又是怎么回事?