From 62826e1d136bb2af753b6e209051ff21aae4d68b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 6 Nov 2026 13:10:01 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E9=98=85=E8=AF=BB?= =?UTF-8?q?=E5=99=A8=E4=B8=A4=E7=BA=A7=E6=B8=85=E6=99=B0=E5=BA=A6=EF=BC=8C?= =?UTF-8?q?=E5=85=88=E4=BD=8E=E6=A1=A3=E4=B8=8A=E5=B1=8F=E3=80=81=E5=81=9C?= =?UTF-8?q?=E7=A8=B3=E5=86=8D=E6=8D=A2=E6=B8=85=E6=99=B0=EF=BC=88M31?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机 1280x2275 单页解码 392ms,首次看某页有一瞬加载感。改成: - 大页(JPEG ≥1MP)先按显示长边一半的档位(真机页=step 4)解一版低档, 精确落到显示框后立刻上屏(换图仍是单次原生 blit,不闪白、不重置缩放/滚动); - 停稳 200ms 后才解目标档位(step 2),在**同一槽**原地替换低档,低档随即释放, 常驻不翻倍;高档失败/预算跳过时保留低档,不把整页标成「内存不足」; - 拖动/连翻/条漫滚动/跳页在途期间只解低档;高档请求复用 M22 的 gen/窗口 stale 判据作废(只多一条 sharpen 谓词),出队前/后两条路径都计入取消; - 日志尾部新增 tier=low|high(取消行同),真机可直接核对先糊后清。 分档只给 JPEG:step 是 JPEG 缩解概念,PNG/BMP 后端按目标逐行丢行本来就不慢, 且峰值模型对非 JPEG 按「输出=显示框」建模,多一条小图→落框路径会让模型低估 (不新增解码路径,也不改 sdk/ 与 page_source 的唯一模型)。 宿主回归: - test_reader_membudget B10:JPEG 低档先落位(open 不阻塞)→ 高档落同槽 (槽内字节不变、槽位全程不空)→ 拖动中强塞的高档请求被取消且不被解码; - B8 连翻 100 页(6 类 1.3–12MP 循环)峰值增量 2714KB ≤3MB、常驻 ≤3MB, 低档/高档都确实跑过;B3 断言小页保持单档(不付两次解码); - test_reader_perf P11:非 JPEG 大页不分档,仍是一次原生 blit。 --- tuku/test/test_reader_membudget.c | 121 +++++++++- tuku/test/test_reader_perf.c | 46 +++- tuku/ui/reader/reader.c | 380 ++++++++++++++++++++++++++---- tuku/ui/reader/reader.h | 17 ++ 4 files changed, 513 insertions(+), 51 deletions(-) diff --git a/tuku/test/test_reader_membudget.c b/tuku/test/test_reader_membudget.c index e83cae6..3765b1c 100644 --- a/tuku/test/test_reader_membudget.c +++ b/tuku/test/test_reader_membudget.c @@ -17,7 +17,10 @@ * B7b 回退路径:CMYK / Adam7 PNG / 1bpp BMP 走 stb,按旧门控占位 * B7c P1-1 旁路回归:截断 12MP JPEG / 3000×3000 24bpp 头-only BMP(判定 backend, * 后端失败必须 NULL、峰值 ≤3MB)/ 非法位深 PNG(矩阵拒绝→stb 占位) - * B8 M27:连翻 30 页(6 类循环)不掉页、不占位、任何时刻存活增量 ≤3MB + * B8 M27/M31:连翻 100 页(6 类循环)不掉页、不占位、任何时刻存活增量 ≤3MB; + * 分档页低档/高档都确实跑过(M31:先低后清不把峰值/常驻翻倍) + * B10 M31:两级清晰度生命周期(JPEG 大页):低档先落位(open 不阻塞)→ 停稳后高档落 + * 同一槽(槽内字节不变、槽位不空)→ 拖动中强塞的高档请求被取消通道作废 * B9 M27:CMYK 回退页集成 —— 占位可重试且不崩 * * 夹具:BMP/PNG 小夹具由本文件用 C 写;JPEG/PNG/BMP 大夹具由 python3+Pillow 生成 @@ -591,6 +594,9 @@ int main(void) mr_reader_ui_failed_pages()); /* 取消/预取纪律:100 次翻页的解码次数必须有界(M20 实测曾 1.75× 浪费)。 */ CHECK(decodes <= 100 * 2, "decode count bounded over 100 flips (%d)", decodes); + /* M31:小页(480×640=0.3MP)不分档——低档只会多一次解码 + 落框,纯开销。 */ + CHECK(mr_reader_ui_low_decodes() == 0, + "small pages stay single-tier (%d low decodes)", mr_reader_ui_low_decodes()); meow_view_page_pop(); pump_ms(200); } @@ -762,7 +768,10 @@ int main(void) printf(" SKIP jpeg assertions: Pillow unavailable (python3 -c 'import PIL')\n"); } - /* ---- B8 M27:连翻 30 页(6 类典型页循环)——不掉页、不占位、峰值 ≤3MB ---- */ + /* ---- B8 M27/M31:连翻 100 页(6 类典型页循环)——不掉页、不占位、峰值不增长 ---- + * M31 起这些 1.3–12MP 的页走两级清晰度(低档先上屏、停稳换高档),所以这条同时是 + * 「分档不把峰值/常驻翻倍」的回归:低档与高档在同一槽原地替换,任何时刻的存活增量 + * 仍按单页解码峰值封顶。 */ if (have_jpeg && mixed_pages == 30) { mr_reader_ui_test_set_mem(4096, 16000); mr_reader_open_dir(mixed); @@ -772,11 +781,12 @@ int main(void) int advanced = 0, skipped_max = 0; size_t live_steady = g_live; int d0 = mr_reader_ui_decode_count(); + int low0 = mr_reader_ui_low_decodes(), high0 = mr_reader_ui_high_decodes(); /* P2-3:只在窗口稳定后 reset 一次,之后不再 reset——这样淘汰旧图不会掩盖 - * 「分配新图 + 解码」的真实峰值;指标 = 整个 30 翻窗口内相对稳态基线的最大存活增量。 + * 「分配新图 + 解码」的真实峰值;指标 = 整个 100 翻窗口内相对稳态基线的最大存活增量。 * (旧实现每翻页 reset 一次,淘汰把基线拉低,报告里的 536KB 是伪值。) */ size_t base = peak_reset(); - for (int i = 0; i < 30; i++) { + for (int i = 0; i < 100; i++) { int before = mr_reader_ui_page(); tap(i % 2 == 0 ? 40 : w - 40, 300); pump_ms(220); @@ -785,20 +795,117 @@ int main(void) skipped_max = mr_reader_ui_skipped_pages(); } size_t transient = g_peak > base ? g_peak - base : 0; - INFO("30 mixed flips: advanced=%d skipped=%d decodes=%d steady=%zuKB " + INFO("100 mixed flips: advanced=%d skipped=%d decodes=%d (low=%d high=%d) steady=%zuKB " "resident=%ldKB max_live_delta=%zuKB", advanced, skipped_max, mr_reader_ui_decode_count() - d0, + mr_reader_ui_low_decodes() - low0, mr_reader_ui_high_decodes() - high0, live_steady / 1024, mr_reader_ui_resident_kb(), transient / 1024); - CHECK(advanced == 30, "30 页全部翻到 (%d/30)", advanced); + CHECK(advanced == 100, "100 页全部翻到 (%d/100)", advanced); CHECK(skipped_max == 0, "典型页连翻无占位 (max=%d)", skipped_max); CHECK(mr_reader_ui_failed_pages() == 0, "无永久失败页 (%d)", mr_reader_ui_failed_pages()); + CHECK(mr_reader_ui_low_decodes() > low0 && mr_reader_ui_high_decodes() > high0, + "大页确实走了两级(low +%d, high +%d)", + mr_reader_ui_low_decodes() - low0, mr_reader_ui_high_decodes() - high0); + CHECK(mr_reader_ui_resident_kb() <= mr_reader_ui_resident_cap_kb(), + "常驻仍在上限内 (%ldKB ≤ %ldKB)", mr_reader_ui_resident_kb(), + mr_reader_ui_resident_cap_kb()); CHECK(transient <= 3 * 1024 * 1024, - "30 页连翻任何时刻存活增量 ≤3MB (%zuKB)", transient / 1024); + "100 页连翻任何时刻存活增量 ≤3MB (%zuKB)", transient / 1024); meow_view_page_pop(); pump_ms(200); } + /* ---- B10 M31:两级清晰度生命周期(2000×3000 JPEG,真机同类大页)---- + * 1) 冻结停稳窗口 → 只解低档:焦点页 tier=low、常驻=显示框大小(仍是一次原生 blit), + * 主线程不等解码(open 立即返回); + * 2) 放开停稳窗口 → 高档落同一槽:tier=high、槽内字节不变(不翻倍常驻)、槽位全程不空; + * 3) 拖动中强塞的高档请求 → 被 M22 的 gen/窗口取消通道作废(skip=cancel),不会被解出来。 */ + if (have_jpeg) { + char tierbook[MR_PATH_MAX]; + snprintf(tierbook, sizeof tierbook, "%s/tier", root); + int tier_ok = mkdir_p(tierbook) == 0; + for (int i = 0; tier_ok && i < 2; i++) { + char src[MR_PATH_MAX], dst[MR_PATH_MAX]; + snprintf(src, sizeof src, "%s/base2000.jpg", jpgbook); + snprintf(dst, sizeof dst, "%s/t%d.jpg", tierbook, i + 1); + if (copy_file(src, dst) != 0) tier_ok = 0; + } + if (tier_ok) { + mr_reader_ui_test_set_mem(4096, 16000); + mr_reader_ui_test_set_sharpen_ms(60000); /* 冻结:先验证低档先落位 */ + uint64_t t_open = (uint64_t)meow_ui_time_ms(); + mr_reader_open_dir(tierbook); + uint64_t open_ms = (uint64_t)meow_ui_time_ms() - t_open; + CHECK(mr_reader_ui_count() == 2, "tier book pages=%d", mr_reader_ui_count()); + CHECK(open_ms < 500, "open is non-blocking (%llu ms)", + (unsigned long long)open_ms); + pump_ms(800); + meow_ui_paint_full(); + int page0 = mr_reader_ui_page(); + CHECK(mr_reader_ui_focus_tier() == MR_TIER_LOW, + "low tier lands first (tier=%d)", mr_reader_ui_focus_tier()); + CHECK(mr_reader_ui_low_decodes() >= 1, "low decode issued (%d)", + mr_reader_ui_low_decodes()); + CHECK(mr_reader_ui_high_decodes() == 0, + "no high decode before the settle window (%d)", mr_reader_ui_high_decodes()); + long low_kb = mr_reader_ui_slot_kb(page0); + CHECK(low_kb > 0, "low tier image resident (%ldKB)", low_kb); + int bl = mr_reader_ui_blit_draws(), sc = mr_reader_ui_scaled_draws(); + meow_ui_paint_full(); + CHECK(mr_reader_ui_blit_draws() > bl && mr_reader_ui_scaled_draws() == sc, + "low tier draws as native blit (blit +%d, scaled +%d)", + mr_reader_ui_blit_draws() - bl, mr_reader_ui_scaled_draws() - sc); + + /* 放开升级:边等边采样,焦点槽任何时刻都不能空(不闪白)。 */ + int high0 = mr_reader_ui_high_decodes(); + long min_slot = low_kb; + mr_reader_ui_test_set_sharpen_ms(0); + for (int i = 0; i < 100 && mr_reader_ui_focus_tier() != MR_TIER_HIGH; i++) { + pump_ms(10); + long k = mr_reader_ui_slot_kb(page0); + if (k > 0 && k < min_slot) min_slot = k; + } + meow_ui_paint_full(); + CHECK(mr_reader_ui_page() == page0, "tier swap keeps the page (%d -> %d)", + page0, mr_reader_ui_page()); + CHECK(mr_reader_ui_focus_tier() == MR_TIER_HIGH, + "high tier replaces low in the same slot (tier=%d)", + mr_reader_ui_focus_tier()); + CHECK(mr_reader_ui_high_decodes() > high0, + "high decode issued after settle (%d -> %d)", high0, + mr_reader_ui_high_decodes()); + long high_kb = mr_reader_ui_slot_kb(page0); + CHECK(high_kb > 0 && high_kb <= low_kb + 8, + "same-slot swap: one page resident, not two (%ldKB -> %ldKB)", low_kb, + high_kb); + CHECK(min_slot > 0, "no blank frame while swapping tiers (min slot %ldKB)", + min_slot); + CHECK(mr_reader_ui_scaled_draws() == sc, + "high tier still native blit (scaled %d -> %d)", sc, + mr_reader_ui_scaled_draws()); + + /* 拖动中取消高档:拖动期间强塞一个「换清晰」请求,必须被取消通道作废。 */ + int hd0 = mr_reader_ui_high_decodes(), hc0 = mr_reader_ui_high_canceled(); + reader_on_touch_down(240, 400); + reader_on_touch_move(180, 400); /* 进入拖动 → g_sharpen_idx=-1 */ + CHECK(mr_reader_ui_sharpen_idx() == -1, "drag clears sharpen eligibility"); + mr_reader_ui_test_queue_high(page0); + pump_ms(200); + CHECK(mr_reader_ui_high_canceled() > hc0, + "high request canceled while dragging (%d -> %d)", hc0, + mr_reader_ui_high_canceled()); + CHECK(mr_reader_ui_high_decodes() == hd0, + "canceled high request is never decoded (%d -> %d)", hd0, + mr_reader_ui_high_decodes()); + reader_on_tap(240, 400); /* 松手:回弹,不翻页 */ + pump_ms(300); + mr_reader_ui_test_set_sharpen_ms(200); /* 恢复产品默认停稳窗口 */ + meow_view_page_pop(); + pump_ms(200); + } + } + /* ---- B9 M27:回退路径集成 —— CMYK 页占位(可重试)且不崩 ---- */ if (have_fallback) { mr_reader_ui_test_set_mem(4096, 16000); diff --git a/tuku/test/test_reader_perf.c b/tuku/test/test_reader_perf.c index 5a84afc..9692da2 100644 --- a/tuku/test/test_reader_perf.c +++ b/tuku/test/test_reader_perf.c @@ -15,7 +15,12 @@ * P5 「先点适高,再切条漫」:scaled 不增长(评审点名的第二入口); * P6 双击放大再双击缩回:缩回后按新几何重解 → scaled 不增长(P2-1); * P7 静止若干帧不产生新解码(无重解抖动/活锁,P2-2); - * P8 驻留页图有字节上界(<= 9MB)。 + * P8 驻留页图有字节上界(<= 9MB); + * P9 800×1280(评审表格另一档):翻页 + 条漫都必须零重采样; + * P10 大屏(1080×1920):fit 目标被字节上限钳住后不得反复重解; + * P11 分档策略(M31):非 JPEG 大页不分档(一次解到目标档、仍是原生 blit); + * 低档先落位→停稳换清晰→拖动取消的完整生命周期在 test_reader_membudget 的 B10 + * 用真实 JPEG 夹具覆盖。 * * 编译:test/run_tests.sh(与 test_open_book_host 同口径;只发布 meow_ui.a 时 SKIP)。 */ @@ -368,6 +373,45 @@ int main(void) "800x1280 webtoon: zero resample (scaled %d -> %d)", st, mr_reader_ui_scaled_draws()); + /* ---- P11 分档策略(M31):非 JPEG 大页不分档——一次解到目标档,仍是原生 blit ---- + * 低档/高档的完整生命周期(低档先落位→停稳换清晰→拖动取消)在 test_reader_membudget + * 的 B10 用真实 JPEG 夹具覆盖;这里只钉住「分档只给 JPEG」这条策略,防止把 PNG/BMP + * 也拖进未校准的「小图→落框」峰值路径。 */ + { + char book3[MR_PATH_MAX]; + snprintf(book3, sizeof book3, "./mr_perf_books_%ld/v3", (long)getpid()); + int ok3 = mkdir_p(book3) == 0; + for (int i = 0; ok3 && i < 2; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/t%d.bmp", book3, i + 1); + /* 1600×2400 = 3.84MP:量级与真机大页相同,但格式不是 JPEG。 */ + if (write_bmp24(p, 1600, 2400, (unsigned)(i + 41)) != 0) ok3 = 0; + } + if (!ok3) { + printf(" SKIP: tier fixture write failed\n"); + } else { + meow_view_page_pop(); /* 关掉 book2 */ + uint64_t t_open = meow_ui_time_ms(); + mr_reader_open_dir(book3); + uint64_t open_ms = meow_ui_time_ms() - t_open; + CHECK(mr_reader_ui_count() == 2, "tier book pages=%d", mr_reader_ui_count()); + CHECK(open_ms < 500, "open returns without decoding on the UI thread (%llu ms)", + (unsigned long long)open_ms); + pump_ms(600); + draw_once(); + CHECK(mr_reader_ui_focus_tier() == MR_TIER_HIGH, + "non-JPEG big page decodes straight to the target tier (tier=%d)", + mr_reader_ui_focus_tier()); + CHECK(mr_reader_ui_low_decodes() == 0, + "no low tier for PNG/BMP (%d low decodes)", mr_reader_ui_low_decodes()); + int bl = mr_reader_ui_blit_draws(), sc = mr_reader_ui_scaled_draws(); + draw_once(); + CHECK(mr_reader_ui_blit_draws() > bl && mr_reader_ui_scaled_draws() == sc, + "big BMP page draws as native blit (blit +%d, scaled +%d)", + mr_reader_ui_blit_draws() - bl, mr_reader_ui_scaled_draws() - sc); + } + } + meow_view_page_pop(); meow_view_free(reader); meow_view_free(lib); diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index ded7039..d739ef7 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -24,6 +24,10 @@ * 按 stb 真实分配模型算峰值,超 3MB 占位 / MemAvailable <6MB 占位。 * 占位可重试,不是永久失败。余量确实充足(MemFree 富余)时允许放宽到 12MB。 * 页数增长不会线性吃内存(常驻只与解码窗大小有关)。 + * 手感(M31):JPEG 大页(≥1MP)先按「显示长边一半」的档位解一版低档并立刻上屏,停稳 + * MR_SHARPEN_MS 后才解目标档位、在同一槽原地替换(两档都落满显示框 → 换图仍是单次 blit, + * 不闪白、不重置缩放/滚动)。拖动/连翻/条漫滚动/跳页在途期间只解低档;高档请求复用 M22 的 + * gen/窗口取消通道作废。每页解码日志尾部带 tier=low|high 便于真机核对「先糊后清」。 * 方向:一律读 M3 的 mr_reader_get_direction()(默认 RTL),不另立全局。 * * 模式(M7):翻页/条漫是一等公民。阅读 chrome 的图标按钮一键切换;每册的模式与 last page @@ -31,7 +35,7 @@ * 自动恢复。书库列表是文件管理器模型(M18),不再显示续读角标。 * * 跳页:chrome 内 meow_slider 选目标页,松手后先把目标页解码进缓存再落位(不白屏不闪烁; - * 2.5s 超时则降级落位并提示)。 + * 目标页的低档落位即可跳过去,停稳后原地换清晰;2.5s 超时则降级落位并提示)。 * * 册间衔接:末页前进 → 同父目录下一个含图册(书库自然序)的首页;首页后退 → 上一册末页; * 无相邻册时停住并轻提示(不误触退出)。条漫在条顶/条底继续拉 MR_BOOK_PULL 像素松手触发。 @@ -85,6 +89,19 @@ #define MR_RESIDENT_CAP (3L * 1024 * 1024) /* 页图缓存总字节上限(M22 由 9MB 校准为 3MB) */ #define MR_SLOT_TOL 2 /* 缓存图与显示框允许的像素偏差(≤2 走原生 blit,不重采样) */ +/* ---- 两级清晰度(M31):先低档秒出,停稳再换高档 ---- + * 大页(真机 1280×2275 单页解码 392ms)打开/翻到时先按「显示长边的一半」解一版低档, + * 落框后立刻上屏;停稳 MR_SHARPEN_MS 后再解目标档位替换同一槽。低档与高档都**精确落到 + * 显示框**,所以换图那一下尺寸不变:仍是一次原生 blit,不闪白、不重置缩放/滚动。 + * 内存:同页只占一个槽(高档落地时替换低档),低档的额外峰值只是「小图 + 一次落框重采样」, + * 已由 page_source 的峰值模型计入(step>1 且输出≠显示框时的 +box)。 */ +#define MR_SHARPEN_MS 200 /* 停稳多久后才发起高档解码(150–300ms 区间取中) */ +#define MR_TIER_MIN_PIXELS 1000000LL /* 源像素少于此值不分档:解本身不慢,分档只添乱 */ +#define MR_LOW_MIN_LONG 240 /* 低档输出长边下限(再粗就糊到难看) */ +#define MR_LOW_MAX_LONG 480 /* 低档输出长边上限(再细省不了多少解码) */ + +/* MR_TIER_LOW / MR_TIER_HIGH 定义在 ui/reader/reader.h(宿主测试也要用同一套档位值)。 */ + #define MR_REQ_MAX 8 #define MR_RES_MAX 2 /* 结果队列上限(M22:4→2;未取走的已解码图不计入常驻预算) */ @@ -152,6 +169,9 @@ typedef struct page_slot { meow_img *img; /* 已 downscale 的页图 */ int src_w, src_h; int want_w, want_h; /* 本次解码按哪个显示框落的(几何判据,见 slot_ready) */ + unsigned char tier; /* MR_TIER_*:低档过渡图 / 目标档位(M31) */ + unsigned char hi_tried; /* 1=这一槽最近一次「换清晰」失败/跳过(别每 tick 重试; + * 拖动取消时由 settle_reset 复位,停稳后还能再试) */ } page_slot; static page_slot g_slot[MR_SLOTS]; @@ -176,10 +196,12 @@ static page_meta *g_meta; typedef struct decode_plan { int want_w, want_h; int box_w, box_h; + int tier; /* MR_TIER_LOW / MR_TIER_HIGH(M31) */ } decode_plan; static decode_plan g_plan; static int g_plan_page; /* 本次解码的页号(只用于日志) */ static int g_decode_skip; /* reader_load_page 的跳过原因(MR_SKIP_*) */ +static int g_decode_tier; /* reader_load_page 实际生效的档位(低档降为单档时=HIGH) */ static volatile int g_decode_count; /* 累计解码次数(观察重解抖动/活锁) */ #define MR_SKIP_NONE 0 /* 解出来了 */ @@ -238,6 +260,8 @@ typedef struct page_req { unsigned gen; int want_w, want_h; /* 按哪个显示框解码(主线程算,slot_ready 判据) */ int box_w, box_h; /* 落地上限(单页字节上限钳制后的目标尺寸) */ + unsigned char tier; /* MR_TIER_*(M31:低档过渡 / 目标档位) */ + unsigned char sharpen; /* 1=「停稳后换清晰」的追加请求:动起来就作废 */ } page_req; typedef struct page_res { int idx; @@ -246,6 +270,7 @@ typedef struct page_res { int fail; int skip; /* MR_SKIP_*:非 0 = 预算跳过(可重试,不是坏图) */ int want_w, want_h; + unsigned char tier; /* MR_TIER_*:结果按哪一档解出来的 */ } page_res; static pthread_t g_worker; @@ -259,6 +284,19 @@ static int g_req_head, g_req_tail, g_req_n; static page_res g_res[MR_RES_MAX]; static int g_res_n; +/* ---- 两级清晰度状态(M31) ---- + * g_sharpen_idx:当前唯一允许解高档的页(-1=拖动/未停稳/无需升级)。worker 在锁内据此 + * 作废「在飞的高档请求」——复用 M22 已有的 gen/窗口取消机制,只是多一条判据。 + * g_move_ms:最近一次「动」的时刻(按下/拖动/吸附动画/条漫滚动/fling/换页); + * 停稳 MR_SHARPEN_MS 后才允许升级。宿主测试可用 mr_reader_ui_test_set_sharpen_ms 改写窗口。 */ +static int g_sharpen_idx = -1; +static int g_hi_inflight = -1; /* 已排队/正在解的目标档位页(去重用;锁内读写) */ +static uint64_t g_move_ms; +static int g_sharpen_ms = MR_SHARPEN_MS; +static volatile int g_low_decodes; /* 完成的低档解码次数(宿主测试/日志对照) */ +static volatile int g_high_decodes; /* 完成的目标档位解码次数 */ +static volatile int g_high_canceled; /* 被取消的高档请求数(拖动/换页/停稳失效) */ + /* ---- 节拍/动画 ---- */ static meow_anim *g_ticker; static meow_anim *g_snap; /* 翻页滑动吸附 */ @@ -420,15 +458,64 @@ static void plan_box_size(int want_w, int want_h, int *bw, int *bh) /* 固定字段的解码日志:page/src/peak/path 之后是结论,便于 `grep 'reader decode'` * 后按列看曲线。peak = reader_estimate_peak 的预估峰值(字节/kB);path = 实际解码路径 - * (backend = M23 真缩解后端,stb = 回退旧路径)。 */ + * (backend = M23 真缩解后端,stb = 回退旧路径);tier = 本次请求的清晰度档位(M31)。 */ static void log_decode(int page, int src_w, int src_h, long long peak_bytes, long memfree, long memavail, const char *verdict, int step, - int backend, unsigned long long ms) + int backend, int tier, unsigned long long ms) { meow_log(MEOW_LOG_INFO, - "reader decode page=%d src=%dx%d peak=%lldkB memfree=%ldkB memavail=%ldkB %s path=%s step=%d ms=%llu", + "reader decode page=%d src=%dx%d peak=%lldkB memfree=%ldkB memavail=%ldkB %s path=%s step=%d ms=%llu tier=%s", page, src_w, src_h, peak_bytes / 1024, memfree, memavail, verdict, - backend ? "backend" : "stb", step, ms); + backend ? "backend" : "stb", step, ms, + tier == MR_TIER_LOW ? "low" : "high"); +} + +/* 低档解码目标长边(M31):显示长边的一半,夹到 [MR_LOW_MIN_LONG, MR_LOW_MAX_LONG]。 + * 只对 JPEG 分档,且必须真的下探一档以上(后端 DCT 阶 n = ceil(8×目标/源长边); + * 低档 n 不更小 = 同样的活,只多一次落框重采样): + * - 「档位」(step)本来就是 JPEG 缩解的概念;PNG/BMP 后端按目标逐行丢行,本来就不慢, + * 而且阅读器的单页峰值模型对非 JPEG 按「输出=显示框」建模——再加一条小图→落框的 + * 路径会让模型低估。分档只留给真机 168–400ms 的 JPEG 大页。 + * 返回 0 = 这页不分档,直接按目标档位解。 */ +static int plan_low_dim(const mr_img_info *in, int box_w, int box_h) +{ + if (!in || in->w <= 0 || in->h <= 0) return 0; + if (in->codec != MR_IMG_CODEC_JPEG) return 0; + if ((long long)in->w * in->h < MR_TIER_MIN_PIXELS) return 0; + int l0 = in->w > in->h ? in->w : in->h; + int bl = box_w > box_h ? box_w : box_h; + if (l0 <= 0 || bl <= 0) return 0; + int lo = bl / 2; + if (lo < MR_LOW_MIN_LONG) lo = MR_LOW_MIN_LONG; + if (lo > MR_LOW_MAX_LONG) lo = MR_LOW_MAX_LONG; + if (lo >= bl) return 0; /* 显示框本来就不大:没有更粗的余地 */ + int nh = (int)((8LL * bl + l0 - 1) / l0); + int nl = (int)((8LL * lo + l0 - 1) / l0); + if (nh < 1) nh = 1; + if (nl < 1) nl = 1; + if (nl >= nh) return 0; /* 同一 DCT 档:低档并不更快 */ + return lo; +} + +/* 按「解码目标长边」跑一次 page_source 的唯一峰值模型(含超预算时继续加粗档位的旧逻辑)。 + * JPEG 档位由目标长边反推,与 meow_img_load_file_step_ex 的 eff 口径一致;模型在 + * 「step>1 且输出≠显示框」时会把落框临时图算进峰值——低档走的正是这条已校准的路径。 */ +static void estimate_for(const mr_img_info *in, int load_dim, int box_w, int box_h, + long mfree, long mavail, mr_decode_decision *dec) +{ + int step = 1; + if (in->codec == MR_IMG_CODEC_JPEG) { + int l0 = in->w > in->h ? in->w : in->h; + step = meow_img_pick_scale(l0, load_dim); + } + for (;;) { + reader_estimate_peak(in, step, box_w, box_h, mfree, mavail, dec); + /* 后端命中 / 已进预算 / 已到最粗档位:定案。非 JPEG 无档位可调。 */ + if (dec->path == MR_DECODE_PATH_BACKEND || dec->skip == MR_DECODE_OK || + step >= 8 || in->codec != MR_IMG_CODEC_JPEG) + break; + step *= 2; + } } static meow_img *reader_load_page(const char *path, void *ud, int maxdim) @@ -438,8 +525,8 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) uint64_t t0 = meow_ui_time_ms(); if (!path || !path[0]) return NULL; - /* 解码计划由 queue_page() 在主线程算好(显示框 + 落地上限);worker/主线程同步兜底 - * 路径在 mr_book_open 前写入。无计划时退回旧预算 maxdim。 */ + /* 解码计划由 queue_page() 在主线程算好(显示框 + 落地上限 + 档位);worker/主线程 + * 同步兜底路径在 mr_book_open 前写入。无计划时退回旧预算 maxdim。 */ int want_w = g_plan.want_w, want_h = g_plan.want_h; int box_w = g_plan.box_w, box_h = g_plan.box_h; int page = g_plan_page; @@ -460,30 +547,38 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) * reader_estimate_peak 同时给出「走后端还是 stb」与「是否占位」。后端能接住的页 * (M23,峰值与源尺寸无关)直接放行;只有回退 stb 的页才参与峰值预算/MemAvailable * 门控。模型不再在本文件另写一份。 */ + int tier = g_plan.tier; mr_img_info in; int have = mr_probe_info(path, &in); + int load_dim = dim; /* 本次解码的输出长边(低档 < dim) */ mr_decode_decision dec; memset(&dec, 0, sizeof dec); dec.step = 1; + g_decode_tier = MR_TIER_HIGH; + if (!have) tier = MR_TIER_HIGH; /* 尺寸未知:没有分档依据,按单档解(日志别谎报) */ if (have) { - int step = 1; - if (in.codec == MR_IMG_CODEC_JPEG) { - int l0 = in.w > in.h ? in.w : in.h; - step = meow_img_pick_scale(l0, dim); + if (tier == MR_TIER_LOW) { + int lo = plan_low_dim(&in, box_w, box_h); + if (lo > 0 && lo < dim) load_dim = lo; + else tier = MR_TIER_HIGH; /* 这页不值得/无法再粗 → 单档 */ } - for (;;) { - reader_estimate_peak(&in, step, box_w, box_h, mfree, mavail, &dec); - /* 后端命中 / 已进预算 / 已到最粗档位:定案。非 JPEG 无档位可调。 */ - if (dec.path == MR_DECODE_PATH_BACKEND || dec.skip == MR_DECODE_OK || - step >= 8 || in.codec != MR_IMG_CODEC_JPEG) - break; - step *= 2; + estimate_for(&in, load_dim, box_w, box_h, mfree, mavail, &dec); + /* 低档多一次落框重采样,峰值比高档高;门控拒绝时退回单档(高档),别把本来 + * 能显示的页变成「内存不足」占位。非 JPEG 的 stb 回退没有缩解能力(整幅解后 + * 再缩),低档只是白解一遍,同样退回单档。 */ + if (tier == MR_TIER_LOW && + (dec.skip != MR_DECODE_OK || + (dec.path == MR_DECODE_PATH_STB && in.codec != MR_IMG_CODEC_JPEG))) { + tier = MR_TIER_HIGH; + load_dim = dim; + estimate_for(&in, load_dim, box_w, box_h, mfree, mavail, &dec); } + g_decode_tier = tier; /* 实际生效的档位(worker 据此计数/落槽) */ if (dec.skip != MR_DECODE_OK) { g_decode_skip = dec.skip == MR_DECODE_SKIP_LOWMEM ? MR_SKIP_LOWMEM : MR_SKIP_PEAK; log_decode(page, in.w, in.h, dec.peak_bytes, mfree, mavail, dec.skip == MR_DECODE_SKIP_LOWMEM ? "skip=lowmem" : "skip=peak", - dec.step, dec.path == MR_DECODE_PATH_BACKEND, + dec.step, dec.path == MR_DECODE_PATH_BACKEND, tier, (unsigned long long)(meow_ui_time_ms() - t0)); return NULL; } @@ -495,12 +590,12 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) * 18–36MB)。只有判定为 stb 的页才允许 stb。 */ int allow_stb = !have || dec.path != MR_DECODE_PATH_BACKEND; int used_backend = 0; - meow_img *im = have ? meow_img_load_file_step_ex(path, dim, dec.step, allow_stb, + meow_img *im = have ? meow_img_load_file_step_ex(path, load_dim, dec.step, allow_stb, &used_backend) : meow_img_load_file_dim(path, dim); if (!im) { log_decode(page, have ? in.w : 0, have ? in.h : 0, dec.peak_bytes, mfree, mavail, "fail", - dec.step, have && dec.path == MR_DECODE_PATH_BACKEND, + dec.step, have && dec.path == MR_DECODE_PATH_BACKEND, tier, (unsigned long long)(meow_ui_time_ms() - t0)); return NULL; } @@ -509,17 +604,18 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) meow_img *small = meow_img_downscale(im, dim); if (small) { meow_img_free(im); im = small; } } - /* 3) 精确落到显示框(仅当「故意用了粗档位」时):结果 = box → 命中页仍是一次原生 blit, - * 不会把内存换来的降级变成每帧重采样(M17 的热路径纪律)。 + /* 3) 精确落到显示框(低档过渡图 / 故意用了粗档位时):结果 = box → 命中页仍是一次原生 + * blit,不会把「先糊后清」变成每帧重采样(M17 的热路径纪律)。低档也落满显示框, + * 所以换高档时尺寸不变:不闪白、不重置缩放/滚动,且两档在槽里是同一张的位置。 * 源图本身就小于显示框(小图/缩略图)时绝不放大:那会凭空多占常驻字节, * 且 M17 的 slot_ready 语义依赖「SDK 不做上采样」。 */ - if (have && dec.step > 1 && (in.w >= box_w || in.h >= box_h) && + if (have && (tier == MR_TIER_LOW || dec.step > 1) && (in.w >= box_w || in.h >= box_h) && (meow_img_w(im) != box_w || meow_img_h(im) != box_h)) { meow_img *fit = meow_img_resample(im, box_w, box_h); if (fit) { meow_img_free(im); im = fit; } } log_decode(page, have ? in.w : 0, have ? in.h : 0, dec.peak_bytes, mfree, mavail, "ok", - dec.step, used_backend, + dec.step, used_backend, tier, (unsigned long long)(meow_ui_time_ms() - t0)); return im; } @@ -680,6 +776,8 @@ static void slot_clear(int i) g_slot[i].idx = -1; g_slot[i].src_w = g_slot[i].src_h = 0; g_slot[i].want_w = g_slot[i].want_h = 0; + g_slot[i].tier = MR_TIER_HIGH; + g_slot[i].hi_tried = 0; } static void slots_free_all(void) @@ -732,9 +830,11 @@ static void slots_reserve(long need_bytes) } } -/* 放进缓存;同页旧图直接替换(fit/zoom/模式变了要换尺寸)。返回 1=已放入。 - * want_w/want_h = 这次解码按哪个显示框落的,交给 slot_ready 做几何判据。 */ -static int slot_put(int idx, meow_img *img, int want_w, int want_h) +/* 放进缓存;同页旧图直接替换(fit/zoom/模式变了要换尺寸;低档→高档也走这里)。 + * 返回 1=已放入。want_w/want_h = 这次解码按哪个显示框落的,交给 slot_ready 做几何判据。 + * tier 记下这一槽的清晰度:低档图先顶上,高档图落地时**原地替换**(同一槽、同一尺寸), + * 页面不闪白、不重置缩放/滚动,常驻也不会一页两张。 */ +static int slot_put(int idx, meow_img *img, int want_w, int want_h, int tier) { if (!img || idx < 0) return 0; int i = -1, empty = -1; @@ -766,6 +866,7 @@ static int slot_put(int idx, meow_img *img, int want_w, int want_h) if (slot_evict_far(i) < 0) break; /* 只剩目标槽:这一页必须放进来 */ } + /* 低档 → 高档替换:低档图在这里释放,不会与高档同时常驻。 */ slot_clear(i); g_slot[i].idx = idx; g_slot[i].img = img; @@ -773,6 +874,7 @@ static int slot_put(int idx, meow_img *img, int want_w, int want_h) g_slot[i].src_h = meow_img_h(img); g_slot[i].want_w = want_w; g_slot[i].want_h = want_h; + g_slot[i].tier = (unsigned char)(tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH); return 1; } @@ -845,11 +947,19 @@ static int prefetch_allowed(void) return a >= MR_PREFETCH_MIN_KB; } -static void queue_page(int idx) +/* 大页才分档:真机 1280×2275 / 1280×1032 这类页单次解码 168–400ms;小图本来就快, + * 分两档只会多一次解码 + 落框重采样。 */ +static int page_needs_tier(int idx) +{ + int sw = 0, sh = 0; + if (!page_meta_dims(idx, &sw, &sh)) return 0; + return (long long)sw * sh >= MR_TIER_MIN_PIXELS; +} + +/* 排一个解码请求。tier 决定解低档还是目标档位;sharpen=1 表示「停稳后换清晰」的追加 + * 请求——动起来(拖动/翻页/滚动)就由 gen/窗口/sharpen 三条判据一起作废。 */ +static int queue_page_tier(int idx, int tier, int sharpen) { - if (!mr_book_valid(&g_book) || idx < 0 || idx >= g_count) return; - /* 永久失败与「仍在跳过期」的页都不排;后者由 maybe_retry_skipped_focus 解封。 */ - if (slot_ready(idx) || page_blocked(idx)) return; /* 几何在这里(主线程)算好塞进请求:worker 不再读 g_fit/g_zoom/g_vw/g_vh。 */ int want_w = 0, want_h = 0; page_geometry_at(idx, &want_w, &want_h); @@ -858,8 +968,12 @@ static void queue_page(int idx) plan_box_size(want_w, want_h, &box_w, &box_h); /* 先释放再解码:按这一页的落地字节先淘汰不可见旧图,别让常驻与解码缓冲叠加。 */ slots_reserve((long)box_w * box_h * 4); + int queued = 0; pthread_mutex_lock(&g_lock); - if (!g_stop && !req_queued(idx) && g_req_n < MR_REQ_MAX) { + /* 去重:已排队(req_queued)或「换清晰」请求正在 worker 手里(g_hi_inflight)都不再发。 + * 后者必须单列:请求被 worker 取走后就不在队列里了,只看 req_queued 会每 tick 重发一次。 */ + if (!g_stop && !req_queued(idx) && g_req_n < MR_REQ_MAX && + !(sharpen && g_hi_inflight == idx)) { page_req *r = &g_req[g_req_tail]; r->idx = idx; r->gen = g_gen; @@ -867,11 +981,35 @@ static void queue_page(int idx) r->want_h = want_h; r->box_w = box_w; r->box_h = box_h; + r->tier = (unsigned char)(tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH); + r->sharpen = (unsigned char)(sharpen ? 1 : 0); g_req_tail = (g_req_tail + 1) % MR_REQ_MAX; g_req_n++; + if (sharpen) g_hi_inflight = idx; + queued = 1; pthread_cond_signal(&g_cv); } pthread_mutex_unlock(&g_lock); + return queued; +} + +static void queue_page(int idx) +{ + if (!mr_book_valid(&g_book) || idx < 0 || idx >= g_count) return; + /* 永久失败与「仍在跳过期」的页都不排;后者由 maybe_retry_skipped_focus 解封。 */ + if (page_blocked(idx)) return; + page_slot *s = slot_find(idx); + if (s && slot_ready(idx)) { + /* 已经能显示:只有「低档 → 目标档位」这一种情况还要再解,而且必须是当前焦点页、 + * 已停稳(g_sharpen_idx 由 tier_pump 在去抖窗口后置位)、并且最近一次升级没失败过。 + * hi_tried 只在「解坏了 / 预算跳过」时置位(避免每 tick 重试);拖动取消会复位。 */ + if (s->tier != MR_TIER_LOW || s->hi_tried || g_sharpen_idx != idx || + !page_needs_tier(idx)) + return; + queue_page_tier(idx, MR_TIER_HIGH, 1); + return; + } + queue_page_tier(idx, page_needs_tier(idx) ? MR_TIER_LOW : MR_TIER_HIGH, 0); } static void *reader_worker(void *arg) @@ -885,15 +1023,26 @@ static void *reader_worker(void *arg) req = g_req[g_req_head]; g_req_head = (g_req_head + 1) % MR_REQ_MAX; g_req_n--; - /* 取消(任务 4 / M20 P0-3):出队后、mr_book_open 前二次判过期——旧册(gen 变) - * 或已经不在解码窗(拖动/快翻的中间页)直接丢弃。M20 实测拖动翻页原本 1.75× 解码, - * 全是这种在飞不可取消的请求。锁内判,窗口也在锁内写,避免共享状态竞争。 */ + /* 取消(任务 4 / M20 P0-3;M31 扩展):出队后、mr_book_open 前二次判过期——旧册 + * (gen 变)或已经不在解码窗(拖动/快翻的中间页)直接丢弃。M20 实测拖动翻页原本 + * 1.75× 解码,全是这种在飞不可取消的请求。锁内判,窗口/sharpen 目标也在锁内写, + * 避免共享状态竞争。 + * M31:拖动/连翻期间只许解低档——「停稳换清晰」的请求一旦动起来就作废(复用同一条 + * stale 判据,不新造取消通道)。 */ int stale = (req.gen != g_gen) || !in_window(req.idx); + int stale_motion = 0; + if (!stale && req.sharpen && req.idx != g_sharpen_idx) { + stale = 1; + stale_motion = 1; + } + if (stale && req.sharpen && g_hi_inflight == req.idx) g_hi_inflight = -1; pthread_mutex_unlock(&g_lock); if (stale) { + if (stale_motion) g_high_canceled++; meow_log(MEOW_LOG_INFO, - "reader decode page=%d src=0x0 peak=0kB memfree=%ldkB memavail=%ldkB skip=cancel step=0", - req.idx, mem_free_kb(), mem_avail_kb()); + "reader decode page=%d src=0x0 peak=0kB memfree=%ldkB memavail=%ldkB skip=cancel step=0 tier=%s", + req.idx, mem_free_kb(), mem_avail_kb(), + req.tier == MR_TIER_LOW ? "low" : "high"); continue; } @@ -902,10 +1051,17 @@ static void *reader_worker(void *arg) g_plan.want_h = req.want_h; g_plan.box_w = req.box_w; g_plan.box_h = req.box_h; + g_plan.tier = req.tier; g_plan_page = req.idx; g_decode_skip = MR_SKIP_NONE; + g_decode_tier = MR_TIER_HIGH; meow_img *img = mr_book_open(&g_book, req.idx); g_decode_count++; + /* 档位以解码回调**实际生效**的为准:低档请求遇到非 JPEG/不值得分档的页会降为 + * 单档(reader_load_page 把 g_decode_tier 置回 HIGH),落槽也就不会误标成低档。 */ + int eff_tier = g_decode_tier; + if (eff_tier == MR_TIER_LOW) g_low_decodes++; + else g_high_decodes++; int skip = img ? MR_SKIP_NONE : g_decode_skip; pthread_mutex_lock(&g_lock); @@ -917,9 +1073,12 @@ static void *reader_worker(void *arg) g_res[g_res_n].skip = skip; g_res[g_res_n].want_w = req.want_w; g_res[g_res_n].want_h = req.want_h; + g_res[g_res_n].tier = (unsigned char)eff_tier; g_res_n++; img = NULL; /* 所有权已移交(失败/跳过时本就是 NULL) */ } + /* 本页的「换清晰」请求已经走完(无论落不落槽):解除去重标记,允许下一轮重新判定。 */ + if (req.sharpen && g_hi_inflight == req.idx) g_hi_inflight = -1; pthread_mutex_unlock(&g_lock); if (img) meow_img_free(img); } @@ -948,10 +1107,12 @@ static void worker_stop(void) g_res_n = 0; g_req_n = 0; g_req_head = g_req_tail = 0; + g_hi_inflight = -1; pthread_mutex_unlock(&g_lock); } -/* 丢弃窗口外的排队请求(主线程调用)。 */ +/* 丢弃窗口外的排队请求(主线程调用)。M31:还没出队的「停稳换清晰」请求,一旦动起来 + * (g_sharpen_idx 被清)也在这里作废,不必等 worker 出队才发现。 */ static void queue_prune(void) { pthread_mutex_lock(&g_lock); @@ -960,6 +1121,11 @@ static void queue_prune(void) for (int i = 0; i < g_req_n; i++) { page_req r = g_req[(g_req_head + i) % MR_REQ_MAX]; if (r.gen != g_gen || !in_window(r.idx)) continue; + if (r.sharpen && r.idx != g_sharpen_idx) { + if (g_hi_inflight == r.idx) g_hi_inflight = -1; + g_high_canceled++; + continue; + } /* 几何已变的请求作废(否则会按旧显示框解出来又被判过期)。 */ int w = 0, h = 0; page_geometry_at(r.idx, &w, &h); @@ -974,7 +1140,9 @@ static void queue_prune(void) } /* 收取后台结果:只留窗口内新页。返回 1=有图落位。 - * 结果分三种:ok(落槽)/ fail(坏图,永久占位)/ skip(预算跳过,可重试占位)。 */ + * 结果分三种:ok(落槽)/ fail(坏图,永久占位)/ skip(预算跳过,可重试占位)。 + * M31:高档(停稳换清晰)失败/跳过时,低档已经在屏幕上——保留低档继续看,不把整页标成 + * 「内存不足」或坏图;低档自己的失败/跳过才走旧语义(那才是真的没图)。 */ static int results_drain(void) { page_res got[MR_RES_MAX]; @@ -988,20 +1156,25 @@ static int results_drain(void) int changed = 0; for (int i = 0; i < n; i++) { if (got[i].gen != g_gen) { meow_img_free(got[i].img); continue; } + page_slot *low = got[i].tier == MR_TIER_HIGH ? slot_find(got[i].idx) : NULL; + if (low && low->tier != MR_TIER_LOW) low = NULL; /* 已有目标档:不是「低档兜底」 */ if (got[i].skip) { /* 预算跳过(内存不足/峰值超限):可重试,不写永久失败位。 */ + if (low) { low->hi_tried = 1; continue; } /* 低档在屏:降级显示,不占位 */ page_mark_skipped(got[i].idx); if (page_visible(got[i].idx)) g_fail_paint = 1; continue; } if (got[i].fail) { + if (low) { low->hi_tried = 1; continue; } page_mark_failed(got[i].idx); if (page_visible(got[i].idx)) g_fail_paint = 1; continue; } if (in_window(got[i].idx)) { - if (slot_put(got[i].idx, got[i].img, got[i].want_w, got[i].want_h) && - page_visible(got[i].idx)) changed = 1; + if (slot_put(got[i].idx, got[i].img, got[i].want_w, got[i].want_h, + got[i].tier) && page_visible(got[i].idx)) + changed = 1; page_skip_clear(got[i].idx); /* 解出来了:撤销跳过标记 */ } else { meow_img_free(got[i].img); @@ -1046,6 +1219,72 @@ static void window_update(void) } } +/* ============================================================ */ +/* 两级清晰度的停稳去抖(M31) */ +/* ------------------------------------------------------------ + * 规则只有三条: + * 1) 拖动 / 吸附动画 / 条漫滚动(含 fling)期间一律不动高档:g_sharpen_idx=-1, + * 在飞/排队的「停稳换清晰」请求按 M22 的取消通道作废;跳页在途时同样不解高档 + * (单 worker 串行,导航优先); + * 2) 停稳 MR_SHARPEN_MS 后,焦点页若是低档,才允许它解目标档位(g_sharpen_idx=focus); + * 3) 每次「动」都重置停稳计时;换页/换几何也会清掉资格(重新计时)。 + * 高档只发给焦点页:单 worker 串行,先保证用户正看的那页清晰,邻居留低档待翻到再说。 */ +static int motion_active(void) +{ + if (g_dragging) return 1; + if (g_snap || g_bounce) return 1; + if (g_scroll && meow_scroll_active(g_scroll)) return 1; + return 0; +} + +/* 「动」了:重置停稳计时,并取消当前的高档资格(排队/在飞的请求随之作废)。 + * 被取消的这一页如果还是低档,把 hi_tried 复位——停稳后还能再试一次。 */ +static void settle_reset(void) +{ + g_move_ms = meow_ui_time_ms(); + if (g_sharpen_idx < 0) return; + page_slot *s = slot_find(g_sharpen_idx); + if (s && s->tier == MR_TIER_LOW) s->hi_tried = 0; + pthread_mutex_lock(&g_lock); + g_sharpen_idx = -1; + pthread_mutex_unlock(&g_lock); + queue_prune(); +} + +static void tier_pump(uint64_t now) +{ + if (g_count <= 0 || g_quitting) return; + if (motion_active()) { + g_move_ms = now; + if (g_sharpen_idx >= 0) settle_reset(); + return; + } + /* 跳页在途:单 worker 串行,「换清晰」是锦上添花,不能插在跳页目标的低档前面—— + * 否则滑块一拖就是几秒(早已过停稳窗口),老页的高档会把跳页落位往后挤几百 ms。 */ + if (mr_jump_active(&g_jump)) { + if (g_sharpen_idx >= 0) settle_reset(); + return; + } + int f = focus_page(); + if (f < 0) return; + page_slot *s = slot_find(f); + if (!s || s->tier != MR_TIER_LOW || s->hi_tried || !page_needs_tier(f)) { + if (g_sharpen_idx == f) { + pthread_mutex_lock(&g_lock); + g_sharpen_idx = -1; + pthread_mutex_unlock(&g_lock); + } + return; + } + if (now - g_move_ms < (uint64_t)g_sharpen_ms) return; /* 还没停稳 */ + if (g_sharpen_idx != f) { + pthread_mutex_lock(&g_lock); + g_sharpen_idx = f; + pthread_mutex_unlock(&g_lock); + } + queue_page(f); /* 幂等:已排队/在飞/已清晰/已试过都直接返回 */ +} + /* ============================================================ */ /* 条漫排版 + 滚动会话 */ /* ============================================================ */ @@ -1369,6 +1608,7 @@ static void set_fit(int fit) g_fit = (fit < 0 || fit >= MR_FIT_COUNT) ? MR_FIT_WIDTH : fit; g_zoom_pct = 100; g_pan_x = g_pan_y = 0; + settle_reset(); /* 几何变了:停稳计时重来,低档先上屏再换清晰 */ window_update(); update_title(); meow_ui_invalidate(0, 0, g_vw, g_vh); @@ -1401,6 +1641,7 @@ static void commit_page(int target) g_page = target; g_zoom_pct = 100; g_pan_x = g_pan_y = 0; + settle_reset(); /* 换页:停稳计时重来(拖动/连翻期间不解高档) */ window_update(); update_title(); slider_sync(); @@ -1535,6 +1776,7 @@ static void jump_land(int page, int timed_out) g_pan_x = g_pan_y = 0; } slider_sync(); + settle_reset(); /* 落位:停稳计时重来 */ window_update(); update_title(); cont_touch(); @@ -1621,6 +1863,7 @@ static void mode_set(int mode, int from_page) g_zoom_pct = 100; g_pan_x = g_pan_y = 0; slider_sync(); + settle_reset(); /* 模式/排版几何变了:停稳计时重来 */ window_update(); update_title(); cont_touch(); @@ -1663,6 +1906,9 @@ static void open_book_reset_state(void) mr_jump_cancel(&g_jump); g_toast[0] = '\0'; g_title_page = -1; + g_sharpen_idx = -1; /* 新册:高档资格清零,停稳计时重来 */ + g_hi_inflight = -1; + g_move_ms = meow_ui_time_ms(); free(g_meta); g_meta = NULL; } @@ -1760,9 +2006,10 @@ static int open_book(const char *dir, int start_page, int mode) g_plan.want_h = wh; g_plan.box_w = bw; g_plan.box_h = bh; + g_plan.tier = MR_TIER_HIGH; /* 没有后台线程:直接解目标档,不分档 */ g_plan_page = g_page; meow_img *first = mr_book_open(&g_book, g_page); - if (first) slot_put(g_page, first, ww, wh); + if (first) slot_put(g_page, first, ww, wh, MR_TIER_HIGH); meow_log(MEOW_LOG_WARN, "reader worker unavailable, sync first page"); } slider_build(); @@ -1876,6 +2123,7 @@ void reader_on_touch_down(int x, int y) { if (!g_ctrl.active || g_quitting) return; if (g_count <= 0) return; + settle_reset(); /* 手一按下去就不算停稳:在飞的高档请求作废 */ g_touch_moved = 0; /* 每次按下都是新手势(chrome 抬手也不残留) */ if (y >= g_vh - 28) return; /* 屏底热区:系统回桌面,勿劫持 */ if (x < 24) return; /* 左缘:系统返回手势,勿劫持 */ @@ -2055,6 +2303,7 @@ static void tap_double(int x, int y) g_pan_y = (g_vh / 2 - y) * (z - 100) / 100; clamp_pan(); } + settle_reset(); /* 缩放几何变了:重新计时(低档先上屏) */ window_update(); meow_ui_invalidate(0, 0, g_vw, g_vh); } @@ -2157,6 +2406,9 @@ static void reader_tick(void) /* 只有当落位的页真的在屏幕上(或换了几何的当前页)才重绘,预取邻居不刷屏。 */ if (results_drain() && !g_dragging) invalidate_content(); + /* 两级清晰度:停稳去抖(拖动/滚动期间只留低档,停稳后才补高档)。 */ + tier_pump(now); + /* 打开册的瞬时资源失败:稍后自动重试一次(进入本页时已停 library 后台解码)。 */ if (g_open_err && g_open_retry_at && now >= g_open_retry_at) { g_open_retry_at = 0; @@ -2312,6 +2564,13 @@ static void reader_state_reset(void) memset(&g_plan, 0, sizeof g_plan); g_plan_page = 0; g_decode_skip = MR_SKIP_NONE; + g_decode_tier = MR_TIER_HIGH; + g_sharpen_idx = -1; + g_hi_inflight = -1; + g_move_ms = meow_ui_time_ms(); + g_low_decodes = 0; + g_high_decodes = 0; + g_high_canceled = 0; } static void reader_page_enter(meow_view *v, void *ctx) @@ -2527,6 +2786,41 @@ int mr_reader_ui_scaled_draws(void) { return g_scaled_draws; } int mr_reader_ui_decode_count(void) { return g_decode_count; } int mr_reader_ui_fit(void) { return g_fit; } int mr_reader_ui_chrome(void) { return g_chrome; } +/* M31 两级清晰度观察口:焦点页当前档位(-1=无图/0=低档/1=目标档位)、各档解码次数、 + * 被取消的高档请求数、当前允许升级的页(-1=拖动/未停稳/无需升级)。 */ +int mr_reader_ui_focus_tier(void) +{ + page_slot *s = slot_find(focus_page()); + if (!s) return -1; + return s->tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH; +} +int mr_reader_ui_slot_tier(int idx) +{ + page_slot *s = slot_find(idx); + if (!s) return -1; + return s->tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH; +} +long mr_reader_ui_slot_kb(int idx) +{ + page_slot *s = slot_find(idx); + if (!s) return 0; + return (long)s->src_w * s->src_h * 4 / 1024; +} +int mr_reader_ui_low_decodes(void) { return g_low_decodes; } +int mr_reader_ui_high_decodes(void) { return g_high_decodes; } +int mr_reader_ui_high_canceled(void) { return g_high_canceled; } +int mr_reader_ui_sharpen_idx(void) { return g_sharpen_idx; } +/* 宿主测试改写「停稳」窗口:0=立刻允许高档(同步验证落位),很大=冻结高档(验证低档先落位)。 */ +void mr_reader_ui_test_set_sharpen_ms(int ms) +{ + g_sharpen_ms = ms < 0 ? 0 : ms; +} +/* 宿主测试:绕过停稳判定强行排一个「换清晰」请求,用于验证拖动中会被取消。 */ +void mr_reader_ui_test_queue_high(int idx) +{ + if (idx < 0 || idx >= g_count) return; + queue_page_tier(idx, MR_TIER_HIGH, 1); +} /* 直接切模式(避开顶栏 tap 命中时序):与顶栏动作走同一个 mode_set。 */ void mr_reader_test_toggle_mode(void) { diff --git a/tuku/ui/reader/reader.h b/tuku/ui/reader/reader.h index 3ddca22..960a43b 100644 --- a/tuku/ui/reader/reader.h +++ b/tuku/ui/reader/reader.h @@ -10,6 +10,11 @@ extern "C" { #endif +/* 两级清晰度档位(M31):低档=先上屏的过渡图,高档=停稳后替换的目标档位。 + * 同时供宿主测试断言槽内档位用(mr_reader_ui_slot_tier / mr_reader_ui_focus_tier)。 */ +#define MR_TIER_LOW 0 +#define MR_TIER_HIGH 1 + void reader_on_touch_down(int x, int y); void reader_on_touch_move(int x, int y); @@ -51,6 +56,18 @@ long mr_reader_ui_resident_cap_kb(void); /* 常驻上限(kB) */ int mr_reader_ui_blit_draws(void); /* 累计原生 blit 绘页次数(热路径应为纯 blit) */ int mr_reader_ui_scaled_draws(void);/* 累计重采样绘页次数(每帧重采样=卡顿) */ int mr_reader_ui_decode_count(void);/* 累计解码次数(查重解抖动/活锁) */ +/* ---- M31 两级清晰度(先低档秒出,停稳再换档)---- */ +int mr_reader_ui_focus_tier(void); /* 焦点页档位:-1=无图,0=低档过渡图,1=目标档位 */ +int mr_reader_ui_slot_tier(int idx);/* 指定页档位:-1=不在槽内,否则同上 */ +long mr_reader_ui_slot_kb(int idx); /* 指定槽内页图的常驻字节(KB);0=不在槽内 */ +int mr_reader_ui_low_decodes(void); /* 低档解码次数 */ +int mr_reader_ui_high_decodes(void);/* 目标档位解码次数 */ +int mr_reader_ui_high_canceled(void);/* 被取消(拖动/换页/未停稳)的高档请求数 */ +int mr_reader_ui_sharpen_idx(void); /* 当前允许升级的页;-1=拖动/未停稳/无需升级 */ +/* 改写「停稳」窗口(默认 MR_SHARPEN_MS=200ms):0=立刻允许高档,很大=冻结高档。 */ +void mr_reader_ui_test_set_sharpen_ms(int ms); +/* 绕过停稳判定强排一个「换清晰」请求(验证拖动中会被 M22 的取消通道作废)。 */ +void mr_reader_ui_test_queue_high(int idx); int mr_reader_ui_fit(void); /* 当前 fit 档(MR_FIT_*) */ int mr_reader_ui_chrome(void); /* 1=顶栏可见 */ /* 直接切模式(绕过顶栏 tap 的命中时序;测试用,产品路径仍走顶栏动作)。 */ From e896a28fbae5ed854df5c612a515a75287c38b20 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 6 Nov 2026 16:15:50 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E4=BF=AE=20M31=20?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=20P1=EF=BC=88=E4=B8=A4=E6=A1=A3=E5=85=B1?= =?UTF-8?q?=E5=AD=98=E5=B3=B0=E5=80=BC=20+=20=E6=8D=A2=E6=B8=85=E6=99=B0?= =?UTF-8?q?=E6=8C=A4=E6=8E=89=E9=A2=84=E5=8F=96=EF=BC=89=E4=B8=8E=E4=B8=89?= =?UTF-8?q?=E4=B8=AA=20P2=EF=BC=88r2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-1 内存账没算全:高档解码时低档还占着槽,但门控/预留只按新图算。 - 高档请求携带发起时的常驻快照(page_req.resident),解码前按 「常驻 + reader_estimate_peak()」算这次操作的总峰值,上限 MR_TIER_TOTAL_CAP (12MB,与 page_source 的绝对上限同值;常态 3MB 只约束 stb 回退路径, 后端页本来就超 3MB 且已被 M27 放行),超了跳过高档、保留低档(skip=coexist)。 - 峰值本身靠调度压回基线:分页模式焦点页还是低档时不预取邻居(预取来会被 换清晰腾掉),换清晰前 slots_reserve 腾掉非可见页,高档只与焦点低档共存。 B8(--wrap malloc,两实例并行):max_live_delta 3250KB→550KB, 绝对峰值 5279KB→3932KB(基线 3929KB)。 P1-2 换清晰挤掉预取:换清晰后不重跑窗口,邻居洞留到下次翻页(0/8 命中、4 次/页)。 - 新增每页 in-flight 去重(g_req_inflight):请求被 worker 取走后不再被窗口刷新重排, 这是重复解码/挤掉邻居的根因之一。 - 高档落位后按最近一次翻页方向补回邻居(sharpen_refill);反方向不补(3MB 常驻 上限下放不下,必然再被挤掉)。 - 真机几何探针(480×720,1280×2275 JPEG,8 次 700ms/页):0/8→8/8 命中, 4→1.5 次解码/页。B8 顺序 100 页:100/100 命中、149 次解码(1.49/页)。 P2-1:B8 改成 101 页册顺序前翻 100 次(每页都是不同的下一页),4MP/12MP 夹具 真正成为焦点页;断言最后一页=100、命中率 ≥90%、解码 ≤2/页+余量。 P2-2:B10 去掉恒真的 slot_kb 比较,改断言「总常驻不增加」+「高档操作增量 ≤3MB」+ 「两档共存绝对峰值有界」;补双击放大后换清晰不重置缩放/平移、条漫滚动位置不变。 P2-3:B10 整串生命周期解码次数 ≤12(实测 7);B8 解码次数上限。 夹具用硬链接(同文件系统),避免多页册真拷贝几百 MB。 顺带:MR_SHARPEN_MS 200→150(任务允许 150–300ms,给「换清晰→补邻居」留翻页余量); 新增宿主观察口 flip_hits/misses、zoom/pan/woff(只读)。 --- tuku/test/test_reader_membudget.c | 169 ++++++++++++++++++++++++------ tuku/ui/reader/reader.c | 149 +++++++++++++++++++++----- tuku/ui/reader/reader.h | 8 +- 3 files changed, 269 insertions(+), 57 deletions(-) diff --git a/tuku/test/test_reader_membudget.c b/tuku/test/test_reader_membudget.c index 3765b1c..32c6e43 100644 --- a/tuku/test/test_reader_membudget.c +++ b/tuku/test/test_reader_membudget.c @@ -17,10 +17,12 @@ * B7b 回退路径:CMYK / Adam7 PNG / 1bpp BMP 走 stb,按旧门控占位 * B7c P1-1 旁路回归:截断 12MP JPEG / 3000×3000 24bpp 头-only BMP(判定 backend, * 后端失败必须 NULL、峰值 ≤3MB)/ 非法位深 PNG(矩阵拒绝→stb 占位) - * B8 M27/M31:连翻 100 页(6 类循环)不掉页、不占位、任何时刻存活增量 ≤3MB; - * 分档页低档/高档都确实跑过(M31:先低后清不把峰值/常驻翻倍) + * B8 M27/M31:顺序连翻 100 页(101 页册,6 类循环,每页都是不同的下一页)不掉页、 + * 不占位、存活增量 ≤3MB、绝对峰值有上界、预取命中 ≥90%、解码次数 ≤2/页+余量 + * (M31 r2:修「每翻一页解 4 次」与高峰值,评审 P1-1/P1-2/P2-1/P2-3) * B10 M31:两级清晰度生命周期(JPEG 大页):低档先落位(open 不阻塞)→ 停稳后高档落 - * 同一槽(槽内字节不变、槽位不空)→ 拖动中强塞的高档请求被取消通道作废 + * 同一槽(总常驻不增加、槽位不空、本次操作增量 ≤3MB)→ 双击放大换清晰不重置缩放/平移 + * → 拖动中强塞的高档请求被取消通道作废 → 整串解码次数有界 * B9 M27:CMYK 回退页集成 —— 占位可重试且不崩 * * 夹具:BMP/PNG 小夹具由本文件用 C 写;JPEG/PNG/BMP 大夹具由 python3+Pillow 生成 @@ -118,6 +120,7 @@ static int g_fail; } while (0) #define INFO(...) do { printf(" info: "); printf(__VA_ARGS__); printf("\n"); } while (0) + /* ------------------------------------------------------------------ */ /* 夹具写入 */ /* ------------------------------------------------------------------ */ @@ -218,6 +221,14 @@ static int copy_file(const char *src, const char *dst) return ok ? 0 : -1; } +/* 同一文件系统优先硬链接:多页册只是「同样的夹具当很多页」,101 页 × 12MP/BMP/PNG 真拷贝 + * 要写几百 MB,跑一次测试 I/O 比解码还久(评审复跑成本)。读侧只按路径打开,硬链接完全等价。 */ +static int link_or_copy(const char *src, const char *dst) +{ + if (link(src, dst) == 0) return 0; + return copy_file(src, dst); +} + static int have_file(const char *path) { struct stat st; @@ -464,20 +475,23 @@ int main(void) } int have_jpeg = gen_jpeg_fixtures(jpgbook); - /* 连翻 30 页:6 类典型页循环(12MP / 4MP / baseline / 渐进 / PNG / BMP)。 - * 每类都必须走后端且不占位——这是「修好了却用不上」的回归防线。 */ + /* 顺序连翻 100 页:6 类典型页循环(12MP / 4MP / baseline / 渐进 / PNG / BMP)。 + * 每类都必须走后端且不占位——这是「修好了却用不上」的回归防线。 + * M31 r2(评审 P2-1):101 页顺序遍历(不是 30 页来回跳)——每次翻页都落到**不同的 + * 下一页**,4MP/12MP 夹具都会真正成为焦点页并被解码/换清晰。 */ static const char *const g_mix[] = { "base2000.jpg", "prog2000.jpg", "page1600.png", "page1600.bmp", "mp4.jpg", "mp12.jpg", }; const int mix_n = (int)(sizeof g_mix / sizeof g_mix[0]); + const int mixed_goal = 101; /* 100 次前翻 → 第 101 页(不回卷、不触发册间衔接) */ int mixed_pages = 0; if (have_jpeg) { - for (int i = 0; i < 30; i++) { + for (int i = 0; i < mixed_goal; i++) { char src[MR_PATH_MAX], dst[MR_PATH_MAX]; snprintf(src, sizeof src, "%s/%s", jpgbook, g_mix[i % mix_n]); - snprintf(dst, sizeof dst, "%s/p%02d_%s", mixed, i, g_mix[i % mix_n]); - if (copy_file(src, dst) == 0) mixed_pages++; + snprintf(dst, sizeof dst, "%s/p%03d_%s", mixed, i, g_mix[i % mix_n]); + if (link_or_copy(src, dst) == 0) mixed_pages++; } } /* 回退路径集成:CMYK(4 分量)必须占位且不崩。 */ @@ -768,50 +782,65 @@ int main(void) printf(" SKIP jpeg assertions: Pillow unavailable (python3 -c 'import PIL')\n"); } - /* ---- B8 M27/M31:连翻 100 页(6 类典型页循环)——不掉页、不占位、峰值不增长 ---- - * M31 起这些 1.3–12MP 的页走两级清晰度(低档先上屏、停稳换高档),所以这条同时是 - * 「分档不把峰值/常驻翻倍」的回归:低档与高档在同一槽原地替换,任何时刻的存活增量 - * 仍按单页解码峰值封顶。 */ - if (have_jpeg && mixed_pages == 30) { + /* ---- B8 M27/M31:顺序连翻 100 页(101 页册,6 类典型页循环)——不掉页、不占位、 + * 峰值不增长、预取命中、解码次数有界 ---- + * M31 起这些 1.3–12MP 的页走两级清晰度(低档先上屏、停稳换高档)。评审 P1: + * - 顺序前翻(每页都落到不同的下一页),4MP/12MP 夹具真正成为焦点页; + * - 断言语义翻页命中率(目标页已在槽)与解码次数上限,防止「每翻一页解 4 次」; + * - 存活增量仍按单页解码峰值封顶(两档共存时换清晰前会腾掉非可见页,见 reader.c)。 */ + if (have_jpeg && mixed_pages == mixed_goal) { mr_reader_ui_test_set_mem(4096, 16000); mr_reader_open_dir(mixed); pump_ms(1200); - CHECK(mr_reader_ui_count() == 30, "mixed book pages=%d", mr_reader_ui_count()); - int w = meow_ui_width(); + CHECK(mr_reader_ui_count() == mixed_goal, "mixed book pages=%d", mr_reader_ui_count()); int advanced = 0, skipped_max = 0; size_t live_steady = g_live; int d0 = mr_reader_ui_decode_count(); int low0 = mr_reader_ui_low_decodes(), high0 = mr_reader_ui_high_decodes(); + int hits0 = mr_reader_ui_flip_hits(), miss0 = mr_reader_ui_flip_misses(); /* P2-3:只在窗口稳定后 reset 一次,之后不再 reset——这样淘汰旧图不会掩盖 * 「分配新图 + 解码」的真实峰值;指标 = 整个 100 翻窗口内相对稳态基线的最大存活增量。 * (旧实现每翻页 reset 一次,淘汰把基线拉低,报告里的 536KB 是伪值。) */ size_t base = peak_reset(); for (int i = 0; i < 100; i++) { int before = mr_reader_ui_page(); - tap(i % 2 == 0 ? 40 : w - 40, 300); - pump_ms(220); - if (mr_reader_ui_page() != before) advanced++; + tap(40, 300); /* RTL:左 1/3 = 下一页(顺序前翻) */ + pump_ms(400); /* 700ms/页:给「换清晰→补邻居」留出余量 */ + if (mr_reader_ui_page() == before + 1) advanced++; if (mr_reader_ui_skipped_pages() > skipped_max) skipped_max = mr_reader_ui_skipped_pages(); } size_t transient = g_peak > base ? g_peak - base : 0; - INFO("100 mixed flips: advanced=%d skipped=%d decodes=%d (low=%d high=%d) steady=%zuKB " - "resident=%ldKB max_live_delta=%zuKB", - advanced, skipped_max, mr_reader_ui_decode_count() - d0, + int hits = mr_reader_ui_flip_hits() - hits0; + int miss = mr_reader_ui_flip_misses() - miss0; + int decodes = mr_reader_ui_decode_count() - d0; + INFO("100 mixed flips: advanced=%d last_page=%d hits=%d miss=%d skipped=%d decodes=%d " + "(low=%d high=%d) steady=%zuKB resident=%ldKB max_live_delta=%zuKB abs_peak=%zuKB", + advanced, mr_reader_ui_page(), hits, miss, skipped_max, decodes, mr_reader_ui_low_decodes() - low0, mr_reader_ui_high_decodes() - high0, - live_steady / 1024, mr_reader_ui_resident_kb(), transient / 1024); - CHECK(advanced == 100, "100 页全部翻到 (%d/100)", advanced); + live_steady / 1024, mr_reader_ui_resident_kb(), transient / 1024, g_peak / 1024); + CHECK(advanced == 100 && mr_reader_ui_page() == 100, + "100 次顺序前翻逐页推进(advanced=%d last=%d)", advanced, mr_reader_ui_page()); CHECK(skipped_max == 0, "典型页连翻无占位 (max=%d)", skipped_max); CHECK(mr_reader_ui_failed_pages() == 0, "无永久失败页 (%d)", mr_reader_ui_failed_pages()); CHECK(mr_reader_ui_low_decodes() > low0 && mr_reader_ui_high_decodes() > high0, "大页确实走了两级(low +%d, high +%d)", mr_reader_ui_low_decodes() - low0, mr_reader_ui_high_decodes() - high0); + /* 顺序阅读的预取命中率:评审要求不低于基线 7/8。 */ + CHECK(hits >= 90, "顺序翻页预取命中率 ≥90%%(hit=%d miss=%d)", hits, miss); + /* 解码次数上限:分档页 = 低档 + 高档(2 次/页),非 JPEG 单档(1 次/页)+ 开册/补邻居余量。 + * 出现「每页 4 次」的放大(P1-2 的病灶)时这里会立刻失败。 */ + CHECK(decodes <= 100 * 2 + 20, "100 次翻页解码次数 ≤2/页+余量 (decodes=%d)", decodes); CHECK(mr_reader_ui_resident_kb() <= mr_reader_ui_resident_cap_kb(), "常驻仍在上限内 (%ldKB ≤ %ldKB)", mr_reader_ui_resident_kb(), mr_reader_ui_resident_cap_kb()); CHECK(transient <= 3 * 1024 * 1024, "100 页连翻任何时刻存活增量 ≤3MB (%zuKB)", transient / 1024); + /* 两档共存的总账:常驻(≤3MB)+ 单次解码(≤3MB)——不许因为「基线里已含低档」 + * 就看着没涨;绝对存活峰值同样要有上界。 */ + CHECK(g_peak <= (size_t)(mr_reader_ui_resident_cap_kb() + 3 * 1024) * 1024, + "绝对存活峰值 ≤ 常驻上限+3MB (%zuKB)", g_peak / 1024); meow_view_page_pop(); pump_ms(200); } @@ -819,8 +848,11 @@ int main(void) /* ---- B10 M31:两级清晰度生命周期(2000×3000 JPEG,真机同类大页)---- * 1) 冻结停稳窗口 → 只解低档:焦点页 tier=low、常驻=显示框大小(仍是一次原生 blit), * 主线程不等解码(open 立即返回); - * 2) 放开停稳窗口 → 高档落同一槽:tier=high、槽内字节不变(不翻倍常驻)、槽位全程不空; - * 3) 拖动中强塞的高档请求 → 被 M22 的 gen/窗口取消通道作废(skip=cancel),不会被解出来。 */ + * 2) 放开停稳窗口 → 高档落同一槽:tier=high、**总常驻不增加**(不是恒真的槽内字节比较)、 + * 槽位全程不空、本次操作存活增量 ≤3MB、两档共存绝对峰值有上界; + * 3) 双击放大后按新几何先落低档,再换清晰 → 缩放/平移一字不差(P2-2); + * 4) 拖动中强塞的高档请求 → 被 M22 的 gen/窗口取消通道作废(skip=cancel),不会被解出来; + * 5) 整串生命周期解码次数有界(P2-3)。 */ if (have_jpeg) { char tierbook[MR_PATH_MAX]; snprintf(tierbook, sizeof tierbook, "%s/tier", root); @@ -829,7 +861,7 @@ int main(void) char src[MR_PATH_MAX], dst[MR_PATH_MAX]; snprintf(src, sizeof src, "%s/base2000.jpg", jpgbook); snprintf(dst, sizeof dst, "%s/t%d.jpg", tierbook, i + 1); - if (copy_file(src, dst) != 0) tier_ok = 0; + if (link_or_copy(src, dst) != 0) tier_ok = 0; } if (tier_ok) { mr_reader_ui_test_set_mem(4096, 16000); @@ -843,6 +875,7 @@ int main(void) pump_ms(800); meow_ui_paint_full(); int page0 = mr_reader_ui_page(); + int dec0 = mr_reader_ui_decode_count(); CHECK(mr_reader_ui_focus_tier() == MR_TIER_LOW, "low tier lands first (tier=%d)", mr_reader_ui_focus_tier()); CHECK(mr_reader_ui_low_decodes() >= 1, "low decode issued (%d)", @@ -851,6 +884,7 @@ int main(void) "no high decode before the settle window (%d)", mr_reader_ui_high_decodes()); long low_kb = mr_reader_ui_slot_kb(page0); CHECK(low_kb > 0, "low tier image resident (%ldKB)", low_kb); + long res_low = mr_reader_ui_resident_kb(); int bl = mr_reader_ui_blit_draws(), sc = mr_reader_ui_scaled_draws(); meow_ui_paint_full(); CHECK(mr_reader_ui_blit_draws() > bl && mr_reader_ui_scaled_draws() == sc, @@ -860,12 +894,15 @@ int main(void) /* 放开升级:边等边采样,焦点槽任何时刻都不能空(不闪白)。 */ int high0 = mr_reader_ui_high_decodes(); long min_slot = low_kb; + size_t op_base = g_live; + peak_reset(); mr_reader_ui_test_set_sharpen_ms(0); for (int i = 0; i < 100 && mr_reader_ui_focus_tier() != MR_TIER_HIGH; i++) { pump_ms(10); long k = mr_reader_ui_slot_kb(page0); if (k > 0 && k < min_slot) min_slot = k; } + size_t op_peak = peak_since(op_base); /* 高档这一次操作的存活增量 */ meow_ui_paint_full(); CHECK(mr_reader_ui_page() == page0, "tier swap keeps the page (%d -> %d)", page0, mr_reader_ui_page()); @@ -875,16 +912,53 @@ int main(void) CHECK(mr_reader_ui_high_decodes() > high0, "high decode issued after settle (%d -> %d)", high0, mr_reader_ui_high_decodes()); - long high_kb = mr_reader_ui_slot_kb(page0); - CHECK(high_kb > 0 && high_kb <= low_kb + 8, - "same-slot swap: one page resident, not two (%ldKB -> %ldKB)", low_kb, - high_kb); + /* P2-2:旧的 `slot_kb` 相等是恒真(两档都落同一个 box)。改成真正会抓问题的 + * 「总常驻不增加」:如果换清晰偷偷多留一张低档,这里立刻涨。 */ + long res_high = mr_reader_ui_resident_kb(); + CHECK(res_high > 0 && res_high <= res_low, + "换清晰不增加常驻(没有第二张低档常驻):%ldKB -> %ldKB", + res_low, res_high); + CHECK(op_peak <= 3 * 1024 * 1024, + "高档单次操作的存活增量 ≤3MB (%zuKB)", op_peak / 1024); + /* 两档共存总账:常驻 + 本次解码 的绝对峰值有上界(不是「基线含低档所以没涨」)。 */ + CHECK(g_peak <= (size_t)(mr_reader_ui_resident_cap_kb() + 3 * 1024) * 1024, + "两档共存绝对峰值 ≤ 常驻上限+3MB (%zuKB)", g_peak / 1024); CHECK(min_slot > 0, "no blank frame while swapping tiers (min slot %ldKB)", min_slot); CHECK(mr_reader_ui_scaled_draws() == sc, "high tier still native blit (scaled %d -> %d)", sc, mr_reader_ui_scaled_draws()); + /* P2-2 之二:换清晰不得重置缩放/平移。先冻结窗口 → 双击放大(低档按新几何重解) + * → 记录缩放/平移 → 放开升级 → 高档落位后两者必须一字不差。 */ + mr_reader_ui_test_set_sharpen_ms(60000); /* 先冻结,让放大后的低档先落位 */ + reader_on_touch_down(240, 360); + reader_on_tap(240, 360); + pump_ms(60); + reader_on_touch_down(240, 360); + reader_on_tap(240, 360); + pump_ms(150); + pump_ms(600); + int zoom_before = mr_reader_ui_zoom_pct(); + int panx_before = mr_reader_ui_pan_x(); + int pany_before = mr_reader_ui_pan_y(); + CHECK(zoom_before > 100, "double tap zoomed in (zoom=%d)", zoom_before); + CHECK(mr_reader_ui_focus_tier() == MR_TIER_LOW, + "zoomed page re-decodes to low tier first (tier=%d)", + mr_reader_ui_focus_tier()); + mr_reader_ui_test_set_sharpen_ms(0); + for (int i = 0; i < 100 && mr_reader_ui_focus_tier() != MR_TIER_HIGH; i++) + pump_ms(10); + CHECK(mr_reader_ui_focus_tier() == MR_TIER_HIGH, + "zoomed page reaches the target tier (tier=%d)", + mr_reader_ui_focus_tier()); + CHECK(mr_reader_ui_zoom_pct() == zoom_before && + mr_reader_ui_pan_x() == panx_before && + mr_reader_ui_pan_y() == pany_before, + "换清晰不重置缩放/平移(zoom %d→%d, pan %d,%d→%d,%d)", + zoom_before, mr_reader_ui_zoom_pct(), panx_before, pany_before, + mr_reader_ui_pan_x(), mr_reader_ui_pan_y()); + /* 拖动中取消高档:拖动期间强塞一个「换清晰」请求,必须被取消通道作废。 */ int hd0 = mr_reader_ui_high_decodes(), hc0 = mr_reader_ui_high_canceled(); reader_on_touch_down(240, 400); @@ -900,7 +974,40 @@ int main(void) mr_reader_ui_high_decodes()); reader_on_tap(240, 400); /* 松手:回弹,不翻页 */ pump_ms(300); - mr_reader_ui_test_set_sharpen_ms(200); /* 恢复产品默认停稳窗口 */ + + /* P2-2 之三:条漫滚动位置在换清晰时不得被重置(换的是视口中心页的档位)。 */ + mr_reader_ui_test_set_sharpen_ms(60000); /* 先冻结:切模式时不要立刻换清晰 */ + mr_reader_test_toggle_mode(); /* 翻页 → 条漫 */ + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "tier book switched to webtoon"); + pump_ms(300); + reader_on_touch_down(240, 600); + for (int k = 1; k <= 5; k++) { + reader_on_touch_move(240, 600 - k * 50); + pump_ms(10); + } + reader_on_tap(240, 300); /* 松手:fling */ + pump_ms(2000); /* 等 fling 停稳 */ + int woff0 = mr_reader_ui_woff(); + int wpage0 = mr_reader_ui_page(); + CHECK(woff0 > 0, "webtoon scrolled to a non-top offset (%d px)", woff0); + CHECK(mr_reader_ui_focus_tier() == MR_TIER_LOW, + "webtoon center page is low tier before settle (tier=%d)", + mr_reader_ui_focus_tier()); + mr_reader_ui_test_set_sharpen_ms(0); + for (int i = 0; i < 200 && mr_reader_ui_focus_tier() != MR_TIER_HIGH; i++) + pump_ms(10); + CHECK(mr_reader_ui_focus_tier() == MR_TIER_HIGH, + "webtoon center page reaches the target tier (tier=%d)", + mr_reader_ui_focus_tier()); + CHECK(mr_reader_ui_woff() == woff0 && mr_reader_ui_page() == wpage0, + "换清晰不重置条漫滚动位置(woff %d→%d, page %d→%d)", + woff0, mr_reader_ui_woff(), wpage0, mr_reader_ui_page()); + mr_reader_ui_test_set_sharpen_ms(150); /* 恢复产品默认停稳窗口 */ + /* P2-3:这一串(开册低+高、放大低+高、条漫低+高、一次被取消的高档)解码次数有界。 */ + int decodes = mr_reader_ui_decode_count() - dec0; + CHECK(decodes <= 12, "两档生命周期解码次数有界 (decodes=%d)", decodes); + INFO("tier lifecycle: decodes=%d resident=%ldKB (低档/高档/放大/条漫 各一次换清晰)", + decodes, mr_reader_ui_resident_kb()); meow_view_page_pop(); pump_ms(200); } diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index d739ef7..de81bbc 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -93,12 +93,18 @@ * 大页(真机 1280×2275 单页解码 392ms)打开/翻到时先按「显示长边的一半」解一版低档, * 落框后立刻上屏;停稳 MR_SHARPEN_MS 后再解目标档位替换同一槽。低档与高档都**精确落到 * 显示框**,所以换图那一下尺寸不变:仍是一次原生 blit,不闪白、不重置缩放/滚动。 - * 内存:同页只占一个槽(高档落地时替换低档),低档的额外峰值只是「小图 + 一次落框重采样」, - * 已由 page_source 的峰值模型计入(step>1 且输出≠显示框时的 +box)。 */ -#define MR_SHARPEN_MS 200 /* 停稳多久后才发起高档解码(150–300ms 区间取中) */ + * 内存(r2,评审 P1-1):高档解码时低档还占着槽,所以「一次操作的总峰值」= + * resident_bytes()(至少含焦点槽低档)+ reader_estimate_peak()(本次解码的峰值); + * 该总峰值受 MR_TIER_TOTAL_CAP 约束(与 page_source 的 12MB 绝对上限同值——常态 3MB + * 只约束 stb 回退路径,后端页连 3MB 都超却已被 M27 放行,拿 3MB 当两档共存上限会让 + * 「先糊后清」在最需要的大页上永远不触发)。超限 → 跳过高档、保留低档(skip=coexist)。 + * 另两条纪律把常驻压到最低(评审 P1-2):分页模式焦点页还是低档时不预取邻居(停稳 150ms 后 + * 就会换清晰,预取来也会被挤掉),高档落位后再按翻页方向补回邻居。 */ +#define MR_SHARPEN_MS 150 /* 停稳多久后才发起高档解码(任务允许 150–300ms;取 150 给「换清晰→补邻居」留出翻页余量) */ #define MR_TIER_MIN_PIXELS 1000000LL /* 源像素少于此值不分档:解本身不慢,分档只添乱 */ #define MR_LOW_MIN_LONG 240 /* 低档输出长边下限(再粗就糊到难看) */ #define MR_LOW_MAX_LONG 480 /* 低档输出长边上限(再细省不了多少解码) */ +#define MR_TIER_TOTAL_CAP (12LL * 1024 * 1024) /* 两档共存:常驻 + 单次解码 的绝对上限 */ /* MR_TIER_LOW / MR_TIER_HIGH 定义在 ui/reader/reader.h(宿主测试也要用同一套档位值)。 */ @@ -197,6 +203,8 @@ typedef struct decode_plan { int want_w, want_h; int box_w, box_h; int tier; /* MR_TIER_LOW / MR_TIER_HIGH(M31) */ + int sharpen; /* 1=「停稳换清晰」的追加请求(要算两档共存峰值) */ + long long resident; /* 发起这次请求时已在槽里的字节(主线程快照) */ } decode_plan; static decode_plan g_plan; static int g_plan_page; /* 本次解码的页号(只用于日志) */ @@ -207,6 +215,7 @@ static volatile int g_decode_count; /* 累计解码次数(观察重解 #define MR_SKIP_NONE 0 /* 解出来了 */ #define MR_SKIP_LOWMEM 1 /* 内存门控拦下(MemAvailable 不足) */ #define MR_SKIP_PEAK 2 /* 真实峰值超预算,且没有余量放行 */ +#define MR_SKIP_COEXIST 3 /* 两档共存总峰值超上限:跳过高档、保留低档 */ static int page_failed(int idx) { @@ -262,6 +271,7 @@ typedef struct page_req { int box_w, box_h; /* 落地上限(单页字节上限钳制后的目标尺寸) */ unsigned char tier; /* MR_TIER_*(M31:低档过渡 / 目标档位) */ unsigned char sharpen; /* 1=「停稳后换清晰」的追加请求:动起来就作废 */ + long long resident; /* 发起时的常驻字节(M31 r2:高档要算两档共存峰值) */ } page_req; typedef struct page_res { int idx; @@ -271,6 +281,7 @@ typedef struct page_res { int skip; /* MR_SKIP_*:非 0 = 预算跳过(可重试,不是坏图) */ int want_w, want_h; unsigned char tier; /* MR_TIER_*:结果按哪一档解出来的 */ + unsigned char sharpen; /* 1=这是一次「停稳换清晰」的结果(落位后要补邻居) */ } page_res; static pthread_t g_worker; @@ -288,14 +299,24 @@ static int g_res_n; * g_sharpen_idx:当前唯一允许解高档的页(-1=拖动/未停稳/无需升级)。worker 在锁内据此 * 作废「在飞的高档请求」——复用 M22 已有的 gen/窗口取消机制,只是多一条判据。 * g_move_ms:最近一次「动」的时刻(按下/拖动/吸附动画/条漫滚动/fling/换页); - * 停稳 MR_SHARPEN_MS 后才允许升级。宿主测试可用 mr_reader_ui_test_set_sharpen_ms 改写窗口。 */ + * 停稳 MR_SHARPEN_MS 后才允许升级。宿主测试可用 mr_reader_ui_test_set_sharpen_ms 改写窗口。 + * g_step_dir:最近一次翻页/跳页的方向(+1 页号增,-1 页号减);高档落位后按这个方向优先 + * 补邻居(P1-2:不在反方向白解一张马上会被挤掉的图)。 + * g_refill:高档落位 → tick 里补回邻居(换清晰会为省峰值腾掉邻居,不补就每次翻页都 miss)。 */ static int g_sharpen_idx = -1; -static int g_hi_inflight = -1; /* 已排队/正在解的目标档位页(去重用;锁内读写) */ +/* 每页是否有请求已排队/正在 worker 手里(去重)。M31 r2(评审 P1-2):只靠队列扫描不够—— + * 请求被 worker 取走后就不在队列里了,窗口刷新会再排一次同一页(重复解码 + 挤掉刚预取的 + * 邻居,正是「每翻一页解 4 次 / 0 命中」的来源)。锁内读写。 */ +static unsigned char g_req_inflight[MR_BOOK_MAX_PAGES]; static uint64_t g_move_ms; static int g_sharpen_ms = MR_SHARPEN_MS; +static int g_step_dir = 1; +static int g_refill; static volatile int g_low_decodes; /* 完成的低档解码次数(宿主测试/日志对照) */ static volatile int g_high_decodes; /* 完成的目标档位解码次数 */ static volatile int g_high_canceled; /* 被取消的高档请求数(拖动/换页/停稳失效) */ +static volatile int g_flip_hits; /* 翻页时目标页已在槽(预取命中)次数 */ +static volatile int g_flip_misses; /* 翻页时目标页还没解好(要等解码)次数 */ /* ---- 节拍/动画 ---- */ static meow_anim *g_ticker; @@ -350,6 +371,7 @@ static page_slot *slot_find(int idx); static void cont_touch(void); static void cont_save_now(void); static int current_page(void); +static int page_needs_tier(int idx); /* ============================================================ */ /* 内存读数与观测(M22) */ @@ -574,6 +596,17 @@ static meow_img *reader_load_page(const char *path, void *ud, int maxdim) estimate_for(&in, load_dim, box_w, box_h, mfree, mavail, &dec); } g_decode_tier = tier; /* 实际生效的档位(worker 据此计数/落槽) */ + /* 两档共存(M31 r2 / 评审 P1-1):高档解码时低档仍在槽里显示,所以这次操作的 + * 真实峰值 = 已在槽里的常驻 + 本次解码峰值。M27 的模型只描述后者;这里把前者 + * 加进同一个上限(MR_TIER_TOTAL_CAP),超了就跳过高档、保留低档(页面仍可读)。 */ + if (g_plan.sharpen && dec.skip == MR_DECODE_OK && + g_plan.resident + dec.peak_bytes > MR_TIER_TOTAL_CAP) { + g_decode_skip = MR_SKIP_COEXIST; + log_decode(page, in.w, in.h, dec.peak_bytes, mfree, mavail, "skip=coexist", + dec.step, dec.path == MR_DECODE_PATH_BACKEND, tier, + (unsigned long long)(meow_ui_time_ms() - t0)); + return NULL; + } if (dec.skip != MR_DECODE_OK) { g_decode_skip = dec.skip == MR_DECODE_SKIP_LOWMEM ? MR_SKIP_LOWMEM : MR_SKIP_PEAK; log_decode(page, in.w, in.h, dec.peak_bytes, mfree, mavail, @@ -937,11 +970,20 @@ static int req_queued(int idx) } /* 预取闸门(任务 4):拖动/惯性期间只解落点页,不做邻居预取;且只在内存确实富裕时预取。 - * MemAvailable 读不到按「不预取」处理(保守)。 */ + * MemAvailable 读不到按「不预取」处理(保守)。 + * M31 r2(评审 P1-2):分页模式下焦点页还是低档 → 停稳 150ms 后就要换清晰,而换清晰为了 + * 压低两档共存峰值会腾掉非可见页;此刻预取的邻居正好会被挤掉(白解一次,且换完不补就 + * 每次翻页 miss)。所以「焦点页等着换清晰」期间不预取邻居,等高档落位后由 sharpen_refill() + * 按翻页方向补回来。低档已放弃升级(hi_tried)或焦点已清晰时恢复正常预取。 */ static int prefetch_allowed(void) { if (g_dragging) return 0; if (g_scroll && meow_scroll_active(g_scroll)) return 0; + if (g_mode == MR_MODE_PAGED) { + int f = focus_page(); + page_slot *s = f >= 0 ? slot_find(f) : NULL; + if (s && s->tier == MR_TIER_LOW && !s->hi_tried && page_needs_tier(f)) return 0; + } long a = mem_avail_kb(); if (a < 0) return 0; return a >= MR_PREFETCH_MIN_KB; @@ -970,10 +1012,11 @@ static int queue_page_tier(int idx, int tier, int sharpen) slots_reserve((long)box_w * box_h * 4); int queued = 0; pthread_mutex_lock(&g_lock); - /* 去重:已排队(req_queued)或「换清晰」请求正在 worker 手里(g_hi_inflight)都不再发。 - * 后者必须单列:请求被 worker 取走后就不在队列里了,只看 req_queued 会每 tick 重发一次。 */ - if (!g_stop && !req_queued(idx) && g_req_n < MR_REQ_MAX && - !(sharpen && g_hi_inflight == idx)) { + /* 去重:同一页只要已排队或已交给 worker(g_req_inflight)就不再发。后者必须单列: + * 请求被 worker 取走后就不在队列里了,只看 req_queued 会在每次窗口刷新时重排一页, + * 重复解码还会把刚预取的邻居挤掉(评审 P1-2 的 4 次/页、0 命中)。 */ + int busy = idx >= 0 && idx < MR_BOOK_MAX_PAGES && g_req_inflight[idx]; + if (!g_stop && !busy && !req_queued(idx) && g_req_n < MR_REQ_MAX) { page_req *r = &g_req[g_req_tail]; r->idx = idx; r->gen = g_gen; @@ -983,9 +1026,10 @@ static int queue_page_tier(int idx, int tier, int sharpen) r->box_h = box_h; r->tier = (unsigned char)(tier == MR_TIER_LOW ? MR_TIER_LOW : MR_TIER_HIGH); r->sharpen = (unsigned char)(sharpen ? 1 : 0); + r->resident = (long long)resident_bytes(); /* 腾完地方之后的常驻快照(两档共存峰值) */ g_req_tail = (g_req_tail + 1) % MR_REQ_MAX; g_req_n++; - if (sharpen) g_hi_inflight = idx; + if (idx >= 0 && idx < MR_BOOK_MAX_PAGES) g_req_inflight[idx] = 1; queued = 1; pthread_cond_signal(&g_cv); } @@ -999,6 +1043,7 @@ static void queue_page(int idx) /* 永久失败与「仍在跳过期」的页都不排;后者由 maybe_retry_skipped_focus 解封。 */ if (page_blocked(idx)) return; page_slot *s = slot_find(idx); + if (g_req_inflight[idx]) return; /* 已排队/在解:别重排(重复解码会挤掉邻居) */ if (s && slot_ready(idx)) { /* 已经能显示:只有「低档 → 目标档位」这一种情况还要再解,而且必须是当前焦点页、 * 已停稳(g_sharpen_idx 由 tier_pump 在去抖窗口后置位)、并且最近一次升级没失败过。 @@ -1035,7 +1080,7 @@ static void *reader_worker(void *arg) stale = 1; stale_motion = 1; } - if (stale && req.sharpen && g_hi_inflight == req.idx) g_hi_inflight = -1; + if (stale && req.idx >= 0 && req.idx < MR_BOOK_MAX_PAGES) g_req_inflight[req.idx] = 0; pthread_mutex_unlock(&g_lock); if (stale) { if (stale_motion) g_high_canceled++; @@ -1052,6 +1097,8 @@ static void *reader_worker(void *arg) g_plan.box_w = req.box_w; g_plan.box_h = req.box_h; g_plan.tier = req.tier; + g_plan.sharpen = req.sharpen; + g_plan.resident = req.resident; g_plan_page = req.idx; g_decode_skip = MR_SKIP_NONE; g_decode_tier = MR_TIER_HIGH; @@ -1074,11 +1121,12 @@ static void *reader_worker(void *arg) g_res[g_res_n].want_w = req.want_w; g_res[g_res_n].want_h = req.want_h; g_res[g_res_n].tier = (unsigned char)eff_tier; + g_res[g_res_n].sharpen = req.sharpen; g_res_n++; img = NULL; /* 所有权已移交(失败/跳过时本就是 NULL) */ } - /* 本页的「换清晰」请求已经走完(无论落不落槽):解除去重标记,允许下一轮重新判定。 */ - if (req.sharpen && g_hi_inflight == req.idx) g_hi_inflight = -1; + /* 本页请求已经走完(无论落不落槽):解除去重标记,允许下一轮重新判定。 */ + if (req.idx >= 0 && req.idx < MR_BOOK_MAX_PAGES) g_req_inflight[req.idx] = 0; pthread_mutex_unlock(&g_lock); if (img) meow_img_free(img); } @@ -1107,7 +1155,7 @@ static void worker_stop(void) g_res_n = 0; g_req_n = 0; g_req_head = g_req_tail = 0; - g_hi_inflight = -1; + memset(g_req_inflight, 0, sizeof g_req_inflight); pthread_mutex_unlock(&g_lock); } @@ -1120,16 +1168,22 @@ static void queue_prune(void) int n = 0; for (int i = 0; i < g_req_n; i++) { page_req r = g_req[(g_req_head + i) % MR_REQ_MAX]; - if (r.gen != g_gen || !in_window(r.idx)) continue; - if (r.sharpen && r.idx != g_sharpen_idx) { - if (g_hi_inflight == r.idx) g_hi_inflight = -1; + int drop = 0; + if (r.gen != g_gen || !in_window(r.idx)) drop = 1; + if (!drop && r.sharpen && r.idx != g_sharpen_idx) { g_high_canceled++; - continue; + drop = 1; } /* 几何已变的请求作废(否则会按旧显示框解出来又被判过期)。 */ - int w = 0, h = 0; - page_geometry_at(r.idx, &w, &h); - if (abs(w - r.want_w) > MR_SLOT_TOL || abs(h - r.want_h) > MR_SLOT_TOL) continue; + if (!drop) { + int w = 0, h = 0; + page_geometry_at(r.idx, &w, &h); + if (abs(w - r.want_w) > MR_SLOT_TOL || abs(h - r.want_h) > MR_SLOT_TOL) drop = 1; + } + if (drop) { + if (r.idx >= 0 && r.idx < MR_BOOK_MAX_PAGES) g_req_inflight[r.idx] = 0; + continue; + } keep[n++] = r; } for (int i = 0; i < n; i++) g_req[i] = keep[i]; @@ -1159,7 +1213,7 @@ static int results_drain(void) page_slot *low = got[i].tier == MR_TIER_HIGH ? slot_find(got[i].idx) : NULL; if (low && low->tier != MR_TIER_LOW) low = NULL; /* 已有目标档:不是「低档兜底」 */ if (got[i].skip) { - /* 预算跳过(内存不足/峰值超限):可重试,不写永久失败位。 */ + /* 预算跳过(内存不足/峰值超限/两档共存超上限):可重试,不写永久失败位。 */ if (low) { low->hi_tried = 1; continue; } /* 低档在屏:降级显示,不占位 */ page_mark_skipped(got[i].idx); if (page_visible(got[i].idx)) g_fail_paint = 1; @@ -1176,6 +1230,9 @@ static int results_drain(void) got[i].tier) && page_visible(got[i].idx)) changed = 1; page_skip_clear(got[i].idx); /* 解出来了:撤销跳过标记 */ + /* 换清晰落位:换清晰前为压峰值腾掉了邻居,这里补回(P1-2)。 + * 普通预取/首次解码不腾邻居,不需要补。 */ + if (got[i].sharpen) g_refill = 1; } else { meow_img_free(got[i].img); } @@ -1285,6 +1342,20 @@ static void tier_pump(uint64_t now) queue_page(f); /* 幂等:已排队/在飞/已清晰/已试过都直接返回 */ } +/* 高档落位后补邻居(M31 r2 / 评审 P1-2)。 + * 换清晰前 slots_reserve 会腾掉非可见页来压两档共存峰值,顺序翻页时被腾掉的正是 + * 「下一页」的预取;不补回来,每次翻页都会 miss(基线 8/8 → 分档后 0/8)。 + * 只补最近一次翻页方向上的邻居:反方向那张在 3MB 常驻上限下放不下(必然被下一次预取 + * 挤掉),白解一次还抬高解码次数;回头看时按需解一版低档——低档本来就是为这个设计的。 */ +static void sharpen_refill(void) +{ + if (g_mode != MR_MODE_PAGED || g_count <= 0 || g_quitting) return; + int f = focus_page(); + if (f < 0) return; + int fwd = f + (g_step_dir >= 0 ? 1 : -1); + if (fwd >= 0 && fwd < g_count && !slot_find(fwd)) queue_page(fwd); +} + /* ============================================================ */ /* 条漫排版 + 滚动会话 */ /* ============================================================ */ @@ -1636,9 +1707,11 @@ static void pan_by(int dx, int dy) static void commit_page(int target) { + int prev = g_page; target = mr_nav_clamp_page(target, g_count); if (target < 0) return; g_page = target; + if (target != prev) g_step_dir = target > prev ? 1 : -1; /* 高档落位后按这个方向补邻居 */ g_zoom_pct = 100; g_pan_x = g_pan_y = 0; settle_reset(); /* 换页:停稳计时重来(拖动/连翻期间不解高档) */ @@ -1748,11 +1821,13 @@ static void page_flip(int delta) g_flip_t0 = meow_ui_time_ms(); if (slot_find(target)) { + g_flip_hits++; if (start_slide(delta, 0)) meow_log(MEOW_LOG_INFO, "reader flip %d->%d hit", g_page + 1, target + 1); else meow_log(MEOW_LOG_INFO, "reader flip %d->%d hit (no anim slot)", g_page + 1, target + 1); } else { + g_flip_misses++; meow_log(MEOW_LOG_INFO, "reader flip %d->%d miss (decode pending)", g_page + 1, target + 1); commit_page(target); } @@ -1764,8 +1839,10 @@ static void page_flip(int delta) static void jump_land(int page, int timed_out) { if (g_count <= 0) return; + int prev = current_page(); page = mr_nav_clamp_page(page, g_count); if (page < 0) page = 0; + if (page != prev) g_step_dir = page > prev ? 1 : -1; /* 高档落位后按跳页方向补邻居 */ if (g_mode == MR_MODE_WEBTOON && g_strip_ok) { if (g_scroll) meow_scroll_stop(g_scroll); g_woff = mr_strip_offset_for_page(&g_strip, page); @@ -1907,8 +1984,10 @@ static void open_book_reset_state(void) g_toast[0] = '\0'; g_title_page = -1; g_sharpen_idx = -1; /* 新册:高档资格清零,停稳计时重来 */ - g_hi_inflight = -1; + memset(g_req_inflight, 0, sizeof g_req_inflight); g_move_ms = meow_ui_time_ms(); + g_step_dir = 1; + g_refill = 0; free(g_meta); g_meta = NULL; } @@ -2092,8 +2171,10 @@ static void drag_finish(void) } g_flip_t0 = meow_ui_time_ms(); if (slot_find(target) && start_slide(delta, dx)) { + g_flip_hits++; meow_log(MEOW_LOG_INFO, "reader drag flip %d->%d hit", g_page + 1, target + 1); } else { + g_flip_misses++; meow_log(MEOW_LOG_INFO, "reader drag flip %d->%d miss (decode pending)", g_page + 1, target + 1); commit_page(target); @@ -2406,6 +2487,12 @@ static void reader_tick(void) /* 只有当落位的页真的在屏幕上(或换了几何的当前页)才重绘,预取邻居不刷屏。 */ if (results_drain() && !g_dragging) invalidate_content(); + /* 高档落位后补回换清晰时为压峰值腾掉的邻居(P1-2:否则顺序翻页每次都 miss)。 */ + if (g_refill) { + g_refill = 0; + if (!g_dragging) sharpen_refill(); + } + /* 两级清晰度:停稳去抖(拖动/滚动期间只留低档,停稳后才补高档)。 */ tier_pump(now); @@ -2566,11 +2653,15 @@ static void reader_state_reset(void) g_decode_skip = MR_SKIP_NONE; g_decode_tier = MR_TIER_HIGH; g_sharpen_idx = -1; - g_hi_inflight = -1; + memset(g_req_inflight, 0, sizeof g_req_inflight); g_move_ms = meow_ui_time_ms(); + g_step_dir = 1; + g_refill = 0; g_low_decodes = 0; g_high_decodes = 0; g_high_canceled = 0; + g_flip_hits = 0; + g_flip_misses = 0; } static void reader_page_enter(meow_view *v, void *ctx) @@ -2810,6 +2901,14 @@ int mr_reader_ui_low_decodes(void) { return g_low_decodes; } int mr_reader_ui_high_decodes(void) { return g_high_decodes; } int mr_reader_ui_high_canceled(void) { return g_high_canceled; } int mr_reader_ui_sharpen_idx(void) { return g_sharpen_idx; } +/* 翻页命中/未命中:目标页已在槽(= 预取生效,翻页不用等解码)/ 还没解好。 */ +int mr_reader_ui_flip_hits(void) { return g_flip_hits; } +int mr_reader_ui_flip_misses(void) { return g_flip_misses; } +/* 视图位置(M31 r2 评审 P2-2:换清晰不得重置缩放/平移/条漫滚动位置)。 */ +int mr_reader_ui_zoom_pct(void) { return g_zoom_pct; } +int mr_reader_ui_pan_x(void) { return g_pan_x; } +int mr_reader_ui_pan_y(void) { return g_pan_y; } +int mr_reader_ui_woff(void) { return g_woff; } /* 宿主测试改写「停稳」窗口:0=立刻允许高档(同步验证落位),很大=冻结高档(验证低档先落位)。 */ void mr_reader_ui_test_set_sharpen_ms(int ms) { diff --git a/tuku/ui/reader/reader.h b/tuku/ui/reader/reader.h index 960a43b..c9c9df1 100644 --- a/tuku/ui/reader/reader.h +++ b/tuku/ui/reader/reader.h @@ -64,7 +64,13 @@ int mr_reader_ui_low_decodes(void); /* 低档解码次数 */ int mr_reader_ui_high_decodes(void);/* 目标档位解码次数 */ int mr_reader_ui_high_canceled(void);/* 被取消(拖动/换页/未停稳)的高档请求数 */ int mr_reader_ui_sharpen_idx(void); /* 当前允许升级的页;-1=拖动/未停稳/无需升级 */ -/* 改写「停稳」窗口(默认 MR_SHARPEN_MS=200ms):0=立刻允许高档,很大=冻结高档。 */ +int mr_reader_ui_flip_hits(void); /* 翻页时目标页已在槽(预取命中,不用等解码)次数 */ +int mr_reader_ui_flip_misses(void); /* 翻页时目标页还没解好(要等解码)次数 */ +int mr_reader_ui_zoom_pct(void); /* 当前缩放档(100=未放大) */ +int mr_reader_ui_pan_x(void); /* 当前平移(px) */ +int mr_reader_ui_pan_y(void); +int mr_reader_ui_woff(void); /* 条漫滚动偏移(px) */ +/* 改写「停稳」窗口(默认 MR_SHARPEN_MS=150ms):0=立刻允许高档,很大=冻结高档。 */ void mr_reader_ui_test_set_sharpen_ms(int ms); /* 绕过停稳判定强排一个「换清晰」请求(验证拖动中会被 M22 的取消通道作废)。 */ void mr_reader_ui_test_queue_high(int idx);