Silverlight/WPF中DependencyProperty使用陷阱一枚
今天有朋友写Silverlight代码遇到一个问题,让我一起看一下。这是他写的一个测试类:
class Foo : DependencyObject{ public List<int> Bars { get { return (List<int>)GetValue (BarsProperty); } set { SetValue (BarsProperty, value); } } public static readonly DependencyProperty BarsProperty = DependencyProperty.Register ( "Bars", typeof (List<int>), typeof (Foo), new PropertyMetadata (new List<int> ()));}
使用代码如下:
可见,当foo2刚刚创建时,Bars中已经有一个元素了。
SL/WPF达人们看到这里肯定已经笑了,我刚开始学习DependencyProperty时也曾在这个问题上卡住。
在Immediate Window中测试foo1与foo2的Bars属性:
果然,Bars其实是同一个List<int>对象。
朋友说,我上网查了,已经知道像List这些集合类属性不能用DependencyProperty Metadata的defaultValue来初始化,必须要在类的构造器中初始化(参考http://msdn.microsoft.com/zh-cn/library/cc903961(v=VS.95).aspx):
class Foo : DependencyObject{ public List<int> Bars { get { return (List<int>)GetValue (BarsProperty); } set { SetValue (BarsProperty, value); } } public static readonly DependencyProperty BarsProperty = DependencyProperty.Register ( "Bars", typeof (List<int>), typeof (Foo), new PropertyMetadata (null)); public Foo () { Bars = new List<int> (); }}
这样就能得到正常结果。但他不明白的是,为什么微软不为这种情况特别处理一下,而要留给开发人员这么一个陷阱?
说实话,我当时被问住了,因为我之前也只是机械地把结论当做一个开发的“注意事项”记了下来,至于原因,并没有深思。
---------------------
其实,从原理上思考一下,并不难得出结论。微软并不是不愿意处理,而是无法处理。
首先,这个问题并不是只有集合类型值才会遇到,所有的引用类型都可能遇到此问题,参考下面的示例:
class Foo : DependencyObject{ public List<int> Bars { get { return (List<int>)GetValue (BarsProperty); } set { SetValue (BarsProperty, value); } } public static readonly DependencyProperty BarsProperty = DependencyProperty.Register ( "Bars", typeof (List<int>), typeof (Foo), new PropertyMetadata (new List<int> ())); public int Length { get { return (int)GetValue (LengthProperty); } set { SetValue (LengthProperty, value); } } public static readonly DependencyProperty LengthProperty = DependencyProperty.Register ( "Length", typeof (int), typeof (Foo), new PropertyMetadata (5)); public class Data { public int Value { get; set; } } public Data MyData { get { return (Data)GetValue (MyDataProperty); } set { SetValue (MyDataProperty, value); } } public static readonly DependencyProperty MyDataProperty = DependencyProperty.Register ( "MyData", typeof (Data), typeof (Foo), new PropertyMetadata (new Data () { Value = 3 }));}
该类声明了三个DP:List<int>类型的Bars,int类型的Length和自定义类Data类型的MyData,均用DP的defaultValue进行初始化。调试结果如下:
可见,在foo2刚刚构造完成时,Bars和MyData属性都已经和foo1一致了,使用object.ReferenceEquals比较后证明确实均为同一实例。这也是可以料想到的结果,所谓的“集合”与其他的引用类型相比,并没有什么本质的特殊性。至于为何一般此问题讨论的都是集合的特殊性,以及MSDN上也特别说明是“集合类型”,我想可能是因为DependencyProperty常用的是一些基本的值类型,String虽是引用类型,却是Immutable的,因此也不会出现这个问题;那么要用到引用类型而又可能出问题的,最常见的就是集合类型了,因此MSDN要特别把这个问题拿出来说明一下。
---------------------
好,那现在问题变得一般化了:一般的引用类型,为何会存在此“陷阱”?
我们还需要从DP的原理说起。DP必须定义为静态成员,其本质是静态的哈希表(当然其实际实现要复杂得多,至于为什么这么实现,微软有很多这方面的介绍,比如可以支持资源、样式、动画、数据绑定等等)。而类的每个实例在这张静态表中就有一项,用来记录每个实例对应DP的值,这个值会使用DP的PropertyMetadata所指定的defaultValue进行初始化。我们使用SetValue和GetValue方法,其实就是在对这张表中的对应项进行读写。
说到这里,大家应该都已经明白问题所在了,值类型属性的值就是存储在哈希表中的,因此修改值类型属性不会影响其他实例的相同属性;而引用类型属性在哈希表中存储的只不过是一个引用,初始化为指向同一对象,当对该属性进行修改时(比如向集合中添加元素),其实是向所有实例所共享的对象中添加元素,因此,其他实例的属性也会受到影响。
---------------------
也许还有人会问,为什么微软不为每个实例初始化一个新的引用对象呢?不错,我也这么想过,但正如我前面所说,按照现在的设计,微软不一定是没有想到,很可能是做不到。实际上,在PropertyMetadata对象初始化之前,引用对象已经创建完成了:
new PropertyMetadata (new List<int> ())
思考一下这段代码的运行顺序,在调用PropertyMetadata的构造函数之前,List<int>对象已经构造完成,因此PropertyMetadata拿到的就只是一个引用,它无法知道如何去构造这个引用对象,因此无法为哈希表每项都创建一个新的实例,而只能老老实实地使用手里这个引用。
因此,对于初始值为引用类型的DependencyProperty来说,我们确实只能在类的构造方法中对齐初始化(虽然微软建议使用PropertyMetadata初始化)。
Bars = new List<int> ();
等等,别以为这就完了,这里使用了Bars属性,实际上是调用了SetValue方法。那对于readonly的DependencyProperty呢?事实上,在使用集合时,很明显这个属性本身最好是只读的,我们修改的是集合的元素,而不是集合这个属性本身。在WPF中,定义一个只读的DependencyProperty的方法可以参考这里(SL 4不支持RegisterReadOnly方法,可以自己实现ReadOnly的效果,参考这篇博客),代码如下:
public List<int> Bars{ get { return (List<int>)GetValue (barsPropertyKey.DependencyProperty); }}private static readonly DependencyPropertyKey barsPropertyKey = DependencyProperty.RegisterReadOnly ( "Bars", typeof (List<int>), typeof (Foo), new PropertyMetadata (null));
此时,使用了私有的DependencyPropertyKey类型字段代替了原来公开的DependencyProperty类型字段,Bars属性也去掉了set方法。那要如何进行初始化呢?
这时,SetValue方法的另一个重载就用上了,这个重载接受一个DependencyPropertyKey而不是一个DependencyProperty,而这正是RegisterOnly方法的返回值。(我原来一直奇怪为什么GetValue只有一个版本而SetValue有两个版本…)
代码如下:
public Foo (){ SetValue (barsPropertyKey, new List<int> ());}
---------------------
现在回到原来的问题上。
对于这样一个“陷阱”,难道真的没有办法?难道必须小心地把初始化放到类实例的构造函数中?
在Prism框架中,当我们向一个Region注册View时,可以使用IRegionManager的扩展方法RegisterViewWithRegion:
public static IRegionManager RegisterViewWithRegion ( this IRegionManager regionManager, string regionName, Func<object> getContentDelegate);
第三个参数,本应传入要注册的View对象,却传入了一个Func<object>,这是什么意思?我们再看一下实际使用代码:
_regionManager.RegisterViewWithRegion (RegionNames.SidebarRegion, () => _container.Resolve<ISidebarPresenter> ().View);
可见,实际传入的是一个匿名委托,这个委托告诉了RegionManager如何创建一个View。RegionManager得到这个委托后,用一个Dictinoary把它保存起来,到实际创建View的时候才去调用。因此_container.Resolve<ISidebarPresenter> ().View这段代码要到实际创建View时才会运行。同时,RegionManager只需反复调用委托,也具备了重复创建多个View实例的能力。
---------------------
这样的方法,也许WPF/SL会借鉴一下?或许某一天,我们可以写出这样的代码:
class Foo : DependencyObject{ public List<int> Bars { get { return (List<int>)GetValue (BarsProperty); } set { SetValue (BarsProperty, value); } } public static readonly DependencyProperty BarsProperty = DependencyProperty.Register ( "Bars", typeof (List<int>), typeof (Foo), new PropertyMetadata (() => new List<int> ()));}
而不用再去担心这样的“陷阱”。