复审的机制三步全部复核成立(iced_winit-0.14.1 `src/lib.rs:1222` 调 update、`1338` 在 `update()` 内部求值订阅表、`1233` 之后才 `build_user_interfaces` 调 view)。第一版把 「标识变了 → play」写在 `pages::render`(= view)里,于是:同一拍的订阅表看不见轨道 ⇒ `frames()` 要等下一条消息才打开;而那一帧的 dt 是整段空闲时长 ⇒ 轨道被当场判到期删掉。 用户看到的是「一帧 12/8px 偏移 + 闪跳」,比硬切更差;截图通道因为 `has_screenshot()` 恒真, 结构上不可能发现它。 修法(三件都做) - **起跑搬进 update**:新增 `app/update/motion_sync.rs::App::sync_motion`,在 `App::update` 收尾(`route_message` 之后、`subscription()` 之前)按**路由**算内容标识并 `motion::observe`。 `pages::render` 只留 `motion::sample()`(纯采样)。 顺带:`screen == None`(账户页 / AI 独立页)只记标识不起跑,于是「从它们回到某一屏」也跑换页。 - **帧基准兜底**:`observe` 起跑成功时把 `LAST_FRAME` 重置到「现在」;`Pump::advance` 再加一道 `MAX_STEP_MS`(4 帧 = 64 ms)单次步进上限——掉帧/挂起时宁可把动画放慢,也不许一帧抹掉。 - **没有窗口就不起跑**:`window.is_none()` 且不是截图通道时 `sync_motion` 直接返回(也**不记** 标识)⇒「首屏入场」等窗口到了才算第一眼,启动期那棵空树吃不掉它(原先被 1 Hz 心跳 / 既有 `an_idle_app_...` 用例照出来的就是这个边界)。 - `on_heartbeat` 也推进动效时间轴:窗口被遮挡/最小化/合成器不送绘制时,帧一个都不来,而 「有轨道」会让 1 Hz 推进整段停摆 ⇒ 轨道靠心跳按单次上限收尾(最坏 5 拍内自然退订)。 守卫(新增 5 条,全部按运行时顺序写) - `a_state_change_opens_the_frame_subscription_in_the_same_tick`:**先** `assert!(!needs_frames)` →**一条真消息**(`Message::Page(Navigate(..))`)→ `assert!(needs_frames)`,并断言 `watch_until` 仍是 `None`(排除「观察窗顺手开帧」这条假绿)。r1 那条顺序是反的,已删。 - `opening_a_detail_also_opens_the_frames_in_the_same_tick`、`pages_that_render_elsewhere_do_not_start_a_track`、 `without_a_window_nothing_animates`、`a_stale_baseline_cannot_kill_the_track_in_one_sample`。 r1 两条 P2 - P2-1:`pump::FRAME_MS` 与 `mod.rs` 契约表补写「16 ms 只卡采样/布局推进,**整窗重绘跟随渲染器** (软件渲染 ≈970 帧/秒、一次 300 ms 换页 ≈290 次整窗重绘 ≈0.30 核·秒;GPU 档 AutoVsync 40~60)」。 - P2-2:删掉 `app/subscription/tests.rs`,测试并回 `app/subscription.rs` 的 `mod tests` ⇒ 本轮 动过的文件全部落在声明 scope 内(新文件只有 `app/update/motion_sync.rs`,在 `app/update/**` 下)。 真机证据(GPU 档,`ULL_TICK_TRACE=1`,空闲 14 s 后由心跳触发一次**真实**导航, `DevDocs/artifacts/m192/idle-click-evidence-gpu-final.log`): idle: frames=96 clock=idle-1hz motion=14(连续 7 拍不动) fire: clock=frames motion=16 → 17(同一拍订阅表就开了) end : frames=102 clock=idle-1hz motion=20(自己退订) 说明:本机会话没有前台窗口(`GetForegroundWindow()==0`),合成点击(`mouse_event` 与 `PostMessage(WM_LBUTTONDOWN)`)递不进 iced 的输入层,故用「心跳第 N 秒真导航」这条同构路径; 样本次数由**绘制送达**决定(该次动效只收到 ~5 次绘制 ⇒ +6 次采样),正常可见窗口下 60 Hz 呈现 对应 ~19 次。 变异证明 7/7 红(`DevDocs/artifacts/m192/mutate.sh`,含新增 M6「删掉 sync_motion」与 M7「去掉单次步进上限」),还原后自检全绿。 编译:cargo test -p unlucky-app-iced → 0(797 passed / 2 ignored,集成 6 passed); clippy --all-targets -D warnings → 0;fmt --check → 0;build → 0。 出图基线复验:三张 rest 基线与九张序列帧的 sha256 与 r1 **逐字节相同**(常驻的那层 container 零内距)。
167 lines
6.9 KiB
Rust
167 lines
6.9 KiB
Rust
//! 动效的**起跑点**(M192 r1 复审 P1-1 修的正是这里)。
|
||
//!
|
||
//! ## 为什么这条不能在 `view` 里
|
||
//!
|
||
//! iced 0.14 的处理顺序是:
|
||
//!
|
||
//! ```text
|
||
//! 消息 ──► update() ← program.subscription() 在这里求值(iced_winit src/lib.rs:1338)
|
||
//! ──► build_user_interfaces() ← 这时才调 program.view(src/lib.rs:1233)
|
||
//! ```
|
||
//!
|
||
//! 也就是说**订阅表的求值早于视图**。第一版把「内容标识变了 → 起跑」写在 `pages::render`
|
||
//! (= `view`)里,于是同一拍订阅表看不见轨道,`frames()` 要等下一条消息(最快鼠标抬起、
|
||
//! 最慢 1 Hz 心跳)才打开;那一帧的 dt 又是整段空闲时长 ⇒ 轨道被当成「早已到期」清掉,
|
||
//! 用户看到的是一次 12/8px 的偏移闪跳,比硬切更难看。截图通道因为 `has_screenshot()` 恒真,
|
||
//! **结构上**不可能暴露这条。
|
||
//!
|
||
//! 所以起跑搬到这里:`App::update` 处理完每一条消息后调 [`App::sync_motion`],
|
||
//! 于是「轨道存在」对**同一拍**的 `subscription()` 可见,帧订阅当拍打开。
|
||
//!
|
||
//! ## 标识从哪来
|
||
//!
|
||
//! 从**路由**取(`AppState::route()` 是唯一真源),不从视图模型取:账户页 / AI 独立页不走
|
||
//! `pages::render`,用路由才能表达「这一屏不归 pages 画」(`None`)——它们之间的切换不起跑,
|
||
//! 而「从它们回到某一屏」照旧跑换页那一档(复审 r1 结论 4 的硬切顺手收口)。
|
||
|
||
use unlucky_presenter::router::Route;
|
||
|
||
use crate::app::components::motion;
|
||
use crate::pages;
|
||
|
||
use super::boot_env::MotionFixture;
|
||
|
||
use super::*;
|
||
|
||
impl App {
|
||
/// 每条消息处理完之后同步一次内容标识(**必须在 `update` 里**,理由见模块文档)。
|
||
pub(in crate::app) fn sync_motion(&mut self) {
|
||
// **还没有窗口就什么都不做**(也不记标识):动效是画出来的东西,没有窗口时
|
||
// `window::frames()` 一条消息也不会有,起跑只会白占一档。更要紧的是「首屏入场」的语义——
|
||
// 等窗口到了再算「第一眼看到的内容」,否则首屏那一下会被启动期的空树吃掉。
|
||
// 截图通道例外:它的窗口句柄也是异步到的,而序列图必须从第 1 帧就按钉住的相位画。
|
||
if self.window.is_none() && !self.has_screenshot() {
|
||
return;
|
||
}
|
||
let route = self.state.route();
|
||
let screen = screen_key(route);
|
||
let detail = match route {
|
||
Route::Detail(id) => Some(id.as_str()),
|
||
_ => None,
|
||
};
|
||
motion::observe(screen, detail);
|
||
}
|
||
|
||
/// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`super::boot_env::MotionFixture`])。
|
||
pub(in crate::app) fn motion_fixture(&mut self) {
|
||
let Some(fixture) = super::boot_env::env_motion_fixture() else {
|
||
return;
|
||
};
|
||
let route = match (fixture, self.frames) {
|
||
// 列表 → 详情:第 1 帧先回列表(详情收起来),第 2 帧再点进第一条实例。
|
||
(MotionFixture::Detail, 1) => Route::Instances,
|
||
(MotionFixture::Detail, 2) => {
|
||
let Some(first) = self
|
||
.state
|
||
.tiles()
|
||
.first()
|
||
.map(|cell| cell.instance.id.clone())
|
||
else {
|
||
return;
|
||
};
|
||
Route::Detail(first)
|
||
}
|
||
(MotionFixture::Route(route), 1) => route,
|
||
_ => return,
|
||
};
|
||
diag!("[motion] ULL_MOTION_FIXTURE → {route:?}");
|
||
self.state.navigate(route);
|
||
self.view_dirty = true;
|
||
self.refresh_view();
|
||
}
|
||
|
||
/// 真机空闲取证:`ULL_MOTION_FIXTURE_AT=<秒>` 到点就**真的**导航一次(`on_heartbeat` 调)。
|
||
///
|
||
/// 为什么不用点击:[`super::boot_env::env_motion_fixture_at`] 的文档写了原因——本机会话没有
|
||
/// 前台窗口,合成鼠标事件递不进 iced 的输入层。心跳这条路与点击在 `update` 之后是同一段代码。
|
||
/// 返回 `true` = 这一拍真的跳了页。
|
||
pub(in crate::app) fn motion_fixture_after_idle(&mut self) -> bool {
|
||
use std::sync::atomic::{AtomicU32, Ordering};
|
||
/// 已经过去的心跳数(进程内一次;只有取证开关打开时才会用到)。
|
||
static FIXTURE_BEATS: AtomicU32 = AtomicU32::new(0);
|
||
|
||
let Some(target) = boot_env::env_motion_fixture_at() else {
|
||
return false;
|
||
};
|
||
let beat = FIXTURE_BEATS.fetch_add(1, Ordering::Relaxed) + 1;
|
||
if beat != target {
|
||
return false;
|
||
}
|
||
let route = match boot_env::env_motion_fixture() {
|
||
Some(MotionFixture::Route(route)) => route,
|
||
Some(MotionFixture::Detail) => {
|
||
let Some(id) = self
|
||
.state
|
||
.tiles()
|
||
.first()
|
||
.map(|cell| cell.instance.id.clone())
|
||
else {
|
||
return false;
|
||
};
|
||
Route::Detail(id)
|
||
}
|
||
None => Route::Download,
|
||
};
|
||
diag!("[motion] ULL_MOTION_FIXTURE_AT 到点(第 {beat} 次心跳)→ {route:?}");
|
||
self.state.navigate(route);
|
||
self.view_dirty = true;
|
||
self.refresh_view();
|
||
true
|
||
}
|
||
}
|
||
|
||
/// 路由 → 屏的稳定键(`pages::Screen::ALL` 的下标 + 1;`None` = 不归 pages 画)。
|
||
///
|
||
/// 与 `pages::Screen::of` 同一判据(`Screen::Instances` ↔ `Route::Instances | Route::Detail(_)`),
|
||
/// 只是把「有没有这一屏」也表达出来——`pages::Screen::of` 给的是 `Option<Screen>`,这里
|
||
/// 再折成 `Option<u64>` 给帧泵用。
|
||
fn screen_key(route: &Route) -> Option<u64> {
|
||
let screen = pages::Screen::of(route)?;
|
||
pages::Screen::ALL
|
||
.iter()
|
||
.position(|candidate| *candidate == screen)
|
||
.map(|index| index as u64 + 1)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// 路由 → 屏键:五个一级页各自唯一,`Detail` 归「实例」,账户/AI 不归 pages。
|
||
#[test]
|
||
fn only_the_five_screens_get_a_key() {
|
||
let keys: Vec<Option<u64>> = [
|
||
Route::Instances,
|
||
Route::Download,
|
||
Route::Content,
|
||
Route::Settings,
|
||
Route::Logs,
|
||
Route::Detail("survival".to_string()),
|
||
Route::Accounts,
|
||
Route::Agent,
|
||
]
|
||
.iter()
|
||
.map(screen_key)
|
||
.collect();
|
||
|
||
assert_eq!(&keys[..5], &[Some(1), Some(2), Some(3), Some(4), Some(5)]);
|
||
assert_eq!(
|
||
keys[5],
|
||
Some(1),
|
||
"详情页归「实例」那一屏(与 Screen::of 同源)"
|
||
);
|
||
assert_eq!(keys[6], None, "账户页不归 pages 画");
|
||
assert_eq!(keys[7], None, "AI 独立页不归 pages 画");
|
||
}
|
||
}
|