perf(reader): M31 字体路径记忆化(开书字体查找 35 目录 walk → 1 次 stat)+ 「· 读」徽标即时刷新

字体:find_font_rec/resolve_font 从 reader.c 抽到 app/font_resolve.c 并加进程内记忆化。
只缓存成功路径;命中后仍做一次 access() 校验(字库被删即重扫);没找到不缓存,
用户之后拷入的字库下次开书即可找到。顶栏「刷新」额外调 font_cache_invalidate(),
给「新拷入字库、旧字库仍在」一条显式失效路径。宿主实测:35 目录树 walk 0.026~0.54ms
(=300+ 次系统调用)vs 命中 0.001ms(1 次 access);开书路径每次省一次完整 walk。

徽标:副题格式化抽成 shelf_fmt_sub()(扫描与「刚读完置顶」共用一份实现),
promote_just_read() 改 ts 后立即重算这本的 sub/sub_folder,「· 读」不再等下次全量刷新。

回归:test_reader_core 6846、shelf_group_smoke 63(+8)、font_resolve_smoke 14 全绿;
build_reader.sh 0 warning;扫描缓存/进度语义未改。app.json 0.1.3/code4 → 0.1.4/code5。
This commit is contained in:
AskaEth committed 2026-11-10 13:39:32 +08:00
1 parent 0b30cdbafb
commit dc9bfdbdaf
12 files changed
+460 -53

No files matched your search

@@ -0,0 +1,109 @@
/* test/font_resolve_bench.c — M31 字体路径记忆化耗时对比(宿主,不依赖设备)。
*
* 同一棵树两种口径:
* walk = 每次都重扫(= 加缓存前的 resolve_font 行为),由 font_cache_invalidate() 强制;
* hit = 缓存命中 + access() 校验(= 加缓存后开书路径)。
* 用法:./font_resolve_bench_host [目录] [迭代数],不传目录则先造一棵 35 目录的树。
*/
#include "font_resolve.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
static void mkdirs(const char *path)
{
char t[512];
snprintf(t, sizeof t, "%s", path);
for (char *p = t + 1; *p; p++) {
if (*p == '/') { *p = 0; mkdir(t, 0755); *p = '/'; }
}
mkdir(t, 0755);
}
static void write_file(const char *path, const char *body)
{
FILE *f = fopen(path, "wb");
if (!f) return;
fputs(body, f);
fclose(f);
}
static void make_fonts_tree(const char *root, int ndirs, int files_per_dir)
{
char p[512];
for (int i = 0; i < ndirs; i++) {
snprintf(p, sizeof p, "%s/family_%02d/sub", root, i);
mkdirs(p);
for (int j = 0; j < files_per_dir; j++) {
snprintf(p, sizeof p, "%s/family_%02d/readme_%d.txt", root, i, j);
write_file(p, "x");
}
snprintf(p, sizeof p, "%s/family_%02d/sub/Font%02d.ttf", root, i, i);
write_file(p, "x");
}
}
static double ms_now(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1e6;
}
typedef struct stat_res { double min, avg, max; } stat_res;
static stat_res run(const char *root, int iters, int invalidate_each)
{
stat_res r = { 1e9, 0, 0 };
double sum = 0;
for (int i = 0; i < iters; i++) {
if (invalidate_each) font_cache_invalidate();
double t0 = ms_now();
(void)font_resolve(root);
double dt = ms_now() - t0;
if (dt < r.min) r.min = dt;
if (dt > r.max) r.max = dt;
sum += dt;
}
r.avg = iters ? sum / (double)iters : 0;
return r;
}
int main(int argc, char **argv)
{
const char *root = (argc > 1) ? argv[1] : NULL;
int iters = (argc > 2) ? atoi(argv[2]) : 200;
char tmpbuf[512] = { 0 };
if (!root) {
const char *base = getenv("TMPDIR");
if (!base || !base[0]) base = "/tmp";
snprintf(tmpbuf, sizeof tmpbuf, "%s/fontbench_XXXXXX", base);
if (!mkdtemp(tmpbuf)) { printf("mkdtemp 失败\n"); return 2; }
make_fonts_tree(tmpbuf, 35, 3);
root = tmpbuf;
}
/* 预热(首轮含 dentry 冷缓存,不计入) */
font_cache_invalidate(); font_resolve(root);
font_resolve(root);
const char *p = font_resolve(root);
printf("root=%s\n", root);
printf("font=%s\n", p ? p : "(none)");
stat_res w = run(root, iters, 1); /* 每次失效 = 旧行为:每次开书重 walk */
stat_res h = run(root, iters, 0); /* 缓存命中 + access 校验 = 新行为 */
printf("walk (每次重扫): min=%.3f avg=%.3f max=%.3f ms\n", w.min, w.avg, w.max);
printf("hit (缓存+access): min=%.3f avg=%.3f max=%.3f ms\n", h.min, h.avg, h.max);
if (h.avg > 0) printf("提速: %.1fx\n", w.avg / h.avg);
if (tmpbuf[0]) {
char cmd[600];
snprintf(cmd, sizeof cmd, "rm -rf '%s'", tmpbuf);
if (system(cmd) != 0) { /* 忽略 */ }
}
return 0;
}
@@ -0,0 +1,162 @@
/* test/font_resolve_smoke.c — M31 字体路径记忆化宿主单测(纯 POSIX,不依赖设备/SD 卡)。
*
* 覆盖缓存的三条红线:
* F1 没找到 → NULL,且**不缓存失败**:之后拷入字库,下一次调用就能找到;
* F2 命中 → 后续调用返回同一路径;
* F3 命中后字库被删/改名 → access() 校验失败 → 重扫,立刻回到「没有字库」的真实答案;
* 重新放回后又能找到(缓存不会一直错下去)。
* 另测与加缓存前一致的扫描语义(深度上限、首个字库、忽略隐藏文件/非字体扩展名)。
*
* 用法:./font_resolve_smoke_host [临时目录],默认用 TMPDIR。
*/
#include "font_resolve.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
static int g_pass = 0, g_fail = 0;
#define CHECK(cond, msg) do { \
if (cond) g_pass++; \
else { g_fail++; printf("FAIL %s:%d %s\n", __FILE__, __LINE__, msg); } \
} while (0)
static void mkdirs(const char *path)
{
char t[512];
snprintf(t, sizeof t, "%s", path);
for (char *p = t + 1; *p; p++) {
if (*p == '/') { *p = 0; mkdir(t, 0755); *p = '/'; }
}
mkdir(t, 0755);
}
static void write_file(const char *path, const char *body)
{
FILE *f = fopen(path, "wb");
if (!f) { printf("无法创建 %s\n", path); exit(2); }
fputs(body, f);
fclose(f);
}
/* 真机形态:MeowOS/fonts 下 35 个目录,每个目录若干非字体文件 + 1 个字体。
* 字体放在子目录里,让「首个命中」落在 walk 较深处(接近 survey 的真机耗时场景)。 */
static void make_fonts_tree(const char *root, int ndirs)
{
char p[512];
for (int i = 0; i < ndirs; i++) {
snprintf(p, sizeof p, "%s/family_%02d/sub", root, i);
mkdirs(p);
for (int j = 0; j < 3; j++) {
snprintf(p, sizeof p, "%s/family_%02d/readme_%d.txt", root, i, j);
write_file(p, "x");
}
snprintf(p, sizeof p, "%s/family_%02d/sub/Font%02d.ttf", root, i, i);
write_file(p, "x");
}
}
int main(void)
{
const char *base = getenv("TMPDIR");
if (!base || !base[0]) base = "/tmp";
char tmpl[512];
snprintf(tmpl, sizeof tmpl, "%s/font_resolve_XXXXXX", base);
char *tmp = mkdtemp(tmpl);
if (!tmp) { printf("mkdtemp 失败\n"); return 2; }
char root[512];
snprintf(root, sizeof root, "%s/fonts", tmp);
mkdirs(root);
/* F1 空目录:没找到,且失败不缓存 */
CHECK(font_resolve(root) == NULL, "空目录 → NULL");
CHECK(font_resolve(root) == NULL, "紧接着再调仍 NULL(失败未缓存成「找到」)");
/* F1b 空目录之后再拷入字库:下一次调用就能找到(「用户后来放了字库」) */
char f1[512];
snprintf(f1, sizeof f1, "%s/late.ttf", root);
write_file(f1, "x");
const char *p = font_resolve(root);
CHECK(p && strcmp(p, f1) == 0, "失败后拷入字库 → 下次调用即找到");
/* F2 命中:连续调用返回同一路径(缓存生效,不重新 walk) */
const char *q = font_resolve(root);
CHECK(q && strcmp(q, f1) == 0, "命中后返回同一路径");
/* 命中期间新增字库不改选(与「取 readdir 顺序第一个」的既有语义一致;
* 显式换字体走顶栏「刷新」→ font_cache_invalidate) */
char f1b[512];
snprintf(f1b, sizeof f1b, "%s/aaa.otf", root);
write_file(f1b, "x");
CHECK(font_resolve(root) && strcmp(font_resolve(root), f1) == 0,
"命中期间新增字库不改选(保持一致,刷新可强制重选)");
/* F3 字库被删:access() 校验失败 → 重扫 → NULL(不许「明明没有却说有」,也不许反过来卡死) */
unlink(f1);
unlink(f1b);
CHECK(font_resolve(root) == NULL, "缓存路径被删 → 下一次调用即重扫得到 NULL");
/* F3b 放回不同名的字库:仍能找到(缓存失效状态不会卡住) */
char f3[512];
snprintf(f3, sizeof f3, "%s/back.ttf", root);
write_file(f3, "x");
p = font_resolve(root);
CHECK(p && strcmp(p, f3) == 0, "删掉后放回字库 → 重新找到");
/* F4 多目录树:首个命中 = walk 顺序第一个(35 个目录时的行为与缓存前一致) */
char big[512];
snprintf(big, sizeof big, "%s/bigfonts", tmp);
make_fonts_tree(big, 35);
font_cache_invalidate(); /* 换树前清缓存(等价于顶栏刷新) */
const char *b = font_resolve(big);
CHECK(b && strstr(b, "/family_") != NULL && strcmp(b + strlen(b) - 4, ".ttf") == 0,
"35 目录树 → 返回字体文件路径");
const char *b2 = font_resolve(big);
CHECK(b2 && strcmp(b2, b) == 0, "大树上命中后路径稳定");
/* F5 深度上限(相对 root:目录深度 > 3 的字库不收,与缓存前一致) */
char dfroot[512];
snprintf(dfroot, sizeof dfroot, "%s/deepfonts", tmp);
char deep[512];
snprintf(deep, sizeof deep, "%s/a/b/c/d", dfroot);
mkdirs(deep);
char dp[512];
snprintf(dp, sizeof dp, "%s/too_deep.ttf", deep); /* root/a/b/c/d/ → 深度 4 */
write_file(dp, "x");
font_cache_invalidate();
CHECK(font_resolve(dfroot) == NULL, "深度 > 3 的字库不收(与缓存前扫描口径一致)");
/* 第 3 层仍收 */
char ok3[512];
snprintf(ok3, sizeof ok3, "%s/a/b/c/ok3.ttf", dfroot); /* root/a/b/c/ → 深度 3 */
write_file(ok3, "x");
font_cache_invalidate();
p = font_resolve(dfroot);
CHECK(p && strcmp(p, ok3) == 0, "第 3 层目录的字库收(深度上限边界)");
/* F6 忽略隐藏文件/非字体扩展名 */
char hid[512];
snprintf(hid, sizeof hid, "%s/hidden", tmp);
mkdirs(hid);
char hp[512];
snprintf(hp, sizeof hp, "%s/.hidden.ttf", hid);
write_file(hp, "x");
snprintf(hp, sizeof hp, "%s/notafont.txt", hid);
write_file(hp, "x");
font_cache_invalidate();
CHECK(font_resolve(hid) == NULL, "隐藏文件/非字体扩展名不被当作字库");
/* F7 root 非法 */
font_cache_invalidate();
CHECK(font_resolve(NULL) == NULL && font_resolve("") == NULL, "root=NULL/空 → NULL");
char missing[512];
snprintf(missing, sizeof missing, "%s/no_such_dir", tmp);
CHECK(font_resolve(missing) == NULL, "root 不存在 → NULL");
printf("font_resolve_smoke: %d pass, %d fail\n", g_pass, g_fail);
char cmd[600];
snprintf(cmd, sizeof cmd, "rm -rf '%s'", tmp);
if (system(cmd) != 0) { /* 忽略 */ }
return g_fail ? 1 : 0;
}
@@ -11,6 +11,8 @@
* G7 超上限不静默:truncated=1(含「cap 卡在目录边界」与「恰好装满不误报」两种);
* 行数上限(段头 ≤ 书数 ⇒ 2N 必够)
* G8 进度不受分组影响:path 不变、rdprog_load 前后同值、ts 两种模式一致
* G9 副题格式化的唯一实现 shelf_fmt_sub:扫描与「刚读完置顶」共用
* (ts 变化后重算 → 「· 读」立即出现;folder 子路径前缀随 group 重建)
*/
#include "shelf_model.h"
#include "progress.h"
@@ -297,6 +299,48 @@ static void test_scan_and_modes(const char *root, const char *tmproot)
CHECK(shelf_copy_utf8(dst, 96, ascii) == 95, "ASCII 90+ 字节按 cap-1 截断");
}
/* G9(M31):副题格式化的唯一实现 —— 扫描与「刚读完置顶」共用 shelf_fmt_sub。
* 置顶只改 ts 不重算副题时,「· 读」要等一次全量刷新才出现(M30 遗留),这里锁住语义。 */
static void test_fmt_sub(void)
{
shelf_book b;
memset(&b, 0, sizeof b);
strcpy(b.title, "t");
b.fmt = 0; b.size = 5;
shelf_fmt_sub(&b);
CHECK(strcmp(b.sub, "TXT · 5B") == 0, "无进度副题不含「读」");
CHECK(strcmp(b.sub_folder, b.sub) == 0, "根下书两种副题相同");
/* 刚读完:ts 从 0 变正 → 重算后「· 读」立即出现(不等全量刷新) */
b.ts = 100;
shelf_fmt_sub(&b);
CHECK(strcmp(b.sub, "TXT · 5B · 读") == 0, "ts>0 重算后副题含「· 读」");
CHECK(strcmp(b.sub_folder, b.sub) == 0, "根下书重算后两种副题仍相同");
/* folder 模式:子路径前置,其余与默认副题一致 */
shelf_book f;
memset(&f, 0, sizeof f);
strcpy(f.title, "f");
strcpy(f.group, "a/b");
f.fmt = 2; f.size = 4 * 1024;
shelf_fmt_sub(&f);
CHECK(strcmp(f.sub, "EPUB · 4KB") == 0, "folder 书的默认副题不带子路径");
CHECK(strcmp(f.sub_folder, "b · EPUB · 4KB") == 0, "folder 副题 = 子路径 · 默认副题");
f.ts = 7;
shelf_fmt_sub(&f);
CHECK(strcmp(f.sub_folder, "b · EPUB · 4KB · 读") == 0,
"folder 副题重算后「· 读」出现在末尾");
/* 一级目录书:rel 无 '/' → 两种副题相同 */
shelf_book g;
memset(&g, 0, sizeof g);
strcpy(g.group, "轻小说");
g.fmt = 3; g.size = 100;
shelf_fmt_sub(&g);
CHECK(strcmp(g.sub, "MOBI · 100B") == 0 && strcmp(g.sub_folder, g.sub) == 0,
"一级目录书无子路径前缀");
}
static void test_progress_stability(const char *root)
{
/* G8:写一份进度,切换分组模式前后 rdprog_load 必须同值,path 不变。 */
@@ -382,6 +426,7 @@ int main(void)
mkdirs(root);
test_scan_and_modes(root, tmp);
test_fmt_sub();
test_progress_stability(root);
test_conf_default(tmp);