WinRT - 在保持UI响应的同时加载数据

6

我正在开发一款Windows Metro应用程序,在处理UI时出现了不响应的问题。据我所知,原因如下:

    <ListView
...
        SelectionChanged="ItemListView_SelectionChanged"            
...

这个事件在这里被处理:

    async void ItemListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        if (this.UsingLogicalPageNavigation()) this.InvalidateVisualState();

        MyDataItem dataItem = e.AddedItems[0] as MyDataItem;
        await LoadMyPage(dataItem);
    }

    private async Task LoadMyPage(MyDataItem dataItem)
    {            
        SyndicationClient client = new SyndicationClient();
        SyndicationFeed feed = await client.RetrieveFeedAsync(new Uri(FEED_URI));                    

        string html = ConvertRSSToHtml(feed)
        myWebView.NavigateToString(html, true);            
    }
LoadMyPage需要一些时间才能完成,因为它从网络服务获取数据并将其加载到屏幕上。然而,UI似乎在等待它完成:我猜测是直到上述事件完成为止。
所以我的问题是:我该怎么办?是否有更好的事件可以连接,或者有其他处理方式?我考虑过启动一个后台任务,但这对我来说似乎有点过度。
编辑:
仅澄清一下这个问题的规模,我说的是最多3-4秒无响应。这绝不是一个长时间运行的任务。
编辑:
我尝试了下面的一些建议,然而,从SelectionChanged函数的整个调用堆栈都在使用async/await。 我已经追踪到了这个语句:
myFeed = await client.RetrieveFeedAsync(uri);

这似乎是一种只有等待完整处理后才会继续处理的情况。

编辑:

我意识到这已经变成了战争与和平,但下面是在空白的 Metro 应用和按钮中复制问题的内容:

XAML:

<Grid Background="{StaticResource ApplicationPageBackgroundThemeBrush}">
    <StackPanel>
        <Button Click="Button_Click_1" Width="200" Height="200">test</Button>
        <TextBlock x:Name="test"/>
    </StackPanel>
</Grid>

代码后台:

    private async void Button_Click_1(object sender, RoutedEventArgs e)
    {
        SyndicationFeed feed = null;

        SyndicationClient client = new SyndicationClient();
        Uri feedUri = new Uri(myUri);

        try
        {
            feed = await client.RetrieveFeedAsync(feedUri);

            foreach (var item in feed.Items)
            {       
                test.Text += item.Summary.Text + Environment.NewLine;                    
            }
        }
        catch
        {
            test.Text += "Connection failed\n";
        }
    }

“Unresponsive”是什么意思?如果您在页面上放置一个按钮并尝试鼠标悬停,是否会看到鼠标悬停的行为?您能与页面进行交互吗?您是否正在尝试在LoadPage期间转换页面?如果RetrieveFeedAsync方法需要一段时间才能完成,它不会锁定UI,但似乎什么也没有发生。 - Shawn Kendrot
屏幕上的控件没有响应。鼠标可以移动,但应用程序的用户界面暂时无法使用。 - Paul Michaels
返回的订阅中有多少项? - Stephen Cleary
在上面的例子中,有50...但是如果我完全删除foreach循环,我会得到相同的行为。 - Paul Michaels
我找不到 LoadMyPage 的代码,请包含它。最好将其简化为一个短但完整的程序,以演示问题。 - Jon Skeet
@Jon Skeet - 我已更新代码以包括LoadMyPage,但底部的程序基本上是一个简化的复制 - 只有一个按钮和对RetrieveFeedAsync的调用。 - Paul Michaels
5个回答

5

试一试这个...

SyndicationFeed feed = null;

SyndicationClient client = new SyndicationClient();

var feedUri = new Uri(myUri);

try {
    var task = client.RetrieveFeedAsync(feedUri).AsTask();

    task.ContinueWith((x) => {
        var result = x.Result;

        Parallel.ForEach(result.Items, item => {
            Dispatcher.RunAsync(Windows.UI.Core.CoreDispatcherPriority.Normal,
            () =>
            {
                test.Text += item.Title.Text;
            });
       });     
   });
}
catch (Exception ex) { }

我在使用Grid应用程序模板为应用程序添加按钮时,在我的设备上进行了尝试。 在不断更新页面标题的同时,我可以轻松地向前后滚动项目网格,虽然我的项目不太多,速度非常快,所以很难百分百确认。


这个可以工作(我省略了并行的 ForEach)- 谢谢。我原本以为 await 的效果和这个一样,那么这个做了什么 await 没有做到的呢? - Paul Michaels
当您使用await时,编译器将自动将异步调用完成后的代码调度到UI线程上。任务在后台中使用以实现此目的,但编译器的魔法将后台处理的机会移回到主线程上。在我的代码中,我们明确控制任务并使用它来继续在其自己的线程上工作,而不是返回到UI线程。根据您循环遍历的数据量以及您希望UI如何更新,您可能希望坚持使用Parallel.ForEach。 - Jeff Brand
你的异常正在泄漏,是吗?这个任务很快就开始了,而继续执行的上下文与你处理异常的上下文不同。 - Henrik

4

由于您在LoadMyPage前使用了await,我假设它已经编译并返回了一个Task。鉴于此,我创建了一个小例子。

让我们假设LoadMyPage(和Sleep())看起来像这样:

public Task<string> LoadMyPage()
{
    return Task<string>.Factory.StartNew(() =>
                                                {
                                                    Sleep(3000);
                                                    return "Hello world";
                                                });
}
static void Sleep(int ms)
{
    new ManualResetEvent(false).WaitOne(ms);
}

以下是 XAML 代码:

<StackPanel>
    <TextBlock x:Name="Result" />
    <ListView x:Name="MyList" SelectionChanged="ItemListView_SelectionChanged">
        <ListViewItem>Test</ListViewItem>
        <ListViewItem>Test2</ListViewItem>
    </ListView>
    <Button>Some Button</Button>
    <Button>Some Button2</Button>
</StackPanel>

我们可以把SelectionChanged事件处理程序改写成这样:
private async void ItemListView_SelectionChanged(object sender,
                                                 SelectionChangedEventArgs e)
{
    MyList.IsEnabled = false;
    var result = await LoadMyPage();

    Result.Text = result;

    MyList.IsEnabled = true;
}
LoadMyPage返回的Task将在并行执行,这意味着当该任务正在运行时,UI不应该冻结。现在要从该Task获取结果,您需要使用await。这将创建一个继续块。
因此,在此示例中,当您选择某些内容时,整个加载时间内都会禁用ListView,而一旦Task完成,则重新启用它。您可以通过按按钮来验证UI没有冻结。
如果LoadMyPage与UI交互,则需要稍微重新安排它,使其返回ViewModel或要返回的结果,然后在UI线程上重新组合所有内容。

-1:这个问题涉及异步操作,而不是长时间运行的项目,并且你不能在WinRT中使用sleep。 - Henrik
@Henrik,请阅读我的回答并再次检查代码示例。 我正在使用 ManualResetEvent(false).WaitOne(ms) 模拟 Sleep。 我不明白我的回答与 async 无关,您能否请澄清一下? - Filip Ekberg
@Henrik,不是吗?Task以异步方式运行,并在ItemListView_SelectionChanged中进行“等待”。 - Filip Ekberg
我可能会这样做,但我认为这与使用回调推迟执行有关的问题不同 - 似乎还有一些遗漏 -- 如果你正在进行CPU密集型工作 - 比如从HDD缓存加载的许多XML解析,就像在原始帖子的例子中一样 - 这样做会并发进行吗?还是会导致UI线程延迟? - Henrik
@Henrik,我认为如果我们把这个问题移到评论之外的地方会更容易一些。我们可以在JabbR上继续讨论。 - Filip Ekberg
显示剩余6条评论

2

我相信你问题所在的是 await 行之外的所有内容,可以看看你简化后的代码:

在下面的代码块中:

private async void Button_Click_1(object sender, RoutedEventArgs e)
    {
        SyndicationFeed feed = null;

        SyndicationClient client = new SyndicationClient();
        Uri feedUri = new Uri(myUri);

        try
        {
            feed = await client.RetrieveFeedAsync(feedUri);

            foreach (var item in feed.Items)
            {       
                test.Text += item.Summary.Text + Environment.NewLine;                    
            }
        }
        catch
        {
            test.Text += "Connection failed\n";
        }
    }

唯一在后台线程上执行的代码是该行:

feed = await client.RetrieveFeedAsync(feedUri);

所有在该块中的其他代码都在UI线程上执行。

仅因为您的按钮单击处理程序标记为async,并不意味着其中的代码不在UI线程上运行。实际上,事件处理程序从UI线程开始。因此,创建SyndicationClient并设置Uri发生在UI线程上。

许多开发人员没有意识到的是,在await之后的任何代码将自动恢复在使用之前的同一线程上。这意味着代码

            foreach (var item in feed.Items)
            {       
                test.Text += item.Summary.Text + Environment.NewLine;                    
            }

正在运行在UI线程上!

这很方便,因为您不必使用Dispatcher.Invoke来更新test.Text,但这也意味着在循环项并连接字符串时,您会一直阻塞UI线程。

在您(尽管简化了)的示例中,将此工作放在后台线程上的最简单方法是在SyndicationClient上再创建一个名为RetrieveFeedAsStringAsync的方法;然后,SyndicationClient可以在其自己的任务中执行下载、循环和字符串连接。完成该任务后,仅有的在UI线程上运行的代码行将是将文本分配给TextBox。


但是如果我完全删除foreach()循环,我会得到相同的行为。 - Paul Michaels

2
一个后台线程绝对不是过度设计。这正是你处理这种问题的方法。
不要在UI线程上执行耗时任务,否则会占用UI并导致其变得无响应。在后台线程上运行它们,然后让该线程在完成时引发一个事件,可以由主UI线程处理。
在UI线程上显示某种进度指示器也很有用。用户想知道“正在发生什么事”。这将向他们保证应用程序没有崩溃或冻结,并且他们愿意等待更长时间。这就是为什么所有Web浏览器都有一些形式的“throbber”或其他加载指示器的原因。

2
最可能的问题是LoadMyPage在执行同步操作。请记住,async不会在后台线程上运行您的代码;默认情况下,它的所有实际代码都将在UI线程上运行(请参见async/await FAQ或我的async/await intro)。因此,如果您在异步方法中阻塞,它仍然会阻塞调用线程。

看一下LoadMyPage。它是否使用await来调用Web服务?它是否在将数据放入UI之前进行了昂贵的处理?它是否压倒了UI(许多Windows控件在达到数千个元素时存在可伸缩性问题)?


最新的编辑中,它所做的最昂贵的事情是调用网络。这需要1到5秒钟的时间,但在此期间会阻塞用户界面。 - Paul Michaels
尝试这个测试:在调用RetrieveFeedAsync之前,将LoadMyPage更改为调用await Task.Run(() => {}).ConfigureAwait(false)。会发生什么? - Stephen Cleary
完全没有任何区别 - Paul Michaels

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接