はじめに
この投稿では、「.NETを利用したクロスプラットフォーム開発」でReactiveUIを用いるサンプルを提供します。「.NETを利用したクロスプラットフォーム開発」に触れたことのない方に、『なるほど、大体こんなもんか』と感じていただけることを目的に書きました。
免責事項:
この投稿に対応するソースコードも公開しており、自由に利用していただけますが、利用することにより生じた損害等に対するすべての責任は、利用する側が負うものとします。
謝辞:
この投稿を作成するにあたり、以下のサイトを参考にさせていただきました。
背景
来る.NET 5の時代に思いを馳せていると、ふと、「現状ではどこまでのクロスプラットフォーム開発ができるのか?」を試してみたくなりました。
.NET 5では、以下のプラットフォームが統合されるようですね。
すごいボリュームですね。全部を試すとなると時間が足りないので、絞ります。
今回は、以下を対象にクロスプラットフォームアプリケーションを作ってみたいと思います。
- DESKTOP WPF: 現状 → .NET Framework / .NET Core
 - DESKTOP Windows Forms: 現状 → .NET Framework / .NET Core
 - 
DESKTOP UWP: 現状 → 
.NET CoreXamarin.Forms.UWP - MOBILE Xamarin: 現状 → Xamarin.Forms.Android / Xamarin.Forms.iOS
 
前提
「クロスプラットフォーム開発」の定義としては、部分的になりますが、以下を意識しました。
- フレームワーク/デバイス固有の部分を最小まで取り除き、それ以外を1本のコードで機能を実現する
 - 共通のコードからフレームワーク/デバイス固有の部分にアクセスするための方法がある
 
「フレームワーク/デバイス固有の部分を最小まで取り除く」ために、以下を採用します。
- UIアーキテクチャパターンはMVVMを採用。ライブラリはXamarin.Forms,WinForms,WPFに対応する ReactiveUI を選択。
 - フレームワーク/デバイス固有の部分にアクセスするための方法として、ReactiveUIが依存するSplatを利用。
 
その結果、以下のライブラリ/バージョンを選定しました。
- .NET Standard 2.0 (.NET Frameworkが対応している最後のバージョンがこれ)
 - .NET Framework 4.8
 - .NET Core 3.1
 - UWP 6.2.8 (Windows10, varsion 1803) ※1
 - Xamarin.Forms 4.2.0.910310
 - ReactiveUI 11.0.1
 
※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パターンを作っていきます。拡張子の表記があるものが開発対象です。

補足:画面遷移の方式
ReactiveUIでは、ViewModelを利用したナビゲーションを行います。それは、ViewにViewModelを対応付け、画面遷移の際にViewModelを指定すれば、Viewが切り替わる仕掛けです。
ReactiveUIでは、この画面遷移方式に対応するために、ViewModelコンテナRoutingStateが提供されています。これとView側のコントロールRoutedViewHostまたはRoutedControlHostをバインドすることにより、ViewModelによる画面遷移が実現されます。View側の実装が2つあるのは、Xamarin.Forms, WPF, WinFormsでの違いを吸収するためです。
補足:MVVMにおけるModelの方式
Model層も、ViewModel層と同じく遷移可能な形をとりました。ReactiveUIの設計に倣い、ModelHostをシングルトンで常駐させ、ModelRoutingStateでModelの遷移を管理します。
※ちなみに今回、このModelHostの命名が一番悩みました。ViewModelの場合はScreenというわかりやすい概念があるのに対し、Modelの場合は・・・。ModelHostとScreenHostの関係は(1-N)でなければならないと考えています。何かいい名前のアイディアをお持ちの方、ぜひご教授ください。。。
なお・・・
この記事の内容は、以下のGitHubリポジトリでソースコードを公開しています。
かなり長編の記事ですが、ReactiveUIに明るい方なら「実装」の章以降はあまり読む意味はありません。リポジトリのソースコードを直接お読みください。
- GitHubリポジトリ atsuteru/dotnet-cross-apps
 - 学びを与えてくれる Issue、Pull Request、大歓迎!
 
本編
では、いよいよ実装に移っていきましょう。
実装
これから説明する内容は、以下にソースを公開していますので、よろしければ併せ見てください。
- GitHubリポジトリ atsuteru/dotnet-cross-apps
 
表にすると、こんな感じです。開発する箇所は、太字の部分。全部で13個のプロジェクトを作ります。

なお、以下に見せるソースコードは、読みやすさを優先し、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 | ビジネスカード作成に関する手続きの実装 | 
(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。画面遷移可能。 | 
(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: ReactiveUI 12.1.1 でこの問題は解消されました。IsViewModelBound の実装は、画面遷移で画面を離れる際に、余計に呼び出される現象を回避するための対策です。
    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:RoutedViewHostをMainPageプロパティに設定します。
注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 でクロスプラットフォーム開発やりまっせー!! (あれ?




