• 日期:2026-11-04
  • 工程:007-MVVM学习(.NET Framework 4.8 / WPF)
  • 主题:WindowProvider 字典版改造、DataContext 绑定机制、弹窗双向数据传递
  • 难度:入门(假设已会 XAML 基本控件、ICommand 的简单用法)

引子:本篇解决哪几个问题

序号 问题 结论一句话
1 启动报 IOException:找不到资源 mainwindow.xaml StartupUri 写的是文件名,实际文件在 Views 目录里
2 WindowProvider 用 List<Type> 还是 Dictionary 用字典按 key 查,从”遍历比对类名”变成”一次查表”
3 ViewModel 里不能 new 窗口,那怎么弹窗 交给一个”第三方对象”统一创建,VM 只报窗口名字
4 弹窗怎么拿到主窗口的数据、又怎么把结果带回来 传的是”子窗口的 ViewModel 对象”,进出都走同一个对象

一、先修掉那个 IOException

1.1 现象

程序一启动就弹出未处理异常:

1
2
System.IO.IOException: 找不到资源"mainwindow.xaml"。
System.Windows.Application.LoadComponent(Object component, Uri resourceLocator)

调用堆栈里能看到 Baml2006Reader、BamlStream、ContentType 这些名字。只要堆栈里出现 ​​Baml​​ ,就说明问题出在”加载 XAML 编译后的资源”这一步,跟你的业务代码无关。

1.2 原因

App.xaml 里写的是文件名,但文件实际放在 Views 子目录里:

1
2
3
4
5
<!-- 错的 -->
<Application ... StartupUri="MainWindow.xaml"></Application>

<!-- 对的 -->
<Application ... StartupUri="Views/MainWindow.xaml"></Application>

图 1 StartupUri 与编译后资源名的对应关系

1.3 资源名到底是谁决定的

StartupUri 会被 WPF 拼成这样一个相对地址:

1
/程序集名称;component/文件夹路径/文件名.xaml

然后去程序集里找同名的 BAML 资源。文件放在哪个文件夹,资源名里就带哪个文件夹,小写。

打开 obj\Debug\Views\MainWindow.g.i.cs,编译器自动生成的代码已经把答案写出来了:

1
2
3
System.Uri resourceLocater =
new System.Uri("/007-MVVM学习;component/views/mainwindow.xaml", System.UriKind.Relative);
System.Windows.Application.LoadComponent(this, resourceLocater);
文件实际位置 编译后的资源名 StartupUri 应该写
项目根目录 MainWindow.xaml mainwindow.xaml MainWindow.xaml
Views\MainWindow.xaml views/mainwindow.xaml Views/MainWindow.xaml

注意:obj 目录里那些 .g.i.cs、.g.cs 是编译器自动生成的,不要手改,但它是最好的”排错说明书”。以后凡是遇到”找不到资源”,先去这里看一眼真实的资源名。

1.4 另一种更稳妥的写法

删掉 StartupUri,改成代码里显式创建:

1
2
3
4
5
6
7
8
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
new Views.MainWindow().Show();
}
}

好处是路径由编译器检查,写错直接编译不过,不会拖到运行时才炸。


二、WindowProvider:开个窗口为什么要绕这么一圈

2.1 如果不绕这一圈

最直觉的写法是在 ViewModel 里直接:

1
new SubWindow().ShowDialog();   // 看起来最简单

问题在于:ViewModel 从此依赖了 View。 你想给 MainViewModel 写单元测试,就必须连带把整个 WPF 界面跑起来,否则编译都过不了。MVVM 的整个分层意义就被破坏了。

于是加一个”中间人”:ViewModel 只负责说”我要弹一个叫 SubWIndow 的窗口”,具体怎么创建、怎么显示,交给第三方对象。

图 2 MVVM 三层,以及 WindowProvider 的位置

注册发生在 App 启动时(View 层的事):

1
2
3
4
5
6
7
public partial class App : Application
{
public App()
{
WindowProvider.Register<SubWIndow>();
}
}

使用发生在 ViewModel 里(只认字符串,不认类型):

1
WindowProvider.ShowDialog("SubWIndow", subVM);

2.2 字典版比 List 版好在哪

对比项 List<Type> 版 Dictionary<string, Type> 版
查找方式 ForEach 遍历,逐个比 t.Name == winName 哈希直接定位,一次查到
找不到时 静默跳过,没有任何反应,极难排查 抛 KeyNotFoundException,并列出已注册的名字
重复注册 Add 允许重复,同名窗口会被弹多次 索引器赋值,后注册的覆盖先注册的
大小写 == 区分大小写,Subwindow 匹配不上 建字典时传 StringComparer.OrdinalIgnoreCase,不区分大小写
1
2
private static readonly Dictionary<string, Type> types =
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);

2.3 几个关键 API

① ​​TryGetValue​​ ** 而不是 ​​ContainsKey​​ ** + 索引器

1
2
3
4
5
6
7
8
9
10
11
12
// 不推荐:查两次
if (types.ContainsKey(key))
{
Type type = types[key];
}

// 推荐:查一次,同时拿到值
Type type;
if (!types.TryGetValue(key, out type))
{
throw new KeyNotFoundException($"没有注册名为 {key} 的窗口");
}

ContainsKey 之后再取一次,是两次哈希运算;更重要的是,如果中间有别的线程改了这个字典,”判断有”和”真的取”之间可能变化,会导致异常。TryGetValue 是原子的。

② ​​Activator.CreateInstance(type)​

运行时按 Type 创建对象,等价于 new SubWindow(),但类名是变量。它要求目标类有公共无参构造函数——SubWindow 默认就有(你没写构造函数时编译器会送你一个)。

③ ​​where T : Window, new()​​ ** 这对约束**

1
public static void Register<T>(string key = null) where T : Window, new()
  • T : Window:编译期就能保证传进来的一定是窗口,后面 (Window) 强转绝不会失败。
  • new():保证有公共无参构造,Activator 才创建得出来。

代价是:如果某个窗口只写了带参数的构造函数,就不能用这个泛型注册了。这也是为什么窗口类通常保留一个无参构造,数据通过 DataContext 传。

2.4 完整实现

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
public static class WindowProvider
{
private static readonly Dictionary<string, Type> types =
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);

// 注册:不传 key 就用类名
public static void Register<T>(string key = null) where T : Window, new()
{
Register(key ?? typeof(T).Name, typeof(T));
}

public static void Register(string key, Type type)
{
if (string.IsNullOrWhiteSpace(key))
throw new ArgumentException("key 不能为空", nameof(key));
if (type == null)
throw new ArgumentNullException(nameof(type));
if (!typeof(Window).IsAssignableFrom(type))
throw new ArgumentException($"{type.Name} 不是 Window 的子类", nameof(type));

types[key] = type; // 索引器赋值,重复注册是覆盖而非报错
}

// 创建窗口但不显示
public static Window Create(string key)
{
Type type;
if (!types.TryGetValue(key, out type))
throw new KeyNotFoundException($"没有注册名为 \"{key}\" 的窗口。当前已注册:{string.Join(", ", types.Keys)}");

return (Window)Activator.CreateInstance(type);
}

// 显示弹窗,viewModel 会赋给窗口的 DataContext
public static bool? ShowDialog(string key, object viewModel = null, Window owner = null)
{
Window win = Create(key);

if (viewModel != null)
win.DataContext = viewModel; // 数据进入弹窗的唯一开关

Window realOwner = owner
?? (Application.Current != null ? Application.Current.MainWindow : null);
if (realOwner != null && !ReferenceEquals(realOwner, win))
win.Owner = realOwner; // 弹窗跟随主窗口居中、不被遮住

return win.ShowDialog();
}
}

三、DataContext:Binding 到底去哪找属性

这是整个 MVVM 里最容易糊掉的一环,但规则只有一条:

​{Binding X}​​ ** 中的 X,是在当前元素向上找到的 ​​DataContext​​ ** 对象里,找名为 X 的属性。

图 3 Binding 按名字去 DataContext 里找属性

1
2
3
4
5
6
7
<!-- 写法一:在 XAML 里声明,WPF 会自动 new 一个实例 -->
<Window x:Class="_007_MVVM学习.MainWindow"
xmlns:vm="clr-namespace:_007_MVVM学习.ViewModels">
<Window.DataContext>
<vm:MainViewModel />
</Window.DataContext>
</Window>
1
2
3
4
5
6
// 写法二:在 code-behind 里赋值(本工程用的是这种)
public MainWindow()
{
InitializeComponent();
this.DataContext = new MainViewModel();
}

两种写法效果完全一样,DataContext 里放的都是一个 MainViewModel 实例。

绑定生效的完整链条:

  1. MainWindow 的 DataContext 是一个 MainViewModel 实例;
  2. 子元素(TextBox)没有自己的 DataContext,就继承父级的;
  3. {Binding SendText} 于是在 MainViewModel 里找名为 SendText 的公共属性;
  4. 找到就建立通道,两边的值互相同步。

3.1 三个最常见的”绑定没反应”

现象 原因
界面上什么都不显示,也不报错 忘了设 DataContext,Binding 找不到源,失败是静默的
属性名写错了也没提示 同上,WPF 不会因为找不到属性而抛异常,只会在输出窗口打一行绑定错误
界面上有值,代码改了值界面不变 属性没实现 INotifyPropertyChanged(见下一节)

排错技巧:Visual Studio 的「输出」窗口默认会打印 System.Windows.Data Error: 40 : BindingExpression path error ...,这类提示是排查绑定问题最快的线索,别只盯着”异常”窗口。

3.2 本工程用到的几种 Binding 写法

1
2
3
4
5
6
7
8
9
10
11
<!-- 单向:值变化时从源流向界面 -->
<TextBlock Text="{Binding FromMain}" />

<!-- 双向 + 输入即时同步:TextBox 每敲一个字就写回 VM -->
<TextBox Text="{Binding InputText, UpdateSourceTrigger=PropertyChanged}" />

<!-- 带格式:{0} 是值本身 -->
<TextBlock Text="{Binding ResultText, StringFormat='子窗口回传:{0}'}" />

<!-- 绑定命令,而不是属性 -->
<Button Command="{Binding BtnCommand}" />

UpdateSourceTrigger 的三个常用值:

值 什么时候写回源 适用
LostFocus(TextBox 默认) 控件失去焦点时 数据量大、需要校验
PropertyChanged 每输入一个字符 需要实时联动(如搜索框)
Explicit 手动调 UpdateSource() 提交型表单

四、INotifyPropertyChanged:界面为什么会自动刷新

4.1 没有它会怎样

1
public string ResultText { get; set; }        // 普通属性

Binding 建立的是”一次性读取”——只在初始化时把值抄到界面上。之后你在代码里改 ResultText,界面毫不知情。所以必须加一个通知机制:

图 4 属性变化后通知界面的四步

4.2 固定套路,背下来就行

所有 ViewModel 都继承同一个基类,属性统一写成”私有字段 + 属性包装”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;

protected bool SetProperty<T>(ref T field, T value,
[CallerMemberName] string propertyName = null)
{
if (Equals(field, value)) return false; // 值没变就不通知,省性能

field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
return true;
}
}

用起来是这样,注意不能简写成 { get; set; }:

1
2
3
4
5
6
7
8
9
10
public class SubViewModel : ViewModelBase
{
private string inputText;

public string InputText
{
get { return inputText; }
set { SetProperty(ref inputText, value); }
}
}

写法对照:

写法 界面会刷新吗
public string Name { get; set; } 不会
set { name = value; }(去掉通知) 不会
set { SetProperty(ref name, value); } 会

4.3 [CallerMemberName] 是什么

它是编译器提供的一个小便利:调用 SetProperty 时不用手写属性名,编译器会自动把调用处所在的成员名填进去。所以 SetProperty(ref inputText, value) 等价于:

1
SetProperty(ref inputText, value, "InputText");

好处是改属性名时不会漏改字符串。


五、弹窗数据传递:完整的三步

图 5 数据进去、数据回来的完整回路

核心思想一句话:

主窗口和子窗口不直接通信,它们共享同一个 ​​SubViewModel​​ ** 对象。传过去的是这个对象,取回来的也是这个对象。**

5.1 第一步:准备数据

1
2
SubViewModel subVM = new SubViewModel();
subVM.FromMain = SendText; // 把主窗口输入框里的内容塞进去

5.2 第二步:把对象交给弹窗

1
bool? ok = WindowProvider.ShowDialog("SubWIndow", subVM);

WindowProvider 内部只做了一件事,但这一件事是关键:

1
win.DataContext = viewModel;

DataContext 一设,子窗口 XAML 里的 {Binding FromMain} 就自动指向 subVM.FromMain,不需要写任何赋值代码。

5.3 第三步:关窗后取回数据

1
2
3
4
5
6
bool? ok = WindowProvider.ShowDialog("SubWIndow", subVM);

if (ok == true) // 用户点的是"确定"
{
ResultText = subVM.InputText; // 从同一个对象里读出用户在弹窗里输入的值
}

​ShowDialog​​ ** 是阻塞的**:这行代码会把当前方法”卡住”,直到窗口关闭才返回。所以紧接着写的代码天然就是”关窗之后”的逻辑,不需要回调、不需要事件。

5.4 三个文件的完整代码

① ​​ViewModels/SubViewModel.cs​ —— 只负责装数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class SubViewModel : ViewModelBase
{
private string fromMain;
private string inputText;

// 主窗口传进来的,只读来显示
public string FromMain
{
get { return fromMain; }
set { SetProperty(ref fromMain, value); }
}

// 用户在弹窗里输入,关窗后回传
public string InputText
{
get { return inputText; }
set { SetProperty(ref inputText, value); }
}
}

② ​​ViewModels/MainViewModel.cs​ —— 发起调用、接收结果

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
public class MainViewModel : ViewModelBase
{
private string sendText = "你好,我是主窗口的数据";
private string resultText = "(还没有回传)";

public string SendText
{
get { return sendText; }
set { SetProperty(ref sendText, value); }
}

public string ResultText
{
get { return resultText; }
set { SetProperty(ref resultText, value); }
}

public Command BtnCommand { get; set; }

public MainViewModel()
{
BtnCommand = new Command(obj =>
{
SubViewModel subVM = new SubViewModel();
subVM.FromMain = SendText;

bool? ok = WindowProvider.ShowDialog("SubWIndow", subVM);

if (ok == true)
ResultText = subVM.InputText;
});
}
}

③ ​​Views/SubWIndow.xaml​ —— 显示与输入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<Window x:Class="_007_MVVM学习.Views.SubWIndow"
...
WindowStartupLocation="CenterOwner">

<!-- 这里的 DataContext 是 WindowProvider 塞进来的 SubViewModel -->
<StackPanel Margin="24">
<TextBlock Text="主窗口传来的内容:" />
<TextBlock Text="{Binding FromMain}" FontWeight="Bold" Margin="0,4,0,18" />

<TextBlock Text="在这里输入,关窗后带回主窗口:" />
<TextBox Text="{Binding InputText, UpdateSourceTrigger=PropertyChanged}"
Height="28" Margin="0,6,0,0" VerticalContentAlignment="Center" />

<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,20,0,0">
<Button Content="确定" Width="80" Height="30" IsDefault="True" Click="Ok_Click" />
<Button Content="取消" Width="80" Height="30" Margin="10,0,0,0" IsCancel="True" />
</StackPanel>
</StackPanel>
</Window>

配套的 code-behind 只有一行有效代码:

1
2
3
4
private void Ok_Click(object sender, RoutedEventArgs e)
{
DialogResult = true;
}

5.5 两个容易踩的点

注意 1:DataContext 只设一次就够,不要既在 SubWindow 构造函数里写 this.DataContext = new SubViewModel(),又让 WindowProvider 赋一次。后赋值的会覆盖前一个,你会看到”我传进去的值没显示”。

注意 2:子窗口自己不要把 InputText 写回主窗口的属性。回传只走”主窗口关窗后取值”这一条路,方向单一才好排查。


六、DialogResult:判断用户点了确定还是取消

图 6 两个按钮分别走到哪

6.1 两个按钮,靠三个属性管住

属性 作用
IsDefault="True" 按回车等于点这个按钮
IsCancel="True" 按 Esc 等于点这个按钮,并且自动把 DialogResult 设为 false 后关闭
DialogResult = true 只能手写在”确定”按钮的事件里

所以”取消”按钮不需要写任何事件处理,WPF 帮你做完了;”确定”按钮必须手写一行,因为编译器无法猜到你想返回什么。

6.2 ShowDialog 与 Show 的区别

对比项 Show() ShowDialog()
是否阻塞调用方 否,立刻返回 是,关窗才返回
能否设置 DialogResult 不能,设了会抛 ​​InvalidOperationException​ 能
主窗口能否操作 能 不能(模态)
适合场景 非阻塞面板、工具窗口 需要”用户确认”的表单

6.3 为什么不能设完 DialogResult 再调 Close()

设置 DialogResult 会自动关闭窗口。紧接着再调 Close() 时,窗口已经关了,会抛异常。所以正确写法就是只写一行:

1
DialogResult = true;      // 够了,不要再加 Close()

七、项目文件结构与职责

1
2
3
4
5
6
7
8
9
10
11
12
13
14
007-MVVM学习/
├── App.xaml 启动入口,StartupUri 指向 Views/MainWindow.xaml
├── App.xaml.cs 启动时注册所有窗口:Register<SubWIndow>()
├── Base/
│ ├── Command.cs ICommand 的极简实现,把委托包成命令
│ ├── ViewModelBase.cs INotifyPropertyChanged 基类,提供 SetProperty
│ └── WindowProvider.cs 第三方对象:按 key 注册 / 创建 / 显示窗口
├── ViewModels/
│ ├── MainViewModel.cs 主窗口的数据与命令
│ └── SubViewModel.cs 弹窗的数据载体
├── Views/
│ ├── MainWindow.xaml 主窗口界面
│ └── SubWIndow.xaml 弹窗界面
└── Models/ 暂时为空,以后放业务实体
文件 只做一件事
Command.cs 把 Action<object> 包装成 ICommand,让 Button 能绑
ViewModelBase.cs 属性变更通知,让界面能刷新
WindowProvider.cs 把”创建窗口”这件事从 ViewModel 里挪出去
MainViewModel.cs 主窗口的全部状态和交互
SubViewModel.cs 弹窗的全部数据

八、旧式 csproj 的坑:新建 .cs 文件要手工登记

8.1 两种工程格式

对比项 旧式(本工程) SDK 风格(新建工程默认)
文件清单 全部写在 .csproj 里,一个一个列 目录下所有 .cs 自动包含
新建文件 **必须手工加 **​ ​<Compile Include="..." />​ 不用管
典型特征 开头有 <Project ToolsVersion="15.0"> 开头有 <Project Sdk="Microsoft.NET.Sdk">

本工程用的是旧式格式,所以新增 ViewModelBase.cs 和 SubViewModel.cs 时,必须往 .csproj 里补两行:

1
2
<Compile Include="Base\ViewModelBase.cs" />
<Compile Include="ViewModels\SubViewModel.cs" />

8.2 忘记加会怎样

编译报错,但提示往往不是”文件没包含”,而是:

1
错误 CS0246  未能找到类型或命名空间名"ViewModelBase"(是否缺少 using 指令或程序集引用?)

文件明明就在旁边,代码也没写错,报的却是”找不到类型”——这时候第一件事就是去 .csproj 里查有没有登记。

顺手的验证方法:在解决方案资源管理器里,如果某个 .cs 文件没有出现在项目节点下面(或者带了个小箭头图标表示”未包含在项目中”),那就一定漏了。


九、2026 工业场景实战:PLC 上位机的”配方设置”弹窗

9.1 场景

你做的上位机主界面显示当前配方,操作员点「修改配方」弹出对话框,改完点确定,新参数立刻回到主界面并下发给 PLC。当前配方参数:

参数 当前值
主料数量 20
主料起始值 560
辅料起始值 760
配方步进 480
辅料数量 15

9.2 拆解成 MVVM 动作

  1. 主界面 VM 有一个 Recipe 对象,代表当前配方;
  2. 点「修改配方」时,把 Recipe 复制一份交给弹窗 VM(不直接传原对象,避免点取消后原数据被改);
  3. 弹窗关闭后,如果点是确定,就把副本的值写回主 VM,并调用 PlcService.Write() 下发。

9.3 代码骨架

配方数据类(放在 Models):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Recipe
{
public int MainCount { get; set; } // 主料数量
public int MainStart { get; set; } // 主料起始值
public int AuxStart { get; set; } // 辅料起始值
public int Step { get; set; } // 配方步进
public int AuxCount { get; set; } // 辅料数量

public Recipe Clone()
{
return new Recipe
{
MainCount = MainCount,
MainStart = MainStart,
AuxStart = AuxStart,
Step = Step,
AuxCount = AuxCount
};
}
}

弹窗 VM:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class RecipeDialogViewModel : ViewModelBase
{
private Recipe recipe;

// 弹窗里编辑的是副本
public Recipe Recipe
{
get { return recipe; }
set { SetProperty(ref recipe, value); }
}

public RecipeDialogViewModel(Recipe current)
{
Recipe = current.Clone();
}
}

主界面 VM:

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
public class MainViewModel : ViewModelBase
{
private Recipe current = new Recipe
{
MainCount = 20, MainStart = 560, AuxStart = 760, Step = 480, AuxCount = 15
};

public Recipe Current
{
get { return current; }
set { SetProperty(ref current, value); }
}

public Command EditRecipeCommand { get; set; }

public MainViewModel()
{
EditRecipeCommand = new Command(obj =>
{
var dlgVM = new RecipeDialogViewModel(Current);

bool? ok = WindowProvider.ShowDialog("RecipeDialog", dlgVM);

if (ok == true)
{
Current = dlgVM.Recipe; // 界面自动刷新
// PlcService.Write(dlgVM.Recipe); // 真正下发到 PLC
}
});
}
}

弹窗 XAML 的一行示例:

1
<TextBox Text="{Binding Recipe.MainCount, UpdateSourceTrigger=PropertyChanged}" />

Binding 支持逐级点下去:先找 DataContext.Recipe,再取它的 MainCount。

9.4 下一步该补的:输入校验

现在直接绑 int,操作员敲个字母界面不会报错,但值也不会变(WPF 静默忽略转换失败)。工业场景里必须挡住,常见做法有两种:

做法 特点
绑 string,在 SetProperty 里用 int.TryParse 校验 简单直接,适合入门
使用 IDataErrorInfo / INotifyDataErrorInfo 标准做法,能在界面上显示红色错误提示
1
2
3
4
5
6
7
8
9
10
11
12
13
private string mainCountText = "20";

public string MainCountText
{
get { return mainCountText; }
set
{
int n;
if (!int.TryParse(value, out n)) return; // 非法输入直接丢弃
if (n < 0 || n > 99) return; // 加范围限制
SetProperty(ref mainCountText, value);
}
}

十、今天踩过的坑清单

现象 真实原因 处理
IOException:找不到资源 mainwindow.xaml StartupUri 没带 Views/ 前缀 改成 Views/MainWindow.xaml
Window 下面一条绿波浪线 文件缺 using System.Windows; 补 using,或写全名
点按钮完全没反应,也不报错 List 版按类名比对,名字写错就静默跳过 改字典版,找不到就抛异常
弹窗里传进去的值不显示 DataContext 被窗口构造函数里的赋值覆盖 只在一处设 DataContext
代码里改了属性,界面不动 属性是 { get; set; },没有通知 继承 ViewModelBase 用 SetProperty
点了确定但主窗口没收到值 子窗口没设 DialogResult = true 在确定按钮事件里加那一行
编译说”找不到类型 ViewModelBase” 旧式 csproj 没登记新文件 往 .csproj 加 <Compile Include>

十一、面试题

题 1:WindowProvider 属于什么设计模式?它解决了 MVVM 的什么痛点?

解析:

它本质是服务定位器(Service Locator) ,也可看作一种抽象工厂——调用方只提供 key,由它负责实例化并返回。在 MVVM 里,它的作用是切断 ViewModel 对 View 的编译期依赖,让 VM 可以在没有 UI 的环境下被单元测试。更”正统”的替代方案是依赖注入(DI)容器,把 IWindowService 接口注入构造函数,好处是依赖关系显式、可被 Mock 替换;WindowProvider 这种静态类写法胜在简单,适合小项目。

题 2:DataContext 和 Binding 是什么关系?为什么绑定失败不抛异常?

解析:

DataContext 是”数据源”,Binding 是”按路径取值”。WPF 沿着可视化树向上查找最近的 DataContext,然后按 Path 反射取属性;找不到时不会抛异常,因为绑定在 WPF 里被视为”运行时约定”,失败只是让值保持默认,同时向 Trace 输出一条 BindingExpression path error。这种设计让 XAML 可以在设计时没有数据的情况下正常渲染,代价是排错要靠输出窗口。想让错误可见,可以设 PresentationTraceSources.TraceLevel=High 或监听 Validation.Error。

题 3:为什么 Show() 打开的窗口设置 DialogResult 会抛异常?

解析:

DialogResult 的语义是”模态对话框的返回值”,它必须有一个正在等待结果的调用方。Show() 是非阻塞的,调用方早已返回,没有”返回值”的接收者,因此 WPF 规定此时 DialogResult 必须为 null,一旦赋非 null 值就抛 InvalidOperationException。只有 ShowDialog() 会挂起调用栈并等待,才能承载这个返回值——这也是它返回 bool? 的原因。


十二、自测清单

  • 能说清 StartupUri 后面为什么要写文件夹路径
  • 能默写出 ViewModelBase 里的 SetProperty 方法
  • 能解释 {Binding X} 去哪个对象里找 X
  • 能说出字典版比 List 版好在哪三点
  • 能独立写出”主窗口传值 → 弹窗修改 → 回传显示”的三步代码
  • 知道 IsDefault 和 IsCancel 各管什么
  • 新建 .cs 文件后,会去 .csproj 里补 <Compile Include>
  • 知道 ShowDialog 是阻塞的,所以关窗后取值不用写回调

十三、下一步学什么

优先级 内容 为什么
高 ObservableCollection<T> 与 ListBox / DataGrid 绑定 工业上位机里配方列表、报警列表都靠它
高 输入校验 IDataErrorInfo 参数输入必须挡住非法值
中 命令的 CanExecute 与 RaiseCanExecuteChanged 让”确定”按钮在数据不合法时自动置灰
中 依赖注入替代静态 WindowProvider 项目变大后静态类的可测试性问题会暴露
低 DataTemplate 与 ContentControl 做页面切换 多画面切换时替代 TabControl

一句话收尾:MVVM 里真正的”传数据”,从来不是窗口之间传,而是两个 ViewModel 之间共享一个数据对象。窗口只是把这个对象渲染出来的壳子。