From 38e8bbc1e65841eed1bd1b34b0613dbfda4eacd3 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 18:13:48 +0800 Subject: [PATCH] docs: explain document publication and visual snapshot invalidation --- src/model/document.rs | 128 ++++++++++++++++++++++++++++++++++++++++++ src/model/snapshot.rs | 95 ++++++++++++++++++++++++++++++- 2 files changed, 222 insertions(+), 1 deletion(-) diff --git a/src/model/document.rs b/src/model/document.rs index cded831..e366e44 100644 --- a/src/model/document.rs +++ b/src/model/document.rs @@ -1,6 +1,9 @@ //! 运行时与窗口线程之间的发布数据契约。NamespaceState 管理提交序号、版本和当前完整文档。 //! RenderDocument/Frame 包含绘制命令、Mount 几何、页面标签和相机投影。发布前统一检查数量、坐标、颜色及索引一致性。 //! 屏幕命中、逻辑坐标和世界 UI 的射线映射必须与绘制采用同一变换;这里的 ID 是数据身份,不是窗口线程可操作的托管引用。 +//! 调用路径:API worker 用 NamespaceState 提交/清空候选;桥接发布前调用 validate。 +//! 窗口壳用 Frame 命中结果构造输入,worker 再复核;软件渲染入口用 display_list 转为设备坐标命令。 +//! 所有发布值拥有绘制所需原生数据,不能要求窗口线程通过 ID 解引用 VM。 //! use std::collections::HashSet; use std::sync::Arc; @@ -14,21 +17,26 @@ use crate::{ ui_bridge::UiNode, }; +// HTTP/Console 未指定名字时的默认命名空间,初始化状态和源入口应使用同一值。 pub const DEFAULT_NAMESPACE: &str = "app.main"; #[derive(Clone, Debug)] /// HTTP/GUI 共享的最近完整发布状态;submission 决定结果归属,revision 表示实际发布次数。 pub struct NamespaceState { + // 命名空间显示/API 身份,区别于每次输入的 submission 与 VM session。 pub name: String, + // 成功替换或 clear 才推进的发布版本,用于调用方识别可见文档更新。 pub revision: u64, // 较新完整 PUT/DELETE 会改变提交身份;旧输入即使晚完成也不得覆盖新结果。 pub submission: u64, + // 最近成功的完整文档;编译/执行失败或过期候选不替换它。 pub document: RenderDocument, // viewport 是主窗口逻辑页面区域;window_viewports 同时记录仍存活的副窗口。 pub viewport: Option, pub window_viewports: std::collections::BTreeMap, // 原生命令与运行时世代一起发布,防止窗口 ID 在跨 VM 时被误用。 pub runtime_session: u64, + // 待窗口壳消费的世代化原生命令,按动作合并并保留全局请求顺序。 pub window_commands: Vec, } @@ -66,12 +74,14 @@ impl NamespaceState { return; } // 本方法接纳传入 session 作为当前世代,调用方负责只在成功发布边界调用;同资源同动作保留最新请求。 + // 同一资源的同类动作保留最新请求,Focus/Resize 等不同动作仍分别排队。 for command in commands { self.window_commands .retain(|old| !old.command.same_kind(&command)); self.window_commands .push(crate::window::Envelope { session, command }); } + // 合并后按 VM 原始 sequence 恢复执行顺序,避免 retain/push 使不同资源动作乱序。 self.window_commands .sort_by_key(|command| command.command.sequence); } @@ -89,6 +99,7 @@ impl NamespaceState { if submission != self.submission { return Ok(None); } + // 先计算可用新版本,溢出时整个提交失败且旧文档仍在。 let revision = self .revision .checked_add(1) @@ -100,7 +111,9 @@ impl NamespaceState { /// 推进提交身份和发布版本,替换为空文档并清除运行时命令,使旧提交不能覆盖清空结果。 pub fn clear(&mut self) -> Result { + // clear 也创建新提交身份,让先前在途的合法候选失去发布资格。 let submission = self.next_submission()?; + // 发布版本与提交令牌都先检查溢出,状态更新集中在全部检查成功之后。 let revision = self .revision .checked_add(1) @@ -119,15 +132,19 @@ impl NamespaceState { /// 可以离开 VM 线程的完整展示值,包含源码投影、根帧和其他窗口的文档。 pub struct RenderDocument { #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + // 主文档携带的副窗口投影,最多一层,不是可任意递归的窗口树。 pub windows: std::collections::BTreeMap, #[serde(skip)] // 仅本机增量渲染使用,不序列化;JSON 文档仍可凭 frame.commands 绘制,不需要持有 VM 句柄。 pub ui_root: Option>, #[serde(default)] + // 完整输入源码或 Console 历史,Sources 使用原文与结构树范围定位。 pub source: String, #[serde(default)] + // 可选源码投影根;空文档可无根,和视觉 ui_root 是不同的数据。 pub root: Option, #[serde(default)] + // 当前窗口完整视觉帧,副窗口文档也各有自己的 frame.window。 pub frame: Frame, } @@ -136,6 +153,7 @@ impl RenderDocument { pub fn validate(&self) -> Result<()> { // 校验页面兼容相机的缩放、pan 和有限 extent;独立视口变换随后按各表与 Mount 另行检查。 let surface = &self.frame.surface; + // 所有页面兼容相机分量先检查范围,之后窗口绘制与输入可使用同一有限变换。 if !surface.zoom.is_finite() || !(0.1..=4.0).contains(&surface.zoom) || ![surface.pan.x, surface.pan.y] @@ -147,11 +165,14 @@ impl RenderDocument { { bail!("invalid page camera or extent"); } + // 主窗口之外最多十五个副窗口,与完整桌面窗口预算一致。 if self.windows.len() > 15 { bail!("too many window projections"); } // 副窗口只允许一层投影,键须等于其 frame.window,不能重复嵌入主窗口或再带嵌套 windows。 + // 按每个副窗口独立校验帧,不允许借嵌套 map 绕开窗口计数。 for (id, document) in &self.windows { + // 副窗口不能冒用主窗口 ID,键/帧身份须相等,且不得再次嵌套 windows。 if *id == crate::window::MAIN_WINDOW || *id != document.frame.window || !document.windows.is_empty() @@ -160,13 +181,16 @@ impl RenderDocument { } document.validate()?; } + // 源码预算按 UTF-8 字节计,不以字符数绕过 1 MiB 输入限制。 if self.source.len() > 1_048_576 { bail!("source exceeds the 1 MiB limit"); } + // 绘制命令预算独立于 Mount 数量,防止单个源 paint 产生无界命令。 if self.frame.commands.len() > 10_000 { bail!("frame exceeds the 10,000 command limit"); } + // 零窗口 ID 无效,不作为默认主窗口的别名接纳。 if self.frame.window == 0 { bail!("invalid window identity"); } @@ -177,27 +201,35 @@ impl RenderDocument { { bail!("invalid command clip table"); } + // 只校验存在的裁剪矩形;None 表示该命令不加这层裁剪。 for clip in self.frame.command_clips.iter().flatten() { validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?; } + // 视图表与裁剪表同样是平行索引,非空半表会使绘制参数错配。 if !self.frame.command_views.is_empty() && self.frame.command_views.len() != self.frame.commands.len() { bail!("invalid command view table"); } + // 各二维变换先检查缩放/平移合法性,后面的绘制转换可据此使用。 for view in self.frame.command_views.iter().flatten() { + // 拒绝不可逆或超界视图变换,避免绘制与命中给出互相矛盾的坐标。 if !view.valid() { bail!("invalid command view transform"); } } + // 逐条分派到命令自己的几何/文本/材质检查,不能只检查命令总数。 for command in &self.frame.commands { command.validate()?; } + // 出现位置也设独立上限,防止少量命令背后展开过多可命中节点。 if self.frame.mounts.len() > 10_000 { bail!("frame exceeds the 10000 mount limit"); } // 标签 ID 必须非零且唯一,最多一个活动页;标题按 UTF-8 字节限长,标签数受 MAX_PAGES 限制。 + // 记录全部标签身份,集合同时用于检测重复;active 数量另外限制为至多一个。 let mut tab_ids = HashSet::new(); + // 同时检查标签数量、活动项数和每项身份/标题预算,不能只验证某个当前活动页。 if self.frame.tabs.len() > crate::ui_bridge::MAX_PAGES || self.frame.tabs.iter().filter(|tab| tab.active).count() > 1 || self @@ -209,18 +241,24 @@ impl RenderDocument { bail!("invalid tab projection"); } // 先收集全部 Mount ID 并校验几何,再第二遍核对关系下标,允许链接指向帧中较后位置。 + // 先建整帧身份集合,第二轮才能允许关系链接指向后面才出现的节点。 let mut mount_ids = HashSet::new(); + // 首轮逐项检查变换、身份与几何,收集关系检查所需的完整 ID 集合。 for mount in &self.frame.mounts { + // Mount 的二维变换与绘制表独立检查,不能因为命令合法就信任输入几何。 if mount.view.is_some_and(|view| !view.valid()) { bail!("invalid Mount view transform"); } + // 业务 block_id 可因共享重复,但每个出现的 Mount ID 必须非零且帧内唯一。 if mount.id == 0 || mount.block_id.is_empty() || !mount_ids.insert(mount.id) { bail!("invalid or duplicate mount identity"); } validate_rect(mount.x, mount.y, mount.width, mount.height, 0.0)?; + // 祖先 clip 与节点原几何分别校验,裁剪不能包含 NaN 或负尺寸。 if let Some(clip) = mount.clip { validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?; } + // 相机手势额外内容区裁剪也须合法,避免手势使用未验证矩形。 if let Some(clip) = mount.viewport_clip { validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?; } @@ -235,11 +273,14 @@ impl RenderDocument { } } // 此处只保证关系目标在帧内,不做完整树环/双向一致性验证;组成与生命周期层承担更强结构不变量。 + // 第二轮使用完整 ID 集合检查关系归属;这里不证明双向一致性或无环。 for mount in &self.frame.mounts { + // 只枚举实际存在的关系 ID,None 是合法边界,不补造关系。 for linked in [mount.parent, mount.first_child, mount.next_sibling] .into_iter() .flatten() { + // 任何父/子/兄弟关系都必须落在本窗口帧内。 if !mount_ids.contains(&linked) { bail!("mount links outside this frame"); } @@ -256,7 +297,9 @@ impl RenderDocument { { bail!("frame root does not match its visual projection"); } + // 源码树单独检查节点 ID 唯一,不能和视觉 Mount ID 集合混用。 let mut ids = HashSet::new(); + // 无源码树是合法空发布;有根则递归检查深度、范围和身份。 if let Some(root) = &self.root { root.validate(0, &mut ids)?; } @@ -265,11 +308,14 @@ impl RenderDocument { /// 将逻辑命令转换到设备比例并应用逐命令视图/裁剪;不自动调用 validate,发布入口须先校验。 pub fn display_list(&self, scale: f32) -> Result { + // 窗口渲染调用此转换,借用已发布完整帧,不访问候选 VM 或业务对象。 let frame = &self.frame; + // 新建拥有数据的设备显示列表,先清背景再按逻辑绘制顺序追加。 let mut list = DisplayList::default(); list.push(DrawCommand::Clear(parse_color(&frame.background)?)); // 按命令顺序取对应 clip/view;缺项按无裁剪/单位变换处理,完整文档合法性应在发布入口预先验证。 for (index, command) in frame.commands.iter().enumerate() { + // 表可整体缺省;存在裁剪时为单条命令压入/弹出,防止影响后续无关命令。 let clip = frame.command_clips.get(index).copied().flatten(); // clip 已在父视图/面板对应空间,只乘设备 scale;命令本体则乘 view.zoom 再加 view 平移。 if let Some(clip) = clip { @@ -277,6 +323,7 @@ impl RenderDocument { clip[0], clip[1], clip[2], clip[3], scale, ))); } + // 没有逐命令 view 时使用单位变换;实际缩放由设备 scale×view.zoom 组成。 let view = frame .command_views .get(index) @@ -289,6 +336,7 @@ impl RenderDocument { .to_draw_command(scale * view.zoom)? .translated(Point::new(view.x * scale, view.y * scale)), ); + // 本命令已经绘制,配对弹出先前压入的 clip,维持列表栈平衡。 if clip.is_some() { list.push(DrawCommand::PopClip); } @@ -299,6 +347,7 @@ impl RenderDocument { .and_then(|id| frame.mounts.iter().find(|m| m.id == id)) .filter(|mount| mount.panel.is_none()) { + // 焦点框使用与目标同一祖先裁剪,不能穿出滚动容器或有限面板。 if let Some(c) = focus.clip { list.push(DrawCommand::PushClip(scaled_rect( c[0], c[1], c[2], c[3], scale, @@ -324,6 +373,7 @@ impl RenderDocument { } .to_draw_command(scale)?, ); + // 焦点框单独配对弹出 clip,避免把之后的故障提示也裁掉。 if focus.clip.is_some() { list.push(DrawCommand::PopClip); } @@ -365,14 +415,20 @@ impl RenderDocument { #[derive(Clone, Debug, Serialize, Deserialize)] /// Sources 使用的源码层次节点;ID、名称、种类和行列范围都属于代码投影而不是运行时对象句柄。 pub struct CodeNode { + // 源码节点稳定选择键,带命名空间/提交来源;与视觉 block_id 字符串用途不同。 pub id: String, + // 源声明或语句的显示名称,Sources 用于树列表标题,不作为全局唯一键。 pub name: String, + // 源码结构类别(如 script/module/function),不等于运行时视觉组件种类。 pub kind: String, #[serde(default)] + // 原源码起始行,零表示缺少来源,不能默认当作第一行。 pub start_line: usize, #[serde(default)] + // 原源码结束行;两端都已知时必须不早于 start_line。 pub end_line: usize, #[serde(default)] + // 源码语法投影的有序孩子,验证 ID 唯一性时共享整树集合。 pub children: Vec, } @@ -383,9 +439,11 @@ impl CodeNode { if depth > 64 { bail!("architecture tree exceeds 64 levels"); } + // 空源码节点 ID 无法供 Sources 稳定选中或查找,发布时拒绝。 if self.id.is_empty() { bail!("architecture node id cannot be empty"); } + // 集合覆盖整棵树,两个不同分支也不能重用同一节点 ID。 if !ids.insert(&self.id) { bail!("duplicate architecture node id: {}", self.id); } @@ -393,6 +451,7 @@ impl CodeNode { if self.start_line > 0 && self.end_line > 0 && self.start_line > self.end_line { bail!("invalid source range for node {}", self.id); } + // 递归共享全局身份集合并增加深度,不能每个子树重新计数而绕过约束。 for child in &self.children { child.validate(depth + 1, ids)?; } @@ -401,6 +460,7 @@ impl CodeNode { /// 按请求的代码节点身份或路径查找对应的源码投影。 fn find(&self, id: &str) -> Option<&CodeNode> { + // node(id) 先匹配当前节点,未命中再按孩子顺序递归返回首个匹配。 if self.id == id { return Some(self); } @@ -415,32 +475,43 @@ pub struct Frame { #[serde(skip)] pub hit_index: Arc, #[serde(default)] + // 页面级兼容 pan/zoom 与内容范围;独立相机另由 command_views/Mount.view 表达。 pub surface: crate::page::Surface, #[serde(default = "default_window")] + // 非零原生窗口身份,副窗口 map 的键必须与它相同。 pub window: u64, #[serde(default = "default_background")] + // 页面清屏颜色文本,每帧显示列表先生成一条 Clear。 pub background: String, #[serde(default)] + // 按最终绘制顺序保存逻辑命令,后项覆盖前项;不包含显示列表追加的焦点/故障层。 pub commands: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] + // 逐命令有效裁剪 [x,y,width,height];可整体为空,否则必须与 commands 等长。 pub command_clips: Vec>, #[serde(default, skip_serializing_if = "Vec::is_empty")] + // 逐命令二维投影,缺省单位变换;clip 已在投影后的对应视图空间。 pub command_views: Vec>, #[serde(skip)] // 仅本机装配源覆盖/面板时使用的命令出现归属表,不进入 JSON;display_list 绘制本身不读取它。 pub command_mounts: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] + // 本窗口所有出现位置的平面索引,业务身份可重复,Mount ID 必须唯一。 pub mounts: Vec, #[serde(default)] + // 本次布局发布版本,与 session/window 一起判断原生输入是否过期。 pub layout_version: u64, #[serde(default)] // 运行时世代与 layout_version 共同确定输入归属;仅布局版本相同不能跨 VM 接受事件。 pub session: u64, #[serde(default)] + // 焦点 Mount ID,普通根焦点在 display_list 追加,面板焦点已组合进纹理。 pub focus: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] + // 当前窗口标签投影,ID 唯一且最多一个 active。 pub tabs: Vec, #[serde(default)] + // 停止域诊断,显示列表追加提示且 hit_test 拒绝继续选择交互目标。 pub fault: Option, } @@ -474,32 +545,48 @@ pub struct MountGeometry { // 可嵌套的世界 UI 映射,把页面坐标还原到此出现所在面板纹理空间。 pub panel: Option>, #[serde(default, skip_serializing_if = "std::ops::Not::not")] + // 是否二维相机视口,供窗口手势定位选择。 pub canvas_viewport: bool, #[serde(default, skip_serializing_if = "std::ops::Not::not")] + // 是否三维视口,原生拾取/相机事件据此进入对应协议。 pub scene3d_viewport: bool, #[serde(default)] + // 三维视口是否具备相机控制器,camera_at 不只凭 scene3d_viewport 接受手势。 pub camera_controls: bool, #[serde(default, skip_serializing_if = "Option::is_none")] // 视口内容区的额外裁剪,供相机手势锚点检查;区别于一般 Mount 的祖先 clip。 pub viewport_clip: Option<[f32; 4]>, #[serde(default, skip_serializing_if = "Option::is_none")] + // 布局空间到当前视图/面板纹理空间的二维变换,None 为单位变换。 pub view: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + // 祖先合成后的可见裁剪,与投影后的点比较,不能当作布局空间矩形。 pub clip: Option<[f32; 4]>, #[serde(default, skip_serializing_if = "Option::is_none")] // 当前有效滚动位置和上限,不是原始请求值;非滚动容器为 None。 pub scroll: Option<[f32; 4]>, + // 本帧内出现身份,重建/换页可变化,不是托管 ObjectRef。 pub id: u64, + // 业务对象身份文本,共享对象的多个 Mount 可拥有同一 block_id。 pub block_id: String, + // 父出现 ID;关系目标必须在本帧中,不允许跨窗口链接。 pub parent: Option, + // 首个子出现 ID,通过 next_sibling 遍历同级。 pub first_child: Option, + // 下一个同级出现 ID,保留组成展开次序。 pub next_sibling: Option, + // 出现的布局空间绝对横坐标,投影通过 view/panel 另算。 pub x: f32, + // 出现的布局空间绝对纵坐标,不随相机直接改写。 pub y: f32, + // 本次布局分配宽度,可不同于共享业务对象的自然 width。 pub width: f32, + // 本次布局分配高度,不和其他出现位置共用几何存储。 pub height: f32, + // 原生粗命中资格,真正源 hitTest/事件监听状态仍由 worker 复核。 pub interactive: bool, #[serde(default)] + // 是否存在 pointer 系列处理需求,供窗口指针输入路由使用。 pub pointer_events: bool, } @@ -518,12 +605,15 @@ impl MountGeometry { } /// 把视图位移换算成目标出现的局部位移,处理缩放与世界 UI 投影差异。 pub fn logical_delta(&self, point: Option<[f32; 2]>, delta: [f32; 2]) -> Option<[f32; 2]> { + // 累计二维缩放用于把最终视图差值还原到布局位移;透视面板还需先投影两个端点。 let zoom = self.view.unwrap_or_default().zoom; // 普通二维映射为线性,只除累计 zoom 即可;世界面板透视非线性,必须映射两个点求差。 if self.panel.is_none() { return Some([delta[0] / zoom, delta[1] / zoom]); } + // 面板投影非线性,缺少当前位置就无法正确换算 delta,明确返回 None。 let point = point?; + // 先反投影手势基点;clipped=false 允许捕获拖动移出面板,投影失败则停止处理。 let current = self.view_point(Point::new(point[0], point[1]), false)?; // 对 point 与 point+delta 分别投影,使用 clipped=false 允许捕获拖动越出面板;投影不可逆则返回 None。 let previous = @@ -535,6 +625,7 @@ impl MountGeometry { } /// 判断给定点是否位于目标范围;需要相机转换的类型会先转换再判断。 pub fn contains(&self, point: Point) -> bool { + // 先完成世界面板反投影和遮挡检查,失败直接视为未命中。 let Some(point) = self.view_point(point, true) else { return false; }; @@ -550,6 +641,7 @@ impl MountGeometry { impl Frame { /// 从顶层绘制顺序向后查找可控制的视口,命中与裁剪均成立才接收相机手势。 pub fn camera_at(&self, point: Point) -> Option<&MountGeometry> { + // 窗口手势目标选择使用空间索引候选次序,但仍须逐个通过视口种类、外框和内容裁剪。 self.hit_index .candidates(point, self.mounts.len()) .into_iter() @@ -595,6 +687,7 @@ fn default_background() -> String { /// 可校验、可序列化的逻辑绘制命令;转换到设备像素在 display_list 阶段完成。 pub enum RenderCommand { #[serde(skip_deserializing)] + // 仅本机可构造的不可变图像资源命令,禁止从外部 JSON 反序列化任意图像资源。 Image { x: f32, y: f32, @@ -607,6 +700,7 @@ pub enum RenderCommand { tint: Option, image: Arc, }, + // 屏幕矩形中的三维 View 快照,世界几何和相机数据由原生 Arc 独立拥有。 Scene3D { x: f32, y: f32, @@ -614,6 +708,7 @@ pub enum RenderCommand { height: f32, view: Arc, }, + // 逻辑坐标填充矩形,颜色保存文本,设备缩放和通道解码在显示列表转换时完成。 RoundedRect { x: f32, y: f32, @@ -623,6 +718,7 @@ pub enum RenderCommand { radius: f32, color: String, }, + // 逻辑坐标描边矩形,stroke_width 与半径都参与设备同比缩放。 StrokeRoundedRect { x: f32, y: f32, @@ -634,6 +730,7 @@ pub enum RenderCommand { stroke_width: f32, color: String, }, + // 逻辑文字和离散字号,可选 wrap_bounds 明确是否换行;旧数据缺省为不换行。 Text { x: f32, y: f32, @@ -649,7 +746,9 @@ pub enum RenderCommand { impl RenderCommand { /// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。 pub(crate) fn validate(&self) -> Result<()> { + // RenderDocument.validate 逐条调用,按命令种类检查自身数据;不在这里尝试光栅化。 match self { + // 本机图片命令除了外框还包含偏移、角度和透明度,资源像素尺寸必须非零。 Self::Image { x, y, @@ -663,6 +762,7 @@ impl RenderCommand { .. } => { validate_rect(*x, *y, *width, *height, 0.)?; + // 所有变换参数有限且 opacity 在 [0,1],防止无效混合或图像采样。 if ![offset_x, offset_y, angle, opacity] .iter() .all(|v| v.is_finite()) @@ -673,6 +773,7 @@ impl RenderCommand { bail!("invalid image command"); } } + // 三维视口外框与 view 快照各自验证,场景数据不能仅因外框合法就直接绘制。 Self::Scene3D { x, y, @@ -681,10 +782,12 @@ impl RenderCommand { view, } => { validate_rect(*x, *y, *width, *height, 0.0)?; + // View 的投影/场景不变量失败时拒绝发布,不让无效射线/深度计算进入渲染。 if !view.valid() { bail!("invalid 3D view"); } } + // 填充矩形检查有限非负尺寸/圆角,再解码颜色格式。 Self::RoundedRect { x, y, @@ -696,6 +799,7 @@ impl RenderCommand { validate_rect(*x, *y, *width, *height, *radius)?; parse_color(color)?; } + // 描边矩形除公共几何外还必须具有正且有限的描边宽度。 Self::StrokeRoundedRect { x, y, @@ -712,6 +816,7 @@ impl RenderCommand { } parse_color(color)?; } + // 文字无框时只要求坐标有限;有 wrap 框时还校验框和端点的协议范围。 Self::Text { x, y, @@ -720,12 +825,14 @@ impl RenderCommand { color, wrap_bounds, } => { + // 首先拒绝文字原点 NaN/无穷,后续测量与绘制不能处理这类坐标。 if !x.is_finite() || !y.is_finite() { bail!("text coordinates must be finite"); } // 有换行框时同时限制框尺寸、原点与右/下端点;无框文本在此只校验坐标有限性和文本/字号预算。 if let Some([width, height]) = wrap_bounds { validate_rect(*x, *y, *width, *height, 0.0)?; + // 换行框本身及其远端坐标都不得超过视觉长度预算,避免原点与宽高相加后越界。 if [*x, *y, *width, *height, *x + *width, *y + *height] .iter() .any(|value| value.abs() > crate::ui_bridge::MAX_DIMENSION as f32) @@ -733,9 +840,11 @@ impl RenderCommand { bail!("text wrap bounds exceed limits"); } } + // 单条文字按 UTF-8 字节限长 16 KiB,不按字符数估算资源用量。 if value.len() > 16_384 { bail!("text command exceeds 16 KiB"); } + // size 是 1..8 的样式等级,不是任意像素字号。 if !(1..=8).contains(size) { bail!("text size must be between 1 and 8"); } @@ -747,7 +856,9 @@ impl RenderCommand { /// 把一条已验证的发布命令变成原生绘制原语,缩放几何和文字样式。 fn to_draw_command(&self, scale: f32) -> Result { + // display_list 调用这里把逻辑命令同比缩放为设备原语;二维平移由调用方随后统一追加。 Ok(match self { + // 图像保留不可变资源 Arc,原点/局部矩形按比例缩放,角度、透明度和 tint 保持其含义。 Self::Image { x, y, @@ -767,6 +878,7 @@ impl RenderCommand { opacity: *opacity, tint: *tint, }, + // 只缩放三维视口屏幕矩形,内部相机与世界场景仍由 View 快照解释。 Self::Scene3D { x, y, @@ -777,6 +889,7 @@ impl RenderCommand { rect: scaled_rect(*x, *y, *width, *height, scale), view: view.clone(), }, + // 填充矩形位置/尺寸/圆角同比缩放,颜色转为渲染器通道值。 Self::RoundedRect { x, y, @@ -789,6 +902,7 @@ impl RenderCommand { radius: radius * scale, color: parse_color(color)?, }, + // 描边宽度也乘设备比例,保持与矩形外观一致的视觉厚度。 Self::StrokeRoundedRect { x, y, @@ -812,12 +926,14 @@ impl RenderCommand { wrap_bounds, // 根据是否提供换行边界选择 WrappedText 或 Text;两者都使用同一等级字号与设备缩放。 } => match wrap_bounds { + // 有 wrap 框时产生 WrappedText,排字区域和样式同时缩放,保持换行语义。 Some([width, height]) => DrawCommand::WrappedText { rect: scaled_rect(*x, *y, *width, *height, scale), value: value.clone(), style: crate::text::TextStyle::ui(*size).scaled(scale), color: parse_color(color)?, }, + // 旧 JSON 未提供 wrap_bounds 时保留不换行 Text 行为,不凭测量结果补造裁剪。 None => DrawCommand::Text { origin: Point::new(x * scale, y * scale), value: value.clone(), @@ -841,6 +957,7 @@ fn default_text_size() -> u32 { /// 统一检查有限矩形坐标、尺寸与可选圆角,阻止异常值进入光栅循环。 fn validate_rect(x: f32, y: f32, width: f32, height: f32, radius: f32) -> Result<()> { + // 各命令/clip 共用基础几何检查;世界坐标可负,但四个分量必须有限。 if !x.is_finite() || !y.is_finite() || !width.is_finite() || !height.is_finite() { bail!("rectangle values must be finite"); } @@ -858,6 +975,7 @@ fn scaled_rect(x: f32, y: f32, width: f32, height: f32, scale: f32) -> Rect { /// 解析支持的十六进制 RGB/RGBA 文本,拒绝错误长度或非法字符。 pub fn parse_color(value: &str) -> Result { + // 属性解码、文档验证和显示列表转换共用颜色入口,必须带显式 # 前缀。 let hex = value .strip_prefix('#') .ok_or_else(|| anyhow::anyhow!("color must begin with #"))?; @@ -865,6 +983,7 @@ pub fn parse_color(value: &str) -> Result { if !hex.is_ascii() || (hex.len() != 6 && hex.len() != 8) { bail!("color must use #RRGGBB or #RRGGBBAA"); } + // 已确认 ASCII 和长度后,可安全按字节成对解析 R/G/B/A,非法十六进制仍返回错误。 let parse = |range| u8::from_str_radix(&hex[range], 16); Ok(Color::rgba( parse(0..2)?, @@ -880,6 +999,7 @@ mod tests { #[test] /// 旧文字JSON缺wrap_bounds仍走不换行命令;合法边界随DPI缩放,负宽度在发布验证中拒绝。 fn text_wrap_bounds_validate_and_legacy_text_remains_unwrapped() { + // 从缺省 wrap_bounds 的旧 JSON 反序列化,验证兼容默认值而非手工构造近似命令。 let mut command: RenderCommand = serde_json::from_str( r##"{"type":"text","x":0,"y":0,"value":"text","size":1,"color":"#000000"}"##, ) @@ -888,6 +1008,7 @@ mod tests { command.to_draw_command(1.0).unwrap(), DrawCommand::Text { .. } )); + // 添加合法 40×20 框,后面以 1.25 DPI 核对实际绘制原语尺寸。 if let RenderCommand::Text { wrap_bounds, .. } = &mut command { *wrap_bounds = Some([40.0, 20.0]); } @@ -895,6 +1016,7 @@ mod tests { assert!( matches!(command.to_draw_command(1.25).unwrap(),DrawCommand::WrappedText{rect,..}if rect.width==50.0 && rect.height==25.0) ); + // 再破坏为负宽,要求发布验证拒绝,而不是把尺寸夹到零继续显示。 if let RenderCommand::Text { wrap_bounds, .. } = &mut command { *wrap_bounds = Some([-1.0, 20.0]); } @@ -906,6 +1028,7 @@ mod tests { #[test] /// 默认空文档可通过验证且没有结构根/绘图命令,支持初始状态与清空发布。 fn empty_document_is_valid_and_blank() { + // 默认值代表初始化/清空后的合法空状态,既不需要源码树也不需要业务命令。 let document = RenderDocument::default(); document.validate().unwrap(); assert!(document.root.is_none()); @@ -915,6 +1038,7 @@ mod tests { #[test] /// 检查六位色默认alpha255,八位色按RRGGBBAA解码,避免将末尾透明度当成首通道。 fn parses_rgb_and_rgba_colors() { + // 六位色默认不透明,八位色最后两位为 alpha,分别核对通道顺序。 assert_eq!( parse_color("#102030").unwrap(), Color::rgba(16, 32, 48, 255) @@ -932,13 +1056,16 @@ mod window_projection_tests { #[test] /// 同会话窗口命令可累积,新会话发布先丢旧命令;clear重置runtime_session并清空所有原生命令。 fn replacement_and_reset_discard_old_window_projections() { + // 模拟 API 发布状态,先在同一 VM session 累积不同动作,再更换世代。 let mut state = NamespaceState::default(); + // 第一动作 Focus 与随后 Resize 类型不同,同世代合并不应互相覆盖。 let command = crate::window::Command { window: 1, sequence: 1, action: crate::window::Action::Focus, }; state.publish_window_commands(1, vec![command.clone()]); + // 第二动作使用更大 sequence,检验同世代累积及换世代清旧命令。 let resize = crate::window::Command { window: 1, sequence: 2, @@ -952,6 +1079,7 @@ mod window_projection_tests { state.publish_window_commands(2, vec![resize]); assert_eq!(state.window_commands.len(), 1); assert_eq!(state.window_commands[0].session, 2); + // clear 应清除当前 runtime_session 和待传输命令,旧 VM 请求不能在重置后落到新窗口。 state.clear().unwrap(); assert_eq!(state.runtime_session, 0); assert!(state.window_commands.is_empty()); diff --git a/src/model/snapshot.rs b/src/model/snapshot.rs index 24360e5..8627f10 100644 --- a/src/model/snapshot.rs +++ b/src/model/snapshot.rs @@ -2,6 +2,10 @@ //! 缓存只借用可检查的对象身份,不增加托管根。对象释放后必须清理缓存索引;快照仍可持有独立的原生数值数据。 //! 干净子树直接复用 Arc;脏节点重新检查属性、方法、绑定和子项,循环与展开数量在发布前拒绝。 //! +//! 调用路径:UiBridge 的 prepare/刷新入口消费 VM ChangeBatch,调用 invalidate/prune 后按页面根 snapshot。 +//! block::snapshot 也可用临时空缓存一次性提取;持久窗口缓存只保留原生 UiNode Arc,不 retain 托管身份。 +//! 协议方法/属性改变会使业务快照失效,但相机、布局和绘制各自还有后续阶段的缓存边界。 +//! //! Object-to-visual dependencies. Cached results never own VM references. use crate::{ block::{self, Kind}, @@ -16,15 +20,20 @@ use std::{ #[derive(Debug)] /// 某个业务组件的视觉快照,以及使它失效的读取依赖和子项身份。 struct Cached { + // 已提取的原生不可变视觉树,窗口/布局可复用 Arc,不能由其中的身份文本操作 VM。 node: Arc, + // 重建时实际登记的反向读取键,remove 必须逐一撤销,避免旧读取持续误失效。 dependencies: Vec, + // 当前组成孩子的借用身份,仅维护父链反向索引,不形成新的托管强边。 children: Vec, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] /// 反向依赖键:区分整个对象内容和精确属性名称,减少无关字段变化引起的失效。 struct Dependency { + // 读取源的借用身份,对象可能已被回收,使用前需由 prune/store 查询处理失效。 object: ObjectRef, + // Some 表示精确字段;None 表示对象内容/方法表等整体依赖,与 ChangeKey 粒度对应。 property: Option, } @@ -41,29 +50,38 @@ pub struct RenderCache { parents: HashMap>, // 已失效但尚未重建的 Block 集合,同时用于传播去重。 dirty: HashSet, + // 最近一次失效原因诊断,供开发工具/统计展示,不决定缓存正确性。 pub last_invalidation: String, + // 本次 snapshot 实际重建的进入次数,按调用重置并用于展开预算。 pub visited: usize, + // 本次直接命中干净缓存的节点数,不等于整个复用子树的展开节点数。 pub reused: usize, } impl RenderCache { /// 把 VM 变化映射到读取过这些属性/对象的视觉节点,再沿父链向上传播脏标记。 pub fn invalidate(&mut self, batch: ChangeBatch) { + // 收集首批受变化影响的业务 Block,随后统一交给父链失效传播。 let mut queue = VecDeque::new(); // 变更日志溢出后无法知道遗漏了哪些写入,保守地将已有快照全部失效。 if batch.rescan_required { self.last_invalidation = "change journal overflow".into(); queue.extend(self.entries.keys().copied()); } else { + // 非溢出批次逐项按读取粒度匹配,不把无关字段变化扩大为整图失效。 for change in batch.changes { // 属性变更使用精确名称,Contents 使用整个对象键;与提取时登记的依赖粒度一致。 + // 变化键与提取时记录的 Dependency 完全同构,保证属性名与 Contents 不混用。 let key = Dependency { object: change.object, property: match change.key { + // 字段写入只匹配该名字的精确读取者。 ChangeKey::Attribute(name) => Some(name), + // 对象 payload/方法内容变化匹配整个对象的依赖。 ChangeKey::Contents => None, }, }; + // 同一个源可被多个业务快照读取,全部加入队列;去重与父链传播在下一入口处理。 if let Some(owners) = self.dependents.get(&key) { self.last_invalidation = format!("{} {:?}", key.object, key.property); queue.extend(owners); @@ -75,7 +93,9 @@ impl RenderCache { /// 迭代传播显式失效,使用集合避免共享父链重复遍历或循环入队。 pub fn invalidate_objects(&mut self, objects: impl IntoIterator) { + // 显式失效入口也复用同一队列算法,调用方可传多个共享节点。 let mut queue: VecDeque<_> = objects.into_iter().collect(); + // 广度推进向上的父链,不递归进入 Rust 栈;dirty 同时作为传播去重集合。 while let Some(block) = queue.pop_front() { // 只有第一次加入 dirty 才继续向父链传播,避免共享祖先被反复展开。 if self.dirty.insert(block) @@ -88,20 +108,27 @@ impl RenderCache { /// 撤销旧缓存的反向读取边和父子边,为重新登记该节点做准备。 fn remove(&mut self, block: ObjectRef) -> Option { + // 先移除本体,若根本没缓存则无需撤边;返回旧 Arc 供调用方比较是否可复用。 let old = self.entries.remove(&block)?; // 旧边必须先撤销,否则节点重建后仍会被已经不再读取的对象触发失效。 + // 撤销旧快照的每个读取反向边,新建依赖可能已不再读取这些对象/属性。 for source in &old.dependencies { + // 只移除当前 Block,不能让其他仍读取该源的快照失去失效通知。 if let Some(owners) = self.dependents.get_mut(source) { owners.remove(&block); + // 没有读取者时清空索引项,避免死亡/过时依赖键持续积累。 if owners.is_empty() { self.dependents.remove(source); } } } // 只撤销当前 Block 对孩子的父边;其他共享父节点仍保留。空集合及时删除以缩小索引。 + // 撤销当前父对孩子的索引边,共享孩子仍可能保留其他父。 for child in &old.children { + // 找到孩子所有父集合,仅删除本 Block 的组成关系。 if let Some(parents) = self.parents.get_mut(child) { parents.remove(&block); + // 孩子已无缓存父时删除反向项,避免无用身份长期占缓存内存。 if parents.is_empty() { self.parents.remove(child); } @@ -112,6 +139,7 @@ impl RenderCache { /// 删除托管堆中已不存在的身份,缓存本身不负责保持业务对象存活。 pub fn prune(&mut self, store: &ObjectStore) { + // 桥接回收/准备入口调用 prune,先丢弃托管场景根已经不存在的原生场景缓存。 self.scenes3d .retain(|object, _| store.object(*object).is_ok()); // 先收集死亡身份,再修改 entries 与反向索引,避免一边借用遍历一边删除。 @@ -121,9 +149,10 @@ impl RenderCache { .filter(|v| store.object(**v).is_err()) .copied() .collect(); + // 每个死亡 Block 都需 remove 撤销依赖和父边,不只删除 entries 本体。 for block in dead { self.remove(block); - // 只有成功建立新条目后才清脏标记;前面任何校验失败都不会把节点错误标成干净。 + // 此处身份已死亡,不会再重建;清除其遗留脏标记,避免弱索引变成长期墓碑。 self.dirty.remove(&block); } } @@ -134,16 +163,22 @@ impl RenderCache { store: &ObjectStore, root: ObjectRef, ) -> Result, VmError> { + // 按本次提取重置工作统计,缓存内容与 dirty 则跨调用保留。 self.visited = 0; self.reused = 0; // 使用进入/退出两阶段的显式 DFS,避免深层组成消耗 Rust 调用栈。 // values 是后序结果栈:每个退出节点消费刚完成的孩子快照,再压入自己。 + // 每项为业务 Block 与后序完成标记;false 先展开孩子,true 再消费孩子结果组装父。 let mut work = vec![(root, false)]; + // 当前祖先 Data 集合防止组合环,不是整棵图的全局去重,合法共享孩子仍可多次出现。 let mut path = HashSet::new(); + // 原生子树后序结果栈,保留兄弟已有结果,父完成时只取尾部本节点孩子。 let mut values = Vec::>::new(); + // 显式 DFS 驱动全部提取,发生错误就拒绝这次快照,不返回半棵树。 while let Some((block, finish)) = work.pop() { // 入栈时先尝试复用;退出标记表示孩子已形成快照,可以组装父节点。 if !finish { + // 干净且存在条目才能复用;仅不在 dirty 中并不能证明已缓存。 if !self.dirty.contains(&block) && let Some(cached) = self.entries.get(&block) { @@ -153,14 +188,18 @@ impl RenderCache { } // 计数的是本次实际展开次数;共享节点可复用缓存,脏图展开仍有 10000 次上限。 self.visited += 1; + // 超过实际重建展开预算明确报错,不能按固定节点数截断显示。 if self.visited > 10_000 { return Err(VmError::ExecutionLimit("expanded Block graph")); } } // 提取前验证实例存储与类型契约;resolve_tracked 同时提供数据以及经过的代理/存储依赖。 store.check_typed_instance_storage(block)?; + // 取得共享转发后的真实 Data 及 Block/Cell 路径,让替换身份和中间单元变化也触发重建。 let (data_ref, raw_data, raw_dependencies) = block::resolve_tracked(store, block)?; + // 托管属性只覆盖本轮副本,提取不改业务对象;后面的三维面板展开也只修改此副本。 let mut data = block::visual_data(store, raw_data)?; + // 把转发链各对象登记为 Contents 依赖,随后再添加精确字段和协议方法键。 let mut dependencies: Vec<_> = raw_dependencies .into_iter() .map(|object| Dependency { @@ -169,10 +208,12 @@ impl RenderCache { }) .collect(); // 方法名称查找也构成依赖。即使属性数值未变,协议方法被替换也必须让快照失效。 + // 方法可被实例存储阴影,记录末端属性存储中所有受契约约束的方法名。 let storage = *store .attribute_path(block)? .last() .expect("nonempty attribute path"); + // 类型方法名也需对实例字段写入失效,以便重建时报告非法阴影而非复用旧合法快照。 for name in store.typed_method_names(block)? { dependencies.push(Dependency { object: storage, @@ -180,7 +221,9 @@ impl RenderCache { }); } // 原生视觉属性按精确字段登记,避免不参与视觉的其他属性写入触发整个节点重建。 + // 原生字段存储独立于 Data 结构 payload,按每个公开视觉字段建立精确依赖。 if let Some(properties) = data.properties { + // 只登记该原生 kind 实际解释的字段,不遍历所有业务自定义属性。 for (name, _) in block::native_properties(&data) { dependencies.push(Dependency { object: properties, @@ -188,9 +231,11 @@ impl RenderCache { }); } } + // 二维相机字段进入视口快照依赖,只有 camera 本身的业务变化影响对应视口。 if matches!(data.kind, Kind::CanvasViewport { .. }) && let Some(camera) = data.camera { + // 记录三项相机参数的精确读依赖,源控制器等无关字段不使二维视图重建。 for name in ["x", "y", "zoom"] { dependencies.push(Dependency { object: camera, @@ -199,11 +244,15 @@ impl RenderCache { } } // 3D 面板把托管内容映射为子 Block,同时保留尺寸与稳定 slot,供后续布局和出现身份使用。 + // 三维提取返回的每块面板 UI 分辨率,顺序必须和替换后的 children 相同。 let mut panel_sizes = Vec::new(); + // 只有 Viewport3D 提取三维 View;普通种类不能凭同名 scene 字段触发场景遍历。 let view3d = if matches!(data.kind, Kind::Viewport3D { .. }) { + // 三维视口必须有 Scene3D 业务根;缺失即拒绝本次快照。 let scene = data .scene3d .ok_or(VmError::InvalidArgument("missing Scene3D"))?; + // 传入同场景旧原生 Arc 以便只改相机时复用场景;同时收集真实字段读取及面板组成。 let (view, reads, panels) = crate::scene3d_model::snapshot_reusing( store, scene, @@ -218,6 +267,7 @@ impl RenderCache { })); data.children = panels .into_iter() + // 每块面板内容作为 UI 孩子,panel.key 成为稳定 slot;另并行保存 UI 尺寸供布局使用。 .map(|panel| { panel_sizes.push(panel.size); crate::block::Child { @@ -231,29 +281,39 @@ impl RenderCache { None }; // path 只追踪当前祖先链,因此允许同一业务组件在不同分支多次出现。 + // 首次进入登记祖先与展开孩子;finish=true 表示孩子结果已齐,下一步可以组装本节点。 if !finish { + // 同一 Data 在当前祖先链再次出现才是环;与兄弟分支共享区分。 if !path.insert(data_ref) { return Err(VmError::InvalidArgument("cyclic Block composition")); } work.push((block, true)); + // 逆序压栈使孩子实际完成顺序仍与组成顺序一致,退出标记排在全部孩子之后。 for child in data.children.iter().rev() { work.push((child.block, false)); } continue; } path.remove(&data_ref); + // 只有仍有 handler 且 kind 以 pointer 开头的连接提供 pointer_events,断开令牌不再算交互需求。 let pointer_connection = data.subscriptions.iter().any(|&o| { store .payload::(o, "Subscription") .is_ok_and(|s| s.handler.is_some() && s.kind.starts_with("pointer")) }); // 协议键包含类型身份、实际方法身份及其内容版本,供布局/绘制缓存识别实现变化。 + // 协议指纹供后续布局/绘制缓存比较;不能仅凭业务 Block ID 认定实现没变。 let mut protocol_key = String::new(); + // 无类型或无 onEvent 时默认没有自定义事件能力,随后按名义方法识别。 let mut custom_event = false; + // 名义类型与其方法表决定实际协议,不从任意同名实例值推断视觉实现。 if let Some(definition) = store.type_of(block)? { protocol_key.push_str(&definition.to_string()); + // 五种视觉/事件协议方法都记录句柄及内容版本,payload 保持句柄不变也会改变指纹。 for name in ["measure", "arrange", "paint", "hitTest", "onEvent"] { + // 仅已解析到的实际方法加入依赖;类型链另登记,负责将来新增/替换方法的失效。 if let Some(method) = store.lookup_method(definition, name)? { + // 使用对象内容版本识别同函数身份下的实现替换,不只把方法名拼入字符串。 let version = store.change_version(method)?; protocol_key.push_str(&format!("/{method}:{version}")); dependencies.push(Dependency { @@ -263,13 +323,17 @@ impl RenderCache { } } // 不仅记录最终方法,还沿基类链登记类型内容依赖,使继承关系或方法表变化能触发重建。 + // 保存当前解析到的 onEvent,再遍历基类链登记方法表依赖。 let event = store.lookup_method(definition, "onEvent")?; + // 从动态类型向基类推进,每层都属于方法查找的读取来源。 let mut current = Some(definition); + // 继承关系/任一基类表变化都能影响查找结果,不能只观察最终方法函数。 while let Some(definition) = current { dependencies.push(Dependency { object: definition, property: None, }); + // 名义类型 payload 提供 base 链,错误类型不能被当成链尾默默接受。 let item = store.payload::(definition, "Type")?; current = item.base(); } @@ -281,29 +345,38 @@ impl RenderCache { .unwrap_or(false); } // 订阅连接的取消会改变 handler;记录连接内容依赖,使交互资格能随取消及时更新。 + // 另累计 click 监听资格,与 pointer 系列、内建 Button 和源 onEvent 合并。 let mut click_connection = false; + // 所有事件连接的 handler 取消都会写 payload,Contents 依赖让交互标记及时重算。 for connection in &data.subscriptions { dependencies.push(Dependency { object: *connection, property: None, }); + // 从真实 Connection payload 判断事件种类与有效 handler,不能凭连接句柄存在就当作活跃。 let item = store .payload::(*connection, "Connection")?; click_connection |= item.kind == "click" && item.handler.is_some(); } // 逆序压入孩子确保后序结果与 data.children 原顺序一致;只取尾部的本节点孩子,保留兄弟结果。 + // 每个孩子在后序栈恰有一个结果;按数量拆尾部,其他兄弟结果留给祖先。 let children = values.split_off(values.len() - data.children.len()); + // 将业务描述转为不依赖 VM 存活的原生 UiKind,孩子 Arc 按种类移入一次。 let kind = match &data.kind { Kind::Text { value, size, color } => { // 旧 textOf 路径按实际源字段提取字符串。属性路径中间对象用整对象依赖,末端用字段依赖。 + // text_binding 兼容路径动态读取其源值,普通文字则采用已解码的 text 字段副本。 let value = if let Some((source, field)) = &data.text_binding { + // 源属性可能经过共享转发链,各中间身份变化都可能改变最后读取的值。 let storage = store.attribute_path(*source)?; + // 中间节点登记 Contents,只有末端属性存储用精确字段名,兼顾转发变更与精确失效。 for (index, object) in storage.iter().enumerate() { dependencies.push(Dependency { object: *object, property: (index + 1 == storage.len()).then(|| field.clone()), }); } + // 按源 Value 类型转换显示文本,对象 str 还需登记该对象内容依赖。 match store.get_attr(*source, field)? { Value::Nil => "nil".into(), Value::Bool(v) => v.to_string(), @@ -327,6 +400,7 @@ impl RenderCache { color: color.clone(), } } + // 矩形只复制自然视觉数据,实际出现宽高由后续布局决定。 Kind::Rect { width, height, @@ -338,6 +412,7 @@ impl RenderCache { radius: *radius, color: color.clone(), }, + // 滚动视口快照保存尺寸/轴与原生孩子,不保存每个 Mount 的偏移。 Kind::Scroll { width, height } => UiKind::Scroll { axis: data.scroll_axis, width: *width, @@ -346,7 +421,9 @@ impl RenderCache { gap: data.gap, children, }, + // 间距叶节点无需组成快照,保留自然尺寸供布局使用。 Kind::Spacer(size) => UiKind::Spacer(*size), + // 三维 View 和面板尺寸与内容孩子一同发布,缺失 View 必须报错。 Kind::Viewport3D { width, height } => UiKind::Viewport3D { width: *width, height: *height, @@ -355,6 +432,7 @@ impl RenderCache { panels: panel_sizes, children, }, + // 二维有限视口保存已解码 Camera2D 值与 Scene2D 孩子,后续投影不再查询 VM。 Kind::CanvasViewport { width, height } => UiKind::CanvasViewport { width: *width, height: *height, @@ -371,42 +449,50 @@ impl RenderCache { .children .iter() .map(|child| { + // Scene2D 的每个孩子可能是别名,先解析当前 Data 再读取经过属性覆盖的位置。 let (_, child) = block::resolve(store, child.block)?; Ok(block::visual_data(store, child)?.position) }) .collect::, VmError>>()?, children, }, + // Overlay 各孩子叠放在相同内容区域,不携带 Row/Column 线性 gap 策略。 Kind::Overlay => UiKind::Overlay { padding: data.padding, children, }, + // 普通 Row 保留 padding/gap 与有序孩子,位置由布局计算。 Kind::Row => UiKind::Row { padding: data.padding, gap: data.gap, children, }, + // 兼容 canvas Page 与 Scene2D 一样提取业务位置,表面 pan/zoom 另在 UiNode 上保存。 Kind::Page if data.surface.mode == crate::page::Mode::Canvas => UiKind::Canvas { positions: data .children .iter() .map(|child| { + // 旧 Page canvas 同样取每个出现对应业务子的当前 x/y,不把父排列结果当作世界位置。 let (_, data) = block::resolve(store, child.block)?; Ok(block::visual_data(store, data)?.position) }) .collect::, VmError>>()?, children, }, + // 旧横向页面模式复用 Row 内容布局,页面表面元信息仍独立保留。 Kind::Page if data.surface.mode == crate::page::Mode::Horizontal => UiKind::Row { padding: data.padding, gap: data.gap, children, }, + // Column 和其余旧页面模式使用纵向内容排列。 Kind::Column | Kind::Page => UiKind::Column { padding: data.padding, gap: data.gap, children, }, + // 按钮作为带默认视觉/事件语义的容器保留孩子与内边距。 Kind::Button => UiKind::Button { padding: data.padding, gap: data.gap, @@ -416,6 +502,7 @@ impl RenderCache { // 统一外层样式与交互标志。UiKind 只表示具体内容,UiNode 保存所有类别共享的视觉元信息。 let next = UiNode { surface: matches!(data.kind, Kind::Page).then(|| { + // 仅 Page 附带表面快照,补入公开页面身份文本而非持有 VM 引用。 let mut surface = data.surface.clone(); surface.page = block.to_string(); surface @@ -443,7 +530,9 @@ impl RenderCache { kind, }; // 即使本次重算结果相同,也要重新登记依赖。shallow_eq 相同时复用旧 Arc,减少下游无效重建。 + // 撤销旧依赖/父边,即使浅层视觉结果相等也必须按本轮读取重新登记。 let old = self.remove(block); + // 结果浅相等才复用旧 Arc,减少下游无效工作;变化结果建立新不可变节点。 let node = if let Some(old) = old.as_ref().filter(|old| old.node.shallow_eq(&next)) { old.node.clone() } else { @@ -452,16 +541,20 @@ impl RenderCache { // 同一属性可能由不同提取路径重复读取,排序去重后再建立反向边。 dependencies.sort_unstable(); dependencies.dedup(); + // 保留孩子业务身份用于父链反向索引,与 UiKind 内的原生 Arc 拥有关系分开。 let child_refs: Vec<_> = data.children.iter().map(|child| child.block).collect(); + // 为每个去重读取键登记当前 Block,供下个 VM ChangeBatch 找到首批失效者。 for source in &dependencies { self.dependents .entry(source.clone()) .or_default() .insert(block); } + // 每个共享孩子可有多个父,子项失效时需要沿所有父边传播脏标记。 for child in &child_refs { self.parents.entry(*child).or_default().insert(block); } + // 节点和全部反向边建立后才安装新条目,最后清 dirty 并把结果交回后序栈。 self.entries.insert( block, Cached {