前置知识:了解 Activity 页面框架(第 8 章),理解 JSON 基本格式,了解 Web API 的概念。 本章目标:理解 CrossPoint Reader 如何用”一张表”驱动设备 UI、Web API 和持久化三套系统,如何安全地存储密码,以及如何让一个嵌入式设备说 12 种语言。

15.1 一个设置项的”一生”

在手机 App 或网页项目中,添加一个设置项通常意味着改好几个地方:UI 界面加一个控件、后端 API 加一个字段、数据库加一列、前端表单加一项校验……改漏一处就出 bug。 CrossPoint Reader 面临同样的问题——一个”屏幕边距”设置需要同时出现在三个地方:
  1. 设备端 UI:用户在墨水屏上操作的设置页面
  2. Web API:通过浏览器远程修改设置
  3. JSON 文件:持久化到 SD 卡,断电后不丢失
如果这三处各自维护一套逻辑,迟早会出现”UI 上改了但没保存到文件”或”API 返回的字段名和文件里的不一致”之类的问题。 CrossPoint Reader 的解决方案是:用一张声明式的表来描述所有设置项,三套系统共用同一张表。

15.2 声明式设置表

核心思想:一处定义,多处使用

所有设置项集中定义在一个函数里:
// src/SettingsList.h
inline const std::vector<SettingInfo>& getSettingsList() {
  static const std::vector<SettingInfo> list = {
    // 枚举类型:有限选项
    SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
                      {StrId::STR_DARK, StrId::STR_LIGHT, ...},
                      "sleepScreen",                    // JSON key
                      StrId::STR_CAT_DISPLAY),          // 分类

    // 开关类型:是/否
    SettingInfo::Toggle(StrId::STR_SUNLIGHT_FADING_FIX,
                        &CrossPointSettings::fadingFix,
                        "fadingFix", StrId::STR_CAT_DISPLAY),

    // 数值类型:带范围和步长
    SettingInfo::Value(StrId::STR_SCREEN_MARGIN, &CrossPointSettings::screenMargin,
                       {5, 40, 5},   // min=5, max=40, step=5
                       "screenMargin", StrId::STR_CAT_READER),

    // 动态字符串:自定义 getter/setter
    SettingInfo::DynamicString(StrId::STR_KOREADER_USERNAME,
        [] { return KOREADER_STORE.getUsername(); },
        [](const std::string& v) {
          KOREADER_STORE.setCredentials(v, KOREADER_STORE.getPassword());
          KOREADER_STORE.saveToFile();
        },
        "koUsername", StrId::STR_KOREADER_SYNC),
  };
  return list;
}
仔细看这段代码,每一个 SettingInfo 都包含了完整的”自我描述”:
字段含义例子
显示名(StrId)UI 上显示的文字,支持多语言STR_SCREEN_MARGIN -> “屏幕边距”
成员指针指向 CrossPointSettings 结构体中的哪个字段&CrossPointSettings::screenMargin
JSON key持久化到 JSON 文件时的键名"screenMargin"
分类(StrId)在设置页面中归入哪个分组STR_CAT_READER -> “阅读”
约束条件枚举的可选值、数值的范围和步长{5, 40, 5} 表示 5 到 40,步长 5

四种设置类型

CrossPoint Reader 定义了四种设置类型,覆盖了绝大多数场景:
  • Enum(枚举):有限的几个选项中选一个。比如休眠画面选”暗色”还是”亮色”。
  • Toggle(开关):只有”开”和”关”两种状态。比如阳光褪色修复功能。
  • Value(数值):在一个范围内选择数值。比如屏幕边距 5~40 像素,每次调整 5 像素。
  • DynamicString(动态字符串):不直接绑定到结构体字段,而是通过自定义的 getter/setter 函数读写。比如 KOReader 的用户名存储在独立的 Store 中,读写逻辑比较特殊。

三处使用同一张表

有了这张表,三套系统就可以各取所需:
                    getSettingsList()
                          |
           +--------------+--------------+
           |              |              |
     设备端 UI         Web API       JSON 持久化
  遍历列表,按分类    遍历列表,     遍历列表,
  绘制设置页面       自动序列化/     读写 JSON 文件
                    反序列化 JSON
设备端 UI:SettingsActivity 遍历列表,按 分类 字段对设置项分组,根据 类型 选择合适的 UI 控件(枚举用选择框、数值用滑块、开关用复选框),从 StrId 获取当前语言的显示文本。 Web API/api/settings 端点遍历同一张列表,GET 请求时用 JSON key成员指针 自动构造 JSON 响应,POST 请求时从 JSON 中按 JSON key 读取值并写回结构体。 JSON 持久化:保存时遍历列表,用 JSON key成员指针 生成 JSON 文件;加载时反向操作。
类比:这张表就像餐厅的菜单。服务员(UI)拿着菜单给客人点菜,厨房(持久化)按菜单做菜,外卖平台(Web API)也展示同一份菜单。新增一道菜只需要在菜单上加一行——不用分别通知服务员、厨房和外卖平台。

新增设置的工作量

假设你要给阅读器添加一个”翻页动画速度”的设置。你只需要做两件事:
  1. CrossPointSettings 结构体里加一个字段 uint8_t pageFlipSpeed;
  2. getSettingsList() 里加一行:
SettingInfo::Value(StrId::STR_PAGE_FLIP_SPEED, &CrossPointSettings::pageFlipSpeed,
                   {1, 10, 1}, "pageFlipSpeed", StrId::STR_CAT_READER),
完成。设备 UI、Web API、JSON 持久化全部自动支持新设置,无需额外代码。 这种”数据驱动”的设计在嵌入式项目中尤其有价值——代码改动越少,引入 bug 的风险就越低。而嵌入式 bug 的代价往往比 Web 项目高得多(用户没法”刷新页面”来恢复)。

15.3 设置持久化:JSON + 格式迁移

为什么选 JSON

CrossPoint Reader 的早期版本用二进制格式存储设置——每个字段按固定偏移量写入文件,读取时也按偏移量读出。这种方式速度快、体积小,但有三个致命缺点:
  1. 不可读:用文本编辑器打开是一堆乱码,调试时无法直观看到设置值
  2. 不兼容:新版本增加了一个字段,所有偏移量都变了,旧文件直接报废
  3. 不可调试:用户反馈”我的设置丢了”,开发者无法查看 SD 卡上的设置文件来定位问题
迁移到 JSON 格式后,这些问题都解决了:
{
  "sleepScreen": 1,
  "fadingFix": true,
  "screenMargin": 15,
  "koUsername": "reader123"
}
人类可读,新增字段不影响旧字段,用户甚至可以手动编辑。

加载逻辑与自动迁移

// src/CrossPointSettings.cpp
bool CrossPointSettings::loadFromFile() {
  if (Storage.exists(SETTINGS_FILE_JSON)) {
    String json = Storage.readFile(SETTINGS_FILE_JSON);
    return JsonSettingsIO::loadSettings(*this, json.c_str(), &resave);
  }
  // 兼容旧版二进制格式
  if (Storage.exists(SETTINGS_FILE_BIN)) {
    loadFromBinaryFile();
    saveToFile();                                        // 迁移为 JSON
    Storage.rename(SETTINGS_FILE_BIN, SETTINGS_FILE_BAK);
  }
}
这段代码的逻辑很清晰:
  1. 优先读 JSON:如果 JSON 格式的设置文件存在,直接加载
  2. 兜底读二进制:如果 JSON 不存在但旧的二进制文件存在,读取旧格式
  3. 自动迁移:把二进制的内容用 JSON 格式重新保存
  4. 备份旧文件:把旧的二进制文件改名为 .bak,而不是直接删除——万一迁移出了问题,还有恢复的机会
用户在升级固件后第一次开机时,这个迁移过程自动完成,完全无感知。这种”静默迁移”策略在嵌入式产品中非常重要——你不能弹一个对话框让用户”确认迁移”,因为用户可能根本不知道什么是”设置格式迁移”。

15.4 密码存储:MAC 地址 XOR 混淆

CrossPoint Reader 支持 WebDAV 同步和 KOReader 同步,这意味着设备需要存储用户的账号密码。但设置文件保存在 SD 卡上——SD 卡可以被拔出来,插到电脑上直接读取。如果密码以明文存储,安全性为零。

混淆方案

// src/JsonSettingsIO.cpp
if (info.obfuscated) {
  doc[key + "_obf"] = obfuscation::obfuscateToBase64(strPtr);
  // XOR with MAC address -> Base64 encode
}
CrossPoint Reader 采用的方案是:用设备的 MAC 地址对密码做 XOR 运算,然后用 Base64 编码存储。 具体过程:原始密码逐字节与 MAC 地址的字节做 XOR 运算,得到乱码数据后再 Base64 编码存入 JSON 文件。

这是加密吗?

不是。 这是混淆(Obfuscation),和加密有本质区别:
  • 加密需要密钥管理、安全随机数、经过验证的算法(如 AES)
  • 混淆只是让数据”不那么容易被看到”,理论上可以被逆向
但对于 CrossPoint Reader 的使用场景来说,混淆已经足够:
  1. 绑定物理设备:XOR 的”密钥”是 MAC 地址,每台设备不同。即使有人拔走 SD 卡,插到另一台设备或电脑上,直接读取配置文件看到的只是乱码。
  2. 防止明文泄露:至少不会出现在文本编辑器里一眼看到 "password": "123456" 的尴尬场面。
  3. 成本极低:XOR 运算在 ESP32-C3 上几乎不消耗资源,不需要引入加密库。
当然,如果攻击者同时拥有 SD 卡和设备(可以读取 MAC 地址),就能还原密码。但在一个墨水屏阅读器的场景下,这种威胁模型已经超出了合理的安全预期——如果有人拿走了你的整台设备,密码安全已经不是最大的问题了。
经验法则:安全方案的选择要匹配威胁模型。银行系统需要硬件加密模块,而一台 10 块钱芯片的阅读器只需要”别把密码写在脸上”就够了。

15.5 国际化(I18n)系统

CrossPoint Reader 支持 12 种以上的语言界面,从中文、英文到日文、韩文、阿拉伯文。在嵌入式设备上实现多语言支持,面临的挑战和 Web 项目很不一样。

核心设计:数组索引法

// lib/I18n/I18n.h
class I18n {
  const char* get(StrId id) const;
};

#define tr(id) I18n::getInstance().get(StrId::id)
// 用法:tr(STR_BOOTING) -> "启动中..." 或 "Booting..."
tr() 宏是代码中获取翻译文本的唯一入口。StrId 是一个枚举,每个翻译项有唯一的 ID。 查找逻辑极其简单:
// lib/I18n/I18n.cpp
const char* I18n::get(StrId id) const {
  const char* const* strings = getStringArray(_language);
  return strings[static_cast<size_t>(id)];  // O(1) 数组索引
}
每种语言就是一个 const char* 数组,翻译项的 ID 就是数组下标。查找翻译的时间复杂度是 O(1)——一次数组索引操作,比 Web 项目中常见的 JSON 文件查找(哈希表 O(1) 但有额外开销)或 gettext 方案(字符串比较 O(n))都更快。 这也意味着每种语言的数组长度必须完全一致,每个位置对应同一个 StrId。如果某种语言少了一个翻译项,编译时就会报错——而不是运行时才发现”这个按钮没翻译”。

翻译数据的管理

翻译源文件(人类编辑)     Python 构建脚本        生成的 C 代码(编译进固件)
   strings_en.csv    --->                  --->  strings_en.cpp
   strings_zh.csv    --->   generate.py    --->  strings_zh.cpp
   strings_ja.csv    --->                  --->  strings_ja.cpp
   ...                                          ...
翻译人员编辑 CSV 文件(或类似格式的翻译文件),Python 脚本在编译前自动将翻译文件转换为 C/C++ 代码。生成的代码类似于:
// 自动生成,请勿手动编辑
static const char* strings_zh[] = {
  "启动中...",          // STR_BOOTING
  "正在加载书籍",       // STR_LOADING_BOOK
  "设置",              // STR_SETTINGS
  "显示",              // STR_CAT_DISPLAY
  "阅读",              // STR_CAT_READER
  // ... 几百个翻译项
};
这种”构建时生成”的方案有几个好处:
  1. 零运行时解析:所有字符串在编译时就确定了,不需要运行时读 JSON 文件或解析翻译数据
  2. 类型安全:如果翻译项数量不对,编译时就能发现错误
  3. Flash 存储:字符串常量存放在 Flash 中(const 修饰),不占用宝贵的 RAM

与 Web 的 i18n 方案对比

方面Web(react-i18next 等)CrossPoint Reader
翻译文件格式JSON / PO / YAMLCSV -> 编译时生成 C 数组
运行时查找哈希表 / 对象属性访问数组下标 O(1)
语言切换动态加载新的 JSON 文件切换数组指针,即时生效
缺失翻译处理运行时 fallback 到默认语言编译时报错,不允许缺失
内存开销所有翻译加载到内存所有翻译存放在 Flash,零 RAM 占用
Web 方案追求灵活性(运行时加载、热更新),而嵌入式方案追求确定性和最小资源占用。不同的约束催生不同的设计。

15.6 设置界面与 Web API 双通道

CrossPoint Reader 的设置可以通过两种方式修改:在设备上操作墨水屏 UI,或者连接 WiFi 后通过浏览器访问 Web 页面。两种方式操作的是同一份数据。

设备端:SettingsActivity

SettingsActivity 是一个标准的 Activity(第 8 章介绍的页面框架)。它的 render() 方法会遍历 getSettingsList() 返回的列表:
SettingsActivity.render()
    |
    for (auto& info : getSettingsList())
        |
        switch (info.type)
            |
            Enum    ->  绘制选项列表,当前值高亮
            Toggle  ->  绘制开/关复选框
            Value   ->  绘制数值和左右箭头
            DynamicString -> 绘制当前字符串值
当用户通过按键修改某个设置项时,SettingsActivity 直接修改 CrossPointSettings 结构体中对应的字段(通过成员指针),然后调用 saveToFile() 持久化。

Web 端:/api/settings

当设备连接 WiFi 后,内置的 Web 服务器(第 12 章)会暴露 /api/settings 端点:
  • GET /api/settings:遍历设置列表,读取每个字段的当前值,构造 JSON 响应返回
  • POST /api/settings:解析请求体中的 JSON,遍历设置列表,按 JSON key 匹配并更新对应字段
浏览器 GET /api/settings           浏览器 POST /api/settings
    |                                    |
    遍历 getSettingsList()               解析 JSON body
    读取每个字段的值                      按 jsonKey 匹配并更新字段
    构造 JSON 返回                        saveToFile() 持久化

修改同步

由于两种通道操作的是同一个 CrossPointSettings 结构体实例,Web 端修改的设置会立即反映到设备端,反过来也一样。这种”单一数据源”(Single Source of Truth)的设计避免了数据不一致的问题。

本章要点

  1. 声明式设置表getSettingsList())实现了”一处定义,多处使用”——设备 UI、Web API 和 JSON 持久化共用同一张设置描述表。新增设置只需加一行。
  2. 设置支持四种类型:Enum(枚举)、Toggle(开关)、Value(数值范围)、DynamicString(自定义读写逻辑)。
  3. 设置从二进制格式迁移到 JSON,获得了可读性、兼容性和可调试性。旧格式自动迁移,用户无感知。
  4. 密码混淆使用 MAC 地址 XOR + Base64 编码。不是加密,但对嵌入式场景足够——密码绑定到物理设备,SD 卡被拔走后无法直接读取明文。
  5. 国际化系统基于编译时生成的 const char* 数组,翻译查找是 O(1) 的数组索引操作。12+ 种语言的翻译数据存放在 Flash 中,零 RAM 占用。
  6. 设备端 UI 和 Web API 是操作设置的两个通道,它们共享同一个数据源(CrossPointSettings 结构体),修改即时同步。
  7. 声明式设计的核心价值:代码改动越少,引入 bug 的风险越低——在无法”热修复”的嵌入式环境中,这一点至关重要。

附录预告:附录 A 将以速查表的形式,汇总整本书中出现的所有关键设计模式——从 Activity 栈到 MAC XOR 混淆,从分块帧缓冲到链接器劫持,看看 380KB 的小芯片上藏了多少精妙的工程智慧。