一个朋友问我:“为什么几千个 Visual 在视觉树上,增加删除几个能够那么快地渲染出来?”这个问题问倒我了,因为我对 WPF 渲染系统的了解很少,更不知道渲染部分和 UI 逻辑部分是如何分工的。
在此机会下,我毫不犹豫地打开 https://referencesource.microsoft.com/ 阅读 WPF 的源码。


探索源码章节不感兴趣的读者,可以直接跳到后面大胆猜想理论依据章节。

探索源码

虽然知道 referencesource 上有源码,但从哪个类哪个方法开始也是个问题。

寻找入口

既然是添加、移除视觉树节点,那么应该在这几个类中找得到相关方法:VisualVisualTreeHelperVisualCollection。试着找了下 VisualTreeHelper,结果只有各种 Get 方法;找了下 VisualCollection,结果找到了。

public int Add(Visual visual)
{
    // …… (省略部分)
    if (visual != null)
    {
        ConnectChild(addedPosition, visual);
    }
    // …… (省略部分)
}

依此为突破口,应该能一层层找到渲染 Visual 部分的代码吧!

层层进入

我们看看 ConnectChild 方法,随后一层层进入。

private void ConnectChild(int index, Visual value)
{
    // …… (省略部分)
    _owner.InternalAddVisualChild(value);
}

其中,_ownerVisual 类型。

internal void InternalAddVisualChild(Visual child)
{
    this.AddVisualChild(child);
}

接下来就不那么顺利了,因为 AddVisualChild 方法进去后发现仅设置了标志位,再没有执行实质性的方法。这下杯具了,此路不通。但是 AddVisualChild 方法旁边还有个 RemoveVisualChild 方法,顺手看了下,居然有实质性方法:

protected void RemoveVisualChild(Visual child)
{
    // …… (省略部分)
    for (int i = 0; i < _proxy.Count; i++)
    {
        DUCE.Channel channel = _proxy.GetChannel(i);

        if (child.CheckFlagsAnd(channel, VisualProxyFlags.IsConnectedToParent))
        {
            child.SetFlags(channel, false, VisualProxyFlags.IsConnectedToParent);
            DUCE.IResource childResource = (DUCE.IResource)child;
            childResource.RemoveChildFromParent(this, channel);
            childResource.ReleaseOnChannel(channel);
        }
    }
    // …… (省略部分)
}

注意到 childResource.RemoveChildFromParent(this, channel); 的调用方是 childResource,而它是从 Visual 强转的 DUCE.IResource 接口对象。由于我们要了解的是实现细节,直接点开接口是看不到的,所以,得看看到底是谁实现了这个接口。既然是 Visual 强转得到,那么确定是 Visual 实现。但必须要强转才能调用,这不得不让我怀疑“Visual 类显式实现了接口 DUCE.IResource”。
于是,我在浏览器中按下 Ctrl+F,搜素 RemoveChildFromParent,结果不出所料:

/// <summary>
/// Sends a command to compositor to remove the child
/// from its parent on the channel.
/// </summary>
void DUCE.IResource.RemoveChildFromParent(
        DUCE.IResource parent,
        DUCE.Channel channel)
{
    DUCE.CompositionNode.RemoveChild(
        parent.GetHandle(channel),
        _proxy.GetHandle(channel),
        channel);
}

其实我们可以继续去看 RemoveChild 方法,但注释却让我感到意外。

Sends a command to compositor to remove the child from its parent on the channel. 在 channel 中向 compositor 发送一个移除视觉子级的命令。

莫非到头来都不会真实地执行任何渲染相关的方法?channel 是什么?compositor 又是什么?一个一个调查!

查到最后

RemoveChild 方法如下,果不其然,真的只是在通道中发送了一条移除视觉子级的命令。

/// <SecurityNote>
///     Critical: This code accesses an unsafe code block
///     TreatAsSafe: Operation is ok to call. It does not return any pointers and sending a pointer to a channel is safe
/// </SecurityNote>
[SecurityCritical, SecurityTreatAsSafe]
internal static void RemoveChild(
    DUCE.ResourceHandle hCompositionNode,
    DUCE.ResourceHandle hChild,
    Channel channel)
{
    DUCE.MILCMD_VISUAL_REMOVECHILD command;

    command.Type = MILCMD.MilCmdVisualRemoveChild;
    command.Handle = hCompositionNode;
    command.hChild = hChild;

    unsafe
    {
        channel.SendCommand(
            (byte*)&command,
            sizeof(DUCE.MILCMD_VISUAL_REMOVECHILD)
            );
    }
}

channel 是 DUCE.Channel 类型的,在 msdn 上搜索 DUCE.Channel 没有得到正式的定义,但是搜到的一篇文章却间接地描述了它的用途。

WPF Render Thread Failures The render thread only synchronizes with the UI thread in a few locations, so the callstacks above are typically where you notice the problem, not where it actually occurred. The most common locations when they synchronize are when a window’s settings are updated (size, position, etc.) or as a result of the UI thread handling a “channel” message from DirectX.
渲染线程只在少数几处与 UI 线程进行同步,这也是为什么你看到的堆栈信息是这么几处,而不是真实发生错误的代码。通常进行线程同步的几个地方是 window 的尺寸、位置等发生变换时或者 UI 线程从 DirectX 处理通道中的消息时。

从原文中的错误堆栈中和上文里面我们可以知道 channel 是用来让 UI 线程和渲染线程进行通信的通道。

记得刚开始 AddVisualChild 方法中没有找到相关方法吗?想知道为什么。于是我又通过 DUCE.Channel 反向查找,最终发现对应的方法其实在 Visual.Render 方法中。

internal void Render(RenderContext ctx, UInt32 childIndex)
{
    // …… (省略部分)
    DUCE.CompositionNode.InsertChildAt(
        ctx.Root,
        _proxy.GetHandle(channel),
        childIndex,
        channel);
    // …… (省略部分)
}

大胆猜想

继续找,发现 DUCE.Channel 类型中只有发送/提交命令的方法,没有获取命令的方法;而且发送命令执行的都是非托管代码。而如果获取命令的方法也是托管代码,那么 DUCE.Channel 中一定有方法获取到命令的。所以,大胆猜测,获取方法仅在非托管代码中实现。也就是说,UI 线程由托管代码实现,但视觉树改变后仅发送一个命令通知渲染线程实现,而渲染线程由非托管代码实现。

理论依据

Google 搜索“WPF Render”关键字,我找到了这篇文章: A Critical Deep Dive into the WPF Rendering System

从这篇文章中,得到了很多 WPF 渲染系统的启发,这也许能解释本文开始的一部分现象。


本文会经常更新,请阅读原文: https://walterlv.github.io/wpf/2017/01/16/wpf-render-system.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

知识共享许可协议 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 吕毅 (包含链接: https://walterlv.github.io ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系