第9章:阅读设置与持久化

"运筹帷幄之中,决胜千里之外"——看书想要舒服,不是靠临时调,而是把字体、字号、行距、边距这套配置提前摆布好,存下来、开机自动恢复。这章我们就看诸葛亮怎么在设置页上排兵布阵。

🪶

本章导师:诸葛亮

核心方法论:运筹帷幄——先摆布清楚六枚棋子,再决定谁进谁出、谁去谁留

「下棋要算三步:第一算棋子有多少,第二算每一步怎么走,第三算这盘棋下完怎么收——收完还要能重开。阅读设置也是三步:设置项有哪些(9.1)、交互怎么转(9.2)、退出怎么存、开机怎么恢复(9.3)。最后一问:这一页和主设置页、阅读页该怎么衔接(9.4)?算清楚了,棋就不会乱。」

9.1 阅读设置项:字体、字号、字重、行距、边距

阅读设置页面(READING_SETTINGS)是把"阅读体验"的五根旋钮集中在一起的子页面。README 的「阅读设置页面」一节把可选值列成一张表:字体可选 Default(内置)及 TF 卡 /fonts 目录下所有 .ttf / .otf 文件;字号24 / 28 / 32 / 36 / 40 / 44 / 48 px 七档;字重正常 / 中粗 / 粗体 三档;行距1.0x2.0x 六档;边距520 px 六档;最后一行是保存退出。上一章 8.4 已经见过字号和字重那两排数组,这里把它们放进完整的设置项枚举里,看它们如何被统一编号。

实现上,六个设置项就是 reading_settings.cpp 里的 SettingItem 枚举(SETTING_FONT_TYPESETTING_FONT_SIZESETTING_BOLDSETTING_LINE_SPACINGSETTING_MARGINSETTING_SAVE_EXIT),当前光标位置用一个 g_current_item 全局变量记录。这一章要特别留意两个嵌入式小技巧:一是行距档位用整数存——LINE_SPACINGS[] = {10, 12, 14, 16, 18, 20} 存的其实是"10 倍行距",10 代表 1.0x、12 代表 1.2x,等真正应用时才 / 10.0f 转成浮点,避免在无浮点单元的 MCU 上做无谓的浮点比较;二是中文标签以 UTF-8 转义字符串写在源码里——"\xe6\xad\xa3\xe5\xb8\xb8" 就是"正常",让源码在任意编码环境里都能编译出正确的中文。

绘制时,每行左边画标签、右边画当前值。值得看的是"光标"的画法:reading_settings_draw 先按布局公式算出行高——max_lh = (page_h - 30) / 17,保证 6 个设置项 + 标题 + 提示区能装进一屏;当前项前画一个实心小三角(fill_triangle),外面再套一个外框矩形(draw_rect)突出选中项。字号、行距、边距的值用 snprintf 拼成 < 32 px >< 1.4x > 这样的格式,字重还会在字体是可变字体时加一个 (VF) 后缀提示。

/* src/reading_settings.cpp:五根旋钮的档位数组(节选) */
/* 字号档位(像素) */
static const int FONT_SIZES[] = {24, 28, 32, 36, 40, 44, 48};
static const int FONT_SIZES_COUNT = sizeof(FONT_SIZES) / sizeof(FONT_SIZES[0]);

/* 加粗等级 */
static const int BOLD_LEVELS[] = {0, 1, 2};
static const char *BOLD_NAMES[] = {
    "\xe6\xad\xa3\xe5\xb8\xb8",    /* 正常 */
    "\xe4\xb8\xad\xe7\xb2\x97",    /* 中粗 */
    "\xe7\xb2\x97\xe4\xbd\x93"     /* 粗体 */
};

/* 行距档位(x10,避免浮点,10 = 1.0x, 12 = 1.2x) */
static const int LINE_SPACINGS[] = {10, 12, 14, 16, 18, 20};

/* 边距档位(像素) */
static const int MARGINS[] = {5, 8, 11, 14, 17, 20};

/* 设置项枚举 */
typedef enum {
    SETTING_FONT_TYPE = 0,
    SETTING_FONT_SIZE,
    SETTING_BOLD,
    SETTING_LINE_SPACING,
    SETTING_MARGIN,
    SETTING_SAVE_EXIT,
    SETTING_COUNT
} SettingItem;
/* src/reading_settings.cpp:绘制当前值(节选) */
case SETTING_FONT_TYPE: {
    char name_buf[32];
    utf8_truncate(name_buf, font_manager_get_name(g_font_type_idx), max_name_bytes);
    snprintf(val_buf, sizeof(val_buf), "< %s >", name_buf);
    break;
}
case SETTING_FONT_SIZE:
    snprintf(val_buf, sizeof(val_buf), "< %d px >", FONT_SIZES[g_font_size_idx]);
    break;
case SETTING_BOLD:
    if (epd_font_ft_is_variable_weight()) {
        snprintf(val_buf, sizeof(val_buf), "< %s (VF) >", BOLD_NAMES[g_bold_idx]);
    } else {
        snprintf(val_buf, sizeof(val_buf), "< %s >", BOLD_NAMES[g_bold_idx]);
    }
    break;
case SETTING_LINE_SPACING:
    snprintf(val_buf, sizeof(val_buf), "< %d.%dx >",
             LINE_SPACINGS[g_line_spacing_idx] / 10,
             LINE_SPACINGS[g_line_spacing_idx] % 10);
    break;
case SETTING_MARGIN:
    snprintf(val_buf, sizeof(val_buf), "< %d px >", MARGINS[g_margin_idx]);
    break;
设置项可选值代码档位应用方式
字体Default + /fonts 的 ttf/otffont_manager_get_count()font_manager_select(8.3)
字号24 / 28 / 32 / 36 / 40 / 44 / 48 pxFONT_SIZES[]epd_font_ft_set_size
字重正常 / 中粗 / 粗体BOLD_LEVELS[]epd_font_ft_set_bold
行距1.0x ~ 2.0x(六档)LINE_SPACINGS[](x10)epd_font_ft_set_line_spacing
边距5 / 8 / 11 / 14 / 17 / 20 pxMARGINS[]renderer->set_margin_left/right
保存退出SETTING_SAVE_EXITreading_settings_apply + save
诸葛亮提示

棋子先编号,后作战。六个设置项用一个枚举统一编号(0–5),后续 UP/DOWN、SELECT、持久化全都围着这个编号转——这就是"运筹"的第一层:先把维度定下来,再谈怎么动。你写任何菜单/表单类 UI 时,都该先问一句:选项的"编号表"建好了吗?

9.2 交互:UP/DOWN 切换项、SELECT 循环取值、保存退出

交互的规矩 README 一句话讲完了:UP / DOWN 在设置项之间上下移动;SELECT 在当前项上循环切换值;光标停在"保存退出"时按 SELECT 保存并退出。落到代码就是 reading_settings_handle_action 里对三种动作的 switch。UP/DOWN 走"减一 / 加一 + 取模":g_current_item = (g_current_item + 1) % SETTING_COUNT,到了 SETTING_COUNT - 1 再按 UP 会绕回 0——循环移动,光标永不越界。每次移动后立刻 reading_settings_draw(renderer) 重绘,返回 true 表示"还留在设置页"。

SELECT 是重头戏。它在当前项上把对应下标 +1 后再 % 档位数 循环:字号 g_font_size_idx = (g_font_size_idx + 1) % FONT_SIZES_COUNT,行距、边距同理,一直按就会绕回第一档。只有一项例外——光标在 SETTING_SAVE_EXIT 时,SELECT 会先检查 g_settings_changed(本次进入后有没有改过任何值),改过才调用 reading_settings_apply + reading_settings_save 落盘,然后 return false 通知上层"设置页结束、该回去了";没改过就跳过保存、直接退出。这个"变了才存"的小开关,避免用户进进出出反复写 TF 卡。

真正把新值"生米煮成熟饭"的是 reading_settings_apply。它把五根旋钮翻译成五条底层调用:字体变化最重——要先 renderer->show_busy() 显示忙碌图标,再 font_manager_select 切换(第 8 章讲过,失败返回非 0 时会保留当前字体并回退下标);其余四个直接调 epd_font_ft_set_sizeepd_font_ft_set_boldepd_font_ft_set_line_spacingrenderer->set_margin_left/right。注意行距在这里才从整数还原成浮点:LINE_SPACINGS[idx] / 10.0f。另外别漏了触控:reading_settings_drawadd_area() 为每一行注册了整行触控区域,reading_settings_set_current_item 供触控回调直接选中某一行——按键与触控(第 5 章)走同一条设置逻辑。

/* src/reading_settings.cpp:按键处理(节选) */
bool reading_settings_handle_action(Renderer *renderer, UIAction action)
{
    switch (action) {
    case UP:                       // 上移一项(循环)
        if (g_current_item > 0)
            g_current_item = (SettingItem)(g_current_item - 1);
        else
            g_current_item = (SettingItem)(SETTING_COUNT - 1);
        reading_settings_draw(renderer);
        return true;

    case DOWN:                     // 下移一项(循环)
        if (g_current_item < SETTING_COUNT - 1)
            g_current_item = (SettingItem)(g_current_item + 1);
        else
            g_current_item = (SettingItem)0;
        reading_settings_draw(renderer);
        return true;

    case SELECT:                   // 循环切换当前项的值
        switch (g_current_item) {
        case SETTING_FONT_TYPE:
            g_font_type_idx = (g_font_type_idx + 1) % font_count;
            g_settings_changed = true;
            g_font_changed = true;   // 字体切换单独记账
            break;
        case SETTING_FONT_SIZE:
            g_font_size_idx = (g_font_size_idx + 1) % FONT_SIZES_COUNT;
            g_settings_changed = true;
            break;
        /* SETTING_BOLD / LINE_SPACING / MARGIN 同理 ... */
        case SETTING_SAVE_EXIT:       // 光标停在"保存退出"
            if (g_settings_changed) {  // 变了才存
                reading_settings_apply(renderer);
                reading_settings_save();
                g_settings_changed = false;
                g_font_changed = false;
            }
            g_current_item = SETTING_FONT_TYPE;
            return false;               /* 退出设置页面 */
        default:
            break;
        }
        reading_settings_draw(renderer);
        return true;
    default:
        return true;
    }
}
/* src/reading_settings.cpp:应用设置(节选) */
void reading_settings_apply(Renderer *renderer)
{
    int new_size = FONT_SIZES[g_font_size_idx];
    float new_spacing = LINE_SPACINGS[g_line_spacing_idx] / 10.0f;
    int new_margin = MARGINS[g_margin_idx];
    int new_bold = BOLD_LEVELS[g_bold_idx];

    if (g_font_changed) {
        renderer->show_busy();                 // 切换字体较慢,先显示忙碌图标
        int ret = font_manager_select(g_font_type_idx, new_size);
        if (ret != 0) {
            // 失败:保留当前字体,下标回退
            g_font_type_idx = font_manager_get_current();
        }
    } else {
        epd_font_ft_set_size(new_size);
    }
    epd_font_ft_set_bold(new_bold);
    epd_font_ft_set_line_spacing(new_spacing);
    renderer->set_margin_left(new_margin);
    renderer->set_margin_right(new_margin);
}
动作在阅读设置页的行为返回值 / 效果
UP光标上移一项(到顶绕回"保存退出")重绘,返回 true
DOWN光标下移一项(到底绕回"字体")重绘,返回 true
SELECT(普通项)当前值下标 +1 再对档位数取模,循环切换g_settings_changed,重绘
SELECT(保存退出)有改动才 apply + save;无论有无都退出返回 false(退出设置页)
触控add_area 注册整行区域,点击选中对应项reading_settings_set_current_item 同步
诸葛亮提示

循环取值用"下标 +1 取模"而不是"值本身 +1":因为你操作的是"第几档"而不是"档位值",下标永远不会越界,后面持久化也是存下标对应的档位值。把"用户看到的档位"和"内部的下标"分开记账,是这类菜单的通用棋路。

9.3 持久化:/settings.cfg、开机加载与字体回退

持久化(persistence)的意思是:把用户这次的配置写到不掉电的存储里,下次开机还能读回来——在阅读器里,就是存到 TF 卡的 /settings.cfg。移植版选了一个最朴素的格式:key=value 的纯文本,一行一个键,结尾 \n 分隔。为什么不用 JSON?因为这里只有五个固定键,一个 snprintf 就能写完、一个 strtok 就能读完,还方便用户拔卡下来用电脑手工改。保存端是 reading_settings_saveopen(SETTINGS_FILE_PATH, O_WRONLY | O_CREAT | O_TRUNC) 打开(不存在就创建、打开即清空),把 font_name / font_size / bold / line_spacing / margin 五个键拼进一个 256 字节的缓冲,一次 write 写完再 close

加载端 reading_settings_load(Renderer *) 正好是逆过程:open(O_RDONLY) 读入,用 strtok(buf, "\n") 按行切,逐行用 strncmp 匹配前缀、atoi 取数字。读到五个值后,用 9.1 的 find_index 把数字"吸"回最近的档位下标——比如配置里 font_size=30 不在档位表里,就回退到默认下标 0(24px)。真正见功夫的是字体回退:先 find_font_by_name(font_name) 在字体表里找名字,找不到(比如这次没插 TF 卡、上次选的外部字体不在),日志打一条 Font not found, fallback to Default 并把 g_font_type_idx 归 0;找到的是内置字体(下标 0)就直接用;找到外部字体才 font_manager_select 真正加载,加载失败同样回退 0。字体这一步比较慢,加载时会先 renderer->show_busy()

调用时机在 main.cpp 的启动序列里,顺序很关键:board->start_filesystem() 先挂起 TF 卡,font_manager_init("/fonts") 扫描字体(第 8 章),然后立刻 reading_settings_load(renderer) 恢复上次的阅读设置,最后才进主循环画主页面。也就是说——用户上次调的字体、字号、行距、边距,在屏幕亮起来之前就已经生效了,你看到的第一页就是上次的样子。README 正是这么承诺的:"设置以 key=value 文本格式保存于 TF 卡 /settings.cfg,下次开机自动加载;若指定字体文件不可用,自动回退到内置默认字体。"文档与代码,一字不差。

/* src/reading_settings.cpp:保存到 /settings.cfg */
#define SETTINGS_FILE_PATH "/settings.cfg"

void reading_settings_save(void)
{
    int fd = open(SETTINGS_FILE_PATH, O_WRONLY | O_CREAT | O_TRUNC);
    if (fd < 0) {
        rt_kprintf("[settings] Cannot save to %s\n", SETTINGS_FILE_PATH);
        return;
    }
    char buf[256];
    int len = snprintf(buf, sizeof(buf),
                       "font_name=%s\n"
                       "font_size=%d\n"
                       "bold=%d\n"
                       "line_spacing=%d\n"
                       "margin=%d\n",
                       font_manager_get_name(g_font_type_idx),
                       FONT_SIZES[g_font_size_idx],
                       BOLD_LEVELS[g_bold_idx],
                       LINE_SPACINGS[g_line_spacing_idx],
                       MARGINS[g_margin_idx]);
    write(fd, buf, len);
    close(fd);
}
/* src/reading_settings.cpp:开机加载与字体回退(节选) */
/* 按行解析 key=value ... */
char *line = strtok(buf, "\n");
while (line) {
    char *cr = strchr(line, '\r');
    if (cr) *cr = '\0';                     // 兼容 Windows 换行
    if (strncmp(line, "font_name=", 10) == 0) {
        strncpy(font_name, line + 10, sizeof(font_name) - 1);
    } else if (strncmp(line, "font_size=", 10) == 0) {
        font_size = atoi(line + 10);
    }
    /* bold / line_spacing / margin 同理 ... */
    line = strtok(NULL, "\n");
}

/* 字体回退:找不到名字或加载失败 → Default(下标 0) */
int font_idx = find_font_by_name(font_name);
if (font_idx < 0) {
    rt_kprintf("[settings] Font '%s' not found, fallback to Default\n", font_name);
    g_font_type_idx = 0;                     // 内置 Default 永远兜底
} else if (font_idx == 0) {
    g_font_type_idx = 0;
} else {
    renderer->show_busy();
    int ret = font_manager_select(font_idx, FONT_SIZES[g_font_size_idx]);
    if (ret != 0) { g_font_type_idx = 0; }  // 加载失败同样回退
}
配置键含义示例缺失/异常时的行为
font_name选中的字体名font_name=songti.ttf找不到 → 回退 Default
font_size字号(像素)font_size=32不在档位表 → find_index 回默认
bold字重档位bold=1同上,回默认 0
line_spacing行距(x10 整数)line_spacing=16(1.6x)同上,回默认 12
margin边距(像素)margin=11同上,回默认 8
诸葛亮提示

持久化的"持久"二字,赢在兜底:数字对不上档位表就吸回最近档,字体找不到就退回内置,文件不存在就全用默认。三步兜底层层收口,无论卡里躺着什么"残缺配置",开机都不会白屏。写配置文件系统时,把"读回来一定合法"当作验收标准。

9.4 与主设置页的关系:两条入口、一套出口

阅读设置页不是独立王国,它有两条入口、且退出后"回到哪"要按来路决定。第一条入口来自功能设置页SETTINGS_PAGE):README 说"通过功能设置 → 阅读设置"进入。代码上,handleSettingsPage 的六个列表项里第 5 项是"阅读设置",用户选中后按 SELECT,函数 return 2main.cpp 收到返回值 2 就把 g_state_before_settings 记成 SETTINGS_PAGE,状态切到 READING_SETTINGS 并立刻画设置页。第二条入口更妙——阅读页的覆盖操作层(第 13 章会细讲)里也有一格"设置":阅读中触发 UPGLIDE 呼出覆盖层,选到设置格按 SELECT,代码先 reader->save_anchor(...) 把"读到哪一块、哪一行"存进锚点,再切进 READING_SETTINGS。所谓锚点(anchor),就是"阅读位置的书签"——因为改字号、行距、边距后整个排版都会重排,页码会变,必须记"块 + 行"而不是"第几页"。

为什么退出时要分来路?因为两条入口的"家"不一样。main.cppREADING_SETTINGS 分支这样收尾:reading_settings_handle_action 返回 false(用户按了保存退出)后,看 g_state_before_settings——若是 READING_EPUB,就删掉旧 reader、按锚点 restore_by_anchor(g_anchor_block, g_anchor_line) 重新定位并渲染,回到刚才读的那一行;若是 SETTINGS_PAGE,就回功能设置页继续改别的(比如触控开关、超时策略)。这个 g_state_before_settings 就是"来路记账",正是 README 说的"退出后自动恢复到当前阅读位置"。

把这一套串起来,就是本章的"帷幄":一个阅读设置页 + 五根旋钮 + 一个保存退出 + 一份 /settings.cfg + 两条入口 + 一套按来路返回的出口。设置项枚举是棋盘,g_current_item 是落子,g_state_before_settings 是后手记忆,reading_settings_save/load 是存棋谱——用户怎么折腾,棋都不会下乱。这也是为什么 8.3 的字体切换、这一章的行距边距,都要归口到"设置"这一根主线上:体验的每一处细节,都在一个地方被摆布。

/* src/main.cpp:设置页返回 2 → 进入阅读设置(节选) */
case SETTINGS_PAGE:
{
    int settings_result = handleSettingsPage(renderer, ui_action, needs_redraw);
    if (settings_result == 1) {
        ui_state = MAIN_PAGE;                    // 回主页
        handleMainPage(renderer, NONE, true);
    } else if (settings_result == 2) {
        g_state_before_settings = SETTINGS_PAGE; // 记账:从功能设置页来的
        ui_state = READING_SETTINGS;
        reading_settings_draw(renderer);
    }
    break;
}

/* 阅读设置页退出:按来路回到阅读页或功能设置页 */
case READING_SETTINGS:
{
    bool still_in_settings = reading_settings_handle_action(renderer, ui_action);
    if (!still_in_settings) {
        if (g_state_before_settings == READING_EPUB) {
            ui_state = READING_EPUB;             // 从阅读页来的 → 回阅读页
            delete reader;
            reader = nullptr;
            handleEpub(renderer, NONE);
            if (g_has_anchor && reader) {
                // 按锚点恢复阅读位置(排版已重排,页码不可靠)
                reader->restore_by_anchor(g_anchor_block, g_anchor_line);
                reader->render();
                g_has_anchor = false;
            }
        } else {
            ui_state = SETTINGS_PAGE;            // 从功能设置页来的 → 回去
            (void)handleSettingsPage(renderer, NONE, true);
        }
    }
    return;
}
/* src/main.cpp:阅读页覆盖操作层的"设置"格(节选) */
else if (sel == 11 || touch_sel == 11) // 设置(阅读设置)
{
    // 保存锚点:记住"读到哪一块、哪一行"
    reader->save_anchor(g_anchor_block, g_anchor_line);
    g_has_anchor = true;
    g_state_before_settings = READING_EPUB;      // 记账:从阅读页来的
    renderer->set_margin_bottom(0);
    reader->stop_overlay();
    ui_state = READING_SETTINGS;
    reading_settings_draw(renderer);
    touch_sel = -1;
    return;
}
入口触发路径来路记账保存退出后回到
功能设置页主页面 → 进入设置 → 阅读设置(SELECT)SETTINGS_PAGE功能设置页
阅读页覆盖层阅读页 UPGLIDE → 覆盖层"设置"格(SELECT)READING_EPUB + 锚点阅读页,锚点恢复位置
诸葛亮提示

"进"分两路,"退"也分两路——但判断只用一条规则:g_state_before_settings 里记的是什么就回哪。复杂功能最怕"多条入口、单一出口":只要进去之前先记账,出来时看账本行事,再多入口也不会迷路。这就是运筹帷幄在代码里的样子。

章末练习

练习 1:设置项盘点 入门

阅读设置页有几枚"棋子"(设置项)?各自的可选值是什么?哪一项在代码里是特殊处理的(不参与"循环取值")?

提示

对照 9.1 的表格:字体/字号/字重/行距/边距/保存退出;"保存退出"不是调值,而是触发保存与退出。

参考答案

六枚:字体(Default + /fonts)、字号(24–48px 七档)、字重(正常/中粗/粗体)、行距(1.0x–2.0x 六档)、边距(5–20px 六档)、保存退出。SETTING_SAVE_EXIT 特殊:SELECT 时不做"下标 +1 取模",而是有改动就 reading_settings_apply + reading_settings_save,然后 return false 退出页面。

练习 2:循环切换机制 进阶

为什么 SELECT 切换值要用"下标 +1 再对档位数取模"?行距为什么要用整数 x10 存储,而不是直接存 1.0 / 1.2 这样的浮点?g_settings_changed 起什么作用?

提示

取模保证下标永不出界、且能绕回第一档;行距 x10 避免在 MCU 上做浮点比较;g_settings_changed 让"没改过就不写 TF 卡"。

参考答案

用户操作的是"第几档"(下标),% FONT_SIZES_COUNT 等取模让下标永远落在合法区间并循环回绕。行距用 LINE_SPACINGS[]={10,12,...} 存"10 倍行距"的整数,应用时才 /10.0f 转浮点,规避在无浮点单元的 MCU 上频繁做浮点运算与比较。g_settings_changed 记录本次进入设置页后是否改过任何值,只有改过才在"保存退出"时写 /settings.cfg,避免无效的 TF 卡写入。

练习 3:持久化解析 进阶

假设 TF 卡 /settings.cfg 内容是 font_name=MyFont.ttffont_size=30bold=1line_spacing=15margin=9,但这次开机没插 TF 卡、/fonts 里没有 MyFont.ttf。逐项说明加载后各设置实际落到哪个值。

提示

find_index 会把不在档位表里的数字吸回最近合法档位的默认下标;find_font_by_name 找不到名字就回退下标 0(Default)。

参考答案

字号 30 不在 {24,28,32,...},find_index 返回默认下标 0 → 24px;行距 15 不在 {10,12,14,16,...} → 回默认下标 1 → 1.2x;边距 9 不在 {5,8,11,...} → 回默认下标 1 → 8px;字重 1 合法 → 中粗。字体 MyFont.ttf 因没插卡在字体表里找不到,find_font_by_name 返回 -1,打日志 "Font not found, fallback to Default",g_font_type_idx = 0 用内置 Default——三层兜底保证不白屏。

练习 4:锚点恢复 挑战

为什么从阅读页进阅读设置要记"块 + 行"的锚点,而不是记页码?改完字号/行距/边距返回阅读页时,代码做了哪几步?试想如果不做锚点恢复会怎样。

提示

改字号行距后整页重排,页码含义会变;流程是 save_anchor → 退出时 delete reader → 重新 handleEpub → restore_by_anchor + render。

参考答案

改字号、行距、边距会改变每一页能放多少行,整本书的分页都会重排,此时"第几页"已经失去意义,而"读到哪个文本块、第几行"是稳定标识,所以锚点记 block + line。退出时:delete reader 释放旧阅读器 → handleEpub 重新加载 → restore_by_anchor(g_anchor_block, g_anchor_line) 用锚点重定位 → reader->render() 渲染。若不做锚点恢复,用户改完设置回到阅读页会跳到错误位置甚至停在书开头,阅读体验完全断裂。