Tuku reader: 真实解码峰值模型 + 3MB 硬阈值 + 解码取消 + 3 槽(M22 止损版)

真机(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 交叉构建零警告。
This commit is contained in:
AskaEth committed 2026-11-05 21:28:50 +08:00
1 parent 773ab9ff92
commit 40751c09bd
10 files changed
+1257 -166

No files matched your search

+445 -132
View File
@@ -15,8 +15,12 @@
* 宽恒为视口宽),不是屏长边,所以翻页/滚动热路径只有原生 blit:不做 meow_ui_img_scaled
* 的每帧重采样,也不再每帧 malloc 1MB 级临时缓冲。几何变化(fit/缩放/切模式)时按新几何
* 重解一次;拖动/吸附帧只 invalidate 内容区(顶栏静态不重绘)。
* 内存:单页显示缓存 ≤3MB、整窗 ≤9MB(超限淘汰最远页),解码中间峰值 >12MB 的页直接跳过
* 并显示占位——页数增长不会线性吃内存(常驻只与解码窗大小有关)。
* 内存(M17 起;M22 按真机实测重定):单页显示缓存 ≤3MB、整窗常驻 ≤3MB(3 槽,超限淘汰
* 最远页),单次解码真实峰值常态 ≤3MB —— 超出时 JPEG 先降缩解档位、PNG/BMP 直接占位
* (占位可重试,不是永久失败)。峰值模型见 decode_peak_bytes():它按 stb 的真实分配算
* (JPEG 分量面、渐进的 short 系数面、PNG 的源缓冲 + RGBA 转换副本),不再是「把 stb 当
* 缩解库」的低估式。余量确实充足(MemFree 富余)时允许放宽到 MR_DECODE_PEAK_HARD。
* 页数增长不会线性吃内存(常驻只与解码窗大小有关)。
* 方向:一律读 M3 的 mr_reader_get_direction()(默认 RTL),不另立全局。
*
* 模式(M7):翻页/条漫是一等公民。阅读 chrome 的图标按钮一键切换;每册的模式与 last page
@@ -54,9 +58,8 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#define MR_SLOTS 4 /* 页图缓存槽上限(条漫视口±1 ≤4;翻页只用 3) */
#define MR_SLOTS 3 /* 页图缓存槽上限(M22:4→3 省 ~1.4MB;翻页用 2,条漫视口 2–3) */
#define MR_TOP_BAR_H 56
#define MR_SLIDER_H 40
#define MR_SLIDE_MS 150 /* 松手后一次短滑动吸附 */
@@ -71,14 +74,21 @@
#define MR_JUMP_TIMEOUT_MS 2500 /* 目标页迟迟解不出来 → 降级落位并提示 */
#define MR_CONT_SAVE_MS 1000 /* 续读状态落盘节流 */
#define MR_TOAST_MS 1400
#define MR_LOAD_RAW_WARN (8L * 1024 * 1024) /* 非缩解格式原始 RGBA 超此值提示残余风险 */
#define MR_DECODE_PEAK_CAP (12L * 1024 * 1024) /* 单次解码中间缓冲峰值上限(超则跳过,防 OOM 闪退) */
/* ---- 内存预算(M22,按 R3 Pro II 实测校准:MemTotal 56872kB、空闲 RSS 6044kB、
* 杀点 RSS≈15.6MB / 当时 MemFree 仅 1.4MB;M20 宿主实测单页解码尖峰 3.4–35MB)---- */
#define MR_DECODE_PEAK_CAP (3L * 1024 * 1024) /* 常态单次解码峰值上限(真实模型;3MB 硬阈值) */
#define MR_DECODE_PEAK_HARD (12L * 1024 * 1024) /* 余量充足时的绝对峰值上限(不再超老 12MB 档) */
#define MR_MEM_FREE_MARGIN_KB (4L * 1024) /* 放宽档:MemFree 必须比峰值多出这么多 */
#define MR_MEM_FLOOR_KB (6L * 1024) /* 解码前门控:MemAvailable 低于此值不解码 */
#define MR_PREFETCH_MIN_KB (12L * 1024) /* 预取邻居要求的最低 MemAvailable */
#define MR_SKIP_RETRY_MS 1500 /* 被预算跳过的页:焦点页冷却这么久后自动重试 */
#define MR_DECODE_SLACK (64L * 1024) /* 峰值模型余量:结构体/行缓冲/分配器开销(实测 5–6KB) */
#define MR_PAGE_BYTES_CAP (3L * 1024 * 1024) /* 单页「显示尺寸」缓存字节上限 */
#define MR_RESIDENT_CAP (9L * 1024 * 1024) /* 页图缓存总字节上限(超则淘汰最远页) */
#define MR_RESIDENT_CAP (3L * 1024 * 1024) /* 页图缓存总字节上限(M22 由 9MB 校准为 3MB) */
#define MR_SLOT_TOL 2 /* 缓存图与显示框允许的像素偏差(≤2 走原生 blit,不重采样) */
#define MR_REQ_MAX 8
#define MR_RES_MAX 4
#define MR_RES_MAX 2 /* 结果队列上限(M22:4→2;未取走的已解码图不计入常驻预算) */
/* view on_event 的事件号不在公共头(sdk/meow_ui_internal.h §6.4);本页只用到 SWIPE=3。 */
#define MR_EV_SWIPE 3
@@ -147,20 +157,37 @@ typedef struct page_slot {
} page_slot;
static page_slot g_slot[MR_SLOTS];
/* 解码失败页标记:避免对坏图/超大图无限重试,UI 显示「图片无法显示」而不是一直加载中。 */
/* 解码失败页标记:坏图/无法解析 → 永久显示占位(避免无限重试)。
* 与「预算跳过」是两回事,见 g_page_skip:后者可重试。 */
static unsigned char g_page_fail[(MR_BOOK_MAX_PAGES + 7) / 8];
/* 预算跳过页标记(M22):内存不足 / 单页峰值超预算 → 先不解码,显示「内存不足」占位。
* 必须在条件改善后可重试:离开再回来(焦点页变化)、或用户点按显式重试、或冷却期后自动重试。
* M17 的实现把这类页直接标成永久 failed,用户再也看不到——那等于把内存问题变成功能问题。 */
static unsigned char g_page_skip[(MR_BOOK_MAX_PAGES + 7) / 8];
static uint64_t g_skip_ms; /* 最近一次预算跳过的时刻(自动重试节流) */
/* 每页原始像素尺寸缓存(打开时按 count 分配;0,0=未知)。绘制/排版几何只查这里,
* 不在每帧去 probe 文件(dir_ops_meta 是 fopen+读头,每帧一次会直接拖垮翻页)。 */
typedef struct page_meta { int w, h; } page_meta;
static page_meta *g_meta;
/* 本次解码计划(主线程在 queue_page 里算好,经 page_req 传给 worker;worker 在
* mr_book_open 前写入这几个静态量,解码回调只读它们 → 后台线程不再读 UI 全局几何)。 */
static int g_plan_want_w, g_plan_want_h; /* 显示框(几何,未钳制) */
static int g_plan_long; /* 实际解码长边(受单页字节上限钳制) */
/* 本次解码计划(主线程在 queue_page 里按显示框算好,经 page_req 传给 worker;worker 在
* mr_book_open 前写入这些静态量,解码回调只读它们 → 后台线程不再读 UI 全局几何)。
* want_* = 显示框(几何,slot_ready 的判据);box_* = 字节上限钳制后的落地尺寸
* (解码结果会精确重采样到 box_*,所以命中页仍是 1 次 blit)。 */
typedef struct decode_plan {
int want_w, want_h;
int box_w, box_h;
} decode_plan;
static decode_plan g_plan;
static int g_plan_page; /* 本次解码的页号(只用于日志) */
static int g_decode_skip; /* reader_load_page 的跳过原因(MR_SKIP_*) */
static volatile int g_decode_count; /* 累计解码次数(观察重解抖动/活锁) */
#define MR_SKIP_NONE 0 /* 解出来了 */
#define MR_SKIP_LOWMEM 1 /* 内存门控拦下(MemAvailable 不足) */
#define MR_SKIP_PEAK 2 /* 真实峰值超预算,且没有余量放行 */
static int page_failed(int idx)
{
if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return 0;
@@ -173,18 +200,53 @@ static void page_mark_failed(int idx)
g_page_fail[idx >> 3] |= (unsigned char)(1u << (idx & 7));
}
static int page_skipped(int idx)
{
if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return 0;
return (g_page_skip[idx >> 3] >> (idx & 7)) & 1;
}
static void page_mark_skipped(int idx)
{
if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return;
g_page_skip[idx >> 3] |= (unsigned char)(1u << (idx & 7));
g_skip_ms = meow_ui_time_ms();
}
static void page_skip_clear(int idx)
{
if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return;
g_page_skip[idx >> 3] &= (unsigned char)~(1u << (idx & 7));
}
/* 该页现在没图、且不会自己再来(永久失败 / 被预算跳过)→ 画占位文字而不是「加载中…」。
* 两者都是「没图」,但只有 skip 是可重试的(见 maybe_retry_skipped_focus)。 */
static int page_blocked(int idx)
{
return page_failed(idx) || page_skipped(idx);
}
/* 占位文字:坏图是不可恢复的;预算跳过是「内存不足」,点按可重试(任务 2 的可重试语义)。 */
static const char *page_placeholder(int idx)
{
if (page_skipped(idx)) return "内存不足,点按重试";
if (page_failed(idx)) return "图片无法显示";
return "加载中…";
}
/* ---- 后台解码队列(请求携带解码几何,结果带回同一条几何) ---- */
typedef struct page_req {
int idx;
unsigned gen;
int want_w, want_h; /* 按哪个显示框解码(主线程算) */
int long_side; /* 实际解码长边(≤ 单页字节上限) */
int want_w, want_h; /* 按哪个显示框解码(主线程算,slot_ready 判据) */
int box_w, box_h; /* 落地上限(单页字节上限钳制后的目标尺寸) */
} page_req;
typedef struct page_res {
int idx;
unsigned gen;
meow_img *img;
int fail;
int skip; /* MR_SKIP_*:非 0 = 预算跳过(可重试,不是坏图) */
int want_w, want_h;
} page_res;
@@ -254,92 +316,15 @@ static void cont_save_now(void);
static int current_page(void);
/* ============================================================ */
/* 页源解码回调(注入 logic 层的 mr_book 契约;可在后台线程调用) */
/* ============================================================ */
/* 扩展名判断:JPEG 支持解码期缩解(M12),PNG/BMP 只能整幅解码后降采样。 */
static int path_is_jpeg(const char *path)
{
const char *dot = path ? strrchr(path, '.') : NULL;
if (!dot) return 0;
dot++;
return strcasecmp(dot, "jpg") == 0 || strcasecmp(dot, "jpeg") == 0;
}
/* 内存读数与观测(M22) */
/* ------------------------------------------------------------
* /proc/meminfo 与 /proc/self/status 是唯一可用的实时内存事实来源。读不到(宿主/非 Linux)
* 一律返回 -1,调用方必须按「未知」处理,不许猜。宿主测试用 mr_reader_ui_test_set_mem()
* 注入固定读数,让内存门控/放宽档可以确定性验证。
* ============================================================ */
static long g_mem_free_override = -1; /* >=0 = 宿主测试注入的 MemFree(kB) */
static long g_mem_avail_override = -1; /* >=0 = 宿主测试注入的 MemAvailable(kB) */
/* 由显示框(want_w×want_h)反推解码长边:默认就解到显示框大小(解码后可直接 blit),
* 只有单页缓存字节上限(MR_PAGE_BYTES_CAP)是硬上界——超长条漫在这里按比例收缩。
* 不再钳到屏长边:条漫页常见长宽比 1.6~1.8,显示高度本来就 > 屏长边,钳了必然每帧重采样。 */
static int plan_decode_long(int want_w, int want_h)
{
int l0 = want_w > want_h ? want_w : want_h;
int s0 = want_w > want_h ? want_h : want_w;
if (l0 <= 0) return 0;
int l = l0 < 64 ? 64 : l0;
while (l > 64) {
int s = (int)((long long)s0 * l / l0);
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;
/* 解码计划由 queue_page() 在主线程算好(want 显示框 + 实际长边),worker/主线程同步兜底
* 路径在 mr_book_open 前写入;无计划时退回旧预算 maxdim。 */
int want_w = g_plan_want_w, want_h = g_plan_want_h;
int dim = g_plan_long > 0 ? g_plan_long : maxdim;
/* 预算预判(M12):先读文件头拿原始尺寸,不解码像素。
* - 解码中间缓冲峰值超上限 → 跳过(页面显示占位),避免真机被 OOM 杀掉(闪退);
* - 峰值 = 解码缓冲 + 随后的 downscale 输出(≤单页缓存上限),两者会同时驻留;
* - 非缩解格式(PNG/BMP)原始 RGBA 超过告警线 → 记 WARN(残余风险)。 */
int pw = 0, ph = 0, ch = 0;
int have = meow_img_probe_file(path, &pw, &ph, &ch) && pw > 0 && ph > 0;
if (have) {
int l0 = pw > ph ? pw : ph;
long long out_bytes = MR_PAGE_BYTES_CAP; /* downscale/缩解输出上限 */
if (want_w > 0 && want_h > 0 && (long long)want_w * want_h * 4 < out_bytes)
out_bytes = (long long)want_w * want_h * 4;
long long decode_bytes;
if (path_is_jpeg(path)) {
/* 估 stbi 缩解后的输出(与 SDK 的档位选择同式):仍超上限才跳过。 */
int s = 1;
while (s < 8 && (l0 / (s * 2)) >= dim) s *= 2;
decode_bytes = (long long)(pw / s) * (ph / s) * 4;
} else {
decode_bytes = (long long)pw * ph * 4; /* 整幅解码后降采样 */
if (decode_bytes > MR_LOAD_RAW_WARN)
meow_log(MEOW_LOG_WARN, "reader decode large non-scalable %dx%d (%lldKB): %s",
pw, ph, decode_bytes / 1024, path);
}
if (decode_bytes + out_bytes > MR_DECODE_PEAK_CAP) {
meow_log(MEOW_LOG_WARN, "reader decode skip %dx%d (peak %lldKB > cap): %s",
pw, ph, (decode_bytes + out_bytes) / 1024, path);
return NULL;
}
}
meow_img *im = meow_img_load_file_dim(path, dim);
if (!im) return NULL;
/* 兜底:M12 之前的格式/未知格式若没被缩到预算内,再降一次(保持驻留纪律)。 */
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;
}
static void reader_unload_page(meow_img *img)
{
meow_img_free(img);
}
/* ============================================================ */
/* 内存观测(验收用:进出 reader 对比) */
/* ============================================================ */
static long mem_kb(const char *path, const char *key)
{
FILE *f = fopen(path, "r");
@@ -354,6 +339,21 @@ static long mem_kb(const char *path, const char *key)
return v;
}
/* MemFree(kB)。真机杀点就是「MemFree 见底」:15,600kB 那次只剩 1,404kB。 */
static long mem_free_kb(void)
{
if (g_mem_free_override >= 0) return g_mem_free_override;
return mem_kb("/proc/meminfo", "MemFree:");
}
/* MemAvailable(kB):含可回收页缓存。注意本机在 MemFree 1.4MB 时仍有 ~26MB,
* 所以它只能当「系统极度缺页」的门控,不能当解码配额(内核不会等回收就杀进程)。 */
static long mem_avail_kb(void)
{
if (g_mem_avail_override >= 0) return g_mem_avail_override;
return mem_kb("/proc/meminfo", "MemAvailable:");
}
static int resident_pages(void)
{
int n = 0;
@@ -370,11 +370,206 @@ static long resident_bytes(void)
return b;
}
/* 进出阅读器各打一行(任务 5):RSS 看当前占用,VmHWM 看本次进入的峰值——
* 验收就是「离开时的 hwm 不超过预算」。 */
static void log_mem(const char *tag)
{
meow_log(MEOW_LOG_INFO, "reader mem[%s]: avail=%ldKB rss=%ldKB pages=%d img=%ldKB",
tag, mem_kb("/proc/meminfo", "MemAvailable:"),
mem_kb("/proc/self/status", "VmRSS:"), resident_pages(), resident_bytes() / 1024);
meow_log(MEOW_LOG_INFO, "reader mem[%s]: rss=%ldkB hwm=%ldkB memfree=%ldkB memavail=%ldkB pages=%d img=%ldkB",
tag, mem_kb("/proc/self/status", "VmRSS:"),
mem_kb("/proc/self/status", "VmHWM:"), mem_free_kb(), mem_avail_kb(),
resident_pages(), resident_bytes() / 1024);
}
/* ============================================================ */
/* 页源解码回调(注入 logic 层的 mr_book 契约;可在后台线程调用) */
/* ============================================================ */
/* 由显示框(want_w×want_h)反推解码长边:默认就解到显示框大小(解码后可直接 blit),
* 只有单页缓存字节上限(MR_PAGE_BYTES_CAP)是硬上界——超长条漫在这里按比例收缩。
* 不再钳到屏长边:条漫页常见长宽比 1.6~1.8,显示高度本来就 > 屏长边,钳了必然每帧重采样。 */
static int plan_decode_long(int want_w, int want_h)
{
int l0 = want_w > want_h ? want_w : want_h;
int s0 = want_w > want_h ? want_h : want_w;
if (l0 <= 0) return 0;
int l = l0 < 64 ? 64 : l0;
while (l > 64) {
int s = (int)((long long)s0 * l / l0);
if ((long long)l * s * 4 <= MR_PAGE_BYTES_CAP) break;
l = l * 3 / 4;
}
return l;
}
/* 显示框 → 实际落地尺寸(保持长宽比,受单页字节上限钳制)。
* 正常情况下 box == want(解码结果就是显示尺寸 → 单次 blit);只有超大屏/超长条漫
* 才会收缩,此时绘制走缩放路径(M17 已知代价,不再是常态)。 */
static void plan_box_size(int want_w, int want_h, int *bw, int *bh)
{
int w = want_w > 0 ? want_w : g_vw;
int h = want_h > 0 ? want_h : g_vh;
if (w <= 0 || h <= 0) { w = h = 1; }
int long0 = w > h ? w : h;
int l = plan_decode_long(w, h);
if (l > 0 && l < long0) {
if (w >= h) { h = (int)((long long)h * l / w); w = l; }
else { w = (int)((long long)w * l / h); h = l; }
if (w < 1) w = 1;
if (h < 1) h = 1;
}
*bw = w;
*bh = h;
}
/* 真实解码峰值模型(M22)。上一版把 stb 当「缩解库」估 (W/s)(H/s)·4,对 JPEG 低估 3.5–8 倍
* (M20 宿主实测:2000×3000 baseline +10.1MB、渐进 +23.5MB、1200×1600 PNG +12.9MB)。
* 这里按 stb 的真实分配逐项相加(与 stb_image.h 的分配点对齐):
* 1) stb 阶段:中间面 planes + 解码输出缓冲 dst(两者并存到解压结束)
* JPEG:planes = 分量面(+渐进系数面),dst = (W/s)(H/s)·4(s>1 时只物化抽取输出)
* PNG :planes = 源像素缓冲,dst = W·H·4(ch==4 时同一缓冲,planes 记 0)
* BMP :planes = 0,dst = W·H·4(stb 直接写 RGBA 目标)
* 2) 精修阶段:dst 超长边上限时再落一张 box 大小的图(dst + finished 并存)
* 3) 落框阶段:档位偏粗时把 finished 重采样到显示框(finished + box 并存)
* 峰值 = 三段的最大值。返回字节数(不是 kB),参数非法返回 0。 */
static long long decode_peak_bytes(const mr_img_info *in, int step, int box_w, int box_h)
{
if (!in || in->w <= 0 || in->h <= 0 || box_w <= 0 || box_h <= 0) return 0;
long long W = in->w, H = in->h;
long long box = (long long)box_w * box_h * 4;
long long dst;
if (in->codec == MR_IMG_CODEC_JPEG) {
if (step < 1) step = 1;
dst = ((W + step - 1) / step) * ((H + step - 1) / step) * 4;
} else {
dst = W * H * 4; /* PNG/BMP:无解码期缩放,整幅落地 */
}
long long peak = in->planes + dst; /* stb 阶段 */
long long finished = dst > box ? box : dst; /* 精修/落框后的最终图 */
if (dst + finished > peak) peak = dst + finished;
if (finished + box > peak) peak = finished + box;
return peak + MR_DECODE_SLACK; /* 结构体/行缓冲/分配器开销的实测余量 */
}
/* 本次解码的峰值预算:
* - 常态 = MR_DECODE_PEAK_CAP(3MB 硬阈值,覆盖基线 6044 + 常驻 3MB + 峰值 3MB ≤ 13.5MB);
* - 只有 MemFree 真的富余时才放宽(真机杀点由 MemFree 见底触发,不是固定 RSS),
* 放宽上限 = min(MR_DECODE_PEAK_HARD, MemFree - MR_MEM_FREE_MARGIN_KB);
* - 读不到 MemFree → 只用常态预算(不猜)。 */
static long long decode_peak_budget(long memfree_kb)
{
long long budget = MR_DECODE_PEAK_CAP;
if (memfree_kb >= 0) {
long long room = (memfree_kb - MR_MEM_FREE_MARGIN_KB) * 1024;
if (room > budget) budget = room;
if (budget > MR_DECODE_PEAK_HARD) budget = MR_DECODE_PEAK_HARD;
}
return budget;
}
/* 固定字段的解码日志(任务 5):page/src/peak/memfree/memavail 之后是结论,
* 便于 `grep 'reader decode'` 后按列看曲线。 */
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,
unsigned long long ms)
{
meow_log(MEOW_LOG_INFO,
"reader decode page=%d src=%dx%d peak=%lldkB memfree=%ldkB memavail=%ldkB %s step=%d ms=%llu",
page, src_w, src_h, peak_bytes / 1024, memfree, memavail, verdict, step, ms);
}
static meow_img *reader_load_page(const char *path, void *ud, int maxdim)
{
(void)ud;
g_decode_skip = MR_SKIP_NONE;
uint64_t t0 = meow_ui_time_ms();
if (!path || !path[0]) return NULL;
/* 解码计划由 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;
if (box_w <= 0 || box_h <= 0) {
if (maxdim > 0) {
box_w = box_h = maxdim;
} else {
box_w = want_w > 0 ? want_w : 1;
box_h = want_h > 0 ? want_h : 1;
}
}
int dim = box_w > box_h ? box_w : box_h;
long mfree = mem_free_kb();
long mavail = mem_avail_kb();
/* 1) 门控(任务 1):MemAvailable 不足 → 不解码、不分配,直接占位(可重试)。
* 阈值 = MR_MEM_FLOOR_KB。读不到(-1)按「可解码」放行,不因探测失败卡死阅读。 */
if (mavail >= 0 && mavail < MR_MEM_FLOOR_KB) {
g_decode_skip = MR_SKIP_LOWMEM;
log_decode(page, 0, 0, 0, mfree, mavail, "skip=lowmem", 1,
(unsigned long long)(meow_ui_time_ms() - t0));
return NULL;
}
/* 2) 头部探测(零像素内存)→ 真实峰值模型 → 选缩解档位。 */
mr_img_info in;
int have = mr_probe_info(path, &in);
int step = 1;
long long peak = 0;
if (have) {
long long budget = decode_peak_budget(mfree);
if (in.codec == MR_IMG_CODEC_JPEG) {
/* 从「不欠采样」的档位起步,逐步加粗直到峰值进预算。
* 分量面与档位无关,所以大页最终仍会无档可用 → 占位(诚实代价)。 */
int l0 = in.w > in.h ? in.w : in.h;
step = meow_img_pick_scale(l0, dim);
for (;;) {
peak = decode_peak_bytes(&in, step, box_w, box_h);
if (peak <= budget) break;
if (step >= 8) { step = 8; break; }
step *= 2;
}
} else {
step = 1;
peak = decode_peak_bytes(&in, step, box_w, box_h);
}
if (peak > budget) {
g_decode_skip = MR_SKIP_PEAK;
log_decode(page, in.w, in.h, peak, mfree, mavail, "skip=peak", step,
(unsigned long long)(meow_ui_time_ms() - t0));
return NULL;
}
}
/* 3) 解码(档位已定)。失败与「跳过」必须分开:NULL + skip==0 才是坏图。 */
meow_img *im = have ? meow_img_load_file_step(path, dim, step)
: meow_img_load_file_dim(path, dim);
if (!im) {
log_decode(page, have ? in.w : 0, have ? in.h : 0, peak, mfree, mavail, "fail", step,
(unsigned long long)(meow_ui_time_ms() - t0));
return NULL;
}
/* 兜底:非 JPEG 或粗档位输出没被缩到上限内,再降一次(保持驻留纪律)。 */
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; }
}
/* 4) 精确落到显示框(仅当「故意用了粗档位」时):结果 = box → 命中页仍是一次原生 blit,
* 不会把内存换来的降级变成每帧重采样(M17 的热路径纪律)。
* 源图本身就小于显示框(小图/缩略图)时绝不放大:那会凭空多占常驻字节,
* 且 M17 的 slot_ready 语义依赖「SDK 不做上采样」。 */
if (have && 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, peak, mfree, mavail, "ok", step,
(unsigned long long)(meow_ui_time_ms() - t0));
return im;
}
static void reader_unload_page(meow_img *img)
{
meow_img_free(img);
}
/* ============================================================ */
@@ -551,7 +746,9 @@ static int slot_ready(int idx)
return abs(w - s->want_w) <= MR_SLOT_TOL && abs(h - s->want_h) <= MR_SLOT_TOL;
}
/* 淘汰离焦点页最远的一张(不淘汰 focus 与 except)。返回被清掉的槽下标,无则 -1。 */
/* 淘汰离焦点页最远的一张(不淘汰 focus、可见页与 except)。返回被清掉的槽下标,无则 -1。
* M22:可见页不淘汰——条漫视口可能同时露出 2–3 页,按距离淘汰会把屏幕上的页拆掉,
* 结果是「一页闪占位 → 重解码 → 再被拆」的抖动。宁可临时超一点字节,也不让可见页消失。 */
static int slot_evict_far(int except)
{
int focus = focus_page();
@@ -559,6 +756,7 @@ static int slot_evict_far(int except)
for (int i = 0; i < MR_SLOTS; i++) {
if (i == except || !g_slot[i].img) continue;
if (g_slot[i].idx == focus) continue;
if (page_visible(g_slot[i].idx)) continue;
int d = abs(g_slot[i].idx - focus);
if (d > far_d) { far_d = d; far = i; }
}
@@ -566,6 +764,17 @@ static int slot_evict_far(int except)
return far;
}
/* 开新页前先腾地方(任务 3):按「即将落地的单页字节」预判,先淘汰非可见/非焦点的旧图,
* 让解码缓冲不与陈旧常驻叠加。只在逼近上限时才动手,避免把预取邻居立刻扔掉
*(那样翻页会从「命中」退化成「重新解码」,反而增加峰值次数)。 */
static void slots_reserve(long need_bytes)
{
if (need_bytes <= 0) return;
while (resident_bytes() + need_bytes > MR_RESIDENT_CAP) {
if (slot_evict_far(-1) < 0) return; /* 只剩可见页:放行,下一次 slot_put 再收敛 */
}
}
/* 放进缓存;同页旧图直接替换(fit/zoom/模式变了要换尺寸)。返回 1=已放入。
* want_w/want_h = 这次解码按哪个显示框落的,交给 slot_ready 做几何判据。 */
static int slot_put(int idx, meow_img *img, int want_w, int want_h)
@@ -579,12 +788,14 @@ static int slot_put(int idx, meow_img *img, int want_w, int want_h)
if (i < 0) {
if (empty >= 0) {
i = empty;
} else { /* 全满:替换离焦点页最远的一张 */
} else { /* 全满:替换离焦点页最远、且不可见的一张 */
int focus = focus_page(), far_d = -1;
for (int k = 0; k < MR_SLOTS; k++) {
if (page_visible(g_slot[k].idx)) continue;
int d = abs(g_slot[k].idx - focus);
if (d > far_d) { far_d = d; i = k; }
}
if (i < 0) i = g_slot[0].img ? 0 : -1; /* 全可见:只能挤掉第一槽(极端条漫页) */
}
}
if (i < 0) { meow_img_free(img); return 0; }
@@ -666,15 +877,30 @@ static int req_queued(int idx)
return 0;
}
/* 预取闸门(任务 4):拖动/惯性期间只解落点页,不做邻居预取;且只在内存确实富裕时预取。
* MemAvailable 读不到按「不预取」处理(保守)。 */
static int prefetch_allowed(void)
{
if (g_dragging) return 0;
if (g_scroll && meow_scroll_active(g_scroll)) return 0;
long a = mem_avail_kb();
if (a < 0) return 0;
return a >= MR_PREFETCH_MIN_KB;
}
static void queue_page(int idx)
{
if (!mr_book_valid(&g_book) || idx < 0 || idx >= g_count) return;
if (slot_ready(idx) || page_failed(idx)) 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);
if (want_w <= 0 || want_h <= 0) { want_w = g_vw; want_h = g_vh; }
int long_side = plan_decode_long(want_w, want_h);
int box_w = 0, box_h = 0;
plan_box_size(want_w, want_h, &box_w, &box_h);
/* 先释放再解码:按这一页的落地字节先淘汰不可见旧图,别让常驻与解码缓冲叠加。 */
slots_reserve((long)box_w * box_h * 4);
pthread_mutex_lock(&g_lock);
if (!g_stop && !req_queued(idx) && g_req_n < MR_REQ_MAX) {
page_req *r = &g_req[g_req_tail];
@@ -682,7 +908,8 @@ static void queue_page(int idx)
r->gen = g_gen;
r->want_w = want_w;
r->want_h = want_h;
r->long_side = long_side;
r->box_w = box_w;
r->box_h = box_h;
g_req_tail = (g_req_tail + 1) % MR_REQ_MAX;
g_req_n++;
pthread_cond_signal(&g_cv);
@@ -701,32 +928,40 @@ 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× 解码,
* 全是这种在飞不可取消的请求。锁内判,窗口也在锁内写,避免共享状态竞争。 */
int stale = (req.gen != g_gen) || !in_window(req.idx);
pthread_mutex_unlock(&g_lock);
if (stale) {
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());
continue;
}
/* 解码计划:本线程私有(单 worker),解码回调只读这三个量。 */
g_plan_want_w = req.want_w;
g_plan_want_h = req.want_h;
g_plan_long = req.long_side;
uint64_t t0 = meow_ui_time_ms();
/* 解码计划:本线程私有(单 worker),解码回调只读这几个量。 */
g_plan.want_w = req.want_w;
g_plan.want_h = req.want_h;
g_plan.box_w = req.box_w;
g_plan.box_h = req.box_h;
g_plan_page = req.idx;
g_decode_skip = MR_SKIP_NONE;
meow_img *img = mr_book_open(&g_book, req.idx);
uint64_t dt = meow_ui_time_ms() - t0;
g_decode_count++;
int w = img ? meow_img_w(img) : 0;
int h = img ? meow_img_h(img) : 0;
meow_log(MEOW_LOG_INFO, "reader decode page=%d want=%dx%d long=%d ms=%llu %dx%d %s",
req.idx, req.want_w, req.want_h, req.long_side,
(unsigned long long)dt, w, h, img ? "ok" : "fail");
int skip = img ? MR_SKIP_NONE : g_decode_skip;
pthread_mutex_lock(&g_lock);
if (!g_stop && g_res_n < MR_RES_MAX) {
g_res[g_res_n].idx = req.idx;
g_res[g_res_n].gen = req.gen;
g_res[g_res_n].img = img;
g_res[g_res_n].fail = img ? 0 : 1;
g_res[g_res_n].fail = (img || skip) ? 0 : 1;
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_n++;
img = NULL; /* 所有权已移交(失败时本就是 NULL) */
img = NULL; /* 所有权已移交(失败/跳过时本就是 NULL) */
}
pthread_mutex_unlock(&g_lock);
if (img) meow_img_free(img);
@@ -781,7 +1016,8 @@ static void queue_prune(void)
pthread_mutex_unlock(&g_lock);
}
/* 收取后台结果:只留窗口内新页。返回 1=有图落位。 */
/* 收取后台结果:只留窗口内新页。返回 1=有图落位。
* 结果分三种:ok(落槽)/ fail(坏图,永久占位)/ skip(预算跳过,可重试占位)。 */
static int results_drain(void)
{
page_res got[MR_RES_MAX];
@@ -795,6 +1031,12 @@ 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; }
if (got[i].skip) {
/* 预算跳过(内存不足/峰值超限):可重试,不写永久失败位。 */
page_mark_skipped(got[i].idx);
if (page_visible(got[i].idx)) g_fail_paint = 1;
continue;
}
if (got[i].fail) {
page_mark_failed(got[i].idx);
if (page_visible(got[i].idx)) g_fail_paint = 1;
@@ -803,6 +1045,7 @@ static int results_drain(void)
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;
page_skip_clear(got[i].idx); /* 解出来了:撤销跳过标记 */
} else {
meow_img_free(got[i].img);
}
@@ -810,14 +1053,40 @@ static int results_drain(void)
return changed;
}
/* 窗口随视口/当前页移动:淘汰窗口外图 → 清掉窗口外请求 → 按优先级补页。 */
/* 预算跳过的页如何重试(任务 2 的可重试语义):只对**焦点页**重试,且冷却 MR_SKIP_RETRY_MS,
* 这样「内存暂时不够」不会变成永久占位,也不会变成每 tick 一次的重试风暴。
* force=1 是用户显式重试(点按占位页)。 */
static void maybe_retry_skipped_focus(int force)
{
int f = focus_page();
if (f < 0 || !page_skipped(f) || page_failed(f)) return;
if (!force) {
uint64_t now = meow_ui_time_ms();
if (now - g_skip_ms < MR_SKIP_RETRY_MS) return;
}
page_skip_clear(f);
g_skip_ms = meow_ui_time_ms();
meow_log(MEOW_LOG_INFO, "reader decode retry page=%d%s", f + 1, force ? " (tap)" : "");
}
/* 窗口随视口/当前页移动:淘汰窗口外图 → 清掉窗口外请求 → 按优先级补页。
* 窗口本身在锁内写(worker 的取消判据要读它)。 */
static void window_update(void)
{
pthread_mutex_lock(&g_lock);
window_compute();
pthread_mutex_unlock(&g_lock);
/* 不主动清窗口外的图:多留一页「回看缓存」,避免 0→1→0 来回翻时反复重解同一页。
* 容量仍由 MR_SLOTS 与 slot_put 的字节淘汰兜底(最多 MR_SLOTS 张)。 */
queue_prune();
for (int i = 0; i < g_win_n; i++) queue_page(g_win_idx[i]);
/* 回到某页 → 允许重试这一页(「离开再回来」的可重试语义)。 */
maybe_retry_skipped_focus(0);
/* 队列第一位 = 跳页目标或焦点页(必解);其余是预取,按内存闸门决定做不做。 */
int prefetch = prefetch_allowed();
for (int i = 0; i < g_win_n; i++) {
if (i > 0 && !prefetch) continue;
queue_page(g_win_idx[i]);
}
}
/* ============================================================ */
@@ -1005,7 +1274,7 @@ static void draw_strip(const meow_theme *th)
meow_ui_fill_rect(0, y0, g_vw, y1 - y0, th->surfaceContainer);
if (y1 - y0 >= 48)
meow_ui_text_center(g_vw / 2, y0 + (y1 - y0) / 2 - 10,
page_failed(i) ? "图片无法显示" : "加载中…",
page_placeholder(i),
th->font_body, th->onSurfaceVariant);
}
}
@@ -1069,7 +1338,7 @@ static void reader_draw(meow_view *v, const meow_theme *th, void *ctx)
draw_page_at(g_page, 0, 0);
if (!slot_find(g_page)) {
meow_ui_text_center(w / 2, h / 2,
page_failed(g_page) ? "图片无法显示" : "加载中…",
page_placeholder(g_page),
th->font_body, th->onSurfaceVariant);
}
draw_toast(th);
@@ -1316,7 +1585,8 @@ static void jump_land(int page, int timed_out)
meow_log(MEOW_LOG_INFO, "reader jump land page=%d/%d%s",
page + 1, g_count, timed_out ? " (timeout)" : "");
if (timed_out) toast("页面解码较慢,已跳到目标页");
else if (page_failed(page)) toast("目标页无法显示");
else if (page_blocked(page))
toast(page_skipped(page) ? "目标页内存不足,已跳过" : "目标页无法显示");
}
static void jump_commit_from_slider(void)
@@ -1430,6 +1700,8 @@ static void open_book_reset_state(void)
g_win_n = 0;
g_fail_paint = 0;
memset(g_page_fail, 0, sizeof g_page_fail);
memset(g_page_skip, 0, sizeof g_page_skip);
g_skip_ms = 0;
g_jump_open = 0;
mr_jump_cancel(&g_jump);
g_toast[0] = '\0';
@@ -1525,9 +1797,13 @@ static int open_book(const char *dir, int start_page, int mode)
int ww = 0, wh = 0;
page_geometry_at(g_page, &ww, &wh);
if (ww <= 0 || wh <= 0) { ww = g_vw; wh = g_vh; }
g_plan_want_w = ww;
g_plan_want_h = wh;
g_plan_long = plan_decode_long(ww, wh);
int bw = ww, bh = wh;
plan_box_size(ww, wh, &bw, &bh);
g_plan.want_w = ww;
g_plan.want_h = wh;
g_plan.box_w = bw;
g_plan.box_h = bh;
g_plan_page = g_page;
meow_img *first = mr_book_open(&g_book, g_page);
if (first) slot_put(g_page, first, ww, wh);
meow_log(MEOW_LOG_WARN, "reader worker unavailable, sync first page");
@@ -1776,6 +2052,18 @@ static void tap_content(int x, int y)
(void)y;
if (g_count <= 0) return;
int zone = mr_nav_tap_zone(x, g_vw);
/* 当前页被预算跳过(内存不足占位)→ 中间区点按就是显式重试(任务 2:不能永久 failed)。
* 只占中间区:左右 1/3 仍必须能翻页,否则占位页会把读者困在这一页。 */
if (zone == MR_ZONE_MIDDLE) {
int f = focus_page();
if (f >= 0 && page_skipped(f) && !page_failed(f)) {
maybe_retry_skipped_focus(1);
toast("正在重试…");
window_update();
meow_ui_invalidate(0, 0, g_vw, g_vh);
return;
}
}
if (zone == MR_ZONE_MIDDLE) {
set_chrome(!g_chrome);
return;
@@ -1929,7 +2217,7 @@ static void reader_tick(void)
if (mr_jump_active(&g_jump)) {
int target = mr_jump_target(&g_jump);
int have = target >= 0 && (slot_find(target) != NULL || page_failed(target));
int have = target >= 0 && (slot_find(target) != NULL || page_blocked(target));
int timed_out = 0;
if (mr_jump_poll(&g_jump, have, now, MR_JUMP_TIMEOUT_MS, &timed_out))
jump_land(target, timed_out);
@@ -2064,7 +2352,9 @@ static void reader_state_reset(void)
free(g_meta);
g_meta = NULL;
g_start_at = MR_READER_START_RESUME;
g_plan_want_w = g_plan_want_h = g_plan_long = 0;
memset(&g_plan, 0, sizeof g_plan);
g_plan_page = 0;
g_decode_skip = MR_SKIP_NONE;
}
static void reader_page_enter(meow_view *v, void *ctx)
@@ -2250,6 +2540,28 @@ int mr_reader_ui_failed_pages(void)
if (page_failed(i)) n++;
return n;
}
int mr_reader_ui_skipped_pages(void)
{
int n = 0;
for (int i = 0; i < g_count && i < MR_BOOK_MAX_PAGES; i++)
if (page_skipped(i) && !page_failed(i)) n++;
return n;
}
void mr_reader_ui_test_set_mem(long free_kb, long avail_kb)
{
g_mem_free_override = free_kb;
g_mem_avail_override = avail_kb;
}
/* 峰值模型自检(只读):用真代码的模型算某个文件的预估峰值,供宿主测试与实测对照。 */
long long mr_reader_ui_decode_peak_kb(const char *path, int step, int box_w, int box_h)
{
mr_img_info in;
if (!path || !mr_probe_info(path, &in)) return 0;
return decode_peak_bytes(&in, step, box_w, box_h) / 1024;
}
long mr_reader_ui_peak_cap_kb(void) { return (long)(MR_DECODE_PEAK_CAP / 1024); }
long mr_reader_ui_peak_budget_kb(void) { return (long)(decode_peak_budget(mem_free_kb()) / 1024); }
long mr_reader_ui_resident_cap_kb(void) { return (long)(MR_RESIDENT_CAP / 1024); }
int mr_reader_ui_blit_draws(void) { return g_blit_draws; }
int mr_reader_ui_scaled_draws(void) { return g_scaled_draws; }
int mr_reader_ui_decode_count(void) { return g_decode_count; }
@@ -2266,3 +2578,4 @@ void mr_reader_test_set_root(const char *root)
}
const char *mr_reader_test_root(void) { return g_root; }
#endif