From 93bd7047861146bf04655586050140308b195500 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 04:52:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(m192):=20r1=20=E5=A4=8D=E5=AE=A1=20P1-1?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=8A=A8=E6=95=88=E8=B5=B7=E8=B7=91=E6=90=AC?= =?UTF-8?q?=E8=BF=9B=20update=20=E4=BE=A7=20+=20=E5=B8=A7=E5=9F=BA?= =?UTF-8?q?=E5=87=86=E5=85=9C=E5=BA=95=20+=20=E6=8C=89=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E9=A1=BA=E5=BA=8F=E7=9A=84=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审的机制三步全部复核成立(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 零内距)。 --- crates/app-iced/src/app/subscription.rs | 248 +++++++++++++++++- crates/app-iced/src/app/subscription/tests.rs | 118 --------- crates/app-iced/src/app/update.rs | 53 ++-- crates/app-iced/src/app/update/boot_env.rs | 22 +- crates/app-iced/src/app/update/motion_sync.rs | 166 ++++++++++++ crates/app-iced/src/components/motion/mod.rs | 70 +++-- crates/app-iced/src/components/motion/pump.rs | 85 ++++-- .../app-iced/src/components/motion/tests.rs | 118 +++++++-- crates/app-iced/src/pages/mod.rs | 25 +- 9 files changed, 665 insertions(+), 240 deletions(-) delete mode 100644 crates/app-iced/src/app/subscription/tests.rs create mode 100644 crates/app-iced/src/app/update/motion_sync.rs diff --git a/crates/app-iced/src/app/subscription.rs b/crates/app-iced/src/app/subscription.rs index 321b67a..d49ce2a 100644 --- a/crates/app-iced/src/app/subscription.rs +++ b/crates/app-iced/src/app/subscription.rs @@ -101,6 +101,250 @@ impl App { .is_some_and(|deadline| Instant::now() < deadline) } } - #[cfg(test)] -mod tests; +mod tests { + //! 帧订阅的**有界**守卫(M192):动画期间订阅、结束即退订、关掉动效一帧都不订阅。 + //! + //! ## 这一组按**运行时顺序**写(M192 r1 复审 P1-1 的教训) + //! + //! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是 + //! 「`update()`(订阅表在这里求值)→ `view()`」,于是这条守卫全绿、生产路径上却根本播不出来。 + //! 现在全部改走**真消息**:`app.update(Message::Page(Navigate(..)))` 之后再问订阅表, + //! 而且断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」这条假绿)。 + //! + //! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。 + + use super::*; + use crate::app::components::motion::{Slot, FRAME_MS}; + use crate::cli::Options; + use crate::pages; + use crate::theme::Families; + use unlucky_design::color::SchemeFlavor; + use unlucky_design::motion::MotionScale; + use unlucky_presenter::mock::MockBackend; + use unlucky_presenter::router::Route; + use unlucky_presenter::settings_bridge::SettingsBridge; + + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + /// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。 + fn headless() -> App { + App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(MockBackend::new()), + SettingsBridge::memory_only(), + ) + } + + /// 走到「真·空闲」:没有观察窗、没有忙碌实例、没有动画(首屏那一条 Boot 已跑完)。 + /// + /// `Boot` 自己就会起跑一次首屏动效(`on_boot` → `sync_motion`),所以必须先把它推完, + /// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。 + fn idle_app() -> App { + motion::reset(); + motion::configure(MotionScale::STANDARD, None); + let mut app = headless(); + // 有窗口才谈得上动效(`sync_motion` 在没有窗口时直接返回,见它的文档) + app.window = Some(iced::window::Id::unique()); + let _ = app.update(Message::Boot); + app.watch_until = None; + run_until_idle(); + assert!(!motion::needs_frames(), "前提:首屏动效已经跑完"); + app + } + + /// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。 + fn run_until_idle() { + for _ in 0..1250 { + if !motion::needs_frames() { + return; + } + motion::advance_by(FRAME_MS); + } + } + + /// **按运行时顺序**:空闲态一条消息进来(换页)⇒ **同一拍**订阅表就要有帧;跑完立刻退订。 + /// + /// 变异证明:把起跑搬回 `view`(删掉 `App::update` 里的 `sync_motion`)⇒ 第一步的 + /// `assert!(app.needs_frames())` 红;把 `Pump::advance` 的「到期即删除」删掉 ⇒ 最后一步红。 + #[test] + fn a_state_change_opens_the_frame_subscription_in_the_same_tick() { + let mut app = idle_app(); + assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧"); + + // 用户点了一次导航轨:**一条**消息,之后不再补任何消息 + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download))); + + assert!( + app.watch_until.is_none(), + "换页不该打开观察窗(这里要证明帧是**动效**要来的,不是派发顺手开的)" + ); + assert!(motion::needs_frames(), "换页必须在同一拍起跑"); + assert!( + app.needs_frames(), + "同一拍的订阅表必须能看见轨道(iced 先算订阅表、后建视图 —— 复审 P1-1)" + ); + + run_until_idle(); + assert!(!motion::needs_frames(), "动画跑完帧泵必须清空"); + assert!( + !app.needs_frames(), + "动画结束后必须退回 1 Hz 心跳(占着帧订阅 = 空闲烧帧)" + ); + motion::reset(); + } + + /// 列表 ↔ 详情同样走「一条消息 ⇒ 当拍有帧」,且 `Detail` 槽真的是那条。 + #[test] + fn opening_a_detail_also_opens_the_frames_in_the_same_tick() { + let mut app = idle_app(); + // 出厂快照会自动选中第一条 ⇒ 先回列表,让「点进详情」真的是一次详情变化 + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); + run_until_idle(); + assert!(!app.needs_frames()); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + assert!(app.needs_frames(), "点进详情必须当拍订阅帧"); + run_until_idle(); + assert!(!app.needs_frames()); + motion::reset(); + } + + /// 账户页 / AI 独立页**不归 pages 画**:切过去不起跑(否则白跑 300 ms 帧);切回来照旧跑。 + #[test] + fn pages_that_render_elsewhere_do_not_start_a_track() { + let mut app = idle_app(); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Accounts))); + assert!( + !motion::needs_frames(), + "账户页没有 pages 树可动,不许白开帧" + ); + + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); + assert!(motion::needs_frames(), "从账户页回到某一屏仍然是换页,要跑"); + run_until_idle(); + motion::reset(); + } + + /// **没有窗口就不起跑**:`window::frames()` 在没窗口时一条消息都不会有,起跑只是白占一档; + /// 首屏那一条也因此要等窗口到了才算「第一眼」。 + #[test] + fn without_a_window_nothing_animates() { + motion::reset(); + motion::configure(MotionScale::STANDARD, None); + let mut app = headless(); + assert!(app.window.is_none(), "前提:还没拿到窗口句柄"); + let _ = app.update(Message::Boot); + app.watch_until = None; + assert!(!motion::needs_frames(), "没有窗口 ⇒ 不许起跑"); + + // 窗口来了:首屏入场这时才开始(不是被启动期那棵空树吃掉) + let _ = app.update(Message::WindowReady(Some(iced::window::Id::unique()))); + assert!(app.needs_frames(), "窗口到了之后「首屏」才起跑"); + run_until_idle(); + motion::reset(); + } + + /// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。 + /// + /// 变异证明:把 `Pump::play` 的 `is_off()` 分支删掉 ⇒ 帧采样次数非 0,本用例红。 + #[test] + fn no_scale_requests_no_frames_at_all() { + motion::reset(); + let mut app = headless(); + app.window = Some(iced::window::Id::unique()); + let _ = app.update(Message::Boot); + app.watch_until = None; + motion::configure(MotionScale::NONE, None); + + // 三种状态变化:换页 / 换详情 / 再换回来 + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download))); + assert!(!app.needs_frames(), "关掉动效后换页一帧都不该订阅"); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + assert!(!app.needs_frames()); + run_until_idle(); + + assert!(!motion::needs_frames()); + assert_eq!(motion::samples(), 0, "NONE 档的帧采样次数必须是 0"); + motion::reset(); + } + + /// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔,所以不会出现「动画与 Busy 各订一条」。 + /// + /// 口径是**全仓 `src/**`**(复审 r1 备注:只扫 `subscription.rs` 抓不到别人另开订阅),只看 + /// 代码行(注释/文档里提到这个符号不算)。唯一允许的第二个文件是 `pages/harness.rs`—— + /// 那是 `pages_demo` 这条**独立 example** 自己的 iced app(不接 `App`、不进产品路径)。 + #[test] + fn there_is_exactly_one_frame_subscription() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + collect_rust_files(&src, &mut files); + assert!( + files.len() >= 40, + "只扫到 {} 个文件:扫描逻辑坏了", + files.len() + ); + + let mut hits = Vec::new(); + for path in files { + let text = std::fs::read_to_string(&path).expect("读源文件"); + let product = text.split("#[cfg(test)]").next().unwrap_or(&text); + let code = product + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join( + " +", + ); + let count = code.matches("window::frames()").count(); + if count > 0 { + let name = path + .strip_prefix(&src) + .unwrap_or(&path) + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/"); + hits.push((name, count)); + } + } + + hits.sort(); + let expected = [ + ("app/subscription.rs".to_string(), 1_usize), + ("pages/harness.rs".to_string(), 1_usize), + ]; + assert_eq!( + hits, expected, + "`window::frames()` 只许出现在 `app/subscription.rs`(产品接线)\ + 与 `pages/harness.rs`(独立 example 的自有 app),且各一处" + ); + } + + /// 递归收集 `dir/**/*.rs`。 + fn collect_rust_files(dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).expect("读目录") { + let path = entry.expect("目录项").path(); + if path.is_dir() { + collect_rust_files(&path, out); + } else if path.extension().is_some_and(|ext| ext == "rs") { + out.push(path); + } + } + } + + /// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。 + #[test] + fn slots_are_the_three_documented_ones() { + assert_eq!(Slot::ALL.len(), 3); + assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]); + } +} diff --git a/crates/app-iced/src/app/subscription/tests.rs b/crates/app-iced/src/app/subscription/tests.rs deleted file mode 100644 index fb6291a..0000000 --- a/crates/app-iced/src/app/subscription/tests.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! 帧订阅的**有界**守卫(M192):动画期间订阅、结束即退订、关掉动效一帧都不订阅。 -//! -//! 这一组是**接线级**的(走真的 `App::needs_frames`),与 `components::motion::tests` 的算术守卫 -//! 互补:那边证明帧泵会清空轨道,这边证明清空之后订阅表真的跟着变干净。 -//! -//! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。 - -use super::*; -use crate::app::components::motion::{Slot, FRAME_MS}; -use crate::cli::Options; -use crate::theme::Families; -use unlucky_design::color::SchemeFlavor; -use unlucky_design::motion::MotionScale; -use unlucky_presenter::mock::MockBackend; -use unlucky_presenter::settings_bridge::SettingsBridge; - -const FAMILIES: Families = Families { - text: "Segoe UI", - display: "Segoe UI", - mono: "Consolas", -}; - -/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。 -fn headless() -> App { - App::with_presenter( - Options::default(), - FAMILIES, - SchemeFlavor::Light, - Box::new(MockBackend::new()), - SettingsBridge::memory_only(), - ) -} - -/// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。 -fn run_until_idle() { - for _ in 0..1250 { - if !motion::needs_frames() { - return; - } - motion::advance_by(FRAME_MS); - } -} - -/// **动画期间订阅帧、结束立刻退订**(空闲回到 1 Hz 心跳)。 -/// -/// 变异证明:把 `App::needs_frames` 里的 `motion::needs_frames()` 分支删掉 ⇒ 第二条断言红; -/// 把 `Pump::advance` 的「到期即删除」删掉 ⇒ 第三条断言红(跑完还在订阅)。 -#[test] -fn frames_are_subscribed_while_animating_and_dropped_afterwards() { - motion::reset(); - let mut app = headless(); - app.watch_until = None; - assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧"); - - motion::configure(MotionScale::STANDARD, None); - // 首帧:页面主体入场(Boot 槽) - motion::enter(1, Some("survival")); - assert!(motion::needs_frames(), "起跑后帧泵必须有轨道"); - assert!(app.needs_frames(), "有动画在跑时必须订阅帧"); - - run_until_idle(); - assert!(!motion::needs_frames(), "动画跑完帧泵必须清空"); - assert!( - !app.needs_frames(), - "动画结束后必须退回 1 Hz 心跳(占着帧订阅 = 空闲烧帧)" - ); - motion::reset(); -} - -/// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。 -/// -/// 变异证明:把 `Pump::play` 的 `is_off()` 分支删掉 ⇒ 帧订阅次数非 0,本用例红。 -#[test] -fn no_scale_requests_no_frames_at_all() { - motion::reset(); - let app = headless(); - motion::configure(MotionScale::NONE, None); - - // 三种状态变化:首屏 / 换页 / 换详情 - assert_eq!(motion::enter(1, None), (0.0, 0.0)); - assert_eq!(motion::enter(2, Some("survival")), (0.0, 0.0)); - assert_eq!(motion::enter(2, Some("creative")), (0.0, 0.0)); - run_until_idle(); - - assert!(!motion::needs_frames()); - assert!(!app.needs_frames(), "关掉动效后一帧都不该订阅"); - assert_eq!(motion::samples(), 0, "NONE 档的帧采样次数必须是 0"); - motion::reset(); -} - -/// 帧订阅在源码里**只出现一处**:五条理由共享同一个布尔,所以不存在「动画与 Busy 各订一条」。 -/// -/// 这是结构守卫(扫自己的源码):谁另起一条 `window::frames()`,本用例立刻红。 -#[test] -fn there_is_exactly_one_frame_subscription() { - let source = include_str!("../subscription.rs"); - let product = source.split("#[cfg(test)]").next().unwrap_or(source); - let code = product - .lines() - .filter(|line| !line.trim_start().starts_with("//")) - .collect::>() - .join( - " -", - ); - assert_eq!( - code.matches("window::frames()").count(), - 1, - "`window::frames()` 只许出现一处代码(五条理由共享同一个 if)" - ); -} - -/// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。 -#[test] -fn slots_are_the_three_documented_ones() { - assert_eq!(Slot::ALL.len(), 3); - assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]); -} diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index d4297b6..80cdd9f 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -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 { + let task = self.route_message(message); + self.sync_motion(); + task + } + + /// 把一条消息派发到它的落点(`update` 的正文;拆出来只为让 [`App::sync_motion`] 收尾)。 + fn route_message(&mut self, message: Message) -> Task { match message { Message::Boot => self.on_boot(), Message::WindowReady(id) => { @@ -273,44 +285,23 @@ impl App { /// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒 /// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。 fn on_heartbeat(&mut self) -> Task { + // 动效时间轴也吃这一拍:窗口被遮挡 / 最小化 / 合成器不送绘制时,帧一个都不来,而 + // 「有轨道」又让下面的 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(); } diff --git a/crates/app-iced/src/app/update/boot_env.rs b/crates/app-iced/src/app/update/boot_env.rs index a4b1789..a075c27 100644 --- a/crates/app-iced/src/app/update/boot_env.rs +++ b/crates/app-iced/src/app/update/boot_env.rs @@ -83,7 +83,7 @@ pub(super) fn env_motion_pin() -> Option { /// `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 { }) } +/// `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 { + std::env::var("ULL_MOTION_FIXTURE_AT") + .ok()? + .trim() + .parse::() + .ok() + .filter(|beats| *beats > 0) +} + /// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。 /// /// ## 为什么这一拍只能「先记、后落」 diff --git a/crates/app-iced/src/app/update/motion_sync.rs b/crates/app-iced/src/app/update/motion_sync.rs new file mode 100644 index 0000000..3996dac --- /dev/null +++ b/crates/app-iced/src/app/update/motion_sync.rs @@ -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`,这里 +/// 再折成 `Option` 给帧泵用。 +fn screen_key(route: &Route) -> Option { + 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> = [ + 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 画"); + } +} diff --git a/crates/app-iced/src/components/motion/mod.rs b/crates/app-iced/src/components/motion/mod.rs index 0df9607..5919e1c 100644 --- a/crates/app-iced/src/components/motion/mod.rs +++ b/crates/app-iced/src/components/motion/mod.rs @@ -7,13 +7,18 @@ //! 中间层:**谁能动**([`Slot`])、**动多久**([`spec`] 的令牌表)、**什么时候该订阅帧** //! ([`needs_frames`])。 //! -//! ## 有界泵的契约(三条,都有守卫) +//! ## 有界泵的契约(四条,都有守卫) //! //! | 契约 | 为什么 | 守在哪 | //! |---|---|---| //! | 只在**有轨道**时订阅帧 | `window::frames()` 一订阅就自持重绘循环(实测几百帧/秒) | `pump::tests::a_finished_animation_stops_asking_for_frames` | //! | 轨道到期**当场删除**,不靠「下次状态变化」 | 删晚了就是空闲烧帧 | 同上(把 `advance` 的清理删掉就红) | -//! | 每帧推进**不密于** [`FRAME_MS`](16 ms ≈ 60/s) | 帧到得再快也只该改这么多次布局 | `pump::tests::frames_are_capped_at_sixty_per_second` | +//! | **时间轴推进**不密于 [`FRAME_MS`](16 ms ≈ 62.5/s),且单次最多吃 [`pump::MAX_STEP_MS`] | 帧到得再快也只该改这么多次布局;空闲醒来时 stale 的帧基准不许把动画一帧杀完 | `pump::tests::frames_are_capped_at_sixty_per_second`、`pump::tests::a_stale_baseline_cannot_kill_the_track_in_one_sample` | +//! | 起跑**必须发生在 `update` 侧**([`observe`]),`view` 侧只采样([`sample`]) | iced 先算订阅表(`update()` 内部)后建视图 ⇒ 在 `view` 里插轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」 | `app::subscription::tests::a_state_change_opens_the_frame_subscription_in_the_same_tick` | +//! +//! ⚠️ **16 ms 只卡「采样与布局推进」,不卡整窗重绘。** 整窗重绘**跟随渲染器**: +//! 软件渲染实测 ≈**970 帧/秒**(一次 300 ms 的换页 ≈ **290 次整窗重绘** ≈ **0.30 核·秒**), +//! GPU 档被 AutoVsync 限到 40~60 帧/秒。重绘次数是「订阅开着多久」的函数,与闸门无关。 //! //! 接法是**加进**既有那条链,不是另起一条:`App::needs_frames()` 多一个「有轨道」的分支, //! 订阅表仍然是**一处** `if`(`app/subscription.rs`),所以与 `watch_until` / Busy 续订/ 心跳 @@ -32,9 +37,17 @@ //! 是同一条纪律),所以一个线程局部就够,且**测试天然隔离**(`cargo test` 每个用例一个线程, //! 不会出现「另一条线的动画把 `needs_frames()` 顶成真」的假红)。 //! -//! ## 页面怎么用 +//! 仓内已有同样的先例(`pages/appearance/probe.rs`、`core/src/addon/pack/format/probe.rs` 都用了 +//! `#[allow(clippy::missing_const_for_thread_local)]`),所以这不是给本 crate 开的新口子。 +//! +//! ## 谁在什么时候调 +//! +//! | 时机 | 调用 | 在哪 | +//! |---|---|---| +//! | 每条消息处理完(**订阅表求值之前**) | [`observe`]:内容标识变了就起跑 | `App::sync_motion`(`app/update/motion.rs`) | +//! | 每帧画视图 | [`sample`]:只读当前位移 | `pages::render` | +//! | 每帧 | [`advance`]:推进时间轴 | `App::on_frame` | //! -//! 只有一个入口:[`enter`](`pages/mod.rs::render` 每帧调一次)+ [`shifted`](把位移套到树上)。 //! 页面**拿不到**时长/缓动/弹簧参数——那是 [`spec`] 的事。 //! //! 位移走 `container` 的 `padding`:动画期间内容比容器少几像素高,末帧归零后与静息**逐像素相同**。 @@ -42,7 +55,7 @@ mod pump; -// 本 mission 里这五个只被测试与文档引用(产品路径只经 `enter`/`shifted`,拿不到时长/缓动), +// 本 mission 里这五个只被测试与文档引用(产品路径只经 `observe`/`sample`/`shifted`,拿不到时长/缓动), // 所以显式 `allow` 一下——与 `interaction::Tracker` 那条同一条理由:它们是对外口径的一部分 // (`spec` 是唯一一张规格表),不该因为「暂时没人调」就被删掉。 #[allow(unused_imports)] @@ -56,9 +69,10 @@ use iced::{Element, Length, Padding}; use unlucky_design::motion::MotionScale; -// clippy 1.99 的 `missing_const_for_thread_local` 对**已经是** `const { … }` 的初始化也报(本仓 -// 之前没有 thread_local,所以这条是第一次撞上):换过三种写法都照报,属于误报。初始化确实已是 -// 常量,所以按项显式放过(上在宏外面不算,lint 是对展开后的 item 报的)。 +// clippy 1.99 的 `missing_const_for_thread_local` 对**已经是** `const { … }` 的初始化也报 +// (换过三种写法都照报,属于误报;复审 r1 已复核并确认,仓内 `pages/appearance/probe.rs` 等 +// 两处已有同样的 `allow`)。初始化确实已是常量,所以按项显式放过(上在宏外面不算,lint 是对 +// 展开后的 item 报的)。 thread_local! { /// 进程内唯一时间轴(见模块文档「为什么是 thread-local」)。 #[allow(clippy::missing_const_for_thread_local)] @@ -88,12 +102,30 @@ pub fn needs_frames() -> bool { PUMP.with(|pump| pump.borrow().is_animating()) } -/// 页主体入场:`(screen, detail)` 任一标识变了就跑对应槽;返回**取整后的位移**。 +/// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。 /// -/// 每帧调一次(`pages/mod.rs::render`)。`detail` 是详情页的实例 id(`None` = 没选中任何一行)。 -/// 没有动画在跑时它只做两次比较,不做任何布局。 -pub fn enter(screen: u64, detail: Option<&str>) -> (f32, f32) { - PUMP.with(|pump| pump.borrow_mut().enter(screen, detail)) +/// `screen` 是 `pages::Screen` 的稳定键(`None` = 这一屏不归 pages 画:账户页 / AI 独立页), +/// `detail` 是详情页的实例 id。 +/// +/// ## 为什么必须在 update 侧(M192 r1 复审 P1-1) +/// +/// iced 0.14 的顺序是「消息 → `update()`(订阅表在**这里**求值)→ `build_user_interfaces` +/// (这时才调 `view`)」(iced_winit-0.14.1 `src/lib.rs:1222-1240`)。轨道若在 `view` 里插, +/// 同一拍的订阅表看不见它 ⇒ `frames()` 要等下一条消息(最快鼠标 / 最慢 1 Hz 心跳)才打开, +/// 而那一帧的 dt 是整段空闲时长 ⇒ 动画一帧走完,用户只看到一次偏移闪跳。 +/// +/// 起跑时**顺带把帧基准重置到「现在」**:空闲期没有帧,`LAST_FRAME` 是几分钟前的值, +/// 不重置的话第一帧仍会按整段空闲时长结算(第二道兜底是 [`pump::MAX_STEP_MS`])。 +pub fn observe(screen: Option, detail: Option<&str>) { + let started = PUMP.with(|pump| pump.borrow_mut().observe(screen, detail)); + if started { + LAST_FRAME.with(|last| last.set(Some(Instant::now()))); + } +} + +/// 当前入场位移(**只采样、无副作用**):`pages::render` 每帧调一次。 +pub fn sample() -> (f32, f32) { + PUMP.with(|pump| pump.borrow().offsets()) } /// 推进时间轴(`App::on_frame` 每帧调一次)。 @@ -112,17 +144,15 @@ pub fn advance() { /// 把入场位移套到树上。 /// -/// 位移为 `(0, 0)` 时**原样返回**(不额外套一层容器):静息态与「没有动效」的代码路径逐像素相同, -/// 这也是既有截图基线一张都不用重出的原因。 +/// **总是**套这一层(位移为 `(0,0)` 时只是 `padding = 0`):它是树的**根**,而 iced 的 +/// `Tree::diff` 按 widget tag 判等——根 tag 一换就重建整棵子树。若只在动画期间才套, +/// 动画起止两侧会各重建一次,页面内的滚动位置 / 焦点会被重置(复审 r1 提示的风险)。 +/// 常驻一层的代价是布局上多一个 `Fill` 容器(零内距时逐像素相同,出图基线已复验)。 pub fn shifted<'a, M: 'a>( element: impl Into>, (dx, dy): (f32, f32), ) -> Element<'a, M> { - let element = element.into(); - if dx == 0.0 && dy == 0.0 { - return element; - } - container(element) + container(element.into()) .width(Length::Fill) .height(Length::Fill) .padding(Padding { diff --git a/crates/app-iced/src/components/motion/pump.rs b/crates/app-iced/src/components/motion/pump.rs index f0bac6a..749efee 100644 --- a/crates/app-iced/src/components/motion/pump.rs +++ b/crates/app-iced/src/components/motion/pump.rs @@ -22,13 +22,28 @@ use std::collections::BTreeMap; use unlucky_design::motion::{DurationToken, Easing, MotionScale}; use unlucky_design::spacing::Space; -/// 逐帧推进的**最短间隔**(16 ms ≈ 60 次/秒)。 +/// 逐帧**推进时间轴**的最短间隔(16 ms ≈ 60 次/秒)。 /// -/// 为什么需要它:`window::frames()` 的到达频率由合成器/渲染器决定,软件渲染实测能到几百帧/秒。 -/// 帧数本身不贵(贵的是整窗重绘,那由「订阅只在动画期间开着」管住),但**每帧都改布局**会让 -/// 页面每毫秒 reflow 一次。所以时间轴按 16 ms 结算:到得再快也只推进这么多次。 +/// ⚠️ **这只卡「采样与布局推进」,不卡整窗重绘。** 两个频率必须分开看: +/// +/// | 什么 | 频率 | 由谁决定 | +/// |---|---|---| +/// | 时间轴推进 / 位移采样(本常量) | ≤62.5 次/秒 | 这里 | +/// | **整窗重绘** | **跟随渲染器**:软件渲染实测 ≈**970 帧/秒**(一次 300 ms 的换页 ≈**290 次整窗重绘** ≈ **0.30 核·秒**);GPU 档被 AutoVsync 限到 40~60 | `window::frames()` 一订阅,iced_winit 每条消息后都 `request_redraw()`(iced_winit-0.14.1 `src/lib.rs:1222-1240`) | +/// +/// 所以「动画期间最多 60fps」只对**采样**成立;重绘次数是订阅时长的函数,与 16 ms 无关。 +/// 为什么仍然要 16 ms:每帧重绘已经是既成事实(忙碌态本来就这样),16 ms 卡的是**布局重排** +/// 与 `pages::render` 的重建成本;两者的量级差一个数量级以上(见报告的性能记账)。 pub const FRAME_MS: f64 = 16.0; +/// 单次推进的**最大步进**(4 帧 = 64 ms)。 +/// +/// 为什么需要它:`advance` 的 dt 来自「上一帧到现在」,而空闲期根本没有帧——醒来的第一帧如果 +/// 按真实流逝(秒~分钟级)结算,`duration` 当场就被跨过 ⇒ 到期轨道被立刻删除 ⇒ 动画一帧走完 +/// (M192 r1 复审 P1-1 就是这么暴露的)。起点由 `motion::observe` 重置,这里再兜一道: +/// **任何一次采样最多只吃掉 4 帧的时长**,掉帧/被挂起时宁可把动画放慢,也不许把它一次性抹掉。 +pub const MAX_STEP_MS: f64 = FRAME_MS * 4.0; + /// 一个动效槽。 /// /// 槽是**同屏互斥**的:三个槽都是「页主体入场」的不同触发点,同时跑会让位移叠加 @@ -187,14 +202,14 @@ impl Pump { !self.tracks.is_empty() } - /// 起跑一个槽(关着动效时什么都不做——**不是**跑一遍 0 时长)。 + /// 起跑一个槽(关着动效时什么都不做——**不是**跑一遍 0 时长动画)。返回 `true` = 真的起跑了。 /// /// 三个槽**互斥**:它们都是「页主体入场」的不同触发点,同时跑会让位移叠加(首屏 + 换页 = /// 28px)。所以起跑新槽就丢掉旧的——语义上也对:新的一次内容变化取代上一次。 - pub fn play(&mut self, slot: Slot) { + pub fn play(&mut self, slot: Slot) -> bool { self.tracks.clear(); if self.scale.is_off() { - return; + return false; } // 钉住相位时轨道的起点固定在 0:进度于是恒等于「钉住的毫秒 / 时长」, // 与「谁在第几帧起跑」无关——这正是多帧序列图要的可复现性。 @@ -206,6 +221,7 @@ impl Pump { spec: spec(slot), }, ); + true } /// 采样:`0.0` = 刚起跑,`1.0` = 到位(**没有轨道就是到位**)。 @@ -227,29 +243,52 @@ impl Pump { self.pin.unwrap_or(self.now_ms) } - /// 页主体的入场位移:**每帧调一次**,标识变了才真的起跑。 + /// 页主体的内容**标识**变了就起跑对应槽;**必须在 update 侧调**(见 `App::sync_motion`)。 + /// + /// ## 为什么不能放在 `view` 里(M192 r1 复审 P1-1,这是本轮最重要的一行注释) + /// + /// iced 0.14 的顺序是「消息 → `update()`(**`program.subscription()` 在这里求值**)→ + /// `build_user_interfaces`(这时才调 `view`)」(iced_winit-0.14.1 `src/lib.rs:1222-1240`, + /// 订阅表在 `update()` 内部的 `lib.rs:1338` 求值)。所以在 `view` 里插轨道,同一拍的订阅表 + /// **看不见**它,`frames()` 要等下一条消息(最快鼠标、最慢 1 Hz 心跳)才打开——那一段里 + /// 页面已经带着位移画过一帧,然后就没了:用户看到的是一次偏移闪跳。 /// /// 三条判据(顺序即优先级): - /// 1. 第一帧 → [`Slot::Boot`](首屏也算一次「东西出现」); + /// 1. 第一次看到**归 pages 画的屏** → [`Slot::Boot`]; /// 2. 屏变了 → [`Slot::Screen`]; /// 3. 屏没变但详情变了 → [`Slot::Detail`]。 /// - /// 返回值按各槽剩余量求和后**取整**:整像素位移不会让文字在动画期间发虚, - /// 末帧三个槽都是 `1.0` ⇒ 恒为 `(0, 0)` ⇒ 与静息的布局逐像素相同。 - pub fn enter(&mut self, screen: u64, detail: Option<&str>) -> (f32, f32) { + /// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**—— + /// 否则会白跑 300 ms 的帧却没人采样它;而「从这些页回到某一屏」因为标识从 `None` 变回 + /// `Some`,仍然会跑换页那一档。 + /// + /// 返回 `true` = 真的起跑了新轨道(调用方要重置帧基准,见 [`MAX_STEP_MS`])。 + pub fn observe(&mut self, screen: Option, detail: Option<&str>) -> bool { + let mut started = false; if !self.painted { self.painted = true; - self.play(Slot::Boot); - } else if self.screen != Some(screen) { - self.play(Slot::Screen); - } else if self.detail.as_deref() != detail { - self.play(Slot::Detail); + if screen.is_some() { + started = self.play(Slot::Boot); + } + } else if self.screen != screen { + if screen.is_some() { + started = self.play(Slot::Screen); + } + } else if self.detail.as_deref() != detail && screen.is_some() { + started = self.play(Slot::Detail); } - self.screen = Some(screen); + self.screen = screen; if self.detail.as_deref() != detail { self.detail = detail.map(str::to_owned); } + started + } + /// 页主体的入场位移(**只采样,无副作用**):`pages::render` 每帧调一次。 + /// + /// 三个槽的剩余量求和后**取整**:整像素位移不会让文字在动画期间发虚;末帧全部 `1.0` + /// ⇒ 恒为 `(0, 0)` ⇒ 与静息的布局逐像素相同。 + pub fn offsets(&self) -> (f32, f32) { let mut dx = 0.0_f32; let mut dy = 0.0_f32; for slot in Slot::ALL { @@ -263,16 +302,18 @@ impl Pump { /// 推进时间轴。返回 `true` = 这一帧真的推进了(进度变了 ⇒ 要重画)。 /// - /// 三步:①不满 [`FRAME_MS`] 就不动(帧上限)②到期的轨道**当场删除**(这是「空闲不烧帧」的 - /// 唯一出口:删掉最后一个轨道之后 [`is_animating`](Self::is_animating) 立刻为假,下一拍订阅表 - /// 里就不再有 `window::frames()`)③钉住相位时时间轴不前进(取证专用)。 + /// 四步:①不满 [`FRAME_MS`] 就不动(采样上限)②**单次最多吃 [`MAX_STEP_MS`]**(掉帧 / + /// 被挂起 / 空闲醒来的第一帧都不许一次性跨过时长)③到期的轨道**当场删除**(这是「空闲不烧帧」 + /// 的唯一出口:删掉最后一个轨道之后 [`is_animating`](Self::is_animating) 立刻为假,下一拍订阅表 + /// 里就不再有 `window::frames()`)④钉住相位时时间轴不前进(取证专用)。 pub fn advance(&mut self, dt_ms: f64) -> bool { if self.tracks.is_empty() { self.pending_ms = 0.0; return false; } if dt_ms.is_finite() && dt_ms > 0.0 { - self.pending_ms += dt_ms; + // ②:上限不是「省事」,是正确性——stale 的帧基准(空闲整段)会把轨道一帧杀完。 + self.pending_ms += dt_ms.min(MAX_STEP_MS); } if self.pending_ms < FRAME_MS { return false; diff --git a/crates/app-iced/src/components/motion/tests.rs b/crates/app-iced/src/components/motion/tests.rs index 647850f..13e09af 100644 --- a/crates/app-iced/src/components/motion/tests.rs +++ b/crates/app-iced/src/components/motion/tests.rs @@ -1,6 +1,8 @@ //! 帧泵的守卫(M192)。全部用**本地** `Pump` 摆时间轴:不起窗口、不等真实时间,所以不会假绿。 +//! +//! 接线级的守卫(真消息 / 真订阅表)在 `app/subscription.rs` 的 `mod tests`:这一组只管算术。 -use super::pump::{offset, spec, Pump, Slot, FRAME_MS}; +use super::pump::{offset, spec, Pump, Slot, FRAME_MS, MAX_STEP_MS}; use super::*; /// 标准档、没有钉相位的泵(出厂态是 `NONE`,见 [`Pump::new`])。 @@ -39,7 +41,7 @@ fn every_slot_spec_is_a_registered_token_pair() { ms ); // 缓动必须是真的曲线(端点 = 0/1) - assert!((spec.easing.sample(0.0)).abs() < 1.0e-6); + assert!(spec.easing.sample(0.0).abs() < 1.0e-6); assert!((spec.easing.sample(1.0) - 1.0).abs() < 1.0e-6); // 位移必须来自间距栅格(4 的倍数),且不为零 let (dx, dy) = offset(slot); @@ -67,7 +69,7 @@ fn every_slot_spec_is_a_registered_token_pair() { #[test] fn a_finished_animation_stops_asking_for_frames() { let mut pump = standard(); - pump.play(Slot::Screen); + assert!(pump.play(Slot::Screen), "起跑必须成功"); assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)"); settle(&mut pump); @@ -76,6 +78,7 @@ fn a_finished_animation_stops_asking_for_frames() { "动画结束必须**当场**退订(占着轨道 = 空闲烧帧)" ); assert_eq!(pump.progress(Slot::Screen), 1.0, "结束即到位"); + assert_eq!(pump.offsets(), (0.0, 0.0), "结束即归位"); // 再推进也不该有任何采样(订阅表里已经没有帧了) let before = pump.samples(); @@ -85,15 +88,16 @@ fn a_finished_animation_stops_asking_for_frames() { assert_eq!(pump.samples(), before, "空闲态一次采样都不该发生"); } -/// **帧间隔上限**:帧到得再快,时间轴也只按 16 ms 结算(≈60 次/秒)。 +/// **采样频率上限**:帧到得再快,时间轴也只按 16 ms 结算(≈62.5 次/秒)。 /// +/// 注意这条**不管整窗重绘**:重绘跟随渲染器(见 [`FRAME_MS`] 文档)。 /// 抓得住:把 `pending_ms` 那道闸门删掉(每来一帧就推进 ⇒ 软件渲染下几百次/秒的重排)。 #[test] fn frames_are_capped_at_sixty_per_second() { let mut pump = standard(); pump.play(Slot::Screen); - // 200 帧、每帧 1 ms(模拟 1000 fps 的合成器) + // 200 帧、每帧 1 ms(模拟 1000 fps 的渲染器) let mut advanced = 0; for _ in 0..200 { if pump.advance(1.0) { @@ -117,6 +121,38 @@ fn frames_are_capped_at_sixty_per_second() { ); } +/// **stale 帧基准不许把轨道一帧杀完**(M192 r1 复审 P1-1 的算术面)。 +/// +/// 运行时真出现过:空闲几分钟后第一条消息起跑轨道,第一帧的 dt 是整段空闲时长 ⇒ 立即到期 ⇒ +/// 动画一帧走完,用户只看到一次偏移闪跳。两道兜底:起跑时重置帧基准(`motion::observe`)与 +/// 单次最大步进 [`MAX_STEP_MS`];这条守的是第二道。 +#[test] +fn a_stale_baseline_cannot_kill_the_track_in_one_sample() { + let mut pump = standard(); + pump.play(Slot::Screen); + + // 60 秒的「空闲间隔」当 dt + assert!(pump.advance(60_000.0)); + assert!( + pump.is_animating(), + "一次超长 dt 不许把轨道当场删掉(那就等于没有动画)" + ); + assert!( + pump.now_ms() <= MAX_STEP_MS + 1.0e-6, + "单次推进最多 {:?} ms,实测 {}", + MAX_STEP_MS, + pump.now_ms() + ); + assert!( + pump.progress(Slot::Screen) < 1.0, + "进度不许一步到位(那就是闪跳)" + ); + + // 正常帧间隔照样把动画跑完(没被抖动卡死) + settle(&mut pump); + assert!(!pump.is_animating()); +} + /// 采样必须单调、且末尾回到 1.0(回弹类曲线不在本批,见 [`spec`] 的表)。 #[test] fn progress_is_monotone_and_lands_on_one() { @@ -146,11 +182,13 @@ fn none_scale_never_starts_a_track() { assert!(pump.scale().is_off()); // 首屏 + 换页 + 换详情:三次状态变化都不许留下轨道 - assert_eq!(pump.enter(1, None), (0.0, 0.0), "关掉动效必须直接落终态"); + assert!(!pump.observe(Some(1), None), "关掉动效时不许起跑"); + assert_eq!(pump.offsets(), (0.0, 0.0), "关掉动效必须直接落终态"); assert!(!pump.is_animating()); - assert_eq!(pump.enter(2, None), (0.0, 0.0)); - assert!(!pump.is_animating()); - assert_eq!(pump.enter(2, Some("instance-7")), (0.0, 0.0)); + assert!(!pump.observe(Some(2), None)); + assert_eq!(pump.offsets(), (0.0, 0.0)); + assert!(!pump.observe(Some(2), Some("instance-7"))); + assert_eq!(pump.offsets(), (0.0, 0.0)); assert!(!pump.is_animating()); for _ in 0..120 { @@ -170,6 +208,7 @@ fn switching_to_none_mid_flight_settles_immediately() { pump.configure(MotionScale::NONE, None); assert!(!pump.is_animating(), "关掉动效必须当场清空轨道"); assert_eq!(pump.progress(Slot::Screen), 1.0); + assert_eq!(pump.offsets(), (0.0, 0.0)); } /// 半速档(CPU 渲染兜底)真的把时长减半:同样的时间轴推进,轨道更早结束。 @@ -193,67 +232,94 @@ fn half_scale_finishes_in_half_the_frames() { // ------------------------------------------------------------------ 触发判据 -/// 三个槽的触发优先级:首帧 Boot → 换屏 Screen → 同屏换详情 Detail。 +/// 三个槽的触发优先级:首屏 Boot → 换屏 Screen → 同屏换详情 Detail。 #[test] fn identity_changes_pick_the_right_slot() { let mut pump = standard(); // 首帧:Boot(位移是纵向 16) - let (dx, dy) = pump.enter(1, None); + assert!(pump.observe(Some(1), None), "首帧要跑 Boot 槽"); + let (dx, dy) = pump.offsets(); assert_eq!(dx, 0.0); - assert!(dy > 0.0, "首屏应有一个向下的入场位移"); - assert!(pump.is_animating(), "首帧要跑 Boot 槽"); + assert_eq!(dy, offset(Slot::Boot).1, "首屏应有一个向下的入场位移"); assert_eq!( pump.progress(Slot::Screen), 1.0, "首帧不该顺带起跑 Screen 槽" ); settle(&mut pump); - assert_eq!(pump.enter(1, None), (0.0, 0.0), "标识没变 ⇒ 不动"); + assert!(!pump.observe(Some(1), None), "标识没变 ⇒ 不起跑"); + assert_eq!(pump.offsets(), (0.0, 0.0)); // 换屏:Screen(纵向) - let (dx, dy) = pump.enter(2, None); + assert!(pump.observe(Some(2), None)); + let (dx, dy) = pump.offsets(); assert_eq!(dx, 0.0); - assert!(dy > 0.0); + assert_eq!(dy, offset(Slot::Screen).1); assert!(pump.progress(Slot::Screen) < 1.0, "换屏要跑 Screen 槽"); settle(&mut pump); // 同屏换详情:Detail(横向) - let (dx, dy) = pump.enter(2, Some("instance-7")); - assert!(dx > 0.0, "列表↔详情是横向入场"); + assert!(pump.observe(Some(2), Some("instance-7"))); + let (dx, dy) = pump.offsets(); + assert_eq!(dx, offset(Slot::Detail).0, "列表↔详情是横向入场"); assert_eq!(dy, 0.0); assert!(pump.progress(Slot::Detail) < 1.0); settle(&mut pump); - assert_eq!(pump.enter(2, Some("instance-7")), (0.0, 0.0)); + assert!(!pump.observe(Some(2), Some("instance-7"))); + assert_eq!(pump.offsets(), (0.0, 0.0)); +} + +/// 不归 pages 画的屏(账户页 / AI 独立页,`screen == None`)**不起跑**;回到某一屏照样跑。 +#[test] +fn screens_rendered_elsewhere_never_start_a_track() { + let mut pump = standard(); + assert!(pump.observe(Some(1), None), "首屏开机跑 Boot"); + settle(&mut pump); + + assert!(!pump.observe(None, None), "没有 pages 树可动 ⇒ 不许白开帧"); + assert!(!pump.is_animating()); + assert_eq!(pump.offsets(), (0.0, 0.0)); + + assert!( + pump.observe(Some(1), None), + "从账户页回到某一屏仍然是换页,要跑" + ); + assert_eq!(pump.offsets().1, offset(Slot::Screen).1); } /// 换页会**取代**首屏那一档:位移不叠加(12px,不是 16+12)。 #[test] fn a_new_transition_supersedes_the_previous_one() { let mut pump = standard(); - let boot_dy = offset(Slot::Boot).1; - assert_eq!(pump.enter(1, None).1, boot_dy, "首帧从 Boot 的最大位移开始"); + assert_eq!( + pump.offsets().1, + 0.0, + "还没 observe 时没有位移(采样是纯函数)" + ); + pump.observe(Some(1), None); + assert_eq!(pump.offsets().1, offset(Slot::Boot).1); // 首屏还没跑完就换页:位移必须是 Screen 那一档,而不是两档相加 - let (_, dy) = pump.enter(2, None); + pump.observe(Some(2), None); assert_eq!( - dy, + pump.offsets().1, offset(Slot::Screen).1, "新的一次内容变化应取代上一次(叠加会让位移变成两档之和)" ); - assert!(!pump.is_animating() || pump.progress(Slot::Boot) == 1.0); } /// 同一屏同一详情重复绘制**永远不会**重新起跑(否则每帧都在重启动画 = 永远停不下来)。 #[test] fn repeated_paints_never_restart_the_animation() { let mut pump = standard(); - pump.enter(3, Some("instance-9")); + pump.observe(Some(3), Some("instance-9")); let frames = settle(&mut pump); assert!(frames > 0); for _ in 0..30 { - assert_eq!(pump.enter(3, Some("instance-9")), (0.0, 0.0)); + assert!(!pump.observe(Some(3), Some("instance-9"))); + assert_eq!(pump.offsets(), (0.0, 0.0)); } assert!(!pump.is_animating(), "重复绘制不该起新轨道"); } diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index 3828af7..ec4f1fe 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -253,27 +253,10 @@ fn render<'a>( None => page, }; - // M192:主体入场(首屏 / 换页 / 列表↔详情)。位移由帧泵按令牌表给,**静息恒为 `(0, 0)`** - // 且此时 `motion::shifted` 原样返回,所以关闭动效与「末帧」两条路径都与没有动效时逐像素相同。 - let shift = motion::enter(screen_key(model.screen), detail_key(model)); - motion::shifted(tree, shift) -} - -/// 屏的稳定标识:值变了才跑「换页」那一档([`Screen::ALL`] 的下标 + 1,`0` 留给「还没见过」)。 -fn screen_key(screen: Screen) -> u64 { - Screen::ALL - .iter() - .position(|candidate| *candidate == screen) - .map_or(0, |index| index as u64 + 1) -} - -/// 详情页的稳定标识:实例 id(`None` = 没选中任何一行)。同一个 id 重复绘制不会重启动画。 -fn detail_key(model: &Model) -> Option<&str> { - model - .instances - .detail - .as_ref() - .map(|detail| detail.id.as_str()) + // M192:主体入场(首屏 / 换页 / 列表↔详情)。**这里只采样**——「标识变了就起跑」在 update 侧 + // (`App::sync_motion`):iced 先算订阅表后建视图,在 view 里插轨道当拍订阅不上(复审 P1-1)。 + // 静息时位移恒为 `(0, 0)`,`shifted` 常驻的那一层零内距,与没有动效时逐像素相同。 + motion::shifted(tree, motion::sample()) } /// 整窗(**harness 专用**):标题栏留白带 + 导航轨 + 当前路由的页面。