[WPF]淺析依賴屬性(DependencyProperty)

czwy發表於2023-09-27

在WPF中,引入了依賴屬性這個概念,提到依賴屬性時通常都會說依賴屬效能節省例項對記憶體的開銷。此外依賴屬性還有兩大優勢。

  • 支援多屬性值,依賴屬性系統可以儲存多個值,配合Expression、Style、Animation等可以給我們帶來很強的開發體驗。
  • 加入了屬性變化通知,限制、驗證等功能。方便我們使用少量程式碼實現以前不太容易實現的功能。

本文將主要介紹依賴屬性是如何存取資料的以及多屬性值的取值優先順序。

CLR屬性

CLR屬性是private欄位安全訪問的封裝

物件例項的每個private欄位都會佔用一定的記憶體,欄位被CLR屬性封裝起來,每個例項看上去都帶有相同的屬性,但並不是每個例項的CLR屬性都會多佔一點記憶體。因為CLR屬性是一個語法糖,本質是Get/Set方法,再多的例項方法也只有一個複製。

以TextBlock為例,共有107個屬性,但通常使用的最多的屬性是Text,FontSize,FontFamily,Foreground這幾個屬性,大概有100個左右屬性是沒有使用的。若按照CLR屬性分配空間,假設每個屬性都封裝了一個4Byte的欄位,一個5列1000行的列表浪費的空間就是4×100×5×1000≈1.9M。而依賴屬性則是省下這些沒有用到的屬性所需的空間,其關鍵就在於依賴屬性的宣告和使用。

依賴屬性的宣告和使用

依賴屬性的使用很簡單,只需要以下幾個步驟就可以實現:

  1. 讓所在型別直接或間接繼承自DependecyObject。在WPF中,幾乎所有的控制元件都間接繼承自DependecyObject
  2. 宣告一個靜態只讀的DependencyProperty型別變數,這個靜態變數所引用的例項並不是透過new運算子建立,而是使用簡單的單例模式透過DependencyProperty.Register建立的,下文會對這個方法進行介紹。
  3. 使用依賴屬性的例項化包裝屬性讀寫依賴屬性。
    按照以上步驟可以寫出如下程式碼:
public class ValidationParams:DependencyObject
{
    public object Param1
    {
        get { return (object)GetValue(Param1Property); }
        set { SetValue(Param1Property, value); }
    }

    // Using a DependencyProperty as the backing store for Data.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty Param1Property =
        DependencyProperty.Register("Param1", typeof(object), typeof(ValidationParams), new PropertyMetadata(null));
}

程式碼中Param1Property才是真正的依賴屬性,Param1是依賴屬性的包裝器,這裡有一個命名約定,依賴屬性的名稱是對應包裝器名稱+Property組成。在Visual studio中輸入propdp,然後Tab鍵就會自動生成依賴屬性以及包裝器的程式碼片段,然後根據實際情況修改相應的引數和型別。

Register方法的第一個引數為string型別,用來指明作為依賴屬性包裝器的CLR屬性;第二個引數指定依賴屬性儲存什麼型別的值,第三個引數指明依賴屬性的宿主是什麼型別,第四個引數是依賴屬性後設資料,包含預設值,PropertyChangedCallback,CoerceValueCallback,ValidateValueCallback等委託。

依賴屬性存取值的機制

從修飾符可以看出依賴屬性是一個靜態的只讀變數,要確保不同例項的依賴屬性正確賦值,肯定不能把資料直接儲存到這個靜態變數中。這裡其實也是依賴屬性機制的核心。
與依賴屬性存取資料有三個關鍵的型別:DependencyPropertyDependencyObjectEffectiveValueEntry

  • DependencyProperty:依賴屬性例項都是單例,其中DefaultMetadata儲存了依賴屬性的預設值,提供變化通知、限制、檢驗等回撥以及子類override依賴屬性的渠道。GlobalIndex用於檢索DependencyProperty的例項。應用程式中註冊的所有DependencyProperty的例項都存放於名為PropertyFromName的Hashtable中。
  • DependencyObject:依賴屬性的宿主物件,_effectiveValues是一個私有的有序陣列,用來儲存本物件例項中修改過值得依賴屬性,GetValueSetValue方法用於讀寫依賴屬性的數值。
  • EffectiveValueEntry:儲存依賴屬性真實數值的物件。它可以實現多屬性值,具體來說就是內部可以存放多個值,根據當前的狀態確定對外暴露哪一個值(這裡涉及到多個值選取的優先順序的問題)。
    image

前邊提到依賴屬性例項是使用簡單的單例模式透過DependencyProperty.Register建立的。透過閱讀原始碼發現,所有的DependencyProperty.Register方法過載都是對DependencyProperty.RegisterCommon的呼叫。為了方便介紹,下文只是提取RegisterCommon方法中的關鍵程式碼

private static DependencyProperty RegisterCommon(string name, Type propertyType, Type ownerType, PropertyMetadata defaultMetadata, ValidateValueCallback validateValueCallback)
{
    FromNameKey key = new FromNameKey(name, ownerType);
    .....略去校驗以及預設後設資料程式碼

    // Create property
    DependencyProperty dp = new DependencyProperty(name, propertyType, ownerType, defaultMetadata, validateValueCallback);

    // Map owner type to this property
    // Build key
    lock (Synchronized)
    {
        PropertyFromName[key] = dp;
    }

    return dp;
}

程式碼的大致意思是生成一個FromNameKey型別的key,然後構造一個DependencyProperty例項dp,並存放到名為PropertyFromName的Hashtable中,最後返回這個例項dp
FromNameKeyDependencyProperty中的內部私有類,其程式碼如下:

private class FromNameKey
{
    public FromNameKey(string name, Type ownerType)
    {
        _name = name;
        _ownerType = ownerType;
        _hashCode = _name.GetHashCode() ^ _ownerType.GetHashCode();
    }

    public override int GetHashCode()
    {
        return _hashCode;
    }
    ...略去部分程式碼
    private string _name;
    private Type _ownerType;
    private int _hashCode;
}

這裡特地介紹這個類是因為FromNameKey物件是依賴屬性例項的key,它的hashcode是由Register的第一個引數(依賴屬性包裝器屬性名稱字串)的hashcode和第三個引數(依賴屬性宿主型別)的hashcode做異或運算得來的,這樣設計確保了每個DependecyObject型別中不同名稱的依賴屬性的例項是唯一的。

接下來就是使用(讀寫)依賴屬性了,前邊提到DependecyObject中提供了GetValueSetValue方法用於讀寫依賴屬性。先看下GetValue方法,程式碼如下:

public object GetValue(DependencyProperty dp)
{
    // Do not allow foreign threads access.
    // (This is a noop if this object is not assigned to a Dispatcher.)
    //
    this.VerifyAccess();

    ArgumentNullException.ThrowIfNull(dp);

    // Call Forwarded
    return GetValueEntry(
            LookupEntry(dp.GlobalIndex),
            dp,
            null,
            RequestFlags.FullyResolved).Value;
}

方法前幾行是執行緒安全性和引數有效性檢測,最後一行是獲取依賴屬性的值。LookupEntry是根據DependencyProperty例項的GlobalIndex_effectiveValues陣列中查詢依賴屬性的有效值EffectiveValueEntry,找到後返回其索引物件EntryIndexEntryIndex主要包含IndexFound兩個屬性,Index表示查詢到的索引值,Found表示是否找到目標元素。

GetValueEntry根據LookupEntry方法返回的EntryIndex例項查詢有效值EffectiveValueEntry。如果entryIndex.Found為true,則根據Index返回_effectiveValues中的元素,否則new一個EffectiveValueEntry例項。

SetValue方法也是先透過GetValueEntry查詢有效值物件,找到則修改舊資料,反之則new一個EffectiveValueEntry例項賦值,並新增到_effectiveValues中。

至此,我們也大致瞭解了依賴屬性存取值的秘密。DependencyProperty並不儲存實際數值,而是透過其GlobalIndex屬性來檢索屬性值。每一個DependencyObject物件例項都有一個EffectiveValueEntry陣列,儲存著已賦值的依賴屬性的資料,當要讀取某個依賴屬性的值時,會在這個陣列中去檢索,如果沒有檢索到,會從DependencyProperty儲存的DefaultMetadata中讀取預設值(這裡只是簡單的描述這個過程,真實情況還涉及到元素的style、Theme、父節點的值等)。

依賴屬性值的優先順序

前邊提到依賴屬性支援多屬性值,WPF中可以透過多種方法為一個依賴項屬性賦值,如透過樣式、模板、觸發器、動畫等為依賴項屬性賦值的同時,控制元件本身的宣告也為屬性進行了賦值。在這種情況下,WPF只能選擇其中的一種賦值作為該屬性的取值,這就涉及到取值的優先順序問題。
從上一小節的圖中可以看到EffectiveValueEntry中有兩個屬性:ModifiedValueBaseValueSourceInternalModifiedValue用於跟蹤依賴屬性的值是否被修改以及被修改的狀態。BaseValueSourceInternal是一個列舉,它用於表示依賴屬性的值是從哪裡獲取的。在與ModifiedValue一起使用,可以確定最終呈現的屬性值。
EffectiveValueEntryGetFlattenedEntry方法中以下程式碼及註釋可以看出強制值>動畫值>表示式值這樣得優先順序

internal EffectiveValueEntry GetFlattenedEntry(RequestFlags requests)
{
    ......略去部分程式碼

    // Note that the modified values have an order of precedence
    // 1. Coerced Value (including Current value)
    // 2. Animated Value
    // 3. Expression Value
    // Also note that we support any arbitrary combinations of these
    // modifiers and will yet the precedence metioned above.
    if (IsCoerced)
    {
        ......略去部分程式碼
    }
    else if (IsAnimated)
    {
        ......略去部分程式碼
    }
    else
    {
        ......略去部分程式碼
    }
    return entry;
}

其中表示式值包含樣式、模板、觸發器、主題、控制元件本身對屬性賦值或者繫結表示式。其優先順序則是在BaseValueSourceInternal中定義的。列舉元素排列順序與取值優先順序順序剛好相反。

// Note that these enum values are arranged in the reverse order of
// precendence for these sources. Local value has highest
// precedence and Default value has the least. Note that we do not
// store default values in the _effectiveValues cache unless it is
// being coerced/animated.
[FriendAccessAllowed] // Built into Base, also used by Core & Framework.
internal enum BaseValueSourceInternal : short
{
    Unknown                 = 0,
    Default                 = 1,
    Inherited               = 2,
    ThemeStyle              = 3,
    ThemeStyleTrigger       = 4,
    Style                   = 5,
    TemplateTrigger         = 6,
    StyleTrigger            = 7,
    ImplicitReference       = 8,
    ParentTemplate          = 9,
    ParentTemplateTrigger   = 10,
    Local                   = 11,
}

綜合起來依賴屬性取值優先順序列表如下:

  1. 強制:在CoerceValueCallback對依賴屬性約束的強制值。
  2. 活動動畫或具有Hold行為的動畫。
  3. 本地值:透過CLR包裝器呼叫SetValue設定的值,或者XAML中直接對元素本身設定值(包括bindingStaticResourceDynamicResource
  4. TemplatedParent模板的觸發器
  5. TemplatedParent模板中設定的值
  6. 隱式樣式
  7. 樣式觸發器
  8. 模板觸發器
  9. 樣式
  10. 主題樣式的觸發器
  11. 主題樣式
  12. 繼承。這裡的繼承Inherited是xaml樹中的父元素,要區別於面嚮物件語言子類繼承(derived,譯為派生更合適)與父類
  13. 依賴屬性後設資料中的預設值

WPF對依賴屬性的優先順序支援分別使用了ModifiedValueBaseValueSourceInternal,大概是因為約束強制值和動畫值是臨時性修改,希望在更改結束後能夠恢復依賴屬性原有值。而對於樣式、模板、觸發器、主題這些來說相對固定,不需要像動畫那樣結束後恢復原來的值。

總結

依賴屬性是WPF中一個非常核心的概念,涉及的知識點也非常多。像RegisterReadOnlyPropertyMetadataOverrideMetadataAddOwner都能展開很多內容。要想真正掌握依賴屬性,這些都是需要熟悉的。

相關文章