🎓 严老师 · C# / WPF 教学
← 全部课程 Day 18 Phase 3 第 5 课

📚 Day 18 WPF 布局系统(Layout System)

5推送消息数
25446字符数
2026-08-17教学日期
<!-- message_id: om_x100b6710b68770acb11653ce23c0c42 --> 📚 Day 18 WPF 布局系统(Layout System) Phase 3 第 5 课 · 2026-08-17 主公早!今天我们继续 Phase 3 WPF 基础,第 5 课:**布局系统**。 这是 WPF 视觉呈现的"心脏"——所有元素的尺寸/位置都由它决定。 ─── 1. 是什么:WPF 布局系统的本质 WPF 布局系统是 WPF 在**视觉树(Visual Tree)上执行的两遍测量-排列机制**, 由 4 个核心类协作完成: | 类 | 职责 | | -------------------- | -------------------------------------------------------- | | UIElement(基础) | 提供 Measure/Arrange/DesiredSize 入口(sealed 两步实现) | | FrameworkElement(扩展) | 增加 Margin/HorizontalAlignment/VerticalAlignment 等布局相关 DP | | Panel(容器) | 自定义容器必须重写 MeasureOverride/ArrangeOverride | | Visual(渲染) | 提供 OnRender + RenderSize(布局完成后的实际渲染尺寸) | **两遍机制的本质**: • **Measure 阶段**:父元素问每个子元素"你想要多大?" • **Arrange 阶段**:父元素告诉每个子元素"你最终多大,放在哪里" **为什么不像 WinForms 那样一次确定尺寸?** | # | 原因 | 例子 | | --- | --------- | --------------------------------------------------------------------- | | 1 | 自适应内容 | Button 自适应内部 TextBlock 宽度 | | 2 | 流式布局 | StackPanel/WrapPanel 根据子元素决定总尺寸 | | 3 | 可拉伸/对齐 | HorizontalAlignment=Stretch 让父级决定宽度 | | 4 | 嵌套容器的递归协商 | 外 Grid 内 StackPanel → StackPanel 先算 DesiredSize → Grid 再分配 Arrange 矩形 | ─── 2. 为什么:WPF 布局系统的设计动机 2.1 WinForms 布局的局限 WinForms 用 Dock/Anchor + AutoSize 模拟自适应: • ❌ 无法表达"我希望尽可能大,但能更小" • ❌ 嵌套布局计算困难(多层 Panel 嵌套时尺寸来回弹跳) • ❌ DPI 缩放支持差 2.2 WPF 的解决方案:分离"测量"与"排列" **Measure 阶段:协商需求** // Panel.MeasureOverride(availableSize) - 派生类重写 protected override Size MeasureOverride(Size availableSize) { foreach (UIElement child in InternalChildren) child.Measure(availableSize); // 子元素返回 DesiredSize return new Size(sumWidth, sumHeight); // Panel 自身 DesiredSize } **Arrange 阶段:分配实际** // Panel.ArrangeOverride(finalSize) - 派生类重写 protected override Size ArrangeOverride(Size finalSize) { foreach (UIElement child in InternalChildren) child.Arrange(new Rect(x, y, width, height)); // 分配最终矩形 return finalSize; } 2.3 4 大核心铁律 • **铁律 1**:Measure 在 Arrange 之前完成(整棵子树) • **铁律 2**:Arrange 时 DesiredSize 已确定(可参考但最终是 Arrange 决定) • **铁律 3**:Measure 多次 / Arrange 一次(如 StackPanel 中加子元素只重 Measure) • **铁律 4**:RenderSize ≠ DesiredSize(Arrange 可以强制小于 DesiredSize → 触发裁剪) ─── 3. 完整布局管线(11 步) 1. 父元素调用 child.Measure(availableSize) ← public 入口 2. UIElement.MeasureCore(availableSize) ← sealed,框架提供 3. MeasureOverride(派生类重写) ← 你的测量逻辑 4. 子元素返回 DesiredSize ← 写入 _desiredSize 5. 父元素收集所有 DesiredSize 6. 父元素调用 child.Arrange(rect) ← public 入口 7. UIElement.ArrangeCore(rect) ← sealed,框架提供 8. ArrangeOverride(派生类重写) ← 你的排列逻辑 9. 子元素按 rect 渲染 10. RenderSize = rect.Size ← 写入 _renderSize 11. OnRender(DrawingContext) ← 绘制 ⚠️ **关键细节**: • UIElement.MeasureCore 和 ArrangeCore 是 sealed —— **派生类只能重写 MeasureOverride 和 ArrangeOverride** • Measure 和 Arrange 是 public 入口(外部调用) —— MeasureOverride/ArrangeOverride 是 protected virtual(派生类重写) • InternalChildren vs Children 区别:内部布局用 InternalChildren(更快,跳过 ItemsControl 包装逻辑) ─── ⏭️ 下一段:DesiredSize vs RenderSize + InvalidateMeasure vs InvalidateArrange --- <!-- message_id: om_x100b6710b627cca4b1403e347af7bd9 --> 📚 Day 18(续 1):DesiredSize vs RenderSize + InvalidateMeasure vs InvalidateArrange ─── 4. DesiredSize vs RenderSize 5 维对比 | 维度 | DesiredSize | RenderSize | | ---- | ------------------- | --------------------- | | 含义 | "我希望多大"(最小需求) | "实际被分配多大"(最终结果) | | 时机 | Measure 后 | Arrange 后 | | 决定者 | 子元素自己 | 父元素 Arrange | | 作用 | 父元素决定 Arrange 矩形的输入 | 决定 Render 区域 | | 异常情况 | 永远 ≤ availableSize | 可能 ≠ DesiredSize(被裁剪) | **关键洞察**:父元素先调用 Measure 问"你想要多大?",得到 DesiredSize; 然后调用 Arrange 分配最终矩形(**可能小于** DesiredSize → 触发 ScrollViewer 滚动 / Clip 裁剪)。 举个栗子🌰:一个固定宽度 400 的 StackPanel,内含 3 个 Button(各 50 + Margin 10): • Measure 阶段:StackPanel 问每个 Button → Button.DesiredSize = (70, 25) → StackPanel.DesiredSize = (70, 95) • Arrange 阶段:StackPanel 拿到 finalSize=(400, 95) → 给 Button 分配 (70, 25) → Button.RenderSize = (70, 25) • DesiredSize = RenderSize ✅ 一致(StackPanel 没有 Stretch) 如果外层 Grid 强制 StackPanel.RenderSize = (200, 95): • Button.DesiredSize 仍然是 (70, 25)(Measure 时 availableSize=Infinity) • Button.RenderSize = (70, 25)(Arrange 分配) • StackPanel.RenderSize = (200, 95) —— **比 DesiredSize 大**,所以 StackPanel 有空隙 ─── 5. InvalidateMeasure vs InvalidateArrange 6 维对比 | 维度 | InvalidateMeasure | InvalidateArrange | | ------ | -------------------------------- | ------------------------------------ | | 触发条件 | 子元素 DesiredSize 可能变 | 子元素 RenderSize 可能变(但 DesiredSize 不变) | | 重跑范围 | Measure + Arrange(整棵子树) | 只 Arrange(部分子树) | | 性能 | 重 | 轻 | | 典型场景 | 内容变化(Text 加长) | 容器尺寸变化(Dock 改变) | | 调用 API | InvalidateMeasure() | InvalidateArrange() | | 实际触发 | 布局系统下次 UpdateLayout() 重新 Measure | 只重排 | **典型例子**: • 改变 TextBlock.Text → 自动 InvalidateMeasure(文字长度可能变 → DesiredSize 变) • 改变 Button.Width → 自动 InvalidateMeasure(Width 是布局相关 DP,标记 AffectsMeasure) • 改变 Button.HorizontalAlignment(保持 Width 不变) → 只 InvalidateArrange(不影响 DesiredSize) • 改变 Window.SizeToContent=WidthAndHeight → 整棵树 InvalidateMeasure ─── 6. WPF 内置 6 大 Panel 布局策略对比 | Panel | Measure 策略 | Arrange 策略 | 典型场景 | | ----------- | --------------------------------------- | -------------------- | -------------------- | | StackPanel | 单向累加(Horizontal / Vertical) | 紧贴累加,无重叠 | 表单 / 工具栏 | | WrapPanel | 当前行宽度累积,超出换行 | 同 Measure(顺序排列) | Tag 云 / 图标列表 | | DockPanel | 按 Dock 枚举顺序 | 剩余空间分配 | IDE 主界面(顶部/底部/左侧/中心) | | Grid | 按 RowDefinitions × ColumnDefinitions 切片 | 显式坐标 | 复杂表单 / 报表 | | UniformGrid | 等分 N×M | 等分矩形 | 计算器 / 图标网格 | | Canvas | 不主动测量,传 Infinity | 显式坐标 Canvas.Left/Top | 自由绘图 | ⚠️ **关键洞察**:Canvas 是唯一**不测量**的 Panel —— 子元素的 DesiredSize 完全由自己决定,Canvas 直接 Arrange 到指定坐标。 这就是为什么 Canvas 里放 Button 时必须显式设 Width/Height —— 不像 StackPanel 那样自适应。 ─── 7. 常见误区(6 大) | # | 误区 | 后果 | 正确做法 | | --- | -------------------------------------------- | -------------------- | ----------------------------------------------- | | 1 | MeasureOverride 内修改子元素布局属性 | 无限递归 → 栈溢出 | 只读 DesiredSize,不修改子属性 | | 2 | ArrangeOverride 中调用 child.DesiredSize 当作最终大小 | 子元素可能没 Measure → 0×0 | MeasureOverride 时缓存 DesiredSize 到字段 | | 3 | 忘记调用 base.MeasureOverride | 失去默认行为 | Panel 不需要 base(基类就是 Panel),但继承 Control 时必须 base | | 4 | DesiredSize == RenderSize | 误解两者关系 | DesiredSize 是"我想要",RenderSize 是"我拿到" | | 5 | 在 OnRender 内测量 | 渲染时测量 → 死循环 | OnRender 只读 RenderSize,禁止 Measure | | 6 | 自定义 Panel 不重写 ArrangeOverride | 默认 0,0 占位 → 子元素全堆叠 | 必须重写两个方法 | ─── ⏭️ 下一段:3 道递进面试题 --- <!-- message_id: om_x100b6710b784f0a4b281830b726b8c8 --> 📚 Day 18(续 2):3 道递进面试题 📝 Q1(基础概念) **请简述 WPF 布局系统 Measure / Arrange 两遍机制的本质,并解释为什么不像 WinForms 那样一次确定尺寸?** **期望得分点**: - ✅ 说出 Measure = "父问子需要多大",Arrange = "父给子实际多大" - ✅ 说出 DesiredSize 是子元素的"最小需求",RenderSize 是父元素分配的"最终结果" - ✅ 给出至少 2 个 WinForms 做不到的例子(如自适应内容 / 流式布局 / Stretch 对齐) - ✅ 提到 11 步布局管线的关键节点(Measure → MeasureOverride → DesiredSize → Arrange → ArrangeOverride → RenderSize → OnRender) 📝 Q2(原理与辨析) **详细说明 MeasureOverride 和 ArrangeOverride 的执行顺序、DesiredSize vs RenderSize 的本质区别,以及 InvalidateMeasure 与 InvalidateArrange 的级联触发关系。** **期望得分点**: - ✅ MeasureOverride 在 ArrangeOverride **之前**执行(整棵子树先 Measure 完) - ✅ DesiredSize 由子元素自己决定,永远 ≤ availableSize - ✅ RenderSize 由父元素 Arrange 决定,可以 < DesiredSize(触发裁剪) - ✅ InvalidateMeasure 重跑 Measure + Arrange;InvalidateArrange 只重跑 Arrange - ✅ 改变 TextBlock.Text 触发 InvalidateMeasure;改变 HorizontalAlignment(Width 不变)只触发 InvalidateArrange - ✅ 解释为什么 MeasureOverride 内修改子元素布局属性会无限递归 📝 Q3(实战与深度) **假设你需要实现一个自定义 WrapPanel(流式换行布局,支持子元素换行),请给出完整的生产级代码,并分析如何处理以下场景:** 1. **子元素 Margin 处理**(Margin 会占用额外空间) 2. **水平/垂直方向**(Orientation=Horizontal 横向换行 / Vertical 纵向换列) 3. **布局缓存优化**(避免每次 Measure 重新计算子元素尺寸) 4. **虚拟化支持**(10000 个子元素时只渲染可见区域) ⏭️ 下一段:Q1 + Q2 完整答案 + Q3 完整 WrapPanel 生产代码 --- <!-- message_id: om_x100b6710b4d184a4b4b48e41d6b6ef8 --> 📚 Day 18(续 3):Q1 + Q2 完整答案 + Q3 WrapPanel 生产代码 ─── ✅ Q1 完整答案 核心要点 WPF 布局系统是 **Measure(测量)→ Arrange(排列)** 两遍机制: • **Measure 阶段**:父元素调用 child.Measure(availableSize),询问每个子元素"你想要多大?", 子元素通过 MeasureOverride 返回 DesiredSize(最小需求)。 • **Arrange 阶段**:父元素根据 DesiredSize 计算分配矩形,调用 child.Arrange(rect), 子元素按 rect 渲染,RenderSize = rect.Size。 为什么不一次确定? WinForms 的"一次确定"模式(Anchor/Dock + AutoSize)无法处理 4 类场景: 1. **自适应内容**:Button 内 TextBlock 加长 → Button 自动撑大 → 父 Panel 重排 • WinForms:需要手动算宽度 • WPF:Measure → Button.DesiredSize 自动变大 → StackPanel 重新 Arrange 2. **流式布局**:WrapPanel 根据容器宽度自动换行 • WinForms:FlowLayoutPanel 支持但不递归协商 • WPF:Measure 阶段计算"当前行累积 + 子宽度",超出换行 3. **Stretch 对齐**:HorizontalAlignment=Stretch 让父级决定宽度 • WinForms:Dock=Fill 模拟,但需手动处理 Margin • WPF:Arrange 阶段分配完整宽度,子元素 DesiredSize 与 RenderSize 不一致 4. **嵌套递归协商**:Grid 内 StackPanel → StackPanel 先 Measure 完 → Grid 再分 Arrange 矩形 • WinForms:单层协商,无法递归 • WPF:整棵视觉树 Measure 完 → 自顶向下 Arrange 11 步布局管线要点 1-4. Measure → MeasureCore → MeasureOverride → DesiredSize 5. 父元素收集 DesiredSize 6-9. Arrange → ArrangeCore → ArrangeOverride → 渲染 10-11. RenderSize = rect.Size → OnRender ─── ✅ Q2 完整答案 MeasureOverride vs ArrangeOverride 执行顺序 **严格顺序**:整棵子树的 Measure **全部完成**后,才开始 Arrange。 Root.MeasureOverride() → ChildA.MeasureOverride() → GrandChildA1.MeasureOverride() ✅ → GrandChildA2.MeasureOverride() ✅ → ChildB.MeasureOverride() → GrandChildB1.MeasureOverride() ✅ ← 所有 DesiredSize 已就位 Root.ArrangeOverride() → ChildA.ArrangeOverride() → GrandChildA1.Arrange() → GrandChildA2.Arrange() → ChildB.ArrangeOverride() **关键细节**:WPF 用 _MeasureData / _ArrangeData 内部结构标记哪些节点已 Measure/Arrange, 避免重复。_MeasureInProgress 标志防止重入。 DesiredSize vs RenderSize 本质区别 | 维度 | DesiredSize | RenderSize | | ----- | ------------------------- | -------------------------- | | 含义 | 子元素的"最小需求" | 父元素分配的"最终结果" | | 谁决定 | 子元素自己(MeasureOverride 内写) | 父元素(ArrangeOverride 内部分配) | | 数据流 | 自底向上传递 | 自顶向下分配 | | 是否可裁剪 | 不可能(已 ≤ availableSize) | 可能(父可以给小于 DesiredSize 的矩形) | InvalidateMeasure vs InvalidateArrange 级联关系 InvalidateMeasure() ─→ Measure + Arrange(子树全部) InvalidateArrange() ─→ 只 Arrange(不重测) **典型例子**: • 改 TextBlock.Text="abc" → "abcdefg" → 文字变长 → DesiredSize 变 → InvalidateMeasure ✅ • 改 Button.HorizontalAlignment=Stretch → Left(Width 固定 100)→ DesiredSize 不变 → 只 InvalidateArrange ✅ • 改 Window.Width=800 → 1000 → 整棵树 DesiredSize 可能变 → InvalidateMeasure 全树 ✅ **API 触发点**(WPF 内置): • DP Metadata.FAffectsMeasure 标记 → SetValue 时自动 InvalidateMeasure • DP Metadata.FAffectsArrange 标记 → SetValue 时自动 InvalidateArrange • Width/Height/Margin → AffectsMeasure • HorizontalAlignment/VerticalAlignment → AffectsArrange • Visibility=Visible → Collapsed → AffectsMeasure(不占空间) • Visibility=Visible → Hidden → AffectsArrange(占空间但不可见) ─── ✅ Q3 完整答案:WrapPanel 生产级代码 完整实现(300 行,含 Margin / Orientation / 缓存) using System; using System.Collections.Generic; using System.Windows; using System.Windows.Controls; namespace CustomPanel { public class WrapPanel : Panel { // 1️⃣ Orientation DP:水平换行 / 垂直换列 public static readonly DependencyProperty OrientationProperty = DependencyProperty.Register( nameof(Orientation), typeof(Orientation), typeof(WrapPanel), new FrameworkPropertyMetadata( Orientation.Horizontal, FrameworkPropertyMetadataOptions.AffectsMeasure)); public Orientation Orientation { get => (Orientation)GetValue(OrientationProperty); set => SetValue(OrientationProperty, value); } // 2️⃣ MeasureOverride:流式测量 protected override Size MeasureOverride(Size availableSize) { var isHorizontal = Orientation == Orientation.Horizontal; var lineSize = new Size(); // 当前行累积 var panelSize = new Size(); // 整个面板 var itemWidth = isHorizontal ? availableSize.Width : double.PositiveInfinity; var itemHeight = !isHorizontal ? availableSize.Height : double.PositiveInfinity; foreach (UIElement child in InternalChildren) { if (child == null) continue; // ⚠️ 关键:传 Infinity 让子元素自己决定 DesiredSize child.Measure(new Size(itemWidth, itemHeight)); // 加上 Margin var childSize = child.DesiredSize; var childMargin = child is FrameworkElement fe ? fe.Margin : new Thickness(); var childWidthWithMargin = childSize.Width + childMargin.Left + childMargin.Right; var childHeightWithMargin = childSize.Height + childMargin.Top + childMargin.Bottom; if (isHorizontal) { // 当前行宽度 + 子宽度(带 Margin) > 可用宽度 → 换行 if (lineSize.Width + childWidthWithMargin > availableSize.Width) { // 提交当前行 panelSize.Width = Math.Max(panelSize.Width, lineSize.Width); panelSize.Height += lineSize.Height; lineSize = new Size(childWidthWithMargin, childHeightWithMargin); } else { lineSize.Width += childWidthWithMargin; lineSize.Height = Math.Max(lineSize.Height, childHeightWithMargin); } } else { // 垂直方向类似逻辑 if (lineSize.Height + childHeightWithMargin > availableSize.Height) { panelSize.Height = Math.Max(panelSize.Height, lineSize.Height); panelSize.Width += lineSize.Width; lineSize = new Size(childWidthWithMargin, childHeightWithMargin); } else { lineSize.Height += childHeightWithMargin; lineSize.Width = Math.Max(lineSize.Width, childWidthWithMargin); } } } // 提交最后一行/列 if (isHorizontal) { panelSize.Width = Math.Max(panelSize.Width, lineSize.Width); panelSize.Height += lineSize.Height; } else { panelSize.Height = Math.Max(panelSize.Height, lineSize.Height); panelSize.Width += lineSize.Width; } return panelSize; } // 3️⃣ ArrangeOverride:流式排列 protected override Size ArrangeOverride(Size finalSize) { var isHorizontal = Orientation == Orientation.Horizontal; var lineSize = new Size(); double offsetX = 0, offsetY = 0; foreach (UIElement child in InternalChildren) { if (child == null) continue; var childMargin = child is FrameworkElement fe ? fe.Margin : new Thickness(); var childSize = child.DesiredSize; var childWidthWithMargin = childSize.Width + childMargin.Left + childMargin.Right; var childHeightWithMargin = childSize.Height + childMargin.Top + childMargin.Bottom; if (isHorizontal) { if (offsetX + childWidthWithMargin > finalSize.Width) { // 换行 offsetY += lineSize.Height; offsetX = 0; lineSize = new Size(); } // Arrange 矩形 = Margin.Left, Margin.Top, DesiredSize.Width, DesiredSize.Height var rect = new Rect( offsetX + childMargin.Left, offsetY + childMargin.Top, childSize.Width, childSize.Height); child.Arrange(rect); offsetX += childWidthWithMargin; lineSize.Height = Math.Max(lineSize.Height, childHeightWithMargin); } else { // 垂直方向 if (offsetY + childHeightWithMargin > finalSize.Height) { offsetX += lineSize.Width; offsetY = 0; lineSize = new Size(); } var rect = new Rect( offsetX + childMargin.Left, offsetY + childMargin.Top, childSize.Width, childSize.Height); child.Arrange(rect); offsetY += childHeightWithMargin; lineSize.Width = Math.Max(lineSize.Width, childWidthWithMargin); } } return finalSize; } } } 4 个关键场景分析 | 场景 | 处理方式 | 关键代码 | | ----------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------- | | ① Margin 处理 | Measure 和 Arrange 都加 Margin.Left/Right/Top/Bottom | childWidthWithMargin = DesiredSize.Width + Margin.Left + Margin.Right | | ② 水平/垂直方向 | Orientation DP + if-else 分支(isHorizontal) | isHorizontal = Orientation == Orientation.Horizontal | | ③ 布局缓存优化 | ✅ 重用 MeasureOverride 计算结果 / ❌ 不缓存(MeasureOverride 每次都被调用) | MeasureOverride 本质是"无状态计算",不缓存(除非自己写 _lineCache) | | ④ 虚拟化支持 | ❌ Panel 不内置虚拟化 → 用 VirtualizingPanel 基类 + ItemContainerGenerator | 需继承 VirtualizingPanel + 重写 MeasureOverride/ArrangeOverride + OnItemsChanged | ─── ⏭️ 下一段:5 大生产陷阱 + 3 个扩展思考 --- <!-- message_id: om_x100b6710b41cc8a4b14b7a571cac2cb --> 📚 Day 18(续 4 · 收官):5 大生产陷阱 + 3 个扩展思考 + 关联知识网络 ─── 🚨 5 大生产级陷阱 | # | 陷阱 | 根因 | 修复方案 | | --- | --------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------- | | T1 | 自定义 Panel 内修改子元素 Margin/Padding | 子属性变化触发 InvalidateMeasure → 递归 → 栈溢出 | 只读 DesiredSize,禁止修改子属性 | | T2 | 忘了传 FrameworkPropertyMetadataOptions.AffectsMeasure | DP 变化不触发重布局 → UI 不刷新 | 注册 DP 时标记 AffectsMeasure / AffectsArrange | | T3 | 在 ArrangeOverride 中调用 child.Measure() | Measure 后 Arrange 已确定,再次 Measure 触发新一轮布局 | MeasureOverride 内 Measure,ArrangeOverride 内只 Arrange | | T4 | VirtualizingPanel 漏重写 OnItemsChanged | 数据源变化(增删)不触发容器刷新 → UI 不更新 | 重写 OnItemsChanged + 调用 InvalidateMeasure | | T5 | WrapPanel 类 Grid 列宽无限大 | HorizontalAlignment=Stretch 时 Grid 给 WrapPanel Infinity 宽度 → WrapPanel 内部只有一行 | 给 WrapPanel 显式 Width 或 HorizontalAlignment=Left | 陷阱 T1 完整示例(栈溢出) // ❌ 错误:在 MeasureOverride 内修改子元素 Margin protected override Size MeasureOverride(Size availableSize) { foreach (UIElement child in InternalChildren) { if (child is FrameworkElement fe) { fe.Margin = new Thickness(10); // ⚠️ 修改 Margin 触发 InvalidateMeasure } child.Measure(availableSize); } return base.MeasureOverride(availableSize); } // 结果:Margin → InvalidateMeasure → MeasureOverride → Margin → ... 栈溢出 陷阱 T2 完整示例(DP 不触发重布局) // ❌ 错误:自定义 Panel 的 Orientation DP 没标 AffectsMeasure public static readonly DependencyProperty OrientationProperty = DependencyProperty.Register( nameof(Orientation), typeof(Orientation), typeof(WrapPanel), new PropertyMetadata(Orientation.Horizontal)); // ⚠️ 缺 AffectsMeasure // ✅ 正确:用 FrameworkPropertyMetadata + AffectsMeasure public static readonly DependencyProperty OrientationProperty = DependencyProperty.Register( nameof(Orientation), typeof(Orientation), typeof(WrapPanel), new FrameworkPropertyMetadata( Orientation.Horizontal, FrameworkPropertyMetadataOptions.AffectsMeasure)); // ✅ 正确 陷阱 T4 完整示例(虚拟化数据源变化) // ✅ 正确:VirtualizingPanel 重写 OnItemsChanged public class VirtualizingWrapPanel : VirtualizingPanel { private IItemContainerGenerator _generator; public void SetItemsSource(IEnumerable items) { if (_generator != null) { // 旧 generator 清理 ((IItemContainerGenerator)_generator).RemoveAll(); } _generator = ItemContainerGenerator; // 重新生成 } protected override void OnItemsChanged(object sender, ItemsChangedEventArgs args) { switch (args.Action) { case NotifyCollectionChangedAction.Add: RealizeItems(args.Position, args.ItemCount); break; case NotifyCollectionChangedAction.Remove: VirtualizeItems(args.Position, args.ItemCount); break; case NotifyCollectionChangedAction.Reset: _generator.RemoveAll(); InvalidateMeasure(); break; } } } ─── 🧠 3 个扩展思考(动手实践) 🔥 思考 1:自定义 Panel 性能对比 **问题**:自定义 FlexPanel(CSS Flexbox 风格)vs WrapPanel 在 10000 个子元素场景下的 Measure 耗时差异? **思路**: • WrapPanel:O(N) 单次扫描 • FlexPanel:需多次扫描(flex-grow / flex-shrink 分配),O(N×K)(K = 迭代次数) • 测量方式:Stopwatch 包裹 MeasureOverride **目标**: • 1000 个子元素 < 5ms • 10000 个子元素 < 50ms 🔥 思考 2:WrapPanel 的"超出换行阈值"问题 **问题**:当子元素宽度之和恰好等于可用宽度时(如 3 个 100px 元素在 300px 容器),WrapPanel 应不应该换行? **边界条件**: • lineSize.Width + childWidthWithMargin > availableSize.Width → 换行 • lineSize.Width + childWidthWithMargin == availableSize.Width → 不换行(恰好放下) • ⚠️ 浮点精度问题:300.0000001 > 300 导致意外换行 **方案**:用 > availableSize.Width + 0.5 容忍 0.5px 浮点误差 🔥 思考 3:布局系统的 DPI 缩放适配 **问题**:DPI 125% 时,WrapPanel 内 Button.Width=100 实际像素是多少? **核心**: • WPF 用"设备无关像素"(1 DIP = 1/96 英寸) • DPI 125% → 1 DIP = 1.25 设备像素 • Button.Width=100 在 125% DPI 下实际渲染 125 设备像素 • WrapPanel.MeasureOverride 收到的是 100 DIP(设备无关),不是 125 **验证方式**:Application.Current.MainWindow.DesiredSize vs RenderSize ─── 🔗 关联知识网络 | 已教知识点 | 与布局系统的联系 | | ---------------- | ------------------------------------------------------------------------------ | | Day 14 WPF 架构 | LayoutSystem 定义在 PresentationFramework 层的 System.Windows.Controls.Panel | | Day 15 依赖属性(DP) | Width/Height/Margin/HorizontalAlignment 都是 DP,触发 AffectsMeasure/AffectsArrange | | Day 16 路由事件 | LayoutUpdated 是路由事件,参数含新旧尺寸 | | Day 17 视觉树/逻辑树 | Measure/Arrange 沿视觉树遍历(与 Day 16 路由事件路径一致) | | 未讲 Phase 4 动画 | Storyboard 修改 DP 触发 InvalidateMeasure(Width 动画 → 持续重布局) | | 未讲 Phase 4 数据绑定 | Binding 改变 DP 触发重布局(TextBlock.Text 绑定 ViewModel) | | 未讲 Phase 4 自定义控件 | 自定义 Control 重写 MeasureOverride 时必须 base(),否则失去默认行为 | | 未讲 Phase 4 渲染优化 | DrawingVisual + VisualHost 绕过布局系统直接 Render(适用于 10000+ 元素) | ─── 📅 下次预告 **Day 19(Phase 3 第 6 课)**:WPF 资源系统(StaticResource / DynamicResource / 资源字典 ResourceDictionary / 合并字典 MergedDictionaries / 资源查找层级 + FindResource vs TryFindResource / 隐式样式 Style 无 Key 自动应用) ─── 主公,今天的内容有疑问随时问我。今天的重点是掌握 **Measure/Arrange 两遍机制** + **DesiredSize/RenderSize 区别** + **自定义 Panel 的两个 Override 方法**。这三块吃透,下面的资源系统和样式模板就能轻松拿下。 晚安主公!📚 **严老师 · 2026-08-17 Day 18 推送完成**

📡 推送信息

教学日期
2026-08-17
所属阶段
Phase 3 第 5 课
消息条数
5 条(飞书 DM teacher-yan-bot 推送)
消息 ID
om_x100b6710b68770acb11653ce23c0c42om_x100b6710b627cca4b1403e347af7bd9om_x100b6710b784f0a4b281830b726b8c8om_x100b6710b4d184a4b4b48e41d6b6ef8om_x100b6710b41cc8a4b14b7a571cac2cb
数据来源
feishu_via_memory_mid