Silverlight

Предоставление интерфейсов в ваших MVVM-приложениях Silverlight через MEF

Сандрино Ди Маттиа

Загрузка примера кода

Хотя многие разработчики могут считать Silverlight технологией, ориентированной исключительно на Web, на практике она превратилась в отличную платформу для создания приложений любых типов. В Silverlight есть встроенная поддержка таких концепций, как связывание с данными, преобразователи значений (value converters), навигация, работа вне браузера и взаимодействие с COM, что позволяет сравнительно легко создавать любые виды приложений. И когда я говорю «любые виды», я имею в виду и корпоративные приложения.

Создание приложения Silverlight на основе шаблона Model-View-ViewModel (MVVM) дает вам — в дополнение к возможностям самой Silverlight — преимущества большей гибкости в сопровождении, тестировании и отделении UI от логики, стоящей за ним. И, конечно, вам не придется разбираться во всем этом самостоятельно. На этот счет есть целое море информации и уйма инструментов, помогающих начать работу. Например, MVVM Light Toolkit (mvvmlight.codeplex.com) — облегченная инфраструктура для реализации MVVM с использованием Silverlight и Windows Presentation Foundation (WPF), а WCF RIA Services (silverlight.net/getstarted/riaservices) упрощает доступ к WCF-сервисам (Windows Communication Foundation) и базам данных благодаря генерации кода.

Вы можете сделать свое приложение Silverlight еще более продвинутым, используя Managed Extensibility Framework (mef.codeplex.com), также известную как MEF. Эта инфраструктура предоставляет основу для создания расширяемых приложений с применением компонентов и композиции.

В остальной части статьи я покажу, как использовать MEF для централизованного управления созданием View и ViewModel. После этого вы сможете делать куда больше, чем просто помещать ViewModel в DataContext, принадлежащий View. Все это будет осуществляться настройкой встроенных средств навигации Silverlight. Когда пользователь переходит по конкретному URL, MEF перехватывает этот запрос, просматривает маршрут (немного похоже на ASP.NET MVC), находит подходящие View и ViewModel, уведомляет ViewModel о том, что происходит, и отображает View.

Приступаем к работе с MEF

Так как MEF является основой, связующей все части этого примера, лучше всего начать с нее. Если вы еще не знакомы с MEF, прочитайте статью Глен Блок (Glenn Block) «Building Composable Apps in .NET 4 with the Managed Extensibility Framework» в февральском номере «MSDN Magazine» за 2010 г. (msdn.microsoft.com/magazine/ee291628).

Сначала вам понадобится корректно конфигурировать MEF при запуске приложения, обрабатывая событие Startup класса App:

private void OnStart(object sender, StartupEventArgs e) {
  // Initialize the container using a deployment catalog.
  var catalog = new DeploymentCatalog();
  var container = CompositionHost.Initialize(catalog);
  // Export the container as singleton.
  container.ComposeExportedValue<CompositionContainer>(container);
  // Make sure the MainView is imported.
  CompositionInitializer.SatisfyImports(this);
}

Каталог развертывания (deployment catalog) обеспечивает, чтобы все сборки сканировались на наличие экспортируемых членов, а затем используется для создания CompositionContainer. Поскольку навигация потребует потом от этого контейнера выполнения некоторой работы, важно зарегистрировать экземпляр этого контейнера как экспортируемое значение. Это позволит в любой момент экспортировать один и тот же контейнер.

Другой вариант — хранить контейнер как статический объект, но это привело бы к жесткому связыванию между классами, что крайне нежелательно.

Расширение навигации Silverlight

Silverlight Navigation Application — это шаблон в Visual Studio, позволяющий быстро создавать приложения, которые поддерживают навигацию через Frame, где размещается контент. Самое замечательное в Frame заключается в том, что они интегрируются с кнопками Back (Назад) и Forward (Вперед) в вашем браузере и поддерживают глубокое вл��жение ссылок (deep linking). Взгляните на этот код:

<navigation:Frame x:Name="ContentFrame" 
  Style="{StaticResource ContentFrameStyle}" 
  Source="Customers" 
  NavigationFailed="OnNavigationFailed">
  <i:Interaction.Behaviors>
    <fw:CompositionNavigationBehavior />
  </i:Interaction.Behaviors>
</navigation:Frame>

Это самый обычный фрейм, запускаемый навигацией (переходом) к Customers. Как видите, этот Frame не содержит UriMapper (где вы могли бы связать Customers с XAML-файлом, таким как /Views/Customers.aspx). Единственное, что он содержит, — собственное поведение CompositionNavigationBehavior. Поведение (из сборки System.Windows.Interactivity) позволяет расширять существующие элементы управления, в данном случае Frame.

Поведение показано на рис. 1. Давайте рассмотрим, что делает CompositionNavigationBehavior. Первое, что мы видим, — этому поведению нужны CompositionContainer и CompositionNavigationLoader (подробности позже) из-за атрибутов Import. Конструктор принудительно вводит в действие атрибуты Import, используя метод SatisfyImports в CompositionInitializer. Заметьте, что применять этот метод можно только в том случае, когда другого выбора нет, так как он приводит к привязыванию вашего кода к MEF.

Рис. 1. CompositionNavigationBehavior

public class CompositionNavigationBehavior : Behavior<Frame> {
  private bool processed;
  [Import]
  public CompositionContainer Container { 
    get; set; 
  }

  [Import]
  public CompositionNavigationContentLoader Loader { 
    get; set; 
  }

  public CompositionNavigationBehavior() {
    if (!DesignerProperties.IsInDesignTool)
      CompositionInitializer.SatisfyImports(this);
  }

  protected override void OnAttached() {
    base.OnAttached();
    if (!processed) {
       this.RegisterNavigationService();
       this.SetContentLoader();
       processed = true;
    }
  }

  private void RegisterNavigationService() {
    var frame = AssociatedObject;
    var svc = new NavigationService(frame);
    Container.ComposeExportedValue<INavigationService>(svc);
  }

  private void SetContentLoader() {
    var frame = AssociatedObject;
    frame.ContentLoader = Loader;
    frame.JournalOwnership = JournalOwnership.Automatic;
  }
}

Когда Frame подключается, создается NavigationService и обертывается вокруг Frame. Используя ComposeExportedValue, экземпляр этой оболочки регистрируется в контейнере.

После создания контейнера его экземпляр также регистрируется в самом себе. В итоге Import, принадлежащий CompositionContainer, всегда будет давать вам один и тот же объект; вот почему я задействовал ComposeExportedValue в обработчике события Startup класса App. Далее CompositionNavigationBehavior запрашивает CompositionContainer, используя атрибут Import, и получает его после выполнения SatisfyImports.

При регистрации экземпляра INavigationService происходит то же самое. Теперь можно из любой точки приложения запросить INavigationService (который обертывает Frame). Не связывая свой ViewModels с фреймом, вы получаете доступ к следующему:

public interface INavigationService {
  void Navigate(string path);
  void Navigate(string path, params object[] args);
}

Далее предположим, что у вас есть ViewModel, отображающий всех ваших клиентов, и что у этого ViewModel должна быть возможность открыть конкретного клиента. Это делается так:

[Import]
public INavigationService NavigationService { 
  get; set; 
}

private void OnOpenCustomer() {
  NavigationService.Navigate(
    "Customer/{0}", SelectedCustomer.Id);
}

Прежде чем продолжить, давайте обсудим метод SetContentLoader в CompositionNavigationBehavior. Он изменяет ContentLoader, принадлежащий Frame. Это отличный пример поддержки расширяемости в Silverlight. Вы можете предоставить собственный ContentLoader (реализующий интерфейс INavigationContentLoader), чтобы обеспечить отображение чего-либо в Frame.

Теперь, когда у вас начинает складываться первое представление, перейдем к следующей теме — расширению MEF.

Возвращаемся к расширению MEF

Здесь моя цель состоит в том, чтобы показать, как переходить по определенному пути (будь то из ViewModel или с адресной панели браузера), а остальное пусть делает CompositionNavigationLoader. Он должен разобрать URI, найти совпадающий ViewModel и подходящий View, а затем скомбинировать их.

Обычно вы пишете нечто вроде:

[Export(typeof(IMainViewModel))]
public class MainViewModel

В данном случае было бы интересно использовать атрибут Export с дополнительной конфигурационной информацией, которую называют метаданными. Пример атрибута метаданных показан на рис. 2.

Рис. 2. Создание ViewModelExportAttribute

[MetadataAttribute]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class ViewModelExportAttribute : 
  ExportAttribute, IViewModelMetadata {
..public Type ViewModelContract { get; set; }
  public string NavigationPath { get; set; }
  public string Key { get; set; }  

  public ViewModelExportAttribute(Type viewModelContract, 
    string navigationPath) : base(typeof(IViewModel)) {

    this.NavigationPath = navigationPath;
    this.ViewModelContract = viewModelContract;
    if (NavigationPath != null && 
      NavigationPath.Contains("/")) {
      // Split the path to get the arguments.
      var split = NavigationPath.Split(new char[] { '/' }, 
        StringSplitOptions.RemoveEmptyEntries);
      // Get the key.
      Key = split[0];
    }
    else {
      // No arguments, use the whole key.
      Key = NavigationPath;
    }
  }
}

Этот атрибут не делает ничего особенного. В дополнение к интерфейсу ViewModel он позволяет определять навигационный путь, например Customer/Id. Он обработает этот путь, используя Customer как Key и Id как один из аргументов. Вот пример использования этого атрибута:

[ViewModelExport(typeof(ICustomerDetailViewModel), 
  "Customer/{id}")]
public class CustomerDetailViewModel 
  : ICustomerDetailViewModel

Нужно отметить еще несколько важных моментов. Во-первых, ваш атрибут должен быть дополнен атрибутом [MetadataAttribute] для корректной работы. Во-вторых, ваш атрибут должен реализовать интерфейс со значениями, которые вы хотите предоставлять как метаданные. И наконец, помните о конструкторе атрибута — он передает тип базовому конструктору. Доступ к классу, дополненному этим атрибутом, будет обеспечиваться с применением этого типа. В моем примере таковым мог бы быть IViewModel.

С экспортом ViewModels разобрались. Если вам понадобится где-то его импортировать, пишите примерно так:

[ImportMany(typeof(IViewModel))]
public List<Lazy<IViewModel, IViewModelMetadata>> ViewModels { 
  get; 
  set; 
}

Вы получите список, содержащий все экспортированные ViewModels с их метаданными, что позволит вам перечислять список и, возможно, отбирать из него лишь тот, который вас интересует (на основе метаданных). По сути, объект Lazy будет проверять, чтобы создавались экземпляры лишь нужных ViewModels.

В View понадобиться нечто похожее:

[MetadataAttribute]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class ViewExportAttribute : 
  ExportAttribute, IViewMetadata {

  public Type ViewModelContract { get; set; }
  public ViewExportAttribute() : base(typeof(IView)) {
  }
}

В этом примере тоже нет ничего особенного. Этот атрибут позволяет указать в контракте ViewModel, с каким View следует осуществлять связывание.

Вот пример AboutView:

[ViewExport(ViewModelContract = typeof(IAboutViewModel))]
public partial class AboutView : Page, IView {
  public AboutView() {
    InitializeComponent();
  }
}

Собственный INavigationContentLoader

Теперь, когда общая архитектура понятна, рас��мотрим управление тем, что загружается при навигационных переходах пользователя. Чтобы создать свой загрузчик контента, нужно реализовать следующий интерфейс:

public interface INavigationContentLoader {
  IAsyncResult BeginLoad(Uri targetUri, Uri currentUri, 
    AsyncCallback userCallback, object asyncState);
  void CancelLoad(IAsyncResult asyncResult);
  bool CanLoad(Uri targetUri, Uri currentUri);
  LoadResult EndLoad(IAsyncResult asyncResult);
}

Самая важная часть этого интерфейса — метод BeginLoad, так как этот метод должен возвращать AsyncResult, содержащий элемент, который будет отображаться в Frame. Реализация собственного INavigationContentLoader показана на рис. 3.

Рис. 3. Собственный INavigationContentLoader

[Export] public class CompositionNavigationContentLoader : 
  INavigationContentLoader { 
  [ImportMany(typeof(IView))] 
  public IEnumerable<ExportFactory<IView, IViewMetadata>> 
    ViewExports { get; set; }

  [ImportMany(typeof(IViewModel))] 
  public IEnumerable<ExportFactory<IViewModel, IViewModelMetadata>> 
    ViewModelExports { get; set; }  

  public bool CanLoad(Uri targetUri, Uri currentUri) { 
    return true; 
  }  

  public void CancelLoad(IAsyncResult asyncResult) { 
    return; 
  }

  public IAsyncResult BeginLoad(Uri targetUri, Uri currentUri, 
    AsyncCallback userCallback, object asyncState) { 
    // Convert to a dummy relative Uri so we can access the host. 
    var relativeUri = new Uri("http://" + targetUri.OriginalString, 
      UriKind.Absolute);  

    // Get the factory for the ViewModel. 
    var viewModelMapping = ViewModelExports.FirstOrDefault(o => 
      o.Metadata.Key.Equals(relativeUri.Host, 
      StringComparison.OrdinalIgnoreCase)); 

    if (viewModelMapping == null) 
      throw new InvalidOperationException( 
        String.Format("Unable to navigate to: {0}. " +
          "Could not locate the ViewModel.", 
          targetUri.OriginalString));  

    // Get the factory for the View. 
    var viewMapping = ViewExports.FirstOrDefault(o => 
      o.Metadata.ViewModelContract == 
      viewModelMapping.Metadata.ViewModelContract); 

    if (viewMapping == null) 
      throw new InvalidOperationException( 
        String.Format("Unable to navigate to: {0}. " +
          "Could not locate the View.", 
          targetUri.OriginalString));  

    // Resolve both the View and the ViewModel. 
    var viewFactory = viewMapping.CreateExport(); 
    var view = viewFactory.Value as Control; 
    var viewModelFactory = viewModelMapping.CreateExport(); 
    var viewModel = viewModelFactory.Value as IViewModel;  

    // Attach ViewModel to View. 
    view.DataContext = viewModel; 
    viewModel.OnLoaded();  

    // Get navigation values. 
    var values = viewModelMapping.Metadata.GetArgumentValues(targetUri); 
    viewModel.OnNavigated(values);  

    if (view is Page) { 
      Page page = view as Page; 
      page.Title = viewModel.GetTitle(); 
    } 
    else if (view is ChildWindow) { 
      ChildWindow window = view as ChildWindow; 
      window.Title = viewModel.GetTitle(); 
    }  

    // Do not navigate if it's a ChildWindow. 
    if (view is ChildWindow) { 
      ProcessChildWindow(view as ChildWindow, viewModel); 
      return null; 
    } 
    else { 
      // Navigate because it's a Control. 
      var result = new CompositionNavigationAsyncResult(asyncState, view); 
      userCallback(result); 
      return result; 
    } 
  }  

  private void ProcessChildWindow(ChildWindow window, 
    IViewModel viewModel) { 
    // Close the ChildWindow if the ViewModel requests it. 
    var closableViewModel = viewModel as IClosableViewModel; 

    if (closableViewModel != null)  { 
      closableViewModel.CloseView += (s, e) => { window.Close(); }; 
    }  

    // Show the window. 
    window.Show(); 
  }  

  public LoadResult EndLoad(IAsyncResult asyncResult) { 
    return new LoadResult((asyncResult as 
      CompositionNavigationAsyncResult).Result); 
  }
}

Как видите, в этом классе происходит много чего, но на самом деле он прост. Первое, на что обратите внимание, — атрибут Export. Он требуется для поддержки импорта этого класса в CompositionNavigationBehavior.

Самые важные части этого класса — свойства ViewExports и ViewModelExports. Эти перечисления содержат все экспорты для Views и ViewModels, включая их метаданные. Вместо объекта Lazy я использую ExportFactory. Это огромная разница! Оба класса создают экземпляр объекта только при необходимости, но отличие в том, что в случае класса Lazy вы можете создать лишь единственный экземпляр объекта. ExportFactory (имя указывает на применяемый шаблон Factory) — это класс, позволяющий запрашивать новый экземпляр типа объекта в любой момент, когда вы пожелаете.

И пару слов о методе BeginLoad. Именно в нем творится вся магия. Это метод, который предоставляет Frame с контентом, отображаемым после перехода по заданному URI.

Создание и обработка объектов

Допустим, вы указываете фрейму перейти к Customers. Это указание и есть содержимое аргумента targetUri в методе BeginLoad. Позаботившись о нем, вы можете приступить к работе.

Первое, что надо сделать, — найти правильный ViewModel. Свойство ViewModelExports является перечислением, которое содержит все экспорты с их метаданными. Используя лямбда-выражение, правильный ViewModel можно найти по его ключу. Вспомните:

[ViewModelExport(typeof(ICustomersViewModel), "Customers")]
public class CustomersViewModel : 
  ContosoViewModelBase, ICustomersViewModel

Вообразите, что вы переходите к Customers. Тогда следующий код будет искать правильный ViewModel:

var viewModelMapping = ViewModelExports.FirstOrDefault(o => o.Metadata.Key.Equals("Customers", 
  StringComparison.OrdinalIgnoreCase));

Как только ExportFactory найден, то же самое нужно сделать для View. Однако вместо поиска ключа навигации вы ищете ViewModelContract, определенный как в ViewModelExportAttribute, так и в ViewModelAttribute:

[ViewExport(ViewModelContract = typeof(IAboutViewModel))
public partial class AboutView : Page

Как только найдены оба ExportFactory, трудная часть работы позади. Теперь метод CreateExport позволит вас создать новый экземпляр View и ViewModel:

var viewFactory = viewMapping.CreateExport(); 
var view = viewFactory.Value as Control; 
var viewModelFactory = viewModelMapping.CreateExport(); 
var viewModel = viewModelFactory.Value as IViewModel;

После создания View и ViewModel последний сохраняется в DataContext, принадлежащем View, и это запускает необходимые процессы связывания с данными. Далее вызывается ViewModel-метод OnLoaded для уведомления ViewModel, что вся черновая работа проделана и что импорт (если он необходим) завершен.

Вы не должны недооценивать важность этого последнего этапа, когда используете атрибуты Import и ImportMany. Во многих случаях требуется что-то делать после создания ViewModel, но только если все загружено корректно. Если вы используете ImportingConstructor, то четко знаете, когда завершается весь импорт (этот момент определяется вызовом данного конструктора). Но при работе с атрибутами Import/ImportMany вы должны начинать с написания кода во всех нужных вам свойствах, который устанавливает флаги после их импорта; это позволит вам определять, когда импорт всех свойств завершен.

В данном случае эту задачу решает за вас метод OnLoaded.

Передача аргументов в ViewModel

Взгляните на интерфейс IViewModel и обратите внимание на метод OnNavigated:

public interface IViewModel {
  void OnLoaded();
  void OnNavigated(NavigationArguments args);
  string GetTitle();
}

Когда вы переходите, например, к Customers/1, этот путь разбирается и аргументы помещаются в класс NavigationArguments (это просто Dictionary с дополнительными методами вроде GetInt, GetString и т. д.). Поскольку каждый ViewModel обязательно должен реализовать интерфейс IViewModel, можно вызывать метод OnNavigated после разрешения ViewModel:

// Get navigation values. 
var values = viewModelMapping.Metadata.GetArgumentValues(targetUri); viewModel.OnNavigated(values);

Когда CustomersViewModel нужно открыть CustomerDetailViewModel, происходит следующее:

NavigationService.Navigate("Customer/{0}", SelectedCustomer.Id);

Эти аргументы потом передаются в CustomerDetailViewModel и могут быть переданы в DataService, например:

public override void OnNavigated(NavigationArguments args) {
  var id = args.GetInt("Id");
  if (id.HasValue) {
    Customer = DataService.GetCustomerById(id.Value);
  }
}

Чтобы находить аргументы, я написал класс, содержащий два метода расширения, которые выполняют свою работу на основе метаданных ViewModel (рис. 4). Это вновь доказывает, что концепция метаданных в MEF по-настоящему полезна.

image: Extension Methods for Navigation Arguments

Рис. 4. Методы расширения для навигационных аргументов

Финальные аккорды

Если представлением (View) является Page или ChildWindow, из объекта IViewModel будет извлекаться и заголовок этого элемента управления. Это позволяет динамически задавать заголовки ваших страниц и дочерних окон в зависимости от текущего клиента (customer), как показано на рис. 5.

image: Setting a Custom Window Title

Рис. 5. Задание собственного заголовка окна

А теперь остался последний шаг. Если View является ChildWindow, то должно отображаться окно. Но, если ViewModel реализует IClosableViewModel, событие CloseView этого ViewModel должно быть связано с методом Close в ChildWindow.

Интерфейс IClosableViewModel очень прост:

public interface IClosableViewModel : IViewModel {
  event EventHandler CloseView;
}

Обработка ChildWindow тоже тривиальна. Когда ViewModel генерирует событие CloseView, вызывается метод Close класса ChildWindow. Это позволяет косвенно подключать ViewModel к View:

// Close the ChildWindow if the ViewModel requests it.
var closableViewModel = viewModel as IClosableViewModel;
if (closableViewModel != null) {
  closableViewModel.CloseView += (s, e) => { 
    window.Close(); 
  };
}

// Show the window.
window.Show();

Если View не яявляется ChildWindow, тогда он должен быть просто доступен в IAsyncResult и будет показан в Frame.

То что надо. Теперь вы знаете весь процесс конструирования View и ViewModel.

Использование кода примера

Исходный код, который можно скачать для этой статьи, содержит приложение MVVM, использующее этот тип собственной навигации на основе MEF. Решение включает следующие примеры:

  • переход к обычному UserControl;
  • переход к обычному UserControl с передачей аргументов (.../#Employee/DiMattia);
  • переход к ChildWindow с передачей аргументов (.../#Customer/1);
  • импорт INavigationService, IDataService и т. д.;
  • примеры конфигурации ViewExport и ViewModelExport.

Эта статья должна была дать вам хорошее представление о том, как работает этот пример. Для более глубокого понимания поэкспериментируйте с кодом и адаптируйте его под свои приложения. Вы убедитесь, насколько эффективна и гибка инфраструктура MEF.

Сандрино Ди Маттиа (Sandrino Di Mattia) — инженер ПО в RealDolmen, живо интересуется всеми технологиями Microsoft. Активный участник различных групп пользователей, пишет статьи в своем блоге blog.sandrinodimattia.net.

Выражаю благодарность за рецензирование статьи экспертам Глен Блок (Glenn Block) и Дэниелю Плейстиду (Daniel Plaisted)