65.9K
CodeProject 正在变化。 阅读更多。
Home

切勿将字符串添加到 WPF 列表

2017年3月13日

CPOL

9分钟阅读

viewsIcon

17943

downloadIcon

171

同样的建议也适用于许多其他项目类型,而不仅仅是列表。更确切地说,这些项目可以被使用,但意外的行为可能会使其成为一场噩梦。

引言

事实上,情况并非总是如此。

Stanisław Jerzy Lec

目录

引言

让我们更准确地界定问题的范围。问题不仅仅是某些特定列表控件的问题,例如 System.Windows.Controls.ListBox;这是所有 System.Windows.Controls.ItemsControl 类的普遍问题。

同样,问题不仅出现在将字符串用作控件项类型时;它也出现在许多类中,尤其是基本类型和*枚举类型*,大多数值类型和一些*引用类型*。

我稍后会解释这一点,所以现在我们可以跳到对问题的讨论,但首先我需要解释如何在准备好的测试用例中观察到这种行为。

演示应用程序

在讨论问题本身之前,我想介绍我的测试工具。我将只用一个演示/测试应用程序进行实验,我称之为“NeverEverAddStringsToWPFLists.exe”。想法是这样的:我们可以使用 System.Windows.Controls.ListBox 的同一实例。该控件以及任何其他 ItemsControl 都对它们的项类型不敏感。应用程序导航到不同的测试用例,这些测试用例显示在应用程序主窗口右侧的树视图中。每个测试用例都以不同的方式初始化列表框,并提供不同的方式来添加新项以及提取要显示为所选项的项的字符串表示。

为了抽象出这种行为,让我们为此定义一个抽象基类

internal abstract class ItemsControlHandlerBase {
   internal virtual void Populate(ListBox listBox) {
       listBox.ItemsSource = null;
       listBox.Items.Clear();
   } //Populate
   internal abstract string GetSelectedItem(ListBox listBox);
   internal abstract void AddNewItem(ListBox listBox, string value);
   internal virtual bool FilterTextBoxInput(string badText) { return false; }
   internal abstract string Help { get; }
} //PreparationBase

表示不同测试用例的类(其中一些代表问题的表现,其他用例演示解决方案)继承自此基类并覆盖五个虚拟方法;其中两个方法是*伪抽象*,而非abstract。下面,在问题解决方案部分,我将仅显示一个覆盖的方法 Populate,这足以揭示问题。

该解决方案适用于面向 .NET v. 3.5 的 Visual Studio 2008,以及面向 .NET v. 3.5 至 4.6.1 的 Visual Studio 2015,以确保它涵盖了大多数支持 WPF 的框架和开发工具。更高版本的 Visual Studio 提供自动转换为更高版本的解决方案和项目类型。可以通过批处理文件“build.bat”一键构建。

问题

要观察异常行为,请运行演示应用程序。它将导航到第一个测试用例,并在主窗口的底部面板上显示一些说明。

首先,以特定顺序单击列表项最终会显示选择的项超过一个的情况,如图片所示。重要的是要理解这里没有所谓的*多选*。只是主要的控件行为严重损坏了。

另一个异常可以通过滚动来揭示。演示应用程序代码总是滚动以将新添加的项带入视图。如果新添加的项是唯一的,它总是有效。假设其字符串(或下一个测试用例中的整数)内容与视图中已有的其他项相同,则不会发生滚动。

正如我们可以合理预期的那样,当列表视图中的所有项都不同时,不会发生任何错误。

字符串列表项

如果至少有两个相同值的字符串类型列表项,则会出现此问题。让我们添加两对相同的字符串

internal override void Populate(ListBox listBox) {
    base.Populate(listBox);
    listBox.Items.Add("one");
    listBox.Items.Add("two");
    listBox.Items.Add("one");
    listBox.Items.Add("two");
} //Populate

整数也存在同样的问题

很难期望整数类型能改善情况。事实上,对于原始类型或枚举类型等类型的异常行为,其解释比字符串更容易。让我们试试这个

internal override void Populate(ListBox listBox) {
    base.Populate(listBox);
    listBox.Items.Add(1);
    listBox.Items.Add(2);
    listBox.Items.Add(1);
    listBox.Items.Add(2);
} //Populate

数据绑定也存在同样的问题

数据绑定能帮助解决问题吗?让我们试试

StringObservableCollection list = new StringObservableCollection();
internal override void Populate(ListBox listBox) {
    base.Populate(listBox);
    list.Clear();
    list.Add("one");
    list.Add("two");
    list.Add("one");
    list.Add("two");
    listBox.ItemsSource = list;
} //Populate

并非如此——情况相同;而且不难弄清楚原因。

.NET 版本的问题

我测试了不同 .NET 版本的问题。测试表明,第二个问题,即通过滚动将项带入视图的问题,在所有 .NET 版本中仍然存在。

解释

由于问题仅在某些项相同时才出现,因此很明显这个问题与对象*标识*有关。

问题始于我们添加一个项。什么项,在哪里?显然,这是项被添加到任何 ItemsControl 类型实例的点,这发生在 System.Windows.Controls. ItemCollection.Add(object) 方法中。

从这个 API 来看,似乎很明显,任何对象都可以被添加,因为 System.Object 是所有 .NET 类型的基类型。确实,这*几乎*是真的,我们都知道细则中的魔鬼常常隐藏在“几乎”这个词下。事实上™ :-),应该有一条隐性规则:*所有对象都被假定是唯一的*。

不幸的是,Add 方法的参数不是泛型的,即使它是泛型的,我们也无法在当前的 .NET 中找到合适的泛型参数约束机制。在我看来(与其他许多软件开发者一致),这是 WPF 的一个主要*设计缺陷*。

在这个上下文中,“几乎”是什么意思?这是在某个 ItemControl 对象实例的项目集合范围内,关于*等价关系*的唯一性。反过来,这由(可能被覆盖的)System.Object.Equals(object) 方法完全定义。

如果不遵守此规则,控件的行为可能会非常混乱。很难解释所有可能的场景,所以让我们只解释异常的滚动行为。假设当我们向列表末尾添加一个项并尝试滚动到新添加的项时。让我们考虑将选择设置为对象“one”的时间点,我们有几个“two”对象在下面;最后一个在列表底部,超出可视范围(如果项)。如果我们添加另一个“two”并尝试使用 ScrollIntoView 方法滚动到它,会发生什么?此函数将尝试查找相同的对象进行滚动(与传递给此方法的 object 参数相同的对象),并且显然会在第一个“two”对象处停止。但这个对象已经在视图中,所以什么都不会发生。

很明显,这个问题也会出现在广泛的类型上:所有原始类型和枚举类型,大多数值类型。也很明显,这个问题不会出现在引用类型上,但并非全部。让我们看看……

对于*引用类型*,默认情况下,此方法仅返回 ReferenceEquals。这是大多数引用类型所做的,但*string 类型是其中一个例外*。String 对象,即使引用不同,如果两个比较对象的*内容*相同,也会从 Equals 返回 true。好像还不够,相同的 string 对象很少是引用不同的。首先,字符串是*不可变的*。有一个特殊的机制用于通过字符串驻留池重用相同的字符串对象。

对这个非常精细的机制的解释将使我们离本文主题太远,但这是值得了解的。另请参阅:String.InternString.IsInterned。不幸的是,我在官方 MSDN 文档中找不到评论或技术文章,只有一些非官方文章,例如这篇

话虽如此,要解决这个问题,我们需要确保所有项都是唯一的。但是考虑到用户总是可以添加第二个“one”或第二个“two”,如何保证这一点?为了确保两个不同的“two”对象是唯一的,只需将它们包装在某个具有默认相等方法的引用类型中即可。然后,我将展示如何通过覆盖 TObject.Equals 来重现问题

让我们就这样做。

解决方案

为了说明解决方案,我们只处理string类型。既然我们确保它有效,那么这个机制对所有其他类型都有效也就不足为奇了。让我们从最简单的情况开始。

带字符串的 ListBoxItem

internal override void Populate(ListBox listBox) {
    base.Populate(listBox);
    string[] items = new string[] { "one", "two", "one", "two", };
    foreach (var item in items) {
        ListBoxItem lbitem = new ListBoxItem();
        lbitem.Content = item;
        listBox.Items.Add(lbitem);
    } //loop
} //Populate

带数据绑定

数据绑定不会改变任何东西

internal override void Populate(ListBox listBox) {
    base.Populate(listBox);
    list.Clear();
    string[] items = new string[] { "one", "two", "one", "two", };
    foreach (var item in items) {
        ListBoxItem lbitem = new ListBoxItem();
        lbitem.Content = item;
        list.Add(lbitem);
    } //loop
    listBox.ItemsSource = list;
} //Populate

带自定义项类型

实际上,WPF 类 ListBoxItem 并没有做什么特别的事情,所以在*逻辑树*中绝对没有必要使用它。更确切地说,它是构建*视觉树*时自动创建的对象的类型。在前两个解决方案中,我只是重用了已有的类型;此外,在 XAML 编程期间,*Intellisense*会建议使用此类型。

事实上,任何自定义类型都可以扮演相同的角色。更确切地说,它应该是一种具有某种唯一标识的类型:通过两个不同的构造函数调用创建的两个对象应该被视为不相同的。话虽如此,即使是引用类型也可能表现不佳并导致完全相同的问题,如果它的标识规则以某种方式被覆盖;这是我们在 string 类型上遇到的问题。对于任何引用类型,默认标识规则仅使用*引用标识*,因此该类型,如果没有重新定义标识规则,将作为完美的项类型。

唯一的问题是:这种项类型的实例将如何显示在列表中?最简单的解决方案确实非常简单:应该覆盖 System.Object.ToString() 方法来显示所需的字符串值。下面是这种解决方案的示例

class MyItem {
    internal MyItem(string content) { this.Content = content; }
    internal string Content { get; set; }
    public override string ToString() { return Content; }
} //class MyItem

// ...

internal override void Populate(ListBox listBox) {
    base.Populate(listBox);
    string[] items = new string[] { "one", "two", "one", "two", };
    foreach (var item in items)
        listBox.Items.Add(new MyItem(item));
} //Populate

请注意,这种解决方案有很多好处。首先,Content 类型不必是未类型化的(System.ObjectListBoxItem)。这意味着数据(内容)可以在没有潜在不安全*类型转换*的情况下读取、赋值或操作。这是一个非常重要的优势。

带自定义项类型和数据绑定

这就是同一个自定义项类型 MyItem 如何与数据绑定一起工作

internal override void Populate(ListBox listBox) {
    base.Populate(listBox);
    list.Clear();
    string[] items = new string[] { "one", "two", "one", "two", };
    foreach (var item in items)
        list.Add(new MyItem(item));
    listBox.ItemsSource = list;
} //Populate} //Populate

如何重现问题?

有了我们的知识,我们可以故意重现问题,精确地找出问题所在并确认我们对其性质的理解。让我们就这样覆盖 System.Object.Equals

class MyItem {
    
    internal MyItem(string content) { this.Content = content; }
    internal string Content { get; set; }
    public override string ToString() { return Content; }
    
    // this is what can be used to reproduce the problem:
    public override bool Equals(object obj) {
        if (obj == null) return false;
        MyItem myItem = obj as MyItem; // dynamic cast
        if (myItem == null) return false;
        return myItem.Content == Content;
    } //Equals

    // when we override Equals, we also have to override GetHashCode:
    public override int GetHashCode() {
        return Content.GetHashCode();
    } //GetHashCode

} //class MyItem

这将立即将我们带入与裸字符串相同的情况。

最终注释

最初,我计划写一篇提示/技巧文章,但在写作过程中,我清楚地意识到这不会成为一篇连贯有用的提示。在这种情况下,重点应该是对 WPF 中总体情况的根本理解。换句话说,真正的提示不是“切勿在 WPF 列表中添加字符串”。它可以作为经验法则,或者只是为了让文章标题看起来更吸引人。

真正的提示是

不要让 WPF 愚弄你;仔细阅读细则。特别是当细则不可用时;在这种情况下,要学会“弦外之音”。

本质上,本文就是那种旨在填补空白的细则。我*幽默*的4月1日文章的最后一段中,甚至有一个更*严肃*的提示。:-)

我很乐意尝试回答任何问题并考虑所有建设性意见。希望这个问题能有所帮助。

© . All rights reserved.