自从.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; });
现在就全部搞定了!逼格也保住了!