はじめに
こんにちは。山本れきです。この記事は「Blazor WebAssemblyでMVVMを実現する」の第2回となります。
過去の記事 Blazor WebAssemblyでMVVMを実現する(第1回) |
前回は素直にいくつかのコンポーネントを書いてみましたが、サンプルの枠を超えて実際に使ってみるとほとんどを作り直すこととなりました。
また、本格的にデザインを始めてみると、XAMLとHTMLの勝手の違いやWPFとBlazorの違い、BlazorならではのC#の制約などを理解するようになりました。
それで今回はBlazor WebAssembly上のMVVMとして必須の基盤実装について、前回より具体的に触れます。
この記事のソースコードの所在はこちらです。 https://github.com/cct-blog/Oniqys.Blazor/tree/blog/mvvm2
Blazor WebAssemblyにおけるMVVM基盤実装のポイント
WPF上のMVVM基盤は数多ありますが、Blazor WebAssembly用のMVVM基盤は2021年6月時点ではそれほど多くありません。
わざわざMVVMにしなくとも開発可能ではありますが、アトミックデザインを採用してみると想像以上にMVVMはしっくりときます。
かつてWPF黎明期にMVVM基盤を作ることは、私を含め多くのプログラマ達が試みていました。その際に、WPFを分析して得た知見が今回役立ちます。
加えて、Blazor WebAssembly特有の制限にも留意しなければなりません。
弱い参照を用いた実装が必須となる
ViewModelの変更をViewに通知するためにPropertyChangedイベント をViewModelに実装することは必須です。
しかし、ごく普通の実装をするとViewとの間で深刻なメモリリークが発生します。下図をご覧ください。
画面側の(比較的シンプルな)実装によっては何らかのプロパティの変化の都度、再描画が行われます。再描画タイミングはBlazor WebAssemblyが管理しているため、ユーザーコード上からはそれを判別できません。
そして再描画の際、View側は別のインスタンスが生成されてPropertyChangedイベントに再度イベントハンドラーが追加されるかもしれません。
しかしViewModel側からはそのViewが維持すべきものか破棄すべきものか識別はできません。MVVMとして、1つのViewModelに複数のViewを認める必要があり、且つ不要なViewの破棄はGC(ガーベージコレクション)に委ねる必要があります。
上図の実装の場合は再描画の度にViewが追加され、参照されたオブジェクトはGCによる破棄対象にはならず、蓄積し続けていくのです。
この問題の解決策は実のところ単純でありながら、しかし多少のコストを含むものとなります。次の図をご覧ください。
「弱い参照(WeakReference)」を使用します。ここで大切なのは、ViewModelからViewへの直接的な「強い参照」を排除することです。こうしておけば、Viewが破棄されるべきタイミングで、Viewに「強い参照」をするものはなくなります。WeakEventとBindingはPropertyChangedイベントの際に弱い参照先のViewがまだ有効か判定して、無効であればPropertyChangedイベントを外してやるだけで参照元がなくなり、GCが破棄してくれます。
以下はWeakEventのソースコードです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
public abstract class WeakEventBase<T, TArgs> where T : class where TArgs : EventArgs { // 「弱い参照」でViewModelとイベントハンドラーを保持します。 private readonly WeakReference<T> _source; private readonly WeakReference<Action<T, TArgs>> _handler; public WeakEventBase(T source, Action<T, TArgs> handler) { _source = new WeakReference<T>(source ?? throw new ArgumentNullException(nameof(source))); _handler = new WeakReference<Action<T, TArgs>>(handler ?? throw new ArgumentNullException(nameof(handler))); } protected void Handle(object sender, TArgs e) { if (_handler.TryGetTarget(out var handler)) handler(sender as T, e); else Remove(); } public void Remove() { if (_source.TryGetTarget(out var source)) Remove(source); } // イベントの種類に応じて Remove を実装します。 protected abstract void Remove(T source); } public sealed class WeakPropertyChangedEvent : WeakEventBase<object, PropertyChangedEventArgs> { // コンストラクター内でHandleメソッドを登録 private WeakPropertyChangedEvent(object source, Action<object, PropertyChangedEventArgs> handler) : base(source, handler) => ((INotifyPropertyChanged)source).PropertyChanged += Handle; protected override void Remove(object source) => ((INotifyPropertyChanged)source).PropertyChanged -= Handle; // ファクトリーメソッド static public WeakPropertyChangedEvent Create(object source, Action<object, PropertyChangedEventArgs> handler) => source is INotifyPropertyChanged ? new WeakPropertyChangedEvent(source, handler) : null; } |
https://github.com/cct-blog/Oniqys.Blazor/blob/blog/mvvm2/Oniqys.Blazor/Core/WeakEvent.cs から抜粋
Bindingについてはまだデザイン途上のため公開していません。.razor 構文内で思いのほか綺麗にまとまらないというのが実情ですが、@bind
を使うことで当座の必要には対応できることも多く、実装を一時保留しています。
.NET 5の.razorではジェネリック型制約を使えない
今回の実装で意外にも悩ましいこととなったのは .razor で記述するコンポーネントをジェネリック型にする場合、型制約(where T)をつけることができないことです。この制限が無ければもっとC#として綺麗なものが作れるのに、と感じることが何度かありました。型制約がないと、比較に == 演算子すら使えないことになります。
これは同時にWeakEventや各種基底クラスにも型制約を付けられないことを意味します。ジェネリックのWeakRefereenceには型制約がついており、基盤実装の際にこれらはかなり難しい制約となり得ます。
以下はその制約下で前回より改善した ViewComponentBase です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
public abstract class ViewComponentBase<TViewModel> : ComponentBase // where TViewModel を本当は記述したい { // OnInitialized処理後まで StateHasChanged() を呼ばせないためのフラグ private bool _initialized; private TViewModel _dataContext; private WeakPropertyChangedEvent _propertyChanged; [Parameter] public TViewModel DataContext { get => _dataContext; set => UpdateDataContext(value); } private void UpdateDataContext(TViewModel value) { // DataContext を変更した場合、_propertyChanged を開放して置き換える if (_propertyChanged != null) { _propertyChanged.Remove(); _propertyChanged = null; } UpdateValue(ref _dataContext, value); if (_dataContext != null) { _propertyChanged = WeakPropertyChangedEvent.Create(_dataContext, OnPropertyChanged); } } protected override void OnInitialized() { base.OnInitialized(); _initialized = true; } protected void UpdateValue<T>(ref T field, T value, [CallerMemberName] string propertyName = null) { field = value; Invalidate(); } protected void OnPropertyChanged(object sender, PropertyChangedEventArgs _) => Invalidate(); protected void Invalidate() { if (_initialized) StateHasChanged(); } } |
メモ:型制約は .NET 6 で使えるようになります。(参考 : Microsoft 開発者ブログの言及先 https://devblogs.microsoft.com/aspnet/asp-net-core-updates-in-net-6-preview-4/#generic-type-constraints-in-razor ) |
最も小さなViewの実装
最もシンプルなViewを実装してみる必要があります。それはこのようになります。
1 2 3 4 5 6 7 8 9 10 |
@namespace Oniqys.Blazor.Controls @inherits ViewComponentBase<TViewModel> @typeparam TViewModel @ChildContent(DataContext) @code { [Parameter] public RenderFragment<TViewModel> ChildContent { get; set; } } |
https://github.com/cct-blog/Oniqys.Blazor/blob/blog/mvvm2/Oniqys.Blazor/Controls/View.razor を引用
これを使う短い構文は Index.razor にあります。以下に抜粋します。
1 2 3 |
<View DataContext="@("test")"> <span>@context</span> </View> |
https://github.com/cct-blog/Oniqys.Blazor/blob/blog/mvvm2/Oniqys.Blazor.Sample/Pages/Index.razor から抜粋
これは非常に短いコードですが、Blazor WebAssemblyならではの記述方法を凝縮したような作りになっています。
- @ChildContentは Viewタグに囲まれた内部を表示する。その際、引数を渡すことができる
- 使用する側の @context は@ChildContent に渡された引数を表示する
- ViewのDataContextのViewComponentBase<TViewModel>を継承しているため動的な切り替えが可能
Commandを含むボタン等の実装
MVVMとしてはぜひともCommandをサポートしなければなりません。CommandはCanExecuteの変更を通知するため、ViewModelの基底クラスである ContentBase を継承します。(WPFのCanExecute()とは異なります)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
// ViewModelの基底クラスの抜粋 public abstract class ContentBase : INotifyPropertyChanged { // 通知型プロパティのSetterの処理として、フィールドの値が更新されたら通知を発する protected bool ValueChangeProcess<T>(ref T field, T value, [CallerMemberName] string propertyName = null) where T : struct { if (EqualityComparer<T>.Default.Equals(field, value)) return false; field = value; OnPropertyChanged(propertyName); return true; } // INotifyPropertyChanged の機能を実装する protected void OnPropertyChanged([CallerMemberName] string propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); public event PropertyChangedEventHandler PropertyChanged; } // Commandの基底クラス public abstract class CommandBase : ContentBase { private bool _canExecute = true; public bool CanExecute { get => _canExecute; set => ValueChangeProcess(ref _canExecute, value); } public abstract Task ExecuteAsync(); } // Commandクラス public class Command : CommandBase { private readonly Func<Task> _execute; // 同期/非同期両対応のコマンド public Command(Action execute) => _execute = () => { execute(); return Task.CompletedTask; }; public Command(Func<Task> execute) => _execute = execute; public override async Task ExecuteAsync() => await _execute(); } |
https://github.com/cct-blog/Oniqys.Blazor/blob/blog/mvvm2/Oniqys.Blazor/ViewModel/ContentBase.cs
https://github.com/cct-blog/Oniqys.Blazor/blob/blog/mvvm2/Oniqys.Blazor/ViewModel/CommandBase.cs
https://github.com/cct-blog/Oniqys.Blazor/blob/blog/mvvm2/Oniqys.Blazor/ViewModel/Command.cs
以上3か所より抜粋
View側であるButtonでは@onclickイベント処理内でCommandを実行するという、Blazor WebAssemblyとして平易な実装となっています。ボタンの基底クラスは以下のリンク先にあります。
https://github.com/cct-blog/Oniqys.Blazor/blob/blog/mvvm2/Oniqys.Blazor/Controls/ButtonBase.cs
ItemsControlなどのリスト表示
ItemsControlはWPFではListBoxなどの基底クラスです。Blazor WebAssembly用のMVVMでも同様の基底クラスを用意しました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
public class ItemsControl<TItem> : ViewComponentBase<TItem> { [Parameter] public RenderFragment<TItem> ItemTemplate { get; set; } private IList<TItem> _items; private WeakCollectionChangedEvent _collectionChanged; [Parameter] public IList<TItem> Items { get => _items; set => UpdateItems(ref _items, value); } private void UpdateItems(ref IList<TItem> items, IList<TItem> value) { if (_collectionChanged != null) { _collectionChanged.Remove(); _collectionChanged = null; } UpdateValue(ref items, value); if (_items != null) _collectionChanged = WeakCollectionChangedEvent.Create(_items, OnCollectionChanged); } private void OnCollectionChanged(object source, NotifyCollectionChangedEventArgs args) => Invalidate(); public ItemsControl() => Items = new ContentCollection<TItem>(); } |
https://github.com/cct-blog/Oniqys.Blazor/blob/blog/mvvm2/Oniqys.Blazor/Controls/ItemsControl.cs から抜粋
Itemsに変化があると、WeakEventで再描画通知を発します。これだけの実装で大体十分です。
あとはItemsをどのように並べたいかによって継承先のHTML/CSSを記述することになります。
TItemをContentBaseとして、DataTemplateを使用するとItems内の型に応じた内容が表示されます。DataTemplateはChildContent内に配置する必要があります。複数のDataTemplateを組み合わせてコンポーネントとすることで、ResourceDictionaryのような働きをさせることができ、結果としてView内の冗長な記述を避けることが可能です。
現状と今後について
試行錯誤を経て、ようやくデザインの方向性が固まったと感じています。
Bindingについてはまだ試行錯誤段階ですがこちらも徐々にまとめて行きます。
現在はButtonなどに暫定的な実装が残っています。また、HTMLのStyleやclassなどをどのように扱うかは実際のところまだ迷いがあります。
加えて、今後はコントロールを増やしていく必要があると感じています。コントロールやパネル、ItemsControlの継承クラスなど、作るべきものは多いと感じます。
また前述のとおり .NET 6 がリリースされるまで型制約が使えないために妥協している部分もあり、まだまだやるべきことは多いと言えるでしょう。.NET 6 の正式リリースまでに整えることができるようにするのが現在の目標です。
実装指針は概ね固まったと感じています。次回の記事ではコンポーネントを増やしていくつもりです。