在标准的 WPF 生命周期中,Application 对象会在 Run() 方法被调用后自动将第一个 Window 对象设置为 MainWindow。但在实际业务中,我们常遇到这样的需求:
“应用启动时,必须先弹出一个登录窗口(LoginView),校验通过后才能跳转到主窗体(MainView)。”
最直观的想法是在 App.xaml.cs 中重写 OnStartup,然后先 ShowDialog() 登录窗,再 Show() 主窗。然而,这样做会引发一个深层问题:当主窗体尚未显示时,Application.MainWindow 属性值为 null。
如果此时用户关闭登录窗或登录失败,后续的 Application.Run 逻辑依然会尝试显示一个空的主窗体,导致程序要么抛出异常,要么在后台残留一个不可见的僵尸进程。
2. 核心痛点:为什么不能简单地先 ShowDialog 再 Show?
WPF 的 Application.Run 内部维护了一个 调度器主循环。当我们重写 CreateWindow 方法(或自定义 Startup 事件)时,如果简单地进行同步 ShowDialog 调用,会导致线程逻辑上的混乱:
- 模态阻塞:
ShowDialog会启动一个嵌套的消息循环,这会干扰Application.Run预期的初始化顺序。 - MainWindow 的赋值时机:WPF 框架倾向于在
Run内部自动给MainWindow赋值。如果我们手动控制了窗口显示顺序,框架容易因找不到第一个可视窗口而报错,或者导致MainWindow始终为null,从而影响后续的ShutdownMode逻辑。
3. 高阶解决方案:DispatcherFrame 队列帧强制插入
为了解决上述“前置登录”的同步异步冲突问题,我们需要一种机制,既能阻塞 Application.Run 的默认窗口显示逻辑,又能保证模态窗口的正常交互。
答案就是 DispatcherFrame。
DispatcherFrame 本质上是 WPF 调度器中的一个循环。通过 PushFrame,我们主动“压入”一个私有的消息循环。这个循环会接管线程控制权,允许登录窗口 (LoginView) 正常接收鼠标、键盘输入,直到我们手动将 frame.Continue 设为 false 释放这个帧。
此时,Application.Run 的主循环被暂时“挂起”,我们的登录逻辑在一个隔离的帧中运行,完美避免了 MainWindow 为 null 带来的副作用。
protected override Window CreateWindow()
{
var login = Provider.GetService<LoginView>();
var win = Provider.GetService<MainView>();
bool? ok = null;
// ② 手动 PushFrame,让 ShowDialog 不干扰后续 Application.Run
var frame = new DispatcherFrame();
Dispatcher.BeginInvoke(DispatcherPriority.Send, new DispatcherOperationCallback(_ =>
{
ok = login.ShowDialog(); // 模态登录
frame.Continue = false; // 关闭登录窗后退出私有帧
return null;
}), null);
Dispatcher.PushFrame(frame); // 阻塞,直到登录窗关闭
// ③ 登录失败直接结束进程(框架来不及 Show 任何窗)
if (ok != true)
{
ShutdownMode = ShutdownMode.OnExplicitShutdown;
Shutdown(0);
return null; // 框架后续 Show(null) 会抛,但已 Shutdown,进程已退出
}
return win;
}