LoginSignup
13
8

More than 3 years have passed since last update.

.NET+ReactiveUIでXamForms,WPF,WinFormsのクロスプラットフォーム開発やってみた

Last updated at Posted at 2020-03-22

はじめに

この投稿では、「.NETを利用したクロスプラットフォーム開発」でReactiveUIを用いるサンプルを提供します。「.NETを利用したクロスプラットフォーム開発」に触れたことのない方に、『なるほど、大体こんなもんか』と感じていただけることを目的に書きました。

免責事項:
この投稿に対応するソースコードも公開しており、自由に利用していただけますが、利用することにより生じた損害等に対するすべての責任は、利用する側が負うものとします。

謝辞:

この投稿を作成するにあたり、以下のサイトを参考にさせていただきました。

背景

来る.NET 5の時代に思いを馳せていると、ふと、「現状ではどこまでのクロスプラットフォーム開発ができるのか?」を試してみたくなりました。

.NET 5では、以下のプラットフォームが統合されるようですね。

引用: 再統合された .NET:.NET 5 に関する Microsoft の計画 - Microsoft Docs
mt833477.0719_michaelis_figure2_hires(ja-jp,msdn.10).png

すごいボリュームですね。全部を試すとなると時間が足りないので、絞ります。
今回は、以下を対象にクロスプラットフォームアプリケーションを作ってみたいと思います。

  • DESKTOP WPF: 現状 → .NET Framework / .NET Core
  • DESKTOP Windows Forms: 現状 → .NET Framework / .NET Core
  • DESKTOP UWP: 現状 → .NET Core Xamarin.Forms.UWP
  • MOBILE Xamarin: 現状 → Xamarin.Forms.Android / Xamarin.Forms.iOS

前提

「クロスプラットフォーム開発」の定義としては、部分的になりますが、以下を意識しました。

  • フレームワーク/デバイス固有の部分を最小まで取り除き、それ以外を1本のコードで機能を実現する
  • 共通のコードからフレームワーク/デバイス固有の部分にアクセスするための方法がある

「フレームワーク/デバイス固有の部分を最小まで取り除く」ために、以下を採用します。

  • UIアーキテクチャパターンはMVVMを採用。ライブラリはXamarin.Forms,WinForms,WPFに対応する ReactiveUI を選択。
  • フレームワーク/デバイス固有の部分にアクセスするための方法として、ReactiveUIが依存するSplatを利用。

その結果、以下のライブラリ/バージョンを選定しました。

※1 興味本位で「Visual Studio App Centerとの連携」も試した結果、UWPのApp Centerでのビルドが、Windows Target versionを1803まで落とさないと成功しなかったため、そのバージョンに引きずられる形で・・・ほかのバージョンが決まりました。なお本記事では「Visual Studio App Centerとの連携」には触れていません(それはまた別の機会に)。

作るもの

要件

  • iOS, Android, UWPに対応するXamarin.FormsのView。
  • WPFのViewを、.NET Coreと.NET Frameworkの両方で。
  • WinForms(Windows Forms)のViewを、.NET Coreと.NET Frameworkの両方で。
  • 機能は何でもよい→過去の勉強会ネタ「iTextで使って名刺PDFを生成するJax-RS」のクライアントを、.NET Standardで。
  • フレームワーク/デバイス固有の部分の解決の例として、アラートダイアログの表示を実装。(ほんとはローカル保存したPDFを別アプリに送信したかったが・・・時間切れ)

MVVMパターンの実現イメージ

次のソースコードの関係性によって、MVVMパターンを作っていきます。拡張子の表記があるものが開発対象です。
NET xPlatform Application Structure(1).png

補足:画面遷移の方式

ReactiveUIでは、ViewModelを利用したナビゲーションを行います。それは、ViewにViewModelを対応付け、画面遷移の際にViewModelを指定すれば、Viewが切り替わる仕掛けです。

ReactiveUIでは、この画面遷移方式に対応するために、ViewModelコンテナRoutingStateが提供されています。これとView側のコントロールRoutedViewHostまたはRoutedControlHostをバインドすることにより、ViewModelによる画面遷移が実現されます。View側の実装が2つあるのは、Xamarin.Forms, WPF, WinFormsでの違いを吸収するためです。

これを実装すると、以下の図のようなイメージになります。
View navigation image.png

補足:MVVMにおけるModelの方式

Model層も、ViewModel層と同じく遷移可能な形をとりました。ReactiveUIの設計に倣い、ModelHostをシングルトンで常駐させ、ModelRoutingStateでModelの遷移を管理します。

※ちなみに今回、このModelHostの命名が一番悩みました。ViewModelの場合はScreenというわかりやすい概念があるのに対し、Modelの場合は・・・。ModelHostとScreenHostの関係は(1-N)でなければならないと考えています。何かいい名前のアイディアをお持ちの方、ぜひご教授ください。。。

なお・・・

この記事の内容は、以下のGitHubリポジトリでソースコードを公開しています。
かなり長編の記事ですが、ReactiveUIに明るい方なら「実装」の章以降はあまり読む意味はありません。リポジトリのソースコードを直接お読みください。

本編

では、いよいよ実装に移っていきましょう。

実装

これから説明する内容は、以下にソースを公開していますので、よろしければ併せ見てください。

ソリューションを開くと、こんな感じになっています。
ソリューションエクスプローラー.png

表にすると、こんな感じです。開発する箇所は、太字の部分。全部で13個のプロジェクトを作ります。
ソリューションの構成.png

なお、以下に見せるソースコードは、読みやすさを優先し、using句、namespaceブロックは省略しています。もし実装時、もし「Nuget追加しているのにクラスの参照がうまくいかないなー」「エラーの波線が消えないー」という時は、以下のusing句を足してみると大体うまくいきます。※以下は、VisualStudioが「候補」として出してくれない時があるので。。。

using System;
using System.Reactive
using System.Reactive.Concurrency;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using ReactiveUI;

では以下、順番に実装の説明を行います。

1) Services

「iTextで使って名刺PDFを生成するJax-RS」=BusinessCard機能のクライアントを作ります。

※ぶっちゃけ・・・ここは「クロスプラットフォーム開発」に何の関係もありません(いや一応.NET Standardは使っているが、うーん。。。)

Servicesの実装

(1) Servicesのすべてのプラットフォーム向けインタフェースの定義

プロジェクト: MyApp.Services

インタフェース:IBusinessCardService
説明:BusinessCard機能のインタフェースを定義。

    public interface IBusinessCardService
    {
        Task<byte[]> GeneratePDF(GenerateParameter parameter);
    }

クラス:GenerateParameter
説明:PDF生成用メソッドのパラメータを定義。ただのPOCOなので掲載は割愛。

(2) Servicesのすべてのプラットフォーム向け実装

プロジェクト: MyApp.Services.BusinessCard

クラス:BusinessCardService
説明:外部のBusinessCard機能を呼び出す、REST-APIクライアント。
※内容の説明は、本編と関係がないので省略します。
※なお以下に含まれるURLは、私が必要な時以外は停止しています。

    public class BusinessCardService : IBusinessCardService
    {
        protected string URL { get; }
        protected int TimeoutSeconds { get; }
        protected string CardTemplate { get; }

        public BusinessCardService()
        {
            URL = "https://business-card-webservice.herokuapp.com/api/businesscard/generate/as/pdf";
            TimeoutSeconds = 10;
            CardTemplate = "templates/business_card.mustache.html";
        }

        Task<byte[]> IBusinessCardService.GeneratePDF(GenerateParameter parameter)
        {
            using (var api = new HttpClient()
            {
                BaseAddress = new Uri(URL),
                Timeout = new TimeSpan(0, 0, TimeoutSeconds)
            })
            {
                var query = HttpUtility.ParseQueryString(string.Empty);
                query.Add("template", CardTemplate);
                query.Add("name", parameter.Name);
                query.Add("company", parameter.Organization);

                var response = api.GetAsync("?" + query.ToString()).Result;
                if (!response.IsSuccessStatusCode)
                {
                    var exception = new ApplicationException("Business card generation error");
                    exception.Data.Add("StatusCode", response.StatusCode);
                    exception.Data.Add("ReasonPhrase", response.ReasonPhrase);
                    exception.Data.Add("Detail", response.Content.ReadAsStringAsync().Result);
                    throw exception;
                }

                return response.Content.ReadAsByteArrayAsync();
            }
        }
    }

2) Dependencies

Dependencyは直訳すると「依存性」ですが、ここでは「プラットフォーム/フレームワークに依存性がある実装部分」という意味で言葉を使用します。

本実装ではその例として、アラートメッセージの表示機能を実装します。任意のタイトルとメッセージを表示し、OKボタン押下により指定した処理が実行される仕様とします。

※「iOS,Android、アラートメッセージは実装違うね!いい例だね!」と思って始めたのですが・・・Xamarin.FormsがDisplayAlertを実装していたという大誤算。でもView層に要求を飛ばすため、MessageBusの良いサンプルになるし、まぁいっか、となったのが、ここの実情です。。。

Dependenciesの実装

実装のバリエーションは、共通のインタフェース1種類+個別の実装3種類。

  • 共通のインタフェース(.NET Standard) ...アラートメッセージ表示機能のインタフェースを定義。
  • WinForms(.NET Framework/.NET Core) ...Sysytem.Windows.Forms.MessageBox を利用して実装。
  • WPF(.NET Framework/.NET Core) ...Sysytem.Windows.Forms.MessageBox を利用して実装。
  • Xamarin.Forms(Xamarin.UWP, Xamarin.iOS, Xamarin.Android) ...Xamarin.Forms.Page.DisplayAlertメソッドを利用して実装。

クロスプラットフォーム対応としての実装は、以下の通り。

クラス 役割
IMessageDialog 機能を利用するためのインタフェース。すべてのプラットフォーム向け。
MessageDialog 機能を実装したクラス。各プラットフォーム向けに実装する。
(1) Dependenciesのすべてのプラットフォーム向けインタフェースの定義

プロジェクト:MyApp.Dependencies (.NET Standard 2.0)
インタフェース:IMessageDialog.cs
説明:メッセージダイアログ処理インタフェース

    public interface IMessageDialog
    {
        void ShowAlertMessage(string title, string message, Action onOkAction);
    }
(2) WinForms向けのDependenciesの実装

プロジェクト:MyApp.WinForms, MyApp.WinForms.NetFramework
クラス:MessageDialog.cs
説明:WinForms用メッセージダイアログ表示処理の実装
補足:System.Windows.Forms.MessageBoxクラスを利用。

    public class MessageDialog : IMessageDialog
    {
        void IMessageDialog.ShowAlertMessage(string title, string message, Action onOkAction)
        {
            MessageBox.Show(message, title, MessageBoxButtons.OK, MessageBoxIcon.Warning);
            onOkAction?.Invoke();
        }
    }
(3) WPF向けのDependenciesの実装

プロジェクト:MyApp.WPF, MyApp.WPF.NetFramework
クラス:MessageDialog.cs
説明:WPF用メッセージダイアログ表示処理の実装
補足:System.Windows.MessageBoxクラスを利用。

    public class MessageDialog : IMessageDialog
    {
        void IMessageDialog.ShowAlertMessage(string title, string message, Action onOkAction)
        {
            MessageBox.Show(message, title, MessageBoxButton.OK, MessageBoxImage.Warning);
            onOkAction?.Invoke();
        }
    }
(4) Xamarin.Forms向けのDependenciesの実装

プロジェクト:MyApp.XamForms
クラス:MessageDialog.cs
説明:Xamarin.Forms用メッセージダイアログ表示処理の実装
補足:Xamarin.Forms.Page.DisplayAlertメソッドを利用。ReactiveUIのMessageBusを使用してView側に要求を送信します。ちなみに・・・ここで使用するMessageBusはメインスレッド上で生成しているため、要求された処理は常にメインスレッド上で実行されます。

    public class MessageDialog : IMessageDialog
    {
        void IMessageDialog.ShowAlertMessage(string title, string message, Action onOkAction)
        {
            // View側へメッセージダイアログ表示要求を送信
            MessageBus.Current.SendMessage(new MessageDialogRequest()
            {
                Title = title,
                Message = message
            });
            onOkAction?.Invoke();
        }

        public class MessageDialogRequest
        {
            public string Title { get; set; }
            public string Message { get; set; }
        }
    }
    public partial class HomeView : ReactiveContentPage<HomeViewModel>
    {
        protected void HandleViewModelBound(CompositeDisposable d)
        {
            // メッセージダイアログ表示要求の受信を開始
            MessageBus.Current.Listen<MessageDialogRequest>().Subscribe(async (x) => await HandleMessageDialogRequest(x)).DisposeWith(d);
        }

        protected async Task HandleMessageDialogRequest(MessageDialogRequest request)
        {
            // メッセージダイアログ表示要求に基づき、メッセージダイアログを表示する
            await DisplayAlert(request.Title, request.Message, "OK");
        }

3) Model

Model、それは「処理要件を実現するための手続きを司るもの」。
ユースケースに書かれるようなレベルの手続きを実装し、入出力等の処理の実装はServices/Dependenciesに任せます。なお、手続きの実行結果は、他の層に対しては通知する形で伝えるところがポイント。

2種類のModelを作成します。

  • 「ホスト」としての役割を持つModel。今回のModelHostです。
  • 「遷移可能」としての役割を持つModel。今回のApplicationStarterとBusinessCardGeneratorです。

Modelの実装

Modelの役割毎にインタフェースを用意します。
今回はそれら役割に対応する抽象クラスも用意しますので、その関係を先にまとめておきます。

インタフェース: IModelHost
役割: 各Modelのホストとしての役割を可能にする。モデル遷移の親オブジェクト。
実装: ModelHost, ModelRoutingState

インタフェース: IRoutableModel
役割: ModelHost内でのModel遷移を可能にする。モデル遷移の子オブジェクト。
実装: RouteModelBase

よって、実装対象は、以下の通り。

クラス 役割
IModelHost 各Modelのホストとしての役割を可能にする
ModelHost IModelHostとしての役割の実装
ModelRoutingState ModelHost内のModel遷移機能の実装
IRoutableModel 遷移可能なModelとしての役割を可能にする
RouteModelBase IRoutableModelとしての役割の実装
ApplicationStarter アプリケーション起動時の手続きの実装
BusinessCardGenerator ビジネスカード作成に関する手続きの実装

クラス図です。
include.png

(1) すべてのプラットフォーム向けの実装

プロジェクト: MyApp.Model

インターフェース:IModelHost
説明:各Modelのホストとしての役割を可能にする

    public interface IModelHost
    {
        ModelRoutingState Router { get; }
    }

クラス:ModelHost
説明:IModelHostとしての役割を実装する
補足:starterFactoryは、アプリケーション起動用の最初のModelを受け取るための引数。

    public class ModelHost: IModelHost
    {
        public ModelRoutingState Router { get; }

        public ModelHost(Func<IModelHost, IRoutableModel> starterFactory)
        {
            Router = new ModelRoutingState();
            Router.Navigate(starterFactory.Invoke(this));
        }
    }

クラス:ModelRoutingState
説明:ModelHost内のModel遷移機能を実装する

    public class ModelRoutingState
    {
        public IRoutableModel Current { get; protected set; }

        public void Navigate(IRoutableModel model)
        {
            Current = model;
        }
    }

インターフェース:IRoutableModel
説明:遷移可能なModelとしての役割を可能にする

    public interface IRoutableModel
    {
        IModelHost Host { get; }
    }

クラス:RouteModelBase
説明:IRoutableModelとしての役割を実装する

    public abstract class RouteModelBase : IRoutableModel
    {
        public IModelHost Host { get; }

        public RouteModelBase(IModelHost host)
        {
            Host = host;
        }
    }

クラス:ApplicationStarter
説明:アプリケーション起動時の手続きを実装する

    public class ApplicationStarter : RouteModelBase
    {
        public ApplicationStarter(IModelHost host) : base(host)
        {
        }
        // アプリケーション初期化手続きを「観測可能な」オブジェクトとして公開する
        public IObservable<InitializeResponse> Initialize(InitializeRequest request)
        {
            return Observable
                .FromAsync(() => InitializeAsync(request));
        }
        // アプリケーション初期化手続きを定義する
        protected virtual Task<InitializeResponse> InitializeAsync(InitializeRequest request)
        {
            // BusinessCardGeneratorに遷移
            Host.Router.Navigate(new BusinessCardGenerator(Host));
            return Task.FromResult(new InitializeResponse());
        }
    }

クラス:BusinessCardGenerator
説明:ビジネスカード作成に関する手続きを実装する
補足:Generateの手続きは、SubscribeOn(ThreadPoolScheduler.Instance)の指定により別スレッドで進められるように定義。

    public class BusinessCardGenerator : RouteModelBase
    {
        public BusinessCardGenerator(IModelHost host) : base(host)
        {
        }
        // ビジネスカード生成手続きを「観測可能な」オブジェクトとして公開する
        public IObservable<GenerateResponse> Generate(GenerateRequest request)
        {
            return Observable
                .FromAsync(() => GenerateAsync(request))
                .SubscribeOn(ThreadPoolScheduler.Instance); //別スレッド(スレッドプール利用)の宣言
        }
        // ビジネスカード生成手続きを定義する
        protected virtual Task<GenerateResponse> GenerateAsync(GenerateRequest request)
        {
            string result = null;
            try
            {
                // ビジネスカード生成をサービスに依頼
                var pdfData = Locator.Current.GetService<IBusinessCardService>()
                    .GeneratePDF(new GenerateParameter()
                    {
                        Name = request.Name,
                        Organization = request.Organization
                    })
                    .Result;
                // ビジネスカード生成結果をファイルに書き出す
                var filePath = Path.Combine(
                    Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), 
                    Path.GetTempFileName());
                File.WriteAllBytes(filePath, pdfData);
                result = filePath;
            }
            catch (Exception ex)
            {
                // ビジネスカード生成失敗時はアラートメッセージ表示を依頼
                Locator.Current.GetService<IMessageDialog>()
                    .ShowAlertMessage("Generation error", ex.Message, () =>
                    {
                        result = ex.Message;
                    });
            }

            return Task.FromResult(new GenerateResponse() { Result = result });
        }
        // ビジネスカードの新規生成に戻る手続きを「観測可能な」オブジェクトとして公開する
        public IObservable<InitializeResponse> Initialize(InitializeRequest request)
        {
            return Observable
                .FromAsync(() => InitializeAsync(request));
        }
        // ビジネスカードの新規生成に戻る手続きを定義する ※今は特にやることがない
        protected virtual Task<InitializeResponse> InitializeAsync(InitializeRequest request)
        {
            return Task.FromResult(new InitializeResponse());
        }
    }

4) ViewModel

ViewModel、すなわち「ModelとViewの仲介者」。
入力をViewから受け取ってModelの入力とし、また結果をModelから受け取ってViewに出力させます。

2種類のViewModelを作成します。

  • 「スクリーンホスト」としての役割を持つViewModel。今回のMainViewModelです。
  • 「画面遷移可能」としての役割を持つViewModel。今回のHomeViewModelとResultViewModelです。

ViewModelの実装

ViewModelは、役割毎にインタフェースを実装しなければなりません。
今回はそれら役割に対応する抽象クラスを用意していますので、その関係を先にまとめておきます。

インタフェース: IActivatableViewModel
役割: Viewの画面遷移に合わせた処理の呼び出しを可能にする
実装: ActivatableViewModel

インタフェース: IScreen
役割: スクリーンホストとしての役割を可能にする。画面遷移の親オブジェクト。
実装: ScreenHostableViewModel

インタフェース: IModelAccessableScreen
役割: スクリーンホストからModelへのアクセスを可能にする。
実装: ScreenHostViewModelBase

インタフェース: IRoutableViewModel
役割: スクリーンホスト内での画面遷移を可能にする。画面遷移の子オブジェクト。
実装: RoutableViewModel, RouteViewModelBase

よって、実装対象は、以下の通り。

クラス 役割
ActivatableViewModel Viewの画面遷移に合わせた処理の呼び出しを可能にする
ScreenHostableViewModel スクリーンホストとしての役割を可能にする
IModelAccessableScreen スクリーンホストからModelへのアクセスを可能にする
ScreenHostViewModelBase Modelへのアクセスを可能な役割を実装する
RoutableViewModel 画面遷移を可能にする
RouteViewModelBase 画面遷移なViewModelの共通処理を実装する
MainViewModel アプリケーションのメインウィンドウのViewModel。スクリーンホスト。
HomeViewModel ビジネスカード作成の入力フォームのViewModel。画面遷移可能。
ResultViewModel ビジネスカード作成の処理結果表示画面のViewModel。画面遷移可能。

クラス図です。
include.png

(1) すべてのプラットフォーム向けの実装

プロジェクト: MyApp.ViewModel

クラス:ActivatableViewModel
説明:Viewの遷移に連動して呼び出される機能を実装。今回は全ViewModelの基底クラスとして利用。

    public abstract class ActivatableViewModel: ReactiveObject, IActivatableViewModel
    {
        public ViewModelActivator Activator { get; }

        public ActivatableViewModel()
        {
            Activator = new ViewModelActivator();

            this.WhenActivated(disposables =>
            {
                HandleActivation(disposables);
                Disposable.Create(() => HandleDeactivation()).DisposeWith(disposables);
            });
        }

        protected virtual void HandleActivation(CompositeDisposable d)
        {
        }

        protected virtual void HandleDeactivation()
        {
        }
    }

クラス:ScreenHostableViewModel
説明:RoutedViewHost/RoutedControlHostのスクリーンホストとしての役割を実装。

    public abstract class ScreenHostableViewModel : ActivatableViewModel, IScreen
    {
        public RoutingState Router { get; }

        public ScreenHostableViewModel()
        {
            Router = new RoutingState();
        }
    }

インターフェース:IModelAccessableScreen
説明:スクリーンホストからModelへのアクセスを可能にする。

    public interface IModelAccessableScreen: IScreen
    {
        IModelHost Model { get; }
    }

クラス:ScreenHostViewModelBase
説明:IModelAccessableScreenとしての役割を実装する

    public abstract class ScreenHostViewModelBase : ScreenHostableViewModel, IModelAccessableScreen
    {
        public IModelHost Model { get; }

        public ScreenHostViewModelBase(IModelHost model)
        {
            Model = model;
        }
    }

クラス:RoutableViewModel
説明:画面遷移に対応するViewModelの共通処理を定義する。
補足:IRoutableViewModel.UrlPathSegmentは現在の遷移位置を表すプロパティ。Xamarin.Formsではデフォルトでアプリケーションタイトルとして表示される。今回はWPF/WinFormsでもウィンドウタイトルの一部として利用。

    public abstract class RoutableViewModel : ActivatableViewModel, IRoutableViewModel
    {
        string IRoutableViewModel.UrlPathSegment => GetUrlPathSegment();

        private IScreen _hostScreen;

        IScreen IRoutableViewModel.HostScreen { get => _hostScreen; }

        public RoutableViewModel(IScreen hostScreen)
        {
            _hostScreen = hostScreen;
        }

        protected virtual string GetUrlPathSegment()
        {
            return GetType().Name.Replace("ViewModel", string.Empty);
        }
    }

クラス:RouteViewModelBase
説明:画面遷移なViewModelの共通処理を実装する
補足:Modelに別スレッドで進められる手続きがあるため、非同期処理中のボタン活性制御用にIsCommandExecutableプロパティと、その観測用オブジェクトCommandExecutableを定義。

    public abstract class RouteViewModelBase : RoutableViewModel
    {
        public IModelAccessableScreen Screen { get; }

        protected IObservable<bool> CommandExecutable { get; }

        [Reactive]
        public bool IsCommandExecutable { get; protected set; }

        public RouteViewModelBase(IModelAccessableScreen hostScreen) : base(hostScreen)
        {
            Screen = hostScreen;

            CommandExecutable = this.ObservableForProperty(x => x.IsCommandExecutable)
                .Select(x => x.Value);
        }
    }

クラス:MainViewModel
説明:スクリーンホストとしての役割を実装。
補足:WPF/WinFormsではMainWindow/MainFormのViewModelとしての役割も兼任。

  • プロパティの[Reactive]は、値がセットされた場合にINotifyPropertyChanged.PropertyChangedイベントを発火させるための宣言。PropertyChangedが発火されないと、Viewに値が反映されない。
    public class MainViewModel: ScreenHostViewModelBase
    {
        [Reactive]
        public string ApplicationTitle { get; set; }

        public MainViewModel(IModelHost model): base(model)
        {
            ApplicationTitle = AppDomain.CurrentDomain.FriendlyName;
            // アプリケーション初期化手続きを実行
            ((ApplicationStarter)Model.Router.Current)
                .Initialize(new InitializeRequest())
                .Select(x =>
                {
                    return new HomeViewModel(this);
                })
                .Select(Router.Navigate.Execute)
                .Subscribe();
        }

        protected override void HandleActivation(CompositeDisposable d)
        {
            // 画面遷移時にアプリケーションタイトルを更新するための定義(WinForms/WPF用)
            this.WhenAnyObservable(_ => _.Router.NavigationChanged)
                .Subscribe(x =>
                {
                    ApplicationTitle = string.Format("{0} [{1}]", AppDomain.CurrentDomain.FriendlyName, Router.GetCurrentViewModel()?.UrlPathSegment);
                })
                .DisposeWith(d);
        }
    }

クラス:HomeViewModel
説明:初期画面のViewModel。BusinessCardを作成するための入力を受け、BusinessCard生成手続きを実行。また処理終了の通知を受け、結果画面への遷移を行う。
補足:SubmitCommand.ThrownExceptions.Subscribeの定義を追加すれば、手続きから例外が戻された場合の処理も記述可能。

    public class HomeViewModel: RouteViewModelBase
    {
        [Reactive]
        public string Name { get; set; }

        [Reactive]
        public string Organization { get; set; }

        public ReactiveCommand<Unit, GenerateResponse> SubmitCommand { get; protected set; }

        public HomeViewModel(IModelAccessableScreen hostScreen) : base(hostScreen)
        {
        }

        protected override void HandleActivation(CompositeDisposable d)
        {
            IsCommandExecutable = false;
            // 「ビジネスカードの作成」ボタンにビジネスカード作成手続きの実行を関連付け。
            // この手続きは非同期で行われるため、ボタンの活性制御のためのプロパティを指定
            SubmitCommand = ReactiveCommand
                .CreateFromObservable(() =>
                {
                    return ((BusinessCardGenerator)Screen.Model.Router.Current)
                        .Generate(new GenerateRequest()
                        {
                            Name = Name,
                            Organization = Organization
                        });
                }, CommandExecutable);
            // 手続きの実行完了後、ResultViewへ遷移するための定義
            SubmitCommand
                .Select(response =>
                {
                    return new ResultViewModel(Screen)
                    {
                        Name = Name,
                        Organization = Organization,
                        Result = response.Result
                    };
                })
                .Select(x => Screen.Router.Navigate.Execute(x).Subscribe())
                .Subscribe()
                .DisposeWith(d);

            IsCommandExecutable = true;
        }
    }

クラス:ResultViewModel
説明:結果表示画面のViewModel。結果を表示し、初期画面への遷移を行う。

    public class ResultViewModel: RouteViewModelBase
    {
        [Reactive]
        public string Name { get; set; }

        [Reactive]
        public string Organization { get; set; }

        [Reactive]
        public string Result { get; set; }

        public ReactiveCommand<Unit, InitializeResponse> BackCommand { get; protected set; }

        public ResultViewModel(IModelAccessableScreen hostScreen) : base(hostScreen)
        {
        }

        protected override void HandleActivation(CompositeDisposable d)
        {
            // 「新しいビジネスカードを作成する」ボタンに対応する手続きの実行を関連付け。
            BackCommand = ReactiveCommand
                .CreateFromObservable(() =>
                {
                    return ((BusinessCardGenerator)Screen.Model.Router.Current)
                        .Initialize(new InitializeRequest());
                });
            // 手続きの実行完了後、HomeViewへ復帰するための定義
            BackCommand
                .Select(x => Screen.Router.NavigateBack.Execute().Subscribe())
                .Subscribe()
                .DisposeWith(d);
        }
    }

5) View

Viewとして作る部分は2つ。「デザイン」と「ViewModelとのバインド」です。

  • 「デザイン」は、WinFormsなら*.Designer.csでデザインし、動的なUIは*.csにも記述できます。 WPF, Xamarin.Formsなら、*.xamlにデザインし、動的なUIは*.csにも記述できます。
  • 「ViewModelとのバインド」は、*.csに記述します。※*.xamlに記述することも可能ですが、デザイナーとプログラマーの職責分担の観点から、*.csに記述することを個人的に好みます。

Viewの実装

実装のバリエーションは、以下3種類。
UWP, Android, iOSはXamarin.FormsによるクロスプラットフォームUIを利用します。

  • WinForms(.NET Framework/.NET Core)
  • WPF(.NET Framework/.NET Core)
  • Xamarin.Forms(Xamarin.UWP, Xamarin.Android, Xamarin.iOS)

クロスプラットフォーム対応としての実装は、以下の通り。

クラス 役割
MainWindow アプリケーションのメインウィンドウ。画面遷移のホストコントロールを配置する。
HomeView 画面遷移のホストコントロール上で最初に表示される画面。ビジネスカード作成の入力フォーム。
ResultView ビジネスカード作成の処理結果を表示する画面。初期画面に戻ることができる。

また、プラットフォーム毎に利用できる継承元クラスが異なるため、先にまとめておきます。

クラス WinForms(ReactiveUI.WinForms) WPF(ReactiveUI.WPF) WPF(ReactiveUI.XamForms)
MainWindow Form, IViewFor<MainViewModel> ReactiveWindow<MainViewModel> (実装無し)
HomeView ReactiveUserControl<HomeViewModel> ReactiveUserControl<HomeViewModel> ReactiveContentPage<HomeViewModel>
ResultView ReactiveUserControl<ResultViewModel> ReactiveUserControl<ResultViewModel> ReactiveContentPage<ResultViewModel>

では以下、WinForms, WPF, Xamarinで実装方法が異なる点を中心に、説明していきます。
なおViewのデザイン部分は、「デザイン+コントロールの名前付け」しかしていないので、説明は省略します。

(1) WinForms向けのViewの実装

プロジェクト:MyApp.WinForms, MyApp.WinForms.NetFramework

クラス:MainForm
説明:WinFormsアプリケーションのMainWindow。
補足:.NET Framework版と.NET Core版での実装に、違いはありません。
注1:MainFormのデザインにRoutedControlHostを貼り付け、名前を"RoutedControlHost"としています。
注2:IsViewModelBoundの実装は、画面遷移で画面を離れる際に、余計に呼び出される現象を回避するための対策です。

    // WinFormsでは、Formを継承し、IViewFor<MainViewModel>インタフェースを自分で実装します。
    public partial class MainForm : Form, IViewFor<MainViewModel>
    {
        protected bool IsViewModelBound { get; private set; }
        public MainViewModel ViewModel { get; set; }
        object IViewFor.ViewModel { get => ViewModel; set => ViewModel = value as MainViewModel; }

        public MainForm()
        {
            InitializeComponent();

            ViewModel = new MainViewModel(Locator.Current.GetService<IModelHost>());
            RoutedControlHost.Router = ViewModel.Router;

            this.WhenActivated((d) =>
            {
                if (IsViewModelBound)
                {
                    return;
                }
                HandleViewModelBound(d);
                IsViewModelBound = true;
            });
        }

        protected void HandleViewModelBound(CompositeDisposable d)
        {
            // ViewModelとのバインド
            this.Bind(ViewModel, vm => vm.ApplicationTitle, v => v.Text).DisposeWith(d);
        }
    }

クラス:HomeView
説明:初期画面として、BusinessCardの作成条件入力フォームと、作成ボタンを配置する画面です。
補足:.NET Framework版と.NET Core版での実装に、違いはありません。

    public partial class HomeView : ReactiveUserControl<HomeViewModel>
    {
        protected bool IsViewModelBound { get; private set; }

        public HomeView()
        {
            InitializeComponent();

            this.WhenActivated((d) =>
            {
                if (IsViewModelBound)
                {
                    return;
                }
                HandleViewModelBound(d);
                IsViewModelBound = true;
            });
        }

        protected void HandleViewModelBound(CompositeDisposable d)
        {
            // ViewModelとのバインド
            this.Bind(ViewModel, vm => vm.Name, v => v.NameTextBox.Text).DisposeWith(d);
            this.Bind(ViewModel, vm => vm.Organization, v => v.OrganizationTextBox.Text).DisposeWith(d);
            this.BindCommand(ViewModel, vm => vm.SubmitCommand, v => v.GenerateButton).DisposeWith(d);
        }
    }

クラス:ResultView
説明:WinForms版の結果表示画面です。
補足:.NET Framework版と.NET Core版での実装に、違いはありません。

    public partial class ResultView : ReactiveUserControl<ResultViewModel>
    {
        /*... HomeViewと同じなので中略 ...*/

        protected void HandleViewModelBound(CompositeDisposable d)
        {
            // ViewModelとのバインド
            this.OneWayBind(ViewModel, vm => vm.Name, v => v.NameTextBox.Text).DisposeWith(d);
            this.OneWayBind(ViewModel, vm => vm.Organization, v => v.OrganizationTextBox.Text).DisposeWith(d);
            this.OneWayBind(ViewModel, vm => vm.Result, v => v.ResultTextBox.Text).DisposeWith(d);
            this.BindCommand(ViewModel, vm => vm.BackCommand, v => v.BackButton).DisposeWith(d);
        }
    }
(2) WPF向けのViewの実装

プロジェクト:MyApp.WPF, MyApp.WPF.NetFramework

クラス:MainWindow
説明:WPFアプリケーションのMainWindow。補足にある以外の実装は、WinFormsと同じです。
補足:.NET Framework版と.NET Core版での実装に、違いはありません。
注1: MainWindowのデザイン全体にRoutedViewHostタグを配置し、名前を"RoutedViewHost"としています。
注2:IsViewModelBoundの実装は、画面遷移で画面を離れる際に、余計に呼び出される現象を回避するための対策です。 ReactiveUI 12.1.1 でこの問題は解消されました。

    public partial class MainWindow : ReactiveWindow<MainViewModel>
    {
        public MainWindow()
        {
            InitializeComponent();

            ViewModel = new MainViewModel(Locator.Current.GetService<IModelHost>());
            RoutedViewHost.Router = ViewModel.Router;

            this.WhenActivated((d) =>
            {
                HandleViewModelBound(d);
            });
        }

        protected void HandleViewModelBound(CompositeDisposable d)
        {
            // ViewModelとのバインド
            this.Bind(ViewModel, vm => vm.ApplicationTitle, v => v.Title).DisposeWith(d);
        }
    }

クラス:HomeView
説明:WPF版の初期画面です。継承元のクラスはWinFormsと同名ですが、提供元は違います。
補足:.NET Framework版と.NET Core版での実装に、違いはありません。

    public partial class HomeView : ReactiveUserControl<HomeViewModel>
    {
        public HomeView()
        {
            InitializeComponent();

            this.WhenActivated((d) =>
            {
                HandleViewModelBound(d);
            });
        }

        protected void HandleViewModelBound(CompositeDisposable d)
        {
            this.Bind(ViewModel, vm => vm.Name, v => v.NameTextBox.Text).DisposeWith(d);
            this.Bind(ViewModel, vm => vm.Organization, v => v.OrganizationTextBox.Text).DisposeWith(d);
            this.BindCommand(ViewModel, vm => vm.SubmitCommand, v => v.GenerateButton).DisposeWith(d);
        }
    }

クラス:ResultView
説明:WPF版の結果表示画面です。
補足:.NET Framework版と.NET Core版での実装に、違いはありません。

    public partial class ResultView : ReactiveUserControl<ResultViewModel>
    {
        public ResultView()
        {
            InitializeComponent();

            this.WhenActivated((d) =>
            {
                HandleViewModelBound(d);
            });
        }

        protected void HandleViewModelBound(CompositeDisposable d)
        {
            this.OneWayBind(ViewModel, vm => vm.Name, v => v.NameTextBox.Text).DisposeWith(d);
            this.OneWayBind(ViewModel, vm => vm.Organization, v => v.OrganizationTextBox.Text).DisposeWith(d);
            this.OneWayBind(ViewModel, vm => vm.Result, v => v.ResultTextBox.Text).DisposeWith(d);
            this.BindCommand(ViewModel, vm => vm.BackCommand, v => v.BackButton).DisposeWith(d);
        }
    }
(3) Xamarin.Forms向けのViewの実装

プロジェクト:MyApp(Xamarin.Forms)

クラス:App
説明:Xamarin.FormsアプリケーションのMainWindowの代わりになるコード。
注1:RoutedViewHostMainPageプロパティに設定します。
注2:「ScreenHost」の役割を持つMainViewModelを、IScreenとしてSplat.Locatorに登録する必要があります。

        public App()
        {
            InitializeComponent();

            var screen = new MainViewModel(Locator.Current.GetService<IModelHost>());
            Locator.CurrentMutable.RegisterConstant(screen, typeof(IScreen));
            MainPage = new RoutedViewHost() { Router = screen.Router };
        }

クラス:HomeView
説明:Xamarin.Forms版の初期画面です。
注1:Xamarin.Formsでは、IsViewModelBoundの実装は不要です。(画面遷移で画面を離れる際にの余計な呼び出しは発生しません)

    public partial class HomeView : ReactiveContentPage<HomeViewModel>
    {
        public HomeView()
        {
            InitializeComponent();

            this.WhenActivated((d) =>
            {
                HandleViewModelBound(d);
            });
        }

        protected void HandleViewModelBound(CompositeDisposable d)
        {
            /*...WinForms版と全く同じなので、中略...*/
        }
    }

クラス:ResultView
説明:Xamarin.Forms版の結果表示画面です。

    public partial class ResultView : ReactiveContentPage<ResultViewModel>
    {
        /*...HomeViewと全く同じなので、中略...*/

        protected void HandleViewModelBound(CompositeDisposable d)
        {
            /*...WinForms版と全く同じなので、中略...*/
        }
    }

6) EntryPoint

EntryPointとは、アプリケーションが起動する際に一番最初に実行されるソースコードの部分です。
プラットフォーム/フレームワーク固有の処理であるため、プロジェクトはその数だけ作成します。
クロスプラットフォーム対応としては、Dependencies、Servicesの登録と、ViewとViewModelの対応付けを行います。

なお、Xamarin.Formsを利用するプロジェクト(UWP,Android,iOS)は、プラットフォームのEntryPointからViewフレームワーク(Xamarin.Forms)のEntryPointが独立しているため、プロジェクトの呼び出しの階層が1段、深くなっています。

では以下、WinForms, WPF, Xamarin.Formsで実装方法が異なる点を中心に、説明していきます。

EntryPointの実装

実装のバリエーションは、以下5種類+1。.NET Frameworkと.NET Coreは同一コードのコピーです。+1は、Xamarin.Formsフレームワーク共通の実装を切り出したもの。

  • WinForms(.NET Framework/.NET Core)
  • WPF(.NET Framework/.NET Core)
  • Xamarin.Forms.iOS
  • Xamarin.Forms.Android
  • Xamarin.Forms.UWP
  • Xamarin.Forms

クロスプラットフォーム対応としての実装は、以下の通り。

クラス 役割
EntryPoint プログラムが起動して一番最初に呼ばれるクラス。Dependencies、Servicesの登録を行う。ViewとViewModelの対応付けを行う(Xamarin.Forms以外)
Xamarin.Forms EntryPoint Xamarin.Formsの起動のために呼ばれるクラス。ViewとViewModelの対応付けを行う
(1) WinForms アプリケーションのEntryPoint

プロジェクト:MyApp.WinForms, MyApp.WinForms.NetFramework
クラス:Program.cs
説明:WinFormsアプリケーションのエントリーポイント。
補足:.NET Framework版と.NET Core版での実装に、違いはありません。

        [STAThread]
        static void Main()
        {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);

            // Regist Services
            Locator.CurrentMutable.RegisterLazySingleton<IBusinessCardService>(() => new BusinessCardService());
            // Regist Dependencies
            Locator.CurrentMutable.Register<IMessageDialog>(() => new MessageDialog());
            // Regist Model
            Locator.CurrentMutable.RegisterConstant<IModelHost>(new ModelHost(x => new ApplicationStarter(x)));
            // Regist ViewModels
            Locator.CurrentMutable.RegisterViewsForViewModels(typeof(MainForm).Assembly);

            Application.Run(new MainForm());
        }
(2) WPF アプリケーションのEntryPoint

プロジェクト:MyApp.WPF, MyApp.WPF.NetFramework
クラス:App.xaml.cs
説明:WPFアプリケーションのエントリーポイント。
補足:.NET Framework版と.NET Core版での実装に、違いはありません。

    public partial class App : Application
    {
        protected override void OnStartup(StartupEventArgs e)
        {
            // Regist Services
            Locator.CurrentMutable.RegisterLazySingleton<IBusinessCardService>(() => new BusinessCardService());
            // Regist Dependencies
            Locator.CurrentMutable.Register<IMessageDialog>(() => new MessageDialog());
            // Regist Model
            Locator.CurrentMutable.RegisterConstant<IModelHost>(new ModelHost(x => new ApplicationStarter(x)));
            // Regist ViewModels
            Locator.CurrentMutable.RegisterViewsForViewModels(typeof(MainWindow).Assembly);

            base.OnStartup(e);
        }
    }
(3) Xamarin.Forms フレームワークのEntryPoint

プロジェクト:MyApp.Xamarin
クラス:App.xaml.cs
説明:Xamarin.Formsフレームワークのエントリーポイント。

    public partial class App : Application
    {
        public App()
        {
            InitializeComponent();

            // Regist ViewModels
            Locator.CurrentMutable.RegisterViewsForViewModels(GetType().Assembly);

            var screen = new MainViewModel(Locator.Current.GetService<IModelHost>());
            Locator.CurrentMutable.RegisterConstant(screen, typeof(IScreen));
            MainPage = new RoutedViewHost() { Router = screen.Router };
        }
    }
(4) Xamarin.Forms.Android アプリケーションのEntryPoint

プロジェクト:MyApp.Xamarin.Android
クラス:MainActivity.cs
説明:Android(Xamarin.Forms)アプリケーションのエントリーポイント。

    [Activity(Label = "MyApp", Icon = "@mipmap/icon", Theme = "@style/MainTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation)]
    public class MainActivity : global::Xamarin.Forms.Platform.Android.FormsAppCompatActivity
    {
        protected override void OnCreate(Bundle savedInstanceState)
        {
            // Regist Services
            Locator.CurrentMutable.RegisterLazySingleton<IBusinessCardService>(() => new BusinessCardService());
            // Regist Dependencies
            Locator.CurrentMutable.Register<IMessageDialog>(() => new MessageDialog());
            // Regist Model
            Locator.CurrentMutable.RegisterConstant<IModelHost>(new ModelHost(x => new ApplicationStarter(x)));

            TabLayoutResource = Resource.Layout.Tabbar;
            ToolbarResource = Resource.Layout.Toolbar;

            base.OnCreate(savedInstanceState);

            Xamarin.Essentials.Platform.Init(this, savedInstanceState);
            global::Xamarin.Forms.Forms.Init(this, savedInstanceState);
            // Xamarin.FormsのAppクラスをロードする
            LoadApplication(new App());
        }
    }
(5) Xamarin.Forms.iOS アプリケーションのEntryPoint

プロジェクト:MyApp.Xamarin.iOS
クラス:AppDelegate.cs
説明:iOS(Xamarin.Forms)アプリケーションのエントリーポイント。

    [Register("AppDelegate")]
    public partial class AppDelegate : global::Xamarin.Forms.Platform.iOS.FormsApplicationDelegate
    {
        public override bool FinishedLaunching(UIApplication app, NSDictionary options)
        {
            // Regist Services
            Locator.CurrentMutable.RegisterLazySingleton<IBusinessCardService>(() => new BusinessCardService());
            // Regist Dependencies
            Locator.CurrentMutable.Register<IMessageDialog>(() => new MessageDialog());
            // Regist Model
            Locator.CurrentMutable.RegisterConstant<IModelHost>(new ModelHost(x => new ApplicationStarter(x)));

            global::Xamarin.Forms.Forms.Init();
            // Xamarin.FormsのAppクラスをロードする
            LoadApplication(new App());
            return base.FinishedLaunching(app, options);
        }
    }
(6) Xamarin.Forms.UWP アプリケーションのEntryPoint

プロジェクト:MyApp.Xamarin.UWP
クラス:App.xaml.cs
説明:UWP(Xamarin.Forms)アプリケーションのエントリーポイント。
補足:Xamarin.FormsのAppクラスの呼び出しまでの手順が多いため、主要な部分だけ掲載しました。

    sealed partial class App : Application
    {
        public App()
        {
            // Regist Services
            Locator.CurrentMutable.RegisterLazySingleton<IBusinessCardService>(() => new BusinessCardService());
            // Regist Dependencies
            Locator.CurrentMutable.Register<IMessageDialog>(() => new MessageDialog());
            // Regist Model
            Locator.CurrentMutable.RegisterConstant<IModelHost>(new ModelHost(x => new ApplicationStarter(x)));

            this.InitializeComponent();
            this.Suspending += OnSuspending;
        }
    }
    public sealed partial class MainPage
    {
        public MainPage()
        {
            this.InitializeComponent();
            // Xamarin.FormsのAppクラスをロードする
            LoadApplication(new MyApp.App());
        }
    }

最後に

以上で、.NETの現状を対象にしたクロスプラットフォーム開発が完了しました。
思い付きで始めたにしては、思いのほか重たい内容になりました。。。どう考えてもクラスプラットフォーム開発よりMVVMのウェイトがヤバ
最後までお読みいただき、ありがとうございます。

しかしXamarin.FormsやReactiveUIを利用することによって、かなりのコードが共通化できることがわかる内容になったと思います。特にXamarin.FormsのUIの共通化はすごいですね。UWPまで対応しているなんて。

また、.NET Core 3.0から実装された WinFormsやWPFが、本当に.NET Frameworkと同じコードで動作したことには驚きました。.NET 5での統合は着々と進行している・・・!!

個人的には、いよいよ.NET 5の登場が待ち遠しくなりました。
まぁ実際SIで使用し始めるのは、LTSとなる.NET 6 (2021年11月予定) が出てからになるのでしょうが。。。

この記事が、クロスプラットフォーム開発を始めようとしている方の助けになれば幸いです。

おまけ

さぁ、明日から Dart×Flutter でクロスプラットフォーム開発やりまっせー!! (あれ?

13
8
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
8