【WPF】ViewModelがINotifyPropertyChangedを実装していないとメモリリークする件

経緯

これはTwitterで okazukiせんせー に教えてもらったことです。

はい、WPFDataContext (ViewModel 層) の型が INotifyPropertyChanged を実装していないと メモリリーク するみたいなので、今回はそれを簡単な実験をして調べてみたいと思います。

どういうこと?

ここ に詳しく書いてありました。

WPFでデータバインディングを使用すると、メモリリークが発生することがあります。
この問題は、次の条件に該当する場合に発生します。

  • データバインディングのPathがオブジェクトXのプロパティPを参照している。
  • オブジェクトXがデータバインディング操作の対象の直接参照、または、間接参照を含んでいる。
  • プロパティPは 依存関係プロパティPropertyInfo の代わりに、PropertyDescriptor を通してアクセスされる。

この PropertyDescriptor が原因のようです。
さらに

依存関係プロパティINotifyPropertyChanged が利用できない場合、WPFは ValueChanged イベントを使用します。
この現象は、プロパティPに対応する PropertyDescriptor オブジェクト上での、 PropertyDescriptor.AddValueChanged メソッドの呼び出しに関係します。
残念なことに、これは CLR に、PropertyDescriptor からオブジェクトXへの強い参照を作らせることになります。
さらに、CLR はグローバルテーブル上で、PropertyDescriptor への参照を保持します。

とあります。

簡単に言うと、ViewModel 層のクラスが DependencyObjectINotifyPropetyChanged の派生ではないときに、内部的に PropertyDescriptor というものを使って、変更通知を受け取ろうとします。
その PropertyDescriptor によって、ランタイムレベルの強い参照が保持されることで、ViewModel 層のクラスの参照が手放されないという問題が起こるようです。

対処法

DataContext に設定する型に、ちゃんと INotifyPropertyChanged を実装させましょう。
当たり前っちゃ当たり前ですね。

実験

以下の簡単なコードで試してみました。

MainWindow.xaml

<Window
    x:Class="WpfMemoryLeakTest.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Title="MainWindow"
    Width="300"
    Height="300">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="*" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>

        <Button Grid.Row="0" Content="サブウィンドウを表示" Click="Button_Click" />
        <Button Grid.Row="1" Content="参照の有無を確認" Click="Button_Click_1" />
        <Button Grid.Row="2" Content="GCを走らせる" Click="Button_Click_2" />
    </Grid>
</Window>

MainWindow.xaml.cs

using System;
using System.Windows;

namespace WpfMemoryLeakTest
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        //  サブウィンドウのViewModelを弱参照でこちら側でも保持しておく
        public WeakReference<SubWindowViewModel> SubWindowViewModel { get; private set; }

        public MainWindow()
        {
            InitializeComponent();
        }

        //  サブウィンドウを表示
        private void Button_Click(object sender, RoutedEventArgs e)
        {
            //  サブウィンドウのViewModelを生成して
            //  DataContextに設定し、同じものを弱参照 (GCの対象になりうる参照) でも保持しておく
            var viewModel = new SubWindowViewModel();
            var window = new SubWindow { DataContext = viewModel };
            SubWindowViewModel = new WeakReference<SubWindowViewModel>(viewModel);            
            window.ShowDialog();
        }

        //  参照の有無を確認
        private void Button_Click_1(object sender, RoutedEventArgs e)
        {
            var result = SubWindowViewModel.TryGetTarget(out var viewModel);
            MessageBox.Show($"ViewModelの参照は{(result ? "残っています。" : "残っていません。")}");
        }

        //  GCを走らせる
        private void Button_Click_2(object sender, RoutedEventArgs e)
        {
            GC.Collect();
        }
    }
}

SubWindow.xaml

<Window
    x:Class="WpfMemoryLeakTest.SubWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Title="SubWindow"
    Width="300"
    Height="300">

    <Grid>
        <TextBox Text="{Binding Text.Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
    </Grid>
</Window>

SubWindowViewModel.cs

using System;
using System.ComponentModel;
using System.Diagnostics;
using Reactive.Bindings;

namespace WpfMemoryLeakTest
{
    //  ReactivePropertyを使ったときによくやっちゃうやつ
    //  ViewModelがINotifyPropertyChangedを実装しなくても
    //  データバインディングが可能だからPOCOにしてしまっている

    public class SubWindowViewModel //: INotifyPropertyChanged
    {
        //public event PropertyChangedEventHandler PropertyChanged;

        public ReactiveProperty<string> Text { get; } = new ReactiveProperty<string>("Hello");

        public SubWindowViewModel()
        {
            Debug.WriteLine("コンストラクタ");
        }

        ~SubWindowViewModel()
        {
            Debug.WriteLine("デストラクタ");
        }
    }
}

サブウィンドウを閉じ、GCを走らせて、確認ボタンを押しました。
SubWindowViewModelINotifyPropertyChanged を実装させた場合は参照が残りませんが、実装させなかった場合には参照が残ってしまいました。

思ったこと

それにしても、ReactiveProperty のサンプルコードでよく見るんですよ。
ViewModel 層が INotifyPropertyChanged を実装していないコードを。
今さっき Qiita で何件か ReactiveProperty に関する記事を見てみましたが、実装していないコードが多かったですね。
まぁ、 ViewModel の寿命が伸びることによる弊害がないなら良いのかもしれませんが、意図しない参照の破棄漏れというのは怖いですね。
私も初めて知ったとき、ゾクッとしました。

2017/12/26 22:32:57
コメントを投稿する