为什么电子墨水屏设备要同时保留"物理按键"和"电容触控"两套输入?它们如何各司其职,最终汇成一套操作体系?
核心方法论:系统分析,把每个输入当作一条线索
「破案要先理清每一条线索从哪来、到哪里去。这台阅读器的'输入'也是两条线:一条走物理按键,一条走电容触控。它们各自带着什么样的证据(事件)、中间经过哪些转译,最后又汇到哪个'办案人'(状态机)手里?顺着这两条线把每一步追清楚,'设备怎么知道我想干什么'就不再是黑盒。这一章,我们逐条取证。」
上季的 ESP32 版是纯三按键,这季 README「当前功能」的第一条就写着"支持按键与触控双输入"。为什么一个以"省电、专注阅读"为使命的设备,要同时保留两套输入?先给结论:按键负责"兜底与盲操作",触控负责"直达与富交互",两者互为备份、各有主用。墨水屏设备的特殊性在于——屏幕刷新慢、静态不耗电,但"手指在屏幕上"恰恰是最自然、也最容易误触的操作方式;而阅读是个长时间、重复性极强的场景(一整晚就在翻页),单靠哪一套输入都撑不满整个使用链路。
物理按键的不可替代性至少有三条:一是不依赖屏幕与触控状态——功能设置里有一个"触控开关",关闭时会同步 powerOffTouch() 给触控芯片硬件下电,此时触控整个失效,但按键照常能用;二是支持盲操作——手不离开按键、眼睛不离开文字就能翻页,这在长时间阅读里非常宝贵;三是功耗极低——一次 GPIO 中断就能响应,不需要一直给触控芯片供电。而触控的价值在于"直达":书库点哪本开哪本、目录点哪章跳哪章、左右区域翻页、上滑呼出覆盖层——这些动作用三个按键要按好几下,用触控一两次就完成。
落到真实硬件上要补一句:V1.1 / V1.2 开发板上,物理按键其实只有一个——EPD_KEY1(GPIO 34,兼电源键)。三键语义(UP / DOWN / SELECT)在这个平台上大部分由触控承担,物理按键只保留"短按 = UP"和"长按 = UPGLIDE"两个角色(构造函数里只有 #if defined(BSP_USING_BOARD_SF32_OED_EPD_V11)… 分支才会注册 KEY2 / KEY3,5.2 会展开)。所以"按键 + 触控"准确说是:一套以触控为主、以按键兜底的输入体系。两条输入通道最终都归一为同一个 UIAction 动作枚举,再交给 UI 状态机处理——这是本章最重要的一张"地图"。
| 维度 | 物理按键 | 电容触控 |
|---|---|---|
| 是否依赖屏幕点亮 | 否,完全盲操作 | 需看屏幕对准位置 |
| 触控被关闭时 | 照常可用(兜底) | 失效(powerOffTouch 下电) |
| 误触概率 | 低(物理行程明确) | 较高,需"先选中再确认"等机制 |
| 表达力 | 少:点击 / 长按 | 多:点击、区域、滑动手势 |
| 典型用途 | 翻页、长按呼出覆盖层、电源键 | 列表点选、区域翻页、上滑手势 |
# epdiy-epub/README.md · 「操作说明:按键动作语义」
UP 上移 / 上一项 / 向前翻页
DOWN 下移 / 下一项 / 向后翻页
SELECT 确认 / 进入 / 循环切换值
UPGLIDE 阅读页呼出覆盖操作层
/* src/boards/controls/Actions.h:UIAction——两套输入的统一"出口"(节选) */
typedef enum {
NONE, // 无动作
UP, // 上移 / 上一项 / 向前翻页
DOWN, // 下移 / 下一项 / 向后翻页
SELECT, // 确认 / 进入 / 循环切换值
UPGLIDE, // 长按触发的上滑操作,用于阅读页半屏操作覆盖
PREV_OPTION, // 上一选项(用于列表选择)
NEXT_OPTION, // 下一选项(用于列表选择)
SELECT_BOX, // 选择框(用于触控选择)
ENTER_READING_SETTINGS,
LAST_INTERACTION,
MSG_DRAW_LOW_POWER_PAGE,
MSG_DRAW_CHARGE_PAGE,
MSG_DRAW_WELCOME_PAGE,
MSG_UPDATE_CHARGE_STATUS,
MSG_BATTERY_CHECK
} UIAction;
用办案的思路记这张地图:物理按键和触控是两条"线索源",UIAction 是"案情通报",UI 状态机是"办案人"。不管线索从哪条线进来,只要它被翻译成统一的 UIAction(UP / DOWN / SELECT / UPGLIDE…),办案人就能用同一套逻辑处理。后面 5.2–5.4 讲的,就是"线索怎么一步步变成案情通报"的过程。
SF32_ButtonControls.cpp 是整个输入体系里最短的"翻译官":它把"引脚电平变化"翻译成 UIAction。先看注册(构造函数):用 SDK 的 button 库(middleware/include/button.h)配置一个 button_cfg_t,填上引脚、有效电平、输入模式和回调,再 button_init + button_enable。其中 active_state = BUTTON_ACTIVE_HIGH 表示高电平有效——即"按下时引脚读到高电平"(对应的还有 BUTTON_ACTIVE_LOW 低电平有效)。把"哪个电平算按下"交给配置而非写死在代码里,是让同一份固件兼容不同硬件接线的关键。
button 库还替我们做了一件重要的事——去抖。什么是去抖?机械按键按下/松开的瞬间,金属触点会"弹跳"几十毫秒,导致一次按下在引脚上表现为一连串高低电平抖动;去抖就是"等电平稳定了再算一次按下",否则一次点击会被当成 N 次。button 库的 button_cfg_t 里有一个 debounce_time 字段(毫秒)就是去抖时长。有意思的是,本项目并没有显式设置这个字段——它取库默认值。这提醒我们:移植代码里经常藏着"看起来不重要、实际会改变行为"的默认参数,读代码时值得逐个字段过一遍。
事件到动作的映射在 button_event_handler。V1.1 / V1.2 / V1.2-SPI 三个分支里,KEY1 点击 → UP,KEY1 长按 → UPGLIDE;而 #else 分支(接了 KEY2 / KEY3 的板型)里是 KEY1 → DOWN、KEY2 → SELECT(长按 → UPGLIDE)、KEY3 → UP。注意 UPGLIDE 是个映射动作:物理世界本没有"上滑"这回事,是作者把"长按"这个原始事件,映射成语义动作"阅读页呼出覆盖操作层"——Actions.h 里 UIAction::UPGLIDE 的注释写得很直白:"长按触发的上滑操作,用于阅读页半屏操作覆盖"。把原始事件和语义动作解耦,是这套设计里最值得学的思路。
| 板型分支 | 物理按键 | 点击 | 长按 |
|---|---|---|---|
BSP_USING_BOARD_SF32_OED_EPD_V11 / V12 / V12_SPI | EPD_KEY1(GPIO 34) | UP | UPGLIDE |
其它板型(#else 分支) | EPD_KEY1 | DOWN | — |
EPD_KEY2 | SELECT | UPGLIDE | |
EPD_KEY3 | UP | — |
/* src/boards/controls/SF32_ButtonControls.cpp:按键事件 → 动作映射 */
void button_event_handler(int32_t pin, button_action_t action)
{
#if defined(BSP_USING_BOARD_SF32_OED_EPD_V11) || defined(BSP_USING_BOARD_SF32_OED_EPD_V12_SPI) || defined(BSP_USING_BOARD_SF32_OED_EPD_V12)
if (action == BUTTON_CLICKED) {
action_cbk(UIAction::UP); // 短按 → UP
} else if (action == BUTTON_LONG_PRESSED) {
action_cbk(UIAction::UPGLIDE); // 长按 → UPGLIDE(映射上滑)
}
#else
if (pin == EPD_KEY1) {
if (action == BUTTON_CLICKED) action_cbk(UIAction::DOWN);
} else if (pin == EPD_KEY2) {
if (action == BUTTON_CLICKED) action_cbk(UIAction::SELECT);
else if (action == BUTTON_LONG_PRESSED) action_cbk(UIAction::UPGLIDE);
} else if (pin == EPD_KEY3) {
if (action == BUTTON_CLICKED) action_cbk(UIAction::UP);
}
#endif
}
/* src/boards/controls/SF32_ButtonControls.cpp:用 button 库注册按键(节选) */
button_cfg_t cfg;
cfg.pin = EPD_KEY1; // GPIO 34,兼电源键
cfg.active_state = BUTTON_ACTIVE_HIGH; // 高电平有效:按下 → 读到高电平
cfg.mode = PIN_MODE_INPUT; // 浮空输入
cfg.button_handler = button_event_handler;
id = button_init(&cfg); // 注册到 button 库(含去抖)
RT_ASSERT(id >= 0);
button_enable(id); // 开始监听
记两个词就够:有效电平(BUTTON_ACTIVE_HIGH = 按下读到高电平,配置说了算,硬件接反也能软件救回来)和去抖(物理弹跳 → 过滤成一次按下)。再看一眼上面的表:V1.1 / V1.2 板上只有 KEY1,其余语义全靠触控——所以下一节讲触控芯片,它才是这个平台的"主力输入"。
先解释术语:触控芯片(电容触控控制器)是一颗专门检测"手指按在哪"的芯片——它通过测量触摸屏上电容的变化定位手指坐标,再通过 I2C(芯片间用两根线"串行"传数据的总线协议)把坐标报给主控。本项目适配了市面上常见的三颗:FT5446U、FT6336U(都是 FocalTech 的电容触控方案)与GT967(Goodix)。真实驱动在 src/boards/touch/ 下各自一个目录(ft5446u/、ft6336u/、gt967/),靠 Kconfig 宏条件编译——每个 SConscript 的 depend 分别是 TSC_USING_FT5446U_V01 / TSC_USING_FT6336U / TSC_USING_GT967,哪个宏被选中,哪个驱动才编进去。
一颗芯片不是万能的,硬件平台的原则是"跟着屏走"。看 project/Kconfig.proj 的 Custom LCD driver 选项:YZC085(V1.1 板)→ 选 TSC_USING_FT5446U_V01;YZC085(V1.2 板)→ 选 TSC_USING_GT967;R7D005(1448×1072)→ 也配 FT5446U;OPM037A3(3.7" SPI 小屏)→ 配 FT6336U。为什么换?因为触控屏的排线走线、分辨率、扫描方式不同,主控侧的 I2C 引脚和复位时序也不一样——所以"换屏就可能换触控芯片"。这一行的配置关系,在第 3 章板级配置里就定下来了,到驱动里体现为条件编译。
三颗芯片的读取模型高度一致:触控芯片拉低中断引脚(中断配置为下降沿 PIN_IRQ_MODE_FALLING)→ 驱动在中断回调里释放一个信号量 → 主控线程被唤醒后走 I2C 读寄存器。差异在寄存器细节:FT 系从机地址 0x38,先读 FT_TD_STATUS(0x02)看有没有手指,再读坐标寄存器高低字节拼出 x / y;GT967 从机地址 0x14(7-bit),寄存器是 16 位地址(0x814E 触点状态、0x8150 坐标),初始化时还读 0x8144 拿固件版本、写 0x8040 做软复位。坐标还要"校正":FT5446U 驱动内置镜像矫正(量程 240×240),GT967 内置旋转(x = y; y = 屏高 - x - 1),FT6336U 则直接透传原生坐标(1032×758)。
驱动层坐标拿到后,SF32_TouchControls::tp_rx_indicate 还会再转一次:"Rotate anti-clockwise 90 degree",x = LCD_VER_RES_MAX - touch_data.y - 1; y = touch_data.x;——把芯片坐标系转成屏幕坐标系。两层坐标变换一叠加,才是真正能对齐到 UI 的坐标。最后是误触治理。README「操作说明」的答案叫"先选中再确认":书库页第一次点击某本书,只是把光标移过去(动作 SELECT_BOX,同时记下 last_clicked_book_index、置 waiting_for_confirmation = true);同一本书再点第二下才真正确认(SELECT,进入目录页)。目录页同理。两次点击必须落在同一项上才生效——专治"手指一滑就点错"。这套机制,我们在后面书库/目录一章(第 12 章)会看它怎么配合页面重绘。
| 触控芯片 | I2C 地址 | 寄存器宽度 | 坐标量程 / 校正 | 配哪块屏(Kconfig.proj) |
|---|---|---|---|---|
FT5446U | 0x38 | 8-bit 寄存器 | 240×240,镜像矫正 | YZC085(V1.1)、R7D005 |
FT6336U | 0x38 | 8-bit 寄存器 | 1032×758,原生直通 | OPM037A3(SPI 小屏) |
GT967 | 0x14 | 16-bit 寄存器 | 旋转矫正(x↔y) | YZC085(V1.2) |
/* src/boards/touch/ft5446u/ft5446u.c:读触点数量 → 拼坐标(节选) */
err = read_regs(FT_TD_STATUS, 1, &tp_num); // 0x02:当前触点数量
if (tp_num > 0)
{
read_regs(FT_P1_XL, 1, &out_val[0]); // 0x06:X 低字节
read_regs(FT_P1_XH, 1, &out_val[1]); // 0x05:X 高字节
p_msg->y = ((out_val[1] & 0x7) << 8) | out_val[0];
read_regs(FT_P1_YL, 1, &out_val[0]); // 0x04:Y 低字节
read_regs(FT_P1_YH, 1, &out_val[1]); // 0x03:Y 高字节
p_msg->x = ((out_val[1] & 0x7) << 8) | out_val[0];
p_msg->event = TOUCH_EVENT_DOWN; // 有手指 → 按下事件
}
else
{
p_msg->event = TOUCH_EVENT_UP; // 没有手指 → 抬起事件
}
/* src/boards/touch/gt967/gt967.c:复位时序 + 固件版本 + 软复位(节选) */
rt_touch_irq_pin_attach(PIN_IRQ_MODE_FALLING, irq_handler, NULL); // 下降沿中断
rt_touch_irq_pin_enable(1);
read_regs(0x8144, 4, buf); // 读固件版本
firmware_version = buf[0] + ((uint16_t)(buf[1] & 0xff) << 8);
LOG_I("Firmware version = 0x%x(%d)", firmware_version, firmware_version);
write_reg(TP_ID_CONTROL, 2); // 0x8040:软复位
write_reg(TP_ID_CONTROL, 0);
/* src/boards/controls/SF32_TouchControls.cpp:书库页"先选中再确认"(节选) */
if (clicked_book_index != -1)
{
if (waiting_for_confirmation && last_clicked_book_index == clicked_book_index)
{
book_index = clicked_book_index;
action = SELECT; // 第二次点同一本 → 真正确认
waiting_for_confirmation = false;
}
else
{
book_index = clicked_book_index;
last_clicked_book_index = clicked_book_index;
waiting_for_confirmation = true; // 第一次点 → 只选中,等确认
action = SELECT_BOX;
}
}
把"中断 → 信号量 → I2C 读坐标"这条链背下来,就能读懂市面上九成电容触控驱动:芯片用中断引脚喊"有人摸了",主控再去 I2C 把坐标"取回来"。三颗芯片的差别只在"寄存器长什么样、地址怎么拼",骨架几乎一样。所以移植新触控芯片,就是照着现有驱动换寄存器宏——第 16 章"移植新屏"会再见到这个套路。
坐标有了,下一个问题是"点在这个位置是什么意思"。答案是把屏幕切成若干"可点击矩形"(区域),每个区域绑定一个动作——这就是 UIRegionsManager(src/UIRegionsManager.cpp/h)干的事。它的核心是一个全局数组 g_area_array[MAX_AREAS](上限 MAX_AREAS = 30)加一个计数器 g_area_count,区域结构 AreaRect 就四个坐标加一个动作:{start_x, start_y, end_x, end_y, action}。接口有两套:add_area(x, y, w, h) 动态追加(数组满了返回 false),static_add_area(x, y, w, h, index) 静态写入指定槽位。
两种用法各有场景。书库页每页 4 本书 + 底部 3 个按钮,位置基本固定,所以用 static_add_area 写死索引:0–3 是四本书、4 / 5 / 6 是"上一页 / 主页面 / 下一页";目录页同理(0–5 是六个目录项,6 / 7 / 8 是底部三键)。而阅读页覆盖层的 12 个按钮每次打开都要按当前屏宽重新布局,所以 render_overlay() 每次都先 clear_areas() 清空、再逐个 add_area() 动态注册。"重绘即重注册"是这套机制能动态命中的关键——区域不是写死的常量表,而是"画到哪、点哪就有效"。
命中检测在 SF32_TouchControls::tp_rx_indicate:按下事件(TOUCH_EVENT_DOWN)进入 switch (ui_state),逐区域判断点击坐标是否落在矩形内,命中就把对应动作通过 on_action 回调塞进 ui_queue,主循环收到后交给 handleUserInteraction 执行;松开事件(TOUCH_EVENT_UP)直接忽略(注释:"只处理按下事件,忽略释放事件")。主页面三块区域:g_area_array[0] = 左(UP)、[1] = 右(DOWN)、[2] = 中(SELECT);阅读页未开覆盖层时左右翻页用两个硬编码矩形(x∈[10,200] → UP、[550,750] → DOWN),开了覆盖层则改查 12 个按钮区域。
最后把覆盖操作层的完整闭环串起来:UPGLIDE(按键长按或触控上滑手势,上滑阈值 SWIPE_THRESHOLD = 100 像素)→ reader->start_overlay() → render_overlay() 画三行按钮(第一行 3 个:<、触摸开关 / 全刷周期、>;第二行 5 个:-5 / -1 / 页码 / +1 / +5;第三行 4 个:确认 / 目录 / 书库 / 设置),每画一个就 add_area 注册一个 → 触控点中任意按钮命中后发 SELECT → main.cpp 的 handleEpub 按 touch_sel 分发:跳页、进目录、返回书库、进阅读设置。"动态注册 + 命中检测 + 动作分发"三件套,就是本章输入体系的完整闭环。
| 页面(ui_state) | 触控区域布局 | 注册方式 | 命中 → 动作 |
|---|---|---|---|
MAIN_PAGE | 左 / 右 / 中三块 | static_add_area(0–2) | UP / DOWN / SELECT |
SELECTING_EPUB | 4 本书 + 底部 3 键 | static_add_area(0–6) | 选中/确认 / 上翻页 / 主页面 / 下翻页 |
READING_EPUB | 左右翻页(硬编码矩形) | 内联判断 | UP / DOWN |
| 阅读覆盖层(overlay) | 12 个按钮(3+5+4) | clear_areas + add_area | 跳页 / 确认 / 目录 / 书库 / 设置 |
SETTINGS_PAGE | 每行 3 块(左箭头/右箭头/值) | static_add_area(行 × 3) | PREV_OPTION / NEXT_OPTION / SELECT |
/* src/UIRegionsManager.h:可点击区域的结构与注册接口 */
typedef struct {
int start_x;
int start_y;
int end_x;
int end_y;
UIAction action; // 点击该区域时触发的动作
} AreaRect;
#define MAX_AREAS 30 // 最大区域数量
extern AreaRect g_area_array[MAX_AREAS];
extern int g_area_count;
void clear_areas();
bool add_area(int x, int y, int width, int height); // 动态添加
bool static_add_area(int x, int y, int width, int height, int index); // 静态添加
/* src/UIRegionsManager.cpp:清空与动态注册的实现(节选) */
void clear_areas()
{
g_area_count = 0;
memset(g_area_array, 0, sizeof(g_area_array));
}
bool add_area(int x, int y, int width, int height)
{
if (g_area_count >= MAX_AREAS) {
return false; // 数组已满
}
g_area_array[g_area_count].start_x = x;
g_area_array[g_area_count].start_y = y;
g_area_array[g_area_count].end_x = x + width;
g_area_array[g_area_count].end_y = y + height;
g_area_count++;
return true;
}
/* lib/Epub/EpubList/EpubReader.cpp:覆盖层按钮动态注册(节选) */
clear_areas(); // 清除旧的触控区域,重新注册 overlay 按钮区域
...
// 第一行(3 列):< / 触摸开关 / >
for (int i = 0; i < c; ++i) {
add_area(x, y, w, row_h); // 每画一个按钮就注册一块区域
...
}
// 第二、三行(5、4 列)同样逐个 add_area(x, y, btn_w, row_h)
UIRegionsManager 的精髓是"区域跟着画面走":画面画到哪,命中区域就注册到哪,清掉重画就重注册。这样页面代码不用维护两份"几何数据"(一份画、一份点),也天然规避了"按钮移动了但热区没跟着动"这类 bug。等你读第 13 章覆盖层与触控区域时,这套机制还会被反复调用。
结合 5.1,列出电子墨水屏阅读器需要保留物理按键的至少三条理由,并说明触控相比按键的优势在哪里。尽量用"特性 → 后果"的句式(例如"按键支持盲操作 → 眼睛不离开文字也能翻页")。
从"不依赖屏幕状态 / 盲操作 / 功耗 / 表达力"四个维度分别想物理按键和触控的强项与弱项。
物理按键:不依赖屏幕与触控状态(触控可被"触控开关"硬件下电,按键仍可用)→ 任何时刻都能操作;支持盲操作 → 眼睛不离开文字即可翻页;功耗极低(GPIO 中断即可响应)→ 不增加待机负担。触控优势:直达(点哪本开哪本)与富交互(上滑手势呼出覆盖层、区域翻页),用一两次操作完成按键需要按好几下的动作。结论:按键兜底、触控直达,互为备份。
打开 src/boards/controls/SF32_ButtonControls.cpp:(1) V1.1 / V1.2 板上 EPD_KEY1 的短按和长按各映射成哪个 UIAction?(2) "其它板型"分支里三个键各映射什么?(3) 为什么叫 UPGLIDE 而不是直接叫"长按"?
对照 5.2 的映射表;UPGLIDE 是"映射动作",注释在 Actions.h 里写着"长按触发的上滑操作,用于阅读页半屏操作覆盖"。
(1) V1.1 / V1.2 / V1.2-SPI 分支:KEY1 点击 → UP,长按 → UPGLIDE。(2) 其它板型分支:KEY1 → DOWN,KEY2 → SELECT(长按 → UPGLIDE),KEY3 → UP。(3) UPGLIDE 是把"长按"这个原始事件映射成语义动作"上滑呼出阅读页覆盖操作层"——物理世界没有上滑,语义动作与原始事件解耦,让按键和触控上滑两种来源都能触发同一个 UIAction::UPGLIDE。
对照 5.3,回答:(1) FT5446U / FT6336U / GT967 的 I2C 从机地址各是多少?(2) 谁的寄存器是 16 位地址?它初始化时还读 / 写了哪些寄存器、各有什么用途?(3) 在 project/Kconfig.proj 里,V1.1 板与 V1.2 板各选了哪颗芯片?
FT 系 0x38、GT967 0x14;GT967 的寄存器地址是 16 位(0x814E / 0x8150…),读 0x8144 拿固件版本、写 0x8040 软复位;配屏关系见 Custom LCD driver 选项的 select TSC_USING_*。
(1) FT5446U / FT6336U 都是 0x38,GT967 是 0x14(7-bit)。(2) GT967 用 16 位寄存器地址:0x814E 触点状态、0x8150 起是坐标;初始化读 0x8144 拿固件版本,写 0x8040 触发软复位(2 → 0)。(3) V1.1 板 → TSC_USING_FT5446U_V01,V1.2 板 → TSC_USING_GT967;R7D005 也配 FT5446U,OPM037A3(SPI 小屏)配 FT6336U。
add_area 和 static_add_area 有什么区别?为什么阅读页覆盖层的 12 个按钮每次打开都要 clear_areas() 后重新 add_area()?如果某个页面忘了注册区域,触控点上去会发生什么?请从 SF32_TouchControls::tp_rx_indicate 的命中逻辑推演。
add_area 按 g_area_count 追加,static_add_area 写指定槽位;命中检测只查 g_area_array 里已注册的矩形。想一想覆盖层按钮宽高是"动态计算"的,而书库页位置是"固定"的。
add_area 动态追加到 g_area_array[g_area_count] 并自增(满了返回 false);static_add_area 直接写指定索引槽位。覆盖层按钮的宽高是每次 render_overlay() 按屏宽动态算的(row_h、btn_w 都会变),所以不能写死,必须先 clear_areas() 清空旧区域、再逐个 add_area() 保证"画哪点哪"。而书库 / 设置页位置固定,用 static_add_area 即可。若页面忘了注册区域,命中检测的 if (x >= start_x && …) 全部不命中,action 保持 NONE,最终不会发出任何有效动作——屏幕上表现为"点了没反应",而且通常很难排查,所以"先 clear 再注册"要成为习惯。