放浪軍師のアプリ開発局

VTuberみたいなアプリケーション夏狂乱など、自由気ままにアプリを開発したりしています。他にもいろいろやってます。尚、このブログはわからないところを頑張って解決するブログであるため、正しい保証がありません。ご注意ください。

.NET MAUI で MVVM パターンを書いてみよう

さて始まりました放浪軍師のアプリ開発局。今回は前回に引き続きクラスプラットフォーム開発ができる .NET MAUI を使って MVVM パターンでのアプリ作成をやってみようと思います。さぁ今回もはりきっていってみよー!

おさらい

久々なのでまずは MVVM パターンとそれに連なる技術のおさらいから行きましょう。

MVVM パターンとは

MVVM パターンとは Model-View-ViewModel パターンの略で、ロジックを司る Model 層、画面を司る View 層、そしてそれらを中継する ViewModel 層をゆるく繋ぐ(疎結合)ことで成り立つプログラミングデザインパターンである。

MVVM パターンは何が嬉しいのか?

結論から言うと、MVVM パターンを用いると画面はロジックを気にすることなく、またロジックは画面を気にすることなくコーディングすることができるようになります。また、単体テストがしやすくなります。

例として【ボタンを押すと何かの値が変わって、その結果を画面が表示する】というような機能を実装する場合で考えてみましょう。MVVM パターンを採用しない場合を図にすると以下のような感じですかね。

この場合、ロジック側の値を変更するメソッドの名前を変更すると、画面はコールするメソッド名を変更しなければなりませんし、画面にあるラベルの名前を変更すると、ロジック側でも代入先のラベル名を変更しなければなりません。これは画面とロジックが密結合な状態です。こうなると画面は画面、ロジックはロジックに集中できないですよね。

それを解決するのが MVVM パターンとなります。この例の場合、MVVM パターンを採用すると以下のような図になります。

画面(View)でボタンを押しことをコマンドにて中継(ViewModel)にてロジックをコール。ロジック(Model)では値を変更して、その変更を中継(ViewModel)に通知。中継は画面(View)に変更を通知。という仕組みになります。この仕組みであれば、Model はただ呼ばれたメソッドを動かすだけですので、View や ViewModel の仕組みを知っておく必要がありません。また、ViewModel はメソッドを呼ぶために Model を知っておく必要はありますが View を知っておく必要はありません。同じように View は ViewModel でのコマンドを知っておく必要はありますが、Model を知っておく必要はありません。このような状態であれば、最初に説明したように画面はロジックを気にすることなく、またロジックは画面を気にすることなくコーディングすることができるようになりますし、単体テストもできるようになります。

ReactiveProperty

MVVM パターンを実装する際に使用する事が多いライブラリで、ViewModel にて View からのコマンドや値の変更を受け取ったり、Model からの通知を受け取ったり、View に通知を送ったりするのを一手に引き受けてくれる凄い奴です。また、変化したプロパティの値を LINQ ライズに加工してから渡したりもできます。MVVM パターンで必須というわけではないですが使用すると断然楽になるので、私は必ず使用します。詳しくは ReactiveProperty のメンテナーであるかずきさんの記事を参考にすると良いです。

qiita.com

Dependency Injection (DI 、依存性の注入)

それぞれのクラスが他のクラスに密接に結びつかないようにするために、クラス内から別クラスのインスタンスを生成するのではなく、外から使用するクラスをインターフェースとして渡してやる仕組みの事です。要は、

public class Hoge {
    public void Method() {
        var dataService = new DataService(); //外部とアクセスするようなクラス
    }
}

のように内部でインスタンスを生成するのではなく、

public class Hoge {
    private readonly IDataService _dataService; 
    public Hoge(IDataService dataService) { //コンストラクタで注入する
        _dataService = dataService;
    }
    public void Method() {
        var dataService = _dataService; 
        //・・・・・・
    }
}

のように使用するクラスをインターフェースとしてコンストラクタから渡してやるという事です。こうすることによって渡してやるクラスを別のクラスに容易に差し替えることが可能になり、単体テストがやりやすくなります。これも MVVM では採用するべきパターンです。ちなみに Dependency Injection を用いるとどう単体テストがやりやすくなるのかは以下の記事で解説したことがあります。

www.gunshi.info

Dependency Injection Container (DIコンテナ)

前述の Dependency Injection に繋がる話で、 DI で使うインターフェースとクラスを事前に登録しておきコンストラクターなどで自在に呼び出すことができる仕組みです。この DI コンテナを使うと、コンストラクタへの引数に渡すためにインスタンスの生成をする必要がなくなり、非常に便利です。ちなみに前回の記事で説明しましたが、MAUI にはこの DI コンテナが標準で搭載されています。

.NET MAUI で MVVM パターンを書いてみよう

本題前に記事が肥大化してしまう事ってあるよね!やっと本題ですよ。.NET MAUI で MVVM パターンを書いてみます。上記の仕組みは全部乗せの豪華仕様だよ!

環境

GitHub

参考になれば。

github.com

実践

ReactiveProperty のインストール

Nuget から ReactiveProperty をインストールします。

ディレクトリ構造

以下のようにディレクトリを構築しました。

root/
 ┠ Extends/
 ┃ ┗ BindableBase.cs : INotifyPropertyChanged を継承した基底クラス
 ┠ Models/
 ┃ ┠ Lottery.cs : 人物抽選クラス
 ┃ ┗ Person.cs : 人物クラス
 ┠ Platforms/
 ┠ Resources/
 ┃ ┗ Images/
 ┃   ┗ rancyan_minikowashiya.png 画像ファイル
 ┠ Services/
 ┃ ┠ DataService.cs : データクラス
 ┃ ┗ NavigationService.cs : ナビゲーションサービスクラス
 ┠ ViewModels/
 ┃ ┠ FirstPageViewModel.cs : 初期ページ ViewModel 
 ┃ ┗ SecondPageViewModel.cs : 遷移先ページ ViewModel
 ┠ Views/
 ┃ ┠ FirstPage.xaml : 初期ページ View
 ┃ ┃ ┗ SecondPage.xaml.cs : 初期ページ View のコードビハインド
 ┃ ┗ SecondPage.xaml : 遷移先ページ View
 ┃   ┗ SecondPage.xaml.cs : 遷移先ページ ViewModel
 ┠ App.xaml
 ┃ ┗ App.xaml.cs : 初期ページ指定
 ┗ MauiProgram.cs : 初回起動クラス DIコンテナ指定

MauiProgram.cs

最初に起動するクラス。DIコンテナへはここで登録する。登録したクラスやインターフェースはコンストラクターの引数で自動呼出しされたり好きに取り出したりできる。とっても便利。

using MauiTry.Models;
using MauiTry.Services;
using MauiTry.ViewModels;
using MauiTry.Views;

namespace MauiTry;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            });

        //Dependensy Injection Container (DIContainer) はここで指定。
        //コンストラクタから自動で呼び出せるようになる。任意に呼ぶことも可能。
        builder.Services.AddTransient<FirstPage>();
        builder.Services.AddTransient<FirstPageViewModel>();
        builder.Services.AddTransient<SecondPage>();
        builder.Services.AddTransient<SecondPageViewModel>();
        builder.Services.AddTransient<Lottery>();
        builder.Services.AddTransient<SecondPageViewModel>();
        builder.Services.AddSingleton<INavigationService, NavigationService>();
        builder.Services.AddSingleton<IDataService, DataService>();

        return builder.Build();
    }
}

Extends/BindableBase.cs

ViewModel や Model で使用する基底クラス。プロパティ変更通知で使用。本来なら各クラスにそれぞれ実装するものだがこうやって基底クラスにしておくと楽ができる。

using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace MauiTry.Extends
{
    public class BindableBase : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        protected virtual bool SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
        {
            if (Equals(field, value)) { return false; }
            field = value;
            //プロパティ変更を通知
            this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); return true;
        }
    }
}

Services/NavigationService.cs

ページ遷移で使用するサービスクラス。このクラスにインターフェースを実装し DI コンテナに登録して、ViewModel のコンストラクタ引数で取り出す事により、View に依存させずにページ遷移を実装することができる。詳しくは以下の記事を参考にしてください。非常にわかりやすかったです。

blog.pieeatingninjas.be

using MauiTry.Views;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace MauiTry.Services {

    public interface INavigationService {
        Task NavigateToSecondPage();
        Task NavigateBack();
    }

    public class NavigationService : INavigationService {

        readonly IServiceProvider _services;

        protected INavigation Navigation {
            get {
                INavigation navigation = Application.Current?.MainPage?.Navigation;
                if (navigation is not null)
                    return navigation;
                if (Debugger.IsAttached)
                    Debugger.Break();
                throw new Exception();
            }
        }

        //使用するクラスを外部から渡す(Dependency Injection)
        public NavigationService(IServiceProvider services) => _services = services;

        //FirstPage へ遷移する
        public Task NavigateToFirstPage() => NavigateToPage<FirstPage>();

        //SecondPage へ遷移する
        public Task NavigateToSecondPage() => NavigateToPage<SecondPage>();

        private Task NavigateToPage<T>() where T : Page {
            var page = ResolvePage<T>();
            if (page is not null)
                //ページ遷移の実施
                return Navigation.PushAsync(page, true);
            throw new InvalidOperationException($"Unable to resolve type {typeof(T).FullName}");
        }

        private T ResolvePage<T>() where T : Page => _services.GetService<T>();

        //前ページへ戻る
        public Task NavigateBack() {
            if (Navigation.NavigationStack.Count > 1)
                //前ページへ戻る
                return Navigation.PopAsync();
            throw new InvalidOperationException("No pages to navigate back to!");
        }
    }
}

Services/DataService.cs

データサービス。本来ならデータベースやAPIからデータを取得するクラスになると思うが、今回はサンプルなので直書き。こちらもインターフェースを実装して DI コンテナに登録することにより、差し替えを容易にしているので、単体テストでは IDataService を持ったクラスをでっちあげて使用すればデータベースなどに依存せずにテストができる。

using MauiTry.Models;

namespace MauiTry.Services {

    public interface IDataService {
        public List<Person> ReadPersons();
    }

    //データクラス。本来ならデータベースとかファイルとかAPIとかから取得になるが今回は面倒なので直書き。
    public class DataService : IDataService {
        public List<Person> ReadPersons() => 
            new() {
                new () { Name = "放浪軍師", Job = "エンジニア"},
                new () { Name = "夏狂乱", Job = "VAppler"},
                new () { Name = "赤猫お夏", Job = "火神"},
                new () { Name = "太照天昼子", Job = "火神"},
                new () { Name = "淀ノ蛇麻呂", Job = "水神"},
            };
    }
}

App.xaml.cs

最初に起動するページを MainPage プロパティに指定するクラス。コンストラクタ引数の FirstPage は DI コンテナに登録されたものが呼び出される。

using MauiTry.Views;

namespace MauiTry;

public partial class App : Application
{
    //使用するクラスを外部から渡す(Dependency Injection)
    public App(FirstPage pageView)
    {
        InitializeComponent();
        MainPage = new NavigationPage(pageView); //最初のページを指定。今回は NavigationPage なのでこのような形になる。
        //MainPage = pageView; //単一ページでよければこれでよい。
    }
}

Models/Person.cs

単純な人物クラス。もし ViewModel へプロパティの変更通知が必要になった場合は BindableBase クラスを継承して setter にて通知する。詳しくは後述。

namespace MauiTry.Models
{
    public class Person
    {
        public string Name { get; set; }
        public string Job { get; set; }
    }
}

Models/Lottery.cs

人物を抽選するクラス。コンストラクタ引数で DI コンテナに登録された DataService クラスが呼び出される。Execution メソッドを呼び出すと DataService クラスから人物リストを呼び出して抽選し、Person プロパティに代入する。ViewModel に何かを返したりしてはいけない。ただ、Personプロパティの SetProperty によって通知は届くので ViewModel はその変化を拾う事ができるようになっている。単体テスト時は IDataService を持つクラスをでっちあげる事で、自前のデータを用いることができる。

using MauiTry.Extends;
using MauiTry.Services;

namespace MauiTry.Models {
    public class Lottery : BindableBase {
        
        private readonly IDataService _dataService;

        public Person Person {
            get => _person;
            set => SetProperty(ref _person, value); //変更通知
        }
        private Person _person;

        //使用するクラスを外部から渡す(Dependency Injection)
        public Lottery(IDataService dataService) => _dataService = dataService;

        //抽選するロジック。必ず戻り値は無しにする事。
        public void Execution() {
            var rnd = new Random((int)DateTime.Now.Ticks & 0x0000FFFF);
            var persons = _dataService.ReadPersons(); //人物リストを取得
            var index = rnd.Next(0, persons.Count); //ランダムで抽選
            Person = persons[index]; //プロパティに代入
        }
    }
}

ViewModels/FirstPageViewModel.cs

最初に表示されるページの中継役クラス。例によってコンストラクタで DI コンテナに登録したクラスを呼び出している。ReactiveProperty によって View のコマンドを受け取って Model のメソッドをコールしたり、Model のプロパティ変更通知を受け取って View に通知したりしている。また、NavigationService を用いてページ遷移が行われる。

using MauiTry.Models;
using MauiTry.Services;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;

namespace MauiTry.ViewModels
{
    public class FirstPageViewModel {
        public ReactiveProperty<Person> Person { get; }
        public ReactiveCommand LotteryCommand { get; }
        public ReactiveCommand NextPageNavigationCommand { get; }

        //使用するクラスを外部から渡す(Dependency Injection)
        public FirstPageViewModel(INavigationService navigationService, Lottery lottery) {

            //model層のプロパティ変更通知を受け取り代入
            Person = lottery.ObserveProperty(x => x.Person).ToReactiveProperty();

            //View層のコマンド変更を感知してModel層のメソッドをコール
            LotteryCommand = new ReactiveCommand();
            LotteryCommand.Subscribe(_ => lottery.Execution());

            //View層のコマンド変更を感知してページ遷移
            NextPageNavigationCommand = new ReactiveCommand();
            NextPageNavigationCommand.Subscribe(async _ => await navigationService.NavigateToSecondPage());
        }
    }
}

Views/FirstPage.xaml

最初に表示されるページ。ラベルやボタンはすべて ViewModel の ReactiveProperty プロパティとバインディングするように記述しているが、ReactiveProprty は値を Value プロパティに格納するのでここでの記述では .Value を忘れないように注意する必要がある。また、Image の Source で指定されている画像は、Resources/Images/ に格納した画像のファイル名とする。

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MauiTry.Views.FirstPage"
             Title="FirstPageView">
  <VerticalStackLayout>
    <!-- ViewModelのプロパティとバインディングさせる。ReactiveProperty を使用する際は .Value を付ける-->
    <Label Text="{Binding Person.Value.Name}" />
    <Label Text="{Binding Person.Value.Job}" />
    <!-- ViewModelのコマンドとバインディング。ReactiveCommand は .Value は不要-->
    <Button Command="{Binding LotteryCommand}" Text="抽選!!" />
    <!-- 画像ファイルは Resources/Images に配置するだけでよい -->
    <Image
      Source="rancyan_minikowashiya.png"
      HeightRequest="200"
      HorizontalOptions="Center" />
    <!-- ViewModelのコマンドとバインディング-->
    <Button Command="{Binding NextPageNavigationCommand}" Text="Next Page!!" />
  </VerticalStackLayout>
</ContentPage>

Views/FirstPage.xaml.cs

最初に表示されるページのコードビハインド。コンストラクタ引数から DI コンテナに登録した ViewModel を取り出し、BindingContext に代入することによって View と ViewModel を結びつけることができる。

using MauiTry.ViewModels;

namespace MauiTry.Views;

public partial class FirstPage : ContentPage
{
    //使用するクラスを外部から渡す(Dependency Injection)
    public FirstPage(FirstPageViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel; //ViewModel を割り当てる
    }
}

ViewModels/SecondPageViewModel.cs

遷移先ページの中継層。解説は省略。

using MauiTry.Services;
using Reactive.Bindings;

namespace MauiTry.ViewModels {
    public class SecondPageViewModel {
        public ReactiveCommand NavigateBackCommand { get; }

        //使用するクラスを外部から渡す(Dependency Injection)
        public SecondPageViewModel(INavigationService navigationService) {
            //View層のコマンド変更を感知して前ページへ戻る
            NavigateBackCommand = new ReactiveCommand();
            NavigateBackCommand.Subscribe(async _ => await navigationService.NavigateBack());
        }
    }
}

Views/SecondPage.xaml

遷移先ページ。解説は省略。

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MauiTry.Views.SecondPage"
             Title="SecondPage">
  <VerticalStackLayout>
    <Label 
            Text="SecondPage!!"
            VerticalOptions="Center" 
            HorizontalOptions="Center" />
    <!-- ViewModelのコマンドとバインディング-->
    <Button Command="{Binding NavigateBackCommand}" Text="Back Page!!" />
  </VerticalStackLayout>
</ContentPage>

Views/SecondPage.xaml.cs

遷移先ページのコードビハインド。解説は省略。

using MauiTry.ViewModels;

namespace MauiTry.Views;

public partial class SecondPage : ContentPage
{
    //使用するクラスを外部から渡す(Dependency Injection)
    public SecondPage(SecondPageViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel; //ViewModel を割り当てる
    }
}

動作確認

左から Windows iPhone Android。やっぱり見た目は結構違うのよね。仕方ないね。

まとめ

とりあえずこんな感じで MVVM パターンでの実装ができるようです。DI コンテナが標準でついているのはやっぱありがたいですねぇ。MVVM 用ライブラリである Prism を使うと INavigationService や BindableBase  を自作しなくて済むので採用するべきかは用途次第かなと思います。さて、次は .NET MAUI で新たに追加されたらしい MVU パターンとやらを試してみたいなぁと思ってます。お楽しみに!