有没有比使用动态进行 WPF 路径索引更好的选择?

问题描述

有关在 XAML PropertyPaths 中使用索引器的 Microsoft 文档说要使用:

<Binding Path="[key]" ... />

然而,key 必须进行硬编码才能使用这种类型的索引。

我知道“灵活”索引的解决方案需要 MultiBinding 的形式:

<MultiBinding Converter="{StaticResource BasicIndexerConverter}">
    <Binding Path="Indexable"/>
    <Binding Path="Key"/>
</MultiBinding>

public class BasicIndexerConverter : IMultiValueConverter
{
    public object Convert(object[] values,Type targettype,object parameter,CultureInfo culture)
    {
        string[] indexable = (string[])values[0]; //Assuming indexable is type string[]
        int key = (int)values[1];
        return indexable[key];
    }
    public object[] ConvertBack(object values,Type[] targettype,CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

如果您注意到我的评论,这仅适用于 string[] 对象的绑定。但是,可以为类创建自定义索引运算符,我想对此进行概括。我不能对每个索引器运算符键类型和返回类型进行硬编码,对吗?至少没有 dynamic

public class DynamicIndexerConverter : IMultiValueConverter
{
    public object Convert(object[] values,CultureInfo culture)
    {
        dynamic indexable = (dynamic)values[0];
        dynamic index = (dynamic)values[1];
        return indexable[index];
    }

    public object[] ConvertBack(object values,CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

这非常简单,考虑到绑定在运行时已解决,我认为 dynamic 的用途并不可怕。但是我想知道是否有一个不太复杂的静态实现,或者是否可以提供具有更好性能的相同功能。这是动态的罕见用例之一吗?

解决方法

示例(一般没有小细节):

public class DynamicIndexerConverter : IMultiValueConverter
{
    public object Convert(object[] values,Type targetType,object parameter,CultureInfo culture)
    {
        if (values[0] is IList list)
           return list[int.Parse(values[1].ToString())];

        if (values[0] is IDictionary dict)
           return dict[values[1]];
       
        dynamic indexable = (dynamic)values[0];
        dynamic index = (dynamic)values[1];
        return indexable[index];
    }

    public object[] ConvertBack(object values,Type[] targetType,CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}
,

这个问题很有教育意义,因为我没有一堆 ViewModel,但我想知道如果我有很多的话,是否有更好的方法。

为了澄清问题的教育性质,我添加了第二个更广泛地涵盖该主题的答案。 在答案的最后,会有一个结果尽可能接近于使用索引绑定的解决方案。

1) 索引类型。
尽管文档指出可以显式指定密钥的类型,但实际上它并不能那样工作。
The type of each parameter can be specified with parentheses

绑定只接受一个索引作为字符串的一部分。
应用此索引时,将确定索引的类型并尝试将字符串转换为该类型。
WPF 中所有常用的类型都声明了自己的 TypeConverter。 因此,这对他们来说不是问题。
但是在创建自定义类型时,很少实现这样的转换器。 而且您将无法为这种类型的键设置字符串索引。
如果索引的类型可以采用字符串可以转换成的几种类型,那么也存在歧义。
假设索引类型是对象,并且存在索引(字符串)“0”、(int)0和(double)0的元素。
几乎不可能预测使用 Path = "0" 的结果会是什么。 最有可能是“0”,但并非总是如此。

2) 索引实例对比。
所以要获取索引,会从一个字符串中创建一个实例,那么默认情况下它不会等于在集合中创建索引时使用的实例。
要获取索引,您需要实现按值比较。
这已经为默认值类型实现了。
但是对于自定义的,需要另外实现。
这可以通过覆盖 Equals 和 GetHashCode 在类型中直接实现,也可以在实现 IEqualityComparer 接口的单独类中实现。

3) 集合类型。
由于我们已经得出了索引必须提供按值比较的结论,这意味着集合必须具有唯一键。
因此我们有两种类型的集合。
第一个是index是元素的序号:Array、List、Collection等实现了IList的类型。
第二个是字典,即 IDictionary 的一个实现。

当然,您可以不实现接口,但仍然必须满足它们所需的实现原则。

4) 索引可变性。
如果索引(以下称为键)是值类型,按值进行比较,则没有问题。
由于不可能隐式更改存储在集合中的内部密钥。
要更改它,您需要调用集合方法,集合将执行与此相关的所有必要更改。

但是这里是如何处理引用类型...
Equals 方法直接比较两个实例的值。
但是在找到集合中的一系列键之前,会计算 HashCode。
如果key被放入range后,它的Hash会发生变化,那么在搜索的时候,可能会得到一个不存在的错误结果。

有两种方法可以解决这个问题。
第一种是在保存之前创建密钥的副本。
克隆后,对原始实例的更改不会导致对保存的克隆的更改。
这样的实现适用于Binding。
但是在 Sharp 中使用它时,它可能会显示出程序员意想不到的行为。
第二个(最常用的)是所有用于计算HashCode的key值必须是不可变的(即只读字段和属性)。

5) 通过绑定自动更新
对于属性,自动更新由 INotifyPropertyChanged 接口提供。
对于索引集合,INotifyCollectionChanged 接口。

但是对于字典,没有默认接口来通知它的变化。
因此,如果您将绑定设置为字典,然后与此键关联的值将在此字典中更改,则不会发生自动更新。
各种转换器/多转换器也不能解决这个问题。

ObservableDictionary 的实现有很多,但都非常糟糕,是不得已而为之。
即使是 MS 本身的实现 «internal ObservableDictionary»,在发生任何更改时,都会导致更新到此字典的所有绑定,从而创建一个带有空参数的 PropertyChanged。

6) 我的发现。
应该以各种可能的方式避免在绑定中使用索引。
它们只能用于在其他情况下肯定无法解决的情况。 在实施它时,必须考虑所有上述细微差别。
或许,除了他们,还有一些我现在没有想到的。

7) 具有可变路径的通用绑定。
作为一个这样的“世界末日解决方案”,我创建了一个代理。
它的工作原理是字符串插值。
有一行带有插值表达式。
有一组参数可以插入它。
插值表达式可以包含索引和某种复合路径。

该解决方案包括几个类:一个主代理、一个简单的辅助代理、一个(或多个)值到数组转换器、标记扩展以简化参数数组的绑定。
所有这些类都可以单独应用。

using System;
using System.Windows;
using System.Windows.Data;


namespace Proxy
{
    /// <summary> Provides a <see cref="DependencyObject"/> proxy with
    /// one property and an event notifying about its change. </summary>
    public class ProxyDO : DependencyObject
    {
        /// <summary> Property for setting external bindings. </summary>
        public object Value
        {
            get { return (object)GetValue(ValueProperty); }
            set { SetValue(ValueProperty,value); }
        }

        // Using a DependencyProperty as the backing store for Value.  This enables animation,styling,binding,etc...
        public static readonly DependencyProperty ValueProperty =
            DependencyProperty.Register(nameof(Value),typeof(object),typeof(ProxyDO),new PropertyMetadata(null));

        protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e)
        {
            base.OnPropertyChanged(e);
            ValueChanged?.Invoke(this,e);
        }

        /// <summary> An event that occurs when the value of any
        /// <see cref="DependencyProperty"/> of this object changes.</summary>
        public event EventHandler<DependencyPropertyChangedEventArgs> ValueChanged;

        /// <summary> Returns <see langword="true"/> if the property value <see cref="Value"/> is not set.</summary>
        public bool IsUnsetValue => Equals(ReadLocalValue(ValueProperty),DependencyProperty.UnsetValue);

        /// <summary> Clears all <see cref="DependencyProperty"/> this <see cref="ProxyDO"/>.</summary>
        public void Reset()
        {
            LocalValueEnumerator locallySetProperties = GetLocalValueEnumerator();
            while (locallySetProperties.MoveNext())
            {
                DependencyProperty propertyToClear = locallySetProperties.Current.Property;
                if (!propertyToClear.ReadOnly)
                {
                    ClearValue(propertyToClear);
                }
            }

        }

        /// <summary> <see langword="true"/> if the property <see cref="Value"/> has Binding.</summary>
        public bool IsValueBinding => BindingOperations.GetBindingExpressionBase(this,ValueProperty) != null;

        /// <summary> <see langword="true"/> if the property <see cref="Value"/> has a binding
        /// and it is in the state <see cref="BindingStatus.Active"/>.</summary>
        public bool IsActiveValueBinding
        {
            get
            {
                var exp = BindingOperations.GetBindingExpressionBase(this,ValueProperty);
                if (exp == null)
                    return false;
                var status = exp.Status;
                return status == BindingStatus.Active;
            }
        }

        /// <summary>Setting the Binding to the Property <see cref="Value"/>.</summary>
        /// <param name="binding">The binding to be assigned to the property.</param>
        public void SetValueBinding(BindingBase binding)
            => BindingOperations.SetBinding(this,ValueProperty,binding);
    }
}
using System;
using System.Windows;
using System.Windows.Data;

namespace Proxy
{
    public class PathBindingProxy : Freezable
    {

        /// <summary>
        /// The value obtained from the binding with the interpolated path.
        /// </summary>
        public object Value
        {
            get { return (object)GetValue(ValueProperty); }
            private set { SetValue(ValuePropertyKey,value); }
        }

        private static readonly DependencyPropertyKey ValuePropertyKey =
            DependencyProperty.RegisterReadOnly(nameof(Value),typeof(PathBindingProxy),new PropertyMetadata(null));
        /// <summary><see cref="DependencyProperty"/> for property <see cref="Value"/>.</summary>
        public static readonly DependencyProperty ValueProperty = ValuePropertyKey.DependencyProperty;


        /// <summary>
        /// The source to which the interpolated path is applied.
        /// The default is an empty Binding.
        /// When used in Resources,a UI element gets his DataContext.
        /// </summary>
        public object DataContext
        {
            get { return (object)GetValue(DataContextProperty); }
            set { SetValue(DataContextProperty,value); }
        }

        /// <summary><see cref="DependencyProperty"/> для свойства <see cref="DataContext"/>.</summary>
        public static readonly DependencyProperty DataContextProperty =
            DependencyProperty.Register(nameof(DataContext),new PropertyMetadata(null));




        /// <summary>
        /// String to interpolate the path.
        /// </summary>
        public string InterpolatedPath
        {
            get { return (string)GetValue(InterpolatedPathProperty); }
            set { SetValue(InterpolatedPathProperty,value); }
        }

        /// <summary><see cref="DependencyProperty"/> для свойства <see cref="InterpolatedPath"/>.</summary>
        public static readonly DependencyProperty InterpolatedPathProperty =
            DependencyProperty.Register(nameof(InterpolatedPath),typeof(string),new PropertyMetadata(null,(d,e) => ((PathBindingProxy)d).ChangeBinding()));



        /// <summary>
        /// Array of interpolation arguments
        /// </summary>
        public object[] Arguments
        {
            get { return (object[])GetValue(ArgumentsProperty); }
            set { SetValue(ArgumentsProperty,value); }
        }

        /// <summary><see cref="DependencyProperty"/> для свойства <see cref="Arguments"/>.</summary>
        public static readonly DependencyProperty ArgumentsProperty =
            DependencyProperty.Register(nameof(Arguments),typeof(object[]),e) => ((PathBindingProxy)d).ChangeBinding()));

        private void ChangeBinding()
        {
            string path = InterpolatedPath;
            string stringPath;
            if (string.IsNullOrWhiteSpace(path))
            {
                stringPath = string.Empty;
            }
            else
            {
                object[] args = Arguments;
                if (args == null || args.Length == 0)
                {
                    stringPath = path;
                }
                else
                {
                    stringPath = string.Format(path,args);
                }
            }
            if (this.stringPath != stringPath)
            {
                this.stringPath = stringPath;
                Path = stringPath;
                proxy.SetValueBinding(new Binding($"DataContext.{stringPath}") { Source = this });
            }
        }




        /// <summary>
        /// Path obtained by string interpolation.
        /// </summary>
        public string Path
        {
            get { return (string)GetValue(PathProperty); }
            private set { SetValue(PathPropertyKey,value); }
        }

        private static readonly DependencyPropertyKey PathPropertyKey =
            DependencyProperty.RegisterReadOnly(nameof(Path),new PropertyMetadata(null));
        /// <summary><see cref="DependencyProperty"/> for property <see cref="Path"/>.</summary>
        public static readonly DependencyProperty PathProperty = PathPropertyKey.DependencyProperty;

        private readonly ProxyDO proxy = new ProxyDO();
        private static readonly Binding bindingEmpty = new Binding();
        private string stringPath;

        public PathBindingProxy()
        {
            proxy.ValueChanged += OnValueChanged;
            BindingOperations.SetBinding(this,DataContextProperty,bindingEmpty);
            InterpolatedPath = string.Empty;
        }

        private void OnValueChanged(object sender,DependencyPropertyChangedEventArgs e)
        {
            Value = e.NewValue;
        }

        protected override Freezable CreateInstanceCore()
        {
            throw new NotImplementedException();
        }
    }

}
using System;
using System.Globalization;
using System.Windows.Data;
using System.Windows.Markup;

namespace Converters
{
    [ValueConversion(typeof(object),typeof(object[]))]
    public class ToArrayConverter : IValueConverter,IMultiValueConverter
    {
        public object Convert(object value,CultureInfo culture)
            => new object[] { value };

        public object Convert(object[] values,CultureInfo culture)
        {
            return values.Clone();
        }

        public object ConvertBack(object value,CultureInfo culture)
        {
            throw new NotImplementedException();
        }

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

        public static ToArrayConverter Instance { get; } = new ToArrayConverter();
    }

    public class ToArrayConverterExtension : MarkupExtension
    {
        public override object ProvideValue(IServiceProvider serviceProvider)
            => ToArrayConverter.Instance;
    }
}
using Converters;
using System.Windows.Data;

namespace Proxy
{
    public class ArrayBinding : Binding
    {
        public ArrayBinding()
            : base()
        {
            Converter = ToArrayConverter.Instance;
        }
        public ArrayBinding(string path)
            : base(path)
        {
            Converter = ToArrayConverter.Instance;
        }
    }

}
using Converters;
using System.Windows.Data;

namespace Proxy
{
    public class ArrayMultiBinding : MultiBinding
    {
        public ArrayMultiBinding()
            : base()
        {
            Converter = ToArrayConverter.Instance;
        }
    }

}

使用示例。

using System.Windows;

namespace InterpolationPathTest
{
    public class TestViewModel
    {
        public int[][] MultiArray { get; } = new int[10][];

        public Point Point { get; } = new Point(123.4,567.8);
        public TestViewModel()
        {
            for (int i = 0; i < 10; i++)
            {
                MultiArray[i] = new int[10];
                for (int j = 0; j < 10; j++)
                {
                    MultiArray[i][j] = i * j;
                }
            }
        }
    }
}
<Window x:Class="InterpolationPathTest.TestWindow"
        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:InterpolationPathTest" xmlns:proxy="clr-namespace:Proxy;assembly=Common"
        mc:Ignorable="d"
        Title="TestWindow" Height="450" Width="800">
    <Window.DataContext>
        <local:TestViewModel/>
    </Window.DataContext>
    <UniformGrid Columns="1">
        <StackPanel Orientation="Horizontal" VerticalAlignment="Center" HorizontalAlignment="Center">
            <StackPanel.Resources>
                <proxy:PathBindingProxy x:Key="multiProxy" InterpolatedPath="MultiArray[{0}][{1}]">
                    <proxy:PathBindingProxy.Arguments>
                        <proxy:ArrayMultiBinding>
                            <Binding Path="Text" ElementName="left"/>
                            <Binding Path="Text" ElementName="right"/>
                        </proxy:ArrayMultiBinding>
                    </proxy:PathBindingProxy.Arguments>
                </proxy:PathBindingProxy>
            </StackPanel.Resources>
            <TextBox x:Name="left" Text="5"
                     Width="{Binding ActualHeight,Mode=OneWay,RelativeSource={RelativeSource Self}}"
                     HorizontalContentAlignment="Center"/>
            <TextBlock Text=" * "/>
            <TextBox x:Name="right" Text="7"
                     Width="{Binding ActualHeight,RelativeSource={RelativeSource Self}}"
                     HorizontalContentAlignment="Center"/>
            <TextBlock Text=" = "/>
            <TextBlock Text="{Binding Value,Source={StaticResource multiProxy}}"/>
        </StackPanel>
        <StackPanel  Orientation="Horizontal" VerticalAlignment="Center" HorizontalAlignment="Center">
            <StackPanel.Resources>
                <proxy:PathBindingProxy x:Key="pointProxy"
                                        InterpolatedPath="Point.{0}"
                                        Arguments="{proxy:ArrayBinding Text,ElementName=enterXY}"/>
            </StackPanel.Resources>
            <TextBlock Text="{Binding Point}" Margin="0,50,0"/>
            <TextBlock Text="Enter X or Y: "/>
            <TextBox x:Name="enterXY"
                     Width="{Binding ActualHeight,RelativeSource={RelativeSource Self}}"
                     HorizontalContentAlignment="Center"/>
            <TextBlock>
                <Run Text=" ="/>
                <Run Text="{Binding Value,Source={StaticResource pointProxy},Mode=OneWay}" />
            </TextBlock>
        </StackPanel>
    </UniformGrid>
</Window>