# 前言
事情的起因是这样的:
昨天刚把 Chat 页面的 ItemsControl VirtualizingStackPanel 换成了固定的 (不然由于子级的复用,会导致 Scroller
的滑动条变来变去)。
然后今天就突然发现,在页面从隐藏变为显示、或者发送新消息的时候,容器中的 Item 对象会产生抖动表现。
经过调试发现,原因是 ItemsControl 内部的子级发生了变化,从而导致 ItemsControl 内部大小变化造成整体的布局更新:
- 很简单的一个表现就是:比如本来是 1000px 变成 800px,然后又变回 1000px,这就导致进度闪烁、内容闪烁、抖动。
- 因为我的内容是 Wrap 的,所以容器大小也会随着其中内容变化而变化,这就是根源。
- 而且一连串的反应还会导致自动 Scroll Down 功能也产生一些问题
但是就在想这么基础的容器,这种用法应该不是特例,怎么会出现这种问题,难道是我哪里用错了吗?
于是就开始继续排查问题。
# 问题排查
实际布局是这样的:
省略无关代码,主体大概是以下这段:
<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 构造函数打了个断点和调试,竟然还真每次都进来了!
一堆重新初始化的日志。
然后就是追源码的过程了。
# 源码追踪
在激活页面的时候,会调到 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> |
如试想的一样,表现上就好了。
继续跟了一下调试,方法中也没走第二次创建之类的:
# 总结
改完后感觉流式更新内容的时候,界面都好多了,之前输出的时候可能还会卡卡的,当时也是调好久觉得是不是我功能逻辑层有啥问题?于是各种优化、加延迟、防抖机制。
果然,对于不熟悉的领域,没有一定经验的情况下,总是得踩不少坑。 比如本文这问题,虽然解决了,但是甚至都不知道这个解决方案是不是正常的:
因为作为最基础的 ItemsControl ,如果真有这问题,那么肯定早就有方案了才对。
另外这个解决方案不知道会不会有后遗症,现在缓存写的也比较暴力,后面估计也需要修改下实现。