WPF ListBox 显示图片内存无法释放问题

科技   教育   2024-06-19 08:03   北京  

 WPF ListBox 显示图片内存无法释放问题

WPF ListBox 显示图片问题

作 者:WPFDevelopersOrg - 驚鏵

  • 框架使用.NET8
  • Visual Studio 2022;

开发者反映了一个问题:使用 ListBox 加载多张图片,并进行来回滚动时,发现内存持续增长,最终达到了1.1GB,并且没有得到释放。

xaml 代码如下:

        <ListBox ItemsSource="{Binding ImageList}">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <Image Source="{Binding}" Width="200" Height="200"/>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>

csharp 代码如下:

DataContext = this;
ImageList = new ObservableCollection<ImageSource>();
var selectedPath = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Images");
var files = Directory.GetFiles(selectedPath);
foreach (var item in files)
{
    var bitmap = new BitmapImage();
    using (var fileStream = new FileStream(item, FileMode.Open, FileAccess.Read))
    {
        bitmap.BeginInit();
        bitmap.CacheOption = BitmapCacheOption.OnLoad;
        bitmap.StreamSource = fileStream;
        bitmap.EndInit();
        bitmap.Freeze();
    }
    ImageList.Add(bitmap);
}
Title = $"图片总数:{files.Length}";

尽管代码相对简单,只是使用 <Image Source="{Binding}" /> 绑定了一个 BitmapSource 对象,但每次实例化对象都会导致内存急剧增长。

优化前

问题原因是因为 `new BitmapImage`[1]  实例内存占用过高

BitmapSource 提供的两个依赖属性 DecodePixelHeightDecodePixelWidth。这些属性的主要作用是在加载图像时对其进行缩放,以减少内存占用并提高性能,尤其在处理大尺寸图像时非常有用。然而,当将这两个值设置为 100 时,发现内存占用被控制在最高 91,但同时加载的图片尺寸变小了。当将 DecodePixelHeightDecodePixelWidth 设置为 100 时,用作缩略图显示。当点击图片时做查看原图操作,可以将它们恢复为默认值 0

  bitmap.DecodePixelWidth = 100;
  bitmap.DecodePixelHeight = 100;

第一种方式 优化后

  • 内存在 90

另一种方式

再换一种方式需要新建一个自定义图片类用来替换 Image 并启用 ListBox 虚拟化并实现懒加载和卸载。

1.创建一个自定义的 ImageDrawingVisual 继承自 FrameworkElement 的自定义控件,内部使用 DrawingVisual 来绘制图片。

  • 并添加一个 Source 用于绑定图片路径
public class ImageDrawingVisual : FrameworkElement
{
    private DrawingVisual _drawingVisual;

    public static readonly DependencyProperty SourceProperty =
    DependencyProperty.Register(
        nameof(Source),
        typeof(string),
        typeof(ImageDrawingVisual),
        new PropertyMetadata(null, OnSourceChanged));

    public string Source
    {
        get => (string)GetValue(SourceProperty);
        set => SetValue(SourceProperty, value);
    }
    public ImageDrawingVisual()
    {
        _drawingVisual = new DrawingVisual();
        AddVisualChild(_drawingVisual);
        AddLogicalChild(_drawingVisual);
    }
    private static void OnSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is ImageDrawingVisual imageDrawingVisual)
        {
            imageDrawingVisual.LoadImage(e.NewValue as string);
        }
    }
    public void LoadImage(string imagePath)
    {
        using (var drawingContext = _drawingVisual.RenderOpen())
        {
            var bitmap = new BitmapImage();
            using (var fileStream = new FileStream(imagePath, FileMode.Open, FileAccess.Read))
            {
                bitmap.BeginInit();
                bitmap.CacheOption = BitmapCacheOption.OnLoad;
                bitmap.StreamSource = fileStream;
                bitmap.EndInit();
                bitmap.Freeze();
            }
            drawingContext.DrawImage(bitmap, new Rect(00, Width, Height));
        }
    }

    protected override int VisualChildrenCount => 1;

    protected override Visual GetVisualChild(int index)
    {
        if (index != 0)
        {
            throw new ArgumentOutOfRangeException();
        }

        return _drawingVisual;
    }
}

2.添加一个新的方法来清除 DrawingVisual 的图片:

 public void ClearImage()
 {
     using (var drawingContext = _drawingVisual.RenderOpen())
     {
         drawingContext.DrawRectangle(Brushes.Transparent, nullnew Rect(00, Width, Height));
     }
 }

3.创建一个自定义 DisposableVirtualizingStackPanel 继承自 VirtualizingStackPanel

  • 然后订阅 VirtualizingStackPanel.CleanUpVirtualizedItem 事件调用 ImageDrawingVisualClearImage 方法清除内容,绘制一个空的矩形。
  • 因为要清空一个 DrawingVisual 的内容时,通常需要使用 DrawingVisual.RenderOpen() 方法打开一个 DrawingContext 并在其中执行绘制操作。简单地打开 DrawingContext 并立即关闭它通常无法清空已有的内容。因此,绘制一个透明矩形是常用的技巧,它可以确保之前的绘制内容被覆盖,从而达到清空的效果。
public class DisposableVirtualizingStackPanel : VirtualizingStackPanel
{
    protected override void OnCleanUpVirtualizedItem(CleanUpVirtualizedItemEventArgs e)
    {
        base.OnCleanUpVirtualizedItem(e);
        if (e.UIElement is ListBoxItem listBoxItem)
        {
            ImageDrawingVisual image = FindVisualChild<ImageDrawingVisual>(listBoxItem);
            if (image != null)
            {
                image.ClearImage();
            }
        }
    }
    private T FindVisualChild<T>(DependencyObject parent) where T : DependencyObject
    {
        for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
        {
            var child = VisualTreeHelper.GetChild(parent, i);
            if (child != null && child is T correctlyTyped)
            {
                return correctlyTyped;
            }
            else
            {
                var result = FindVisualChild<T>(child);
                if (result != null)
                {
                    return result;
                }
            }
        }
        return null;
    }
}

4.使用代码如下:

<ListBox ItemsSource="{Binding ImageList}">
    <ListBox.ItemsPanel>
        <ItemsPanelTemplate>
            <local:DisposableVirtualizingStackPanel IsVirtualizing="True" VirtualizationMode="Recycling" />
        </ItemsPanelTemplate>
    </ListBox.ItemsPanel>
    <ListBox.ItemTemplate>
        <DataTemplate>
            <local:ImageDrawingVisual
                Width="200"
                Height="200"
                Source="{Binding}" />

        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>

第二种方式优化后

直接从生成的目录下运行 exe 效果看起来更加明显

如果你对此有任何更好的想法或建议,欢迎分享。

参考资料

[1]

new BitmapImage: https://referencesource.microsoft.com/#PresentationFramework/src/Framework/System/Windows/Controls/Image.cs,ea65ed9300b0595d


WPF开发者
「WPF开发者」现役微软MVP,专注 WPF 和 Avalonia 技术分享与传播。
 最新文章