ObservableCollection.Clear 留下幻影元素

ObservableCollection.Clear leaving phantom elements

我有一组转换器,用于在 canvas 上动态定位方块。这些正方形存储在一个可观察的集合中。 CanvasPositionScaleConverter将范围内的值转换为0到1之间的值。如果它转换的值超出指定范围,则会抛出Argument异常。

我的问题是,当我清除我的方块集合时,屏幕变空,“幻影”元素似乎被留下,转换器继续对其进行操作。因此,当 canvas 调整大小时,我仍然会抛出异常,即使 squares 集合已被清除。

为什么转换器仍然 运行 在这些已通过 Squares.Clear() 删除的元素上?


备注

使用 Squares = new ObservableCollection<Square>() 而不是 Squares.Clear() 仍然会导致此问题。


更新

如果从未添加 Sqaures 集合并按下 Resize,则不会发生此问题。仅当元素已从集合中删除时才会发生(显然如果元素尚未从集合中删除但超出有效范围)。

异常详情为

Exception thrown: 'System.ArgumentException' in LateExceptionMcve.dll
An unhandled exception of type 'System.ArgumentException' occurred in LateExceptionMcve.dll
Value cannot be greater than max

MCVE

MainWindow.xaml

<Window x:Class="LateExceptionMcve.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:LateExceptionMcve"
        mc:Ignorable="d"
        d:DataContext="{d:DesignInstance Type=local:MainWindow, IsDesignTimeCreatable=True}"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>

        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>

        <Viewbox Grid.Column="0"
                 Grid.ColumnSpan="2"
                 Grid.Row="0"
                 Stretch="Uniform">
            <Border BorderThickness="1"
                    BorderBrush="Black">
                <Border.Resources>
                    <local:CanvasScaleConverter x:Key="CanvasScaleConverter" />
                    <local:CanvasPositionScaleConverter x:Key="CanvasPositionScaleConverter" />
                </Border.Resources>
                <ItemsControl ItemsSource="{Binding Squares}"
                                  Width="{Binding Size, Converter={StaticResource CanvasScaleConverter}}"
                                  Height="{Binding Size, Converter={StaticResource CanvasScaleConverter}}">
                    <ItemsControl.ItemsPanel>
                        <ItemsPanelTemplate>
                            <Canvas />
                        </ItemsPanelTemplate>
                    </ItemsControl.ItemsPanel>
                    <ItemsControl.ItemContainerStyle>
                        <Style TargetType="ContentPresenter">
                            <Setter Property="Canvas.Left">
                                <Setter.Value>
                                    <MultiBinding Converter="{StaticResource CanvasPositionScaleConverter}">
                                        <Binding Path="X" />
                                        <Binding RelativeSource="{RelativeSource FindAncestor, AncestorType={x:Type Window}}" 
                                                     Path="DataContext.Min" />
                                        <Binding RelativeSource="{RelativeSource FindAncestor, AncestorType={x:Type Window}}" 
                                                     Path="DataContext.Max" />
                                        <Binding RelativeSource="{RelativeSource FindAncestor, AncestorType={x:Type Window}}" 
                                                     Path="DataContext.Size"
                                                     Converter="{StaticResource CanvasScaleConverter}" />
                                    </MultiBinding>
                                </Setter.Value>
                            </Setter>
                            <Setter Property="Canvas.Top">
                                <Setter.Value>
                                    <MultiBinding Converter="{StaticResource CanvasPositionScaleConverter}">
                                        <Binding Path="Y" />
                                        <Binding RelativeSource="{RelativeSource FindAncestor, AncestorType={x:Type Window}}" 
                                                     Path="DataContext.Min" />
                                        <Binding RelativeSource="{RelativeSource FindAncestor, AncestorType={x:Type Window}}" 
                                                     Path="DataContext.Max" />
                                        <Binding RelativeSource="{RelativeSource FindAncestor, AncestorType={x:Type Window}}" 
                                                     Path="DataContext.Size"
                                                     Converter="{StaticResource CanvasScaleConverter}" />
                                    </MultiBinding>
                                </Setter.Value>
                            </Setter>
                            <Setter Property="Width" Value="50" />
                            <Setter Property="Height" Value="50" />
                        </Style>
                    </ItemsControl.ItemContainerStyle>
                    <ItemsControl.ItemTemplate>
                        <DataTemplate>
                            <Grid Background="Red" />
                        </DataTemplate>
                    </ItemsControl.ItemTemplate>
                </ItemsControl>
            </Border>
        </Viewbox>

        <Button Grid.Column="0"
                Grid.Row="1"
                Content="Clear"
                Click="Clear" />

        <Button Grid.Column="1"
                Grid.Row="1"
                Content="Resize"
                Click="Resize" />
    </Grid>
</Window>

MainWindow.xaml.cs

using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Data;

namespace LateExceptionMcve
{
    public partial class MainWindow : INotifyPropertyChanged
    {
        public MainWindow()
        {
            InitializeComponent();
            DataContext = this;
            Min = 0;
            Max = 10;
            Size = Max - Min;
            Squares = new ObservableCollection<Square>
            {
                new Square
                {
                    X = 8,
                    Y = 7,
                },
            };
        }

        public event PropertyChangedEventHandler PropertyChanged;

        private void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }

        private void Resize(object sender, RoutedEventArgs e)
        {
            Min = 0;
            Max = 5;
            Size = Max - Min;
        }

        private void Clear(object sender, RoutedEventArgs e)
        {
            Squares.Clear();
        }

        private ObservableCollection<Square> _squares;

        public ObservableCollection<Square> Squares
        {
            get => _squares;
            set
            {
                _squares = value;
                OnPropertyChanged();
            }
        }

        private double _size;

        public double Size
        {
            get => _size;
            set
            {
                _size = value;
                OnPropertyChanged();
            }
        }

        private double _min;

        public double Min
        {
            get => _min;
            set
            {
                _min = value;
                OnPropertyChanged();
            }
        }

        private double _max;

        public double Max
        {
            get => _max;
            set
            {
                _max = value;
                OnPropertyChanged();
            }
        }
    }

    public sealed class Square : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        private void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }

        private double _x;

        public double X
        {
            get => _x;
            set
            {
                _x = value;
                OnPropertyChanged();
            }
        }

        private double _y;

        public double Y
        {
            get => _y;
            set
            {
                _y = value;
                OnPropertyChanged();
            }
        }
    }

    public sealed class CanvasScaleConverter : IValueConverter
    {
        private const double Scale = 100;

        public object Convert(
            object value,
            Type targetType,
            object parameter,
            CultureInfo culture)
        {
            if (value is double valueToScale)
            {
                return valueToScale * Scale;
            }

            return value;
        }

        public object ConvertBack(
            object value,
            Type targetType,
            object parameter,
            CultureInfo culture)
        {
            throw new NotSupportedException();
        }
    }

    public sealed class CanvasPositionScaleConverter : IMultiValueConverter
    {
        public object Convert(
            object[] values,
            Type targetType,
            object parameter,
            CultureInfo culture)
        {
            if (values.Length == 4 &&
                values[0] is double value &&
                values[1] is double min &&
                values[2] is double max &&
                values[3] is double canvasWidthHeight)
            {
                return canvasWidthHeight * RangeToNormalizedValue(min, max, value);
            }

            return values;
        }

        private static double RangeToNormalizedValue(
            double min,
            double max,
            double value)
        {
            if (min > max)
            {
                throw new ArgumentException("Min cannot be less than max");
            }

            if (value < min)
            {
                throw new ArgumentException("Value cannot be less than min");
            }

            if (value > max)
            {
                throw new ArgumentException("Value cannot be greater than max");
            }

            return (value - min) / (max - min);
        }

        public object[] ConvertBack(
            object value,
            Type[] targetTypes,
            object parameter,
            CultureInfo culture)
        {
            throw new NotSupportedException();
        }
    }
}

编辑

我已经在 WPF GitHub 存储库中填写了一个 bug report,希望这可以帮助找到这里发生的事情。

来自 miloush on the GitHub issue 的信息。

此问题的原因是即使 Squares 集合已被清除并且对象在屏幕上不再可见,但对象尚未被垃圾回收。

可以通过将 Clear 方法修改为类似

的方法来强制进行垃圾回收
private void Clear(object sender, RoutedEventArgs e)
{
    Squares.Clear();
    Dispatcher.BeginInvoke(new Action(GC.Collect), DispatcherPriority.ContextIdle);
}

这将(最终)清除对象。但是,这不是立竿见影的。即使在调用 Squares.Clear 之后只是 GC.Collect() 它也不是立即的。

此问题可能会在 WPF 中多次发生,包括在虚拟化等常见操作中。因此,最好更新转换器,以便它可以优雅地处理无效输入。

public sealed class CanvasPositionScaleConverter : IMultiValueConverter
{
    public object Convert(
        object[] values,
        Type targetType,
        object parameter,
        CultureInfo culture)
    {
        if (values.Length != 4 ||
            !(values[0] is double value) ||
            !(values[1] is double min) ||
            !(values[2] is double max) ||
            !(values[3] is double canvasWidthHeight))
        {
            return null;
        }

        try
        {
            return canvasWidthHeight * RangeToNormalizedValue(min, max, value);
        }
        catch (ArgumentException e) when (e.Message == ValueLessThanMin)
        {
            return 0;
        }
        catch (ArgumentException e) when (e.Message == ValueGreaterThanMax)
        {
            return canvasWidthHeight;
        }
    }

    // This is in a utility class normally
    private const string ValueLessThanMin = "Value cannot be less than min";
    private const string ValueGreaterThanMax = "Value cannot be greater than max";

    private static double RangeToNormalizedValue(
        double min,
        double max,
        double value)
    {
        if (min > max)
        {
            throw new ArgumentException("Min cannot be less than max");
        }

        if (value < min)
        {
            throw new ArgumentException(ValueLessThanMin);
        }

        if (value > max)
        {
            throw new ArgumentException(ValueGreaterThanMax);
        }

        return (value - min) / (max - min);
    }

    public object[] ConvertBack(
        object value,
        Type[] targetTypes,
        object parameter,
        CultureInfo culture)
    {
        throw new NotSupportedException();
    }
}