剖析依赖属性

语言: CN / TW / HK

这节来讲一下WPF中的 依赖属性 (Dependency Property)

【了解属性和字段】

我们知道,属性是面向对象语言中用来封装字段的外衣,它像是字段对外界的桥梁,我们可以通过属性来验证数据的合法性或控制对外的访问性等等。每个属性的背后都有其对应的一个字段做支撑,就算是自动属性,在编译时系统也会创建其字段,只不过自动属性是微软给我们的语法糖罢了。在C#中,属性最后是会编译成两个方法: get_属性名set_属性名 (如果是只读属性,则没有set方法,反之没有get方法)。

编译成方法,属性就不会占用太多空间,因为方法存在于内存公共的方法区,每个实例的创建不过是多一个指向该方法的指针。但是字段不一样,每个实例创建的创建,都会在内存中开辟对应的空间来存放字段,一个类中的字段越多,它在内存中占用的空间就越大,理解了这个理论,下面我们来正式说明什么是依赖属性,为什么要有依赖属性。

【什么是依赖属性】

我们使用一个控件,可以看到这个控件有很多的属性,有属性就有字段的内存开销,但实际上对于一个控件,我们大多数只会使用其部分常用属性,比如Button我们最常使用Content,Height等属性,那些不经常使用的属性相当于白白占用着内存。当我们写一个复杂的XAML页面,涉及到很多控件的使用时,这种浪费内存的现象就很严重。

对此,微软在WPF中引入了 依赖属性(Dependency Property),依赖属性允许没有自己的字段,可以通过Binding绑定到其它对象的属性或者说数据源上,从而获得值 ,这种依赖在其它对象上的属性,就是依赖属性,当明确了它的功能,我想大家就不会对依赖二字产生疑惑了,依赖属性没有自己的字段,只在使用时通过Binding从别的对象身上获取,给自己临时创建内存空间,这样不使用就不会有多余内存消耗。

包含依赖属性的对象称为 依赖对象(Dependency Object) ,这种对象需要继承DependencyObject这个基类,实际上,WPF中的控件,都继承了DependencyObject这个类,控件中的大部分属性都是依赖属性,这样我们才能通过Binding去绑定值(不熟悉Binding的同学可以参见前文Binding(一):数据绑定系列),才不会有内存浪费现象的发生。

【从代码中学习依赖属性】

下面我们通过代码来学习一下如何声明并使用依赖属性,请先看我写好的一段代码:

public class Pikachu : DependencyObject
{
public static readonly DependencyProperty PikachuNameProperty =
DependencyProperty.Register("PikachuName",typeof(string),typeof(Pikachu));
}

上文说到,使用依赖属性必须要 继承DependencyObject类 ,另外,声明

依赖属性,需要 使用public static readonly三个修饰符修饰 ,实例依赖属性也不是通过new操作符,而是 通过DependencyProperty的Register方法来获取

依赖对象的名字,有个约定,就是以 Property 为后缀,在C#中有很多命名约定,比如接口用 I 做前缀,特性用 Attribute 做后缀等等,这样做都是为了有个良好的命名规范,做到见名知意。

Register方法有三个重载,此处用的是其三个参数的重载,它还有四个参数和五个参数的重载。

  • 第一参数是指定依赖属性的包装器名称是什么(包装器就是用来包装依赖属性的,通过一个属性来包装依赖属性供外部使用,具体下文会讲,此处先做了解)

  • 第二个参数是指定依赖属性要存储的值的类型是什么

  • 第三个参数是指定依赖属性属于哪个类的,或者说是为哪个类定义依赖属性

  • 其它重载中第四个参数是指定依赖属性的源数据,用于提供给调用者此依赖属性的信息

  • 其它重载中第五个参数是自定义的依赖属性生成时的验证回调

声明了依赖属性,但是如何给依赖属性赋值呢,这就要用到DependencyObject基类中的方法了,我们使用其中的 SetValue 方法和 GetValue 方法来操作依赖属性的值,请看下面改动后的代码:

public class Pikachu : DependencyObject
{
public string PikachuName
{
get => (string)GetValue(PikachuNameProperty);
set => SetValue(PikachuNameProperty, value);
}


public static readonly DependencyProperty PikachuNameProperty =
DependencyProperty.Register("PikachuName", typeof(string), typeof(Pikachu));
}

上述代码,就是一个比较完善的声明依赖属性并通过包装器将依赖属性暴露出去的例子,属性PikachuName就是依赖属性的包装器,在get块中通过GetValue方法传入依赖属性的名字获取依赖属性的值,在Set块中通过SetValue方法,给依赖属性赋值,对依赖属性的这层包装,使得我们在外部操作依赖属性变得简单,这也是为什么我们在正常使用中感觉不到依赖属性的存在,因为字段也好,依赖属性也好,我们在外部看到的操作的都是它的属性。

下面通过一个实例展示一下依赖属性的使用:

前台代码是一个名为btn_show的Button控件,后台代码如下:

public MainWindowBase()
{
InitializeComponent();
this.DataContext = this;
Data = "我是皮卡丘";
Pikachu pikachu = new Pikachu();
//使用Binding操作类将皮卡丘对象的皮卡丘名字依赖属性关联到Data上
BindingOperations.SetBinding(pikachu,Pikachu.PikachuNameProperty, new Binding("Data") { Source = this });
//将按钮的Content依赖属性绑定到皮卡丘的皮卡丘名字包装器上
btn_show.SetBinding(Button.ContentProperty, new Binding(nameof(pikachu.PikachuName)) { Source = pikachu });
}

这个例子的逻辑是有一个名为Data的属性作为数据源,先将皮卡丘对象的依赖属性绑定到Data数据源上,再将Button的Content依赖属性绑定到皮卡丘对象的依赖属性包装器上,这就形成了一个Binding链,运行效果如下:

整个过程中,只有Data属性是有字段在背后支撑的,它存储了“我是皮卡丘”这个数据,皮卡丘对象和Button对象都是依赖属性,不占内存空间,它们之间使用Binding关联,形成数据通道,这样就实现了一块内存,供给多处使用。按照之前的编程模式,需要皮卡丘和Button各自开辟一段空间存储Data来的数据,现在由三块内存节省为一块内存,这就是依赖属性对于节省内存的效果。

【从源码分析依赖属性】

下面我们来分析一下,为什么依赖属性不是用new实例,而是要注册,以及Get/SetValue的操作依赖属性值的原理。

我们先从Register方法看起:

Register的三个和四个参数的重载都指向了五个参数的重载,我们主要看一下这五参数重载的方法里边都有什么。方法体里边,前几行实际上是一些验证代码,当参数有误时,会抛出异常。紧接着的是一个返回依赖属性对象的 RegisterCommon 方法,从名字和返回值来看这就是最核心的方法了,我们接着跟进去看:

代码内部第一行使用FromNameKey生成了一个 key 对象,这个FromNameKey是Dependency类的一个内部类,它构造器需要传入的包装器名称和依赖对象所在的类的Type,    这个类及构造器代码如下:

构造器第三行代码比较重要,我们可以看到,这个类通过传入的参数两者异或生成了一个hashcode,经过这个异或运算,那就保证了同一个类,同样的包装器名称生成的hashcode是一样的。

同时这个类重写了GetHashCode方法,就是把异或生成的hashcode返回出去了。

了解了这个类,我们再回到RegisterCommon类中,接着往下看,下面是一个线程同步块:

这个代码块里边,出现了一个PropertyFromName参数,看样子是个集合,我们找到这个属性的定义处,发现它是个全局的HashTable:

那这个代码块的意思就明了了,目的就是判断生成的Key是否已存在,如果存在,就抛异常,从这里就控制了, 在类内部定义两个相同包装器名称的依赖属性是不允许的 ,实际上也必须是这样,同一个类中,属性肯定是不能同名的,依赖属性也是如此,那我们从此处还能获得一个信息,就是PropertyFromName肯定和我们要生成的依赖属性有很大的关系,具体我们继续往下看代码:

如果没有传入依赖属性的源数据,系统会生成默认的源数据,在往下看是一些校验逻辑,具体内容此处就不分析,有兴趣的可以自己点进去看,紧接着就到代码核心了:

经过层层把关,依赖属性终于new出来了,new出来后,下面我们又看到PropertyFromName的影子了:

原来PropertyFromName是存储依赖属性的一个集合,所有new出来的依赖对象都存储在这里,它的hashcode就是之前通过FromNameKey类异或出的。

最后,通过return,返回了这个依赖属性 ,至此,依赖属性的整个创建过程解析完毕。

我们再来了解一下依赖属性的值的读取:

先看GetValue方法:

前几句代码还是校验,核心代码是最后一句,此处涉及到了依赖属性的GlobalIndex属性,这个属性是系统经过一系列算法得出的,具有唯一性,我们看到,这个GlobalIndex传入了名为LookupEntry方法中,Entry是入口的意思,从方法名上看,我们能得知,是根据GlobalIndex找到了一个访问入口,实际上,这个入口就是依赖属性值的访问入口。

我们进入GetValueEntry方法中查看,会找到一个名为_effectiveValues的属性,这是一个EffectiveValueEntry类型的数组,原来,依赖属性所有的值都存放在这个数组中,根据依赖属性唯一的GlobalIndex,我们就能从这个数组中找到依赖属性的值。

再来看SetValue方法:

其实明白了GetValue,SetValue也就很好理解了,道理都是一样的,根据依赖属性的GlobalIndex值获取到入口,更新上新值,我们进入SetValueCommon方法中看,代码比较繁琐,实际上的流程有三块:

  • 判断值是不是 DependencyProperty.UnsetValue ,如果是,则清除依赖属性的值,所以我们要想对依赖属性设置空值,不要用null,要用DependencyProperty.UnsetValue

  • 判断能否找到入口,如果没有入口,则新建一个入口对象,将值放进去,有入口则更新值

  • 最后,通过UpdateEffectiveValue方法对依赖属性的值做一些处理

至此依赖属性的读取流程解析完毕。

喜欢就来个三连,让更多人因你而受益