PrismのDIコンテナを使ってWPFアプリ実行中に注入クラスを切り替える

Prismを使ったWPFアプリで、アプリ実行中にDIコンテナへの登録を変更できる、という話です。アプリ実行中に設定画面でSQLiteファイルの保存場所を変更したいと思ったけれど良い方法が分からず、ずっと悩んでいたのが最近解決したのでその備忘録です。

今回やりたいことを図にするとこんな感じです。SQLiteのファイルの場所の他にもDBエンジンを切り替えることもできます。
(この図はDDDでいうRepositoryを切り替える様子を表していますが、この記事で紹介する切り替えの方法はDIコンテナに注入するどんなクラスにも適用できます。)

注入クラス切り替えのイメージ

結論

以下の2点がポイントです。

  • RegisterTypes関数の引数であるcontainerRegistryをDIコンテナに登録する。
protected override void RegisterTypes(IContainerRegistry containerRegistry)
{
    containerRegistry.RegisterInstance(containerRegistry);

    // 中略
}
  • 切り替えたいクラスだけでなく、そのクラスを直接的にも間接的にも読み込むクラスたちもDIコンテナに再登録する。

サンプルアプリ作成

検証のために作ったアプリの開発手順を残しておきます。アプリのコードはGitHubに上げてます

検証環境

作成手順

プロジェクト作成

  • Visual Studioを起動してテンプレートに「Prism Blank App (WPF)」を選択
    (※:これは「Prism Template Pack」という拡張機能をインストールすることで利用できます。)
  • 「プロジェクト名」と「場所」を入力
  • 単純なアプリが実行できる状態でプロジェクトが作成される

コーディング

以下の画像のようにファイルを作成しコーディングしていきます(GitHub参照)。Infra1ItemRepositoryとInfra2ItemRepositoryがIItemRepositoryの実装クラスです(Repositoryパターン)。

ファイル一覧

以下にポイントとなるコードの内容を解説します。

App.xaml.csではRegisterTypes関数内で利用するクラスを初期登録します。RegisterInstance関数でcontainerRegistryを登録しているのが結論の1つ目で紹介した部分です。

protected override void RegisterTypes(IContainerRegistry containerRegistry)
{
    containerRegistry.RegisterInstance(containerRegistry);

    containerRegistry.Register<IItemRepository, Infra1ItemRepository>();
    containerRegistry.Register<IItemApplicationService, ItemApplicationService>();

    containerRegistry.Register<IMainUserControlModel, MainUserControlModel>();

    containerRegistry.RegisterForNavigation<MainUserControl>();
}

MVVMのM(モデル)に該当するクラスにボタンを押したときに実行される関数を記述します。このクラスはDIコンテナのレジストリ(IContainerRegistry)型の変数を持ち、コンストラクタの引数で変数を受け取り代入します。再登録する際は切り替えたいRepositoryだけでなく、ApplicationServiceやMVVMのMに該当するクラスも再登録しています。

public class MainUserControlModel : IMainUserControlModel
{
    // 中略

    private readonly IContainerRegistry _containerRegistry;

    public MainUserControlModel(
            // 中略
            IContainerRegistry containerRegistry)
    {
        _containerRegistry = containerRegistry;

        // 中略
    }

    public void SwitchToInfra1()
    {
        _containerRegistry.Register<IItemRepository, Infra1ItemRepository>();
        _containerRegistry.Register<IItemApplicationService, ItemApplicationService>();

        _containerRegistry.Register<IMainUserControlModel, MainUserControlModel>();
    }

    public void SwitchToInfra2()
    {
        _containerRegistry.Register<IItemRepository, Infra2ItemRepository>();
        _containerRegistry.Register<IItemApplicationService, ItemApplicationService>();

        _containerRegistry.Register<IMainUserControlModel, MainUserControlModel>();
    }
}

ViewModelのボタン押下時の処理です(ReactivePropertyというライブラリを使用して書いています)。上述の処理を呼び出した後、RequestNavigate関数というPrismのNavigation機能を使ってページ遷移します。こうすることでViewModelクラスのインスタンスが再生成されます(コンストラクタが実行される)。上述の処理ではViewModelクラスに関連するクラスたちを再登録していたので、ページ遷移後には切り替え後のクラスがアプリに反映されます。

SwitchToInfra1Command = new ReactiveCommand();
SwitchToInfra1Command.Subscribe(() =>
{
    _mainUserControlModel.SwitchToInfra1();

    _regionManager.RequestNavigate("ContentRegion", nameof(MainUserControl));
});

SwitchToInfra2Command = new ReactiveCommand();
SwitchToInfra2Command.Subscribe(() =>
{
    _mainUserControlModel.SwitchToInfra2();

    _regionManager.RequestNavigate("ContentRegion", nameof(MainUserControl));
});

実行

F5を押してアプリを実行します。ボタンを押すとRepositoryの実装クラスが切り替わり、画面に表示されるアイテムが変わるのを確認できます。

まとめと感想

アプリ実行時の変数containerRegistryをDIコンテナに登録することで、他クラスでも依存性注入することができます。注入したクラスをアプリに反映させるには関連する全てのクラスを登録しなおす必要があります。
関連する全てのクラスを再登録することが盲点でした。変更したクラスだけ再登録したのにアプリの実行結果に反映されなかったことから、一度登録したら変更できないのかと考えていました。注入クラスの切り替えが実現できたことで、アプリ実行中に依存クラスを動的に変更できるため、アプリ内で行えることが広がりそうです。

参考

WPFアプリケーションの公開作業をAzure Pipelinesを使って自動化する

前回の記事で行った作業を自動化できたらいいなと思っていたらAzure Pipelinesでいい感じにできたので、その時の記録です。Azureの他のサービスとの連携が簡単で、Azure Blob Storageにアップロードする作業も簡単に書くことができました。

準備

前回の記事の続きとして書くので、記事の中で作成したWPFアプリケーションが既にあることを前提とします。今回の作業では、前回の「公開」作業を行った時点で生成される「ClickOnceProfile.pubxml」を使います。Azure Blob Storageも前回と同じコンテナーを使う想定で書きます。

必要なもの

前回の記事に加えて、本記事では以下のもの利用します。

記事の構成

この記事は以下の9つの手順でまとめます。

  1. プロジェクトにランタイムを指定する
  2. Azure DevOpsでプロジェクトを作成する
  3. Azure Reposにコードをプッシュする
  4. Azure Pipelinesでパイプラインを作成し、 Gitリポジトリと紐づける
  5. Pipelineを編集する
  6. Pipelineを実行する
  7. バージョンアップしてGitリポジトリにプッシュする
  8. Pipelineが自動実行することを確認する
  9. アプリが自動更新するか試す

1. プロジェクトにランタイムを指定する

SimpleWPFApplication.csprojRuntimeIdentifiers タグを追加します。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    ...
    <RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
    ...
  </PropertyGroup>
</Project>

ちょっと説明

RuntimeIdentifiers タグを追加しておかないと、手順6でパイプラインを実行したときに以下のようなエラーが出てしまいます。

##[error]C:\Program Files\dotnet\sdk\5.0.402\Sdks\Microsoft.NET.Sdk\targets\Microsoft.PackageDependencyResolution.targets(241,5): Error NETSDK1047: Assets file 'D:\a\1\s\SimpleWPFApplication\obj\project.assets.json' doesn't have a target for 'net5.0-windows/win-x64'. Ensure that restore has run and that you have included 'net5.0-windows' in the TargetFrameworks for your project. You may also need to include 'win-x64' in your project's RuntimeIdentifiers.

このエラーを避けるため、エラーメッセージで勧められている通りランタイムを指定しておきます。

2. Azure DevOpsでプロジェクトを作成する

Azure DevOpsの自分の組織(Organization)のページを開き、 [+ New project] から新しいプロジェクトを作成します。
今回は [SimpleWPFApplication] という名前で作成しました。

3. Azure Reposにコードをプッシュする

プロジェクトの中にいくつかアイコンがありますが、その中の [Repos] を開きます。

クローンしたりプッシュするためのURLやコマンド例が載せられているので、手元の Git クライアントを使ってコードをプッシュします。手順は省略します。ちなみに .gitignore はGitHub公式のこちらが役に立ちます。注意点として、この .gitignore は今回使いたい pubxml ファイルが含まれているので pubxml をGit管理できるように変更する必要があります。

プッシュするとReposの画面は以下のようになります。

4. Azure Pipelinesでパイプラインを作成し、 Gitリポジトリと紐づける

プロジェクトの中から [Pipelines] を開き、 [Create Pipeline] から新しいパイプラインを作成します。接続先を選択する画面では、 [Azure Repos Git] から先程作成したリポジトリを選択します。ちなみに、候補にあるGitHubやBitbucketなどのGitホスティングサービスと連携することもできます。試しにGitHubとの連携をやってみましたがとても簡単にできました。以降の手順はそれらのサービスと連携した場合も同じように行えます。

5. Pipelineを編集する

この記事のメインです。パイプラインのテンプレートを選べるので [.NET Desktop] を選択します。

そうすると画面上で直接yamlが編集できるようになるので、以下のように編集します。yamlの編集が完了したら [Save] します。

# .NET Desktop
# Build and run tests for .NET Desktop or Windows classic desktop solutions.
# Add steps that publish symbols, save build artifacts, and more:
# https://docs.microsoft.com/azure/devops/pipelines/apps/windows/dot-net

trigger:
- main

pr: none

pool:
  vmImage: 'windows-latest'

variables:
  solution: 'SimpleWPFApplication.sln'
  project: 'SimpleWPFApplication\SimpleWPFApplication.csproj'
  buildPlatform: 'Any CPU'
  buildConfiguration: 'Release'

steps:
- task: NuGetToolInstaller@1

- task: NuGetCommand@2
  inputs:
    restoreSolution: '$(solution)'

- task: VSBuild@1
  inputs:
    solution: '$(solution)'
    platform: '$(buildPlatform)'
    configuration: '$(buildConfiguration)'

- task: VSTest@2
  inputs:
    platform: '$(buildPlatform)'
    configuration: '$(buildConfiguration)'

- task: VSBuild@1
  displayName: 'Publish'
  inputs:
    solution: '$(project)'
    platform: '$(buildPlatform)'
    configuration: '$(buildConfiguration)'
    msbuildArgs: '-t:publish -p:PublishProfile=ClickOnceProfile.pubxml -p:PublishDir="bin\publish\\"'

- task: AzureFileCopy@3
  displayName: 'Upload setup.exe'
  inputs:
    SourcePath: 'SimpleWPFApplication\bin\publish\setup.exe'
    azureSubscription: '(サブスクリプション名)'
    Destination: 'AzureBlob'
    storage: '(ストレージアカウント名)'
    ContainerName: '$web'

- task: AzureFileCopy@3
  displayName: 'Upload SimpleWPFApplication.application'
  inputs:
    SourcePath: 'SimpleWPFApplication\bin\publish\SimpleWPFApplication.application'
    azureSubscription: '(サブスクリプション名)'
    Destination: 'AzureBlob'
    storage: '(ストレージアカウント名)'
    ContainerName: '$web'

- task: AzureFileCopy@3
  displayName: 'Upload Application Files'
  inputs:
    SourcePath: 'SimpleWPFApplication\bin\publish\Application Files'
    azureSubscription: '(サブスクリプション名)'
    Destination: 'AzureBlob'
    storage: '(ストレージアカウント名)'
    ContainerName: '$web'
    BlobPrefix: 'Application Files'

ちょっと説明

ブラウザの右側にある [Tasks] から使いたいタスクを選択し、フォーム入力すればタスクのyamlを自動生成してくれます。まずはこの機能を使ってyamlを自動生成し、必要であれば直接編集する、という流れが良いと思います。

pr: none はプルリクエストを作成した時にパイプラインを実行しないように指定します。

ClickOnce公開のためのコマンドは公式ページを参考にしました。yamlでは [VSBuild] となっていますが、おそらくMSBuildを表していて同じオプションで動きました。PublishProfileオプションを使えば、Visual Studioで操作したときに生成された [ClickOnceProfile.pubxml] を指定してオプションをすっきりさせることができます。しかし、公式ページにもある通りPublishDirオプションはここでも指定する必要があります。

[AzureFileCopy] というタスクでAzure Blob Storageにアップロードしています。[azureSubscription] には、自分のAzureサブスクリプションを指定してください。執筆時点では最新版であるバージョン4 [@4] では動作しませんでした。アップロードしているファイルやフォルダは基本的に前回の記事と同じです。ただしインストールするときに使えるWebページ Publish.html はMSBuildコマンドでは出力されないようで、今回は含めていません。インストール用のリンクが載ったページが必要であれば自分で作成する必要がありそうです。

6. Pipelineを実行する

画面右上の [Run] からパイプラインを実行します。うまくいけば下の図のように成功します。

7. バージョンアップしてGitリポジトリにプッシュする

最後に手順7から9で、コードを変更→mainブランチにプッシュ→パイプライン実行→アプリを更新の流れをやってみます。

ここはVisual Studioで作業します。前回の記事の「発行」機能を使えばリビジョンが自動で増加しましたが、今回はそういうわけにはいかないので ClickOnceProfile.pubxml を自分で編集する必要があります。バージョン番号はなんでもいいですが 1.0.1.0 としてみました。

<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <PropertyGroup>
    <ApplicationRevision>0</ApplicationRevision>
    <ApplicationVersion>1.0.1.*</ApplicationVersion>
    ...
  </PropertyGroup>
</Project>

前回と同様に MainWindow.xaml を編集して画面にバージョン番号を表示するようにします。

<Window
    ...
    >
    <Grid>
        <TextBlock Text="Ver.1.0.1.0" />
    </Grid>
</Window>

これらの変更分をgit commitしてmainブランチにプッシュします。

8. Pipelineが自動実行することを確認する

Azure DevOpsのパイプラインのページを見にいくとパイプラインが実行しています。

9. アプリが自動更新するか試す

アプリを起動すると更新ダイアログが表示され、更新すると新しいバージョンになっていることが確認できます。

WPFアプリケーションをClickOnce発行してAzure Blob Storageに公開する

タイトル通りなのですが、開発したWindowsデスクトップアプリケーションをインターネットからインストールして使えるようにしたいということがあったので、その時の備忘録です。

必要なもの

この記事で紹介している方法を試すには以下のものが必要です。

記事の構成

この記事は以下の7つの手順でまとめます。

  1. Azure Blob Storageに静的Webサイト用コンテナーを作成する
  2. WPFアプリケーションを作成する
  3. WPFアプリケーションをClickOnce発行する
  4. アプリのファイル一式をAzure Blob Storageにアップロードする
  5. アプリをインストールする
  6. アプリをバージョンアップして再アップロードする
  7. アプリが自動更新するか試す

1. Azure Blob Storageに静的Webサイト用コンテナーを作成する

Azure Portalに移動して、ストレージアカウントを作成します。

作成し終わったら、左メニューから [静的なWebサイト] を選択します。

[有効] を選択して [保存] をクリックします。(裏で $web という名前のコンテナーが生成されます)

表示されるプライマリエンドポイントをコピーしておきます。(手順3で使います)

2. WPFアプリケーションを作成する

ここからはVisual Studioで作業します。新しいプロジェクトの作成で [WPFアプリケーション] を選択します。

プロジェクト名は [SimpleWPFApplication] とします。

ターゲットフレームワークは [.NET5.0] とします。

以下のようなファイルが自動生成されます。

MainWindow.xaml を少し書き換えてバージョン番号を表示するようにしておきます。

<Window
    ...
    >
    <Grid>
        <TextBlock Text="Ver.1.0.0.0" />
    </Grid>
</Window>

3. WPFアプリケーションをClickOnce発行する

Visual Studioのソリューションエクスプローラーでプロジェクトを右クリックし [発行] をクリックします。

[ClickOnce] を選択します。

発行場所はデフォルトの [bin\publish\] のままでいきます。

インストール場所は [Webサイトから] を選択し、手順1でコピーしたBlob Storageのプライマリエンドポイントを貼り付けます。

設定はデフォルトのままでいきます。ClickOnceには更新プログラムを確認して自動で更新する機能があるのでそれを使ってみます。

今回マニフェストの署名はなしでいきます。

構成はいろいろいじります。「配置モード」は [自己完結] 、「ターゲットランタイム」は [win-x64] を選択し、 [単一ファイルの作成] をチェックします。ここは全てのパターンを試してないですが、この構成でうまく動いたのでこれでいきます。

[完了] をクリックすると下の画像のような画面になるので、 [発行] をクリックします。

発行後、発行場所を見に行くといろいろなファイルが生成されています。

4. アプリのファイル一式をAzure Blob Storageにアップロードする

Azure Storage Explorerを使って作業します。以下のファイルとフォルダを$webコンテナーにアップロードします。Azure Storage Explorerを使えば簡単にドラッグアンドドロップでアップロードできます。

  • setup.exe
  • SimpleWPFApplication.application
  • Publish.html
  • Application Files フォルダ

アップロード後、Azure Portalでも確認できます。

5. アプリのインストール

ブラウザで「Publish.html」を見に行きます。(URLは https://ストレージアカウント名.z11.web.core.windows.net/Publish.html のようになります。)

[インストール] をクリックするとsetup.exeがダウンロードされます。setup.exeを起動すると確認ウィンドウが表示されるので発行元を確認して [実行] をクリックします。

[インストール] をクリックするとインストールが始まります。

インストールが完了するとアプリが起動します。

左下のスタートメニューから起動することができるようになりました。

6. アプリをバージョンアップして再アップロード

ここからは少し蛇足になりますが、新バージョン版をアップロードしてアプリが自動更新するところを手順6と7で記載します。まず、バージョンアップしたことが分かるように画面 MainWindow.xaml のバージョン番号を変えておきます。

<Window
    ...
    >
    <Grid>
        <TextBlock Text="Ver.1.0.0.1" />
    </Grid>
</Window>

手順3の再度で行った [発行] を再度クリックします。リビジョン(バージョン番号の一番右)が自動で増加するようになっているので、ここで発行したバージョンは「1.0.0.1」となります。

手順4で行ったアップロードをもう一度行います。

7. アプリの自動更新

手順5でインストールしたアプリを起動します。すると更新を促すウィンドウが出るので、そのまま [OK] をクリックすると更新されます。

更新後にアプリを開くと新しいバージョン(1.0.0.1)になっていることが確認できます。

ItemsControlを使ってWPFでGitHub風ヒートマップを描画する

WPFアプリで日々の実績を図で表現したいと思い、GitHubのContribution Graph(芝生)を参考に描画してみたのでその時の記録を残しておきます。外部データを参照し動的に描画できるように繰り返し描画に便利な ItemsControlを利用しました。xamlはWPFの素の機能だけで書いていますが、裏側はMVVMパターンで実装できるPrismというフレームワークを利用しています。

環境

使用しているプラットフォームやフレームワークは以下の通りです。

実装:xaml

メインテーマであるItemsControlはこんな感じです。

<ItemsControl ItemsSource="{Binding WeekHeatmaps}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <StackPanel Orientation="Horizontal" />
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
    <ItemsControl.ItemTemplate>
        <DataTemplate>

            <ItemsControl ItemsSource="{Binding HeatmapElements}">
                <ItemsControl.ItemsPanel>
                    <ItemsPanelTemplate>
                        <StackPanel Orientation="Vertical" />
                    </ItemsPanelTemplate>
                </ItemsControl.ItemsPanel>
                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <Grid
                            Width="15"
                            Height="15"
                            ToolTip="{Binding Comment}">
                            <Border
                                Margin="1"
                                Background="{Binding Color}"
                                CornerRadius="2" />
                        </Grid>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>

        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

ちょっと説明

ItemsControlを入れ子にしています。外側が芝生の列を表していて、実績データでいう1週間分に相当する部分です。横方向に連ねていくため、水平方向(Horizontal)に伸びるStackPanelを使います。内側は芝生の一つのマスを表していて、実績データでいう1日分に相当する部分です。縦方向に連ねていくため、垂直方向(Vertical)に伸びるStackPanelを使います。どちらのItemsControlも中身のカスタマイズにItemTemplateを利用しています。

実装:バインディングするデータ

バインディングしているデータWeekHeatmapsですが、 ViewModel内で以下のようなリストとして定義しています。

public class MainWindowViewModel : BindableBase
{
    ...

    public List<WeekHeatmap> WeekHeatmaps { get; }

    ...
}

リストの型として使っているWeekHeatmapクラスと関連クラスは以下の通りです。

public class WeekHeatmap
{
    public WeekHeatmap(List<HeatmapElement> heatmapElements)
    {
        if (heatmapElements.Count > 7) throw new ArgumentOutOfRangeException(nameof(heatmapElements), "ヒートマップの数が7を超えています。");
        HeatmapElements = heatmapElements;
    }

    public List<HeatmapElement> HeatmapElements { get; }
}
public class HeatmapElement
{
    public HeatmapElement(string color, string comment)
    {
        Color = color;
        Comment = comment;
    }

    public string Color { get; }
    public string Comment { get; }
}

ちょっと説明

芝生のマスの情報はHeatmapElementクラスに持たせています。芝生の色を表す文字列とマウスオーバーした時に表示されるTooltip用の文字列をプロパティとしています。芝生縦一列の一週間分の情報はWeekHeatmapクラスに持たせています。HeatmapElementのリストをプロパティとしています。機能には関係ないですが、一週間分という性質を補強するためリストの長さが7以上だった場合はエラーになるようにしています。

実行

サンプルのデータを代入し実行すると以下の画像ように芝生を描画できました。補足情報として赤文字でどの情報がどこを表しているか記しています。

実行画面。赤文字は後付け

参考

コード全体はGitHubにアップロードしています。リポジトリをcloneしてきたらVisualStudioでGitHubStyleHeatmap.slnを開いてF5実行すると上に記載した画像のウィンドウが表示されます。

まとめと感想

WPFでGitHubのContribution Graph(芝生)風のグラフを描画してみました。グラフを動的に生成するためにItemsControlを利用しました。StackPanelを入れ子にすることで芝生の行と列を表現しました。ItemsControlのItemTemplateを使ってマスの描画方法を指定しています。今回の取り組みでItemsControlの柔軟性を実感しました。特に入れ子にすることでリストの中身にもItemsControlが使えるのは表現の幅が広がっていいですね。また、バインディングするデータに自作クラスが使えるので工夫次第でいろんなリスト情報を見やすく描画するためにも使えると思います。発展として、WeekHeatmapクラスに「概要」プロパティを追加することで芝生の上部に情報を描画させることができるかもしれません。GitHubのように月を表示できそうです。