WinForm 加载界面卡死:UI 线程被堵住了
2022 年 8 月 22 日 · 2 分钟读完
两个现象,根因相同:界面线程忙着干别的,没空处理界面消息。
现象一:启动时的加载窗体不刷新
做软件启动画面时,在 Program.Main 里 new 了一个加载窗体并 Show(),然后开始初始化。结果窗体显示出来了,里面的进度条和文字却不跟着变,甚至直接卡死。
正常应该是这样:

三种更新方式都不行:
用窗体里自己写的方法设置文本:

用窗体里的 Timer 定时刷新文本:

用窗体里自己开的线程循环刷新文本:

出问题的 Main:
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Global_Value.G_LoadingFrm = new FrmLoading();
Global_Value.G_LoadingFrm.Show();
Global_Method.Ini(); // 耗时的初始化
Global_Value.G_LoadingFrm.SetPercentVal(90, "初始化界面");
Global_Value.G_MainFrm = new FrmMain();
Global_Value.G_LoadingFrm.SetPercentVal(100, "加载完成");
Global_Value.G_LoadingFrm.Close();
Application.Run(Global_Value.G_MainFrm); // 消息循环到这里才开始
}
原因
WinForm 的界面靠消息循环驱动:重绘、Timer.Tick、Invoke 投递过来的委托,都是排在消息队列里的消息,由消息循环一条条取出来处理。消息循环是 Application.Run 启动的。
上面的代码里,Application.Run 在最后一行,初始化期间消息循环根本没在跑:
Timer的 Tick 是一条消息,没人处理,所以不触发;- 改了
Label.Text需要重绘,重绘也是消息,所以界面不变; - 后台线程
Invoke过来的委托同样排在队列里等着,Invoke会一直阻塞,看起来就是卡死。
解决:让加载窗体有自己的消息循环
把加载窗体放到一个单独的线程里,用 Application.Run 给它开一个消息循环;主线程照常做初始化:
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
var loadingThread = new Thread(() =>
{
Global_Value.G_LoadingFrm = new FrmLoading();
Application.Run(Global_Value.G_LoadingFrm); // 加载窗体自己的消息循环
});
loadingThread.SetApartmentState(ApartmentState.STA); // 界面线程要求 STA
loadingThread.Start();
while (Global_Value.G_LoadingFrm == null || !Global_Value.G_LoadingFrm.Visible)
Thread.Sleep(100); // 等加载窗体显示出来
Global_Method.Ini();
Global_Value.G_LoadingFrm.SetPercentVal(90, "初始化界面");
Global_Value.G_MainFrm = new FrmMain();
Global_Value.G_LoadingFrm.SetPercentVal(100, "加载完成");
loadingThread.Join(); // 等加载窗体自己关掉
Application.Run(Global_Value.G_MainFrm);
}
加载窗体:
public partial class FrmLoading : Form
{
public FrmLoading()
{
InitializeComponent();
StartPosition = FormStartPosition.CenterScreen;
}
// 被主线程调用,所以要 Invoke 到加载窗体自己的线程
public void SetPercentVal(int val, string message)
{
Invoke(new Action(() =>
{
pgb_1.Value = val;
lb_Percent.Text = val + "%";
lb_Message.Text = message;
}));
}
private void FrmLoading_Load(object sender, EventArgs e) => timer1.Enabled = true;
private void timer1_Tick(object sender, EventArgs e)
{
if (pgb_1.Value == 100) Close(); // Tick 本来就在窗体线程上,直接 Close
}
}
原文的线程没有设置 STA。界面线程应该是 STA,否则用到剪贴板、拖放、文件对话框等 COM 相关功能时会出错。
现象二:Show 之后窗体显示不全
参考:lchaxwy 的文章
软件关闭时有个很耗时的操作,想先弹个提示框告诉用户「正在关闭,请稍候」,结果提示框弹出来了,内容却是空白的,直到耗时操作结束才显示完整:

var frm = new FrmTest { TopMost = true, StartPosition = FormStartPosition.CenterScreen };
frm.Show();
Thread.Sleep(10 * 1000); // 模拟耗时操作
原因一样:Show() 只是发出「请显示、请绘制」的消息,界面线程紧接着就去 Sleep 了,没机会处理这些绘制消息。
转载文章给的办法是 Show() 之后调用 Refresh(),它会让控件立即同步重绘自己和子控件:
frm.Show();
frm.Refresh(); // 立即重绘
Thread.Sleep(10 * 1000);

这能让提示框画出来,但耗时操作期间窗口依然无响应(拖不动、点不了、可能被系统标记为「未响应」)。
延伸:根本的办法是别堵住界面线程
- 耗时操作放到后台:按钮事件写成
async void,耗时部分await Task.Run(...),界面线程全程空闲,提示框、进度条自然正常刷新。
private async void btnClose_Click(object sender, EventArgs e)
{
var tip = new FrmTest();
tip.Show();
await Task.Run(() => SaveAndDisconnect()); // 后台执行,界面不卡
tip.Close();
Close();
}
- 进度回报用
IProgress<T>:var progress = new Progress<int>(v => pgb_1.Value = v);传给后台方法,后台里progress.Report(50),回调自动回到界面线程执行,不用自己写Invoke。 Application.DoEvents()能救急但别常用:它会在当前位置处理一遍消息队列,界面能动了,但也可能在你没预料的时候触发别的事件(比如用户又点了一次按钮),造成重入问题。- 启动画面的另一种做法:.NET Framework 可以借用 VB 的
WindowsFormsApplicationBase,设置SplashScreen属性,框架会自动在另一个线程里显示启动画面。
原文首发于 blog.csdn.net