вторник, 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.

На этом все.

17 комментариев:

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

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

    ОтветитьУдалить
  3. какой-то странный недостаток у 3-го варианта по сравнению со 2-м. там точно так же имя свойства строкой указывается... и вообще есть уже давно nameof(...).
    Отличие основное в проверке изменилось ли условие и надобности вызова уведомления. Можно его вызвать и без параметра имени функции, что ещё упрощает 3-й вариант. Надо только Caller... атрибут поставить в методе-приёмнике. Так что тут уж надо смотреть - наследовать можно - бери Dep..Obj.. т.к. коллекции большие побыстрее. Нет - ИНотифы.. Ну и по удобству тоже смотреть. Кому что удобнее в каких ситуациях.

    ОтветитьУдалить
    Ответы
    1. Ну или чего-то из указанного во времена создания статьи не было. Или забыто.
      Или я "иногда" бываю не прав =)

      Удалить
    2. Статья от 2012 года, тогда nameof еще не было. Сейчас, согласен, проще с этим стало. Как я написал чуть ниже, у каждого из подходов есть свои плюсы и минусы, но они, уже в больше в области производительности.

      Удалить
    3. Зачем nameof, если появилось CallerMemberName
      как итог метод становится таким :

      protected void RaisePropertyChanged([CallerMemberName]string propertyName = "")
      {
      if (PropertyChanged != null)
      PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
      }

      Удалить
    4. П.С.: ну а в свойстве просто вызываем метод
      private bool _isEdit;
      public bool IsEdit
      {
      get { return _isEdit; }
      set
      {
      if (_isEdit != value)
      {
      _isEdit = value;
      OnPropertyChanged();
      }
      }
      }

      Удалить
    5. опечатка RaisePropertyChanged(); конечно же

      Удалить
    6. Статья 2012 года, тогда такой полезной штуки как CallerMemberName еще не было, вот про нее https://losev-al.blogspot.com/2016/10/blog-post.html
      Как всегда за все надо платить. Пишем nameof платим лишним стучанием по клавиатуре и большей скоростью работы, используем CallerMemberName меньше стучим по клавиатуре, чуть медленнее происходит вызов.

      Удалить
  4. Этот комментарий был удален автором.

    ОтветитьУдалить
  5. Я всё таки так и не понял в каких случаях использовать INotifyPropertyChanged и DependencyProperty. :(

    ОтветитьУдалить
    Ответы
    1. Если нет ограничений по памяти, надо более быструю работу Binding-а и, самое главное, у вас есть возможность унаследоваться от DependencyObject, то лучше использовать его. Если класс к свойствам которого у вас планируется биндинг уже имеет предка и на вершине его иерархии нет DependencyObject, то у вас выбора нет, только INotifyPropertyChanged

      Удалить
    2. Я вот ещё кстати какую тему нашёл. Если вы не знали, то думаю вам пригодится.
      https://habrahabr.ru/post/95211/

      Удалить
    3. Да, при массовых присвоениях DependencyObject работает медленнее, согласен. Можно тоже добавить к критериям выбора.

      Удалить
  6. Алексей спасибо вам огромное ваша статья меня просто спасла! весь интернет перерыл люди пишут как то не понятно огромные коды лишь для маленькой вещи а вы так хорошо всё изложили и всё понятно и главное чётко. Только есть одно но у вас инициализация проходит через DataContext я пробовал у меня не получилось так я через ItemSource я не знаю это беда или нет. ещё есть два вопроса. У меня программа прочитывает .csv файл возможно ли через один метод про инициализировать все таблицы. либо для каждой таблицы надо иметь свой обработчик событий? и последний вопрос у вас есть статья как удалять со списка или добавлять в список? заранее спасибо!

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