Files
Unlucky-Launcher/crates/app-iced/src/app/update/motion_sync.rs
T
AskaEth 93bd704786 fix(m192): r1 复审 P1-1——动效起跑搬进 update 侧 + 帧基准兜底 + 按运行时顺序的守卫
复审的机制三步全部复核成立(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 零内距)。
2026-11-09 04:52:40 +08:00

167 lines
6.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 动效的**起跑点**(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 画");
}
}