WPF 学习笔记(四):布局系统——Measure/Arrange 与面板

写在前面

版本说明:基于 .NET 8 LTS。

WPF 的布局系统和 WinForms 是完全不同的范式。WinForms 是"绝对定位"——button.Location = new Point(10, 20),所有控件都用屏幕坐标。WPF 是"两阶段布局"——父元素问子元素"你想多大?"(Measure),然后告诉子元素"你的最终位置和大小"(Arrange)。

这个范式差别,理解了之后写 WPF 布局就顺;不理解,会一直被"为什么我的 Grid 在 StackPanel 里高度变 0"这种问题困扰。

本文要回答:

为什么 WPF 用两阶段布局?各种 Panel 的度量公式是什么?怎么自定义 Panel?


一、为什么是两阶段布局

1.1 WinForms 的绝对定位

1
2
3
4
// WinForms
var button = new Button();
button.Location = new Point(10, 20);
button.Size = new Size(100, 30);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
特点:
  - 你写死坐标和尺寸
  - 父容器不参与"计算"
  - 简单直接

问题:
  - 窗口缩放,控件不动 → 丑陋
  - DPI 变化,控件位置乱
  - 国际化(标签变长)→ 控件重叠
  - 嵌套布局完全手动

1.2 WPF 的两阶段

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
Measure(度量):
  父问子:"给你这么多空间,你想要多大?"
  子答:"我想要 100x30。"(DesiredSize)

Arrange(安排):
  父告诉子:"你的最终位置是 (10, 20),大小是 100x30。"
  子按这个尺寸渲染

为什么分两阶段?
  1. 父可能要让多个子共享空间(Grid 的 * 比例)
     → 必须先知道每个子要多少,再分配
  2. 嵌套布局需要递归
     → Window 问 Grid,Grid 问 Button,Button 问 Content
  3. 容器策略不同(StackPanel 累加,Grid 平均)
     → 但都遵循 Measure → Arrange 模式

1.3 触发时机

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
什么时候触发 layout:

1. 窗口初始化
2. 控件加进视觉树
3. Visibility 变化(Collapsed ↔ Visible)
4. Margin / Padding 变化
5. 字号、内容变化
6. 显式调用 InvalidateMeasure() / InvalidateArrange()

Layout 是异步的:
  - 你改一个属性,不会立刻 re-layout
  - 标记"脏",等下一个 Layout Pass(Dispatcher 优先级 Layout)
  - 多次 InvalidateMeasure 在一次 Pass 里合并

  例外:UpdateLayout() 强制立即 layout(慎用,性能杀手)

二、Measure 与 Arrange

2.1 Measure

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// FrameworkElement.Measure 的简化签名
public void Measure(Size availableSize)
{
    // 1. 检查 cache
    // 2. 调用 MeasureCore → MeasureOverride
    var desiredSize = MeasureOverride(availableSize);
    // 3. 缓存 desiredSize 到 this.DesiredSize
    this.DesiredSize = desiredSize;
}

protected virtual Size MeasureOverride(Size availableSize)
{
    // 子类(Panel)实现
    // 返回"我想要多大"
}
1
2
3
4
5
6
7
8
9
availableSize 的含义:
  - 父给子的"可用空间"
  - Size.Infinity 表示"无限"(StackPanel 给子元素无限主轴空间)
  - 具体数字表示"父有这么多空间给你"

DesiredSize 的含义:
  - 子返回的"想要大小"
  - 必须 ≤ availableSize(不强制,但 WPF 会裁剪)
  - 必须是有限值(不能返回 Infinity)

2.2 Arrange

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public void Arrange(Rect finalRect)
{
    // finalRect: 最终位置 + 大小(相对于父)
    // 调用 ArrangeCore → ArrangeOverride
    ArrangeOverride(finalRect);
}

protected virtual Size ArrangeOverride(Size finalSize)
{
    // 子类(Panel)实现
    // 安排每个子的位置和大小
    // 返回实际使用的大小(通常等于 finalSize)
}
1
2
3
4
5
finalRect 的含义:
  - X, Y:相对于父的位置
  - Width, Height:实际大小

  必须调用每个子的 Arrange,否则子不会渲染

2.3 完整流程示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
StackPanel(垂直)有 3 个 Button:

  Measure 阶段:
    StackPanel.MeasureOverride(availableSize):
      foreach child:
        child.Measure(availableSize with Width=infinity)
        desiredHeight += child.DesiredSize.Height
      return new Size(maxChildWidth, desiredHeight)

  Arrange 阶段:
    StackPanel.ArrangeOverride(finalSize):
      y = 0
      foreach child:
        child.Arrange(new Rect(0, y, finalSize.Width, child.DesiredSize.Height))
        y += child.DesiredSize.Height
      return finalSize

三、HorizontalAlignment / VerticalAlignment / Margin

3.1 三件套

1
2
3
4
5
Margin:子元素外边距
HorizontalAlignment:水平对齐(Left/Center/Right/Stretch)
VerticalAlignment:垂直对齐(Top/Center/Bottom/Stretch)

它们在 Arrange 阶段生效

3.2 Stretch 的特殊含义

1
2
3
4
5
6
7
<!-- Stretch(默认) -->
<Button HorizontalAlignment="Stretch" VerticalAlignment="Stretch"/>
<!-- 子元素占满可用空间 -->

<!-- Left/Top -->
<Button HorizontalAlignment="Left" VerticalAlignment="Top"/>
<!-- 子元素在 DesiredSize 大小,贴左上 -->
1
2
3
4
5
6
Stretch 的副作用:
  StackPanel(垂直)的子元素 HorizontalAlignment 默认是 Stretch
  → 子元素的宽度 = StackPanel 的宽度(拉伸)

  但如果父容器高度有限,StackPanel 自己可能不占满高度
  → 嵌套布局需要逐层考虑 Stretch

3.3 Margin 的作用

1
2
3
4
5
6
7
Margin="10,5,10,5"  ← 左,上,右,下

Measure 阶段:
  availableSize 减去 Margin 后再传给子

Arrange 阶段:
  finalRect 收缩 Margin 后才是子的实际矩形

四、内置 Panel 全家桶

4.1 Canvas(绝对定位)

1
2
3
4
<Canvas>
    <Button Canvas.Left="10" Canvas.Top="20" Content="A"/>
    <Button Canvas.Left="50" Canvas.Top="60" Content="B"/>
</Canvas>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
特点:
  - 类似 WinForms 的绝对定位
  - Canvas.Left/Top 附加属性定位
  - 子元素超出 Canvas 区域不裁剪(除非 ClipToBounds=true)
  - 不参与 measure(子元素 DesiredSize 直接用)

适用:
  - 图形 / 自定义绘图场景
  - 元素位置完全可控
  - 不适合业务 UI(不响应缩放、不国际化)

4.2 StackPanel(栈)

1
2
3
4
5
6
7
<StackPanel Orientation="Vertical">
    <Button/> <Button/> <Button/>
</StackPanel>

<StackPanel Orientation="Horizontal">
    <Button/> <Button/> <Button/>
</StackPanel>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
特点:
  - 单方向排列(垂直 / 水平)
  - 主轴累加,副轴 Stretch
  - 不滚动(超出区域就溢出)

  垂直模式:
    - 高度 = 所有子的高度之和
    - 宽度 = 父宽度(子 HorizontalAlignment=Stretch)

  水平模式:反过来

适用:
  - 简单列表
  - 工具栏

注意:
  - 不要用来装大数据!StackPanel 不虚拟化
  - 1000 个 Button 在 StackPanel 里 = 性能崩盘

4.3 WrapPanel(自动换行)

1
2
3
<WrapPanel Orientation="Horizontal" ItemWidth="100" ItemHeight="40">
    <Button/> <Button/> <Button/> ... <!-- 自动换行 -->
</WrapPanel>
1
2
3
4
5
6
7
8
特点:
  - 类似 StackPanel,但装不下时换行
  - 主轴到边界 → 跳到下一行/列

适用:
  - 标签云
  - 缩略图网格
  - 工具栏自动布局

4.4 DockPanel(停靠)

1
2
3
4
5
6
7
<DockPanel LastChildFill="True">
    <Button DockPanel.Dock="Top" Content="Top"/>
    <Button DockPanel.Dock="Bottom" Content="Bottom"/>
    <Button DockPanel.Dock="Left" Content="Left"/>
    <Button DockPanel.Dock="Right" Content="Right"/>
    <Button Content="Center (填充)"/>
</DockPanel>
1
2
3
4
5
6
7
特点:
  - DockPanel.Dock 附加属性
  - 子元素依次"贴边"
  - LastChildFill=true(默认)→ 最后一个填充剩余

适用:
  - 经典窗口布局(顶部工具栏 + 底部状态栏 + 主内容)

4.5 Grid(栅格)—— WPF 最复杂 Panel

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto"/>     <!-- 自动高度 -->
        <RowDefinition Height="*"/>         <!-- 占剩余 -->
        <RowDefinition Height="2*"/>        <!-- 占 2/3 剩余 -->
        <RowDefinition Height="100"/>       <!-- 固定 -->
    </Grid.RowDefinitions>

    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*"/>
        <ColumnDefinition Width="Auto"/>
    </Grid.ColumnDefinitions>

    <Button Grid.Row="0" Grid.Column="0" Content="A"/>
    <Button Grid.Row="1" Grid.Column="1" Grid.RowSpan="2" Content="B"/>
</Grid>

Grid 的度量公式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
Height 取值三种:
  Auto:根据内容算高度
  *(含 N*):按比例瓜分剩余空间
  数字:固定像素

算法(行高度计算):
  1. 先满足所有"固定数字"
  2. 然后满足所有 Auto(看内容的 DesiredSize)
  3. 剩余空间按 * 比例分配

例:
  总高度 600
  Row 0: Auto → 50(看内容)
  Row 1: *   → 200(占 1/3 剩余 500)
  Row 2: 2*  → 400(占 2/3)
  Row 3: 100 → 100(固定)
  剩余 = 600 - 50 - 100 = 450
  Row 1 = 450 / 3 = 150
  Row 2 = 450 * 2/3 = 300
1
2
3
4
适用:
  - 几乎所有业务 UI
  - 表单、对话框、仪表盘
  - 默认首选 Grid

4.6 UniformGrid(等分 Grid)

1
2
3
<UniformGrid Rows="3" Columns="2">
    <Button/> <Button/> <Button/> <Button/> <Button/> <Button/>
</UniformGrid>
1
2
3
4
5
6
7
特点:
  - 行列均分(不需要 RowDefinitions)
  - 简化版的 Grid(适合 N×M 等分)

适用:
  - 计算器数字键盘
  - 等宽缩略图

4.7 VirtualizingStackPanel(虚拟化栈)

1
2
3
<ListBox ItemsSource="{Binding Items}">
    <!-- ListBox 默认就用 VirtualizingStackPanel -->
</ListBox>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
特点:
  - StackPanel 的虚拟化版本
  - 只渲染可见区域的子元素
  - 滚动时动态创建/销毁

适用:
  - 大数据列表(1000+ 项)
  - 配合 ItemsControl 系列控件

启用条件:
  1. ItemsControl 派生控件(ListBox/ComboBox/ListView/DataGrid)
  2. 默认 Panel 是 VirtualizingStackPanel
  3. VirtualizingPanel.IsVirtualizing=true(默认开)

4.8 Panel 选型表

场景推荐 Panel
业务表单 / 主窗口Grid
简单垂直/水平列表StackPanel
大数据列表ListBox(VirtualizingStackPanel)
顶部工具栏 + 主区域DockPanel
标签云 / 自动换行WrapPanel
等分网格UniformGrid
绝对定位(图形)Canvas

五、DPI 感知与设备无关像素

5.1 DIP(Device Independent Pixel)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
WPF 的"1像素"不是物理像素,是 1/96 英寸

  1 DIP = 1/96 inch
  1 inch = 96 DIP

物理像素计算:
  显示器 DPI = 96(默认)→ 1 DIP = 1 物理像素
  显示器 DPI = 192(200% 缩放)→ 1 DIP = 2 物理像素
  显示器 DPI = 144(150% 缩放)→ 1 DIP = 1.5 物理像素

意义:
  - 你写 Width=100 → 任意 DPI 下都是 100 DIP
  - 高分屏自动缩放,不糊
  - 不需要写 dpi 适配代码

5.2 DPI 感知声明

1
2
3
4
5
6
7
8
<!-- app.manifest -->
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
  <application xmlns="urn:schemas-microsoft-com:asm.v3">
    <windowsSettings>
      <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
    </windowsSettings>
  </application>
</assembly>
1
2
3
4
5
6
7
8
PerMonitorV2:
  - 每个显示器独立 DPI(拖到不同屏幕自适应)
  - 现代 WPF 推荐
  - .NET 4.7+ 支持

不声明:
  - 系统级 DPI(DPI=Aware)→ 拖到不同屏幕糊
  - 或 DPI=Unaware → 全程被系统拉伸(最糊)

5.3 物理像素转换

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// DIP → 物理像素
var dpi = VisualTreeHelper.GetDpi(this);
double pixelsPerDip = dpi.PixelsPerDip;   // 1.0 / 1.5 / 2.0 / ...

double dipWidth = 100;
double pixelWidth = dipWidth * pixelsPerDip;

// PresentationSource 旧 API
var matrix = PresentationSource.FromVisual(this).CompositionTarget.TransformToDevice;
double pixelWidth = dipWidth * matrix.M11;

六、布局槽与 Transform

6.1 LayoutSlot

1
2
3
4
5
6
7
每个 FrameworkElement 有一个 LayoutSlot(布局槽)
  = Arrange 时父给的 Rect
  = 在父坐标系里的位置和大小

可视化辅助(调试时打开 ShowLayoutGrid):
  RenderTransform 不影响 LayoutSlot
  LayoutTransform 会影响 LayoutSlot(重新 measure)

6.2 RenderTransform vs LayoutTransform

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<!-- RenderTransform:渲染时变换,不触发 layout -->
<Button Content="A" RenderTransformOrigin="0.5,0.5">
    <Button.RenderTransform>
        <RotateTransform Angle="45"/>
    </Button.RenderTransform>
</Button>

<!-- LayoutTransform:变换后重新参与 layout -->
<Button Content="A">
    <Button.LayoutTransform>
        <RotateTransform Angle="45"/>
    </Button.LayoutTransform>
</Button>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
区别:
  RenderTransform:
    - 渲染时变换
    - 不影响周围元素布局
    - 性能好(动画用这个)
    - 可能与邻居重叠

  LayoutTransform:
    - 变换后影响 measure/arrange
    - 邻居会被推开
    - 性能稍差
    - 适合"永久性"变换(如旋转标签)

七、虚拟化深度

7.1 为什么需要虚拟化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
无虚拟化:
  ListBox 绑定 10000 项
  → ItemsControl 创建 10000 个 ItemContainer
  → 每个 ItemContainer 有完整视觉树
  → 内存爆炸 + 启动卡死

有虚拟化:
  → 只创建可见的 ~20 个
  → 滚动时回收/创建
  → 内存恒定

7.2 虚拟化的必要条件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
1. ItemsControl 派生(ListBox/ListView/DataGrid/ComboBox)
   - 普通 ItemsControl 默认不虚拟化
   - ListBox 以后默认虚拟化

2. ItemsPanel 必须是 VirtualizingStackPanel
   <ListBox>
     <ListBox.ItemsPanel>
       <ItemsPanelTemplate>
         <VirtualizingStackPanel/>
       </ItemsPanelTemplate>
     </ListBox.ItemsPanel>
   </ListBox>

3. VirtualizingPanel.IsVirtualizing=True(默认开)

4. VirtualizingPanel.VirtualizationMode=Standard(默认)
   或 Recycling(容器回收,性能更好)

7.3 影响虚拟化的属性

1
2
3
4
5
<ListBox VirtualizingPanel.IsVirtualizing="True"
         VirtualizingPanel.VirtualizationMode="Recycling"
         VirtualizingPanel.ScrollUnit="Pixel"
         VirtualizingPanel.CacheLength="2,2"
         VirtualizingPanel.CacheLengthUnit="Page">
1
2
3
4
5
6
7
ScrollUnit:
  Item(默认):按 Item 滚动,可能不流畅
  Pixel:按像素滚动,更流畅

CacheLength:
  保留多少额外项(预渲染)
  "2,2" = 上下各预渲染 2 屏

八、实战:自定义 TimelinePanel

把两阶段布局用上——一个按时间轴排布子元素的 Panel。

8.1 需求

  • 子元素带 DateTime 附加属性
  • 按时间从左到右排布
  • 同一时刻的元素垂直堆叠

8.2 附加属性

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public static class Timeline
{
    public static readonly DependencyProperty TimeProperty =
        DependencyProperty.RegisterAttached(
            "Time",
            typeof(DateTime),
            typeof(Timeline),
            new PropertyMetadata(DateTime.Now));

    public static DateTime GetTime(DependencyObject obj)
        => (DateTime)obj.GetValue(TimeProperty);

    public static void SetTime(DependencyObject obj, DateTime value)
        => obj.SetValue(TimeProperty, value);
}

8.3 Panel 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
public class TimelinePanel : Panel
{
    public DateTime StartTime
    {
        get => (DateTime)GetValue(StartTimeProperty);
        set => SetValue(StartTimeProperty, value);
    }

    public static readonly DependencyProperty StartTimeProperty =
        DependencyProperty.Register(
            nameof(StartTime),
            typeof(DateTime),
            typeof(TimelinePanel),
            new FrameworkPropertyMetadata(
                DateTime.MinValue,
                FrameworkPropertyMetadataOptions.AffectsArrange));

    public DateTime EndTime
    {
        get => (DateTime)GetValue(EndTimeProperty);
        set => SetValue(EndTimeProperty, value);
    }

    public static readonly DependencyProperty EndTimeProperty =
        DependencyProperty.Register(
            nameof(EndTime),
            typeof(DateTime),
            typeof(TimelinePanel),
            new FrameworkPropertyMetadata(
                DateTime.MaxValue,
                FrameworkPropertyMetadataOptions.AffectsArrange));

    // 第一阶段:度量
    protected override Size MeasureOverride(Size availableSize)
    {
        double maxHeight = 0;
        foreach (UIElement child in InternalChildren)
        {
            child.Measure(availableSize);
            maxHeight = Math.Max(maxHeight, child.DesiredSize.Height);
        }
        return new Size(availableSize.Width, maxHeight);
    }

    // 第二阶段:安排
    protected override Size ArrangeOverride(Size finalSize)
    {
        var span = (EndTime - StartTime).TotalSeconds;
        if (span <= 0) return finalSize;

        // 跟踪每"列"已堆叠的高度
        var laneHeights = new Dictionary<int, double>();

        foreach (UIElement child in InternalChildren)
        {
            var time = Timeline.GetTime(child);
            var ratio = (time - StartTime).TotalSeconds / span;
            var x = ratio * finalSize.Width;

            // 简化:同一时间窗内堆叠
            int lane = (int)(x / 80);  // 每列宽 80 DIP
            double y = laneHeights.GetValueOrDefault(lane, 0);

            var rect = new Rect(x, y, Math.Min(80, child.DesiredSize.Width), child.DesiredSize.Height);
            child.Arrange(rect);

            laneHeights[lane] = y + child.DesiredSize.Height + 4;
        }

        return finalSize;
    }
}

8.4 使用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<Window xmlns:local="clr-namespace:MyApp">
    <local:TimelinePanel StartTime="2026-05-01" EndTime="2026-05-31" Height="300">
        <Border local:Timeline.Time="2026-05-03" Background="LightBlue" Width="70" Height="40">
            <TextBlock Text="事件 1"/>
        </Border>
        <Border local:Timeline.Time="2026-05-10" Background="LightCoral" Width="70" Height="40">
            <TextBlock Text="事件 2"/>
        </Border>
        <Border local:Timeline.Time="2026-05-10" Background="LightGreen" Width="70" Height="40">
            <TextBlock Text="事件 3"/>
        </Border>
        <Border local:Timeline.Time="2026-05-25" Background="LightYellow" Width="70" Height="40">
            <TextBlock Text="事件 4"/>
        </Border>
    </local:TimelinePanel>
</Window>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
效果:
  - 事件按时间从左到右
  - 同时间的(5-10)垂直堆叠
  - 改 StartTime/EndTime 触发 AffectsArrange 重新布局

知识点:
  ✅ MeasureOverride 返回所有子最大高度
  ✅ ArrangeOverride 按时间算 X,按 lane 算 Y
  ✅ 附加属性带"元数据"
  ✅ AffectsArrange 标记触发 re-layout

九、常见陷阱

9.1 StackPanel + Grid * 不工作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<!-- ❌ Grid 的 * 在 StackPanel 里失效 -->
<StackPanel>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>   <!-- 想填充剩余 -->
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
    </Grid>
</StackPanel>

<!-- StackPanel 给子元素无限高度
     Grid 的 * 不知道"剩余"是多少
     → * 行变 0 高度 -->
1
2
3
4
5
6
7
8
9
✅ 用 Grid 嵌套
<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="*"/>
        <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>
    <!-- 内容 -->
</Grid>

9.2 用 Width 而不是 Margin 控制间距

1
2
3
4
5
<!-- ❌ 用 MinWidth 制造空隙 -->
<Button Content="OK" MinWidth="100" MinHeight="30"/>

<!-- ✅ 用 Margin / Padding -->
<Button Content="OK" Margin="10" Padding="20,5"/>

9.3 大数据用 StackPanel

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!-- ❌ 10000 项在 StackPanel 里 = 内存爆炸 -->
<ItemsControl ItemsSource="{Binding Items}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <StackPanel/>
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
</ItemsControl>

<!-- ✅ 用 ListBox(自带虚拟化) -->
<ListBox ItemsSource="{Binding Items}"/>

9.4 DesiredSize 不能直接设

1
2
3
4
5
6
// ❌ 不能
button.DesiredSize = new Size(100, 30);

// ✅ 只能通过 Measure 让子元素自己算
button.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
var desired = button.DesiredSize;

9.5 Canvas 子元素不响应父缩放

1
2
3
4
5
6
7
<!-- Canvas 里的子元素位置固定 -->
<Canvas Width="1000">
    <Button Canvas.Left="950" Content="靠右"/>
</Canvas>
<!-- Canvas 缩到 500 宽度,按钮还在 950 → 溢出 -->

<!-- Canvas 不适合响应式 UI -->

十、与 CSS Flex/Grid 的对比

后端工程师可能也写过前端。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
CSS Flex                     WPF StackPanel
─────────────────────────────────────────────
display: flex                (默认 StackPanel)
flex-direction: row/column   Orientation
justify-content              HorizontalAlignment
align-items                  VerticalAlignment
gap                          Margin
flex-grow                    (StackPanel 不支持)

CSS Grid                     WPF Grid
─────────────────────────────────────────────
grid-template-columns        ColumnDefinitions
1fr / auto                   * / Auto
grid-column: 1 / 3           Grid.ColumnSpan
gap                          Margin

关键差异:
  - CSS 用 fr(fraction),WPF 用 *(语义一致)
  - CSS 自动响应式,WPF 要手动(一般够用)
  - WPF 没有 CSS 的 gap(用 Margin 模拟)

十一、性能与调试

11.1 Layout 性能

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Layout 是 WPF 性能敏感区域:
  - 改一个属性触发 InvalidateMeasure → 整树重算
  - 复杂嵌套 Grid 在大窗口里慢

优化:
  1. 减少 Grid 嵌套(用 RowSpan/ColumnSpan 替代)
  2. 大数据用虚拟化
  3. 避免在 Loaded 里反复改 Margin/Width
  4. UIElement 而不是 FrameworkElement(如果不需要布局特征)
  5. 用 IsHitTestVisible=false 跳过命中测试

11.2 调试布局

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Visual Studio WPF Designer:
  - 选中元素 → Properties → Layout
  - 实时看 ActualWidth/ActualHeight

WPF Performance Suite(旧 SDK):
  - 可视化布局边界
  - 找到 measure 次数过多的元素

代码调试:
  Console.WriteLine($"Desired: {element.DesiredSize}");
  Console.WriteLine($"Render: {element.RenderSize}");
  Console.WriteLine($"Actual: {element.ActualWidth}x{element.ActualHeight}");

十二、小结

本文深入 WPF 的布局系统:

  • WinForms 绝对定位 vs WPF 两阶段布局
  • Measure(度量)与 Arrange(安排)
  • HorizontalAlignment/VerticalAlignment/Margin 三件套
  • 内置 Panel 全家桶(Canvas/StackPanel/DockPanel/WrapPanel/Grid/UniformGrid/VirtualizingStackPanel)
  • Grid 的三种 Height(Auto/*/固定)与度量公式
  • DPI 感知与设备无关像素
  • RenderTransform vs LayoutTransform
  • 虚拟化的必要条件与优化属性
  • 实战:自定义 TimelinePanel(MeasureOverride + ArrangeOverride + 附加属性)
1
2
3
4
记住三句话:
  1. 布局是两阶段:Measure 问子多大,Arrange 告诉子去哪
  2. 业务 UI 优先 Grid,能用 Grid 就别嵌套 StackPanel
  3. 大数据列表必须虚拟化——这是从"卡死"到"丝滑"的开关

下一篇将进入 WPF 的核心特性——数据绑定。理解了 DP + 绑定,WPF 的"声明式响应式"就立起来了。

Licensed under CC BY-NC-SA 4.0