nuits.jp blog

C#, Xamarin, WPFを中心に書いています。Microsoft MVP for Visual Studio and Development Technologies。なお掲載内容は個人の見解であり、所属する企業を代表するものではありません。

Prism for Xamarin.Formsの画面遷移 10のベストプラクティス

本エントリーは Xamarin Advent Calendar 2016 (その1)5日目のエントリーです。

さてこれまで何度かPrismを利用する場合の画面遷移について記述してきました。

これらを踏まえていろいろ考え悩んだ結果、たどり着いたベストプラクティスを紹介したいと思います。
あと、その過程でPrism for Xamarin.Formsの拡張ライブラリを作ってNuGetに公開してみました。

www.nuget.org

まだPreview版で破壊的変更を想定していますが、画面遷移フレームワークの一つのヒントにはなると思いますので、ご紹介したいと思います。

と言う訳で、本エントリーの主な内容は以下の通りです。

  • Prism for Xamarin.Formsで画面遷移する際のベストプラクティスと、その解説
  • ベストプラクティスの具体的な実装例の紹介

なお、目次がちょっと引くレベルで長いです。

Prism for Xamarin.Formsの画面遷移 10のベストプラクティス」の章だけであれば一目で読み終えられるので、こちらはぜひ目を通して頂けたらと思います。
それで興味を持っていただけたら、実装例や詳細解説をご覧ください。

それでは早速いってみましょう!

Prism for Xamarin.Formsの画面遷移 10のベストプラクティス

さて、結論から行きましょう。
私が考えるベストプラクティスは以下の通りです。
個々の詳細な解説は後半に記載します。

  1. 画面遷移処理はViewModelに実装する
  2. 画面遷移の指定に文字列を使用しない
  3. 画面遷移のベースにViewModel First的な画面遷移アプローチを採用する
  4. ViewModel指定と文字列指定の双方に対応できるフレームワークを用意する
  5. 遷移名はViewModelそのままではなく、変更可能な仕組みを設ける
  6. ViewModel FirstによるDeepLinkに対応する
  7. 画面遷移フレームワークとしてはINavigationServiceをそのまま利用する
  8. 画面遷移時のパラメーターは、Model層のコラボレーションと合わせて最低限にとどめる
  9. 画面遷移時のパラメーターキーは遷移先のViewModelで定義する
  10. 画面遷移時のパラメーター値の型安全性は諦める

ベストプラクティスの具体的な実装方法

本章ではベストプラクティスを具体的な実装を紹介しながら説明していきましょう。
フレームワーク(Prism.Forms.Toolkit)とサンプルのソースは、いずれも以下のリポジトリにありますので、参考にしてください。

github.com

サンプルの前提条件について

本サンプルはMVVMパターンを適用して実装しています。
MVVMパターンについてあまり良く分からないという方は、以下をご一読されることを強くお勧めします。

#yapc8oji 得票数4位トーク「あの日見たM V WhateverのModelを僕たちはまだ知らない」実況中継 - Re.Ra.Ku tech blog

また本サンプルのModel部分は以下のサイトの考え方に近いと思っています。

MVVMのModelにまつわる誤解 - the sea of fertility

サンプルの設計に対して、もう少しサンプルを拡充してから詳細に述べたいと思いますが、現時点で疑問に思った場合は、こちらをご一読いただけると解決することもあるかもしれません。

またPrism for Xamarin.Formsの画面遷移に関する基礎知識がある方を対象としています。
知らん!て人はまずはこちらをお読みください。

なおサンプルはPrism for Xamarin.Forms以外のサードパーティライブラリは意図的に使用せず作成しています。
この為、特にView-ViewModelの設計実装が最適解にはなっていませんがご了承ください。

サンプルアプリケーションの概要

今回紹介するサンプルアプリケーションは、「EmployeeManager」というアプリになります。
従業員管理アプリとなっていますが、現時点ではそこまでの機能はありません。
扱っているデータはEmployee(従業員)と、それの所属するSection(部署)です。

アプリの画面イメージは以下の通りです。

f:id:nuitsjp:20161204125930p:plain

3画面構成で、メニュー画面・部署一覧画面・部署詳細画面になっています。
部署詳細画面では部署の名称と、所属する従業員のリストが表示されています。
メニューからは直接DeepLinkを使って部署詳細画面への遷移も可能としています。
以下がその動作イメージです。

f:id:nuitsjp:20161204130822g:plain

サンプルアプリケーションの開発環境について

今回のアプリは、Prism Template Packを使って作成したプロジェクトを作成しています。
その上で、Prism.Forms.ToolkitをNuGetから取得して利用しています。
Prism.Forms.ToolkitはPCLプロジェクトのみにインストールすれば十分です。

なお現時点ではPrism.Forms 6.2.0にのみ対応しています。
6.3.0-pre1などへの対応は今後予定しています。

コード解説

usingについて

Prism.Forms.Toolkitは基本的に拡張メソッドで機能が提供されています。
その為、利用個所では必要に応じて以下のusingを追加してください。

using Prism.Forms.Toolkit;

DIコンテナへViewを登録する

さて、ViewModelで画面遷移する場合、ViewをDIコンテナに登録する際に、その旨伝えて登録する必要があります。
具体的には以下のように実施します。

protected override void RegisterTypes()
{
    // 中略

    Container.RegisterTypeForNavigation<NavigationPage>();
    Container.RegisterTypeForViewModelNavigation<MainPage, MainPageViewModel>();
    Container.RegisterTypeForViewModelNavigation<SectionListPage, SectionListPageViewModel>();
    Container.RegisterTypeForViewModelNavigation<SectionPage, SectionPageViewModel>();
}

NavigationPageは該当するViewModelを作成する必要が無いので通常の方式で登録しています。
RegisterTypeForViewModelNavigationメソッドでViewとViewModelを型パラメーターで指定している箇所が、今回のケースになります。*1

最もシンプルなViewModel指定による画面遷移

前述のメニュー画面から部署一覧画面への遷移は以下のように実装しています。
画面遷移をCommandとして実装しており、ViewのCommandに該当のCommandをバインドしています。

NavigationSectionListCommand = 
    new DelegateCommand(() => _navigationService.NavigateAsync<SectionListPageViewModel>());
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:prism="clr-namespace:Prism.Mvvm;assembly=Prism.Forms"
             prism:ViewModelLocator.AutowireViewModel="True"
             x:Class="EmployeeManager.Views.MainPage"
             Title="Menu">
  <StackLayout HorizontalOptions="Center" VerticalOptions="Center">
    <Button Text="Section List" Command="{Binding NavigationSectionListCommand}"/>

シンプルで明快ですね。

パラメーターを伴う画面遷移

部署一覧画面から部署詳細画面へ遷移する際、部署IDをパラメーターで渡しています。

public void OnSelectedSection(Section section)
{
    var navigationParameter = new NavigationParameters();
    navigationParameter[SectionPageViewModel.SectionIdKey] = section.Id;

    _navigationService.NavigateAsync<SectionPageViewModel>(navigationParameter);
}

パラメーターのキーの指定に注目してください。
キーは遷移先のViewModel側の定数として定義しています。

  • 画面遷移する以上、遷移先の存在と、遷移時に必要な情報は遷移元でも認知している必要がある
  • この為、遷移先のViewModelを参照するデメリットは少ない
    (物理的な参照が無くても、それを表す論理情報は知る必要があり、物理と論理を切り離すメリットが薄い)
  • したがって、パラメーターキーの定義も遷移先のViewModelで定義するのがシンプルで良い

といった理由に基づいています。
それ以外については、通常のPrismの画面遷移と大きな違いはありません。

ViewModel指定と文字列指定の混在によるメニュー画面への遷移

NavigationPage内にMainPageを初期画面として表示する場合、素のPrismでは以下のように指定します。

NavigationService.NavigateAsync("NavigationPage/MainPage");

型パラメーターでViewModelを指定する方式では、遷移先を任意の数、指定することはできません。
そこでPrism.Forms.Toolkitでは、画面遷移をNavigationオブジェクトとして扱い、遷移時に複数指定できる機能を提供しています。
具体的な利用コードは以下の通りです。

NavigationService.NavigateAsync(
    new Navigation(nameof(NavigationPage)),
    new Navigation<MainPageViewModel>());

NavigationPageには該当するViewModelが無いので、Viewの名称で遷移先を指定しています。
MainPageへの遷移はViewModelの型パラメータで指定しています。

Prism.Forms.Toolkitではベストプラクティスで記載した通り、内部的にはINavigationServiceをそのまま利用しています。
前述のコードは内部的に次のように処理されています。

public static Task NavigateAsync(this INavigationService navigationService, IEnumerable<Navigation> navigations, bool? useModalNavigation = null, bool animated = true)
{
    if(!navigations.Any())
        throw new ArgumentException("navigations count is 0.");

    var builder = new StringBuilder();
    var delim = navigations.Count() == 1 ? string.Empty : "/";
    foreach (var navigation in navigations)
    {
        builder.Append(delim);
        builder.Append(navigation);
        delim = "/";
    }
    return navigationService.NavigateAsync(builder.ToString(), null, useModalNavigation, animated);
}

Navigationオブジェクトで遷移指定されても、それを同等の意味を持つ文字列に変更した上で、標準提供のNavigateAsyncを呼び直しています。
トリックとしては単純ですね。
NavigationクラスのToStringメソッドが肝ですがその点に興味がある方はソースを直接ご覧ください。

ちなみに一点、悩んでいるところがあります。
相対パスによるDeepLinkを実行すると謎の挙動を示します。しかもUWPだけは意図した通り動作します(´・ω・`)
現在Prism.Forms.Toolkitでは、複数のNavigationが指定された場合、強制的に絶対パス指定でDeepLinkするようにしています。
これは私が何か勘違いしているのか、PrismかXamarin.Formsに何らかの不都合があるのか、現時点では調べ切れていません。
この為、将来的に上の実装は変更する可能性があります。

さて、上記の例ではパラメーターが含まれていませんが、実際のケースでは当然パラメーターが必要となることがあるでしょう。
という訳で、メニューの「DeepLink by ViewModel」ボタンをクリックした際に実行されるCommandは、以下のように実装しています。

var sectionPageNavigation = new Navigation<SectionPageViewModel>();
sectionPageNavigation.Parameters[SectionPageViewModel.SectionIdKey] = 9;

_navigationService.NavigateAsync(
    new Navigation("NavigationPage"),
    new Navigation<MainPageViewModel>(),
    new Navigation<SectionListPageViewModel>(),
    sectionPageNavigation);

前述の通り、DeepLinkは絶対パスでしか正しく動作させられていない為、NavigationPageから遷移を指定しています。*2

ViewModelによる画面遷移名に共通ルールから外れる特殊名を使用する

メニュー画面の「DeepLink by Literal」ボタンを押下した場合、次のようなパスで画面遷移を実施しています。

DeepLinkByLiteralCommand =
    new DelegateCommand(
        () => _navigationService.NavigateAsync(
            "/NavigationPage/MainPage/SectionListPage/SelectedSection?sectionId=5"));

これは前述の「遷移パラメーターを伴う、ViewModelベースのDeepLink」と完全に同一の意味を持っています。
見てもらいたいのは最後の遷移名が「SelectedSection」になっている点です。
該当のViewModelはSectionPageViewModelであり、名前が一致していません。

ViewModelの実装を見てみましょう。

[NavigationName("SelectedSection")]
public class SectionPageViewModel : BindableBase, INavigationAware

ViewModelにNavigationName属性を指定することで任意の遷移名を指定できるようにしています。
これはDeepLinkのURLが外部に発行された後、ViewModelに対する仕様変更が発生した場合に、リンク名を維持したい場合などを想定しています。
でも、そんな事が本当に必要なのかちょっと疑問がないでもないですが、一つの解法として検討しておく価値はあると考えています。

Prism.Forms.Toolkitの設計概要

フレームワークはおおよそ以下のような設計になっています。

f:id:nuitsjp:20161204161538p:plain

■ Navigation
画面遷移を表すクラス。 遷移先名と遷移パラメーターを保持している。 遷移パラメーターはPrismのNavigationParameterを直接保持して利用している。

■ Navigation<TViewModel>
Navigationのサブクラス。 ViewModelベースの画面遷移時に利用する。

■ NavigationNameAttribute
ViewModelに対して特殊な遷移名を指定する場合に利用する属性クラス

■ NavigationNameProvider
ViewModelに対応する遷移名を提供するクラス。
本クラスに命名規則を決定するResolverを独自に設定することでViewModelに割り当てる遷移名ルールを変更することができる。

■ NavigationServiceExtensions
INavigationServiceに対する拡張メソッド

■ UnityContainerExtensions
IUnityContainerに対する拡張メソッド

■ UnityExtensionsProxy
Prismの提供するUnityExtensionsをラップするプロキシークラス。
UnityExtensionsは拡張メソッドの集合なため、UnityContainerExtensionsから直接UnityExtensionsを利用してしまうとテスト容易性が激しく低下する。
このため、UnityContainerExtensionsからはUnityExtensionsを単純ラップする本クラスを利用して、UnityContainerExtensions側のテスト容易性を確保する。
本クラス側は単純ラップしているだけの本クラスの自動テストは行わないものとしている。

Prism for Xamarin.Formsの画面遷移ベストプラクティス - 詳細解説

さてここからは、ベストプラクティスを一つずつ詳細に解説していきたいと思います。
とは言え、本エントリーだけでは解説しきれていないものもありますが、それはおいおい追加エントリーを書きたいと思います。
なお本章は、人によっては助長だったりすることでしょうから、必要そうな部分を拾い読む事をお勧めします。
もちろんすべて読んでいただけると泣いて喜びます。

画面遷移処理はViewModelに実装する

Prismを利用した以上、当たり前のことでもありますが念のため。
これにはいくつかの理由がありますが、以下が主な理由です。

  • Viewに画面遷移ロジックを含めるとテスト容易性が低下する
  • 画面構成・遷移はPresentation(View+ViewModel)レイヤーの関心ごとであり、Modelからは分離するべきである

後者はそもそもModelの定義の通りですが、もう少し分かりやすく補足しましょう。

例えば、iPhoneとiPadで同一機能を持ったアプリを提供するとした場合、Modelは共有できるケースが高いでしょう。
しかし、少なくともベストなPresentationは異なるケースが多いでしょう。
Presentationが異なるのですから、Modelから共通のロジックで制御するのは困難です。

これらを総合すると、画面遷移指示はViewModelから行わせるのがベストとなります。

画面遷移の指定に文字列を使用しない

最も良くないのは、以下のようなパターンです。

navigationService.NavigateAsync("DetailPage");

文字列で指定している上に、画面遷移の箇所ごとにマジックストリングとして埋め込まれています。
遷移(画面)名やパラメーターが変更になる都度、関連箇所を全て適切に改修する必要があり問題があります。

では定数を定義すれば良いかと言うと、どこに定義すべきか?と言う問題が発生します。
遷移先の論理的な名称(文字列)と、物理的な実装(View)は1:1で安全に保たれている必要があります。
MVVMの場合、View(遷移の対象)には大抵、対となるViewModelがあり、そこに定義するのが第一候補でしょう。

しかしこの場合、ViewModelに対して依存関係が発生します。
であれば、次のアプローチの方が(文字数が増えることを除き)あらゆる点でスマートです。

画面遷移のベースにViewModel First的な画面遷移アプローチを採用する

詳細はこちらの過去記事も参考にしてください。
要は、遷移先を指定するにあたり、文字列ではなくViewModelそのものを利用しようというアプローチです。

navigationService.NavigateAsync<DetailPageViewModel>();

ViewとそのViewのRootレベルのViewModelが1:1である限り、非常にシンプルです。
遷移元のViewModelから遷移先のViewModelへ依存関係が発生してしまいますが

  • 「どこに遷移するか?」という論理的な遷移名は知っている必要がありそれは≒ViewModelを知っていることになる
  • 定義先がViewModelになるのであればどうせ依存関係は発生する

と言うことで、問題とすべき話ではないでしょう。

しかしViewModelによる画面遷移の実装は、実のところPreview段階のPrism本体には存在したのですが、正式リリース前に削除された経緯があります。

[XF] Remove NavigateAsync<TViewModel> or keep it?

これは主に4つの理由がありました。

  • 遷移先のViewModelを参照していない場合、遷移できない(PrismのModule機能を利用している場合など)
  • DeepLinkがサポートされない
  • そのため、Sleepからの復帰時などにNavigation Stackの復元がサポートされない
  • 上記が問題にならない場合、簡単な拡張メソッドを用意すればPrism側でサポートしなくてもアプリケーション側で対処できる(上のリンク先参照)

さて、上の3つが致命的に思えるかも知れませんが、これらは次の対策を行うことでまとめて解消することが可能です。

ViewModel指定と文字列指定の双方に対応できるフレームワークを用意する

アプリケーションからの画面遷移呼び出しは以下で実装します。

navigationService.NavigateAsync<DetailPageViewModel>();

フレームワークは上記の呼び出しに対して、Prismを以下の形で呼び出します。

navigationService.NavigateAsync("DetailPage");

こうすることで、Navigation Stackの復旧時などに、結果的にViewModelで指定された場合と、同じ振る舞いを取る事になります。
ただ、参照がないModuleへの遷移時には、アプリケーションからも文字列による呼び出しで妥協する必要があります。残念。

また、ViewModelの存在しないViewもあります。代表的なのはトップレベルのNavigationPageでしょう。
この場合も、無理にViewModelを作っても良いですが、文字列方式で対応してもまぁ許される妥協の範囲かな?
とも思ったのですが、「NavigationPageの文字列はどこに定義すべきか?」という問題がまた発生してしまいます。
この為、仮のViewModelを割り当てた方がやはり良いのかもしれません。

遷移名はViewModelそのままではなく、変更可能な仕組みを設ける

Brian氏によって提供されているこちらの方法では、遷移名にViewModelクラスのFullNameが使用されています。
もちろん安全性が高いのですが、如何せん長すぎます。特にDeepLinkを考慮すると厳しいです。

と言うわけで、ViewModelを指定した場合にPrismへ伝達する遷移先の文字列は、以下の2種類の変更可能な手段が提供されていることが好ましいでしょう。

  1. ViewModelクラスから文字列へルールに基づいて変換する仕組みを用意し、それはアプリケーションによってカスタマイズ可能とする
  2. 上記とは別に個別のViewModelごとに、遷移名をルールとは異なる別名に変更できる仕組みを用意する

ViewModel FirstによるDeepLinkに対応する

相対パスによるDeepLinkがサポートされていない場合、利用ケースは限定的です。
しかし、アプリケーション起動時にNavigationPage内のContentPageへDeepLinkするパターン等は、ごく一般的にありえます。

ポイントとなるのは、以下の様にNavigationAsyncの型パラメーターで指定した場合、当然DeepLinkに対応することができないと言う点です。

navigationService.NavigateAsync<DetailPageViewModel>();

またその際に、各遷移ごとにパラメーターを指定できる仕組みも、検討しておいた方が好ましいでしょう。*3
具体的な方法は、前述の実装例の中で紹介させていただいていますので、そちらをご覧ください。

画面遷移フレームワークとしてはINavigationServiceをそのまま利用する

もちろん、アプリケーション個別でNavigationServiceを改変したり拡張することは構いません。
しかし、画面遷移フレームワークとしては標準のNavigationServiceに手を入れることを前提とせず、INavigationServiceをそのまま利用できるよう設計するべきでしょう。
これは、Prismのバージョンアップへの追随や、画面遷移フレームワークの再利用を考慮した場合、絶対に必要なことです。

画面遷移時のパラメーターは、Model層のコラボレーションと合わせて最低限にとどめる

さて、画面遷移するたびに、次の画面が必要な情報を全てパラメーターとして渡す必要があるかと言うと、決してそんなことはありません。
画面遷移間のパラメーター数が多すぎると、変更容易性や開発効率が低下します。
そこで、Modelとのコラボレーションと併用して最低限に収めるべきです。
具体的には以下の二つのパターンが、利便性が高いでしょう。

  • 遷移前にModelに状態を保持しておいて、遷移後に利用する
  • 遷移時にはIDのみ渡し、遷移後にその他の値を再取得する(一覧から詳細画面など)

もともとModelとは、アプリケーションの状態そのものを表すクラス群です。
この為、同一のModelを共有するView間であれば、Modelで保持しておけば良いケースも存在します。

ではアプリケーション全体の状態管理するModelを作れば、そもそもパラメーター渡しなんて要らないんじゃないの?と言う疑問がわくかも知れませんが、さすがにそれはModelが大きくなりすぎます。

ではどう言うガイドラインでパラメーター渡しと、Modelでの保持を使い分ければ良いのか?
私個人の考えとしては、多くの場合、ViewModelから直接利用するModelはUMLで言う「ユースケース」単位で作成し

  • 一覧から詳細や、参照から編集などの場合はID渡し
  • ユースケースをまたがる画面遷移ではパラメーター渡し
  • ユースケース内の画面遷移はModel渡し

にしたら良いと思っています。もちろん例外は多数ありますがガイドラインとしては利用できるでしょう。

なお、ここでいうユースケースとは「ユーザーにとって価値(本質的な目的の達成)のある一連のアクション」を言います。
この辺りの詳しい話は、話出すと長くなるので別のエントリーをそのうち書きたいと思います。*4

画面遷移時のパラメーターキーは遷移先のViewModelで定義する

Prismでは画面間のパラメーター渡しの際に、パラメーターのキーを文字列で指定する必要があります。
それらのキー文字列は遷移先のViewModelに定義すると良いでしょう。
なぜなら、必要なパラメーターを規定するのは遷移先自体である為です。

画面遷移時のパラメーター値の型安全性は諦める

さて、PrismではパラメーターをDictionaryに詰め込んで受け渡すことが可能で、その際に値は文字列のみではなくオブジェクトを渡すことも可能です。 その際、受け取り側は、objectからダウンキャストする必要がありますが。。。
ここに型安全性を求めるのはちょっと難しいです。
この辺はまた今度別のエントリーにしたいと思います。

最後に

という訳で、 Xamarin Advent Calendar 2016 (その1)5日目のエントリー何とか5日のAM 0:00に間に合いました。
ここまでお付き合いいただけた方は、本当にありがとうございます!

あとできれば本エントリーについて、どこかのセッションで話したいなと思っています。
どこかいい場があったら、ご紹介くださるとうれしいです。

という訳で、今回はここまで。
それではまた!

*1:ちなみに、PrismではViewが確定すればViewModelは決定されます。
特殊なViewModelのマッピングを行っていない今回の例では、本来ViewModelを指定する必要は無いはずです。
しかし、Prism内部のViewとViewModelのマッピング情報を、外部から簡単に取得する方法がないため現時点では今回の例の通りになっています。
将来的にはViewModelの指定はなしでできるように対応したいのですが、やるかどうかは少し悩んでいます。

*2:これ実のところ、次の自分自身の疑問が解消されていません。

  • NavigationPageの名称をどこで定義すべきか決めかねている。
    そもそもやはり、ViewModel Firstに則るのであれば、NavigationPageにも仮のViewModelを定義すべきで混在は不要なのではないか?

という根本的な話です。
だれかこの辺の意見ある方は、ぜひ聞かせてほしいです。マサカリplz。

*3:しかしこれは、具体的な利用ケースはごく限られている可能性が高いです。

*4:MVVMの設計と密接に絡むのでマサカリがめっちゃ怖いんですけどね。。。