真机(R3 Pro II 480×720 / MemTotal 56872kB):空闲 RSS 6044kB → 翻页 11400kB → 峰值 15600kB(MemFree 仅 1404kB)→ SIGKILL。M20 审计坐实根因是「单页解码瞬时峰值」, 且旧模型把 stb 当缩解库,对 JPEG 低估 3.5–8 倍(实测 baseline 2000×3000 +10.1MB、 渐进 +23.5MB、1200×1600 PNG +12.9MB)。本次按实测重定预算: - 真实峰值模型 decode_peak_bytes():JPEG 分量面(+渐进 short 系数面)、PNG 源缓冲 + RGBA 转换副本、BMP 直写目标,再叠精修/落框两段一次性重采样;上限常态 3MB (MR_DECODE_PEAK_CAP),只有 MemFree 真富余时才放宽到 min(12MB, MemFree-4MB)。 - 超预算时的降级:JPEG 逐步加粗 stbi 缩解档位(meow_img_load_file_step), PNG/BMP 无解码期缩放 → 占位;结果精确重采样回显示框(meow_img_resample), 命中页仍是单次 blit(M17 热路径纪律不破)。 - 占位可重试(g_page_skip 与永久失败 g_page_fail 分离):焦点页冷却 1.5s 自动重试、 中间区点按立即重试、离开再回来即重试;坏图仍是永久占位。 - 解码前内存门控:MemAvailable < 6MB 不解码(读不到就放行,不猜)。 - 解码请求取消:worker 出队后、mr_book_open 前二次判 gen/窗口;拖动/fling 期间只解落点页, 预取邻居仅当 MemAvailable ≥ 12MB。 - 先释放再解码:queue_page 按落地字节 slots_reserve() 先淘汰不可见旧图;可见页不淘汰。 - 槽位 4→3、常驻上限 9MB→3MB、结果队列 4→2。 - 可观测性:`reader decode page=N src=WxH peak=XkB memfree=YkB memavail=ZkB ok|skip=… step=S ms=M` 与进出阅读器 rss/hwm/memfree/memavail。 page_source: 头部探测新增 codec/progressive/channels/planes(mr_probe_info), 只带最小 SOF 的合成头仍能取尺寸(旧契约不退化)。 宿主证据:新增 test_reader_membudget(--wrap malloc 计真实峰值): PNG 600×960 model 4001kB vs 实测 3943kB;JPEG 1000×1500 step2 model 3749/实测 3711; 连翻 100 页 peak_transient 0kB(先释放再解码让缓冲复用)、驻留 ≤3MB、解码 100 次(取消生效)、 3MB 阈值下 1.5MP JPEG 可解、4MP JPEG 需 MemFree 放行(否则占位)、渐进式模型更高于 baseline。 run_tests.sh 全绿(含 zip 106 / cbz 325)、run_webtoon_tests.sh 全绿、SDK 与 tuku 交叉构建零警告。
320 lines
12 KiB
C
320 lines
12 KiB
C
/* 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 <dirent.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <sys/stat.h>
|
||
#include <unistd.h>
|
||
|
||
#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);
|
||
int draws0 = mr_reader_ui_blit_draws() + mr_reader_ui_scaled_draws();
|
||
meow_ui_paint_full();
|
||
CHECK(mr_reader_ui_blit_draws() + mr_reader_ui_scaled_draws() > draws0,
|
||
"draw path executed (paged, 140p book)");
|
||
|
||
/* ---- 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);
|
||
meow_ui_paint_full(); /* 绘制路径覆盖(P2-5) */
|
||
if (mr_reader_ui_page() == before) ok_move = 0;
|
||
}
|
||
CHECK(mr_reader_ui_blit_draws() + mr_reader_ui_scaled_draws() > draws0,
|
||
"draw path executed across flips");
|
||
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());
|
||
/* M22:坏图 = 永久占位(failed);超预算大页 = 预算跳过(skipped,可重试,不是 failed)。 */
|
||
CHECK(mr_reader_ui_failed_pages() == 1,
|
||
"garbage page is a permanent placeholder (failed=%d)", mr_reader_ui_failed_pages());
|
||
CHECK(mr_reader_ui_skipped_pages() == 1,
|
||
"oversized page is a retryable budget skip (skipped=%d)", mr_reader_ui_skipped_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");
|
||
{
|
||
int d0 = mr_reader_ui_blit_draws() + mr_reader_ui_scaled_draws();
|
||
meow_ui_paint_full();
|
||
meow_ui_paint_full(); /* 条漫绘制路径覆盖(P2-5) */
|
||
CHECK(mr_reader_ui_blit_draws() + mr_reader_ui_scaled_draws() > d0,
|
||
"draw path executed (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;
|
||
}
|