玩转 .NET 线程调度模型 - 多线程窗体与控件

在上一篇文章《 玩转 .NET 线程调度模型 - Dispatcher 》,我们简单描述了 Dispatcher 的线程调度作用,这篇文章将会着重描述如何将多线程运用在窗体以及各种 UI 控件上。

在 XAML 创建 UI 元素时,用于承载 UI 元素的线程默认为窗体本身的承载线程,也就是窗体的主线程。

在默认的情况下,一个 WPF 应用拥有一个默认的 STA线程用于承载 Application 派生类以及所有窗体,而控件默认是在窗体线程上创建的,那么多个窗体与控件都运行在同一个线程。

稍微思考一下,我们就能发现这种模式的缺陷,首先是当 UI 线程出现耗时操作的时候,就会导致整个应用全部卡死,其次我们可能需要在应用无响应的时候提供一个用户可交互的窗体,用于提示应用目前状态,亦或是在 UI 更新密集的控件,例如视频呈现控件,我们可能希望该控件的更新操作与主线程隔离开来,再或者是应用有很多个窗口,这个时候我们可能需要充分利用 CPU 的多核心优势来优化应用程序的性能,还有其他诸如此类的需求等等。

那么我能可能将一个窗体甚至一个控件使用一个单独的线程用于承载呢?答案当然是肯定的,否则的话你也看不到这篇文章了。

创建线程独立窗体

本小节内容将会描述,如何将一个新窗体被承载在一个新进程,而非默认的 UI 线程中。

首先我们需要明白一个线程如果需要承载 UI 元素需要什么条件,首先所有 UI 元素都是 DispatcherObject,这意味着,承载 UI 元素的线程需要支持线程调度。而支持调度的线程只有 STA 线程(其实由此我们可知道 UI 元素都是 STA-based)。

以下代码用于演示如何创建一个新 STA 线程与相应的调度器,并在其上创建并承载一个新 MainWindow:

// 创建一个新线程。
Thread thread = new Thread(() =>  
{
    // 创建一个新 MainWindow
    MainWindow newWindow = new MainWindow();

    // 在窗口关闭时,关闭调度器,否则会导致线程在窗体关闭后仍在运行调度器的消息循环,不会自动结束,从而导致应用不会被关闭
    newWindow.Closing += (sender, args) =>
    {
        newWindow.Dispatcher.InvokeShutdown();
    };

    // 显示该窗口
    newWindow.Show();

    // 开始运行调度器的消息循环。
    System.Windows.Threading.Dispatcher.Run();
});

// 设定线程为 STA 线程,可用于调度器。
thread.SetApartmentState(ApartmentState.STA);

// 开始线程。
thread.Start();  

要注意的是,由于每个窗口所在的线程都不同,所以窗口间的交互需要借助调度器来进行。

以下代码用于演示如何创建一个等待窗口提示用户现在主窗口处于无响应状态,需要等待:

主窗口的构造函数,创建一个侦听线程来侦听主线程线程是否有响应。

public MainWindow()  
{
    InitializeComponent();

    // 新建侦听线程,用于侦听该窗口是否可响应
    Thread listenThread = new Thread(() =>
   {
       WaitWindow waitWindow = null;

       while (true)
       {
           // 响应超时则显示等待窗口。
           if (IsThreadBlocked(this.Dispatcher))
           {
               waitWindow = CreateNewWindow<WaitWindow>();

               while (true)
               {
                   if (!IsThreadBlocked(this.Dispatcher))
                   {
                       break;
                   }

                   Thread.Sleep(100);
               }

               // UI 线程已恢复响应,关闭等待窗口
               waitWindow.Dispatcher.Invoke(() =>
               {
                   waitWindow.Close();
               });
           }

           // 等待 500 毫秒,进入下次循环
           Thread.Sleep(500);
       }
   });

    // 关闭窗口时停止侦听线程
    this.Closing += (sender, args) =>
    {
        listenThread.Abort();
    };

    // 开始侦听线程
    listenThread.Start();
}

当按钮点下时,停止主线程 10s。

private void OnButtonClick(object sender, RoutedEventArgs e)  
{
    // 让主线程停止响应 10 秒
    Thread.Sleep(10000);
}

CreateNewWindow 函数会创建一个线程独立的窗口。

/// <summary>
/// 创建并显示一个新窗口用一个新线程承载
/// </summary>
private T CreateNewWindow<T>() where T : Window, new()  
{
    AutoResetEvent sync = new AutoResetEvent(false);
    T newWindow = null;

    // 创建一个新线程。
    Thread thread = new Thread(() =>
    {
        // 创建一个新 WaitWindow
        newWindow = new T();

        // 在窗口关闭时,关闭调度器
        newWindow.Closing += (sender, args) =>
        {
            newWindow.Dispatcher.InvokeShutdown();
        };

        // 显示该窗口
        newWindow.Show();

        // 提示窗口已经创建完成并可使用
        sync.Set();

        // 开始运行调度器的消息循环。
        System.Windows.Threading.Dispatcher.Run();
    });

    // 设定线程为 STA 线程,可用于调度器。
    thread.SetApartmentState(ApartmentState.STA);

    // 开始线程。
    thread.Start();

    // 等待窗口创建并显示
    sync.WaitOne();

    return newWindow;
}

IsThreadBlocked 函数能判断一个拥有调度器的线程是否处于无响应状态。

/// <summary>
/// 判断一个线程是否处于无响应状态
/// </summary>
private bool IsThreadBlocked(Dispatcher dispatcher)  
{
    AutoResetEvent sync = new AutoResetEvent(false);
    bool timeOut = true;

    this.Dispatcher.BeginInvoke(new Action(() =>
    {
        // 当线程无响应状态时,提交的任务不会执行的

        // 超时标记设置为 false
        timeOut = false;
        sync.Set();
    }));

    // 超时时间为 100ms
    sync.WaitOne(100);

    // 响应超时则返回 true
    if (timeOut)
    {
        return true;
    }
    return false;
}

创建线程独立控件

本小节内容将会描述,如何让一个控件被承载在一个新进程,而非默认的 UI 线程中。在上个小节中,我们实现了让不同的窗口运行在不同的线程上,而 UI 控件也能如此么?

以下的代码能正常运行吗?

private T CreateNewControl<T>() where T : FrameworkElement, new()  
{
    AutoResetEvent sync = new AutoResetEvent(false);
    T newControl = null;

    Thread thread = new Thread(() =>
    {
        newControl = new T();
        newControl.Unloaded += (sender, args) =>
        {
            newControl.Dispatcher.InvokeShutdown();
        };

        sync.Set();

        Dispatcher.Run();
    });

    thread.SetApartmentState(ApartmentState.STA);
    thread.Start();

    sync.WaitOne();

    return newControl;
}

public MainWindow()  
{
    InitializeComponent();

    var button = CreateNewControl<Button>();
    rootGrid.Children.Add(button);
}

答案是否定的,由于创建的 Button 是承载在新线程上,他并不能直接被添加在 rootGrid 中,该操作将会抛出 InvalidOperationException 异常,异常信息为 “调用线程无法访问此对象,因为另一个线程拥有该对象”。

那么如何让同样一个窗口上的控件分别运行在不同的线程中呢?

首先我们可以尝试猜想一下,为什么我们添加 Button 会出现异常?当然是因为父控件(rootGrid)调用了 Button 里面的受线程限制的方法或者属性。那么有没有办法多线程访问 UI 组件呢?答案就是 HostVisual 与 VisualTarget。

WPF 的图像组合引擎是多线程的,HostVisual 与 VisualTarget 正是利用其特性,呈现处于不同线程的控件,VisualTarget 用于提供可视化树,HostVisual 承载可视化树。但是其没有交互功能,也就是说处于其他线程不能响应鼠标,键盘等相关的输入事件。主 UI 线程之所以能够相应输入事件,如果你有一定 Win32 编程基础,你应该知道,系统接受输入后,使用 SendMessage 类似的函数将消息传递给相关的句柄,而在 WPF 中,只有窗体具有句柄,所以窗口是接受事件的载体,再将其转换为 WPF 的事件/路由事件,将其传递给子控件。而 HostVisual 是没有处理输入的能力,也不能传递给子控件。

如何使用 HostVisual 呢?

我们可以创建一个 ThreadSeparatedControlHost 类用于承载 HostVisual。

/// <summary>
/// 用于承载线程独立控件。
/// </summary>
public abstract class ThreadSeparatedControlHost : FrameworkElement  
{
    public FrameworkElement TargetElement { get; private set; }
    public HostVisual HostVisual { get; private set; }
    public VisualTarget VisualTarget { get; private set; }
    public Dispatcher SeparateThreadDispatcher => HostVisual?.Dispatcher;
}

我们首先需要获取到我们需要创建控件的实例,这里提供一个虚方法返回一个 UIElement 来获取到我们需要的控件的实例。

/// <summary>
/// 在派生类中重写,创建需要承载的目标控件。
/// </summary>
protected virtual FrameworkElement CreateThreadSeparatedControl();  

然后我们需要编写独立线程逻辑。

/// <summary>
/// 在独立的线程上创建控件,并运行调度器。
/// </summary>
private void CreateControlOnSeparateThread()  
{
    TargetElement  = CreateThreadSeparatedControl();

    if (TargetElement == null) return;

    // HostVisual 承载 VisualTarget,而 VisualTarget 承载独立线程的可视化树。
    // 由于 HostVisual 需要与父控件交互,所以需要创建在主线程,而 VisualTarget 负责承载独立线程的控件,所以需要创建在独立线程。
    VisualTarget = new VisualTarget(HostVisual);
    VisualTarget.RootVisual = threadSeparatedControl;

    Dispatcher.Run();
}

然后是载入控件与线程初始化逻辑。

/// <summary>
/// 初始化独立线程并开始运行。
/// </summary>
private void LoadThreadSeparatedControl()  
{
    if (SeparateThreadDispatcher != null) return;

    HostVisual = new HostVisual();

    // 将 HostVisual 加入到可视化树。
    AddLogicalChild(HostVisual);
    AddVisualChild(HostVisual);

    var thread = new Thread(CreateControlOnSeparateThread)
    {
        IsBackground = true
    };

    thread.SetApartmentState(ApartmentState.STA);
    thread.Start();

}

截至是卸载控件,停止线程的逻辑。

/// <summary>
/// 卸载目标控件。
/// </summary>
private void UnloadThreadSeparatedControl()  
{
    if (SeparateThreadDispatcher == null) return;

    SeparateThreadDispatcher.InvokeShutdown();

    RemoveLogicalChild(HostVisual);
    RemoveVisualChild(HostVisual);

    HostVisual = null;
    TargetElement = null;
}

接着是将载入与卸载控件的操作与该 ControlHost 联系起来。

protected override void OnInitialized(EventArgs e)  
{
    LoadThreadSeparatedControl();

    Unloaded += (sender, args) =>
    {
        UnloadThreadSeparatedControl();
    };

    base.OnInitialized(e);
}

看上去我们已经完成了?当然没有!UI 元素最重要的部分还没有完成,那就是布局和测量,布局过程都没有传递给 VisualTarget 所承载的视觉树, PresentationSource 类可以用在不同的呈现技术,可以为两个不同的视觉树当作桥梁。

创建一个 VisualTargetPresentationSource 类用于提供 VisualTarget 的呈现源。

public class VisualTargetPresentationSource : PresentationSource, IDisposable  
{
    private readonly VisualTarget _visualTarget;

    public VisualTargetPresentationSource(HostVisual hostVisual)
    {
        _visualTarget = new VisualTarget(hostVisual);
        AddSource();
    }
    public override Visual RootVisual
    {
        get
        {
            try
            {
                return _visualTarget.RootVisual;
            }
            catch (Exception)
            {
                return null;
            }
        }

        set
        {
            Visual oldRoot = _visualTarget.RootVisual;
            _visualTarget.RootVisual = value;
            RootChanged(oldRoot, value);

            UIElement rootElement = value as UIElement;
            if (rootElement != null)
            {
                rootElement.Measure(new Size(Double.PositiveInfinity, Double.PositiveInfinity));
                rootElement.Arrange(new Rect(rootElement.DesiredSize));
            }
        }
    }

    protected override CompositionTarget GetCompositionTargetCore()
    {
        return _visualTarget;
    }

    private bool _isDisposed;

    public override bool IsDisposed
    {
        get { return _isDisposed; }
    }

    public void Dispose()
    {
        RemoveSource();
        _isDisposed = true;
    }
}

由于 VisualTarget 交给 VisualTargetPresentationSource 管理,所以我们将代码中的所有关于 VisualTarget 都替换为 VisualTargetPresentationSource。

接下来就是将布局与测量传递到独立线程的可视化树。

protected override Size MeasureOverride(Size constraint)  
{
    var uiSize = new Size();

    if (TargetElement != null)
    {
        TargetElement.Dispatcher.Invoke(DispatcherPriority.Background, new Action(() => TargetElement.Measure(constraint)));
        uiSize.Width = TargetElement.ActualWidth;
        uiSize.Height = TargetElement.ActualHeight;
    }

    return uiSize;
}

protected override Size ArrangeOverride(Size finalSize)  
{
    if (TargetElement != null)
    {
        TargetElement.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => TargetElement.Arrange(new Rect(finalSize))));
    }

    return finalSize;
}

然后是一些细节方面的调整,与子项相关属性的重写,下面是 ThreadSeparatedControlHost 完整代码:

using System;  
using System.Threading;  
using System.Windows;  
using System.Windows.Media;  
using System.Windows.Threading;

namespace ThreadTest  
{
    /// <summary>
    /// 用于承载线程独立控件。
    /// </summary>
    public abstract class ThreadSeparatedControlHost : FrameworkElement
    {
        public FrameworkElement TargetElement { get; private set; }
        public HostVisual HostVisual { get; private set; }
        public VisualTargetPresentationSource VisualTarget { get; private set; }
        public Dispatcher SeparateThreadDispatcher => HostVisual?.Dispatcher;

        /// <summary>
        /// 在派生类中重写,创建需要承载的目标控件。
        /// </summary>
        protected abstract FrameworkElement CreateThreadSeparatedControl();

        /// <summary>
        /// 在独立的线程上创建控件,并运行调度器。
        /// </summary>
        private void CreateControlOnSeparateThread()
        {
            var threadSeparatedControl = CreateThreadSeparatedControl();

            if (threadSeparatedControl == null)
            {
                throw new InvalidOperationException("CreateThreadSeparatedControl can not retune null.");
            }

            // HostVisual 承载 VisualTarget,而 VisualTarget 承载独立线程的可视化树。
            // 由于 HostVisual 需要与父控件交互,所以需要创建在主线程,而 VisualTarget 负责承载独立线程的控件,所以需要创建在独立线程。
            VisualTarget = new VisualTargetPresentationSource(HostVisual);
            VisualTarget.RootVisual = threadSeparatedControl;

            Dispatcher.BeginInvoke(new Action(() =>
            {
                this.InvalidateMeasure();
            }));

            Dispatcher.Run();

            VisualTarget.Dispose();
        }

        /// <summary>
        /// 初始化独立线程并创建控件。
        /// </summary>
        protected void LoadThreadSeparatedControl()
        {
            if (SeparateThreadDispatcher != null) return;

            HostVisual = new HostVisual();

            // 将 HostVisual 加入到可视化树。
            AddLogicalChild(HostVisual);
            AddVisualChild(HostVisual);

            var thread = new Thread(CreateControlOnSeparateThread)
            {
                IsBackground = true
            };

            thread.SetApartmentState(ApartmentState.STA);
            thread.Start();
        }

        /// <summary>
        /// 卸载目标控件。
        /// </summary>
        protected void UnloadThreadSeparatedControl()
        {
            if (SeparateThreadDispatcher == null) return;

            SeparateThreadDispatcher.InvokeShutdown();

            RemoveLogicalChild(HostVisual);
            RemoveVisualChild(HostVisual);

            HostVisual = null;
            TargetElement = null;
        }

        protected override void OnInitialized(EventArgs e)
        {
            LoadThreadSeparatedControl();

            Unloaded += (sender, args) =>
            {
                UnloadThreadSeparatedControl();
            };

            base.OnInitialized(e);
        }

        protected override Size MeasureOverride(Size constraint)
        {
            var uiSize = new Size();

            if (TargetElement != null)
            {
                TargetElement.Dispatcher.Invoke(DispatcherPriority.Background, new Action(() => TargetElement.Measure(constraint)));
                uiSize.Width = TargetElement.ActualWidth;
                uiSize.Height = TargetElement.ActualHeight;
            }

            return uiSize;
        }

        protected override Size ArrangeOverride(Size finalSize)
        {
            if (TargetElement != null)
            {
                TargetElement.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => TargetElement.Arrange(new Rect(finalSize))));
            }

            return finalSize;
        }

        protected override int VisualChildrenCount
        {
            get
            {
                return HostVisual != null ? 1 : 0;
            }
        }

        protected override IEnumerator LogicalChildren
        {
            get
            {
                if (HostVisual != null)
                {
                    yield return HostVisual;
                }
            }
        }

        protected override Visual GetVisualChild(int index)
        {
            if (index == 0)
            {
                return HostVisual;
            }

            throw new IndexOutOfRangeException("index");
        }
    }
}

我们已经完成了可承载独立线程控件的基类,下一步就是编写通用的组件。

需要解决的问题只有一个,如何生成目标控件?

如果在 XAML 中定义就会默认从主 UI 线程生成控件,用反射性能可能会有问题,特别是属性多的情况下,那么有没有一种方法又能知道控件的类型,又能快捷的设置各种属性呢?

当然有! Style 类就有类型信息,和各种属性的 Setter!我们可以根据 Style 的类型信息来创建一个空控件,然后给控件设置 Style 就可以设置各种属性了。

下面是能够通过设置 ContentStyle 属性的 CommonThreadSeparatedControlHost 完整代码:

using System;  
using System.Collections.Generic;  
using System.Linq;  
using System.Windows;  
using System.Windows.Markup;

namespace ThreadTest  
{
    [ContentProperty("ContentStyle")]
    public class CommonThreadSeparatedControlHost : ThreadSeparatedControlHost
    {
        public static readonly DependencyProperty ContentStyleProperty = DependencyProperty.Register(
            "ContentStyle", typeof (Style), typeof (CommonThreadSeparatedControlHost), new PropertyMetadata(default(Style),
                (o, args) =>
                {
                    var @this = (CommonThreadSeparatedControlHost) o;

                    if (args.NewValue != args.OldValue)
                    {
                        if (args.OldValue != null)
                        {
                            @this.UnloadThreadSeparatedControl();
                        }

                        if (args.NewValue != null)
                        {
                            @this.LoadThreadSeparatedControl();
                        }
                    }
                }));

        public Style ContentStyle
        {
            get { return (Style) GetValue(ContentStyleProperty); }
            set { SetValue(ContentStyleProperty, value); }
        }

        protected override FrameworkElement CreateThreadSeparatedControl()
        {
            IList<SetterBase> setters = null;
            Type targeType = null;

            Dispatcher.Invoke(() =>
            {
                targeType = ContentStyle?.TargetType;
                setters = ContentStyle?.Setters?.ToList();
            });

            if (targeType == null) return null;

            Style newStyle = new Style(targeType);

            foreach (SetterBase setter in setters)
            {
                Setter s = setter as Setter;

                if (s?.Value is FrameworkTemplate)
                {
                    string templateString = XamlWriter.Save(s.Value);
                    s.Value = XamlReader.Parse(templateString) as FrameworkTemplate;
                }

                newStyle.Setters.Add(setter);
            }

            var content = (FrameworkElement)Activator.CreateInstance(targeType);
            content.Style = newStyle;
            return content;
        }
    }
}

效果如下:

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章