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
@@ -56,6 +56,7 @@ mod dialog;
|
||||
mod dialog_fixture;
|
||||
mod download;
|
||||
mod instances;
|
||||
mod motion_sync;
|
||||
mod page_messages;
|
||||
mod settings;
|
||||
mod update_action;
|
||||
@@ -73,7 +74,18 @@ fn is_escape(event: &iced::keyboard::Event) -> bool {
|
||||
|
||||
impl App {
|
||||
/// 一条消息进来,改状态并返回后续任务。
|
||||
///
|
||||
/// **分两段**:先派发([`App::route_message`]),再同步一次动效的内容标识。第二段必须在
|
||||
/// `update` 里而不是 `view` 里:iced 求值订阅表**早于**建视图(`app/update/motion.rs` 的模块
|
||||
/// 文档有源码行号),在 `view` 里起跑的轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」。
|
||||
pub(super) fn update(&mut self, message: Message) -> Task<Message> {
|
||||
let task = self.route_message(message);
|
||||
self.sync_motion();
|
||||
task
|
||||
}
|
||||
|
||||
/// 把一条消息派发到它的落点(`update` 的正文;拆出来只为让 [`App::sync_motion`] 收尾)。
|
||||
fn route_message(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::Boot => self.on_boot(),
|
||||
Message::WindowReady(id) => {
|
||||
@@ -273,44 +285,23 @@ impl App {
|
||||
/// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒
|
||||
/// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。
|
||||
fn on_heartbeat(&mut self) -> Task<Message> {
|
||||
// 动效时间轴也吃这一拍:窗口被遮挡 / 最小化 / 合成器不送绘制时,帧一个都不来,而
|
||||
// 「有轨道」又让下面的 1 Hz 推进整段停摆——那会让 `needs_frames()` 永远为真。心跳把
|
||||
// 轨道按单次上限([`components::motion::pump::MAX_STEP_MS`])推几步收尾,最坏的「没有帧」
|
||||
// 情况下 5 拍内自然退订;有帧时它只多推 64 ms/秒,看不出来。
|
||||
motion::advance();
|
||||
if !self.needs_frames() {
|
||||
let now = Instant::now();
|
||||
let elapsed = now.saturating_duration_since(self.last_tick).max(IDLE_TICK);
|
||||
self.last_tick = now;
|
||||
self.advance(elapsed);
|
||||
}
|
||||
// M192 真机取证(`ULL_MOTION_FIXTURE_AT`):到点就**真的**导航一次。放在 advance 之后 ⇒
|
||||
// 这一拍是货真价实的空闲态;跳转后由 `update` 收尾的 `sync_motion` 起跑(与点击同一条路)。
|
||||
self.motion_fixture_after_idle();
|
||||
heartbeat()
|
||||
}
|
||||
|
||||
/// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`boot_env::MotionFixture`])。
|
||||
fn motion_fixture(&mut self) {
|
||||
use boot_env::MotionFixture;
|
||||
let Some(fixture) = 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();
|
||||
}
|
||||
|
||||
/// 一帧画完:①动效时间轴推进 ②够 33 ms 就推进一拍 presenter ③截图模式在第
|
||||
/// [`CAPTURE_AT_FRAME`] 帧出图。
|
||||
///
|
||||
@@ -324,7 +315,9 @@ impl App {
|
||||
motion::advance();
|
||||
// 出图取证(`ULL_MOTION_FIXTURE`):前两帧各做一次**真的**状态变化。
|
||||
// 没有这步,截图通道每一帧画的都是同一屏,页面切换 / 列表↔详情的入场就没有可拍的相位。
|
||||
if self.frames <= 2 {
|
||||
// `ULL_MOTION_FIXTURE_AT`(真机空闲取证)在的时候**不跑**这一支:两条取证路互斥,
|
||||
// 否则第 1 帧就把页面换成目标页,等 `_AT` 的心跳再跳同一个目标 = 标识没变 = 没有动效。
|
||||
if self.frames <= 2 && boot_env::env_motion_fixture_at().is_none() {
|
||||
self.motion_fixture();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user