【WPF】UI虚拟化之------自定义VirtualizingWrapPanel
来源:互联网 发布:什么才叫做数据库 编辑:程序博客网 时间:2024/06/05 11:56
前言
前几天QA报了一个关于OOM的bug,在排查的过程中发现,ListBox控件中被塞入了过多的Item,而ListBox又定义了两种样式的ItemsPanelTemplate。一种用的是虚拟化的VirtualizingStackPanel,另一种没有考虑虚拟化用的是WrapPanel。所以当ListBox切换到第二种Template,而且有很多Item的时候,内存就爆掉然后直接挂了。
然后就想着有没有现成的VirtualizingWrapPanel可以直接拿来用用,可惜微软并没有直接给我们提供这种panel,但是提供了VirtualizingPanel这个抽象类。没办法只能自己动手做了,借助于VirtualizingPanel和IScrollInfo。IScrollInfo主要是用来滚动效果,而VirtualizingPanel则提供了虚拟化过程中,child的移除和添加操作。其实虚拟化的本质不就是把需要显示到UI上的item画上去,把已经画上去但不需要再显示的撤下来嘛!
因为改bug的时候又来了个新的需求,就是要把WrapPanel中每一行的item之间的距离设置为等间距的,所以这次的UI虚拟化之旅确切来说应该是自定义一个VirtualizingUniformGridWrapPanel。
实现
1. 新建类VirtualizingWrapPanel,继承VirtualizingPanel并实现IScrollInfo
public class VirtualizingWrapPanel : VirtualizingPanel, IScrollInfo{}
然后添加一个TranslateTransform字段,这主要是滚动时需要用到。
private TranslateTransform trans = new TranslateTransform();
接下来添加几个依赖属性,设置内部Child的宽、高和鼠标滚动一次的偏移量。
public static readonly DependencyProperty ChildWidthProperty = DependencyProperty.RegisterAttached("ChildWidth", typeof(double), typeof(VirtualizingWrapPanel), new FrameworkPropertyMetadata(200.0, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange));public static readonly DependencyProperty ChildHeightProperty = DependencyProperty.RegisterAttached("ChildHeight", typeof(double), typeof(VirtualizingWrapPanel), new FrameworkPropertyMetadata(200.0, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange));//鼠标每一次滚动 UI上的偏移public static readonly DependencyProperty ScrollOffsetProperty = DependencyProperty.RegisterAttached("ScrollOffset", typeof(int), typeof(VirtualizingWrapPanel), new PropertyMetadata(10));public int ScrollOffset{ get { return Convert.ToInt32(GetValue(ScrollOffsetProperty)); } set { SetValue(ScrollOffsetProperty, value); }}public double ChildWidth{ get => Convert.ToDouble(GetValue(ChildWidthProperty)); set => SetValue(ChildWidthProperty, value);}public double ChildHeight{ get => Convert.ToDouble(GetValue(ChildHeightProperty)); set => SetValue(ChildHeightProperty, value);}
2. 理解WPF中的布局定位流程
WPF中布局定位的计算是通过Measure和Arrange方法构成的。以VirtualizingWrapPanel为例(以下简称VWP),VWP的父layout调用自身的Measure(Size availableSize)
方法,告诉VWP你有availableSize的大小可以使用,然后MeasureCore
会根据一定的测量逻辑,告诉VWP的protected override Size MeasureOverride(Size availableSize)
方法,你有availableSize的大小可以用,在这里VWP调用其子元素的Measure方法,告诉子元素有多大的Size可以用(此例,因为我们子child的大小都是通过依赖属性设置好的,所以直接传入即可,子child的DesiredSize也不考虑)。子child都Measure完之后,返回一个Size,这个Size是VMP自身需要的Size,父layout会通过VWP.DesiredSize
属性拿到这个值。然后ArrangeCore
又会根据一定的逻辑,分配一个finalSize给VWP。VWP通过protected override Size ArrangeOverride(Size finalSize)
方法就收到了这个值,然后在给定的finalSize里划分不同的区域,调用子child的Arrange方法,告诉每个child应该在哪个区域。
/// <summary> /// scroll/availableSize/添加删除元素 改变都会触发 edit元素不会改变 /// </summary> /// <param name="availableSize"></param> /// <returns></returns> protected override Size MeasureOverride(Size availableSize) { this.UpdateScrollInfo(availableSize);//availableSize更新后,更新滚动条 int firstVisiableIndex = 0, lastVisiableIndex = 0; GetVisiableRange(ref firstVisiableIndex, ref lastVisiableIndex);//availableSize更新后,获取当前viewport内可放置的item的开始和结束索引 firstIdnex-lastIndex之间的item可能部分在viewport中也可能都不在viewport中。 UIElementCollection children = this.InternalChildren;//因为配置了虚拟化,所以children的个数一直是viewport区域内的个数,如果没有虚拟化则是ItemSource的整个的个数 IItemContainerGenerator generator = this.ItemContainerGenerator; //获得第一个可被显示的item的位置 GeneratorPosition startPosi = generator.GeneratorPositionFromIndex(firstVisiableIndex); int childIndex = (startPosi.Offset == 0) ? startPosi.Index : startPosi.Index + 1;//startPosi在chilren中的索引 using (generator.StartAt(startPosi, GeneratorDirection.Forward, true)) { int itemIndex = firstVisiableIndex; while (itemIndex <= lastVisiableIndex)//生成lastVisiableIndex-firstVisiableIndex个item { bool newlyRealized = false; var child = generator.GenerateNext(out newlyRealized) as UIElement; if (newlyRealized) { if (childIndex >= children.Count) base.AddInternalChild(child); else { base.InsertInternalChild(childIndex, child); } generator.PrepareItemContainer(child); } else { if (!child.Equals(children[childIndex])) { base.RemoveInternalChildRange(childIndex, 1); } } child.Measure(new Size(this.ChildWidth, this.ChildHeight)); //child.DesiredSize//child想要的size itemIndex++; childIndex++; } } CleanUpItems(firstVisiableIndex, lastVisiableIndex); return new Size(double.IsInfinity(availableSize.Width) ? 0 : availableSize.Width, double.IsInfinity(availableSize.Height) ? 0 : availableSize.Height);//自身想要的size } protected override Size ArrangeOverride(Size finalSize) { Debug.WriteLine("----ArrangeOverride"); var generator = this.ItemContainerGenerator; UpdateScrollInfo(finalSize); int childPerRow = CalculateChildrenPerRow(finalSize); double availableItemWidth = finalSize.Width / childPerRow; for (int i = 0; i <= this.Children.Count - 1; i++) { var child = this.Children[i]; int itemIndex = generator.IndexFromGeneratorPosition(new GeneratorPosition(i, 0)); int row = itemIndex / childPerRow;//current row int column = itemIndex % childPerRow; double xCorrdForItem = 0; xCorrdForItem = column * availableItemWidth + (availableItemWidth - this.ChildWidth) / 2; Rect rec = new Rect(xCorrdForItem, row * this.ChildHeight, this.ChildWidth, this.ChildHeight); child.Arrange(rec); } return finalSize; }
3. 什么时候应该刷新UI
MSDN文档 告诉我们,当ScrollViewer的offset, extent, or viewport
这三个属性发生变化时,应该当调用ScrollViewer的InvalidateScrollInfo
方法,然后ScrollView就会自动更新滚动条长短和位置。此时也应该调用InvalidateMeasure方法,然后会重新Measure布局。
offset,extent和viewport的表示区域如下图:
黑色的表示实际显示到界面上的内容。如果不虚拟化则24个item都会在wrappanel中,虚拟化后只有需要显示的那部分(9-16)会在wrappanel中,其他的都删除了。
更新UI操作:
public void SetVerticalOffset(double offset) { if (offset < 0 || this.viewPort.Height >= this.extent.Height) offset = 0; else if (offset + this.viewPort.Height >= this.extent.Height) offset = this.extent.Height - this.viewPort.Height; this.offset.Y = offset; this.ScrollOwner?.InvalidateScrollInfo();//Scroll信息已过期 this.trans.Y = -offset; this.InvalidateMeasure();//Measure信息已过期 //接下来会触发MeasureOverride() }
4. 虚拟化操作
操作的第一步是获取当前VWP中已加载的所有的child和ListBox的数据源中所有的child。
VWP中children的获取可通过this.InternalChildren
拿到。
数据源中children包含在this.ItemContainerGenerator
里面。
这里sdk有个bug,如果你不先调用this.InternalChildren,直接用ItemContainerGenerator后续生成child操作会返回null。
第二步,获取到应该显示到viewport区域内的第一个child和最后一个child的索引,此时viewport的大小可能已经是变化后的。(因为你可能滚动了鼠标,或者更改了VWP的宽高)
/// <summary> /// 获取所有item,在可视区域内第一个item和最后一个item的索引 /// </summary> /// <param name="firstIndex"></param> /// <param name="lastIndex"></param> void GetVisiableRange(ref int firstIndex, ref int lastIndex) { int childPerRow = CalculateChildrenPerRow(this.extent); firstIndex = Convert.ToInt32(Math.Floor(this.offset.Y / this.ChildHeight)) * childPerRow; lastIndex = Convert.ToInt32(Math.Ceiling((this.offset.Y + this.viewPort.Height) / this.ChildHeight)) * childPerRow - 1; int itemsCount = GetItemCount(this); if (lastIndex >= itemsCount) lastIndex = itemsCount - 1; }
第三,将VWP中已不需在viewport内显示的的child从VWP和generator的container中移除。
/// <summary> /// 将不在可视区域内的item 移除 /// </summary> /// <param name="startIndex">可视区域开始索引</param> /// <param name="endIndex">可视区域结束索引</param> void CleanUpItems(int startIndex, int endIndex) { var children = this.InternalChildren; var generator = this.ItemContainerGenerator; for (int i = children.Count - 1; i >= 0; i--) { var childGeneratorPosi = new GeneratorPosition(i, 0); int itemIndex = generator.IndexFromGeneratorPosition(childGeneratorPosi); if (itemIndex < startIndex || itemIndex > endIndex) { generator.Remove(childGeneratorPosi, 1); RemoveInternalChildRange(i, 1); } } }
这里虽然调用了generator.Remove方法,将不需要显示的进行移除,但是我发现移除后generator中元素的个数与ListBox绑定的数据源中元素的个数始终是一致的。所以我觉得可能是将次child从generator的container中移除了。因为你从上面的MeasureOverride方法中也看到了,新加时是调用generator的PrepareItemContainer方法。
5. UniformGrid效果的WrapPanel该怎么给child Arrange
其实就在上面的ArrangeOverride方法里,很简单:
xCorrdForItem = column * availableItemWidth + (availableItemWidth - this.ChildWidth) / 2;
6. 存在的Bug或者问题
第一个bug你已经在上面的第4步看过了。
还有个bug就是如果为你的ListBox设置ItemsPanelTemplate是通过绑定的方式。那么使用时,VWP的ScrollOwner属性就会报null reference了(当然你也可以再加一个依赖属性,给其bind一个scrollview,解决)。而且滚动的时候页面会出现空白,明明新的child已经生成了,但就是不会显示的UI上。解决方法就是Code Behind吧,bind的方式不知道会产生多少为null的属性。
7. 使用方法
<ListBox Margin="0,50,0,0" Name="listB"> <ListBox.ItemTemplate> <DataTemplate> <TextBlock Text="{Binding}" Width="70" Height="70"/> </DataTemplate> </ListBox.ItemTemplate> <ListBox.ItemsPanel> <ItemsPanelTemplate> <local:VirtualizingWrapPanel ScrollOffset="50" ChildHeight="70" ChildWidth="70"/> </ItemsPanelTemplate> </ListBox.ItemsPanel> </ListBox>
8. 完整代码:
using System;using System.Collections.Generic;using System.Diagnostics;using System.Linq;using System.Text;using System.Threading.Tasks;using System.Windows;using System.Windows.Controls;using System.Windows.Controls.Primitives;using System.Windows.Media;namespace VirtualizingPPanel{ public class VirtualizingWrapPanel : VirtualizingPanel, IScrollInfo { private TranslateTransform trans = new TranslateTransform(); public VirtualizingWrapPanel() { this.RenderTransform = trans; } #region DependencyProperties public static readonly DependencyProperty ChildWidthProperty = DependencyProperty.RegisterAttached("ChildWidth", typeof(double), typeof(VirtualizingWrapPanel), new FrameworkPropertyMetadata(200.0, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange)); public static readonly DependencyProperty ChildHeightProperty = DependencyProperty.RegisterAttached("ChildHeight", typeof(double), typeof(VirtualizingWrapPanel), new FrameworkPropertyMetadata(200.0, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange)); //鼠标每一次滚动 UI上的偏移 public static readonly DependencyProperty ScrollOffsetProperty = DependencyProperty.RegisterAttached("ScrollOffset", typeof(int), typeof(VirtualizingWrapPanel), new PropertyMetadata(10)); public int ScrollOffset { get { return Convert.ToInt32(GetValue(ScrollOffsetProperty)); } set { SetValue(ScrollOffsetProperty, value); } } public double ChildWidth { get => Convert.ToDouble(GetValue(ChildWidthProperty)); set => SetValue(ChildWidthProperty, value); } public double ChildHeight { get => Convert.ToDouble(GetValue(ChildHeightProperty)); set => SetValue(ChildHeightProperty, value); } #endregion int GetItemCount(DependencyObject element) { var itemsControl = ItemsControl.GetItemsOwner(element); return itemsControl.HasItems ? itemsControl.Items.Count : 0; } int CalculateChildrenPerRow(Size availableSize) { int childPerRow = 0; if (availableSize.Width == double.PositiveInfinity) childPerRow = this.Children.Count; else childPerRow = Math.Max(1, Convert.ToInt32(Math.Floor(availableSize.Width / this.ChildWidth))); return childPerRow; } /// <summary> /// width不超过availableSize的情况下,自身实际需要的Size(高度可能会超出availableSize) /// </summary> /// <param name="availableSize"></param> /// <param name="itemsCount"></param> /// <returns></returns> Size CalculateExtent(Size availableSize, int itemsCount) { int childPerRow = CalculateChildrenPerRow(availableSize);//现有宽度下 一行可以最多容纳多少个 return new Size(childPerRow * this.ChildWidth, this.ChildHeight * Math.Ceiling(Convert.ToDouble(itemsCount) / childPerRow)); } /// <summary> /// 更新滚动条 /// </summary> /// <param name="availableSize"></param> void UpdateScrollInfo(Size availableSize) { var extent = CalculateExtent(availableSize, GetItemCount(this));//extent 自己实际需要 if (extent != this.extent) { this.extent = extent; this.ScrollOwner.InvalidateScrollInfo(); } if (availableSize != this.viewPort) { this.viewPort = availableSize; this.ScrollOwner.InvalidateScrollInfo(); } } /// <summary> /// 获取所有item,在可视区域内第一个item和最后一个item的索引 /// </summary> /// <param name="firstIndex"></param> /// <param name="lastIndex"></param> void GetVisiableRange(ref int firstIndex, ref int lastIndex) { int childPerRow = CalculateChildrenPerRow(this.extent); firstIndex = Convert.ToInt32(Math.Floor(this.offset.Y / this.ChildHeight)) * childPerRow; lastIndex = Convert.ToInt32(Math.Ceiling((this.offset.Y + this.viewPort.Height) / this.ChildHeight)) * childPerRow - 1; int itemsCount = GetItemCount(this); if (lastIndex >= itemsCount) lastIndex = itemsCount - 1; } /// <summary> /// 将不在可视区域内的item 移除 /// </summary> /// <param name="startIndex">可视区域开始索引</param> /// <param name="endIndex">可视区域结束索引</param> void CleanUpItems(int startIndex, int endIndex) { var children = this.InternalChildren; var generator = this.ItemContainerGenerator; for (int i = children.Count - 1; i >= 0; i--) { var childGeneratorPosi = new GeneratorPosition(i, 0); int itemIndex = generator.IndexFromGeneratorPosition(childGeneratorPosi); if (itemIndex < startIndex || itemIndex > endIndex) { generator.Remove(childGeneratorPosi, 1); RemoveInternalChildRange(i, 1); } } } /// <summary> /// scroll/availableSize/添加删除元素 改变都会触发 edit元素不会改变 /// </summary> /// <param name="availableSize"></param> /// <returns></returns> protected override Size MeasureOverride(Size availableSize) { this.UpdateScrollInfo(availableSize);//availableSize更新后,更新滚动条 int firstVisiableIndex = 0, lastVisiableIndex = 0; GetVisiableRange(ref firstVisiableIndex, ref lastVisiableIndex);//availableSize更新后,获取当前viewport内可放置的item的开始和结束索引 firstIdnex-lastIndex之间的item可能部分在viewport中也可能都不在viewport中。 UIElementCollection children = this.InternalChildren;//因为配置了虚拟化,所以children的个数一直是viewport区域内的个数,如果没有虚拟化则是ItemSource的整个的个数 IItemContainerGenerator generator = this.ItemContainerGenerator; //获得第一个可被显示的item的位置 GeneratorPosition startPosi = generator.GeneratorPositionFromIndex(firstVisiableIndex); int childIndex = (startPosi.Offset == 0) ? startPosi.Index : startPosi.Index + 1;//startPosi在chilren中的索引 using (generator.StartAt(startPosi, GeneratorDirection.Forward, true)) { int itemIndex = firstVisiableIndex; while (itemIndex <= lastVisiableIndex)//生成lastVisiableIndex-firstVisiableIndex个item { bool newlyRealized = false; var child = generator.GenerateNext(out newlyRealized) as UIElement; if (newlyRealized) { if (childIndex >= children.Count) base.AddInternalChild(child); else { base.InsertInternalChild(childIndex, child); } generator.PrepareItemContainer(child); } else { //处理 正在显示的child被移除了这种情况 if (!child.Equals(children[childIndex])) { base.RemoveInternalChildRange(childIndex, 1); } } child.Measure(new Size(this.ChildWidth, this.ChildHeight)); //child.DesiredSize//child想要的size itemIndex++; childIndex++; } } CleanUpItems(firstVisiableIndex, lastVisiableIndex); return new Size(double.IsInfinity(availableSize.Width) ? 0 : availableSize.Width, double.IsInfinity(availableSize.Height) ? 0 : availableSize.Height);//自身想要的size } protected override Size ArrangeOverride(Size finalSize) { Debug.WriteLine("----ArrangeOverride"); var generator = this.ItemContainerGenerator; UpdateScrollInfo(finalSize); int childPerRow = CalculateChildrenPerRow(finalSize); double availableItemWidth = finalSize.Width / childPerRow; for (int i = 0; i <= this.Children.Count - 1; i++) { var child = this.Children[i]; int itemIndex = generator.IndexFromGeneratorPosition(new GeneratorPosition(i, 0)); int row = itemIndex / childPerRow;//current row int column = itemIndex % childPerRow; double xCorrdForItem = 0; xCorrdForItem = column * availableItemWidth + (availableItemWidth - this.ChildWidth) / 2; Rect rec = new Rect(xCorrdForItem, row * this.ChildHeight, this.ChildWidth, this.ChildHeight); child.Arrange(rec); } return finalSize; } protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo) { base.OnRenderSizeChanged(sizeInfo); this.SetVerticalOffset(this.VerticalOffset); } protected override void OnClearChildren() { base.OnClearChildren(); this.SetVerticalOffset(0); } protected override void BringIndexIntoView(int index) { if (index < 0 || index >= Children.Count) throw new ArgumentOutOfRangeException(); int row = index / CalculateChildrenPerRow(RenderSize); SetVerticalOffset(row * this.ChildHeight); } #region IScrollInfo Interface public bool CanVerticallyScroll { get; set; } public bool CanHorizontallyScroll { get; set; } private Size extent = new Size(0, 0); public double ExtentWidth => this.extent.Width; public double ExtentHeight => this.extent.Height; private Size viewPort = new Size(0, 0); public double ViewportWidth => this.viewPort.Width; public double ViewportHeight => this.viewPort.Height; private Point offset; public double HorizontalOffset => this.offset.X; public double VerticalOffset => this.offset.Y; public ScrollViewer ScrollOwner { get; set; } public void LineDown() { this.SetVerticalOffset(this.VerticalOffset + this.ScrollOffset); } public void LineLeft() { throw new NotImplementedException(); } public void LineRight() { throw new NotImplementedException(); } public void LineUp() { this.SetVerticalOffset(this.VerticalOffset - this.ScrollOffset); } public Rect MakeVisible(Visual visual, Rect rectangle) { return new Rect(); } public void MouseWheelDown() { this.SetVerticalOffset(this.VerticalOffset + this.ScrollOffset); } public void MouseWheelLeft() { throw new NotImplementedException(); } public void MouseWheelRight() { throw new NotImplementedException(); } public void MouseWheelUp() { this.SetVerticalOffset(this.VerticalOffset - this.ScrollOffset); } public void PageDown() { this.SetVerticalOffset(this.VerticalOffset + this.viewPort.Height); } public void PageLeft() { throw new NotImplementedException(); } public void PageRight() { throw new NotImplementedException(); } public void PageUp() { this.SetVerticalOffset(this.VerticalOffset - this.viewPort.Height); } public void SetHorizontalOffset(double offset) { throw new NotImplementedException(); } public void SetVerticalOffset(double offset) { if (offset < 0 || this.viewPort.Height >= this.extent.Height) offset = 0; else if (offset + this.viewPort.Height >= this.extent.Height) offset = this.extent.Height - this.viewPort.Height; this.offset.Y = offset; this.ScrollOwner?.InvalidateScrollInfo(); this.trans.Y = -offset; this.InvalidateMeasure(); //接下来会触发MeasureOverride() } #endregion }}
9. Demo下载
最终效果:
下载:
链接: https://pan.baidu.com/s/1jHMBFM2 密码: 4csp
参考
1. Magentaize!——正确实现 WPF 中的 UI 虚拟化
2. GitHub - digimezzo/WPFControls: WPF Controls
3.WPF布局
- 【WPF】UI虚拟化之------自定义VirtualizingWrapPanel
- wpf DataGrid 虚拟化
- WPF 虚拟化面板
- WPF之路——实现自定义虚拟容器(实现VirtualizingPanel)
- WPF之自定义窗体
- WPF之自定义窗体
- WPF之自定义窗体
- WPF之自定义控件
- WPF UI布局之概述
- WPF之UI知识总结
- 自定义UI之ActionBar
- UI之自定义View
- wpf 自定义控件之Button
- C#学习之五---WPF-UI线程
- Wpf itemsControl listbox 虚拟化平滑滚动
- 高仿易信UI之自定义ActionBar
- Android UI之自定义SeekBar
- Android UI之自定义AutoCompleteTextView
- 基础数据机构之WeakHashMap源码分析
- 使用doc4j生成word文档
- 【Spring+SpringMVC+MyBatis深入学习及搭建】11.SpringMVC架构
- MySQL数据类型和常用字段属性总结
- java实现敏感词过滤
- 【WPF】UI虚拟化之------自定义VirtualizingWrapPanel
- discuz论坛用户--设置--修改头像不显示
- 微信开发遇到的问题及解决
- java 文件上传
- 前段node项目构建常见问题
- pl/sql developer 远程连接数据库本地网络服务配置
- spring boot 1.4.2.RELEASE+Thymeleaf+mybatis 集成通用maper,与分页插件:
- Go运行时bug调试过程解析
- 学习 shell 有什么好书推荐?