feat(app-iced)+test(presenter): 句柄按 (目录,mtime,大小) 复用;缓存可清空;端到端出图

复审 r1 的三条:

1. 端到端图:DevDocs/artifacts/m251/saves-real-decode-light.png —— 真 host
   (ULL_SCREENSHOT_REAL_HOST=1 + --data-dir 一个临时数据根)→ 后台列 screenshots/ →
   palette_extract::thumbnail 真解码 → DTO → Handle::from_rgba → 上屏。三格是三张真 PNG
   的真像素,副文案的 2.3 KB / 2.2 KB / 24.1 KB 也是真扫描值。造根脚本与复现命令进
   artifacts/m251/{README.md,make-real-root.py},并写明「哪张是排版、哪张是端到端」。
2. 每帧重建 Handle 的成本:改成**缓存**(不是实测)。model.rs 新增 process-level
   memo,键 = (截图目录, 文件, mtime, 字节数)(与 presenter 的缓存键同源,目录单列一格),
   上限 120 条(≈12MB)。判据:the_same_screenshot_reuses_one_handle 断言两次
   from_state 拿到**同一个 Handle id**;变异(换回 Handle::from_rgba)即红。
   a_changed_screenshot_gets_a_new_handle 钉住「文件变了必须换句柄」。
3. 进程级 memo 的副作用:两层各加 #[cfg(test)] clear_cache()/clear_handles(),用例从
   已知状态起跑;两条上限从 240 收到 120(≈12MB/层,算术写进常量文档)。

顺带:句柄键改用 push 拼(format! 模板串会被页面字面量守门当成英文文案抓走);
preview 的页面模型用例拆到 tests/saves/preview.rs(saves.rs 破 400)。
This commit is contained in:
AskaEth committed 2026-11-11 17:09:17 +08:00
1 parent dd9962e610
commit 779a2ae254
6 files changed
+288 -110

No files matched your search

@@ -14,6 +14,9 @@
//! 这也正是 D54「**不许在渲染回调里解码**」的同一条纪律的延伸:解码在 presenter 的后台线程,
//! 句柄在这里,`view()` 只借。
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use iced::widget::image::Handle;
use unlucky_i18n::Catalog;
@@ -173,7 +176,7 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
out.screenshots = view
.screenshots
.iter()
.map(|shot| shot_cell(shot, texts))
.map(|shot| shot_cell(&view.screenshots_dir, shot, texts))
.collect();
out.count = texts.saves_count(view.world_count(), view.screenshot_count());
out.skipped = view.skipped_count();
@@ -189,6 +192,79 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
out
}
/// 句柄缓存的条目上限(超过就整表清空)。
///
/// 一张 160×160 的 RGBA 最坏 ≈ 100KB ⇒ 120 条 ≈ **12MB 上限**;正常一屏 ≤60 张(≈6MB)。
/// 与 presenter 的 `MAX_CACHE_ENTRIES`(也是 120)同量级:两层各自有天花板,不叠成无界。
/// 清空而不是 LRU:代价是下一次 `refresh_view` 重传一遍(罕见),收益是这段逻辑没有顺序状态。
const MAX_HANDLES: usize = 120;
/// 句柄缓存:`(截图目录, 文件名, mtime, 字节数) → Handle`。
///
/// ## 为什么必须缓存(而不是每次 `from_state` 建一个新的)
///
/// `Handle::from_rgba` 每次都生成一个**新 id**,而 `iced` 的渲染器按 id 缓存纹理:id 一变就
/// 重新上传一遍像素。`from_state` 挂在 `refresh_view` 上,而 `refresh_view` 每个 UI 事件都跑
/// (点一下、打一个字)——一屏 60 张就是「每次事件重传 ≈6MB」。M251 复审要求把这条成本消掉:
/// 缓存之后同一张图永远是同一个 id,纹理只上传一次。
///
/// ## 键为什么与 presenter 的缓存键同源
///
/// presenter 那边是 `(完整路径, mtime, 字节数)`(`host::saves::thumbs`),这里换成
/// `(目录, 文件, mtime, 字节数)`——同一组事实,只是目录单独放一格,免得每次拼长串。
/// 任一字段变了(文件被覆盖)就是另一张图 ⇒ 新 id ⇒ 重传,这也正是我们要的。
fn handles() -> &'static Mutex<HashMap<String, Handle>> {
static HANDLES: OnceLock<Mutex<HashMap<String, Handle>>> = OnceLock::new();
HANDLES.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 句柄键的分隔符(`0x01`:文件名里不可能出现,也避免 `<a|b>` 与 `<ab|>` 撞键)。
///
/// 用 `push` 拼而不是 `format!`:`format!` 的模板串里带 `dir`/`u` 这类 ASCII 词,会被
/// `pages/instances/tests/i18n.rs` 的「页面里不许写死英文文案」守门当成界面字面量抓走。
const KEY_SEP: char = '\u{1}';
/// 取这张截图的句柄:命中就复用(id 不变 ⇒ 不重传),未命中才建一次。
fn handle_for(
dir: &str,
shot: &ScreenshotView,
thumb: &unlucky_presenter::dto::ThumbnailView,
) -> Handle {
let mut key = String::with_capacity(dir.len() + shot.file.len() + 24);
key.push_str(dir);
key.push(KEY_SEP);
key.push_str(&shot.file);
key.push(KEY_SEP);
match shot.modified_ms {
Some(ms) => key.push_str(&ms.to_string()),
// 取不到时间:占一个不含数字的记号(不会与任何毫秒数撞上)。
None => key.push('-'),
}
key.push(KEY_SEP);
key.push_str(&shot.bytes.to_string());
let mut store = handles()
.lock()
.unwrap_or_else(|poison| poison.into_inner());
if let Some(cached) = store.get(&key) {
return cached.clone();
}
let handle = Handle::from_rgba(thumb.width, thumb.height, thumb.pixels.clone());
if store.len() >= MAX_HANDLES {
store.clear();
}
store.insert(key, handle.clone());
handle
}
/// 清空句柄缓存(**只给用例**:这是进程级 memo,用例之间必须能从已知状态起跑)。
#[cfg(test)]
pub(in crate::pages::instances) fn clear_handles() {
handles()
.lock()
.unwrap_or_else(|poison| poison.into_inner())
.clear();
}
/// 一张截图 → 墙上的一格(缩略图句柄 / 占位 + 副文案)。
///
/// **三种状态各有各的话**(D54 第 6 条:失败逐条可见):
@@ -199,15 +275,16 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
/// | `Ready`(像素长度不对) | 「解不出这张图」 | `大小 · 时间`(防御性:壳层不该拿到坏像素) |
/// | `NotGenerated` | 「未生成预览」 | `大小 · 时间`(超预算没轮到) |
/// | `Failed` | 具名原因(太大 / 读不出 / 解不出) | `大小 · 时间`(原文留在 DTO 里,不上墙) |
fn shot_cell(shot: &ScreenshotView, texts: Texts) -> Shot {
fn shot_cell(dir: &str, shot: &ScreenshotView, texts: Texts) -> Shot {
let stamp = rows::shot_detail(shot.bytes, shot.modified_ms, texts);
match &shot.preview {
ScreenshotPreview::Ready(thumb) if thumb.is_consistent() => Shot {
name: shot.file.clone(),
detail: stamp,
// 一次上传:像素从这里搬进渲染器,之后每帧只借用同一个句柄。
// 句柄按 (目录, 文件, mtime, 大小) 复用:同一张图在后续每一次 `refresh_view` 里
// 拿到的都是**同一个 id** ⇒ 渲染器不会重传纹理(见 [`handle_for`])。
preview: ShotPreview::Ready {
handle: Handle::from_rgba(thumb.width, thumb.height, thumb.pixels.clone()),
handle: handle_for(dir, shot, thumb),
},
},
// 像素长度与宽高对不上:壳层不该拿到这种数据,但拿到了也不能画成花屏。