shuhelohelo’s blog

Xamarin.Forms多めです.

Xamarin.FormsでASP.NET CoreなDI(Dependency Injection) (1)

montemagno.com

こ,これは...!

Xamarin.Forms以外のアプリケーションでも同様に使えそう.

Microsoft.Extensions.Hostingをインストールする

Nugetから.

すべてのプロジェクトに.

f:id:shuhelohelo:20200604200845p:plain

ビルトインのLoggingフレームワークと,jsonファイル形式(appsettings.json)のアプリケーション設定もできる.おなじみ.

Startup.csを作る

共有プロジェクト内に作る.

雛形としては以下のとおり.

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System;
using System.Collections.Generic;
using System.Text;

namespace XFUseAspNetCoreDI
{
    public class Startup
    {
        public static IServiceProvider ServiceProvider { get; set; }
        public static void Init()
        {

        }

        static void ConfigureServices(HostBuilderContext ctx,IServiceCollection services)
        {

        }
    }
}

ふむふむ

appsettings.jsonを作る

Embedded resourceにする. f:id:shuhelohelo:20200604202434p:plain

中身は以下のように,ASP.NET Coreでおなじみの内容.

{
  "Hello": "World",
  "Environment": "Development",
  "Logging": {
    "IncludeScopes": false,
    "LogLevel": {
      "Default": "Debug",
      "System": "Information",
      "Microsoft": "Information"
    }
  }
}

HostBuilderの設定

ASP.NET CoreだとPrograms.cs内に書いてある内容.

  • HostBuilder
    • アプリに各種Serviceを追加するためのBuilder
  • IServiceProvider
    • すべてのDependency Injectionを担う
    • HostBuilderから作られるService Locater.

Startup.csのInitメソッドを以下のようにする.

        public static void Init()
        {
            var a = Assembly.GetExecutingAssembly();
            using var stream = a.GetManifestResourceStream("XFUseAspNetCoreDI.appsettings.json");

            var host = new HostBuilder().ConfigureHostConfiguration(c =>
                {
                    //これは?
                    c.AddCommandLine(new string[] { $"ContentRoot={FileSystem.AppDataDirectory}" });

                    //設定ファイルを読む
                    c.AddJsonStream(stream);
                })
                .ConfigureServices((c, x) =>
                {

                    ConfigureServices(c, x);
                })
                .ConfigureLogging(l=>l.AddConsole(o=> {
                    o.DisableColors = true;
                }))
                .Build();

            ServiceProvider = host.Services;
        }

DIしたいクラスを作成する

MVVMパターンでDIしたいものの代表としてはまずはViewModelかなと思います.

いつもどおりViewModelを作りますが,ここではMvvmHelpersというライブラリを使用して,BaseViewModelを作ってあるとします.

MvvmHelpersを使ったMVVMパターンによる実装の基本的なことについては以下の記事を参照.

shuhelohelo.hatenablog.com

MainPageViewModel.csとして以下のように作りました.

using System;
using System.Collections.Generic;
using System.Text;
using System.Windows.Input;
using Xamarin.Forms;
using XFUseAspNetCoreDI.Models;
using XFUseAspNetCoreDI.Services;

namespace XFUseAspNetCoreDI.ViewModels
{
    public class MainPageViewModel : BaseViewModel
    {
        private string _message;
        public string Message
        {
            get => _message;
            set => SetProperty(ref _message, value);
        }

        public MainPageViewModel(IDataService dataService)
        {
            this.Message = "Hello, Dependency Injection!";
        }
    }
}

そしてMainPage.xamlは以下のとおりです.

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    x:Class="XFUseAspNetCoreDI.MainPage"
    xmlns="http://xamarin.com/schemas/2014/forms"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:d="http://xamarin.com/schemas/2014/forms/design"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:viewmodels="clr-namespace:XFUseAspNetCoreDI.ViewModels"
    mc:Ignorable="d">

    <StackLayout>
        <Label
            FontSize="Large"
            Text="{Binding Message}"
            TextColor="Red" />
    </StackLayout>

</ContentPage>

Messageプロパティに入れた文字列を赤字で表示するだけのシンプルな内容です.

DIコンテナへ登録

今回のViewModelに限らず,DIしたいクラスの登録はStartup.csのConfigureServicesメソッドで行います.

以下のように先程作成したMainPageViewModelを登録します.

        static void ConfigureServices(HostBuilderContext ctx, IServiceCollection services)
        {
            services.AddTransient<MainPageViewModel>();
        }

AddTransientメソッドは登録したクラスがDIされる毎にインスタンスが新規作成されます.

他にもAddSingletonメソッドがありますが,こちらは名前のとおり同じインスタンスを使います.

これでDIの準備は整いました.

この時点でのStartup.csは以下のとおりです.

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using Xamarin.Essentials;
using XFUseAspNetCoreDI.Services;
using XFUseAspNetCoreDI.ViewModels;

namespace XFUseAspNetCoreDI
{
    public class Startup
    {
        public static IServiceProvider ServiceProvider { get; set; }
        public static void Init()
        {
            var a = Assembly.GetExecutingAssembly();
            using var stream = a.GetManifestResourceStream("XFUseAspNetCoreDI.appsettings.json");

            var host = new HostBuilder().ConfigureHostConfiguration(c =>
                {
                    //これは?
                    c.AddCommandLine(new string[] { $"ContentRoot={FileSystem.AppDataDirectory}" });

                    //設定ファイルを読む
                    c.AddJsonStream(stream);
                })
                .ConfigureServices((c, x) =>
                {

                    ConfigureServices(c, x);
                })
                .ConfigureLogging(l=>l.AddConsole(o=> {
                    o.DisableColors = true;
                }))
                .Build();

            ServiceProvider = host.Services;
        }

        static void ConfigureServices(HostBuilderContext ctx, IServiceCollection services)
        {
            services.AddTransient<MainPageViewModel>();
        }
    }
}

ViewとViewModelを結びつける

あとはViewのBindingContextにViewModelのインスタンスを入れれば完了ですが,ここが素晴らしいところです.

通常であれば,Viewのコンストラクタで以下のようにするかと思います.

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Xamarin.Forms;
using XFUseAspNetCoreDI.ViewModels;
using Xamarin.Forms.Xaml;

namespace XFUseAspNetCoreDI
{
    [DesignTimeVisible(false)]
    public partial class MainPage : ContentPage
    {
        public MainPage()
        {
            InitializeComponent();

            this.BindingContext = new MainPageViewModel();
        }
    }
}

このMainPageViewModelのインスタンスをDIコンテナから取得するようにします.

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Xamarin.Forms;
using XFUseAspNetCoreDI.ViewModels;
using Xamarin.Forms.Xaml;

namespace XFUseAspNetCoreDI
{
    [DesignTimeVisible(false)]
    public partial class MainPage : ContentPage
    {
        public MainPage()
        {
            InitializeComponent();

            this.BindingContext = Startup.ServiceProvider.GetService<MainPageViewModel>();
        }
    }
}

このように登録したクラスはGetService<T>メソッドでいつでもインスタンスを取り出すことができます.

そしてDIによって生成されたインスタンスは生成時に自動的にコンストラクタインジェクションされます.

これについては後で書くとします.

最後に,Startup.Init()メソッドが何よりも先に実行されている必要があるので,App.xaml.csのAppコンストラクタで以下のように実行されるようにしておきます.

        public App()
        {
            InitializeComponent();

            Startup.Init();//これ

            MainPage = new MainPage();
        }

さて,上の例でまずは実行してみましょう.

f:id:shuhelohelo:20200605115035p:plain

メッセージが表示されました. ViewとViewModelのバインディングができていることが確認できました.

ViewにもViewModelをコンストラクタインジェクションする

さて,上の例ではMainPageViewModelのインスタンスをServiceProviderから取得してBindingContextに入れているので,直接new MainPageViewModel()するのとあまり変わらない気がします.

やはり,ViewにもViewModelのインスタンスが自動的に渡されるようにしたいところです.

MainPageのコンストラクタを以下のように書き換えます.

        public MainPage(MainPageViewModel viewModel = null)
        {
            InitializeComponent();

            BindingContext = viewModel;
        }

このコンストラクタの引数にMainPageViewModelのインスタンスが渡されてくれると嬉しいです.

そのためには,このMainPageもServiceProviderによって生成されるようにする必要があります.

Startup.csのConfigureServicesメソッド内で,MainPageを登録します.

        static void ConfigureServices(HostBuilderContext ctx, IServiceCollection services)
        {
            services.AddTransient<MainPageViewModel>();
            services.AddTransient<MainPage>();//これ
        }

そしてAppコンストラクタを以下のようにします.

        public App()
        {
            InitializeComponent();

            Startup.Init();

            //ServiceProviderからMainPageのインスタンスを取得
            MainPage = Startup.ServiceProvider.GetService<MainPage>();
        }

これで準備完了です.

MainPageのコンストラクタ内にブレークポイントを設置して実行します.

f:id:shuhelohelo:20200605123030p:plain

MainPageViewModelのインスタンスが渡されたことが確認できました.

実行結果は同様です.

ViewModelへのコンストラクタインジェクション

ここまででViewへViewModelをコンストラクタインジェクションすることができました.

このときにViewModelはServiceProviderによって生成されるため,ViewModelにもコンストラクタインジェクションが行われます.

ここではViewModelへのコンストラクタインジェクションについて説明していきたいと思います.

ViewModel側ではViewに表示させるデータを用意したりすると思います.

アプリ内の何かしらのDB(SQLiteやLiteDB,テキストファイル)からデータを取得することでしょう.

このとき,DB操作を担当するクラスを作成して,そのインスタンスを介してDBの操作を行ったりします.これを仮にDataServiceクラスとしましょう.

DataServiceクラスをViewModel内でnew DataService();というようにインスタンス化すると,依存関係が生じてしまい,ViewModelクラスのテストが難しくなってしまいます.

なので,ViewModelで使用するオブジェクトもコンストラクタ経由で渡すということをします.

これもServiceProvider経由で自動的にインスタンスが渡されます.

結果として,MainPageにMainPageViewModelが渡され,MainPageViewModelにDataServiceが渡されるということが自動的に行われます.

ではDataServiceクラスを作成しましょう.

まずはインターフェースを作成します.IDataServiceとします.Personのコレクションを返すメソッドを持ちます.

using System;
using System.Collections.Generic;
using System.Text;
using XFUseAspNetCoreDI.Models;

namespace XFUseAspNetCoreDI.Services
{
    public interface IDataService
    {
        ICollection<Person> FindAll();
    }
}

これを実装するMockDataServiceを作成します.これは開発やテストで使用するためのクラスです.

FindAllメソッドは100個のPersonが入ったコレクションを返します.

using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Text;
using XFUseAspNetCoreDI.Models;

namespace XFUseAspNetCoreDI.Services
{
    public class MockDataService : IDataService
    {
        private readonly ILogger<MockDataService> _logger;
        
        public MockDataService(ILogger<MockDataService> logger)
        {
            this._logger = logger;
        }
        public ICollection<Person> FindAll()
        {
            var people = new List<Person>();

            for (int i = 0; i < 100; i++)
            {
                people.Add(new Person { Name = $"Person{i}", Age = i });
            }

            _logger.LogCritical("MockDataServiceから呼ばれました");

            return people;
        }
    }
}

合わせて,本番用のDataServiceクラスも作っておきます.が,今回は呼び出されることはないのでメソッドの中身は例外を吐くだけのものです.

using System;
using System.Collections.Generic;
using System.Text;
using XFUseAspNetCoreDI.Models;

namespace XFUseAspNetCoreDI.Services
{
    public class DataService : IDataService
    {
        public ICollection<Person> FindAll()
        {
            throw new NotImplementedException();
        }
    }
}

ではこれをServiceProviderに登録します.おなじみConfigureServicesを以下のようにします.

        static void ConfigureServices(HostBuilderContext ctx, IServiceCollection services)
        {
            //開発と本番で登録するクラスを切り替える
            if(ctx.HostingEnvironment.IsDevelopment())
            {
                services.AddSingleton<IDataService, MockDataService>();
            }
            else
            {
                services.AddSingleton<IDataService, DataService>();
            }

            services.AddTransient<MainPageViewModel>();
            services.AddTransient<MainPage>();
        }

次にMainPageViewModelのコンストラクタを以下のようにします.

    public class MainPageViewModel : BaseViewModel
    {
        private ICollection<Person> _people;
        public ICollection<Person> People
        {
            get => _people;
            set => SetProperty(ref _people, value);
        }

        private string _message;
        public string Message
        {
            get => _message;
            set => SetProperty(ref _message, value);
        }

         //ここ
        public MainPageViewModel(IDataService dataService)
        {
            this.Message = "Hello, Dependency Injection!";
        }
    }

コンストラクタはIDataService型の引数を受け取るようにします.

それではコンストラクタ内にブレークポイントを設置し,実行してみましょう.

この引数にMockDataServiceクラスのインスタンスが渡されていることがわかります.

f:id:shuhelohelo:20200605151333p:plain

ここまでで登録しておいたクラスのインスタンスが自動的にコンストラクタに渡されていることが確認できました.

では最後に,MainPageViewModelで受け取ったMockDataServiceのインスタンスを使ってデータを取得し,それを表示するようにします.

以下のようにコンストラクタに渡されたMockDataServiceのインスタンスを保持する_dataServiceフィールドと,取得したコレクションを保持するPeopleプロパティ,そしてMainPageでボタンがタップされたときの処理を受け付けるGetPeopleCommandを追加しています.

    public class MainPageViewModel : BaseViewModel
    {
        private string _message;
        public string Message
        {
            get => _message;
            set => SetProperty(ref _message, value);
        }

        private readonly IDataService _dataService;

        private ICollection<Person> _people;
        public ICollection<Person> People
        {
            get => _people;
            set => SetProperty(ref _people, value);
        }

        public ICommand GetPeopleCommand { get; }

        public MainPageViewModel(IDataService dataService)
        {
            this.Message = "Hello, Dependency Injection!";
            this._dataService = dataService;

            GetPeopleCommand = new Command(_ => this.People = _dataService.FindAll());
        }
    }

MainPageは以下のようにしました.

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    x:Class="XFUseAspNetCoreDI.MainPage"
    xmlns="http://xamarin.com/schemas/2014/forms"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:d="http://xamarin.com/schemas/2014/forms/design"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:viewmodels="clr-namespace:XFUseAspNetCoreDI.ViewModels"
    mc:Ignorable="d">

    <StackLayout>
        <Label
            FontSize="Large"
            Text="{Binding Message}"
            TextColor="Red" />
        <Button Command="{Binding GetPeopleCommand}" Text="Get People" />
        <CollectionView ItemsSource="{Binding People}">
            <CollectionView.ItemsLayout>
                <LinearItemsLayout Orientation="Vertical" />
            </CollectionView.ItemsLayout>
            <CollectionView.ItemTemplate>
                <DataTemplate>
                    <Grid>
                        <Frame
                            Margin="5"
                            Padding="0"
                            BackgroundColor="LightPink">
                            <StackLayout>
                                <Label Text="{Binding Name}" />
                                <Label Text="{Binding Age}" />
                            </StackLayout>
                        </Frame>
                    </Grid>
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>
    </StackLayout>

</ContentPage>

実行し,「GET PEOPLE」ボタンを押すと,以下のように取得したデータがリスト状に表示されます.

おわりに

ASP.NET CoreのDIの仕組みを利用することで,View,ViewModel以下,アプリケーション全体でASP.NET Coreのようなコンストラクタインジェクションができるようになります.

Jamesさんはブログの中で「Well maybe you shouldn't do this, but it is pretty cool.」と言っているので,使うべきか否かはよく考えるとして,とにかく「Cool!」なので素晴らしいと思います.

今後

Jamesさんの記事では,IHttpFactoryについても書かれているので,試してみたいですね.

ソースコード

github.com