No more Death March

あるSEのチラシの裏 C# WPF

WPF MultiBindingのコールバックの挙動について

WPFのMultiBindingを初めて使った時に若干嵌ったのでメモしておく。

テスト用ビヘイビア

コードビハインドでMultiBindingクラスを生成しバインディングする。マルチと言いつつTextプロパティしかバインディングしていないが確認するのには十分、コンバーター内で呼び出された旨をデバッグ出力し、ターゲットの添付プロパティもコールバック関数内で同様にデバッグ出力する。

using System;
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;

namespace CodeBehindBindingSample
{
    public static class TextBoxBehavior
    {


        public static bool GetIsAttached(DependencyObject obj)
        {
            return (bool)obj.GetValue(IsAttachedProperty);
        }

        public static void SetIsAttached(DependencyObject obj, bool value)
        {
            obj.SetValue(IsAttachedProperty, value);
        }

        public static readonly DependencyProperty IsAttachedProperty = DependencyProperty.RegisterAttached("IsAttached", typeof(bool), typeof(TextBoxBehavior), new PropertyMetadata(false,OnIsAttachedChanged));

        private static void OnIsAttachedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            var multiBinding =
                new MultiBinding()
                {
                    Mode = BindingMode.OneWay,
                    Converter = new MultiBindingConverter()
                };
            multiBinding.Bindings.Add(
                new Binding()
                {
                    Source = d,
                    Path = new PropertyPath(TextBox.TextProperty),
                }
                );

            BindingOperations.SetBinding(d, TextBoxBehavior.MultiBindingTargetProperty, multiBinding);
        }



        public static object[] GetMultiBindingTarget(DependencyObject obj)
        {
            return (object[])obj.GetValue(MultiBindingTargetProperty);
        }

        public static void SetMultiBindingTarget(DependencyObject obj, object[] value)
        {
            obj.SetValue(MultiBindingTargetProperty, value);
        }

        public static readonly DependencyProperty MultiBindingTargetProperty = DependencyProperty.RegisterAttached("MultiBindingTarget", typeof(object[]), typeof(TextBoxBehavior), new PropertyMetadata(new object[] { },OnMultiBindingTargetChanged));

        private static void OnMultiBindingTargetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            System.Diagnostics.Debug.WriteLine("OnMultiBindingTargetChanged Called");
        }

        private class MultiBindingConverter
            : IMultiValueConverter
        {
            public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
            {
                System.Diagnostics.Debug.WriteLine("Convert Called");
                return values;
            }

            public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
            {
                throw new InvalidOperationException();
            }
        }
    }
}

画面

先のビヘイビアを有効にしたテキストボックスを画面におく。

<Window x:Class="CodeBehindBindingSample.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"
        xmlns:local="clr-namespace:CodeBehindBindingSample"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <StackPanel>
        <TextBox local:TextBoxBehavior.IsAttached="True"/>
    </StackPanel>
</Window>

何が問題なのか

この画面を起動すると起動直後に以下のように出力される。

Convert Called
OnMultiBindingTargetChanged Called


ここまでは良い。SetBindingを実行したタイミングでバインディング先の添付プロパティに値が書き込まれることがわかる。問題はここからで、以降ソースとなっているTextBoxをいくら書き換えても以下の通りコンバーターのみ動作し、コールバック関数が呼ばれなくなる。

Convert Called
Convert Called
Convert Called

解決方法

解消するには以下の通り、コンバーター内で新しいインスタンスが戻り値として返るようにすればよい。

        private class MultiBindingConverter
            : IMultiValueConverter
        {
            public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
            {
                System.Diagnostics.Debug.WriteLine("Convert Called");
                // ToArrayメソッドで常に参照が変わるように
                return values.ToArray();
            }

            public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
            {
                throw new InvalidOperationException();
            }
        }


再度実行するとTextBoxのTextプロパティが変わる度にコールバック関数が呼ばれるようになる。

Convert Called
OnMultiBindingTargetChanged Called
Convert Called
OnMultiBindingTargetChanged Called
Convert Called
OnMultiBindingTargetChanged Called
Convert Called
OnMultiBindingTargetChanged Called

おそらくだが、MultiBindingは内部で同じ参照の配列を保持しソースの値が変わったらインスタンスを変えずに配列を編集しているのだろう。今回のケースだと初回のバインディング時に参照が渡されて以降参照が変わっていないため、コールバックが呼ばれなくなっていたと思われる。MultiBinding自体あまり使う機会もないだろうし、使ったとしてもなんらかの加工を加えインスタンスが生成された結果を利用すると思われるのであまり気にすることはないのだけど、運良く(?)こういう事象を踏み抜く場合もありますよと・・・・

WPF Window起動時のイベント発生順位をメモ

余り複雑な制御を行っているコントロールを作るのは好ましくないが、どうしてもやらなければならない場合が結構あったりする。そんな時に毎回イベントの発生順位ってどうだっけ?と忘れてしまうことがあるのでメモしておく。後述の通りサンプル画面を作ってビヘイビアで各イベントが着火したタイミングで発生したイベントをデバッグプリントしてやる。

<Window x:Class="WpfEventTracking.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"
        xmlns:local="clr-namespace:WpfEventTracking"
        mc:Ignorable="d"
        local:WindowBehavior.IsAttached="True"
        Title="MainWindow" Height="450" Width="800"
        >
    <GroupBox Header="GroupBox" local:ControlBehavior.IsAttached="True">
        <StackPanel>
            <TextBox Text="TextBox" local:ControlBehavior.IsAttached="True"/>
            <Button Content="Button" local:ControlBehavior.IsAttached="True"/>
        </StackPanel>
    </GroupBox>
</Window>

全てのイベントを網羅すると大変なので今回はUIElement、FrameworkElement、Control、Windowのイベントのみ出力する。

Window起動時

TextBox Initialized
Button Initialized
GroupBox Initialized
MainWindow Initialized
MainWindow IsVisibleChanged
GroupBox IsVisibleChanged
TextBox IsVisibleChanged
Button IsVisibleChanged
MainWindow SizeChanged
GroupBox SizeChanged
Button SizeChanged
TextBox SizeChanged
Button LayoutUpdated
TextBox LayoutUpdated
GroupBox LayoutUpdated
MainWindow LayoutUpdated
MainWindow Activated
MainWindow PreviewGotKeyboardFocus
MainWindow IsKeyboardFocusWithinChanged
MainWindow IsKeyboardFocusedChanged
MainWindow GotKeyboardFocus
Button LayoutUpdated
TextBox LayoutUpdated
GroupBox LayoutUpdated
MainWindow LayoutUpdated
MainWindow Loaded
GroupBox Loaded
TextBox Loaded
Button Loaded
MainWindow ContentRendered
Button LayoutUpdated
TextBox LayoutUpdated
GroupBox LayoutUpdated
MainWindow LayoutUpdated
Button LayoutUpdated
TextBox LayoutUpdated
GroupBox LayoutUpdated
MainWindow LayoutUpdated
Button LayoutUpdated
TextBox LayoutUpdated
GroupBox LayoutUpdated
MainWindow LayoutUpdated
Button LayoutUpdated
TextBox LayoutUpdated
GroupBox LayoutUpdated
MainWindow LayoutUpdated
Button LayoutUpdated
TextBox LayoutUpdated
GroupBox LayoutUpdated
MainWindow LayoutUpdated
Button LayoutUpdated
TextBox LayoutUpdated
GroupBox LayoutUpdated
MainWindow LayoutUpdated

LayoutUpdatedイベントまで出すと見難い・・・

Window起動時(LayoutUpdate無しver)

TextBox Initialized
Button Initialized
GroupBox Initialized
MainWindow Initialized
MainWindow IsVisibleChanged
GroupBox IsVisibleChanged
TextBox IsVisibleChanged
Button IsVisibleChanged
MainWindow SizeChanged
GroupBox SizeChanged
Button SizeChanged
TextBox SizeChanged
MainWindow Activated
MainWindow PreviewGotKeyboardFocus
MainWindow IsKeyboardFocusWithinChanged
MainWindow IsKeyboardFocusedChanged
MainWindow GotKeyboardFocus
MainWindow Loaded
GroupBox Loaded
TextBox Loaded
Button Loaded
MainWindow ContentRendered

LayoutUpdatedイベントを非表示にした再度確認

Window終了時

MainWindow Closing
MainWindow IsVisibleChanged
GroupBox IsVisibleChanged
TextBox IsVisibleChanged
Button IsVisibleChanged
MainWindow Deactivated
MainWindow IsKeyboardFocusWithinChanged
MainWindow IsKeyboardFocusedChanged
MainWindow LostKeyboardFocus
MainWindow Closed

ついでなのでWindow終了時の結果もメモ

まとめ

フォーカス取得系を省いて順番を整理すると
Initialize→IsVisibleChanged→SizeChanged→Activated→Loaded→ContentRendered→(画面表示完了)→Closing→IsVisibleChanged→Deactivec→Closed

バブルイベントとトンネルイベントを分けると

バブルイベント
Initialized

トンネルイベント
IsVisibleChanged
SizeChanged
Loaded

といった具合になる。

少し検索してみるとここで確認した通り、Windowのイベント発生順位がまとめられてた。

stackoverflow.com

WPF DependencyPropertyDescriptorのAddValueChanged

前回投稿した記事の中で触れていたDependencyPropertyDescriptorで実際にメモリリークするかどうかを試してみた。

前回の記事はこちら↓
nomoredeathmarch.hatenablog.com

画面側のコードは省くが、StackPanelとButtonを二つ配置、片方のボタンを押したらStackPanelに後述のビヘイビアを添付したTextBoxを1000個追加、もう一つのボタンを押したらStackPanelのChildrenをClearメソッドでクリアする。別途TextBlockを配置し、こちらはDespatcherTimerを使って周期的にメモリ使用量を取得して表示する。

ビヘイビアは以下の通り、TextChangedイベントを購読した場合とDependencyPropertyDescriptorのAddValueChangedメソッドを使う二通りの処理を用意しメモリの状況を確認してみた。

using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;

namespace BehaviorMemoryLeak
{
    public static class TextBoxBehavior
    {


        public static bool GetIsAttached(DependencyObject obj)
        {
            return (bool)obj.GetValue(IsAttachedProperty);
        }

        public static void SetIsAttached(DependencyObject obj, bool value)
        {
            obj.SetValue(IsAttachedProperty, value);
        }

        public static readonly DependencyProperty IsAttachedProperty = DependencyProperty.RegisterAttached("IsAttached", typeof(bool), typeof(TextBoxBehavior), new PropertyMetadata(IsAttachedChanged));

        private static void IsAttachedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            /*
             * TextChangeを購読
            (d as TextBox).TextChanged += (sender, args) => { System.Diagnostics.Debug.WriteLine("TextChanged"); };
             */

            /* 
             * AddValueChangedでプロパティの変更を検知
            DependencyPropertyDescriptor
                .FromProperty(TextBox.TextProperty, typeof(TextBox))
                .AddValueChanged(d, (sender, args) => { System.Diagnostics.Debug.WriteLine("TextChanged"); });
             */
        }

    }
}


結果やはりDependencyPropertyDescriptorを使った方法はメモリリークしていた。WPFの基本となる依存関係プロパティの仕組み上、この辺りの制御は一通り弱参照を駆使しているのだろうなと勝手に思っていたのだがどうやら違う様子。

WPF TextBlockのTextChangedイベント

TextBlockにはTextChangedというイベントは無いのだけど変更されたタイミングを知る方法はある。検索すれば色々やり方が出てくるけどいくつか種類があるので要点をメモ

TargetUpdatedイベントを使う

TargetUpdateイベントはバインディングのターゲットが更新された時に着火するイベント、BindingのオプションにあるNotifyTargetUpdatedにtrueを指定する必要がある。イベント引数の中を探ればどのプロパティをターゲットにしたバインディングが更新されたかを知ることが出来る。おそらく検索すると一番最初に目に付くかと思うが、TargetUpdateイベントの中でプロパティを判定しなければいけないところが余り好きじゃない。

PropertyDescriptorを使う

PropertyDescriptorというstaticクラスにあるAddValueChangedにイベントハンドラーを登録し変更通知を受け取ってやる。ちゃんと登録を解除してやらないとメモリリークするか。

依存関係プロパティを作成し、変更通知を受け取りたいプロパティをバインディングする

自分が使うことが多いのはこのやり方、ビヘイビアの中にstring型の添付プロパティを作成しTextBlockのTextプロパティをソースにバインディングしてやる。


どの方法を採用すべきかはお好みで、TextBlockのTextプロパティに限らず、TextBoxのIsReadOnlyやCanvasのLeftなんかも〇〇Changedといったイベントが用意されていないので、応用出来る。地味だけど重宝するテクニックです。

WPF Bindingの解除は必要なのか

StackOverFlowから、ざっくりいうと

質問が「Bindingって解除しないとメモリリークする?」ということで、
それに対する回答が
「DependencyPropertyかINotifyPropertyChangedを実装しているなら大丈夫、CLRオブジェクトだとメモリリークするかも。」
とのこと。

c# - Can bindings create memory leaks in WPF? - Stack Overflow

変更チェックのためにPropertyDescriptorが対象のオブジェクトに対する強参照を保持してしまうようだけど、OneTimeなら大丈夫みたい。一回こっきりで値がわかれば良いからね。

WPF ScrollViewerとPreviewKeyDown

画面上にButtonやToggleButtonを並べて配置すると矢印キーの操作でフォーカスを移動させることが出来るが、この連続したボタンをScrollViewerでラップするとフォーカス移動が効かなくなり、代わりにScrollViewerのスクロールバーが押した矢印の方向に動くようになる。

スクロールバーの動きを無効化してボタン間のフォーカス移動を復活させようとしたとき、ボタン側のKeyDownイベント等でフォーカスを操作しようとしても上手くいかない、というのもボタンでKeyDownイベントが着火する前にスクロールバーのPreviewKeyDownイベントでスクロール操作が処理され、下位のUI要素には処理済みとしてイベントが通知されなくなるからだ。

WPF PreviewKeyDownとPreviewKeyUpの動き

またWPFについてちょとしたメモ、プログラムで嵌ったことはいつかの自分のためなるべくメモしておきたい。

今回はPreviewKeyDownイベントとPreviewKeyUpイベントについて書いておく。当初、キーを押したらKeyDownイベントがキーを離したらKeyUpイベントが着火する程度の認識だったんだけど、コントロールをカスタマイズしててなかなか上手く行かないことがあり、よくよく調べて見れば思ってたのと大分違う動き方をしていた。

端的に結論だけ、まずキーを押下するとKeyDownイベントが着火するのだが、キーを押しっぱなしにしている限りイベントは連続で着火し続ける。でキーを離すとKeyUpイベントが着火する。加えて、例えばShiftキーとAltキーを同時押ししていた場合、キーを押しっぱなしにしている間はKeyDownイベントが連続で着火するのだが、どちらかのキーを離し、KeyUpイベントが着火すると、新たにキーを押下するまでKeyDownイベントは着火しなくなる。

つまり、KeyDownはKeyUpが着火するまで連続で着火し続け、KeyUpが発生すると押しているキーが残っているか否かを問わず、次のKeyDown操作が行われるまでイベントは発生しなくなるということ。