はじめに
こんにちは、ヒロです。
前回、「Blazor WebAssembly と SignalR でチャットアプリを作ってみた」という記事を投稿しました。
今回は、前回作ったチャットアプリを View, View のデータを扱う部分、サーバーとのデータのやり取りを行う部分の3つに分けてみました。
前回のコードでは、Razor コンポーネント内でコンポーネントに必要なデータをすべて処理していましたが、役割の分離・明確化とコードの可読性の点からコードを分割していきたいと思っていました。
そこで、MVVM の構造を目標として、コードを先に述べた責務で分割していきました。このブログでは、その軌跡と躓いた点などを共有できればと思います。
※ 作成したチャットアプリのソースコードはこちらで公開しています。
ブログ中にサンプルコードを載せていますが、作成したチャットアプリそのものではなく、説明用に簡単な構成で作ったものを使用しています。
ViewModel の作成
図 1 はよく MVVM の説明で用いられる図です。
左から右(①:View から ViewModel と ②:ViewModel から Model のやり取り) についてはメソッドやプロパティを参照するだけなので比較的イメージがしやすいのではないでしょうか?
ViewModel に View で利用するデータを持たせて、参照する。クリックなどのイベントが発行された場合は、ViewModel のメソッドを呼び出す形です。
では、右から左 (④:Model から ViewModel, ③:ViewModel から View) へのやり取りはどうでしょうか?
これからは、③ と ④ に分けてそれぞれ説明していきます。
ViewModel から View への変更の通知
まず、③:ViewModel から View へのデータのやり取りですがこれは、Blazor が自動で行ってくれる場合と行ってくれない場合があります。
Razor コンポーネント内で参照している値が、そのコンポーネントのライフサイクルメソッドや Blazor によって
トリガーされるイベント内で変更された場合、自動で変更が通知されて、再レンダリングを行ってくれるため特にこちらでする必要はありません。
以下サンプルです。currentCount が変更されると自動で画面の値も変更されるのを確認できます。
1 2 3 4 5 6 7 |
@page "/counter" @inject CounterViewModel CounterViewModel /*onClickでcurrentCountがインクリメントされるとViewも書き換わる*/ <p role="status">Current count: @CounterViewModel.currentCount</p> <button class="btn btn-primary" @onclick="@(() => CounterViewModel.IncrementCount())">Click me</button> |
1 2 3 4 5 6 7 8 9 |
public class CounterViewModel { public int currentCount = 0; public void IncrementCount() { currentCount++; } } |
しかし、ライフサイクルメソッドや Blazor でトリガーされるイベント以外で値が変更された場合、その変更は認識されず、再レンダリングによって画面に反映されることがありません。
今回はチャット機能のために SignalR を使用して双方向通信を実現しており、これが変更の認識外になります。
自分で状態の更新を通知して再レンダリングを促す必要があるわけですが、このためのメソッドとして、Blazor には StateHasChanged が用意されています。
この StateHasChanged ですが、View の Razor コンポーネントが継承している ComponentBase クラスから由来しています。
ViewModel で StateHasChanged を使おうとしても、Razor コンポーネントではない ViewModel では使うことができません。
参考:ASP.NET Core Blazor コンポーネントのレンダリング
ViewModel で用意することができないので、View から渡してくる必要があります。
StateHasChanged の他にも Razor コンポーネントに由来していて ViewModel には分離できないものがいくつかあり、
今回の変更ではそれらは、Razor コンポーネントに残したまま、または View から渡してくることになります。
(StateHasChanged の他にはライフサイクルメソッドなどが該当します)
では、ViewModel で StateHasChanged メソッドを呼んで、コンポーネントを再レンダリングする方法ですが、イベントハンドラを利用します。
まず、イベントハンドラを ViewModel に定義しておき、Razor コンポーネント側で StateHasChanged メソッドを追加します。
後は必要な時にイベントを呼び出せば、再レンダリングが行われます。
注意点としては、購読したイベントは Razor コンポーネントが削除される時には外す必要があります。
この処理は、Razor コンポーネントに IDisposable を継承して、Dispose メソッドの中に記述すれば、Razor コンポーネントが削除される時にイベントを外してくれます。
下記はタイマーの実装例です。StateHasChanged を OnInitialized の中で、ViewModel のイベントに追加しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
@page "/watch" @inject TimerViewModel TimerViewModel @implements IDisposable <div> Count: @TimerViewModel.count </div> @code { protected override void OnInitialized() { // ViewModelのイベントハンドラにStateHasChangedを追加する TimerViewModel.TimerChanged += (s, e) => StateHasChanged(); } // コンポーネントが破棄される時にイベントを外す public void Dispose() => WatchViewModel.TimerChanged -= StateChangedEvent; private void StateChangedEvent(object s, EventArgs e) => StateHasChanged(); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
public class TimerViewModel : IDisposable { public int count { get; set; } = 0; public event EventHandler TimerChanged; private System.Timers.Timer timer = new(1000); public TimerViewModel() { timer.Elapsed += (s, e) => { currentCount++; // ViewのOnInitializedで追加したStateHasChangedを呼び出す TimerChanged?.Invoke(this, EventArgs.Empty); }; timer.Start(); } public void Dispose() { timer.Dispose(); } } |
さてこの StateHasChanged ですが、Razor コンポーネントの基底クラスに依存している以上、そのコンポーネント以外の再レンダリングは行ってくれません。
つまり、他のコンポーネントに影響する部分が変わった場合はそのコンポーネントの StateHasChanged を呼ばなければいけないわけです。
今回の場合はチャットルームからの退出が該当します。ルームからの退出は個々のチャットルームからできるようになっています。
ルーム一覧はチャットルームとは関係なく、サイドバーに常に表示されるようになっています。
このコンポーネントにも変更を通知してあげる必要があります。
下の図は Razor コンポーネントの構成図になります。
退出のイベントが存在する UserList コンポーネント、ルーム一覧を表示する SideMenu の 2 つに共通する親コンポーネント MainLayout にルーム一覧を持つ ViewModel を作成します。
作成した ViewModel にルーム一覧の再取得を Model に要求するメソッドを持たせておきます。
このメソッドを、チャットルームで退出処理を行った時に呼び出します。
再取得が完了し、Model 内のプロパティの変更されたら、変更が ViewModel に通知されます。
ViewModel が変更の通知を受けて、プロパティが変更されると画面が再レンダリングされます。
そのためには、親コンポーネントの ViewModel を子コンポーネントに渡す必要があります。
この親から子への値の受け渡しには Cascading Property を使用します。
まず、受け渡したい子コンポーネントを CascadingValue タグで囲み、Value 要素に受け渡したい値またはインスタンスを指定します。
子コンポーネントでは、受取先のプロパティを作成し、CascadingParameter 属性をプロパティに設定してやります。
これで、親コンポーネントの値を子コンポーネントに受け渡します。
参考:ASP.NET Core Blazor の値とパラメーターのカスケード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@inject IndexViewModel IndexViewModel <div class="app-layout"> <div class="sidebar"> <CascadingValue Value="IndexViewModel"> <SideMenu /> </CascadingValue> </div> <div class="body"> <CascadingValue Value="IndexViewModel"> <Chat /> </CascadingValue> </div> </div> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
@using ChatApp.Client.ViewModel; <ul class="nav flex-column"> @if (IndexViewModel is null) { } else { @foreach (var room in IndexViewModel.Rooms) { <li class="nav-item px-3"> <NavLink class="nav-link" href=@("chat/" + room.RoomId) Match="NavLinkMatch.All"> <span class="oi oi-document" aria-hidden="true"></span>@room.RoomName </NavLink> </li> } } </ul> @code { [CascadingParameter] protected IndexViewModel IndexViewModel { get; set; } |
ですが、MainLayout で作成した ViewModel を CascadingParameter を通して SideMenu や UserList で受け取って、さあ使おうというところで Null 参照例外に遭遇することになりました。
CascadingParameter によって値が渡されるのはコンポーネントがインスタンス化されて、SetParameterAsync が呼ばれたところになります。
OnInitialized は SetParameterAsync の後に実行されるので、ここでルーム一覧の ViewModel を渡していたわけですが、実際は中身が Null で中のメソッドなどが呼べずに例外が発生することとなっていました。
Null チェックを入れることで対応できたわけですが、サーバー側より非同期で動く処理がクライアントは多く、予想外に Null 参照例外が起こる可能性が高いことを実感しました。
パフォーマンスを意識すれば、意図せぬレンダリングを防止したりとさらに注意しなければいけないところは増えると思います。
Blazor にも様々な状態管理ライブラリがすでに存在しています。今後、Blazor で MVVM で作成するのもより簡単になってくるのではないかと思っています。
Model の作成
次に Model です。
DI 時のスコープの違いによって発生する問題
Model では、サーバーとの HTTP 通信のために HttpClientFactory と SignalR (WebSocket 通信) のために HubConnection を必要としています。
この 2 つのインスタンスは DI から取得できるので、Model も DI に登録して、自動で作成してくれるようにしておきます。
ちなみに HubConnection インスタンスを作成している HubUtility クラスでは、アクセストークンを設定するために、
AuthenticationStateProvider クラスを DI しています。
しかし、AuthenticationStateProvider は Scoped で DI されているため、Singleton の中では DI することができません。
部屋選択画面はチャットアプリの中で 1 つなので、Singleton は変えたくない… どうしよう??となったわけです。
方法 1
Model で受け取れないものは仕方がないので、ViewModel で受け取るようにします。ViewModel は Transient で DI しているので、Scoped で DI されたものも受け取ることができます。
そして、Model が DI された後に、ViewModel の AuthenticationStateProvider を Model に渡します。
Model に別に初期化用の処理ができることを意味するので、少々煩雑にはなりますが、DI を維持したままにできます。
1 2 3 4 5 6 7 8 9 10 11 12 |
// このViewModelはTransientでDIしている public class ViewModel { private readonly Model _Model; // ScopedObjectがScoped public ViewModel(Model Model, ScopedObject scopedObject) { Model = Model; Model.initialize(scopedObject); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// SingletonでDIしている public class Model { private readonly IHttpClientFactory _httpClientFactory; private ScopedObject _scopedObject; public FetchDataModel(IHttpClientFactory httpClientFactory) { _httpClientFactory = httpClientFactory; } public void Initialize(ScopedObject scopedObject) => _scopedObject = scopedObject; } |
方法 2
DI を維持しないのであれば、Factory メソッドを作成して、インスタンスを作るようにすればコンストラクタだけで初期化処理を完結できます。以下サンプルになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public class ViewModel { private readonly Model _model; // Modelの作成に必要なものはViewModelで受け取っておく public ViewModel(IHttpClientFactory httpClientFactory, ScopedObject scopedObject) { // Factoryメソッドを通して、Modelを作る ModelFactory(httpClientFactory, scopedObject); } private void ModelFactory(IHttpClientFactory httpClientFactory, ScopedObject scopedObject) { _model ??= new Model(httpClientFactory, scopedObject); } } |
1 2 3 4 5 6 7 8 9 10 11 12 |
public class Model { private readonly IHttpClientFactory _httpClientFactory; private readonly ScopedObject _scopedObject; public Model(IHttpClientFactory httpClientFactory, ScopedObject scopedObject) { _httpClientFactory = httpClientFactory; _scopedObject = scopedObject; } } |
ViewModel への変更の通知
最後に、ViewModel から Model のメソッドが呼ばれ、HTTP 通信なり、SignalR の結果として Model のプロパティが変わった時に、ViewModel に変更を通知する方法についてです。
ViewModel は自作クラスなので、初めから変更を通知するような便利なメソッドがあるわけではありませんが、基本的には View と ViewModel の間で行ったのと同じようにイベントハンドラで実現できます。
イベントハンドラを Model に用意して、ViewModel 側で Model が変更された時に行いたい処理を登録するだけです。
これで、晴れて、MVVM 風にコードを分離することができました。
総括
ここまで、この記事を読んでいただきありがとうございます。
MVVM 構成を目標にこの半年進めて来たわけですが、だいぶ形が整ってきたと思います。
やってみて一番良かったと感じたのは、コードがきれいにまとまったところです。
Razor コンポーネントにすべて書いてしまうとかなりみにくかったので、それだけでも価値がありました。
苦戦した点は値の管理です。何度も Null 参照例外に遭遇し、「いつ」値が入ってきて使えるようになるのかを意識するようになりました。
他にも、セッターで値を受け取った時に処理を行おうとすれば、セッターに非同期処理が書けなかったりと苦戦しつつも多くのことを学べました。
すでに動いているものを変更するのは体力を使う作業ではありますが、学ぶことも多いので、機会があれば挑戦してみてはいかがでしょうか。