はじめに
この投稿では、「.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 でクロスプラットフォーム開発やりまっせー!! (あれ?