【.NET core】教程之WinForms 异步编程和 UI 实战

做桌面应用练习时,很多人一开始写按钮事件,习惯就是"点一下 -> 直接执行逻辑 -> 更新界面"。

如果逻辑非常轻,这么写当然没问题;但只要里面出现了耗时操作,比如:

  • 请求接口
  • 读取数据库
  • 扫描文件
  • 导入导出数据
  • 模拟等待 2 秒

界面就很容易出现一个经典问题:窗体卡住,按钮点不动,用户以为程序死了。

所以今天这节练习主题非常重要:异步编程和 UI 配合使用

这篇文章会用 WinForms 的方式,围绕下面几个重点来讲:

  • async/await 基本用法
  • 按钮点击后异步加载模拟数据 2 秒
  • 禁用按钮,防止重复点击
  • 异常处理
  • 正确更新 UI
  • 刷新 ListBoxDataGridView

文章最后我还给你准备了一套很适合今天练习的代码,你可以直接拿去改、拿去敲、拿去练。


一、为什么桌面应用特别要重视异步?

在 WinForms 里,大多数控件都运行在 UI 线程 上。

这意味着:

  • 按钮点击事件在 UI 线程执行
  • 窗体重绘在 UI 线程执行
  • ListBoxDataGridView 的刷新也在 UI 线程执行

如果你在按钮点击事件里直接写一个耗时操作,UI 线程就会被占住。

例如下面这种写法:

csharp 复制代码
private void btnLoad_Click(object sender, EventArgs e)
{
    Thread.Sleep(2000);
    listBox1.Items.Add("加载完成");
}

这段代码的问题非常明显:

  • Thread.Sleep(2000) 会阻塞 UI 线程
  • 这 2 秒里窗体不能及时响应
  • 按钮、窗口拖动、界面刷新都会变差

用户看到的效果通常就是:

"程序像卡住了一样。"

这也是为什么桌面应用里必须学会把耗时操作改成异步。


二、今天这节练习,你到底要掌握什么?

这次练习不要只停留在"会写 await Task.Delay(2000)",真正要掌握的是下面这几个点:

  1. 能把按钮点击事件改成 async
  2. 能在事件里 await 一个异步方法
  3. 知道怎么在加载期间禁用按钮
  4. 知道怎么防止用户重复点击
  5. 知道发生异常时怎么提示用户
  6. 知道异步完成后怎么刷新 ListBoxDataGridView

如果你把这些点练熟,后面写:

  • 接口请求
  • 数据库查询
  • 文件导入导出
  • 后台统计计算

都会顺手很多。


三、先理解 async/await:它到底解决了什么?

很多初学者看到 async/await,会误以为"它让代码变快了"。

其实更准确地说:

它不是让耗时任务本身更快,而是让等待过程不阻塞界面。

比如下面这个异步方法:

csharp 复制代码
private async Task<List<string>> GetMockDataAsync()
{
    await Task.Delay(2000);

    return new List<string>
    {
        "C#",
        ".NET 8",
        "WinForms",
        "async/await",
        "DataGridView"
    };
}

这里的 await Task.Delay(2000) 表示:

  • 模拟一个耗时 2 秒的操作
  • 这 2 秒不阻塞 UI 线程
  • 等待结束后,再继续执行下面的代码

这样用户在等待过程中,界面仍然可以响应。


四、WinForms 按钮点击异步加载数据:先做一个最小可运行版本

先来看今天最核心的基础写法。

1. 场景说明

窗体上有下面几个控件:

  • 一个按钮:btnLoad
  • 一个列表框:listBoxUsers
  • 一个标签:lblStatus

目标效果:

  • 点击按钮后开始加载数据
  • 模拟等待 2 秒
  • 加载完成后刷新 ListBox
  • 加载期间按钮不可重复点击
  • 状态文字实时变化

2. 完整示例

csharp 复制代码
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace AsyncUiDemo;

public partial class Form1 : Form
{
    private bool _isLoading;

    public Form1()
    {
        InitializeComponent();
    }

    private async void btnLoad_Click(object sender, EventArgs e)
    {
        if (_isLoading)
        {
            return;
        }

        try
        {
            _isLoading = true;
            btnLoad.Enabled = false;
            lblStatus.Text = "正在加载数据,请稍候...";

            List<string> data = await GetMockDataAsync();

            listBoxUsers.Items.Clear();
            listBoxUsers.Items.AddRange(data.ToArray());
            lblStatus.Text = $"加载完成,共 {data.Count} 条数据";
        }
        catch (Exception ex)
        {
            lblStatus.Text = "加载失败";
            MessageBox.Show($"发生异常:{ex.Message}", "错误提示");
        }
        finally
        {
            _isLoading = false;
            btnLoad.Enabled = true;
        }
    }

    private async Task<List<string>> GetMockDataAsync()
    {
        await Task.Delay(2000);

        return new List<string>
        {
            "张三",
            "李四",
            "王五",
            "赵六",
            "小杨"
        };
    }
}

五、这段代码里最值得你练的点有哪些?

这段代码看起来不长,但里面其实把今天练习的重点都覆盖到了。

1. 按钮事件为什么是 async void

按钮点击事件本身是事件处理器,WinForms 这里通常写成:

csharp 复制代码
private async void btnLoad_Click(object sender, EventArgs e)

这里用 async void 是因为:

  • 事件处理器的签名就是这样
  • 它不是普通业务方法
  • 真正的异步逻辑最好放到 Task 方法里

也就是说:

  • 事件方法可以是 async void
  • 业务方法尽量写成 async Taskasync Task<T>

这是一个很重要的习惯。

2. 为什么要加 _isLoading

很多人以为只要:

csharp 复制代码
btnLoad.Enabled = false;

就足够防重复点击了。

在大多数情况下确实已经可以,但再加一个 _isLoading 标志会更稳妥,原因是:

  • 更明确表达"当前正在加载"这个状态
  • 即使以后按钮状态控制改了,也还能拦住重复进入
  • 逻辑上更清晰

所以这是一种很常见、也很实用的写法。

3. 为什么把恢复按钮写到 finally

这是今天练习里特别值得记住的一点。

如果你只在成功时恢复按钮:

csharp 复制代码
btnLoad.Enabled = true;

一旦中间抛异常,按钮可能永远都恢复不了。

所以更稳妥的方式是:

csharp 复制代码
finally
{
    _isLoading = false;
    btnLoad.Enabled = true;
}

这样无论成功还是失败,界面状态都能回到正常状态。


六、把 ListBox 刷新理解透:异步结束后再更新 UI

在这段代码里,await 结束后我们直接写:

csharp 复制代码
listBoxUsers.Items.Clear();
listBoxUsers.Items.AddRange(data.ToArray());

为什么这里可以直接操作控件?

因为在 WinForms 中,默认情况下 await 完成后会回到原来的 UI 上下文继续执行,所以你通常可以直接更新界面。

也就是说这个流程其实是:

  1. 点击按钮
  2. 进入异步等待
  3. UI 线程没有被卡死
  4. 等待结束
  5. 回到 UI 线程继续执行
  6. 刷新 ListBox

这就是 async/await 在 UI 编程里最实用的价值。


七、如果你想刷新 DataGridView,应该怎么写?

除了 ListBox,练习里还提到了 DataGridView

这个控件通常更适合展示结构化数据,比如:

  • 编号
  • 姓名
  • 年龄
  • 城市

1. 先定义一个数据模型

csharp 复制代码
namespace AsyncUiDemo;

public class UserInfo
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public int Age { get; set; }
}

2. 模拟异步加载数据

csharp 复制代码
private async Task<List<UserInfo>> GetUserListAsync()
{
    await Task.Delay(2000);

    return new List<UserInfo>
    {
        new UserInfo { Id = 1, Name = "张三", Age = 20 },
        new UserInfo { Id = 2, Name = "李四", Age = 22 },
        new UserInfo { Id = 3, Name = "王五", Age = 21 }
    };
}

3. 点击按钮后绑定到 DataGridView

csharp 复制代码
private async void btnLoadGrid_Click(object sender, EventArgs e)
{
    btnLoadGrid.Enabled = false;
    lblStatus.Text = "正在加载表格数据...";

    try
    {
        List<UserInfo> users = await GetUserListAsync();
        dataGridView1.DataSource = null;
        dataGridView1.DataSource = users;
        lblStatus.Text = $"表格加载完成,共 {users.Count} 条数据";
    }
    catch (Exception ex)
    {
        lblStatus.Text = "表格加载失败";
        MessageBox.Show(ex.Message, "错误提示");
    }
    finally
    {
        btnLoadGrid.Enabled = true;
    }
}

4. 为什么先写 DataSource = null

很多初学者在重新绑定数据时,会遇到"界面没刷新彻底"或者"旧数据还在"的感觉。

所以练习阶段你可以先这么写:

csharp 复制代码
dataGridView1.DataSource = null;
dataGridView1.DataSource = users;

它的好处是更直观、更稳定,特别适合刚开始练习数据绑定时使用。


八、异常处理不能省:异步代码更要会兜底

很多人写练习时,只关注"能不能跑通",容易忽略异常处理。

但真实开发里,只要涉及异步加载,就可能出错,比如:

  • 网络超时
  • 服务返回异常
  • 数据格式错误
  • 空引用问题
  • 控件状态不一致

所以按钮异步事件里,推荐至少保留一个完整的 try-catch-finally 结构:

csharp 复制代码
try
{
    // 异步加载
}
catch (Exception ex)
{
    MessageBox.Show(ex.Message);
}
finally
{
    // 恢复按钮状态
}

这是桌面应用里很实用的一个基本模板。


九、为什么不要在这里继续用 Thread.Sleep

今天这个练习最容易犯的错误之一,就是明明要练异步,还写成:

csharp 复制代码
Thread.Sleep(2000);

你一定要记住:

  • Thread.Sleep 会阻塞当前线程
  • 在 UI 线程里使用,会让界面卡住
  • 这和今天练习目标是反着来的

如果你只是模拟耗时,请优先用:

csharp 复制代码
await Task.Delay(2000);

这才符合异步练习的目的。


十、一个更完整的练习版示例:按钮 + ListBox + DataGridView

如果你今天想一次把练习点串起来,可以做一个稍微完整一点的小例子。

1. 功能目标

窗体上放这些控件:

  • btnLoadList:加载列表数据
  • btnLoadGrid:加载表格数据
  • listBoxCourses
  • dataGridViewUsers
  • lblStatus

点击不同按钮,分别异步加载不同的数据。

2. 示例代码

csharp 复制代码
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace AsyncUiDemo;

public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();
    }

    private async void btnLoadList_Click(object sender, EventArgs e)
    {
        await LoadListAsync();
    }

    private async void btnLoadGrid_Click(object sender, EventArgs e)
    {
        await LoadGridAsync();
    }

    private async Task LoadListAsync()
    {
        btnLoadList.Enabled = false;
        lblStatus.Text = "正在加载课程列表...";

        try
        {
            List<string> courses = await GetCoursesAsync();
            listBoxCourses.Items.Clear();
            listBoxCourses.Items.AddRange(courses.ToArray());
            lblStatus.Text = "课程列表加载完成";
        }
        catch (Exception ex)
        {
            lblStatus.Text = "课程列表加载失败";
            MessageBox.Show(ex.Message, "错误提示");
        }
        finally
        {
            btnLoadList.Enabled = true;
        }
    }

    private async Task LoadGridAsync()
    {
        btnLoadGrid.Enabled = false;
        lblStatus.Text = "正在加载用户表格...";

        try
        {
            List<UserInfo> users = await GetUsersAsync();
            dataGridViewUsers.DataSource = null;
            dataGridViewUsers.DataSource = users;
            lblStatus.Text = "用户表格加载完成";
        }
        catch (Exception ex)
        {
            lblStatus.Text = "用户表格加载失败";
            MessageBox.Show(ex.Message, "错误提示");
        }
        finally
        {
            btnLoadGrid.Enabled = true;
        }
    }

    private async Task<List<string>> GetCoursesAsync()
    {
        await Task.Delay(2000);

        return new List<string>
        {
            "C# 基础",
            ".NET 8 入门",
            "WinForms 控件",
            "异步编程",
            "LINQ 实战"
        };
    }

    private async Task<List<UserInfo>> GetUsersAsync()
    {
        await Task.Delay(2000);

        return new List<UserInfo>
        {
            new UserInfo { Id = 1, Name = "小李", Age = 18 },
            new UserInfo { Id = 2, Name = "小王", Age = 19 },
            new UserInfo { Id = 3, Name = "小张", Age = 20 }
        };
    }
}

public class UserInfo
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public int Age { get; set; }
}

这个版本已经非常接近你今天练习主题的标准答案了。


十一、练这节时最容易踩的坑,我帮你提前列出来

1. 把耗时逻辑写成同步代码

错误思路:

csharp 复制代码
Thread.Sleep(2000);

正确思路:

csharp 复制代码
await Task.Delay(2000);

2. 忘记禁用按钮

如果不禁用按钮,用户连续点几次,就可能触发多个并发加载,界面状态会乱。

3. 忘记在 finally 里恢复按钮

一旦异常发生,按钮可能一直不可用。

4. 业务方法也写成 async void

普通异步方法不要随便写 async void,推荐:

  • async Task
  • async Task<T>

5. 异步完成后忘记刷新控件

比如 ListBox 不清空就直接追加、DataGridView 不重新绑定,就容易造成显示混乱。


十二、你今天可以怎么练,效果最好?

如果你不是只想"看懂",而是真想练会,建议按下面这个顺序来:

第 1 步:先做 ListBox

实现下面功能:

  • 点击按钮
  • 等待 2 秒
  • 加载 5 条字符串到 ListBox
  • 按钮加载期间不可点击

第 2 步:补上异常处理

在异步方法里故意抛一个异常试试:

csharp 复制代码
throw new Exception("模拟加载失败");

然后观察:

  • 是否弹出错误提示
  • 按钮是否恢复可点击
  • 状态文本是否变化正确

第 3 步:再做 DataGridView

把字符串列表换成对象列表,练习数据绑定。

第 4 步:自己加一个"加载中"状态

比如:

  • 标签显示"正在加载..."
  • 加载完成显示"加载完成"
  • 失败显示"加载失败"

这一步能帮助你真正理解 UI 状态切换。


十三、这节练习的核心模板,建议你直接记住

如果把今天的内容浓缩成一个可复用模板,大概就是下面这样:

csharp 复制代码
private async void btnAction_Click(object sender, EventArgs e)
{
    btnAction.Enabled = false;

    try
    {
        lblStatus.Text = "正在处理...";

        var result = await SomeAsyncMethod();

        // 刷新 UI
        lblStatus.Text = "处理完成";
    }
    catch (Exception ex)
    {
        lblStatus.Text = "处理失败";
        MessageBox.Show(ex.Message, "错误提示");
    }
    finally
    {
        btnAction.Enabled = true;
    }
}

这个模板以后你可以复用到很多地方:

  • 加载数据
  • 保存数据
  • 调接口
  • 导入 Excel
  • 导出文件
  • 查询数据库

十四、总结:今天这节你真正要带走的,不只是语法

很多人学 async/await 时,只记住了语法,却没有把它和 UI 编程真正连起来。

但对 WinForms 来说,今天这节真正重要的是下面这几个意识:

  • 耗时操作不要堵住 UI 线程
  • 按钮点击事件可以写成 async
  • 业务异步方法要写成 TaskTask<T>
  • 加载期间要控制按钮状态,防止重复点击
  • 异常要兜底,界面状态要恢复
  • 异步完成后再刷新 ListBoxDataGridView

如果你把这些都练熟,后面不管是做管理系统、工具软件,还是接口调用型桌面程序,代码都会明显更稳。


十五、课后练习

你可以直接按下面 3 个题去练。

练习 1:列表加载

实现一个按钮,点击后等待 2 秒,把 5 门课程加载到 ListBox

要求:

  • 使用 async/await
  • 加载期间按钮禁用
  • 加载完成更新状态文本

练习 2:表格加载

实现一个按钮,点击后等待 2 秒,把 3 个学生对象绑定到 DataGridView

要求:

  • 数据包含 IdNameAge
  • 加载期间按钮禁用
  • 发生异常时弹窗提示

练习 3:模拟失败

让异步方法随机或手动抛异常,验证下面几点:

  • 是否会进入 catch
  • 状态文本是否提示失败
  • 按钮是否最终恢复

十六、结尾

如果你今天的主题是"异步编程和 UI",那最值得反复敲的不是复杂语法,而是这一整套节奏:

点击按钮 -> 进入异步等待 -> 界面不假死 -> 禁止重复点击 -> 完成后刷新控件 -> 出错也能恢复状态

这才是桌面应用里真正实用的异步编程。