MVVMパターンとページナビゲーション



ページナビゲーション使用は、デスクトップWPF-MVVMアプリケーションにとって非常に緊急のタスクです。

このようなナビゲーションをネットワークで整理するための十分なガイドがあります。

そしてもちろん、Habrahabrも例外ではありません(記事は1回2回あります )。

最初の記事では、 NavigationServiceHyperlinkを使用する機能について学習します。

2番目のリンクをたどると、いわゆる「コードビハインド」でNavigationServiceを使用する方法がわかります。

したがって、これらの記事には完全な解決策は示されていません(私の意見では)。

Xはそのギャップを埋めてあなたの注意を引き付けたいと思っています。

また 、ページナビゲーションを整理するための完全なコンポーネントのふりをすることは絶対にありません。

有益なコメント、修正、追加に感謝します。

誰かが私のナビゲーターの実装を便利だと思ってくれたら嬉しいです。





はじめに



すでにWPFを使用したことがある人は誰でも、おそらくMVVMパターンに精通しているでしょう(記事の最後にリンクを示しました)。 結局、MVVMの概念は単純であり、少なくともその使用の利点は直感的に理解されるべきです。 すべての栄光でそれ自体を表示したい場合は、ユーザーコントロール(UserControl)の "コードビハインド"にできるだけ少ないロジックを配置し、ViewModel内のUIへの直接リンクを使用しないでください。 このアプローチは、ViewModelをコントロールとは別にテストできるという形で大きな利益をもたらします。 もう1つの良い方法は、コントロールで直接ViewModelのインスタンスの作成を最小限にすることです。 コントロールがそれ自体に対して特定のタイプのViewModelを作成しても問題ありません。この場合、ある種のテスト人形をコントロールするのは簡単ではありません。 そうしないと、特定のペアレンタルコントロールが残りの画面のViewModelの作成でビジー状態になると、コードがテスト不能なスパゲッティの束に変わる可能性があるため、事態が発生します。 他のViewModelがViewModelの作成を担当している場合、テストがはるかに簡単になります。



ナビゲーションバー、複数の画面、ダイアログボックスを備えたアプリケーションを想像してみましょう。 同様のことを以下に示します。







メインウィンドウ、ボタンのあるナビゲーションバー、現在のページ、このページの上のダイアログなど、いくつかのエンティティを作成できます。 このアプリケーションでは、ボタンの代わりにコンテンツとしてHyperLinkでTextBlockを使用する代わりに、ページングにHyperLinkを使用できます。 HyperLinkには、新しいページにジャンプするフレームの名前を示すプロパティがあります。 そして、すべてがうまくいくように見えますが、HyperLinkを使用して、目的のViewModelをページに転送することは難しいようです。

私はネット上でこの問題の解決策をいくつか見ました。



最初の2つの決定は明らかな松葉杖です。 PRISMは、UI構成フレームワーク全体です。 もちろん、研究に投資する価値はありますが、小規模なアプリケーション(概念実証など)では、IoCやPRISMなどを使用することは実用的ではない場合があります。



MVVMのコンテキストにどの程度簡単にスムーズに適合することができる最も簡単なソリューションはどれですか? SilverlightのPageクラスには、OnNavigatedToオーバーロードメソッドがあります。 このメソッドでは、2番目のパラメーターとしてNavigationService.Navigate(Uri uri、オブジェクトnavigationContext)に渡されたViewModelを受け入れると便利です。 ただし、WPFでは、Pageはそうではありません。 少なくとも私はそれまたは同等のものを見つけられませんでした。 特定の仲介者、または必要に応じて、ページナビゲーションを制御し、メソッドパラメーターからDataContextに必要なViewModelを転送するマネージャーが必要です。 この記事では、このようなナビゲーションマネージャーの実装について説明します。

次のセクションでは、コアソリューションの実装、ナビゲーションマネージャーについて説明します。 次に、UIレイヤーとViewModelレイヤーに実装する必要があるものについて説明します。 時間を節約するために、「Navigation Manager」セクションを読んで、問題を解決する過程で残りを考えてください。

コードをすぐに見ることに興味がある人は誰でもGitHubリポジトリにアクセスできます



ナビゲーションマネージャー



このマネージャは、nullのインスタンスをダブルチェックするシングルトンとして実装されます(いわゆるダブルチェックロックシングルトンシングルトンのマルチスレッドバージョン)。 私の好みはシングルトンの使用です。 そのため、ライフサイクルを制御する方が簡単です。 単純な静的クラスで十分かもしれません。

シングルトン実装コードは以下を参照してください。

シングルトン
#region Singleton private static volatile Navigation instance; private static object syncRoot = new Object(); private Navigation() { } private static Navigation Instance { get { if (instance == null) { lock (syncRoot) { if (instance == null) instance = new Navigation(); } } return instance; } } #endregion
      
      





上記のコードでは、 Instanceプロパティをプライベートにしたことがわかります。 これは単純にするために行われ、余計なものは覗き見されません。 実際には、公開する必要があります。 シングルトンインスタンスのプライベートプロパティの代わりに、サービスナビゲーションサービス (NavigationServiceなど)のパブリックプロパティを作成し、シングルトンのプライベートインスタンスを介して呼び出しを変換します。 他の方法で行うこともできますが、外部からのすべての呼び出しはインスタンスを介して行う必要があります。

 Navigation.Instance.Service
      
      





の代わりに

 Navigation.Service
      
      





最適なオプションを選択してください。 私には最後のオプションの方が簡単なようですが、静的プロパティとメソッドの追加の実装が必要です。 したがって、新しい機能の実装により、インスタンスプロパティ(Navigation.Instance)を開く方がより有益になる場合があります。



このシングルトンのServiceプロパティには、ページ遷移が必要なFrameインスタンスのNavigationServiceへの参照が格納されます。 このリンクには、アプリケーションの起動時(メインウィンドウのLoadedイベントハンドラー内)と、ナビゲーションメソッドのいずれかを呼び出す前の他の時点の両方で、現在の値を割り当てることができます。

  public MainWindow() { InitializeComponent(); Loaded += MainWindow_Loaded; } void MainWindow_Loaded(object sender, RoutedEventArgs e) { Navigation.Navigation.Service = MainFrame.NavigationService; DataContext = new MainViewModel(new ViewModelsResolver()); }
      
      





上記の例では、NavigatorService Frameをメインウィンドウに割り当てます。 メインウィンドウの代わりに任意のコントロールを使用できますが、このコントロールのLoadedイベントでNavigationServiceを取得する必要があります。 このイベントの前に、 nullを取得できます 。 コントロールとNavigationServiceのライフサイクルを詳細に検討しませんでした。

別のシナリオとして、 WPF Toolkit Extendedの ChildWindowを使用することをお勧めします。これには、別のフレームが組み込まれています。 この場合、ナビゲーターのNavigationServiceを一時的に置き換えて、そのようなダイアログ内で遷移を行うことができます。 これにより、バインダーを介したさまざまな画面のダイアログへのロードが自動化されます。 しかし、この使用のシナリオは非常にエキゾチックであるため、詳細には説明しません。 そのようなシナリオが興味深い場合は、別の記事を書きます。



現在の実装では、マネージャーは非常に簡単に動作します。 プライベートフィールドに新しい値を割り当てることに加えて、ナビゲーションサービスのセッターで、Navigatedサービスイベントへの登録解除とサブスクリプションが作成されます。

ナビゲーションサービスプロパティ
  public static NavigationService Service { get { return Instance._navService; } set { if (Instance._navService != null) { Instance._navService.Navigated -= Instance._navService_Navigated; } Instance._navService = value; Instance._navService.Navigated += Instance._navService_Navigated; } }
      
      





良い意味では、セッター(およびパブリックマネージャーメソッド)でのロックの使用は十分ではありません。 しかし、一般に、アプリケーションがNavigationServiceをナビゲーションメソッドの呼び出しと並行して置き換える場合、おそらく何かが正しく実装されていない可能性があります。 簡単にするために、 lockなしで実行できますが 、警告しました。



以下は、パブリックナビゲーションメソッドです。

ナビゲーション方法
  #region Public Methods public static void Navigate(Page page, object context) { if (Instance._navService == null || page == null) { return; } Instance._navService.Navigate(page, context); } public static void Navigate(Page page) { Navigate(page, null); } public static void Navigate(string uri, object context) { if (Instance._navService == null || uri == null) { return; } var page = Instance._resolver.GetPageInstance(uri); Navigate(page, context); } public static void Navigate(string uri) { Navigate(uri, null); } #endregion
      
      





上記のコードでは、「 _resolver 」の使用に気付くかもしれません。 IoCセクションでは、彼について説明します。 つまり、これはControl for Inversion for Controlの最も単純な実装です。



ナビゲーションマネージャーは、 NavigationServiceからナビゲーションメソッドのサブセットを実装します。これは、ほとんどの単純な場合に十分です。 送信されたViewModelをランディングページのDataContextプロパティにアタッチするためだけに残ります。 これは、 Navigatedイベントハンドラーで実行されます(以下のコードを参照)。

ナビゲートされたイベントの処理
  #region Private Methods void _navService_Navigated(object sender, NavigationEventArgs e) { var page = e.Content as Page; if (page == null) { return; } page.DataContext = e.ExtraData; } #endregion
      
      







ナビゲートイベントハンドラーで、 フレームコンテンツをページタイプにキャストしようとします 。 したがって、ページへの遷移のみが処理されます。 その他はすべて除外されます。 必要に応じて、この「鉄のカーテン」を取り外すことができます。 キャストが成功した場合、イベント引数のExtraDataプロパティで渡されたViewModelのインスタンスは、ランディングページのDataContextに配置されます。 ナビゲーションマネージャーがすべてです。

ページの実装とアセンブリViewModel'eyを使用してアセンブリを作成する必要があります。 また、ViewModelのRelayCommand実装コードを投稿したHelpersアセンブリも実装しました。 強度と時間がある場合は、UIとViewModelの実装の説明とともに次のセクションに進んでください。 そうでない場合は、他に何を実装する必要があるかを簡単に説明します。

ページごとに、個別のViewModelを作成します。 これらの「プライベート」ViewModelは、「 Inversion of Control 」を使用して親MainViewModelでインスタンス化されますIoCセクションを参照)。 メインViewModelはメインウィンドウのDataContextに配置されますが、同じ成功により、メインウィンドウのリソースウィンドウまたはアプリケーションレベル全体で静的XAMLリソースとしてインスタンス化できます。 この場合、DataContextのバインダーでSource = {StaticResource MainViewModelDataSourceKey}のようなものを指定する必要があります。 ただし、論理親のDataContextが継承されているかどうかを心配する必要はありません。

MainViewModelで、いくつかのコマンドを作成しました。 CommandParameterで指定されたページの文字列エイリアスに移動するためのもの(データコンテキストを渡さずに移動する)。 他のコマンドのExecuteデリゲートには、 CommandParameterを介して受信したデータコンテキストを持つランディングページの特定のエイリアスへのジャンプが含まれます。 詳細については、 GitHubにアクセスするか、この記事を読み続けてください。



ViewModelsアセンブリ



このアセンブリはINotifyPropertyChangedを実装するベースViewModelを導入します。

Baseviewmodel
  public class BaseViewModel : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; protected void RaisePropertyChanged(string propertyName) { if (string.IsNullOrWhiteSpace(propertyName)) { return; } if (PropertyChanged != null) { PropertyChanged(this, new PropertyChangedEventArgs(propertyName)); } } }
      
      







残りのViewModelはこのモデルから継承され、現在は非常に単純です。 これらには、一意の名前を持つ1つの文字列プロパティが含まれます(以下の例を参照)。

  public class Page1ViewModel : BaseViewModel { public string Page1Text { get { return "Hello, world!\nSent from my iPage 1..."; } } }
      
      





ご注意
ここで、プロパティは読み取り専用であり、RaisePropertyChanged(...)をどこにも呼び出さないことに注意してください。 この場合、これは簡単にするために行われました。 実際には、ViewModelsのこのようなプロパティは見つかりますが、まれです。 このようなプロパティのバインドは1回だけ機能します。 RaisePropertyChanged(...)を使用せずにセッターを追加しても、バインディングは「1回限り」のままです。



MainViewModelはすでにはるかに複雑です。 前のセクションで簡単に書いたように、プライベートViewModelを格納し、ナビゲーションコマンドを実装します。 私の場合、プライベートViewModelは、MainViewModelを初期化するときに、いわゆる「 リゾルバー 」を使用して一度だけ作成されます。 したがって、これらのViewModelのゲッターのみを実装しました。

  public Page1ViewModel Page1ViewModel { get { return _p1ViewModel; } } public Page2ViewModel Page2ViewModel { get { return _p2ViewModel; } } public Page3ViewModel Page3ViewModel { get { return _p3ViewModel; } }
      
      





フィールドはMainViewModelコンストラクターで初期化されます。

  _p1ViewModel = _resolver.GetViewModelInstance(Page1ViewModelAlias); _p2ViewModel = _resolver.GetViewModelInstance(Page2ViewModelAlias); _p3ViewModel = _resolver.GetViewModelInstance(Page3ViewModelAlias);
      
      





ご注意
この場合の「 _resolver 」は、管理の反転の別のコンテナです。これについては、対応するセクションで説明します。 現時点では、このリゾルバーは、渡されたエイリアスViewModelと一致する辞書からデリゲートを抽出するだけです。 また、実際には、プライベートViewModelのフィールドとプロパティの完全な実装を作成する必要がある場合があることに注意してください。 これはすでに基本的に行われています。



私の場合、コマンドはgetおよびsetを使用して実装され 、インスタンスの初期化は別の関数に配置されます。 コマンドセッターを使用すると、現在のViewModel以外のすべてのコマンドを置き換えることができます。 このアプローチにより、たとえば、ダイアログボックスの(ダイアログ)内部ViewModel内の対応するコマンドにバインドを介してリンクされている場合、ダイアログボックスの応答を[OK]ボタンのクリックに変更できます。 ただし、このようなシナリオは非常にエキゾチックであり、チームセッターなしで実装できます。

チームの実装
  public ICommand GoToPathCommand { get { return _goToPathCommand; } set { _goToPathCommand = value; RaisePropertyChanged("GoToPathCommand"); } } public ICommand GoToPage1Command { get { return _goToPage1Command; } set { _goToPage1Command = value; RaisePropertyChanged("GoToPage1Command"); } } private void InitializeCommands() { GoToPathCommand = new RelayCommand<string>(GoToPathCommandExecute); GoToPage1Command = new RelayCommand<Page1ViewModel>(GoToPage1CommandExecute); GoToPage2Command = new RelayCommand<Page2ViewModel>(GoToPage2CommandExecute); GoToPage3Command = new RelayCommand<Page3ViewModel>(GoToPage3CommandExecute); } private void GoToPathCommandExecute(string path) { if (string.IsNullOrWhiteSpace(path)) { return; } var uri = new Uri(path); Navigation.Navigate(uri); } private void GoToPage1CommandExecute(Page1ViewModel viewModel) { Navigation.Navigate(Navigation.Page1Alias, Page1ViewModel); }
      
      





ランディングページのエイリアスがパスとして渡されることに注意してください。 これらのエイリアスは、ナビゲーションマネージャーで定数の形式で配置しますが、一般的には、XML設定ファイルまたは何らかの種類のテキストディクショナリで最適な場所に配置します。



GoToPage1Commandコマンドを実行すると、 ユーザーは指定されたエイリアスを使用してページにリダイレクトされ、 Page1ViewModelへのリンクがページのDataContextに配置されます。 したがって、ランディングページからデータを取得するために追加のロジックを実装する必要はありません。 MainViewModel内のリポジトリで動作するため、戻る前にすべての変更を自動的に受け取ります。

すべてがViewModelsにあるようです。 UIに渡します。



メインウィンドウとページアセンブリ



便宜上、もう一度テストアプリケーションを提供します。







左側には4つのボタンがあります。 最初のボタンはGoToPathCommandコマンドにバインドされ、データコンテキストなしでPage1にジャンプします。 データコンテキストのないページに移動した後、ViewModelの現在の値は、BindingオブジェクトのFallbackValueパラメーターの値に置き換えられます。 残りのボタンは、コマンドデリゲートで指定された必要なページページのエイリアスを持つ「プライベート」コマンドに関連付けられています。

メインウィンドウのレイアウトとコード
 <Window x:Class="Navigator.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="480" Width="640"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto"/> <ColumnDefinition /> </Grid.ColumnDefinitions> <ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Hidden"> <StackPanel> <Button Content="P 1 w/o data" Command="{Binding GoToPathCommand}" CommandParameter="pack://application:,,,/Pages;component/Page1.xaml"/> <Button Content="Page 1" Command="{Binding GoToPage1Command}" CommandParameter="{Binding Page1ViewModel}"/> <Button Content="Page 2" Command="{Binding GoToPage2Command}" CommandParameter="{Binding Page2ViewModel}"/> <Button Content="Page 3" Command="{Binding GoToPage3Command}" CommandParameter="{Binding Page3ViewModel}"/> </StackPanel> </ScrollViewer> <Frame x:Name="MainFrame" Grid.Column="1" Background="#CCCCCC"/> </Grid> </Window>
      
      







  public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); Loaded += MainWindow_Loaded; } void MainWindow_Loaded(object sender, RoutedEventArgs e) { Navigation.Service = MainFrame.NavigationService; DataContext = new MainViewModel(); } }
      
      









Pagesアセンブリには、Page1、Page2、Page3、Page404の4つのページが含まれています。 最初の2つには、対応するプライベートViewModelのプロパティに添付されたテキストブロックが含まれています。 3番目を少し複雑にして、別のMVVM問題、つまりListBox.SelectedItemsをViewModelにバインドするタスクを実装しました。 これは別のトピックであり、私の意見では別の記事に値します。 楽しみのために、以下のネタバレマークアップをご覧ください。

マークアップPage3
 <Page x:Class="Pages.Page3" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:tkx="http://schemas.xceed.com/wpf/xaml/toolkit" mc:Ignorable="d" d:DesignHeight="400" d:DesignWidth="400"> <Grid> <tkx:ChildWindow WindowState="Open" Caption="My Dialog" IsModal="True" WindowStartupLocation="Center"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition /> </Grid.RowDefinitions> <TextBlock Text="{Binding Page3Text, FallbackValue='No Data'}" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="24" FontWeight="Bold"/> <StackPanel Grid.Row="1"> <TextBlock Text="Category:" Margin="5"/> <ComboBox Text="Select..." Margin="5"> <ComboBoxItem Content="Category 1"/> <ComboBoxItem Content="Category 2"/> <ComboBoxItem Content="Category 3"/> </ComboBox> <TextBlock Text="Items:" Margin="5 10 5 5"/> <ListBox Margin="5" SelectionMode="Multiple"> <ListBoxItem Content="Item 1"/> <ListBoxItem Content="Item 2"/> <ListBoxItem Content="Item 3"/> <ListBoxItem Content="Item 4"/> <ListBoxItem Content="Item 5"/> <ListBoxItem Content="Item 6"/> <ListBoxItem Content="Item 7"/> <ListBoxItem Content="Item 8"/> </ListBox> </StackPanel> </Grid> </tkx:ChildWindow> </Grid> </Page>
      
      





このページでは、特定のカテゴリの要素を選択した簡単なダイアログを投稿しました。 これは現実に近い例です。 ダイアログのタイプは、上のメインウィンドウのスクリーンショットに表示されていました。 注意してください、テキストブロックはダイアログボックスにあるように見えますが、何のトリックもなしに、ページのDataContextに直接バインドします。 これは、WPF Toolkit ExtendedのChildWindowのメリットです。 このコントロールは、実際にはダイアログボックスの動作をシミュレートするだけで、XAMLマークアップの親の直接の子孫です。 したがって、DataContextは、それを配置したグリッドからChildWindowに継承されます。

ListBoxで複数選択をバインドする問題について簡単に説明します。 選択したListBox要素のリストをViewModelに戻すには、バインダーを直接使用できません。 プロパティListBox.SelectedItemsはバインディングをサポートしていません。 この問題を解決するために、DependencyPropertyを追加するコントロールをListBoxから継承できます。 ただし、MVVMのコンテキストには、より柔軟なアプローチがあります。興味がある場合は、別の記事で説明します。





IoC(管理の反転)



残念ながら、この記事ではこのアプローチを詳しく説明することはできません。 ボリュームはすでに大きくなっています。 しかし、必要な知識は、たとえばHabrに関する記事から引き出すことができます。 多くのリソースをグーグル検索することもできます。 つまり、「制御の反転」は、あるアセンブリから別のアセンブリへの直接リンクを排除する方法です。 依存性注入は、特別な「 コンテナ 」によって実行されます。これは、構成ファイルから、構成内の指定されたインターフェイスとセクション名に対して初期化する特定のクラスとアセンブリを認識します。 私のコードでは、IoCが完全に実装されていないことを認めなければなりません。 正直に言うと、それは目的ではありませんでした。 もちろん、IoCの概念をコードに反映させ、コードの接続性を低下させる方法を示しました。

以下は、コンテナインターフェイスとその実装です。

 namespace ViewModels.Interfaces { public interface IViewModelsResolver { INotifyPropertyChanged GetViewModelInstance(string alias); } } namespace Navigator.Navigation.Interfaces { public interface IPageResolver { Page GetPageInstance(string alias); } }
      
      





これらのインターフェイスは、ページコンテナおよびViewModelのさまざまな実装の特定のコントラクトの役割を果たします。 現時点では、実際のプロジェクトでは絶対に使用しないでください2つの実装を作成しました

コンテナ実装のテスト
 namespace Navigator.Navigation { public class PagesResolver : IPageResolver { private readonly Dictionary<string, Func<Page>> _pagesResolvers = new Dictionary<string, Func<Page>>(); public PagesResolver() { _pagesResolvers.Add(Navigation.Page1Alias, () => new Page1()); _pagesResolvers.Add(Navigation.Page2Alias, () => new Page2()); _pagesResolvers.Add(Navigation.Page3Alias, () => new Page3()); _pagesResolvers.Add(Navigation.NotFoundPageAlias, () => new Page404()); } public Page GetPageInstance(string alias) { if (_pagesResolvers.ContainsKey(alias)) { return _pagesResolvers[alias](); } return _pagesResolvers[Navigation.NotFoundPageAlias](); } } } namespace ViewModels { public class ViewModelsResolver : IViewModelsResolver { private readonly Dictionary<string, Func<INotifyPropertyChanged>> _vmResolvers = new Dictionary<string, Func<INotifyPropertyChanged>>(); public ViewModelsResolver() { _vmResolvers.Add(MainViewModel.Page1ViewModelAlias, () => new Page1ViewModel()); _vmResolvers.Add(MainViewModel.Page2ViewModelAlias, () => new Page2ViewModel()); _vmResolvers.Add(MainViewModel.Page3ViewModelAlias, () => new Page3ViewModel()); _vmResolvers.Add(MainViewModel.NotFoundPageViewModelAlias, () => new Page404ViewModel()); } public INotifyPropertyChanged GetViewModelInstance(string alias) { if (_vmResolvers.ContainsKey(alias)) { return _vmResolvers[alias](); } return _vmResolvers[MainViewModel.NotFoundPageViewModelAlias](); } } }
      
      







これらは単なるコンテナの「人形」であり、 Unityライブラリなどから管理されるものに置き換える必要があります。 IUnityContainerのようなものをインターフェイスとして使用する方が良いでしょうが、ソリューションに追加の参照を追加し、ナビゲーター実装の認識を複雑にしたくありませんでした。 さらに、Unityの代わりに他のIoCライブラリを使用できます。

さらに読む



ウィキペディアのシングルトンについて

Habrahabrのシングルトンについて

ウィキペディアのMVVMパターンについて

HabrahabrのMVVMパターンについて



All Articles