вторник, 17 июля 2012 г.

О INotifyPropertyChanged и DependencyProperty

Эта тема настолько базовая, что я даже не думал, что об этом надо рассказывать. Но вот уже в какой раз задается этот вопрос на форумах MSDN. Собственно поэтому, потрачу один раз время, чтобы в дальнейшем просто давать ссылку.
Итак, у нас есть объект, у объекта свойство, которое через Binding привязано к свойству визуального элемента управления. Из кода мы меняем свойство, хотим, чтобы изменение отображалось в визуальном элементе.

Итак, давайте для примера возьмем вот такой класс:
public class MyClass
{
    public string MyString { get; set; }
}
Как видим, клас достаточно простой. Для демонстрации работы с ним, создадим простое WPF приложение, в котором разметка главной формы пусть имеет вид:
<Window x:Class="WpfApplication19.MainWindow"
   xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
   xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
   Title="MainWindow" Height="350" Width="525" >
<Grid>
   <Button Content="Button" Height="23" HorizontalAlignment="Left" Margin="171,48,0,0" Name="button1" VerticalAlignment="Top" Width="75" Click="button1_Click" />
   <Label Content="{Binding MyString}" Height="28" HorizontalAlignment="Left" Margin="10,43,0,0" Name="label1" VerticalAlignment="Top" />
</Grid>
</Window>
  
Видно, что свойство Content нашего Label через Binding привязано к свойсту MyString (если запустить приложение сейчас, то мы в label ничего не увидим, т.к. объекта класса MyClass в свойсте DataContext нашего Label нет).
Пишем обработчики для события загрузки формы и нажатия кнопки:

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        Loaded += new RoutedEventHandler(MainWindow_Loaded);
    }
    void MainWindow_Loaded(object sender, RoutedEventArgs e)
    {
        my = new MyClass() { MyString = "Привет" };
        label1.DataContext = my;
    }
    MyClass my = null;
    private void button1_Click(object sender, RoutedEventArgs e)
    {
        my.MyString = "Пока";
    }
}

Запустив приложение теперь, мы увидим в label слово "Привет", но вот сколько бы мы раз не нажимали на копку, значение в label не поменяется. Почему же так происходит? На самом деле все просто. Когда у нас меняется свойство объекта, о том, что свойство поменялось визуальный компонент не знает. Для решения этой проблемы у нас есть один неправильный и два правильных способа.
Первый способ - неправильный. Раз слово "Привет" у нас появляется, то можно изменять DataContext у label каждый раз на изменение свойства MyString. Например вот так:

private void button1_Click(object sender, RoutedEventArgs e)
{
    my.MyString = "Пока";
    label1.DataContext = null;
    label1.DataContext = my;
}

Причем обязательно через null, т.к. просто повторное присвоение игнорируется. Данный способ является неправильным, т.к. нам нужно семантически следить за тем, чтобы на каждое изменение свойств переприсваивать DataContext всем компонентам которые могут смотреть на этот объект. К сожалению, если класс, свойства которого вы пытаетесь показывать через Binding не является потомком INotifyProperptyChanged или свойство не является DependencyProperty, то вам придеться делать именно так. такая ситуация может возникнуть с классами полученными из кода написанного до появления WPF, а у вас нет возможности этот код менять. Во всех остальных случаях, необходимо использовать второй и третий варианты.
Второй способ - DependencyObject. Если вы являетесь разработчиком класса свойста которого вы планируете отображать через Binding и у вас не будет создаваться миллион объектов данного типа, то наиболее правильно будет объявить ваш класс потомком DependencyObject и все свойства сделать DependencyProperty.
Кстати, для написания DependencyProperty есть соответствующий сниппет propdp, расширенный вариант которого я уже описывал здесь.
Итак, в этом случае наш класс будет иметь вид:

public class MyClass : DependencyObject
{
    public string MyString
    {
        get { return (string)GetValue(MyStringProperty); }
        set { SetValue(MyStringProperty, value); }
    }
    // Using a DependencyProperty as the backing store for MyString.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty MyStringProperty =
        DependencyProperty.Register("MyString", typeof(string), typeof(MyClass), new UIPropertyMetadata(""));
}
Как видно, свойство существенно усложнилос, появилась статическая часть, но благодаря этим изменения, при изменении свойства "внешний" по отношению к классу мир будет проинформирован о том, что свойство поменялось.
Убираем переприсвоение DataContext-а из обработчика клика на кнопке, изменяем свойство на DependencyProperty, запускаем. Теперь при клике на кнопку в label будет "Пока". Как я сказал выше, данный способ можно применять не всегда. Если класс который вы хотите использовать является потомком другого класса, то из-за запрета на множественное наследование, вы не сможите сделать его потомком DependencyObject. В этом случае вам на помощь придет интерфейс INotifyPropertyChanged.
Третий способ - INotifyPropertyChanged. С ним все чуть, на мой взгляд сложнее. Вам необходимо сделать ваш класс потомком указанного интерфейса, реализовать событие, написать метод вызывающий это событие и во всех свойствах прописать вызов этого метода с передачей имени изменяющегося свойства:

public class MyClass : INotifyPropertyChanged
{
    private string _myString;

    public string MyString
    {
        get
        {
            return _myString;
        }
        set
        {
            if (value != _myString)
            {
                _myString = value;
                OnPropertyChanged("MyString");
            }
        }
    } 

    public event PropertyChangedEventHandler PropertyChanged; 

    protected void OnPropertyChanged(string p_propertyName)
    {
        PropertyChangedEventHandler handler = PropertyChanged;
        if (handler != null)
        {
            handler(this, new PropertyChangedEventArgs(p_propertyName));
        }
    }
}
Запускаем, нажимаем на кнопку, опять видим строку "Пока". У данного подхода есть свои недостатки, по сравнению со вторым. Например, если вы, в процессе разработки, измените имя свойства с MyString на LastName, но забудете изменить строку передаваемую в метод OnPropertyChanged, то свойство меняется,  а внешний мир об этом не узнает, т.к. считает, что поменялось свойство MyString. Ну и на MSDN я натыкался на статистику, что DependencyObject при биндинге больших коллекций работает быстрее.

Ну и вместо заключения, аналогичная проблема при биндинге коллекций.
Итак, пусть у нас есть демонстрационное приложение, в котором показан список чисел и кнопка, для добавления числа в коллекцию. Итак, на главной форме Grid заменим на DockPanel с кнопкой и ListBox-ом:
<DockPanel>
    <Button x:Name="btAddNumber" Content="Добавить число" DockPanel.Dock="Top" Click="btAddNumber_Click" />
    <ListBox x:Name="lbNumbers" ItemsSource="{Binding }" />
</DockPanel>
В код данного окна запишем поле коллекцию целых чисел, создание этой коллекции и запись ее в ListBox поместим в обработчик события загрузки формы, ну и добавление элемента в коллекцию при нажатии на кнопку:

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        Loaded += new RoutedEventHandler(MainWindow_Loaded);
    }
    List<int> numbers = null;      
    void MainWindow_Loaded(object sender, RoutedEventArgs e)
    {
        numbers = new List<int>();
        for (int i = 0; i < 3; i++)
        {
            numbers.Add(i);
        }
        lbNumbers.DataContext = numbers;
    }
    Random rnd = new Random();
    private void btAddNumber_Click(object sender, RoutedEventArgs e)
    {
        numbers.Add(rnd.Next(3, 100));
    }
}
К сожалению, запустив это приложение 0, 1, 2 мы то увидим, а вот при нажатии на кнопку, коллекция меняться не будет. Это происходит по аналогичной причине. Класс List не умеет уведомлять внешний мир о том, что у него изменилось количество элементов. Чтобы решить данную проблему нам придется заменить List на класс, который может уведомлять о изменении количества членов. Таким классом является ObservableCollection. Т.е. код необходимо переписать вот так:
ObservableCollection<int> numbers = null;        

void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
    numbers = new ObservableCollection<int>();
    for (int i = 0; i < 3; i++)
    {
        numbers.Add(i);
    }
    lbNumbers.DataContext = numbers;
}
Все, теперь если мы запустим приложение, и будем нажимать кнопку, в списке будут пояляться новые значения.
Ну и совсем последний комментарий, если вы надумаете писать свою коллекцию, и захотите, чтобы она уведомляла об изменении количества членов в ней, то вам необходимо ваш класс либо селать потомком указанного класса ObservableCollection, либо реализовать поддержку интерфейса INotifyCollectionChanged.

На этом все.

4 комментария:

  1. Спасибо, Алексей! Ваша статья очень выручила!

    ОтветитьУдалить
    Ответы
    1. Пожалуйста. Рад, что пригодилось :)

      Удалить
  2. Спасибо, Алексей! Теперь стало понятней)))

    ОтветитьУдалить