WPF 学习笔记(一):架构总览与 XAML 原理

写在前面

版本说明:本文基于 .NET 8 LTS + Visual Studio 2022,所有代码在 Windows 10/11 验证。

WPF(Windows Presentation Foundation)是微软 2006 年随 .NET Framework 3.0 发布的 UI 框架。当时它是一次彻底的重新设计——统一了 UI、文档、图形、媒体——但快 20 年过去了,WPF 既老又新:老在 API 表面(很多类从 3.0 没动过),新在运行时(.NET 8 上原生编译、性能持续优化)。

写这个系列是因为我自己从 ASP.NET Core 后端切到 WPF 桌面开发时踩了不少"概念陷阱":依赖属性为什么和普通属性不一样?路由事件到底在路由什么?为什么 UI 必须单线程?这些问题不搞清楚,写出来的 WPF 代码就是"用 WinForms 思维写 WPF"——能跑但别扭。

本文是开篇,先建立 WPF 的心智模型:架构分层、视觉树/逻辑树、XAML 编译流水线。理解了这些,后续 11 篇才有共同语言。


一、WPF 是什么:与 WinForms 的根本区别

1.1 一个根本区别:保留模式 vs 立即模式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
WinForms(立即模式 Immediate Mode 的近亲,本质是封装 User32):
  - 控件是 Window Handle(HWND)的包装
  - 每次重绘:WM_PAINT → OnPaint → Graphics 画
  - 画完即"忘",下次重绘重新画
  - 每个控件一个 HWND,靠 User32 GDI 渲染

WPF(保留模式 Retained Mode):
  - 控件不是 HWND,是托管对象
  - 整个 UI 是一棵"视觉树",常驻内存
  - 渲染由 DirectX 接管,WPF 把视觉树提交给 GPU
  - 一个窗口通常只有一个 HWND(顶层),内部全是自绘
1
2
3
4
5
6
7
类比:
  WinForms 像"画黑板"——你画一笔是一笔,擦了就没了
  WPF   像"摆积木"——你描述积木的位置和形状,渲染器替你画

后端视角:
  WinForms = 命令式 UI(你自己控制重绘)
  WPF     = 声明式 UI(你描述树,框架渲染)

1.2 为什么要换一套

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
GDI / User32 的瓶颈(2006 年视角):
  - 不支持硬件加速(GPU 没用上)
  - 不支持透明(Alpha 混合)
  - 文字渲染粗糙(ClearType 也救不了复杂排版)
  - 控件之间 HWND 隔离,做"圆角按钮+渐变背景"要subclass 黑魔法
  - DPI 处理粗糙(高分屏糊一脸)

WPF 用 DirectX 重写渲染层:
  - GPU 硬件加速
  - 矢量图形(缩放不失真)
  - 透明、抗锯齿、复杂文字排版
  - 设备无关像素(DPI 自动适配)
  - 一致的控件模型(一个 HWND,全部自绘)

1.3 WPF 的代价

1
2
3
4
5
6
7
8
9
代价 1:内存占用高(视觉树常驻)
代价 2:启动慢(要构建视觉树、加载 BAML、解析资源)
代价 3:线程模型严格(UI 单线程,Dispatcher 调度)
代价 4:学习曲线陡(XAML + DP + 绑定 + 路由事件 + MVVM)

但换来的是:
  ✅ 复杂 UI 容易做(动画、3D、文档、富绑定)
  ✅ 数据驱动的开发模式(声明式绑定)
  ✅ 设计师与工程师协作(XAML + Blend)

二、架构分层

2.1 自上而下分层

 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
┌─────────────────────────────────────────────────┐
│           你的应用(Application + Views)          │
├─────────────────────────────────────────────────┤
│     PresentationFramework.dll(托管)             │
│     - 控件(Button/TextBox/ListBox...)            │
│     - Style/Template/Binding                      │
│     - Application/Window/FrameworkElement         │
├─────────────────────────────────────────────────┤
│     PresentationCore.dll(托管)                   │
│     - 视觉树核心:Visual/UIElement/ContentElement │
│     - DependencyProperty / RoutedEvent            │
│     - Dispatcher                                  │
├─────────────────────────────────────────────────┤
│     WindowsBase.dll(托管)                        │
│     - DispatcherObject                            │
│     - Freezable、Animatable                       │
│     - 基础数据结构                                 │
├─────────────────────────────────────────────────┤
│     milcore.dll(非托管,C++)                     │
│     - Media Integration Layer                     │
│     - 与 DirectX 通信                             │
│     - Composition 线程                            │
├─────────────────────────────────────────────────┤
│     DirectX / User32 / GDI                        │
│     - GPU 渲染 / 顶层 HWND                        │
└─────────────────────────────────────────────────┘

2.2 各层职责

程序集职责
你的应用-XAML + ViewModel + 业务逻辑
PresentationFrameworkPresentationFramework.dll高层控件、样式、模板、应用模型
PresentationCorePresentationCore.dll视觉树核心、DP/RE、Dispatcher
WindowsBaseWindowsBase.dllDispatcher 基类、Freezable
milcoremilcore.dll(非托管)DirectX 通信、Composition 线程
DirectX/User32系统GPU 渲染、HWND 管理
1
2
3
4
5
6
7
关键观察:
  1. 大部分代码在托管层(C#),但渲染核心 milcore 是非托管 C++
  2. milcore 跑在单独的 Composition 线程,与 UI 线程解耦
     → UI 线程算视觉树变化,Composition 线程提交 GPU
     → 这就是 WPF 动画"不卡 UI"的根本原因(动画在 Composition 线程)
  3. 一个窗口通常只有一个 HWND(HwndSource)
     → 内部所有控件没有独立 HWND(除非 Popup、WebBrowser 等互操作控件)

2.3 为什么分层这么细

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
后端类比:和 ASP.NET Core 的分层思路一致

ASP.NET Core 分层:
  你的应用 → MVC/Razor → HttpAbstractions → Kestrel → OS Socket

WPF 分层:
  你的应用 → Framework → Core → WindowsBase → milcore → DirectX

共同点:
  - 核心层(Core / Abstractions)只放"地基"概念
  - 高层(Framework / MVC)放具体功能
  - 这样可以单独替换高层(比如 WPF 自己换 Skin、ASP.NET Core 换 MVC/Razor Pages)

WPF 的具体体现:
  - DependencyProperty 在 PresentationCore(地基)
  - Button 这种控件在 PresentationFramework(上层)
  - 你要写"纯高性能绘图",可以只用 PresentationCore 的 Visual,绕开 Framework 的控件开销

三、视觉树与逻辑树

WPF 内部其实有两棵树——视觉树(Visual Tree)和逻辑树(Logical Tree)。这两棵树大部分时候长得像,但语义不同。

3.1 逻辑树:XAML 的"骨架"

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
XAML:
<Window>
  <Grid>
    <Button Content="OK"/>
    <Button Content="Cancel"/>
  </Grid>
</Window>

逻辑树:
  Window
   └─ Grid
       ├─ Button (OK)
       └─ Button (Cancel)
1
2
3
4
特点:
  - 就是 XAML 元素的父子关系
  - 用于:属性继承、资源查找、DataContext 流转
  - API:LogicalTreeHelper

3.2 视觉树:渲染的"实现细节"

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
视觉树是逻辑树的"展开版",包含所有渲染细节:

视觉树(Button "OK" 展开后):
  Button
   └─ ButtonChrome(边框)
       └─ ContentPresenter
           └─ TextBlock("OK" 文字)
               └─ Glyph(字模)

特点:
  - 比逻辑树更深,包含模板内部的元素
  - 用于:渲染、路由事件传播、命中测试
  - API:VisualTreeHelper

3.3 两棵树的对比

维度逻辑树视觉树
含义XAML 结构渲染结构(含模板展开)
深度
用途资源/属性继承/DataContext渲染/路由事件/命中测试
APILogicalTreeHelperVisualTreeHelper
变化XAML 改了才变模板展开会动态变
1
2
3
4
实战意义:
  - 想找"父窗口":用 LogicalTreeHelper(逻辑层级稳定)
  - 想找"鼠标点击的内部元素":用 VisualTreeHelper.HitTest(视觉层级精确)
  - 想遍历所有 TextBlock:可能要遍历视觉树(模板内可能有)

3.4 实战:打印两棵树

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static void DumpTrees(DependencyObject root)
{
    Console.WriteLine("=== Logical Tree ===");
    DumpLogical(root, 0);
    Console.WriteLine("\n=== Visual Tree ===");
    DumpVisual(root, 0);
}

static void DumpLogical(DependencyObject d, int depth)
{
    Console.WriteLine($"{new string(' ', depth * 2)}{d.GetType().Name}");
    foreach (var child in LogicalTreeHelper.GetChildren(d))
        if (child is DependencyObject dep)
            DumpLogical(dep, depth + 1);
}

static void DumpVisual(DependencyObject d, int depth)
{
    Console.WriteLine($"{new string(' ', depth * 2)}{d.GetType().Name}");
    for (int i = 0; i < VisualTreeHelper.GetChildrenCount(d); i++)
        DumpVisual(VisualTreeHelper.GetChild(d, i), depth + 1);
}

四、XAML 的本质

XAML 是 WPF 最显眼的特征。但它经常被误解为"另一种语言"。其实它是对象初始化的语法糖

4.1 XAML = XML + 对象初始化

1
2
<!-- XAML -->
<Button Content="Click Me" Width="100" Height="30"/>

等价的 C#:

1
2
3
4
var button = new Button();
button.Content = "Click Me";
button.Width = 100;
button.Height = 30;
1
2
3
4
5
关键事实:
  - XAML 元素 = new 一个对象(类型 = xmlns 映射的 CLR 类型)
  - XAML 属性 = 给对象赋值
  - 嵌套元素 = 父子关系(Content / Children / Properties)
  - XAML 不是必须的,纯 C# 也能写 WPF(只是啰嗦)

4.2 命名空间映射

1
2
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
1
2
3
4
5
6
7
8
http://schemas.microsoft.com/winfx/2006/xaml/presentation
  → 不是真的 URL,是"程序集命名空间的别名"
  → 映射到多个 CLR 命名空间:
    System.Windows、System.Windows.Controls、System.Windows.Media 等
  → 设计如此:让 XAML 不用每次写 xmlns:wb="clr-namespace:System.Windows..."

xmlns:x
  → x: 前缀,专门给 XAML 语言本身用(x:Class、x:Name、x:Key、x:Static)

自定义命名空间:

1
2
3
4
5
6
<!-- 引用自定义 CLR 命名空间 -->
xmlns:local="clr-namespace:MyApp.ViewModels"
xmlns:sys="clr-namespace:System;assembly=mscorlib"

<local:MainViewModel x:Key="vm"/>
<sys:Int32 x:Key="answer">42</sys:Int32>

4.3 属性的两种写法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- 1. 属性特性(Attribute)—— 简单类型 -->
<Button Content="OK" Width="100"/>

<!-- 2. 属性元素(Property Element)—— 复杂类型 -->
<Button Width="100">
  <Button.Content>
    <StackPanel Orientation="Horizontal">
      <Image Source="ok.png" Width="16"/>
      <TextBlock Text="OK"/>
    </StackPanel>
  </Button.Content>
</Button>
1
2
3
4
规则:
  - 字符串/数字 → 直接 Attribute(TypeConverter 自动转换)
  - 复杂对象 → Property Element(外层是类型,内层 Class.Property)
  - 两者的等价关系:Content="OK" ↔ <Button.Content>OK</Button.Content>

4.4 ContentControl 与子元素

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!-- Button 是 ContentControl,子元素就是 Content -->
<Button>
  <TextBlock Text="OK"/>   <!-- 等价于 <Button.Content><TextBlock/></Button.Content> -->
</Button>

<!-- ItemsControl 的子元素是 Items -->
<ListBox>
  <ListBoxItem>Item 1</ListBoxItem>
  <ListBoxItem>Item 2</ListBoxItem>
</ListBox>
1
2
3
4
5
6
7
8
ContentControl 语义:
  - 有且只有一个 Content(可以是任意对象)
  - WPF 自动用 DataTemplate 渲染 Content
  - Button / CheckBox / Label / Window 都是 ContentControl

后端类比:
  ContentControl ≈ 一个"槽位",框架帮你渲染槽位里的对象
  而不是你手动 new TextBlock() 加进去

五、XAML 编译流水线

这是 WPF 学习中最容易被忽略、但理解后受益最大的部分——XAML 到底怎么变成可执行代码的。

5.1 流水线总览

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
源代码                      编译产物
─────────────────────────────────────────────────
MainWindow.xaml            ┐
                           │  ① XAML → BAML(编译)
MainWindow.baml            ┘   (BAML = Binary Application Markup Language)

MainWindow.xaml.cs         ┐
                           │  ② code-behind 编译成 IL
MainWindow.g.cs            ┘   (自动生成的 partial 类)

合并到 MainWindow 类型(partial):
  - 你写的 InitializeComponent override(如果有)
  - 生成的 InitializeComponent(从 g.cs)
  - 生成的 Connect(IComponentConnector)
  - 你写的事件处理、业务逻辑

最终在 .exe / .dll 里:
  - MainWindow.baml 作为资源嵌入(Embedded Resource)
  - MainWindow 类型的 IL

5.2 第一步:XAML → BAML

1
2
3
4
5
6
7
8
BAML 不是二进制 XAML,是"压缩的对象图"
  - 把 XAML 的元素/属性/值编译成一串 token
  - 比 XAML 体积小、加载快
  - 不是 IL,运行时还要解析

BAML 在哪里?
  - 编译后嵌入 assembly(Embedded Resource)
  - 运行时 Application.LoadComponent 读出来

5.3 第二步:生成 g.cs

MainWindow.g.cs 是 XAML 编译器自动生成的 partial 类,它做的事情:

 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
// MainWindow.g.cs(简化,反编译样貌)
public partial class MainWindow : Window, IComponentConnector
{
    // 1. XAML 里所有 x:Name 的元素都成了字段
    internal System.Windows.Controls.Button okButton;
    internal System.Windows.Controls.TextBox userNameBox;

    // 2. 自动生成 InitializeComponent
    public void InitializeComponent()
    {
        if (_contentLoaded) return;
        _contentLoaded = true;
        // 关键:加载 BAML 资源
        System.Uri resourceLocator = new System.Uri(
            "/MyApp;component/mainwindow.baml",
            System.UriKind.Relative);
        System.Windows.Application.LoadComponent(this, resourceLocator);
    }

    // 3. IComponentConnector.Connect:把 XAML 里的元素和事件接到这个对象上
    public void Connect(int connectionId, object target)
    {
        switch (connectionId)
        {
            case 1:
                this.okButton = (System.Windows.Controls.Button)target;
                break;
            case 2:
                this.userNameBox = (System.Windows.Controls.TextBox)target;
                #line 12 "..\\..\\MainWindow.xaml"
                ((System.Windows.Controls.Button)target).Click += this.OkButton_Click;
                #line default
                break;
        }
    }

    private bool _contentLoaded = false;
}

5.4 InitializeComponent 的真相

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 你的 MainWindow.xaml.cs
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();   // ← 这个方法在 g.cs 里
        // 之后才能用 okButton 字段
    }

    private void OkButton_Click(object sender, RoutedEventArgs e) { ... }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
执行顺序:
  1. new MainWindow()
  2. 构造函数调用 InitializeComponent()
  3. InitializeComponent 调 Application.LoadComponent(this, bamlUri)
  4. LoadComponent 解析 BAML,逐个 new 出 XAML 里的元素
  5. 每创建一个元素,调 Connect(connectionId, target) 接到字段
  6. BAML 解析完成,构造函数继续执行
  7. 之后访问 this.okButton 就是有效的

常见错误:
  - 在 InitializeComponent() 之前访问 okButton → NullReferenceException
  - 忘了写 InitializeComponent() → 窗口空白

5.5 x:Name 与 Name 的区别

1
2
3
4
5
6
7
8
<Button x:Name="okButton" Content="OK"/>
<Button Name="okButton" Content="OK"/>   <!-- 等价,FrameworkElement 才有 Name -->

效果:
  - 都会在 g.cs 里生成 internal Button okButton; 字段
  - x:Name 通用(任何元素都行,包括非 FrameworkElement)
  - Name 只 FrameworkElement 有
  - 推荐 x:Name(一致 + 万能)

5.6 为什么 WPF 用 BAML + 反射,而不是直接编译成 IL

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
理论上有"更激进"的方案:
  - 把 XAML 直接编译成 new Button() { Content = "OK" } 这种 IL
  - 加载时零反射,性能更好

WPF 没这么做的原因:
  1. 资源需要在运行时可替换(主题、本地化)
     - BAML 是资源,可以打包成卫星程序集
     - 直接 IL 化就不能运行时替换
  2. 设计器需要解析 XAML/BAML
     - Visual Studio / Blend 要在没编译的情况下显示 UI
  3. 历史包袱(2006 年的决策)

代价:
  - 启动时反射开销(ILemit 优化只解决了一部分)
  - 部分场景下 NativeAOT 受限(详见第 12 篇)

六、MarkupExtension 与 TypeConverter

XAML 里那些 {...} 花括号到底在干什么?

6.1 MarkupExtension:花括号的本质

1
2
3
4
5
<!-- 这些都是 MarkupExtension -->
<Button Content="{Binding UserName}"/>
<Button Background="{StaticResource AccentBrush}"/>
<TextBlock Text="{x:Static sys:Environment.MachineName}"/>
<Grid DataContext="{DynamicResource ViewModel}"/>
1
2
3
4
5
6
7
8
{...} 的本质:
  - XAML 编译器看到 { 就知道要 new 一个 MarkupExtension
  - 然后调用它的 ProvideValue(IServiceProvider) 方法
  - 用返回值作为属性的最终值

后端类比:
  MarkupExtension ≈ "值工厂"
  你声明 "{...}",框架运行时调用工厂拿值
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Binding 的简化实现
public class Binding : MarkupExtension
{
    public string Path { get; set; }
    public object Source { get; set; }

    public override object ProvideValue(IServiceProvider serviceProvider)
    {
        // 解析 Binding 表达式,建立监听
        // 返回 BindingExpression
        return new BindingExpression(this);
    }
}

6.2 自定义 MarkupExtension

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 一个简单的"多语言翻译"扩展
public class TranslateExtension : MarkupExtension
{
    public string Key { get; set; }

    public TranslateExtension(string key) => Key = key;

    public override object ProvideValue(IServiceProvider serviceProvider)
    {
        // 从资源文件查翻译
        return Resources.ResourceManager.GetString(Key) ?? Key;
    }
}
1
2
<TextBlock Text="{Translate HelloWorld}"/>
<!-- 等价于 TextBlock.Text = Resources.HelloWorld -->

6.3 TypeConverter:字符串到对象的转换

1
2
3
4
<Button Background="Red"/>           <!-- "Red" → SolidColorBrush -->
<Button Background="#FF0000"/>       <!-- "#FF0000" → SolidColorBrush -->
<Button Width="100"/>                <!-- "100" → int -->
<Button Margin="10,5,10,5"/>         <!-- "10,5,10,5" → Thickness -->
1
2
3
4
5
6
7
8
TypeConverter 的角色:
  - XAML 属性是字符串,但 C# 属性是强类型
  - "Red" 怎么变成 SolidColorBrush?靠 BrushConverter
  - "100" 怎么变成 double?靠 LengthConverter

机制:
  - 每个类型用 [TypeConverter] 特性声明自己的转换器
  - XAML 解析时查表,找到对应 TypeConverter 调用 ConvertFrom
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 自己实现 TypeConverter(简化)
[TypeConverter(typeof(PointConverter))]
public struct Point { ... }

public class PointConverter : TypeConverter
{
    public override object ConvertFrom(ITypeDescriptorContext ctx, CultureInfo c, object value)
    {
        var s = value as string;
        var parts = s.Split(',');
        return new Point(double.Parse(parts[0]), double.Parse(parts[1]));
    }
}

七、实战:纯 C# vs XAML 对照

同一个登录窗口,两种写法对照,巩固 XAML 心智模型。

7.1 XAML 版

 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
<!-- LoginWindow.xaml -->
<Window x:Class="MyApp.LoginWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Login" Width="320" Height="200"
        WindowStartupLocation="CenterScreen">
    <Grid Margin="16">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <TextBlock Grid.Row="0" Text="用户名" Margin="0,0,0,4"/>
        <TextBox Grid.Row="1" x:Name="userNameBox" Height="28"/>

        <TextBlock Grid.Row="2" Text="密码" Margin="0,12,0,4"/>
        <PasswordBox Grid.Row="3" x:Name="passwordBox" Height="28"/>

        <StackPanel Grid.Row="4" Orientation="Horizontal"
                    HorizontalAlignment="Right" Margin="0,16,0,0">
            <Button Content="取消" Width="80" Margin="0,0,8,0" IsCancel="True"/>
            <Button Content="登录" Width="80" x:Name="loginButton" IsDefault="True"
                    Click="LoginButton_Click"/>
        </StackPanel>
    </Grid>
</Window>

7.2 纯 C# 版(等价)

 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
public class LoginWindowPure : Window
{
    private TextBox userNameBox;
    private PasswordBox passwordBox;

    public LoginWindowPure()
    {
        Title = "Login";
        Width = 320; Height = 200;
        WindowStartupLocation = WindowStartupLocation.CenterScreen;

        var grid = new Grid { Margin = new Thickness(16) };
        grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
        grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
        grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
        grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
        grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });

        var userLabel = new TextBlock { Text = "用户名", Margin = new Thickness(0, 0, 0, 4) };
        Grid.SetRow(userLabel, 0);
        grid.Children.Add(userLabel);

        userNameBox = new TextBox { Height = 28 };
        Grid.SetRow(userNameBox, 1);
        grid.Children.Add(userNameBox);

        var pwdLabel = new TextBlock { Text = "密码", Margin = new Thickness(0, 12, 0, 4) };
        Grid.SetRow(pwdLabel, 2);
        grid.Children.Add(pwdLabel);

        passwordBox = new PasswordBox { Height = 28 };
        Grid.SetRow(passwordBox, 3);
        grid.Children.Add(passwordBox);

        var buttonPanel = new StackPanel
        {
            Orientation = Orientation.Horizontal,
            HorizontalAlignment = HorizontalAlignment.Right,
            Margin = new Thickness(0, 16, 0, 0)
        };
        Grid.SetRow(buttonPanel, 4);

        var cancelButton = new Button { Content = "取消", Width = 80, Margin = new Thickness(0, 0, 8, 0), IsCancel = true };
        var loginButton = new Button { Content = "登录", Width = 80, IsDefault = true };
        loginButton.Click += LoginButton_Click;
        buttonPanel.Children.Add(cancelButton);
        buttonPanel.Children.Add(loginButton);
        grid.Children.Add(buttonPanel);

        Content = grid;
    }

    private void LoginButton_Click(object sender, RoutedEventArgs e)
    {
        MessageBox.Show($"Login: {userNameBox.Text}");
    }
}

7.3 对比观察

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
XAML 版(22 行):
  ✅ 简洁、声明式
  ✅ 结构一目了然
  ✅ 设计器友好(VS/Blend 可视化)
  ✅ 编译时检查(缺 RowDefinition 会警告)
  ❌ 不能写循环/条件(要靠 ItemsControl)

C# 版(55 行):
  ✅ 可写循环/条件
  ✅ 重构友好(重命名、提取方法)
  ❌ 啰嗦(每个属性单独 set)
  ❌ 视觉上看不出布局
  ❌ 设计器不支持

实践建议:
  - 99% 的 UI 用 XAML 写
  - 动态/复杂组合才用 C# 补充
  - ItemsControl + DataTemplate 是处理"循环"的标准武器(详见第 8 篇)

八、WPF 项目结构

8.1 一个标准 WPF 项目的目录

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
MyApp/
├── MyApp.csproj
├── App.xaml                ← 应用入口、全局资源
├── App.xaml.cs
├── MainWindow.xaml         ← 主窗口
├── MainWindow.xaml.cs
├── Views/                  ← 子视图(Window / UserControl)
│   └── LoginView.xaml
├── ViewModels/             ← MVVM 的 ViewModel
│   └── MainViewModel.cs
├── Models/                 ← 业务模型
│   └── User.cs
├── Services/               ← 业务服务(数据访问、API)
│   └── IUserService.cs
├── Styles/                 ← 样式资源
│   └── Buttons.xaml
├── Themes/                 ← 主题(自定义控件)
│   └── Generic.xaml
├── Assets/                 ← 图标、图片
│   └── logo.png
└── Converters/             ← IValueConverter
    └── BoolToVisibilityConverter.cs

8.2 .csproj(.NET 8)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net8.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <UseWPF>true</UseWPF>
    <ImplicitUsings>enable</ImplicitUsings>
    <ApplicationManifest>app.manifest</ApplicationManifest>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2"/>
    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0"/>
  </ItemGroup>

</Project>
1
2
3
4
5
关键点:
  - OutputType=WinExe(不是 Exe,否则会弹出黑色控制台窗口)
  - TargetFramework=net8.0-windows(WPF 必须带 -windows)
  - UseWPF=true(启用 WPF SDK)
  - 8.0 不能跨平台(WPF 只跑 Windows)

8.3 App.xaml 与启动流程

1
2
3
4
5
6
7
8
9
<!-- App.xaml -->
<Application x:Class="MyApp.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             StartupUri="MainWindow.xaml">
    <Application.Resources>
        <!-- 全局资源 -->
    </Application.Resources>
</Application>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// App.xaml.cs
public partial class App : Application
{
    // 默认不需要写 Main,编译器自动生成
    // Main 内部调用 new App().Run()

    // 如果要自定义启动(如 DI、登录流程):
    protected override void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);
        // 初始化 DI、显示登录窗口等
    }
}
1
2
3
4
5
6
7
启动流程:
  1. 编译器自动生成 Main() → new App()
  2. App 构造函数 → InitializeComponent()(加载 App.xaml 的 BAML)
  3. App.Run() → 触发 Startup 事件
  4. StartupUri="MainWindow.xaml" → 自动 new MainWindow() + Show()
  5. Application.Run() 进入消息循环
  6. 主窗口关闭 → Shutdown → 进程退出

九、后端视角总结卡

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
ASP.NET Core 后端工程师看 WPF 的"翻译字典"

ASP.NET Core                  WPF
──────────────────────────────────────────────────
Razor 视图                    XAML
ViewModel (Razor)             DataContext
Tag Helpers                   MarkupExtension
_Layout.cshtml                Window/App.Resources
appsettings.json              App.config / Resources
Middleware 管道               Routed Event 路由
DI Container                  ServiceProvider(同样)
HttpContext                   Dispatcher(线程上下文)
async/await                   async/await(UI 线程行为不同)
SignalR Hub                   WCF 已死,用 gRPC 或 SignalR 客户端
EF Core                       EF Core(同样,桌面也可以用)
日志 Serilog                  Serilog(同样)

十、小结

本文建立了 WPF 的心智模型:

  • WPF 与 WinForms 的根本区别:保留模式 vs 立即模式
  • 架构分层:Framework / Core / WindowsBase / milcore / DirectX
  • 视觉树与逻辑树的两元结构
  • XAML 的本质:对象初始化的语法糖
  • XAML → BAML → g.cs 的编译流水线
  • InitializeComponent 的真相(LoadComponent + Connect)
  • MarkupExtension 与 TypeConverter 的角色
  • 项目结构与启动流程
1
2
3
4
记住三句话:
  1. WPF 是声明式 UI——你描述"树",框架渲染
  2. XAML 不是语言,是"对象初始化的 XML 序列化"
  3. InitializeComponent 是 XAML 和 C# 的缝合点——没它你的字段都是 null

下一篇将进入 WPF 的"地基"——依赖属性系统。这是 WPF 区别于其他 UI 框架的核心机制,理解它之后再看数据绑定、样式、动画都会豁然开朗。

Licensed under CC BY-NC-SA 4.0