WPF中进度条

问题:为什么我的进度条不更新?

不知道有没有朋友在使用WPF时第一次接触ProgressBar遇到和我一样的困惑。我现在要做一个长时间执行的任务,我希望我的应用界面可以有一个进度条反映我的长时间执行的任务进度。

我们知道ProgressBar.Value决定了当前ProgressBar,进度条从0到100之间的一个整数,所以,最自然的事情当然是在做长时间执行的任务的时候,把每个step的数值update到ProgressBar.Value就应该可以更新到ProgressBar了。

这感觉应该是一个很常见的技巧吧,所以应该比较直观吧?

但是非常反直觉的事情是,这样做并不行。

例1:直接盘它!

为了展示我所说的(也是新手可能会遇到的)问题,我这里做一个demo:

WPF中进度条

然后在MainWindow.xaml里,创建一个进度条:

<Window x:Class="Zhihu_Demo_ProgressBar1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Zhihu_Demo_ProgressBar1"
        mc:Ignorable="d"
        Title="Demo Zhihu Progress Bar" Height="450" Width="800">
    <Grid Margin="10">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="7*"/>
            <ColumnDefinition Width="3*"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="40"/>
            <RowDefinition Height="40"/>
            <RowDefinition Height="40"/>
            <RowDefinition Height="40"/>
            <RowDefinition Height="40"/>
            <RowDefinition Height="40"/>
        </Grid.RowDefinitions>
        <ProgressBar x:Name="pbNormal" Grid.Row="0" Grid.Column="0"
                     Margin="5"
                     Value="33"/>
        <Button x:Name="btNormal" Grid.Row="0" Grid.Column="1"
                Margin="5" Click="OnbtNormalClicked">Grow Progress - Normal</Button>
                

    </Grid>

</Window>

在MainWindow.xaml.cs里的MainWindow中建立事件:

        private void OnbtNormalClicked(object sender, RoutedEventArgs e)
        {
            for (int i = 0; i <= 100; i++)
            {
                Thread.Sleep(34);
                pbNormal.Value = i;
            }
        }

其实就是0到100循环,每步34毫秒。

运行后,点击按钮测试。

整个应用窗口卡死,然后突然之间进度条变成100之后,卡死解除。

WPF中进度条

这是啥玩意?!首先是应用窗口卡死,意味着你直到长时间任务跑完之前都暂时不能和它互动;再一个是非零即百的进度条完全没有起到『报告任务进度』这个作用。我要你有何用?!

解决思路:异步处理

我们知道,在开始应用之后,整个MainWindow对象上所有控件都在一个线程里,而背后的逻辑则是另外的一些线程。像我刚才那样采用非异步处理的方式去更新进度条的值,在这个应用看来,就是应用的控件——包括这个进度条——的线程在跑,然后,他们全部都等长时间执行任务(也就是例子里面的这个for循环)跑完,然后再去取得了它的值,也就是100。

那么我们来弄个异步处理不就好了吗?

例2:用异步处理直接盘它!

新建下一个进度条和按键,注意这里我新建了按键的style,失败的按键就标记出来:

<Window x:Class="Zhihu_Demo_ProgressBar1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Zhihu_Demo_ProgressBar1"
        mc:Ignorable="d"
        Title="Demo Zhihu Progress Bar" Height="450" Width="800">
    <Window.Resources>
        <Style x:Key="ButtonBaseStyle"
               TargetType="{x:Type Button}">
            <Setter Property="Margin" Value="5"/>
        </Style>
               
        <Style x:Key="ButtonFailedStyle" 
               TargetType="{x:Type Button}"
               BasedOn="{StaticResource ButtonBaseStyle}">
            <Setter Property="Background" Value="OrangeRed"/>
            <Setter Property="BorderBrush" Value="DarkRed"/>
            <Setter Property="Foreground" Value="LightGray"/>
            
        </Style>
    </Window.Resources>
    <Grid Margin="10">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="7*"/>
            <ColumnDefinition Width="3*"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="40"/>
            <RowDefinition Height="40"/>
            <RowDefinition Height="40"/>
            <RowDefinition Height="40"/>
            <RowDefinition Height="40"/>
            <RowDefinition Height="40"/>
        </Grid.RowDefinitions>
        <ProgressBar x:Name="pbNormal" Grid.Row="0" Grid.Column="0"
                     Margin="5"
                     Value="33"/>
        <Button x:Name="btNormal" Grid.Row="0" Grid.Column="1"
                Click="OnbtNormalClicked"
                Style="{StaticResource ButtonFailedStyle}">
            Grow Progress - Normal
        </Button>

        <ProgressBar x:Name="pbAsyncNormal" Grid.Row="1" Grid.Column="0"
                     Margin="5"
                     Value="33"/>
        <Button x:Name="btAsyncNormal" Grid.Row="1" Grid.Column="1"
                Click="OnbtAsyncNormalClicked"
                Style="{StaticResource ButtonBaseStyle}">
            Grow Progress - Async Normal
        </Button>

    </Grid>

</Window>

接下来就不写xaml的内容了,反正每一对控件Grid.Row里面去每次加一推进,全部的代码会展示再后面。

然后是逻辑层:

        private async void OnbtAsyncNormalClicked(object sender, RoutedEventArgs e)
        {
            Action growProgress = () =>
            {
                for (int i = 0; i <= 100; i++)
                {
                    Thread.Sleep(34);
                    pbAsyncNormal.Value = i;
                }
            };
            await Task.Run(growProgress);
        }

异步处理,有两点要改,一个是这个方法前面要加async关键词,还有一个是里面需要有await。

这里是新建了一个Action对象,然后用await Task.Run(Action())去跑它,Action内容就是我们刚才的循环的代码,一模一样的,作为void类型写进去了。

然后编译,点击按键。

呵呵,这次直接报错了……

WPF中进度条

错误信息:

System.InvalidOperationException: 'The calling thread cannot access this object because a different thread owns it.'

简单来说就是,你想要从一个进程(我们的异步task)去呼叫另外一个进程(MainWindow.ProgressBar),这件事情是不可能的。

也打上失败的标记吧。

目前为止就是我一开始遇到的问题

这个进度条我不是第一次用。在WinForm的时候我就使用过进度条,但是绝对没有WPF里面这么难用。其实这也就是怎么去理解多线程的应用和单线程应用的区别。在WinForm里面没有特殊情况的时候,模板会给你打上STP的标签,也就是强制单线程。但是WPF就可以*使用多线程,与单线程共存的这样一种情况。

那么怎么去解决呢?我搜集了两种方法:1)首先是利用System.Progress来报告它的进度;2)利用CLR属性绑定Notifiy UI的方法,把进度条的值绑定在我们CLR里,然后加一个通知更新的事件。

当然方法还有很多,不仅仅局限于这两种。需要注意的是,这些方法无一例外都是需要异步处理的。如果你没有使用异步处理,那么结局就会像例1一样,卡死,然后更新到100%这样的结果。

例3:利用System.Progress间接报告进程

先造下一排的进度条和按键控制。然后逻辑层先展示一下。

        private async void OnbtAsyncProgressClicked(object sender, RoutedEventArgs e)
        {
            //新建一个Action的委托,内容是把值赋给我们的进度条上。
            Action<int> bindProgress = 
                value => pbAsyncProgress.Value = value;
            //新建一个Progress对象,把Action设定为刚才的action,这里需要注意的是
            //我们为了简化,把这个progress对象cast成了它的Interface,这是因为只有
            //IProgress才有 .report方法,而我们在这里需要用到这个方法。
            //
            //这个也可以新建为Progress或者索性var,然后再之后的引用里面写为
            //  ((IProgress<int>)progress).Report(i)
            //反正早晚都是需要cast之后才能使用
            IProgress<int> progress = new Progress<int>(bindProgress);

            //好了,现在这个从Progress报告到赋值给进度条的绑定就做好了,
            //和我们的例2一样,建立Action,然后异步跑它
            Action growProgress =
                () =>
                {
                    for (int i = 0; i <= 100; i++)
                    {
                        Thread.Sleep(34);
                        progress.Report(i);
                    }
                };
            await Task.Run(growProgress);
        }

大致就是这样,细节都再注释里。然后运行:

WPF中进度条

成功!!!!

这个方法是开始创造时相对简单,但是引用相对繁琐,我们把它放到一个实例的场景里面,就会发现,我们需要把Action做成一个带Action<int>去引用。在Task里面我们也需要不断地去报告这样一个流程,这意味着我们所有需要用到这个进度条的地方,都必须重新去把它当作某个arg给加进去。

反正暂时是成功了。

例4:属性绑定和通知UI

啥是属性绑定和通知UI呢?其实这个的运用很广泛,在教科书里有写如何操作这个,简单来说就是把xaml里面的一个对象的属性值,绑定到我们的逻辑层xaml.cs代码里面的某个属性。然后呢,利用System.ComponentModel.INotifyPropertyChanged去通知UI:

时代变了。

教科书的例子是利用这个机制去清空一个textbox,当我们点击清空的时候,textbox绑定的属性会被清空,但是textbox本身的值不会被清空;在加入INotifyPropertyChanged自带的事件机制之后,点击清空,textbox也随之清空。

那么我就觉得这么厉害的机制只能这么用吗?能不能把我们的进度条的value属性绑定给某个CRL属性,然后建立通知的事件处理,来通知进度条呢?这样一来,我们只需要不断对我们的CRL属性进行跟新,进度条就会随之变化?

这么一想真是美滋滋。

答案是:

完全可以啊!

首先我们来绑定属性,也就是从UI绑定到CLR,找到你的新的进度条,然后

        <ProgressBar x:Name="pbAsyncNotify" Grid.Row="3" Grid.Column="0"
                     Margin="5"
                     Value="33"/>

看到Value="33"了吗,这个是把它的值设置为一个整数。我们不喜欢这样,我们喜欢可变的变量。

把它改为

...
                     Value="{Binding CurrentProgress, ElementName=mywindow, Mode=OneWay}"/>
...

注意这里是什么用法,我们在这里把一个控件的某个属性,等价于了CLR中的某个方法,我们需要在Binding后面写出我们希望捆绑的CRL方法名,在ElementName后面写出了我们的元素名,Mode后面写出模式,这里只需要OneWay就行了,意思是从CLR到UI控件单向,因为我们只需要用到CRL里面的CurrentProgress属性让我们的进度条去被动地接收更新。

为了让我们的元素名有效,在<window></window>中加入x:Name="mywindow"

<Window x:Class="Zhihu_Demo_ProgressBar1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Zhihu_Demo_ProgressBar1"
        mc:Ignorable="d"
        x:Name="mywindow"
        Title="Demo Zhihu Progress Bar" Height="450" Width="800">
...
</Window>

这里就完成了UI侧的绑定了。接下来绑定CLR的对应属性。

在MainWindows.xaml.cs内容前面加入一个新的库:System.ComponentModel,

然后再在MainWindow 后面加一个继承。我们看到它已经继承了Window,那么这里加一个逗号隔开,加一个INotifyPropertyChanged的Interface。

刚加完,我们会看到它有红线。

WPF中进度条

自动fix一下让我们的MainWindow应用这个Interface,生成我们的event:

public event PropertyChangedEventHandler PropertyChanged;

这个事件可以绑定到CLR里面的某个属性上,然后通知UI去更新这个属性。

在MainWindow类里建立一个field以及一个方法,名字和我们先前UI绑定的一致:

        private int currentProgress = 0;
        public int CurrentProgress
        {
            get { return currentProgress; }
            set
            {
                currentProgress = value;
            }
        }

这是一个简单的更新field的方法。然后,我们去找到刚才生成的事件,新建一个void方法,其实也就是去引发我们所有引用这个属性的Task去通知我们的UI需要更新了。

        public event PropertyChangedEventHandler PropertyChanged;
        public void OnPropertyChanged(string PropertyName)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(PropertyName));
        }

然后,在我们绑定好的方法CurrentProgress中,加入这个引发事件的void方法:

        public int CurrentProgress
        {
            get { return currentProgress; }
            set
            {
                currentProgress = value;
                OnPropertyChanged("CurrentProgress")
            }
        }

这样一个通知就绑定好了。绑定好了之后,所有只要是通过CurrentProgress去更新currentProgress的引用,都会通知到这个进度条,让它的value随之变化。然后我们就很简单了,所有需要更新当前进度的地方,直接引用这个CurrentProgress方法就行了。

怎么样,抛开那一大堆event的繁杂的东西,单单看下面一串代码是不是感觉很像我们先前失败的例2啊?

        private async void OnbtAsyncNotifyClicked(object sender, RoutedEventArgs e)
        {
            Action growProgress =
                () =>
                {
                    for (int i = 0; i <= 100; i++)
                    {
                        Thread.Sleep(34);
                        CurrentProgress = i;
                    }
                };
            await Task.Run(growProgress);
        }

编译,执行,成功。

WPF进度条入门实例1

后记,反思,扩展

这个面向新手的教学就先到这里。不正确的地方希望大家积极指出。由于本人也只是个初学者,在这里的所有东西都只是作为一种推荐。如果有更加好的方法欢迎大家提出、讨论。

首先要指出的是这些例子都有局限性。如果你多次点击按钮,那么你的进度条里会出现两条进度闪烁的这样的情况,这也意味着随着用户的点击,新的线程在被不停地加进来。我能想到的解决方法就是在线程启动之后,暂时把按键给禁用掉,然后用等待去开启它。这个只是提供一个解决这个bug的思路。

以下是文章中的所有代码

MainWindow.xaml

<Window x:Class="Zhihu_Demo_ProgressBar1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Zhihu_Demo_ProgressBar1"
        mc:Ignorable="d"
        x:Name="mywindow"
        Title="Demo Zhihu Progress Bar" Height="450" Width="800">
    <Window.Resources>
        <Style x:Key="ButtonBaseStyle"
               TargetType="{x:Type Button}">
            <Setter Property="Margin" Value="5"/>
        </Style>
               
        <Style x:Key="ButtonFailedStyle" 
               TargetType="{x:Type Button}"
               BasedOn="{StaticResource ButtonBaseStyle}">
            <Setter Property="Background" Value="OrangeRed"/>
            <Setter Property="BorderBrush" Value="DarkRed"/>
            <Setter Property="Foreground" Value="LightGray"/>
            
        </Style>
        <Style x:Key="ButtonSuccessStyle" 
               TargetType="{x:Type Button}"
               BasedOn="{StaticResource ButtonBaseStyle}">
            <Setter Property="Background" Value="YellowGreen"/>
            <Setter Property="BorderBrush" Value="DarkGreen"/>
            <Setter Property="Foreground" Value="DarkGreen"/>

        </Style>
    </Window.Resources>
    <Grid Margin="10">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="7*"/>
            <ColumnDefinition Width="3*"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="40"/>
            <RowDefinition Height="40"/>
            <RowDefinition Height="40"/>
            <RowDefinition Height="40"/>
            <RowDefinition Height="40"/>
            <RowDefinition Height="40"/>
        </Grid.RowDefinitions>
        <ProgressBar x:Name="pbNormal" Grid.Row="0" Grid.Column="0"
                     Margin="5"
                     Value="33"/>
        <Button x:Name="btNormal" Grid.Row="0" Grid.Column="1"
                Click="OnbtNormalClicked"
                Style="{StaticResource ButtonFailedStyle}">
            Grow Progress - Normal
        </Button>

        <ProgressBar x:Name="pbAsyncNormal" Grid.Row="1" Grid.Column="0"
                     Margin="5"
                     Value="33"/>
        <Button x:Name="btAsyncNormal" Grid.Row="1" Grid.Column="1"
                Click="OnbtAsyncNormalClicked"
                Style="{StaticResource ButtonFailedStyle}">
            Grow Progress - Async Normal
        </Button>

        <ProgressBar x:Name="pbAsyncProgress" Grid.Row="2" Grid.Column="0"
                     Margin="5"
                     Value="33"/>
        <Button x:Name="btAsyncProgress" Grid.Row="2" Grid.Column="1"
                Click="OnbtAsyncProgressClicked"
                Style="{StaticResource ButtonSuccessStyle}">
            Grow Progress - Async Progress
        </Button>

        <ProgressBar x:Name="pbAsyncNotify" Grid.Row="3" Grid.Column="0"
                     Margin="5"
                     Value="{Binding CurrentProgress, ElementName=mywindow, Mode=OneWay}"/>
        <Button x:Name="btAsyncNotify" Grid.Row="3" Grid.Column="1"
                Click="OnbtAsyncNotifyClicked"
                Style="{StaticResource ButtonBaseStyle}">
            Grow Progress - Async Notify
        </Button>

    </Grid>

</Window>

MainWindow.xaml.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.ComponentModel;

namespace Zhihu_Demo_ProgressBar1
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window, INotifyPropertyChanged
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        public event PropertyChangedEventHandler PropertyChanged;
        public void OnPropertyChanged(string PropertyName)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(PropertyName));
        }

        private void OnbtNormalClicked(object sender, RoutedEventArgs e)
        {
            for (int i = 0; i <= 100; i++)
            {
                Thread.Sleep(34);
                pbNormal.Value = i;
            }
        }

        private async void OnbtAsyncNormalClicked(object sender, RoutedEventArgs e)
        {
            Action growProgress = () =>
            {
                for (int i = 0; i <= 100; i++)
                {
                    Thread.Sleep(34);
                    pbAsyncNormal.Value = i;
                }
            };
            await Task.Run(growProgress);
        }

        private async void OnbtAsyncProgressClicked(object sender, RoutedEventArgs e)
        {
            //新建一个Action的委托,内容是把值赋给我们的进度条上。
            Action<int> bindProgress =
                value => pbAsyncProgress.Value = value;
            //新建一个Progress对象,把Action设定为刚才的action,这里需要注意的是
            //我们为了简化,把这个progress对象cast成了它的Interface,这是因为只有
            //IProgress才有 .report方法,而我们在这里需要用到这个方法。
            //
            //这个也可以新建为Progress或者索性var,然后再之后的引用里面写为
            //  ((IProgress<int>)progress).Report(i)
            //反正早晚都是需要cast之后才能使用
            IProgress<int> progress = new Progress<int>(bindProgress);

            //好了,现在这个从Progress报告到赋值给进度条的绑定就做好了,
            //和我们的例2一样,建立Action,然后异步跑它
            Action growProgress =
                () =>
                {
                    for (int i = 0; i <= 100; i++)
                    {
                        Thread.Sleep(34);
                        progress.Report(i);
                    }
                };
            await Task.Run(growProgress);
        }


        private int currentProgress = 0;
        public int CurrentProgress
        {
            get { return currentProgress; }
            set
            {
                currentProgress = value;
                OnPropertyChanged("CurrentProgress");
            }
        }

        private async void OnbtAsyncNotifyClicked(object sender, RoutedEventArgs e)
        {
            Action growProgress =
                () =>
                {
                    for (int i = 0; i <= 100; i++)
                    {
                        Thread.Sleep(34);
                        CurrentProgress = i;
                    }
                };
            await Task.Run(growProgress);
        }
    }
}

 

上一篇:基于WS-BPEL2.0的服务组合研究


下一篇:Flutter:教你用CustomPaint画一个自定义的CircleProgressBar