From 462cfd5bc0220320e19f32ea9f19c71d7ffbb77b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 18:06:26 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E9=98=85=E8=AF=BB?= =?UTF-8?q?=E5=99=A8=E7=BF=BB=E9=A1=B5=E6=8C=89=E6=98=BE=E7=A4=BA=E5=B0=BA?= =?UTF-8?q?=E5=AF=B8=E8=A7=A3=E7=A0=81=EF=BC=88=E9=9B=B6=E9=87=8D=E9=87=87?= =?UTF-8?q?=E6=A0=B7=EF=BC=89+=20=E5=A4=9A=E9=A1=B5=E9=97=AA=E9=80=80?= =?UTF-8?q?=E9=98=B2=E5=BE=A1=20+=20=E4=BB=8E=E7=AC=AC=20N=20=E5=BC=A0?= =?UTF-8?q?=E5=BC=80=E5=A7=8B=EF=BC=88M17=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈「横竖翻页都卡(40 页也卡)」「140 张图的文件夹一打开就闪退」 「要从中间某张开始看」三条,本次分别处理: 翻页卡顿(真因:每帧一次重采样) - 解码目标从「屏长边」改成「当前 fit/zoom 的显示尺寸」:适宽 3:4 页原来解成 540x720、显示 480x640,draw_page_at 于是每帧走 meow_ui_img_scaled —— 每页每帧 多一遍全图最近邻重采样 + 一次 1.2MB 临时 malloc/free(>128KB 走 mmap)。 - 现在解码结果与显示框一致(±2px 容差)→ 翻页/滚动热路径只有原生 blit。 - fit/zoom 变化时按新几何重解(slot_ready 判据 + 同页替换),不再残留缩放; 已到解码预算或已是原始分辨率的小图不会反复重解。 - 拖动/吸附/fling 帧只 invalidate 内容区(顶栏静态,不再每帧重绘标题文字); 异步落位只在「该页真的在屏幕上」时才重绘,预取邻居不刷屏。 - 页原始尺寸按册缓存(8B/页),绘制几何不再每帧 fopen probe 文件。 多页闪退(宿主未能复现,按内存边界防御) - 常驻页图改为「显示尺寸」并加上限:单页 ≤3MB、整窗 ≤9MB(超限淘汰最远页); 常驻内存只与解码窗大小有关,不随页数增长。 - 解码中间峰值 >12MB 的页直接跳过并显示占位 + WARN(旧代码 PNG/BMP 放到 40MB, 3000x3000 就是 36MB 瞬时分配;JPEG 完全没上限)。140 页夹具(含坏图/超大图) 走真实 UI 链路打开、翻页、跳页、条漫全部通过。 新增 mr_reader_open_dir_at(dir, start_index)(文件管理器模式入口) - 显式下标优先于续读记录;-1=续读(旧入口语义),其它负数钳到 0,≥页数钳到末页。 - mr_reader_open_dir 保留为 (dir, MR_READER_START_RESUME) 的包装;reader.h 导出。 测试:新增 test_reader_perf(翻页零重采样/fit 切换/驻留上界)与 test_reader_many_pages(140 页 + 坏图/超大图 + 起始下标/钳制/续读优先级 + 条漫跳页)。 --- tuku/test/run_tests.sh | 34 +++ tuku/test/test_reader_many_pages.c | 303 ++++++++++++++++++++++++ tuku/test/test_reader_perf.c | 222 ++++++++++++++++++ tuku/ui/reader/reader.c | 357 +++++++++++++++++++++++------ tuku/ui/reader/reader.h | 13 ++ 5 files changed, 858 insertions(+), 71 deletions(-) create mode 100644 tuku/test/test_reader_many_pages.c create mode 100644 tuku/test/test_reader_perf.c diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index 92debcd..f55bec8 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -107,6 +107,40 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ -pthread -lm -lz -o "$OUT"/test_open_book_host "$OUT"/test_open_book_host + + # 翻页热路径(M17):解码=显示尺寸 → 翻页零重采样;fit 切换按新几何重解。 + gcc -Wall -Wextra -O1 -DMEOW_UI_HOST_TEST -DMR_READER_HOST_TEST -I. -I"$SDK" -I"$SDK/include" \ + test/test_reader_perf.c ui/reader/reader.c \ + logic/library.c logic/reader.c logic/reader/page_source.c \ + logic/reader/archive_source.c logic/reader/page_cache.c logic/zip/zip.c \ + logic/reader/nav.c \ + "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ + "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ + "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ + "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ + "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ + "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ + -pthread -lm -lz -o "$OUT"/test_reader_perf + "$OUT"/test_reader_perf + + # 140 页夹打开/翻页/跳页 + mr_reader_open_dir_at(文件管理器模式入口)。 + gcc -Wall -Wextra -O1 -DMEOW_UI_HOST_TEST -DMR_READER_HOST_TEST -I. -I"$SDK" -I"$SDK/include" \ + test/test_reader_many_pages.c ui/reader/reader.c \ + logic/library.c logic/reader.c logic/reader/page_source.c \ + logic/reader/archive_source.c logic/reader/page_cache.c logic/zip/zip.c \ + logic/reader/nav.c \ + "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ + "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ + "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ + "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ + "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ + "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ + -pthread -lm -lz -o "$OUT"/test_reader_many_pages + "$OUT"/test_reader_many_pages else echo "SKIP test_settings_lock_host/test_browse_ui_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" fi diff --git a/tuku/test/test_reader_many_pages.c b/tuku/test/test_reader_many_pages.c new file mode 100644 index 0000000..d81a1ba --- /dev/null +++ b/tuku/test/test_reader_many_pages.c @@ -0,0 +1,303 @@ +/* test_reader_many_pages.c — 多页(140 张)文件夹打开/翻页/跳页回归 +「从第 N 张开始」API。 + * + * 用户反馈:「文件夹里图片过多时(例如 140 个),APP 会直接闪退」;且后续书库要改成文件 + * 管理器模式,需要「从选中的那张开始看」。本测试走真实 SDK UI 链路(view/activity/anim/ + * 真实解码),断言: + * M1 140 页册可打开,页数/模式正确(翻页模式、第 1 页); + * M2 mr_reader_open_dir_at(dir, 57):从第 57 页(0 基)开始;越界 9999 → 末页;负数 → 首页; + * M3 显式下标优先于续读记录;mr_reader_open_dir 仍走续读记录(旧语义不变); + * M4 连续翻页穿过坏图(解码失败)不崩、不卡死,坏页给占位后继续翻; + * M5 条漫模式:140 页排版/跳页/滚动可用; + * M6 常驻页图有字节上界(<=9MB)——页数增长不会线性吃内存。 + * + * 编译:test/run_tests.sh(与 test_open_book_host 同口径;只发布 meow_ui.a 时 SKIP)。 + */ +#include "tuku_internal.h" +#include "ui/reader/reader.h" +#include "meow_ui_internal.h" + +#include +#include +#include +#include +#include +#include + +#define BOOK_PAGES 140 + +void mr_nav_report(void) {} + +static int g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { printf(" PASS\n"); } \ + else { g_fail++; printf(" FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +static void put16(unsigned char *p, unsigned v) { p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8); } +static void put32(unsigned char *p, unsigned v) +{ + p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8); + p[2] = (unsigned char)(v >> 16); p[3] = (unsigned char)(v >> 24); +} + +static int write_bmp24(const char *path, int w, int h, unsigned seed) +{ + int stride = ((w * 3 + 3) / 4) * 4; + int imgsz = stride * h; + unsigned char fh[14] = { 0 }, ih[40] = { 0 }; + fh[0] = 'B'; fh[1] = 'M'; + put32(fh + 2, (unsigned)(54 + imgsz)); + put32(fh + 10, 54); + put32(ih + 0, 40); + put32(ih + 4, (unsigned)w); + put32(ih + 8, (unsigned)h); + put16(ih + 12, 1); + put16(ih + 14, 24); + put32(ih + 20, (unsigned)imgsz); + + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = fwrite(fh, 1, sizeof fh, f) == sizeof fh && + fwrite(ih, 1, sizeof ih, f) == sizeof ih; + unsigned char *row = (unsigned char *)calloc(1, (size_t)stride); + if (!row) ok = 0; + for (int y = 0; ok && y < h; y++) { + for (int x = 0; x < w; x++) { + row[x * 3 + 0] = (unsigned char)((x + seed) & 0xFF); + row[x * 3 + 1] = (unsigned char)((y + seed) & 0xFF); + row[x * 3 + 2] = (unsigned char)(seed & 0xFF); + } + if (fwrite(row, 1, (size_t)stride, f) != (size_t)stride) ok = 0; + } + free(row); + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + +static int write_garbage(const char *path) +{ + static const unsigned char junk[64] = { 'N', 'O', 'T', 'A', 'N', 'I', 'M', 'G' }; + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = fwrite(junk, 1, sizeof junk, f) == sizeof junk; + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + +/* 合法 BMP24 头(声称 3000x3000,无像素数据):stbi_info 能读到尺寸, + * 整幅 RGBA 峰值 36MB 必须被 MR_DECODE_PEAK_CAP(12MB) 在解码前拦下 + * (旧代码的 raw 上限是 40MB,这种页会被放行 → 真机上瞬时分配即可能被 OOM 杀掉)。 */ +static int write_huge_bmp_header(const char *path) +{ + unsigned char b[54] = { 0 }; + b[0] = 'B'; b[1] = 'M'; + put32(b + 2, 54); /* 文件大小(谎报,只为通过头检查) */ + put32(b + 10, 54); /* 像素数据偏移 */ + put32(b + 14, 40); /* BITMAPINFOHEADER */ + put32(b + 18, 3000); /* width */ + put32(b + 22, 3000); /* height */ + put16(b + 26, 1); /* planes */ + put16(b + 28, 24); /* bpp */ + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = fwrite(b, 1, sizeof b, f) == sizeof b; + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + +static int mkdir_p(const char *path) +{ + char tmp[MR_PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s", path); + for (size_t i = 1; i <= strlen(tmp); i++) { + if (tmp[i] == '/' || tmp[i] == '\0') { + char ch = tmp[i]; + tmp[i] = '\0'; + if (mkdir(tmp, 0755) != 0) { + struct stat st; + if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; + } + tmp[i] = ch; + } + } + return 0; +} + +static int rm_rf(const char *path) +{ + struct stat st; + if (lstat(path, &st) != 0) return -1; + if (!S_ISDIR(st.st_mode)) return unlink(path); + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + char full[MR_PATH_MAX]; + snprintf(full, sizeof full, "%s/%s", path, de->d_name); + rm_rf(full); + } + closedir(d); + return rmdir(path); +} + +static void pump_ms(int ms) +{ + for (int i = 0; i < ms / 10; i++) { + usleep(10 * 1000); + meow_anim_tick(); + } +} + +static int tap(int x, int y) +{ + reader_on_touch_down(x, y); + reader_on_tap(x, y); + pump_ms(300); + return 1; +} + +int main(void) +{ + const char *base = getenv("TMPDIR"); + if (!base || !base[0]) base = "."; + char tmpl[512]; + snprintf(tmpl, sizeof tmpl, "%s/mr_many_XXXXXX", base); + char *data = mkdtemp(tmpl); + if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; } + setenv("MEOW_DATA_DIR", data, 1); + + char root[MR_PATH_MAX], book[MR_PATH_MAX], bad[MR_PATH_MAX]; + snprintf(root, sizeof root, "./mr_many_books_%ld", (long)getpid()); + snprintf(book, sizeof book, "%s/v140", root); + snprintf(bad, sizeof bad, "%s/vbad", root); + if (mkdir_p(book) != 0 || mkdir_p(bad) != 0) { printf("SKIP: mkdir failed\n"); return 0; } + for (int i = 0; i < BOOK_PAGES; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/p%03d.bmp", book, i + 1); + if (write_bmp24(p, 200, 267, (unsigned)i) != 0) { printf("SKIP: bmp write failed\n"); return 0; } + } + /* 坏册:第 2 页是垃圾字节(解码失败 → 占位,不许崩/死循环)。 */ + for (int i = 0; i < 4; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/p%d.bmp", bad, i + 1); + if (i == 1) { if (write_garbage(p) != 0) { printf("SKIP: write failed\n"); return 0; } } + else if (i == 2) { if (write_huge_bmp_header(p) != 0) { printf("SKIP: write failed\n"); return 0; } } + else if (write_bmp24(p, 200, 267, (unsigned)i) != 0) { printf("SKIP: bmp write failed\n"); return 0; } + } + + meow_ui_opts o = { 480, 720 }; + if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } + + static const meow_view_hooks lh = { 0 }; + meow_view *lib = meow_view_create(ACT_LIBRARY, MEOW_VIEW_ACTIVITY, &lh, NULL); + meow_view *reader = reader_view_create(); + meow_view_page_push(ACT_LIBRARY); + + int w = meow_ui_width(); + int mode_x = w - 10 - 48 * 3 + 24; + int jump_x = w - 10 - 48 * 2 + 24; + int bar_y = 28; + int slider_y = 56 + 4 + 20; + int sx = 24, sw = w - 48; + + /* ---- M1 140 页可打开 ---- */ + mr_reader_open_dir_at(book, 0); + CHECK(meow_ui_activity_active() == ACT_READER, "reader active"); + CHECK(mr_reader_ui_count() == BOOK_PAGES, "pages=%d (want %d)", + mr_reader_ui_count(), BOOK_PAGES); + CHECK(mr_reader_ui_page() == 0, "start at page 0 (got %d)", mr_reader_ui_page()); + pump_ms(600); + + /* ---- M2 从第 N 张开始 / 越界钳制 ---- */ + meow_view_page_pop(); + mr_reader_open_dir_at(book, 57); + CHECK(mr_reader_ui_page() == 57, "open_dir_at(57) -> page %d", mr_reader_ui_page()); + pump_ms(400); + meow_view_page_pop(); + + mr_reader_open_dir_at(book, 9999); + CHECK(mr_reader_ui_page() == BOOK_PAGES - 1, "open_dir_at(9999) clamps to last (%d)", + mr_reader_ui_page()); + pump_ms(400); + meow_view_page_pop(); + + mr_reader_open_dir_at(book, -5); + CHECK(mr_reader_ui_page() == 0, "open_dir_at(-5) clamps to first (%d)", mr_reader_ui_page()); + pump_ms(300); + meow_view_page_pop(); + + /* ---- M3 显式下标优先于续读;open_dir 保持续读语义 ---- */ + mr_reader_open_dir_at(book, 77); /* 离开时会把 77 落盘 */ + pump_ms(300); + meow_view_page_pop(); + mr_reader_open_dir_at(book, 12); /* 显式下标赢过续读记录 77 */ + CHECK(mr_reader_ui_page() == 12, "explicit start beats resume record (%d)", + mr_reader_ui_page()); + pump_ms(300); + meow_view_page_pop(); + mr_reader_open_dir(book); /* 旧入口 = 续读 */ + CHECK(mr_reader_ui_page() == 12, "open_dir resumes saved page (%d)", mr_reader_ui_page()); + pump_ms(300); + + /* ---- M4 连续翻页(含异步解码)不崩、页码推进、内存有界 ---- */ + int ok_move = 1; + for (int i = 0; i < 12; i++) { + int before = mr_reader_ui_page(); + tap(40, 300); + pump_ms(260); + if (mr_reader_ui_page() == before) ok_move = 0; + } + CHECK(ok_move, "12 consecutive flips advanced the page"); + CHECK(mr_reader_ui_resident_kb() <= 9 * 1024, "resident bounded on 140p book (%ldKB)", + mr_reader_ui_resident_kb()); + + /* 坏图册:翻过坏页不崩、可继续翻。 */ + meow_view_page_pop(); + mr_reader_open_dir_at(bad, 0); + pump_ms(600); + int bad_ok = 1; + for (int i = 0; i < 5; i++) { + int before = mr_reader_ui_page(); + tap(40, 300); + pump_ms(300); + if (mr_reader_ui_page() == before && before < 3) bad_ok = 0; + } + CHECK(bad_ok, "flip across corrupt/huge pages keeps working (page=%d)", mr_reader_ui_page()); + CHECK(mr_reader_ui_resident_kb() <= 9 * 1024, + "huge page skipped without allocating it (%ldKB)", mr_reader_ui_resident_kb()); + CHECK(mr_reader_ui_failed_pages() == 2, + "garbage + oversized page both degraded to placeholder (failed=%d)", + mr_reader_ui_failed_pages()); + CHECK(meow_ui_activity_active() == ACT_READER, "still in reader after corrupt page"); + + /* ---- M5 条漫:140 页排版 + 跳页落位 ---- */ + meow_view_page_pop(); + mr_reader_open_dir_at(book, 0); + pump_ms(400); + CHECK(mr_reader_ui_count() == BOOK_PAGES, "back on the 140p book (%d)", mr_reader_ui_count()); + CHECK(tap(mode_x, bar_y) == 1, "mode icon consumed"); + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "webtoon mode on 140 pages"); + pump_ms(500); + CHECK(tap(jump_x, bar_y) == 1, "jump icon consumed"); + CHECK(mr_reader_ui_jump_open() == 1, "slider open"); + reader_on_touch_down(sx + sw, slider_y); + reader_on_tap(sx + sw, slider_y); + pump_ms(900); + CHECK(mr_reader_ui_jump_pending() == 0, "jump landed in webtoon"); + CHECK(mr_reader_ui_page() >= BOOK_PAGES - 3, "jumped near last page (got %d)", + mr_reader_ui_page()); + CHECK(mr_reader_ui_resident_kb() <= 9 * 1024, "resident bounded in webtoon (%ldKB)", + mr_reader_ui_resident_kb()); + + meow_view_page_pop(); + meow_view_free(reader); + meow_view_free(lib); + meow_ui_shutdown(); + rm_rf(root); + rm_rf(data); + + printf("%s: %s\n", g_fail ? "FAIL" : "PASS", + g_fail ? "see failures above" : "many-pages reader checks passed"); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_reader_perf.c b/tuku/test/test_reader_perf.c new file mode 100644 index 0000000..14843f2 --- /dev/null +++ b/tuku/test/test_reader_perf.c @@ -0,0 +1,222 @@ +/* test_reader_perf.c — 阅读页翻页热路径回归(需要 SDK 源码树,宿主跑真实 view/anim/img)。 + * + * 用户反馈:「翻页很卡,横翻竖翻都卡」「40 页也卡」。真因是每帧一次 meow_ui_img_scaled: + * 解码尺寸(maxdim=屏长边) ≠ 显示尺寸(适宽/适高) → 每帧重采样 + 每帧 1.2MB 临时缓冲 + * malloc/free。修法:解码期直接按显示尺寸落地,翻页只做原生 blit。 + * + * 本测试用 600x800(3:4)合成 BMP 当页图,断言: + * P1 适宽:解码 480x640 == 显示框 → 绘页零重采样(scaled_draws 不增长); + * P2 连续翻页 10 次(预取窗口命中):每帧仍零重采样,且确实画了页(blit 增长); + * P3 切「适高」:重解到 540x720 后同样零重采样(fit 变化会按新几何重解,不残留缩放); + * P4 驻留内存有上界(<= 9MB:3~4 张显示尺寸页)。 + * + * 编译:test/run_tests.sh(与 test_open_book_host 同口径;只发布 meow_ui.a 时 SKIP)。 + */ +#include "tuku_internal.h" +#include "ui/reader/reader.h" +#include "meow_ui_internal.h" + +#include +#include +#include +#include +#include +#include + +void mr_nav_report(void) {} + +static int g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { printf(" PASS\n"); } \ + else { g_fail++; printf(" FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +static void put16(unsigned char *p, unsigned v) { p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8); } +static void put32(unsigned char *p, unsigned v) +{ + p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8); + p[2] = (unsigned char)(v >> 16); p[3] = (unsigned char)(v >> 24); +} + +/* 合成 BMP24(stb 可解);填一点非零像素,避免全 0 触发任何「透明」特判。 */ +static int write_bmp24(const char *path, int w, int h, unsigned seed) +{ + int stride = ((w * 3 + 3) / 4) * 4; + int imgsz = stride * h; + unsigned char fh[14] = { 0 }, ih[40] = { 0 }; + fh[0] = 'B'; fh[1] = 'M'; + put32(fh + 2, (unsigned)(54 + imgsz)); + put32(fh + 10, 54); + put32(ih + 0, 40); + put32(ih + 4, (unsigned)w); + put32(ih + 8, (unsigned)h); + put16(ih + 12, 1); + put16(ih + 14, 24); + put32(ih + 20, (unsigned)imgsz); + + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = fwrite(fh, 1, sizeof fh, f) == sizeof fh && + fwrite(ih, 1, sizeof ih, f) == sizeof ih; + unsigned char *row = (unsigned char *)calloc(1, (size_t)stride); + if (!row) ok = 0; + for (int y = 0; ok && y < h; y++) { + for (int x = 0; x < w; x++) { + row[x * 3 + 0] = (unsigned char)((x + seed) & 0xFF); + row[x * 3 + 1] = (unsigned char)((y * 3 + seed) & 0xFF); + row[x * 3 + 2] = (unsigned char)((x ^ y) & 0xFF); + } + if (fwrite(row, 1, (size_t)stride, f) != (size_t)stride) ok = 0; + } + free(row); + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + +static int mkdir_p(const char *path) +{ + char tmp[MR_PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s", path); + for (size_t i = 1; i <= strlen(tmp); i++) { + if (tmp[i] == '/' || tmp[i] == '\0') { + char ch = tmp[i]; + tmp[i] = '\0'; + if (mkdir(tmp, 0755) != 0) { + struct stat st; + if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; + } + tmp[i] = ch; + } + } + return 0; +} + +static int rm_rf(const char *path) +{ + struct stat st; + if (lstat(path, &st) != 0) return -1; + if (!S_ISDIR(st.st_mode)) return unlink(path); + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + char full[MR_PATH_MAX]; + snprintf(full, sizeof full, "%s/%s", path, de->d_name); + rm_rf(full); + } + closedir(d); + return rmdir(path); +} + +static void pump_ms(int ms) +{ + for (int i = 0; i < ms / 10; i++) { + usleep(10 * 1000); + meow_anim_tick(); + } +} + +static void tap(int x, int y) +{ + reader_on_touch_down(x, y); + reader_on_tap(x, y); + pump_ms(300); /* 越过双击判定窗口,让单击生效 */ +} + +static void draw_once(void) { meow_ui_paint_full(); } + +int main(void) +{ + const char *base = getenv("TMPDIR"); + if (!base || !base[0]) base = "."; + char tmpl[512]; + snprintf(tmpl, sizeof tmpl, "%s/mr_perf_XXXXXX", base); + char *data = mkdtemp(tmpl); + if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; } + setenv("MEOW_DATA_DIR", data, 1); + + char book[MR_PATH_MAX]; + snprintf(book, sizeof book, "./mr_perf_books_%ld/v1", (long)getpid()); + if (mkdir_p(book) != 0) { printf("SKIP: mkdir failed\n"); return 0; } + for (int i = 0; i < 4; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/p%d.bmp", book, i + 1); + if (write_bmp24(p, 600, 800, (unsigned)i) != 0) { printf("SKIP: bmp write failed\n"); return 0; } + } + + meow_ui_opts o = { 480, 720 }; + if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } + + static const meow_view_hooks lh = { 0 }; + meow_view *lib = meow_view_create(ACT_LIBRARY, MEOW_VIEW_ACTIVITY, &lh, NULL); + meow_view *reader = reader_view_create(); + meow_view_page_push(ACT_LIBRARY); + + int w = meow_ui_width(); + int fit_x = w - 10 - 48 * 3 + 24 + 48 * 2; /* 顶栏第 3 个动作 = fit */ + int bar_y = 28; + + /* ---- P1 进入:适宽 480x640 == 解码尺寸 → 纯 blit ---- */ + mr_reader_open_dir(book); + CHECK(mr_reader_ui_count() == 4, "book pages=%d", mr_reader_ui_count()); + pump_ms(700); + draw_once(); + int b0 = mr_reader_ui_blit_draws(), s0 = mr_reader_ui_scaled_draws(); + draw_once(); + CHECK(mr_reader_ui_blit_draws() > b0, "page drawn with native blit"); + CHECK(mr_reader_ui_scaled_draws() == s0, + "fit-width: zero resample per frame (scaled %d -> %d)", + s0, mr_reader_ui_scaled_draws()); + + /* ---- P2 连续翻页:预取命中,仍零重采样 ---- */ + int bt = mr_reader_ui_blit_draws(), st = mr_reader_ui_scaled_draws(); + int pages_ok = 1; + for (int i = 0; i < 10; i++) { + int before = mr_reader_ui_page(); + /* RTL 默认:左 1/3=下一页、右 1/3=上一页;来回翻避免撞到首末页的册间衔接。 */ + tap(i % 2 ? w - 40 : 40, 300); + pump_ms(350); + if (mr_reader_ui_page() == before) pages_ok = 0; + draw_once(); + } + CHECK(pages_ok, "10 flips advanced the page each time"); + CHECK(mr_reader_ui_blit_draws() - bt >= 10, "flips drew pages (%d blits)", + mr_reader_ui_blit_draws() - bt); + CHECK(mr_reader_ui_scaled_draws() == st, + "flip hot path: zero resample (scaled %d -> %d)", st, mr_reader_ui_scaled_draws()); + + /* ---- P3 切「适高」:按新几何重解后再测 ---- */ + tap(fit_x, bar_y); /* 适宽 → 适高 */ + pump_ms(600); + draw_once(); + int s2 = mr_reader_ui_scaled_draws(); + for (int i = 0; i < 5; i++) { + tap(i % 2 ? w - 40 : 40, 300); + pump_ms(350); + draw_once(); + } + CHECK(mr_reader_ui_scaled_draws() == s2, + "fit-height: re-decoded page still zero resample (scaled %d -> %d)", + s2, mr_reader_ui_scaled_draws()); + + /* ---- P4 驻留内存上界 ---- */ + long kb = mr_reader_ui_resident_kb(); + printf(" info: resident=%ldKB blit=%d scaled=%d\n", kb, + mr_reader_ui_blit_draws(), mr_reader_ui_scaled_draws()); + CHECK(kb <= 9 * 1024, "resident page images bounded (got %ldKB)", kb); + + meow_view_page_pop(); + meow_view_free(reader); + meow_view_free(lib); + meow_ui_shutdown(); + rm_rf(book); + char rootdir[MR_PATH_MAX]; + snprintf(rootdir, sizeof rootdir, "./mr_perf_books_%ld", (long)getpid()); + rm_rf(rootdir); + rm_rf(data); + + printf("%s: %s\n", g_fail ? "FAIL" : "PASS", + g_fail ? "see failures above" : "reader flip hot-path checks passed"); + return g_fail ? 1 : 0; +} diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index fd717d2..04d9d4b 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -10,6 +10,12 @@ * 页源:logic/reader/page_source 的 mr_book 契约(count/meta/open/close)。条漫排版用 * meta() 不解码像素;解码入口走 SDK M12 预算解码 meow_img_load_file_dim(JPEG 解码期缩解, * PNG/BMP 整幅解码后降采样——超大头先 meow_img_probe_file 预判,超过预算跳过并告警)。 + * + * 性能(M17):解码目标 = **当前 fit/zoom 的显示尺寸**(不是屏长边),所以翻页/滚动热路径 + * 只有原生 blit:不做 meow_ui_img_scaled 的每帧重采样,也不再每帧 malloc 1.2MB 临时缓冲。 + * fit 变化时窗口按新几何重解一次;拖动/吸附帧只 invalidate 内容区(顶栏静态不重绘)。 + * 内存:单页显示缓存 ≤3MB、整窗 ≤9MB(超限淘汰最远页),解码中间峰值 >12MB 的页直接跳过 + * 并显示占位——页数增长不会线性吃内存(常驻只与解码窗大小有关)。 * 方向:一律读 M3 的 mr_reader_get_direction()(默认 RTL),不另立全局。 * * 模式(M7):翻页/条漫是一等公民。阅读 chrome 的图标按钮一键切换;每册的模式与 last page @@ -64,8 +70,11 @@ #define MR_JUMP_TIMEOUT_MS 2500 /* 目标页迟迟解不出来 → 降级落位并提示 */ #define MR_CONT_SAVE_MS 1000 /* 续读状态落盘节流 */ #define MR_TOAST_MS 1400 -#define MR_LOAD_RAW_WARN (12L * 1024 * 1024) /* 非缩解格式原始 RGBA 超此值提示残余风险 */ -#define MR_LOAD_RAW_CAP (40L * 1024 * 1024) /* 超此值直接跳过(防 OOM;页显示占位) */ +#define MR_LOAD_RAW_WARN (8L * 1024 * 1024) /* 非缩解格式原始 RGBA 超此值提示残余风险 */ +#define MR_DECODE_PEAK_CAP (12L * 1024 * 1024) /* 单次解码中间缓冲峰值上限(超则跳过,防 OOM 闪退) */ +#define MR_PAGE_BYTES_CAP (3L * 1024 * 1024) /* 单页「显示尺寸」缓存字节上限 */ +#define MR_RESIDENT_CAP (9L * 1024 * 1024) /* 页图缓存总字节上限(超则淘汰最远页) */ +#define MR_SLOT_TOL 2 /* 缓存图与显示框允许的像素偏差(≤2 走原生 blit,不重采样) */ #define MR_REQ_MAX 8 #define MR_RES_MAX 4 @@ -75,6 +84,7 @@ static mr_page_ctrl g_ctrl; static char g_dir[MR_PATH_MAX]; +static int g_start_at = MR_READER_START_RESUME; /* 下次 on_enter 的显式起始页(-1=续读) */ static mr_book_handle g_handle; /* 目录册或档案册(M11 统一开册) */ static mr_book g_book; @@ -133,6 +143,14 @@ static page_slot g_slot[MR_SLOTS]; /* 解码失败页标记:避免对坏图/超大图无限重试,UI 显示「图片无法显示」而不是一直加载中。 */ static unsigned char g_page_fail[(MR_BOOK_MAX_PAGES + 7) / 8]; +/* 每页原始像素尺寸缓存(打开时按 count 分配;0,0=未知)。绘制/排版几何只查这里, + * 不在每帧去 probe 文件(dir_ops_meta 是 fopen+读头,每帧一次会直接拖垮翻页)。 */ +typedef struct page_meta { int w, h; } page_meta; +static page_meta *g_meta; + +/* 解码预算(屏长边):超过它的显示尺寸无法靠「再解一次」变大,用于避免重解死循环。 */ +static int g_decode_budget = 720; + static int page_failed(int idx) { if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return 0; @@ -183,6 +201,17 @@ static int g_wo_last_y; /* 条漫:上一次 move 的 y */ static int g_wo_y0; /* 条漫:按下点 y(slop 判定) */ static int g_wo_pull; /* 条漫:到边后继续拉的累计量(>0 往末页方向) */ +/* ---- 绘页路径计数(宿主测试观察:翻页热路径应全部是 blit)---- */ +#ifdef MR_READER_HOST_TEST +static int g_blit_draws; +static int g_scaled_draws; +#define NOTE_BLIT() (g_blit_draws++) +#define NOTE_SCALED() (g_scaled_draws++) +#else +#define NOTE_BLIT() ((void)0) +#define NOTE_SCALED() ((void)0) +#endif + /* ---- 单击延迟(双击判定窗口)---- */ static int g_tap_pending; static int g_tap_x, g_tap_y; @@ -197,6 +226,8 @@ static void mode_set(int mode, int from_page); static void toast(const char *msg); static void slider_place(void); static void update_title(void); +static void invalidate_content(void); +static page_slot *slot_find(int idx); static void cont_touch(void); static void cont_save_now(void); static int current_page(void); @@ -213,34 +244,66 @@ static int path_is_jpeg(const char *path) return strcasecmp(dot, "jpg") == 0 || strcasecmp(dot, "jpeg") == 0; } +/* 目标解码长边:优先=当前 fit/zoom 的显示尺寸(解码后可直接 blit,翻页零重采样), + * 再受屏长边预算与单页字节上限约束。pw/ph=原始尺寸。 */ +static int decode_long_side(int pw, int ph, int budget) +{ + int tw = 0, th = 0; + int long0 = pw > ph ? pw : ph; + int short0 = pw > ph ? ph : pw; + if (mr_nav_fit_size(g_fit, pw, ph, g_vw, g_vh, &tw, &th) != 0) + return budget; + if (g_zoom_pct != 100) mr_nav_zoom_size(tw, th, g_zoom_pct, &tw, &th); + int l = tw > th ? tw : th; + if (l < 64) l = 64; + if (budget > 0 && l > budget) l = budget; + /* 单页缓存上限:长边按比例收到能装下为止(高度极大的长条漫走这里)。 */ + while (l > 64) { + int s = (int)((long long)short0 * l / long0); + if ((long long)l * s * 4 <= MR_PAGE_BYTES_CAP) break; + l = l * 3 / 4; + } + return l; +} + static meow_img *reader_load_page(const char *path, void *ud, int maxdim) { (void)ud; if (!path || !path[0]) return NULL; /* 预算预判(M12):先读文件头拿原始尺寸,不解码像素。 - * - 非缩解格式(PNG/BMP)原始 RGBA 超过预算 → 跳过,避免真机 OOM(页面显示占位); - * - 超过告警线 → 记 WARN(残余风险,按 M12 报告口径如实提示)。 */ + * - 解码中间缓冲峰值超上限 → 跳过(页面显示占位),避免真机被 OOM 杀掉(闪退); + * - 非缩解格式(PNG/BMP)原始 RGBA 超过告警线 → 记 WARN(残余风险)。 */ int pw = 0, ph = 0, ch = 0; - if (meow_img_probe_file(path, &pw, &ph, &ch) && pw > 0 && ph > 0) { - long long raw = (long long)pw * (long long)ph * 4; - if (!path_is_jpeg(path)) { - if (raw > MR_LOAD_RAW_CAP) { - meow_log(MEOW_LOG_WARN, "reader decode skip %dx%d (raw %lldKB > cap): %s", - pw, ph, raw / 1024, path); - return NULL; - } - if (raw > MR_LOAD_RAW_WARN) + int have = meow_img_probe_file(path, &pw, &ph, &ch) && pw > 0 && ph > 0; + int dim = maxdim; + if (have) { + int l0 = pw > ph ? pw : ph; + dim = decode_long_side(pw, ph, maxdim); + long long peak; + if (path_is_jpeg(path)) { + /* 估 stbi 缩解后的输出(与 SDK 的档位选择同式):仍超上限才跳过。 */ + int s = 1; + while (s < 8 && (l0 / (s * 2)) >= dim) s *= 2; + peak = (long long)(pw / s) * (ph / s) * 4; + } else { + peak = (long long)pw * ph * 4; /* 整幅解码后降采样 */ + if (peak > MR_LOAD_RAW_WARN) meow_log(MEOW_LOG_WARN, "reader decode large non-scalable %dx%d (%lldKB): %s", - pw, ph, raw / 1024, path); + pw, ph, peak / 1024, path); + } + if (peak > MR_DECODE_PEAK_CAP) { + meow_log(MEOW_LOG_WARN, "reader decode skip %dx%d (peak %lldKB > cap): %s", + pw, ph, peak / 1024, path); + return NULL; } } - meow_img *im = meow_img_load_file_dim(path, maxdim); + meow_img *im = meow_img_load_file_dim(path, dim); if (!im) return NULL; /* 兜底:M12 之前的格式/未知格式若没被缩到预算内,再降一次(保持驻留纪律)。 */ - if (maxdim > 0 && (meow_img_w(im) > maxdim || meow_img_h(im) > maxdim)) { - meow_img *small = meow_img_downscale(im, maxdim); + if (dim > 0 && (meow_img_w(im) > dim || meow_img_h(im) > dim)) { + meow_img *small = meow_img_downscale(im, dim); if (small) { meow_img_free(im); im = small; } } return im; @@ -291,6 +354,54 @@ static void log_mem(const char *tag) mem_kb("/proc/self/status", "VmRSS:"), resident_pages(), resident_bytes() / 1024); } +/* ============================================================ */ +/* 页原始尺寸缓存(绘制几何的唯一来源;每页只 probe 一次) */ +/* ============================================================ */ +static int page_meta_dims(int idx, int *w, int *h) +{ + if (w) *w = 0; + if (h) *h = 0; + if (idx < 0 || idx >= g_count) return 0; + if (g_meta) { + if (g_meta[idx].w > 0 && g_meta[idx].h > 0) { + if (w) *w = g_meta[idx].w; + if (h) *h = g_meta[idx].h; + return 1; + } + if (g_meta[idx].w < 0) return 0; /* 探测过且未知:别再每帧 fopen 一遍 */ + } + int sw = 0, sh = 0; + if (!mr_book_meta(&g_book, idx, &sw, &sh) || sw <= 0 || sh <= 0) { + if (g_meta) { g_meta[idx].w = -1; g_meta[idx].h = 0; } + return 0; + } + if (g_meta) { g_meta[idx].w = sw; g_meta[idx].h = sh; } + if (w) *w = sw; + if (h) *h = sh; + return 1; +} + +/* 显示框(fit + zoom):绘图与点击几何都走这里。未知名按 2:3 竖图占位。 */ +static void page_target_size_at(int idx, int *w, int *h) +{ + int sw = 0, sh = 0; + if (w) *w = 0; + if (h) *h = 0; + if (!w || !h) return; + if (!page_meta_dims(idx, &sw, &sh)) { + /* meta 不可用(未知/坏头):用已解码尺寸兜底(保持真实长宽比), + * 都没解出来才按 2:3 竖图占位——排版稳定且不会把页面拉变形。 */ + page_slot *s = slot_find(idx); + if (s) { sw = s->src_w; sh = s->src_h; } + if (sw <= 0 || sh <= 0) { + sw = g_vw > 0 ? g_vw : 1; + sh = sw * 3 / 2; + } + } + if (mr_nav_fit_size(g_fit, sw, sh, g_vw, g_vh, w, h) != 0) return; + if (g_zoom_pct != 100) mr_nav_zoom_size(*w, *h, g_zoom_pct, w, h); +} + /* ============================================================ */ /* 续读状态(逐册 last page + mode;写入节流,避免滚动时频繁写卡) */ /* ============================================================ */ @@ -351,6 +462,18 @@ static int in_window(int idx) return 0; } +/* 该页此刻是否真的画在屏幕上(异步落位后只重绘这种情况)。 */ +static int page_visible(int idx) +{ + if (g_count <= 0 || idx < 0 || idx >= g_count) return 0; + if (g_mode == MR_MODE_WEBTOON && g_strip_ok) { + int f = mr_strip_page_at(&g_strip, g_woff); + int l = mr_strip_page_at(&g_strip, g_woff + g_vh - 1); + return idx >= f && idx <= l; + } + return idx == g_page || (g_slide_delta != 0 && idx == g_page + g_slide_delta); +} + static page_slot *slot_find(int idx) { if (idx < 0) return NULL; @@ -373,20 +496,70 @@ static void slots_free_all(void) for (int i = 0; i < MR_SLOTS; i++) slot_clear(i); } -/* 放进缓存;缓存满时淘汰离焦点页最远的一张。返回 1=已放入。 */ +/* 缓存图是否就是当前 fit/zoom 的显示尺寸(可直接 blit)。已达解码预算的图也算就绪, + * 否则「原始/放大」档会陷入「解不出目标尺寸 → 反复重解」。 */ +static int slot_ready(int idx) +{ + page_slot *s = slot_find(idx); + if (!s) return 0; + int tw = 0, th = 0; + page_target_size_at(idx, &tw, &th); + if (tw <= 0 || th <= 0) return 1; + if (abs(tw - s->src_w) <= MR_SLOT_TOL && abs(th - s->src_h) <= MR_SLOT_TOL) return 1; + if (g_decode_budget > 0 && + (s->src_w >= g_decode_budget || s->src_h >= g_decode_budget)) return 1; + /* 已是原始分辨率(小图不放大,SDK 不做上采样):这就是能拿到的最好结果。 */ + int mw = 0, mh = 0; + if (page_meta_dims(idx, &mw, &mh) && s->src_w >= mw && s->src_h >= mh) return 1; + return 0; +} + +/* 淘汰离焦点页最远的一张(不淘汰 focus 与 except)。返回被清掉的槽下标,无则 -1。 */ +static int slot_evict_far(int except) +{ + int focus = focus_page(); + int far = -1, far_d = -1; + for (int i = 0; i < MR_SLOTS; i++) { + if (i == except || !g_slot[i].img) continue; + if (g_slot[i].idx == focus) continue; + int d = abs(g_slot[i].idx - focus); + if (d > far_d) { far_d = d; far = i; } + } + if (far >= 0) slot_clear(far); + return far; +} + +/* 放进缓存;同页旧图直接替换(fit/zoom 变了要换尺寸)。返回 1=已放入。 */ static int slot_put(int idx, meow_img *img) { if (!img || idx < 0) return 0; - int focus = focus_page(); - int empty = -1, far = -1, far_d = -1; - for (int i = 0; i < MR_SLOTS; i++) { - if (g_slot[i].idx == idx && g_slot[i].img) { meow_img_free(img); return 0; } - if (!g_slot[i].img && empty < 0) empty = i; - int d = !g_slot[i].img ? -1 : abs(g_slot[i].idx - focus); - if (d > far_d) { far_d = d; far = i; } + int i = -1, empty = -1; + for (int k = 0; k < MR_SLOTS; k++) { + if (g_slot[k].idx == idx && g_slot[k].img) { i = k; break; } + if (!g_slot[k].img && empty < 0) empty = k; + } + if (i < 0) { + if (empty >= 0) { + i = empty; + } else { /* 全满:替换离焦点页最远的一张 */ + int focus = focus_page(), far_d = -1; + for (int k = 0; k < MR_SLOTS; k++) { + int d = abs(g_slot[k].idx - focus); + if (d > far_d) { far_d = d; i = k; } + } + } } - int i = empty >= 0 ? empty : far; if (i < 0) { meow_img_free(img); return 0; } + + /* 总字节上限:新图放进去前先淘汰最远页,保证常驻图不超过预算。 */ + long need = (long)meow_img_w(img) * meow_img_h(img) * 4; + for (;;) { + long held = resident_bytes(); + if (g_slot[i].img) held -= (long)g_slot[i].src_w * g_slot[i].src_h * 4; + if (held + need <= MR_RESIDENT_CAP) break; + if (slot_evict_far(i) < 0) break; /* 只剩目标槽:这一页必须放进来 */ + } + slot_clear(i); g_slot[i].idx = idx; g_slot[i].img = img; @@ -456,7 +629,7 @@ static int req_queued(int idx) static void queue_page(int idx) { if (!mr_book_valid(&g_book) || idx < 0 || idx >= g_count) return; - if (slot_find(idx) || page_failed(idx)) return; + if (slot_ready(idx) || page_failed(idx)) return; pthread_mutex_lock(&g_lock); if (!g_stop && !req_queued(idx) && g_req_n < MR_REQ_MAX) { g_req[g_req_tail].idx = idx; @@ -562,11 +735,11 @@ static int results_drain(void) if (got[i].gen != g_gen) { meow_img_free(got[i].img); continue; } if (got[i].fail) { page_mark_failed(got[i].idx); - if (in_window(got[i].idx)) g_fail_paint = 1; + if (page_visible(got[i].idx)) g_fail_paint = 1; continue; } - if (in_window(got[i].idx) && !slot_find(got[i].idx)) { - if (slot_put(got[i].idx, got[i].img)) changed = 1; + if (in_window(got[i].idx)) { + if (slot_put(got[i].idx, got[i].img) && page_visible(got[i].idx)) changed = 1; } else { meow_img_free(got[i].img); } @@ -590,7 +763,8 @@ static void window_update(void) /* ============================================================ */ static int strip_meta_cb(void *ctx, int index, int *w, int *h) { - return mr_book_meta((const mr_book *)ctx, index, w, h); + (void)ctx; + return page_meta_dims(index, w, h); /* 走缓存:重排版不再逐页 fopen probe */ } static int strip_ensure(void) @@ -641,7 +815,7 @@ static void wo_set(void *ctx, int off) static void wo_redraw(void *ctx) { (void)ctx; - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); /* 滚动只动内容区,顶栏静态不重绘 */ } static void scroll_ensure(void) @@ -661,27 +835,23 @@ static void scroll_free(void) /* ============================================================ */ /* 布局/绘制 */ /* ============================================================ */ -static void page_target_size(const page_slot *s, int *w, int *h) +/* 内容视口失效区:chrome 可见时顶栏是静态的,拖动/吸附帧只刷其下内容, + * 避免每帧重绘顶栏文字(软渲下文字是最贵的绘制物)。 */ +static void invalidate_content(void) { - *w = *h = 0; - if (!s) return; - if (mr_nav_fit_size(g_fit, s->src_w, s->src_h, g_vw, g_vh, w, h) != 0) return; - if (g_zoom_pct != 100) mr_nav_zoom_size(*w, *h, g_zoom_pct, w, h); + if (g_chrome) + meow_ui_invalidate(0, MR_TOP_BAR_H, g_vw, g_vh - MR_TOP_BAR_H); + else + meow_ui_invalidate(0, 0, g_vw, g_vh); } static void current_geom(int *w, int *h) { - page_slot *s = slot_find(g_page); - if (s) { page_target_size(s, w, h); return; } - /* 页未就绪:用 meta 估算,保证平移/缩放的夹取稳定。 */ - int sw = 0, sh = 0; - if (mr_book_meta(&g_book, g_page, &sw, &sh) && sw > 0 && sh > 0) { - mr_nav_fit_size(g_fit, sw, sh, g_vw, g_vh, w, h); - if (g_zoom_pct != 100) mr_nav_zoom_size(*w, *h, g_zoom_pct, w, h); - return; + page_target_size_at(g_page, w, h); + if (!w || !h || *w <= 0 || *h <= 0) { + if (w) *w = g_vw; + if (h) *h = g_vh; } - *w = g_vw; - *h = g_vh; } static void clamp_pan(void) @@ -705,18 +875,24 @@ static int pan_mode(void) return g_zoom_pct > 100 || content_overflow(); } -/* 画一页(带水平位移);图与目标尺寸相等时走原生 blit(翻页热路径),否则最近邻缩放。 */ +/* 画一页(带水平位移);解码尺寸==显示尺寸时走原生 blit(翻页热路径零重采样), + * 只在 fit/zoom 变化或极端长页(超单页缓存上限)时才退回最近邻缩放。 */ static void draw_page_at(int idx, int off_x, int off_y) { page_slot *s = slot_find(idx); if (!s) return; int w, h; - page_target_size(s, &w, &h); + page_target_size_at(idx, &w, &h); if (w <= 0 || h <= 0) return; int x = (g_vw - w) / 2 + g_pan_x + off_x; int y = (g_vh - h) / 2 + g_pan_y + off_y; - if (w == s->src_w && h == s->src_h) meow_ui_img(x, y, s->img); - else meow_ui_img_scaled(x, y, w, h, s->img); + if (abs(w - s->src_w) <= MR_SLOT_TOL && abs(h - s->src_h) <= MR_SLOT_TOL) { + meow_ui_img(x + (w - s->src_w) / 2, y + (h - s->src_h) / 2, s->img); + NOTE_BLIT(); + } else { + meow_ui_img_scaled(x, y, w, h, s->img); + NOTE_SCALED(); + } } /* 条漫:按滚动偏移画可见页。典型 2:3 页的解码尺寸与显示尺寸相等 → 原生 blit; @@ -735,8 +911,15 @@ static void draw_strip(const meow_theme *th) if (h <= 0) continue; page_slot *s = slot_find(i); if (s) { - if (s->src_w == g_vw && s->src_h == h) meow_ui_img(0, y, s->img); - else meow_ui_img_scaled(0, y, g_vw, h, s->img); + /* 解码=显示尺寸时原生 blit:gfx 只处理屏幕上可见的行, + * 长条页只露一截时不再整页重采样(M7 finding 的子矩形 blit 诉求)。 */ + if (abs(s->src_w - g_vw) <= MR_SLOT_TOL && abs(s->src_h - h) <= MR_SLOT_TOL) { + meow_ui_img((g_vw - s->src_w) / 2, y, s->img); + NOTE_BLIT(); + } else { + meow_ui_img_scaled(0, y, g_vw, h, s->img); + NOTE_SCALED(); + } continue; } /* 未就绪:只填可见部分(长页不整块清屏),给一帧占位而不是白屏。 */ @@ -907,7 +1090,7 @@ static void pan_by(int dx, int dy) g_pan_x += dx; g_pan_y += dy; clamp_pan(); - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); } static void commit_page(int target) @@ -929,7 +1112,7 @@ static void slide_update(meow_anim *a, float value, void *ctx) { (void)a; (void)ctx; g_anim_v = (int)value; - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); /* 吸附帧只刷内容区(整屏 minus 静态顶栏) */ } static void slide_done(meow_anim *a, int canceled, void *ctx) @@ -944,7 +1127,7 @@ static void slide_done(meow_anim *a, int canceled, void *ctx) if (!canceled && delta) commit_page(g_page + delta); else - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); meow_log(MEOW_LOG_INFO, "reader flip done page=%d/%d total_ms=%llu", g_page + 1, g_count, (unsigned long long)(meow_ui_time_ms() - g_flip_t0)); @@ -982,7 +1165,7 @@ static void bounce_update(meow_anim *a, float value, void *ctx) { (void)a; (void)ctx; g_drag_off = (int)value; - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); } static void bounce_done(meow_anim *a, int canceled, void *ctx) @@ -991,7 +1174,7 @@ static void bounce_done(meow_anim *a, int canceled, void *ctx) g_bounce = NULL; g_drag_off = 0; g_drag_mode = 0; - if (!g_quitting) meow_ui_invalidate(0, 0, g_vw, g_vh); + if (!g_quitting) invalidate_content(); } static void start_bounce(int from_off) @@ -1002,7 +1185,7 @@ static void start_bounce(int from_off) bounce_update, bounce_done, NULL, }; g_bounce = meow_anim_start(&d); - if (!g_bounce) { g_drag_off = 0; g_drag_mode = 0; meow_ui_invalidate(0, 0, g_vw, g_vh); } + if (!g_bounce) { g_drag_off = 0; g_drag_mode = 0; invalidate_content(); } } /* 点击触发的翻页(无跟手位移)。首/末页再前进 → 相邻册(无相邻册则轻提示)。 */ @@ -1176,6 +1359,8 @@ static void open_book_reset_state(void) mr_jump_cancel(&g_jump); g_toast[0] = '\0'; g_title_page = -1; + free(g_meta); + g_meta = NULL; } /* 打开一本册并恢复模式/起始页。start_page<0 = 末页(上一册衔接)。1=成功。 */ @@ -1189,6 +1374,7 @@ static int open_book(const char *dir, int start_page, int mode) g_mode = mr_mode_valid(mode) ? mode : MR_MODE_PAGED; int maxdim = g_vw > g_vh ? g_vw : g_vh; + g_decode_budget = maxdim; /* 目录 → 目录册;.cbz/.zip → 档案册(M11)。两者同一个 mr_book 契约, * 下面的翻页/条漫消费代码完全不区分 provider。 */ int n = mr_book_open_auto(&g_book, &g_handle, g_dir, maxdim, @@ -1211,6 +1397,10 @@ static int open_book(const char *dir, int start_page, int mode) return 0; } g_count = n; + /* 页尺寸缓存:每页 8 字节(4096 页上限 ≈32KB),换来的是一帧内零 fopen probe。 */ + g_meta = (page_meta *)calloc((size_t)g_count, sizeof *g_meta); + if (!g_meta) + meow_log(MEOW_LOG_WARN, "reader meta cache unavailable (%d pages)", g_count); int page = start_page < 0 ? g_count - 1 : start_page; page = mr_nav_clamp_page(page, g_count); @@ -1289,7 +1479,7 @@ static void drag_paint(void) int gap = g_zoom_pct > 100 ? 33 : MR_PAINT_MS; /* 缩放帧要重采样:30fps 上限 */ if (now - g_last_paint < (uint64_t)gap) return; g_last_paint = now; - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); } /* 松手:过阈值且目标页已缓存 → 从当前位移吸附过去;否则回弹。 */ @@ -1303,7 +1493,7 @@ static void drag_finish(void) g_drag_mode = 0; if (mode != 1 || abs(dx) < MR_DRAG_SLOP) { - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); return; } int rtl = mr_reader_get_direction() == MR_DIR_RTL; @@ -1312,7 +1502,7 @@ static void drag_finish(void) if (mr_nav_commit(dx, g_vw)) { if (target == g_page) { /* 首/末页越界:进相邻册 */ book_switch(delta > 0 ? 1 : -1); - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); return; } g_flip_t0 = meow_ui_time_ms(); @@ -1341,7 +1531,7 @@ static void webtoon_release(void) if (pull <= -MR_BOOK_PULL && book_switch(-1)) return; if (g_scroll) meow_scroll_fling(g_scroll); cont_touch(); - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); } void reader_on_touch_down(int x, int y) @@ -1440,7 +1630,7 @@ void reader_on_touch_move(int x, int y) g_dragging = 0; g_drag_mode = 0; g_drag_off = 0; - meow_ui_invalidate(0, 0, g_vw, g_vh); + invalidate_content(); return; } @@ -1614,9 +1804,8 @@ static void reader_tick(void) if (now - g_last_tick < MR_TICK_MS) return; g_last_tick = now; - int had_current = slot_find(current_page()) != NULL; - if (results_drain() && !had_current && slot_find(current_page()) && !g_dragging) - meow_ui_invalidate(0, 0, g_vw, g_vh); + /* 只有当落位的页真的在屏幕上(或换了几何的当前页)才重绘,预取邻居不刷屏。 */ + if (results_drain() && !g_dragging) invalidate_content(); /* 打开册的瞬时资源失败:稍后自动重试一次(进入本页时已停 library 后台解码)。 */ if (g_open_err && g_open_retry_at && now >= g_open_retry_at) { @@ -1628,7 +1817,7 @@ static void reader_tick(void) } if (g_fail_paint) { g_fail_paint = 0; - if (!g_dragging) meow_ui_invalidate(0, 0, g_vw, g_vh); + if (!g_dragging) invalidate_content(); } if (g_tap_pending && now - g_tap_ms >= MR_DOUBLE_MS) tap_flush(); @@ -1811,9 +2000,14 @@ static void reader_page_enter(meow_view *v, void *ctx) if (data && data[0]) mr_cache_init(data); } - /* 打开册:恢复该册上次的模式与页码(无记录 → 翻页模式第 1 页)。 */ + /* 打开册:显式起始页(书库文件管理器模式点第 N 张)优先于续读记录; + * 否则恢复该册上次的模式与页码(无记录 → 翻页模式第 1 页)。 */ int mode = mr_cont_get_mode(g_dir, MR_MODE_PAGED); int page = mr_cont_get_page(g_dir, 0); + if (g_start_at != MR_READER_START_RESUME) { + page = g_start_at; + g_start_at = MR_READER_START_RESUME; + } open_book(g_dir, page, mode); ticker_start(); @@ -1842,6 +2036,9 @@ static void reader_page_leave(meow_view *v, void *ctx) mr_strip_free(&g_strip); g_strip_ok = 0; slots_free_all(); + free(g_meta); + g_meta = NULL; + g_start_at = MR_READER_START_RESUME; mr_book_close_auto(&g_book, &g_handle); if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; } @@ -1882,14 +2079,22 @@ const char *mr_reader_dir(void) return g_dir; } -void mr_reader_open_dir(const char *path) +void mr_reader_open_dir_at(const char *path, int start_index) { if (!path) return; snprintf(g_dir, sizeof g_dir, "%s", path); - meow_log(MEOW_LOG_INFO, "reader open: %s", g_dir); + /* -1=续读(旧入口语义);其它负数钳到 0;上界在 open_book 里按页数钳。 */ + g_start_at = start_index == MR_READER_START_RESUME ? MR_READER_START_RESUME + : (start_index < 0 ? 0 : start_index); + meow_log(MEOW_LOG_INFO, "reader open: %s (start=%d)", g_dir, g_start_at); meow_view_page_push(ACT_READER); } +void mr_reader_open_dir(const char *path) +{ + mr_reader_open_dir_at(path, MR_READER_START_RESUME); +} + #ifdef MR_READER_HOST_TEST /* 宿主集成测试观察口(不改变任何状态)。 */ int mr_reader_ui_mode(void) { return g_mode; } @@ -1898,4 +2103,14 @@ int mr_reader_ui_count(void) { return g_count; } int mr_reader_ui_open_err(void) { return g_open_err; } int mr_reader_ui_jump_open(void) { return g_jump_open; } int mr_reader_ui_jump_pending(void) { return mr_jump_active(&g_jump); } +long mr_reader_ui_resident_kb(void) { return resident_bytes() / 1024; } +int mr_reader_ui_failed_pages(void) +{ + int n = 0; + for (int i = 0; i < g_count && i < MR_BOOK_MAX_PAGES; i++) + if (page_failed(i)) n++; + return n; +} +int mr_reader_ui_blit_draws(void) { return g_blit_draws; } +int mr_reader_ui_scaled_draws(void) { return g_scaled_draws; } #endif diff --git a/tuku/ui/reader/reader.h b/tuku/ui/reader/reader.h index 411981b..55523ce 100644 --- a/tuku/ui/reader/reader.h +++ b/tuku/ui/reader/reader.h @@ -13,6 +13,15 @@ extern "C" { void reader_on_touch_down(int x, int y); void reader_on_touch_move(int x, int y); +/* 书库 → 阅读器(文件管理器模式用):按目录内图片自然序从 start_index 开始。 + * - start_index >= 册页数:钳到末页;其它负数按 0 处理; + * - start_index == MR_READER_START_RESUME(-1):沿用该册续读记录(= mr_reader_open_dir)。 + * 显式下标优先于续读记录:用户在列表里点了某张图,就不该被上次的页码带走。 */ +#define MR_READER_START_RESUME (-1) +void mr_reader_open_dir_at(const char *dir, int start_index); +/* 保留旧入口:等价于 mr_reader_open_dir_at(dir, MR_READER_START_RESUME)。 */ +void mr_reader_open_dir(const char *dir); + #ifdef MR_READER_HOST_TEST /* 宿主集成测试专用:只读观察本页状态(不参与产品逻辑)。 */ int mr_reader_ui_mode(void); /* MR_MODE_PAGED / MR_MODE_WEBTOON */ @@ -20,6 +29,10 @@ int mr_reader_ui_page(void); /* 当前页(条漫=视口顶端所在页 int mr_reader_ui_count(void); /* 当前册页数 */ int mr_reader_ui_jump_open(void); /* 1=跳页滑块已展开 */ int mr_reader_ui_jump_pending(void);/* 1=跳页目标等待解码 */ +long mr_reader_ui_resident_kb(void);/* 驻留页图字节(KB) */ +int mr_reader_ui_failed_pages(void);/* 解码失败/超预算被跳过的页数 */ +int mr_reader_ui_blit_draws(void); /* 累计原生 blit 绘页次数(热路径应为纯 blit) */ +int mr_reader_ui_scaled_draws(void);/* 累计重采样绘页次数(每帧重采样=卡顿) */ #endif #ifdef __cplusplus