Всі, хто вже працював з WPF, напевно знайомі з патерном MVVM (дал посилання в кінці статті). Адже концепція MVVM нескладна і, як мінімум, інтуїтивно повинна бути зрозумілою вигода від його використання. Якщо хочете, щоб він проявив себе у всій красі, то як можна менше логіки розміщуйте в «Code Behind» призначених для користувача елементів управління (UserControl) і ні в якому разі не використовуйте прямі посилання на UI всередині ViewModel'ей. Даний підхід дасть Вам великий профіт у вигляді можливості тестування ViewModel'ей окремо від контролів. Ще однією доброю практикою буде зведення до мінімуму створення екземплярів ViewModel'ей безпосередньо в контролі. Нестрашно, якщо елемент керування сам для себе створює ViewModel конкретного типу - в цьому випадку просто важче буде підкласти контролю якусь тестову ляльку. Інакше йтимуть справи, коли якийсь батьківський контрол буде зайнятий створенням ViewModel'ей для інших екранів, адже тоді код може перетворитися в нетестіруемую купу спагеті. Якщо за створення ViewModel'ей відповідатимуть інші ViewModel'і, то тестувати стане набагато легше.
Давайте уявимо собі додаток з панеллю навігації, декількома екранами і діалоговими вікнами. Щось подібне представлено нижче.
Ми можемо розглядати кілька сутностей: головне вікно, панель навігації з кнопками, поточна сторінка і діалог над цією сторінкою. У нашому додатку для сторінкової навігації можна було б використовувати HyperLink, підклавши замість кнопок TextBlock з HyperLink як контенту. У HyperLink є властивість, що вказує ім'я Frame, в якому виконувати перехід на нову сторінку. І начебто все нормально, але з використанням HyperLink видається важким передача сторінці потрібної ViewModel'і.
Я бачив в мережі пару рішень цієї проблеми:
- У події Frame.Navigated в головному вікні програми через Code Behind можна отримати доступ до завантаженого у фрейм вмісту і підкласти туди створену там же в Code Behind ViewModel. Таким чином, створення ViewModel'ей для всіх сторінок буде сконцентровано в одному обробнику з використанням довгою онучі if ... else if ... або switch. Про те, що тестування такого «Hard Coded» процесу навігації вкрай важко автоматизувати, я мовчу.
- Іншим рішенням є створення екземпляра Page і ViewModel'і під неї, підкладання ViewModel'і в DataContext примірника Page і виклик Navigate у фрейму з передачею створеного екземпляра Page. Це рішення трохи краще попереднього, але як і раніше зовсім не "MVVM-way».
- Третім рішенням можна назвати використання бібліотек PRISM. Вона використовується в секторі великих корпоративних додатків для реалізації композитного UI. Якщо знайомі з AngularJS, то зрозумієте що це. Реалізується якийсь RegionManager, в якому реєструються частини UI. Потім через створений менеджер викликається інстанціірованіе контрола по некоемому псевдоніму, також привласнення потрібного контексту даних. Цей функціонал схожий на те, що вже реалізовано в NavigationService WPF.
Перші два рішення - явний милицю. PRISM ж - це цілий фреймворк композиції UI. Інвестувати в його вивчення, звичайно, варто, але для невеликих додатків (proof of concept, напр.) Використання таких речей, як IoC і PRISM, може виявитися недоцільним.
Яке найпростіше рішення могло б більш-менш гладко вписується в контекст MVVM? У класу Page в Silverlight є перевантажується метод OnNavigatedTo. У цьому методі було б зручно приймати ViewModel, передану в NavigationService.Navigate (Uri uri, object navigationContext) другим параметром. Однак в WPF у Page такого методу немає. Принаймні я не знайшов його або чогось еквівалентного. Нам потрібен якийсь посередник або, якщо хочете, менеджер, який буде контролювати переходи по сторінках і перекладати з параметра методу в DataContext потрібну ViewModel. Про реалізацію такого менеджера навігації і піде мова в даній статті.
У наступному розділі я розповім про реалізацію ядра рішення, про менеджера навігації. Потім, буде розказано про те, що потрібно реалізувати на UI і ViewModel шарах. Для економії часу можна прочитати розділ «Менеджер навігації», а решта додумати по ходу вирішення своїх завдань.
Кому цікаво відразу поглянути на код, може переходити в репозиторій на GitHub.
Менеджер навігації
Цей менеджер реалізований у вигляді Сінглтона з подвійною перевіркою примірників на null (так званий Double-Check Locking Singleton. Многопоточная версія Сінглтона). Використання Сінглтона - це моє перевагу. Так мені простіше контролювати життєвий цикл. Вам можливо вистачило б і простого статичного класу.
Код реалізації Сінглтона дивіться нижче.
У представленому вище коді Ви можете побачити, що властивість Instance я зробив приватним. Так зроблено для простоти, щоб назовні не визирало нічого зайвого. Вам же на практиці може знадобитися зробити його доступним публічно. Замість приватного властивості екземпляра Сінглтона я створив публічне якість обслуговування навігації Service (типу NavigationService), яке транслює виклики через приватний екземпляр Сінглтона. Можна було зробити навпаки, але тоді б всі виклики зовні доводилося робити через екземпляр, тобто
Вибирайте варіант, який Вам більше подобається. Мені здається останній варіант простіше, але він вимагає додаткової реалізації статичних властивостей і методів. Тому з реалізацією нового функціоналу може стати вигідніше відкрити властивість екземпляра (Navigation.Instance).
Властивість Service в цьому Сінглтон буде зберігати посилання на NavigationService примірника Frame, в якому потрібно виконувати сторінкові переходи. Привласнювати актуального значення цим посиланням можна як при старті програми (в обробнику події Loaded головного вікна), так і в будь-який інший більш пізній момент до виклику одного з методів навігації.
В наведеному вище прикладі ми призначаємо нашому навігатору NavigationService Frame головного вікна. Замість головного вікна міг бути будь-який контрол, але забирати NavigationService потрібно в подію Loaded даного контрола. До цієї події можна отримати null. Більш детально життєвий цикл контролів і NavigationService я не вивчав.
В якості альтернативного сценарію я міг би запропонувати використання ChildWindow з WPF Toolkit Extended. в який вбудований ще один Frame. Можна в такому разі тимчасово підмінити NavigationService в нашому навігаторі, щоб зробити перехід всередині такого діалогу. Це дозволить автоматизувати через Біндінг подгрузку різних екранів в діалогові вікна. Але сценарій такого використання здається вельми екзотичним, тому детально розписувати не буду. Якщо такий сценарій цікавий, то напишу окрему статтю.
Якість обслуговування навігації
По-хорошому, в сетера (і публічних методах менеджера теж) не вистачає використання lock. Але взагалі, якщо у Вас в додатку паралельно з викликом будь-якого методу навігації буде проводитися заміна NavigationService. то, швидше за все, щось реалізовано некоректно. Поки для простоти обійдемося без lock. але я Вас попередив.
Нижче представлені публічні методи навігації.
У коді вище Ви можете помітити використання "_resolver". У розділі IoC я про нього розповім. Якщо коротко, то це найпростіших реалізація Контейнери для Инверсии Управління.
У менеджері навігації реалізовано підмножина навігаційних методів з NavigationService. якого цілком достатньо для більшості простих випадків. Залишається тільки підкласти передану ViewModel в властивість DataContext цільової сторінки. Це робиться в обробнику події Navigated (див. Код нижче).
Обробка події Navigated
У обробнику події Navigated робиться спроба приведення контенту Frame до типу Page. Таким чином, оброблені будуть тільки переходи на Page. Всі інші відфільтрують. Якщо хочете, то можете прибрати цей «залізна завіса». У разі успішного приведення типів переданий у властивості ExtraData аргументів події екземпляр ViewModel'і буде поміщений в DataContext цільової сторінки. Це все про менеджера навігації.
Залишилося створити збірку з реалізацією сторінок і складання ViewModel'ей. Ще я реалізував збірку Helpers, в якій розмістив код реалізації RelayCommand для ViewModel'ей. Якщо є сили і час, переходите до наступних розділів з описом реалізації UI і ViewModel'ей. Якщо немає, то нижче коротко викладу, що ще потрібно реалізувати.
Для кожної сторінки слід створити окрему ViewModel. Ці, «приватні», ViewModel'і інстанцііруются в їх батьківської MainViewModel з використанням "Інверсії Управління" (див. Розділ IoC). Головна ViewModel поміщається в DataContext головного вікна, але з таким же успіхом її можна було б інстанцііровать у вигляді статичного ресурсу XAML в словнику ресурсів головного вікна або навіть на рівні всього програми. В такому випадку в Біндінг для DataContext доведеться вказувати щось типу Source =. Але зате можна буде не турбуватися про те, успадковується чи в потрібному місці DataContext логічного батька.
У MainViewModel я створив кілька команд. Одну для переходу за вказаною в CommandParameter строковому псевдоніму сторінки (перехід без передачі контексту даних). Інші команди містять в їх делегата Execute перехід по якомусь конкретному псевдоніму цільової сторінки з прийому через CommandParameter контексту даних. За деталями можете перейти на GitHub або продовжити читання цієї статті.
збірка ViewModels
У цій збірці представлена базова ViewModel, яка реалізує INotifyPropertyChanged.
Решта ViewModel'і успадковуються від неї і на даний момент гранично прості. Вони містять одне строкове властивість з унікальним ім'ям (див. Приклад нижче).
Зверніть увагу, тут властивість тільки для читання і без виклику RaisePropertyChanged (...) де б то не було. В даному випадку це було зроблено для простоти. На практиці такі властивості ViewModel'ей зустрічаються, але нечасто, тому що Binding на таких властивостях спрацює лише раз. Навіть якщо я додам сетер без RaisePropertyChanged (...), то Binding все одно буде «одноразовим».
MainViewModel вже значно складніше. Як я написав коротко в попередньому розділі, вона буде зберігати приватні ViewModel'і і реалізовувати команди навігації. У моєму випадку приватні ViewModel'і створюються лише раз з використанням так званого "Resolver 'а", при ініціалізації MainViewModel. Тому я реалізував тільки геттери цих ViewModel'ей.
Поля не започатковано в конструкторі MainViewModel:
"_resolver" в даному випадку - це ще один Контейнер Инверсии Управління, про які піде мова у відповідному розділі. На даний момент цей Resolver просто дістає зі словника делегат, відповідний переданому псевдоніму ViewModel. Також варто зауважити, що на практиці Вам може знадобитися зробити повну реалізацію полів і властивостей для приватних ViewModel'ей. Це вже робиться елементарно.
Команди в моєму випадку реалізовані із зазначенням get і set. а ініціалізація їх примірників поміщена в окремій функції. Наявність сеттерів команди дозволяє мені підмінити кожну команду зовні поточної ViewModel. Такий підхід дозволяє, наприклад, змінити реакцію діалогового вікна на клік кнопки «ОК», якщо та прив'язана через Binding до відповідної команді в його (діалогу) внутрішньої ViewModel. Втім, такий сценарій дуже екзотичний і може бути реалізований без сеттерів команд.
Зверніть увагу, як шлях передається псевдонім цільової сторінки. Ці псевдоніми я помістив у вигляді констант в менеджер навігації, але взагалі краще місце для них в XML-файлі або просто в якомусь текстовому словнику.
Головне вікно та збирання Pages
Наведу знову для зручності вид тестового додатка.
Зліва представлені чотири кнопки. Перша кнопка прив'язана до команди GoToPathCommand і виконує перехід на Page1 без контексту даних. Після переходу на сторінку без контексту даних замість актуального значення з ViewModel'і буде підставлена значення з параметра FallbackValue об'єкта Binding. Решта кнопки прив'язані до «приватним» командам із зазначеним в делегата команди псевдонімом необхідної сторінки сторінки.
Розмітка і код головного вікна
Збірка Pages містить чотири сторінки: Page1, Page2, Page3, Page404. Перші дві просто містять текстові блоки, прив'язані до властивості відповідної приватної ViewModel. Третю я трохи ускладнив, щоб реалізувати ще одну проблему MVVM, а саме завдання прив'язки ListBox.SelectedItems до ViewModel. Це окрема тема, яка на мій погляд заслуговує окремої статті. Для інтересу можете заглянути під спойлер розмітки нижче.
IoC (інверсія управління)
На жаль не можу детально описати цей підхід в даній статті. Обсяг і так великий. Але Ви можете почерпнути необхідні знання, наприклад, зі статей на Хабре. Також безліч ресурсів можна нагугліть. Якщо коротко, то «інверсія управління» це спосіб усунути прямі посилання в одній збірці на іншу збірку. Інжекція залежностей виконується спеціальними "Контейнерами", які з конфігураційних файлів дізнаються які конкретно класи і з яких збірок форматувати для зазначених вище інтерфейсу і імені секції в конфіги. Потрібно зізнатися, що в моєму коді IoC не реалізована повністю. Якщо чесно, то і цілі такої не було. Зрозуміло, концепцію IoC в коді я спробував відбити і спробував показати яким чином можна зробити код менше зв'язковим.
Нижче представлені інтерфейси контейнерів і їх реалізації.
Ці інтерфейси відіграють роль певних контрактів для різних реалізацій контейнерів сторінок і ViewModel'ей. На даний момент я зробив дві реалізації, які Ви ні в якому разі не повинні використовувати в реальних проектах.
Реалізації тестових контейнерів
Це просто «ляльки» контейнерів, які підлягатимуть заміні на щось кероване, наприклад з бібліотеки Unity. Як інтерфейсів теж краще було б використовувати щось на зразок IUnityContainer. але мені не хотілося обтяжувати СОЛЮШЕН додатковим референсом і ускладнювати сприйняття своєї реалізації навігатора. Тим більше Ви можете віддати перевагу будь-яку іншу бібліотеку IoC замість Unity.