← 全部文章 WinForm

WinForm 加载界面卡死:UI 线程被堵住了

2022 年 8 月 22 日 · 2 分钟读完

目录
  1. 现象一:启动时的加载窗体不刷新
  2. 原因
  3. 解决:让加载窗体有自己的消息循环
  4. 现象二:Show 之后窗体显示不全
  5. 延伸:根本的办法是别堵住界面线程

两个现象,根因相同:界面线程忙着干别的,没空处理界面消息。

现象一:启动时的加载窗体不刷新

做软件启动画面时,在 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 属性,框架会自动在另一个线程里显示启动画面。

# WinForm # 多线程 # 界面

原文首发于 blog.csdn.net