天天看點

WPF 多線程 UI:設計一個異步加載 UI 的容器

原文 WPF 多線程 UI:設計一個異步加載 UI 的容器

對于 WPF 程式,如果你有某一個 UI 控件非常複雜,很有可能會卡住主 UI,給使用者軟體很卡的感受。但如果此時能有一個加載動畫,那麼就不會感受到那麼卡頓了。UI 的卡住不同于 IO 操作或者密集的 CPU 計算,WPF 中的 UI 卡頓時,我們幾乎沒有可以讓 UI 響應的方式,因為 WPF 一個視窗隻有一個 UI 線程。

No!WPF 一個視窗可以不止一個 UI 線程,本文将設計一個異步加載 UI 的容器,可以在主線程完全卡死的情況下顯示一個加載動畫。

本文是對我另一篇部落格 

WPF 同一視窗内的多線程 UI(VisualTarget)  的一項應用。閱讀本文,你将得到一個 UI 控件 

AsyncBox

,放入其中的控件即便卡住主線程,也依然會有一個加載動畫緩解使用者的焦慮情緒。

本文内容

下圖的黑屏部分是正在加載一個布局需要花 500ms 的按鈕。我們可以看到,即便是主線程被占用了 500ms,依然能有一個加載動畫緩解使用者的等待焦慮。

▲ 異步加載效果預覽

控件的名字為 

AsyncBox

,意為異步加載顯示 UI 的容器。如果要使用它,可以很簡單地寫出以下代碼:

<ww:AsyncBox LoadingViewType="demo:LoadingView">
    <demo:LongTimeView />
</ww:AsyncBox>
           

其中,

LoadingView

 是在指定用哪一個控件來做加載動畫。由于這個控件會在背景線程建立并執行,為了避免意外的線程問題,這裡傳入類型,而不是執行個體。

LongTimeView

 是一個用來模拟耗時 UI 的模拟控件。

如果要看整個視窗,則是下面這樣:

<Window x:Class="Walterlv.Demo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:Walterlv.Demo"
        xmlns:ww="clr-namespace:Walterlv.Windows;assembly=Walterlv.Windows"
        xmlns:demo="clr-namespace:Walterlv.Demo"
        Title="walterlv.com" Height="450" Width="800"
        Background="Black">
    <Grid>
        <ww:AsyncBox LoadingViewType="demo:LoadingView">
            <demo:LongTimeView />
        </ww:AsyncBox>
    </Grid>
</Window>
           

LongTimeView

 則是這樣:

<UserControl x:Class="Walterlv.Demo.LongTimeView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:Walterlv.Demo"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800"
             FontSize="48" FontFamily="Monaco">
    <Grid>
        <Button Content="walterlv.com" Click="DelayButton_Click" />
    </Grid>
</UserControl>
           
using System.Threading;
using System.Windows;
using System.Windows.Controls;

namespace Walterlv.Demo
{
    public partial class LongTimeView : UserControl
    {
        public LongTimeView()
        {
            InitializeComponent();
        }

        protected override Size MeasureOverride(Size constraint)
        {
            Thread.Sleep(500);
            return base.MeasureOverride(constraint);
        }

        private void DelayButton_Click(object sender, RoutedEventArgs e)
        {
            Thread.Sleep(3000);
        }
    }
}
           

而 

LoadingView

 則很簡單,隻是一個無限旋轉的動畫而已。同時它還沒有背景代碼:

▲ LoadingView 的動畫效果

<UserControl x:Class="Walterlv.Demo.LoadingView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:Walterlv.Demo"
             mc:Ignorable="d" d:DesignHeight="450" d:DesignWidth="800">
    <FrameworkElement.Resources>
        <Storyboard x:Key="Storyboard.Loading">
            <DoubleAnimation Storyboard.TargetName="Target"
                             Storyboard.TargetProperty="(UIElement.RenderTransform).(RotateTransform.Angle)"
                             From="0" To="1440" Duration="0:0:1.5" RepeatBehavior="Forever">
            </DoubleAnimation>
        </Storyboard>
    </FrameworkElement.Resources>
    <Grid>
        <Ellipse x:Name="Target" Width="48" Height="48" Stroke="White" StrokeThickness="8"
                 StrokeDashArray="10" StrokeDashCap="Round" RenderTransformOrigin="0.5 0.5">
            <Ellipse.RenderTransform>
                <RotateTransform />
            </Ellipse.RenderTransform>
            <Ellipse.Triggers>
                <EventTrigger RoutedEvent="FrameworkElement.Loaded">
                    <BeginStoryboard Storyboard="{StaticResource Storyboard.Loading}" />
                </EventTrigger>
            </Ellipse.Triggers>
        </Ellipse>
    </Grid>
</UserControl>
           

你需要為你的項目添加以下檔案:

其中,1、2、3、4、6 這幾個檔案可分别從以下連結找到并下載下傳到你的項目中:

  1. Annotations.cs
  2. AwaiterInterfaces.cs
  3. DispatcherAsyncOperation.cs
  4. UIDispatcher.cs
  5. VisualTargetPresentationSource.cs

這些檔案都是通用的異步類型。

第 5 個檔案 

AsyncBox

 就是我們要實作的主要類型。

實作思路是建一個 

PresentationSource

(類似于視窗的根 

HwndSource

),這可以用來承載一個新的可視化樹(Visual Tree)。這樣,我們就能在一個視窗中顯示兩個可視化樹了。

這兩個可視化樹通過 

HostVisual

 跨線程連接配接起來,于是我們能在一個視窗中得到兩個不同線程的可視化樹。

由于這兩棵樹不在同一個線程中,于是主線程即便卡死,也不影響背景用來播放加載動畫的線程。

如果你不能在下面看到 

AsyncBox

 的源碼,那麼你的網絡應該是被屏蔽了,可以通路 

AsyncBox.cs - A UI container for async loading.

 檢視。

using System;
using System.Collections;
using System.ComponentModel;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Threading;
using Walterlv.Annotations;
using Walterlv.Demo;
using Walterlv.Demo.Utils.Threading;
using DispatcherDictionary = System.Collections.Concurrent.ConcurrentDictionary<System.Windows.Threading.Dispatcher, Walterlv.Demo.Utils.Threading.DispatcherAsyncOperation<System.Windows.Threading.Dispatcher>>;

namespace Walterlv.Windows
{
    [ContentProperty(nameof(Child))]
    public class AsyncBox : FrameworkElement
    {
        /// <summary>
        /// 儲存外部 UI 線程和與其關聯的異步 UI 線程。
        /// 例如主 UI 線程對應一個 AsyncBox 專用的 UI 線程;外面可能有另一個 UI 線程,那麼對應另一個 AsyncBox 專用的 UI 線程。
        /// </summary>
        private static readonly DispatcherDictionary RelatedAsyncDispatchers = new DispatcherDictionary();

        [CanBeNull]
        private UIElement _child;

        [NotNull]
        private readonly HostVisual _hostVisual;

        [CanBeNull]
        private VisualTargetPresentationSource _targetSource;

        [CanBeNull]
        private UIElement _loadingView;

        [NotNull]
        private readonly ContentPresenter _contentPresenter;

        private bool _isChildReadyToLoad;

        [CanBeNull]
        private Type _loadingViewType;

        public AsyncBox()
        {
            _hostVisual = new HostVisual();
            _contentPresenter = new ContentPresenter();
            Loaded += OnLoaded;
        }

        [CanBeNull]
        public UIElement Child
        {
            get => _child;
            set
            {
                if (Equals(_child, value)) return;

                if (value != null)
                {
                    RemoveLogicalChild(value);
                }

                _child = value;

                if (_isChildReadyToLoad)
                {
                    ActivateChild();
                }
            }
        }

        [NotNull]
        public Type LoadingViewType
        {
            get
            {
                if (_loadingViewType == null)
                {
                    throw new InvalidOperationException(
                        $"在 {nameof(AsyncBox)} 顯示之前,必須先為 {nameof(LoadingViewType)} 設定一個 {nameof(UIElement)} 作為 Loading 視圖。");
                }

                return _loadingViewType;
            }
            set
            {
                if (value == null)
                {
                    throw new ArgumentNullException(nameof(LoadingViewType));
                }

                if (_loadingViewType != null)
                {
                    throw new ArgumentException($"{nameof(LoadingViewType)} 隻允許被設定一次。", nameof(value));
                }

                _loadingViewType = value;
            }
        }

        /// <summary>
        /// 傳回一個可等待的用于顯示異步 UI 的背景 UI 線程排程器。
        /// </summary>
        [NotNull]
        private DispatcherAsyncOperation<Dispatcher> GetAsyncDispatcherAsync() => RelatedAsyncDispatchers.GetOrAdd(
            Dispatcher, dispatcher => UIDispatcher.RunNewAsync("AsyncBox"));

        [NotNull]
        private UIElement CreateLoadingView()
        {
            var instance = Activator.CreateInstance(LoadingViewType);
            if (instance is UIElement element)
            {
                return element;
            }

            throw new InvalidOperationException($"{LoadingViewType} 必須是 {nameof(UIElement)} 類型");
        }

        private async void OnLoaded(object sender, RoutedEventArgs e)
        {
            if (DesignerProperties.GetIsInDesignMode(this)) return;

            var dispatcher = await GetAsyncDispatcherAsync();
            _loadingView = await dispatcher.InvokeAsync(() =>
            {
                var loadingView = CreateLoadingView();
                _targetSource = new VisualTargetPresentationSource(_hostVisual)
                {
                    RootVisual = loadingView
                };
                return loadingView;
            });
            AddVisualChild(_contentPresenter);
            AddVisualChild(_hostVisual);

            await LayoutAsync();
            await Dispatcher.Yield(DispatcherPriority.Background);

            _isChildReadyToLoad = true;
            ActivateChild();
        }

        private void ActivateChild()
        {
            var child = Child;
            if (child != null)
            {
                _contentPresenter.Content = child;
                AddLogicalChild(child);
                InvalidateMeasure();
            }
        }

        private async Task LayoutAsync()
        {
            var dispatcher = await GetAsyncDispatcherAsync();
            await dispatcher.InvokeAsync(() =>
            {
                if (_loadingView != null)
                {
                    _loadingView.Measure(RenderSize);
                    _loadingView.Arrange(new Rect(RenderSize));
                }
            });
        }

        protected override int VisualChildrenCount => _loadingView != null ? 2 : 0;

        protected override Visual GetVisualChild(int index)
        {
            switch (index)
            {
                case 0:
                    return _contentPresenter;
                case 1:
                    return _hostVisual;
                default:
                    return null;
            }
        }

        protected override IEnumerator LogicalChildren
        {
            get
            {
                if (_isChildReadyToLoad)
                {
                    yield return _contentPresenter;
                }
            }
        }

        protected override Size MeasureOverride(Size availableSize)
        {
            if (_isChildReadyToLoad)
            {
                _contentPresenter.Measure(availableSize);
                return _contentPresenter.DesiredSize;
            }

            var size = base.MeasureOverride(availableSize);
            return size;
        }

        protected override Size ArrangeOverride(Size finalSize)
        {
            if (_isChildReadyToLoad)
            {
                _contentPresenter.Arrange(new Rect(finalSize));
                var renderSize = _contentPresenter.RenderSize;
                LayoutAsync().ConfigureAwait(false);
                return renderSize;
            }

            var size = base.ArrangeOverride(finalSize);
            LayoutAsync().ConfigureAwait(false);
            return size;
        }
    }
}      
view raw AsyncBox.cs

 hosted with by 

GitHub

本文會經常更新,請閱讀原文: 

https://walterlv.com/post/design-an-async-loading-view.html

 ,以避免陳舊錯誤知識的誤導,同時有更好的閱讀體驗。