页面加载很卡
我的一个应用程序有一个用来管理原材料库的页面,如图 1 所示,这是一个 Pivot 页面,每个 Pivot 项列出一类原材料。整个 Pivot 页面绑到一个 ManageIngredientsViewModel 对象,每个 Pivot 项绑到一个 IngredientGroupViewModel 对象,这些 IngredientGroupViewModel 对象是在运行时根据原材料库的数据创建的。
图 1
目前的做法是在 ManageIngredientsViewModel 的构造函数里通过 LINQ to SQL 加载数据,然后创建相应的 IngredientGroupViewModel 对象,如代码 1 所示。这种同步加载数据的做法很常见,也很直观,不过,如果数据比较多,并且伴随磁盘或者网络的访问,就有可能导致页面加载很卡。
代码 1
我希望异步加载数据,并且只在用户查看某个 Pivot 项时才加载它的数据,这样可以确保页面保持响应,同时又能避免加载多余的数据。在这篇文章里,我们将会以这个应用程序为背景探讨如何通过任务并行库(Task Parallel Library,TPL)实现这些效果。
启动任务
首先,我不希望一开始就加载所有数据,因此把前面的代码1 换成下面的代码2,新的代码负责创建一组空的IngredientGroupViewModel 对象。由于Pivot 控件的ItemsSource 属性和ManageIngredientsViewModel 对象的IngredientGroups 属性绑定,Pivot 控件会自动创建一组空的Pivot 项。
代码 2
接着,为了实现按需加载,我需要知道当前显示的Pivot 项是哪个。这点很容易办到,我们可以让Pivot 控件的SelectedItem 属性和ManageIngredientsViewModel 对象的CurrentIngredientGroup 属性双向绑定,这样的话,每次用户切换Pivot 项时,我们就可以通过CurrentIngredientGroup 属性访问当前显示的Pivot 项对应的IngredientGroupViewModel 对象了。
当CurrentIngredientGroup 属性的值发生改变时,我们将会调用LoadIngredientsAsync 方法加载数据,如代码3 所示。当然,这里不是调用LoadIngredientsAsync 方法唯一选择,你也可以在CurrentIngredientGroup 属性的set 访问器里调用,因为加载数据的代码是异步执行的,所以不必担心对属性的返回造成阻塞。此外,你也可以订阅Pivot 控件的 LoadingPivotItem 或 SelectionChanged 事件,在它的事件处理程序里执行加载数据的代码。
代码 3
当用户第一次切换到某个 Pivot 项时,将会调用 LoadIngredientsAsync 方法加载数据,为了避免阻塞,这个方法会在启动加载数据的任务之后马上返回,任务会以异步的方式执行,此时用户可以自由切换到其他 Pivot 项。当用户从其他 Pivot 项切换回来时,将会再次调用 LoadIngredientsAsync 方法,为了避免重复启动加载数据的任务,我们需要一个布尔字段来表示任务是否已经开始,如代码 4 所示,仅当任务还没开始才会启动任务。
代码 4
启动任务的代码非常简单,如代码 5 所示, StartNew 方法会用我们传给它的 Lambda 创建一个 Task 对象,然后启动并返回它。StartNew 方法的类型参数和 Lambda 的返回值的类型对应,你可以通过 Task 对象的 Result 属性访问这个返回值,访问的时候,如果任务已经完成,将会马上得到结果,如果任务还没完成,将会阻塞当前线程。对于没有返回值的 Lambda,可以使用非泛型的 StartNew 方法创建 Task 对象。
代码 5
值得提醒的是,StartNew 方法不一定马上执行任务,它会对任务进行排期,然后等待空闲的线程来执行。TPL 的 TaskScheduler 支持通过工作窃取实现负载平衡,因此,如果多个线程同时执行任务,先完成的线程会自动分摊其他线程的任务。
延续任务
加载数据完毕之后,我们需要在页面上显示出来。要在一个任务完成之后执行另一个任务,我们可以在第一个任务上调用 ContinueWith 方法,并以 Lambda 的方式向它传递第二个任务,如代码 6 所示。Lambda 的参数是第一个任务,我们可以通过它访问任务的状态和结果。
代码 6
因为 Pivot 项的 ListBox 控件和 IngredientGroupViewModel 对象的 Ingredients 属性绑定,所以我们只需把数据添加到 Ingredients 属性,ListBox 控件就会自动更新了。但是,由于这个任务(间接)涉及到 UI 上的控件,必须切换到 UI 线程上执行,常见的做法是通过 Lambda 包装需要执行的代码,然后交给 Dispatcher 对象的 BeginInvoke 方法执行,如代码 7 所示。
代码 7
TPL 默认在工作线程上排期和执行任务,如果我们想换另一种方式或者另一个地方排期和执行任务,我们可以向 ContinueWith 方法传递其他 TaskScheduler 对象。TaskScheduler 类有一个 FromCurrentSynchronizationContext 静态方法,可以用来获取与当前同步上下文关联的 TaskScheduler 对象。我们在 UI 线程上调用这个方法,获取与 UI 同步上下文关联的 TaskScheduler 对象,再把它传给 ContinueWith 方法,如代码 8 所示,这样就能在 UI 线程上排期和执行这个任务了。
代码 8
ContinueWith 方法是有返回值的,它会返回第二个任务,如果有需要的话,我们可以在第二个任务上调用 ContinueWith 方法创建第三个任务,如此类推,这意味着我们可以通过 ContinueWith 方法创建任意长度的延续链(continuation chain)。
取消任务
当一个任务已经开始但尚未结束时,我们可以取消这个任务。取消一个任务并不像杀掉一个进程这么简单直接,取消任务的过程是一个协同过程,任务的取消可以看作调用方和被调用方达成一致共识的结果,取消任务的标准流程如图 2 所示。接下来,我们将会详细看看每个步骤是如何实现的。
图 2
首先,我们需要创建一个 CancellationTokenSource 对象,并通过它的 Token 属性获取一个 CancellationToken 对象。我们可以把它们声明为私有字段,并在构造函数里初始化,如代码 9 所示。
代码 9
然后,添加一个 _completed 布尔字段,用来标记任务已经完成的状态,并添加一个 CancelLoading 方法,如代码 10 所示。在 CancelLoading 方法里,我们会检查任务是否已经开始但尚未结束,如果是,就调用 CancellationTokenSource 对象的 Cancel 方法发送取消请求。
代码 10
接着,把 LoadIngredientsAsync 方法的代码改成代码 11 所示的那样。这段代码有三个改动,第一个是修改任务的启动条件,并在任务完成的时候设置任务的状态。随着逻辑的发展,可能会出现更多的状态,这个时候,我们可以考虑通过一个枚举字段而不是一组布尔字段组合表示状态。第二个改动是在 foreach 语句里调用 CancellationToken 对象的 ThrowIfCancellationRequested 方法,这个方法会检查调用方是否发送了取消请求,如果是,就抛出 OperationCanceledException 异常取消任务。从这里不难看出,调用方可以发送取消请求,但是否接受请求并取消任务是由被调用方决定,如果被调用方认为任务不宜取消,可以忽略请求并继续执行。最后一个改动是把 CancellationToken 对象传给 ContinueWith 方法,这样做是因为任务不一定马上启动,如果调用方在任务启动之前发送取消请求,TPL 将会直接跳过这个任务,而不必先启动已经取消的任务再调用 ThrowIfCancellationRequested 方法取消任务。
代码 11
在我们的示例里,CancellationTokenSource、CancellationToken 和 Task 这三个对象是一一对应的,但是,这不是必须的,事实上,如果你想同时取消多个任务,可以在多个任务里使用相同的 CancellationToken 对象,这样的话,调用方只需调用一个 CancellationTokenSource 对象的 Cancel 方法就可以取消这些任务了。
异常处理
处理任务抛出的异常非常简单,你只需在 try 块里调用 Wait 方法或者访问 Result 属性,然后在 catch 块里处理 AggregateException 异常就行了,如代码 12 所示。AggregateException 异常有一个 InnerExceptions 属性,你可以通过它访问同时执行的多个任务抛出的一个或多个异常。
代码 12
不过,这种做法并不适用于我们的场景,因为调用 Wait 方法会阻塞当前线程,这正是我们极力避免的。想要避免阻塞,又要确保会在任务出错时执行,我们可以通过 ContinueWith 方法创建一个专门处理异常的任务,如代码 13 所示,TaskContinuationOptions.OnlyOnFaulted 用来指定这个任务只在前面的任务出错时才执行。相应地,我们要把代码 11 的 ContinueWith 方法的 TaskContinuationOptions.None 改为 TaskContinuationOptions.OnlyOnRanToCompletion,确保这个任务只在前面的任务完成时才执行。在传给 ContinueWith 方法的 Lambda 里,我们通过 Exception 属性访问前面的任务抛出的异常,因为它是一个 AggregateException 异常,所以需要通过 InnerExceptions 属性访问实际抛出的异常。
代码 13
细心观察前面的代码,你会发现那条延续链已经演变成一颗延续树了,如图 3 所示。延续链上的每个任务抛出的异常都需要处理,如果不同的异常有不同的处理方式,那么延续树能够提供最大的灵活性,代价是代码的逻辑会因此变得晦涩。
图 3
如果你想统一处理延续链上的多个任务,可以考虑通过 Task.Factory.ContinueWhenAll 方法为它们创建一个处理异常的任务,如代码 14 所示。在处理异常之前,你必须确保 Exception 属性不为 null,因为完成或者取消的任务是没有异常的。
代码 14
如何获取 TPL?
最后一个问题,也是最重要的一个问题,如何获取 TPL?如果你正在使用 Windows Phone SDK 8.0 开发 Windows Phone OS 8.0 的应用程序,那么你只需在代码顶部添加 using System.Threading; 和 using System.Threading.Tasks; 就行了,因为 Windows Phone 8 本身就支持 TPL。
如果你正在开发 Windows Phone OS 7.1 的应用程序,可以通过 NuGet 在 Visual Studio 里添加 TPL 的引用,方法是在 Manage NuGet Packages 对话框里搜索 Microsoft.Bcl,然后安装 BCL Portability Pack for .NET Framework 4, Silverlight 4 and 5, and Windows Phone 7.5 ,如图 4 所示。
图 4
TPL 只适用于托管应用程序,如果你正在使用 C++ 开发 Windows Phone Direct3D 应用程序或者组件 / 类库,你可以考虑并行模式库(Parallel Patterns Library,PPL),详细的用法可以参见《遇见C++ PPL:C++ 的并行和异步》。
感谢贾国清对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。
评论