# 前言

事情的起因是这样的:

昨天刚把 Chat 页面的 ItemsControl VirtualizingStackPanel 换成了固定的 (不然由于子级的复用,会导致 Scroller
的滑动条变来变去)。

然后今天就突然发现,在页面从隐藏变为显示、或者发送新消息的时候,容器中的 Item 对象会产生抖动表现。

经过调试发现,原因是 ItemsControl 内部的子级发生了变化,从而导致 ItemsControl 内部大小变化造成整体的布局更新:

  • 很简单的一个表现就是:比如本来是 1000px 变成 800px,然后又变回 1000px,这就导致进度闪烁、内容闪烁、抖动。
  • 因为我的内容是 Wrap 的,所以容器大小也会随着其中内容变化而变化,这就是根源。
  • 而且一连串的反应还会导致自动 Scroll Down 功能也产生一些问题

但是就在想这么基础的容器,这种用法应该不是特例,怎么会出现这种问题,难道是我哪里用错了吗?

于是就开始继续排查问题。

# 问题排查

实际布局是这样的:

img_3.png

省略无关代码,主体大概是以下这段:

<ItemsControl ItemsSource="{Binding ChatItems}">
    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <CustomTextBlock Text="{Binding Text}"/>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

其中的 Text 可以当作会自适应 Wrap 的设置。
很简单的逻辑是吧?
但就是出问题了。

于是先手动排查 CustomTextBlock 的问题,中间的过程也挺曲折的:
先是试图在各种重载打印、加断点,但始终无法找到问题。

检查了一下 TextBlock 的值,竟然发现每次刚进来还是有值的状态,结果初始化一半反而变成空了,然后又走重新赋值的流程:

protected override void OnLoaded(RoutedEventArgs e)
{
    base.OnLoaded(e);
    _isLoaded = true;
    CheckUpdateValid();
    Log.Debug("SimpleMarkdownViewer loaded"+PlainTextBlock?.Text);
}
protected override void OnUnloaded(RoutedEventArgs e)
{
    base.OnUnloaded(e);
    _isLoaded = false;
    Log.Debug("SimpleMarkdownViewer unloaded"+PlainTextBlock?.Text);
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
    base.OnDetachedFromVisualTree(e);
    Log.Debug("SimpleMarkdownViewer detached"+PlainTextBlock?.Text);
}

就这么又调了一阵子,不得结果。最后灵机一动,直接在 CustomTextBlock 构造函数打了个断点和调试,竟然还真每次都进来了!

img_1.png

一堆重新初始化的日志。

然后就是追源码的过程了。

# 源码追踪

在激活页面的时候,会调到 Layouable MeasureCore (Size availableSize) 方法,这里倒是看着没啥问题,目前已知的是涉及页面刷新都会走到这。
但下面的继续调用的方法就不对了:
其中 ApplyTemplate () 会走到 ContentPresenter 里面:

public sealed override void ApplyTemplate()
{
    if (!_createdChild && ((ILogical)this).IsAttachedToLogicalTree)
    {
        UpdateChild();
    }
}

这里会检查更新子级,注意 createdChild 是在 OnAttachedToLogicalTree 就设置为 false 了,也就是重新进来的时候肯定为 false:

protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e)
{
    base.OnAttachedToLogicalTree(e);
    _recyclingDataTemplate = null;
    _createdChild = false;
    InvalidateMeasure();
}

所以又一定会走到 UpdateChild:

private void UpdateChild(object? content)
{
    var contentTemplate = ContentTemplate;
    var oldChild = Child;
    var newChild = CreateChild(content, oldChild, contentTemplate);
    var logicalChildren = GetEffectiveLogicalChildren();
    // 其它后续处理,不重要
}

这里取到的 Child 是有值的,也就是子级在这确实还存在,也就是说 oldChild 也肯定有值。
问题就出在 CreateChild 方法里面:

private Control? CreateChild(object? content, Control? oldChild, IDataTemplate? template)
{            
    var newChild = content as Control;
    // We want to allow creating Child from the Template, if Content is null.
    // But it's important to not use DataTemplates, otherwise we will break content presenters in many places,
    // otherwise it will blow up every ContentPresenter without Content set.
    if ((newChild == null 
        && (content != null || template != null)) || (newChild is { } && template is { }))
    {
        var dataTemplate = this.FindDataTemplate(content, template) ??
            (
                RecognizesAccessKey
                    ? FuncDataTemplate.Access
                    : FuncDataTemplate.Default
            );
        if (dataTemplate is IRecyclingDataTemplate rdt)
        {
            var toRecycle = rdt == _recyclingDataTemplate ? oldChild : null;
            newChild = rdt.Build(content, toRecycle);
            _recyclingDataTemplate = rdt;
        }
        else
        {
            newChild = dataTemplate.Build(content);
            _recyclingDataTemplate = null;
        }
    }
    else
    {
        _recyclingDataTemplate = null;
    }
    return newChild;
}

其中我用的是 DataTemplate,它实现了 IRecyclingDataTemplate 接口,所以会走到 IRecyclingDataTemplate 分支:

if (dataTemplate is IRecyclingDataTemplate rdt)
{
    var toRecycle = rdt == _recyclingDataTemplate ? oldChild : null;
    newChild = rdt.Build(content, toRecycle);
    _recyclingDataTemplate = rdt;
}

这里关键就来了:recyclingDataTemplate 根本就没值!
甚至我翻来覆去检查了几次,通篇都只有对 recyclingDataTemplate 赋值为 null 的,就是没有赋实际值的设置。

而且这东西定义的还是个 private 变量:

private IRecyclingDataTemplate? _recyclingDataTemplate;

所以也不存在子级。

再看看 DataTemplate 实现的 IRecyclingDataTemplate 接口:

public Control? Build(object? data) => Build(data, null);
public Control? Build(object? data, Control? existing)
{
    return existing ?? TemplateContent.Load(Content)?.Result;
}

上面的代码是 rdt == _recyclingDataTemplate 才会传入 oldChild,否则就返回 null。
但这里的 _recyclingDataTemplate 根本就没值,所以肯定是 null。

那么传进来的结果就显而易见,就是 null,也就是说会调用 TemplateContent.Load (Content) 去创建新的子级。

至于旧的,后面就有重复检测,会直接再给删除掉。

所以问题就来了:同一个对象,从隐藏变为显示,会重新创建子级,但却实际没有赋予 _recyclingDataTemplate
值,导致后面又会重新创建子级,这就导致了子级的重复创建。

我也不知道为什么会怎样?是代码有点问题,还是有其它考虑?

为了解决这个问题:

  • 要么继承 DataTemplate 重写 Build 方法,要么看能不能继承 ContentPresenter 接口,在 OnAttachedToLogicalTree
    之类的里面设置 _recyclingDataTemplate 值把这些逻辑补全。
  • 或者自己实现 IRecyclingDataTemplate 接口,自己管理缓存试试能不能解决这个问题。

看来下逻辑结构,感觉还是自己实现 IRecyclingDataTemplate 接口比较简单,而且也能更精确地控制缓存逻辑。

于是就开始动手了。

# 解决方案

首先,实现 DataTemplate 实现过的两个,接口,重写 Build 之类的方法:

public class ReuseDataTemplate : IRecyclingDataTemplate, ITypedDataTemplate
{
    private readonly Dictionary<object, Control> _cacheDictionary = new();
    [DataType] public Type? DataType { get; set; }
    [Content] [TemplateContent] public object? Content { get; set; }
    public bool Match(object? data)
    {
        if (DataType == null)
        {
            return true;
        }
        return DataType.IsInstanceOfType(data);
    }
    public Control Build(object? data) => Build(data, null);
    public Control Build(object? data, Control? existing)
    {
        return existing ?? FindControl(data);
    }
    private Control FindControl(object? data)
    {
        if (data == null) return new TextBlock() { Text = "Null Data" };
        if (_cacheDictionary.TryGetValue(data, out var template)) return template;
        var control = TemplateContent.Load(Content)?.Result;
        if (control == null) return new TextBlock() { Text = "Null Control" };
        _cacheDictionary[data] = control;
        return control;
    }
}

然后在使用的地方换成 ReuseDataTemplate 即可:

<ItemsControl ItemsSource="{Binding ChatItems}">
    <ItemsControl.ItemTemplate>
        <ReuseDataTemplate>
            <CustomTextBlock Text="{Binding Text}"/>
        </ReuseDataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

如试想的一样,表现上就好了。

继续跟了一下调试,方法中也没走第二次创建之类的:

img_2.png

# 总结

改完后感觉流式更新内容的时候,界面都好多了,之前输出的时候可能还会卡卡的,当时也是调好久觉得是不是我功能逻辑层有啥问题?于是各种优化、加延迟、防抖机制。

果然,对于不熟悉的领域,没有一定经验的情况下,总是得踩不少坑。 比如本文这问题,虽然解决了,但是甚至都不知道这个解决方案是不是正常的:

因为作为最基础的 ItemsControl ,如果真有这问题,那么肯定早就有方案了才对。

另外这个解决方案不知道会不会有后遗症,现在缓存写的也比较暴力,后面估计也需要修改下实现。