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 零内距)。
This commit is contained in:
1 parent
a1876a08c0
commit
93bd704786
9 files changed
+665
-240
No files matched your search
@@ -83,7 +83,7 @@ pub(super) fn env_motion_pin() -> Option<f64> {
|
||||
|
||||
/// `ULL_MOTION_FIXTURE`:第 1 帧之后**真的**做一次状态变化(多帧序列图靠它)。
|
||||
///
|
||||
/// 为什么要制造变化:页面主体的入场只在「内容标识变了」时起跑(`motion::enter`),而截图通道
|
||||
/// 为什么要制造变化:页面主体的入场只在「内容标识变了」时起跑(`motion::observe`),而截图通道
|
||||
/// 从第一帧起画的就是同一页——不制造一次真实的路由变化,就没有可拍的动效。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) enum MotionFixture {
|
||||
@@ -106,6 +106,26 @@ pub(super) fn env_motion_fixture() -> Option<MotionFixture> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `ULL_MOTION_FIXTURE_AT=<秒>`:启动后第 N 次心跳(≈第 N 秒)**真的**导航一次。
|
||||
///
|
||||
/// ## 为什么需要它(真机空闲取证)
|
||||
///
|
||||
/// 复审 r1 要求「空闲 ≥10 s 后点一次导航轨,看 `[tick] motion=` 涨多少」。本机工作区所在会话
|
||||
/// 没有前台窗口(`GetForegroundWindow()==0`),合成鼠标事件(`mouse_event` 与
|
||||
/// `PostMessage(WM_LBUTTONDOWN)` 都试过)递不进 iced 的输入层,所以这里换一条**同构**的路:
|
||||
/// 由 1 Hz 心跳(空闲态本来就有的消息)在第 N 秒做一次真实的路由跳转。
|
||||
///
|
||||
/// 两条路在 `update` 之后**完全同一段代码**:消息 → `sync_motion`(起跑)→ `subscription()`
|
||||
/// (同一拍看见轨道)→ `frames()`。差别只在「消息是谁产生的」,而这正是 P1 要验的那一段。
|
||||
pub(super) fn env_motion_fixture_at() -> Option<u32> {
|
||||
std::env::var("ULL_MOTION_FIXTURE_AT")
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|beats| *beats > 0)
|
||||
}
|
||||
|
||||
/// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。
|
||||
///
|
||||
/// ## 为什么这一拍只能「先记、后落」
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! 动效的**起跑点**(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 画");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user