自从.NET 4.5开始,C#多了一对新的异步关键词 async 和 await,如果不了解的朋友可以简单的看下下面的示意图。

简单的说,就是在通常情况下,用户在界面上进行的操作,比如点击一个按钮之后,如果进行大量的计算,或者读写文件、网络请求等耗时的操作,那么程序的界面就会卡住,在这段时间里,任何交互都不会响应,直到后台的代码执行完毕才会继续响应用户操作。

这种现象和你的应用是不是UWP没有什么卵关系,WinForm,WPF,Windows Phone SL/RT应用都会有这个问题。

这个问题的原因是因为这些耗时的操作和界面(UI线程)没有关系,但却在UI线程上执行了。解决办法就是另起一个线程,让它在UI线程之外去执行代码,这样就不会锁死UI。就像这图:

在.NET 4.5以前,要进行一个异步操作写法有点繁琐,.NET 4.5以后。我们只要把一个方法的签名声明为async Task,就可以直接变成异步方法。然后被调用者await。在UI线程上await后台操作,是不会卡住界面的。当然,前提是这个方法体内本身有异步API。不清楚的朋友可以去MSDN看看async await的介绍:

使用 Async 和 Await 的异步编程(C# 和 Visual Basic)

好了,话题回到UWP应用,今天我无聊的时候写了个算24点解法的应用:

问题是后台计算24点的解法需要几秒钟时间,用户点击“求解”按钮以后,界面就卡住,直到计算完毕。

原先的代码如下:

...
public ObservableCollection<string> ResultList
{
    get { return _resultList; }
    set { _resultList = value; RaisePropertyChanged(); }
}

private void DoCalc()
{
    ResultList.Clear();

    // ... 各种计算逻辑

    foreach (一个神器的循环)
    {
	ResultList.Add($"{expression} = {value}");
    }
}
...

这里的蛋疼之处在于,DoCalc()方法里没有任何.NET已提供的async API,没有await的机会。如果是.NET自带的异步API,比如写文件和网络请求(.NET Http Client),就可以天生被await。

.NET里处理这种情况,用的是Task.Run(),它可以接受一个委托,然后起一个新线程去执行这个委托。由于返回类型是Task,所以可以被await。

public static Task Run(Action action);

所以DoCalc的方法签名就可以改成异步的了:

private async Task DoCalc()

然后里面塞个Task.Run(),然后把计算逻辑包在委托里面

private void DoCalc()
{
    ResultList.Clear();

	await Task.Run(()=>
	{
		// ... 各种计算逻辑

		foreach (一个神器的循环)
		{
			ResultList.Add($"{expression} = {value}");
		}
	});
}

但是这样是会爆炸的!原因是ResultList还在主线程(UI线程)上,Task新起的线程会跨线程访问这个ResultList变量,就会爆炸。

所以我们的思路是让这个计算逻辑带返回值,算完以后把结果扔回UI线程,再给ResultList赋值,而不是直接更改ResultList。

所以我们要把Action委托改成Func委托:

Func<List<string>> funcCalc = () =>
{
    var tempList = new List<string>();

    ...

    foreach (...)
    {
        tempList.Add($"{expression} = {value}");
    }

    return tempList;
};

然后这时候用Task.Run()的一个重载方法去执行,就能得到返回值:

public static Task<TResult> Run<TResult>(Func<TResult> function);

然后再给UI线程上的ResultList赋值:

var list = await Task.Run(funcCalc);
ResultList = list.ToObservableCollection();

为了让交互更加友好,可以在界面里加个Progress Ring显示忙操作的状态:

<ProgressRing Height="50" Width="50" IsActive="{Binding IsBusy}" />

异步方法如下:

private async Task DoCalc()
{
    IsBusy = true;
    ResultList.Clear();

    Func<List<string>> funcCalc = () =>
    {
        var tempList = new List<string>();
		...
        foreach (...)
        {
            tempList.Add($"{expression} = {value}");
        }

        return tempList;
    };

    var list = await Task.Run(funcCalc);
    ResultList = list.ToObservableCollection();

    IsBusy = false;
}

最后要说的是,这种方法只针对由于大量CPU计算、磁盘IO和网络请求等非界面的逻辑造成的卡死,如果你的XAML写的像翔一样的,那么很有可能界面卡死是因为数据绑定、长列表没做虚拟化等等,那就另当别论啦~

这还没完呢。。昨天经过老司机 @JustinAndDesign 的指正,Task.Run()的正确姿势有两个重要的原则:

1. Task.Run() 只用于大量CPU计算的场景,不用于磁盘IO。在我这个场景中,恰好就是CPU计算,所以还是可以用。

2. 不要在方法体内使用Task.Run(),而应该把这部分职责交给调用方法的地方。在这个场景里是用户在界面上点击的按钮,所以代码还是得改一下。

虽然在我这个场景下,改不改并不影响程序的表现,但是做人最重要的就是逼格,所以我觉得有责任把这个逼装完。

具体可以参考两篇文章:

http://blog.stephencleary.com/2013/10/taskrun-etiquette-and-proper-usage.html

http://blog.stephencleary.com/2013/11/taskrun-etiquette-examples-dont-use.html

然后要把这个异步的逼装完,还需要把DoCalc()方法改回同步的:

private List<string> DoCalc()
{
    var tempList = new List<string>();
	
	... 各种耗CPU的神奇的计算

    return tempList;
}

然后把Task.Run()的逼装到按钮对应的Command里:

// Only use Task.Run() on CPU bound work. 
// Do not use Task.Run in the implementation of the method; instead, use Task.Run to call the method.
// http://blog.stephencleary.com/2013/10/taskrun-etiquette-and-proper-usage.html
// http://blog.stephencleary.com/2013/11/taskrun-etiquette-examples-dont-use.html
CommandCalc = new RelayCommand(async () =>
{
    IsBusy = true;
    ResultList.Clear();

    var list = await Task.Run(() => DoCalc());
    ResultList = list.ToObservableCollection();

    IsBusy = false;
});

现在就全部搞定了!逼格也保住了!