添付ビヘイビアの注意点

2025年4月12日土曜日

C# WPF

記事のカテゴリー: C#、.NET9、WPF

コントロールの機能拡張の手法の一つに添付ビヘイビアがあります。その添付ビヘイビアを作るときの注意点を考えます。

メモリリーク

添付ビヘイビアでは多くの場合でターゲットコントロールのイベントを購読します。ソース側とターゲット側でライフサイクルが違う場合、イベント購読に伴うターゲットへの参照がガベージコレクションの対象になることを妨げて、メモリリークの原因になる可能性があります。

対策1 Unloadedイベント

コントロールのUnloadedイベントでイベントの購読解除をします。ただし、Unloadedイベントが発生した時点ではビジュアルツリーから削除されただけで、コントロールが破棄されたわけではありません。MenuTabControlの子コントロールの場合、ロードやアンロードが繰り返される可能性があります。

C#:

private static void OnCommandChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
{
    if (obj is not Button button)
    {
        return;
    }
    
    button.Click += Button_Click;
    button.Unloaded += Button_Unloaded;
}

private void Button_Unloaded(object sender, RoutedEventArgs e)
{
    if (sender is not Button button)
    {
        return;
    }
    
    button.Click -= Button_Click;
    button.Unloaded -= Button_Unloaded;
}

対策2 弱いイベント

弱いイベントはまさにメモリリーク問題に対応するための機能です。弱いイベントを使うとターゲットがガベージコレクションの対象になることを邪魔しません。

C#:

private static void OnCommandChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
{
    if (obj is not Button button)
    {
        return;
    }
    
    WeakEventManager<Button, RoutedEventArgs>.AddHandler(button, nameof(button.Click), Button_Click);
}

参照

イベント購読の多重登録

メモリリーク問題と同様に、プロパティ変更コールバックでコントロールのイベント購読を行っていると、添付プロパティの値を変更したときにイベント購読の多重登録が起こる可能性があります。

多重登録が起こると、対象のイベントが発生するたびにイベントハンドラーが複数回呼び出されてしまいます。

対策1 無効な値から有効な値に変わったときだけ購読する

添付プロパティの値が無効な値から有効な値に切り替わったときだけ購読します。注意する必要があるのは、プロパティの型や運用方法によって有効な値から有効な値に変化する場合が存在することで、この場合を除外することです。

ちなみに、ここでは「添付ビヘイビアが機能する=イベントを購読する必要がある」場合を有効な値、「添付ビヘイビアが機能しない=イベントを購読する必要がない」場合を無効な値としています。

C#:

public static DependencyProperty CommandProperty = DependencyProperty.RegisterAttached(
    "Command", typeof(ICommand), typeof(SampleBehavior), new FrameworkPropertyMetadata(null, OnPropertyChanged));

public static ICommand? GetCommand(DependencyObject obj) { return (ICommand)obj.GetValue(CommandProperty); }
public static void SetCommand(DependencyObject obj, ICommand? value) { obj.SetValue(CommandProperty, value); }

private static void OnPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
{
    if (obj is not Button button)
    {
        return;
    }
    
    switch (e.OldValue, e.NewValue)
    {
        case (null, ICommand):  // 「無効な値から有効な値」なら購読する
            WeakEventManager<Button, RoutedEventArgs>.AddHandler(button, nameof(button.Click), Button_Click);
            break;
    
        case (ICommand, null):  // 「有効な値から無効な値」なら購読解除する
            WeakEventManager<Button, RoutedEventArgs>.RemoveHandler(button, nameof(button.Click), Button_Click);
            break;
    }
}

対策2 購読済みのコントロールを記録する

イベントを購読しているコントロールをListなどで管理します。また、メモリリークの原因にならないように弱い参照を使います。弱い参照を使う場合は定期的に参照先を失った弱い参照を整理することも必要かもしれません(サンプルでは行っていません)。

C#:

// 購読済みのコントロールの List
private static List<WeakReference<Button>> attachingTargets = [];

public static DependencyProperty CommandProperty = DependencyProperty.RegisterAttached(
    "Command", typeof(ICommand), typeof(SampleBehavior), new FrameworkPropertyMetadata(null, OnCommandChanged));

public static ICommand? GetCommand(DependencyObject obj) { return (ICommand)obj.GetValue(CommandProperty); }
public static void SetCommand(DependencyObject obj, ICommand? value) { obj.SetValue(CommandProperty, value); }

private static void OnCommandChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
{
    if (obj is not Button button)
    {
        return;
    }
    
    if (e.NewValue is ICommand)
    {
        // 購読済みでないなら購読する
        if (!attachingTargets.Exists(targetRef => targetRef.TryGetTarget(out Button? target) && (target == button)))
        {
            attachingTargets.Add(new (button));
            WeakEventManager<Button, RoutedEventArgs>.AddHandler(button, nameof(button.Click), Button_Click);
        }
    }
    else
    {
        // 購読済みなら購読解除する
        if (attachingTargets.FindIndex(targetRef => targetRef.TryGetTarget(out Button? target) && (target == button)) is (int index and not -1))
        {
            attachingTargets.RemoveAt(index);
            WeakEventManager<Button, RoutedEventArgs>.RemoveHandler(button, nameof(button.Click), Button_Click);
        }
    }    
}

対策3 イベント購読用の添付プロパティ

イベント購読を管理する添付プロパティを用意します。次のサンプルのIsAttachedプロパティがそれです。IsAttachedプロパティはbool型で、初期値はfalseです。trueになったらイベントを購読し、falseになったら購読解除します。truefalseの二値であることで余計にコールバックが呼び出されないため、多重登録を起こしません。

C#:

public static readonly DependencyProperty CommandProperty = DependencyProperty.RegisterAttached(
    "Command", typeof(ICommand), typeof(SampleBehavior), new FrameworkPropertyMetadata(null, OnCommandChanged));

public static ICommand? GetCommand(DependencyObject obj) { return (ICommand)obj.GetValue(CommandProperty); }
public static void SetCommand(DependencyObject obj, ICommand? value) { obj.SetValue(CommandProperty, value); }

// イベントの購読に使う添付プロパティ
private static readonly DependencyProperty IsAttachedProperty = DependencyProperty.RegisterAttached(
    "IsAttached", typeof(bool), typeof(SampleBehavior), new FrameworkPropertyMetadata(false, OnIsAttachedChanged));

public static bool GetIsAttached(DependencyObject obj) { return (bool)obj.GetValue(IsAttachedProperty); }
public static void SetIsAttached(DependencyObject obj, bool value) { obj.SetValue(IsAttachedProperty, value); }
    
private static OnCommandChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
{
    if (obj is not Button button)
    {
        return;
    }
        
    // 購読したいときに SetIsAttached(button, true)、購読解除したいときに SetIsAttached(button, false) を呼ぶ
    SetIsAttached(button, e.NewValue is ICommand);
}

private static OnIsAttachedChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
{
    if (obj is not Button button)
    {
        return;
    }

    if (e.NewValue is true)
    {
        WeakEventManager<Control, RoutedEvemtArgs>.AddHandler(button, nameof(button.Click), Button_Click);
    }
    else
    {
        WeakEventManager<Control, RoutedEvemtArgs>.RemoveHandler(button, nameof(button.Click), Button_Click);
    }
}