※注意※ 今回の記事は実験的なものであり、業務用・製品用のコードにそのまま使うのはお勧めしません。
前回、Harmonyの基本的な使い方の記事を書きました。
記事を書いている途中で、「これMVVMのViewModel書く時に使えるのでは?」という考えがピコーンと浮かんできたので、試してみることにします。
1. ベタにViewModelを実装
まず、TextBoxに入力した文字が、TextBlockに反映されるようなウィンドウのViewとViewModelを実装してみます。今回のプロジェクトはWPFアプリ(.NET Framework)で、.NET Framework 4.8 を使用します。
<Window
x:Class="HarmonyMvvm.View.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:vm="clr-namespace:HarmonyMvvm.ViewModel"
Title="MainWindow"
Width="300"
Height="150"
mc:Ignorable="d">
<Window.DataContext>
<vm:MainWindowViewModel />
</Window.DataContext>
<StackPanel>
<TextBlock
Width="200"
Height="30"
Text="{Binding Text1}" />
<TextBox
Width="200"
Height="30"
Text="{Binding Text1, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</Window>
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace HarmonyMvvm.ViewModel
{
public class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
public void NotifyChanged([CallerMemberName] string propertyName = "")
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public class MainWindowViewModel : ViewModelBase
{
public string Text1
{
get => _Text1;
set
{
_Text1 = value;
NotifyChanged();
}
}
private string _Text1;
}
}
MainWindowViewModelのText1プロパティの実装だけで10行もあって面倒ですね。
2. ViewModelのプロパティ処理を外に追い出す
試しに、MainWindowViewModelからプロパティのバッキングフィールド、NotifyChanged呼び出しをスパっと削除し、ViewModelBaseのスタティックコンストラクタから、Harmonyを使用してプロパティのSetメソッドにNotifyChangedを実行するPostfixメソッドを注入してみます。
using HarmonyLib;
using System.ComponentModel;
using System.Reflection;
using System.Runtime.CompilerServices;
namespace HarmonyMvvm.ViewModel
{
public class ViewModelBase : INotifyPropertyChanged
{
static ViewModelBase()
{
var harmony = new Harmony("Test");
var originalSetMethod =
typeof(MainWindowViewModel)
.GetProperty(nameof(MainWindowViewModel.Text1))
.SetMethod;
var postfixSetMethod =
typeof(ViewModelBase)
.GetMethod(nameof(SetMethodPostfix));
harmony.Patch(originalSetMethod, postfix: postfixSetMethod);
}
public event PropertyChangedEventHandler PropertyChanged;
public void NotifyChanged([CallerMemberName] string propertyName = "")
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
public static void SetMethodPostfix(string value, MainWindowViewModel __instance, MethodInfo __originalMethod)
=> __instance.NotifyChanged(__originalMethod.Name.Replace("set_", ""));
}
public class MainWindowViewModel : ViewModelBase
{
public string Text1 { get; set; }
}
}
一応、MainWindowViewModelのプロパティの処理を外に追い出す事には成功しました。ただ、このままでは汎用性がないので抽象化を考えてみます。
3. 抽象化とプロパティへのパッチの自動化
まず、INotifyPropertyChangedだけでは外からPropertyChangedイベントを起動できないので、PropertyChangedイベントの起動用メソッドを追加したインターフェースを作成します。
using System.ComponentModel;
namespace HarmonyMvvm.ViewModel
{
public interface INotifyPropertyChangedEx : INotifyPropertyChanged
{
void NotifyChanged(string propertyName);
}
}
次に、Harmonyを使用してINotifyPropertyChangedExを実装したクラスの全プロパティにパッチを当てるクラスを作成します。今回の肝になります。
using HarmonyLib;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace HarmonyMvvm.ViewModel
{
public class PropertyChangedNotifier
{
private static Harmony _Harmony = new Harmony(nameof(PropertyChangedNotifier));
private static MethodInfo _SetMethodPostfix =
typeof(PropertyChangedNotifier)
.GetMethod(nameof(SetMethodPostfix), BindingFlags.Static | BindingFlags.NonPublic);
public static void Inject(Assembly asm)
{
// INotifyPropertyChangedExを実装したクラスを探して全てパッチを当てる
var targetClassTypes = asm.GetTypes().Where(x => x.GetInterface(nameof(INotifyPropertyChangedEx)) != null);
foreach (var targetClassType in targetClassTypes)
{
Inject(targetClassType);
}
}
public static void Inject(Type targetClassType)
{
if (targetClassType.GetInterface(nameof(INotifyPropertyChangedEx)) == null)
{
throw new ArgumentException($"({targetClassType.FullName}) INotifyPropertyChangedExが実装されていません。");
}
// 全プロパティのSetメソッドにパッチを当てて
// SetMethodPostfixが実行されるようにする
var originalSetMethods =
targetClassType
.GetProperties()
.Where(x => x.SetMethod != null)
.Select(x=>x.SetMethod);
foreach (var originalSetMethod in originalSetMethods)
{
_Harmony.Patch(originalSetMethod, postfix: _SetMethodPostfix);
}
}
private static void SetMethodPostfix(object value, INotifyPropertyChangedEx __instance, MethodInfo __originalMethod)
{
__instance.NotifyChanged(__originalMethod.Name.Replace("set_", ""));
}
}
}
ViewModel側は、ViewModelBaseのインターフェースをINotifyPropertyChangedExに変更し、スタティックコンストラクタでPropertyChangedNotifier.Injectを実行します。コードはかなりシンプルになりました。
using System.ComponentModel;
using System.Reflection;
using System.Runtime.CompilerServices;
namespace HarmonyMvvm.ViewModel
{
public class ViewModelBase : INotifyPropertyChangedEx
{
static ViewModelBase()
=> PropertyChangedNotifier.Inject(Assembly.GetExecutingAssembly());
public event PropertyChangedEventHandler PropertyChanged;
public void NotifyChanged([CallerMemberName] string propertyName = "")
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public class MainWindowViewModel : ViewModelBase
{
public string Text1 { get; set; }
}
}
これで、ViewModel側のプロパティに手を入れる事なくPropertyChangedイベントが起動されるようになりました。
実際には、Postfixメソッドを注入したくないプロパティや、値が変更された時だけPropertyChangedを起動したいなど様々なケースが考えられると思うので、プロパティにカスタム属性を付与したりして、属性に応じてパッチの当て方を変更するなどの工夫が必要になると思いますが、Harmonyには新しい可能性を感じました。