Prism - Modularne aplikacje WPF

Udostępnij na: Facebook

Autor: Piotr Zieliński

Opublikowano: 2012-01-25

Wprowadzenie

Artykuł ma na celu łagodne wprowadzenie czytelnika w świat PRISM. Dokumentacja, dostępna aktualnie na CodePlex lub MSDN, jest moim zdaniem zbyt skomplikowana dla osoby, która chce się w skrócie dowiedzieć, co oferuje programistom ten bardzo rozbudowany framework.

PRISM przeznaczony jest dla programistów WPF, Silverlight oraz Windows Phone 7. Proste aplikacje (np. prototypy) prawdopodobnie nie zyskają na wdrożeniu PRISM. Framework ten przeznaczony jest dla skomplikowanego, modularnego oprogramowania, które jest rozwijane niezależnie przez grupę developerów. Aby dowiedzieć się, dlaczego warto stosować PRISM, należy sprecyzować kilka głównych i najczęstszych wyzwań warstwy prezentacji:

  • Logika przepływu oraz aktualizacja stanu muszą zostać przetestowane z użyciem zautomatyzowanych testów.
  • Aplikacja składa się z różnych modułów, często ładowanych na żądanie – interfejs musi się stosownie zmieniać.
  • Moduły rozwijane są przez różnych programistów (lub nawet przez różne zespoły) i muszą ze sobą współpracować. Wysoka elastyczność nie wymaga dostępu do kodu konkretnego widoku, z którym pisany moduł musi współpracować – w praktyce oznacza to np. współpracę za pomocą czystego interfejsu, np. IPersonView.
  • Aby uruchomić aplikację, nie trzeba mieć wszystkich modułów – architektura musi umożliwiać załadowanie dowolnych bibliotek – luźne powiązania.
  • Współpraca między modułami odbywa się w sposób luźny i kontrolowany, co ma na celu ułatwienie testowania, np. za pomocą wstrzyknięcia zależności.

Wśród programistów, aplikacje, spełniające powyższe kryteria, nazywane są *Composite Applications.*Powyższa architektura wymaga większych nakładów czasu, szczególnie na początku. Z tego względu w bardzo prostych aplikacjach taka inwestycja może się nie zwrócić. PRISM szczególnie należy stosować, jeśli:

  • system jest rozwijany latami – sprawa oczywista, testowalność i jakość mają kluczowe znaczenie, aby nie zaczynać projektu po kilku latach od nowa,
  • nastąpią możliwe nieoczekiwane zmiany klienta – zmiana wymagań klienta jest problemem wielu zespołów programistycznych; jeśli na pewnym etapie projektu, klient zdecyduje się na zmianę założeń lub dodanie nowej funkcjonalności (np. z powodu zainteresowania projektem i chęcią jego rozwoju), słaba architektura spowoduje problemy i doprowadzi do plątaniny w kodzie - w żargonie nazywanym spaghetti code,
  • nastąpi wprowadzenie nowej technologii; co będzie, jeśli pewnego dnia okaże się, że niektóre rzeczy da się zrobić prościej z powodu dostępności lepszej technologii (np. jakieś biblioteki)? w przypadku silnych wiązań będzie to trudne i często spowoduje zakłócenie współpracy z innymi modułami, bądź też konieczność odświeżenia referencji,
  • wielu developerów będzie musiało współpracować nad poszczególnymi elementami warstwy prezentacji, które dość mocno są ze sobą powiązane (problem z podziałem pracy między developerami).

Bibliotekę, dokumentację oraz wszystkie wymagane pliki można ściągnąć z https://compositewpf.codeplex.com.

Podstawowe pojęcia

Na tym etapie powinieneś już wiedzieć, do czego służy PRISM. Dobrym słowem, opisującym powyższe założenia, jest modularność – tak naprawdę wszystkie wysiłki mają na celu osiągnięcie tej cechy. W następnych sekcjach oraz w artykułach dowiesz się dokładnie, jak PRISM radzi sobie z tymi wyzwaniami. Najpierw jednak należy poznać kilka podstawowych pojęć, które w dokumentacji przewijają się na każdej stronie:

  • Moduły – podstawowa jednostka funkcjonalności. Moduł stanowi część, która może być samodzielnie (niezależnie) pisana, testowana a nawet wdrażana.
  • Katalog modułów – jak sama nazwa wskazuje, stanowi zbiór modułów. Dzięki katalogowi, PRISM wie, kiedy ma załadować moduł, skąd (zdalnie czy lokalnie) oraz w jakiej kolejności. Katalogi mogą być zdefiniowane za pomocą kodu, pliku konfiguracyjnego lub XAML.
  • Shell – główny element całej aplikacji. Wizualnie reprezentuje załadowane moduły – to właśnie do shell każdy z modułów doczepia swoją wizualizację (widok).
  • Widok – najczęściej czysty plik XAML, prezentujący interfejs użytkownika – widok nie zawiera logiki biznesowej.
  • ViewModel, Presenter – klasy specyficzne dla danego wzorca projektowego. ViewModel kojarzony jest z wzorcem MVVM, z kolei Presenter z MVP.
  • Model – model zawiera logikę biznesową, walidację itp.
  • Komendy – w WPF komendy realizowane są poprzez klasy implementujące ICommand. Prism dostarcza kilku implementacji, np. CompositeCommand.
  • Regiony – miejsca (np. w shell lub widokach), w których inne widoki będą wstrzykiwane. Regiony można określać w różnych kontrolkach, np. w TabControl. W przypadku TabControl region zwykle oznacza zakładkę. Każdy załadowany moduł będzie więc wstrzykiwany dynamicznie w formie zakładki.
  • Nawigacja – Prism wspiera dwa typy nawigacji. Pierwszy sposób polega na zmianie stanu pojedynczego widoku – w zależności od danego stanu wyświetlane są specyficzne składowe widoku. Z kolei drugi mechanizm polega na tworzeniu nowego widoku i zastąpienie nim aktualnie wyświetlanego.
  • EventAggregator – poszczególne komponenty, w aplikacji modularnej, prawdopodobnie będą musiały się komunikować. Aby nie tworzyć sztywnych referencji, Prism wprowadza EventAggregator, umożliwiający luźno powiązaną komunikację, opartą na mechanizmie publikacji i subskrypcji. Idea jest prosta, komponent A dokonuje subskrypcji na danym agregatorze. Następnie komponent B publikuje zdarzenie wraz z dodatkowymi informacjami (np. typ zdarzenia itp.). Po publikacji komponent A otrzyma (ponieważ jest zasubskrybowany) daną informację.
  • IoC – PRISM potrzebuje mechanizmu do wstrzykiwania zależności – jest to podstawowa kwestia w aplikacjach modularnych. Framework może współpracować w zasadzie z dowolnymi bibliotekami IoC, jednak najpopularniejszym sposobem jest wykorzystanie UnityContainer.
  • Usługi (Services) – są to klasy implementujące logikę, która nie jest związana z UI – np. wykonywanie logów części dologowania, dostęp do danych, wysłanie e-mail itp. Dobrą praktyką jest umieszczanie usług w kontenerze (IoC), umożliwiając potem podpięcie Mock**.**
  • Bootstrapper – klasa, którą inicjalizują różne komponenty Prism – moduły, katalog modułów, usługi, kontenery itp.
  • Wieloplatformowość (multi-targeting) – Prism wspiera projektowanie aplikacji, które wykorzystują zarówno Silverlight jak i WPF. Oddzielając czystą warstwę prezentacji (Silverlight, WPF) od logiki biznesowej oraz przypływu screenów, można stworzyć aplikację, która będzie oparta zarówno na Silverlight jak i WPF.

Tworzenie modularnej aplikacji

Aby stworzyć aplikację z użyciem PRISM nie trzeba wykorzystywać wszystkich opisanych powyżej mechanizmów. W większości przypadków można wyróżnić kilka zasadniczych etapów:

  • utworzenie projektu shell,
  • zdefiniowanie widoku dla shell – jest to główne okno aplikacji, w którym będą doczepiane różne widoki poszczególnych modułów (top-level view),
  • dodanie regionów do shell – w tym miejscu będą pojawiać się widoki z załadowanych modułów (tzw. placeholders).

Po utworzeniu shell, utwórz wspomniany bootstrapper, który załaduje moduły i ewentualnie utworzy współdzielone komendy oraz zdarzenia. Bootstrapper dziedziczy z UnityBootstrapper lub MefBootstrapper.

Implementacja poszczególnych modułów zwykle polega na utworzeniu osobnego projektu, zdefiniowaniu widoków oraz usług. Moduł musi implementować interfejs IModule. Aby wstrzyknąć swój widok do shell, moduły wykorzystują klasę RegionManager.

Przykład inicjalizacji

Spróbuj, zgodnie z powyższym wprowadzeniem teoretycznym, stworzyć prostą aplikację modularną, opartą na Prism.

  • Bootstrapper

Implementacja bootstrapper sprowadza się do przeładowania kilku wirtualnych metod. Jak już zostało wspomniane, utwórz najpierw klasę dziedziczącą po MefBootstrapper lub UnityBootstrapper. W artykule posłużono się UnityBootStrapper (należy pamiętać o podłączeniu bibliotek Microsoft.Practices.Prism.UnityExtensions oraz Microsoft.Practices.Prism).

Podstawowym, niezbędnym elementem jest przeładowanie abstrakcyjnej metody CreateShell, która zwraca widok shell (w następnych sekcjach zobaczysz, jak zdefiniować widok shell):

class PrismBootStrapper : UnityBootstrapper
{
        protected override System.Windows.DependencyObject CreateShell()
        {
            return new Shell();
        }
    }

Kolejną kwestią jest konfiguracja shell za pomocą przeładowania metody InitializeShell. W tym miejscu zwykle należy podpiąć widok. Musisz to zrobić samodzielnie, ponieważ implementacja różni się w zależności od tego, czy masz do czynienia z Silverlight, czy z WPF.

Silverlight:

protected override void InitializeShell()
{
    Application.Current.RootVisual = Shell;
}

WPF:

protected override void InitializeShell()
{
    Application.Current.MainWindow = Shell;
    Application.Current.MainWindow.Show();
}
  • Konfiguracja kontenera Unity (dotyczy UnityBootstrapper) – etap opcjonalny

Kontenery służą do zarządzania instancjami obiektów. Możesz inicjalizować kontener w metodzie ConfigureContainer. Domyślnie inicjalizowanych jest wiele standardowych usług m.in.: IServiceLocator, IModuleManager, IModuleCatalog, ILoggerFacade. Aby skonfigurować kontener, wystarczy przeładować wspomnianą metodę ConfigureContainer:

protected override void ConfigureContainer()
{
    base.ConfigureContainer();

    this.RegisterTypeIfMissing(typeof(IInterface), typeof(SampleClass), true);
}
  • Konfiguracja kontenera w MefBootstrapper – dotyczy tylko implementacji z MEF

W przypadku MEF, sytuacja wygląda bardzo podobnie – należy przeładować metodę ConfigureContainer i za pomocą właściwości Container zarejestrować stosowne instancje. Domyślna implementacja (którą można przeładować i rozszerzyć) wygląda następująco:

protected virtual void ConfigureContainer()
{
    this.RegisterBootstrapperProvidedTypes();
}

protected virtual void RegisterBootstrapperProvidedTypes()
{
    this.Container.ComposeExportedValue<ILoggerFacade>(this.Logger);
    this.Container.ComposeExportedValue<IModuleCatalog>(this.ModuleCatalog);
    this.Container.ComposeExportedValue<IServiceLocator>(new MefServiceLocatorAdapter(this.Container));
    this.Container.ComposeExportedValue<AggregateCatalog>(this.AggregateCatalog);
}

Wdrożenie bootstrapper

Na tym etapie bootstrapper jest już utworzony i skonfigurowany. Następnie należy go w odpowiedni sposób połączyć z aplikacją – w tej chwili stanowi osobną, odizolowaną klasę. Doskonałym miejscem na jego uruchomienie jest plik App.xaml.cs oraz metoda OnStartup, uruchamiana przy starcie aplikacji:

public partial class App : Application
{
        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);
            PrismBootStrapper bootstrapper = new PrismBootStrapper();
            bootstrapper.Run();
        }
}

Domyślnie plik App.xaml zawiera atrybut StartupUri, wskazujący okno, które pojawi się po uruchomieniu aplikacji. W przypadku PRISM, bootstrapper powinien być odpowiedzialny za inicjalizowanie okna (Shell), w związku z tym musisz usunąć podany atrybut:

<Application x:Class="PrismIntro.App"
            xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
             StartupUri="Shell.xaml">
    <Application.Resources>         
    </Application.Resources>
</Application>

Wersja App.xaml po usunięciu atrybutu StartupUri wygląda następująco:

Application x:Class="PrismIntro.App"
            xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml">
    <Application.Resources>         
    </Application.Resources>
</Application>

Utworzenie okna Shell

Podczas definiowania bootstrapper, zostało wspomniane o oknie shell. Shell stanowi główne okno aplikacji, w której ładowane są dynamiczne, pozostałe widoki, specyficzne dla konkretnych modułów. Za pomocą regionów określa się, w których miejscach i w jakiej formie będą dodawane zasoby (widoki) załadowanych modułów. Region definiuje się za pomocą właściwości (attached property) RegionName. Przykład:

<Window x:Class="PrismIntro.Shell"
        xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:cal="https://www.codeplex.com/CompositeWPF"
        Title="MainWindow" Height="350" Width="525">
    <TabControl Name="MainRegion" cal:RegionManager.RegionName="MainRegion" />
</Window>

Nowe moduły (widoki) będą dołączane w formie zakładki w TabControl. -> Dobrym zwyczajem jest stworzenie osobnej klasy, zawierającej nazwy regionów, a następnie po prostu je  bindować. W ten sposób programista ustrzeże się przed literówkami – za pomocą nazwy następnie wstrzykiwane są widoki (patrz: następne sekcje). Warto wspomnieć również o tzw. adapterach – klasy, które mapują region do kontrolki. Adaptery definiują, w jaki sposób nowe widoki będą pojawiać się w shell. W przypadku TabControlRegionAdapter, moduły ładowane są w formie zakładek. Można tworzyć  własne regiony i adaptery.

PRISM dostarcza następujące adaptery:

  • ContentControlRegionAdapter – dla kontrolek, które pochodzą od System.Windows.Controls.ContentControl.
  • SelectorRegionAdapter – dla kontrolek, które pochodzą od System.Windows.Controls.Primitives.Selector (np. TabControl).
  • ItemsControlRegionAdapter – dla kontrolek typu System.Windows.Controls.ItemsControl.

Za pomocą atrybutu można również przekazać kontekst regionu np.:

<ItemsControl Name="MainRegion" cal:RegionManager.RegionName="MainRegion" cal:RegionManager.RegionContext="wartosc" />

Kontekst to dowolna wartość: string, liczba lub obiekt. Przeważnie za pomocą wiązania przekazuje się encję, na której potem moduł operuje. Moduł może uzyskać wartość kontekstu, za pomocą statycznej metody RegionContext.GetObservableContext, przekazując widok:

RegionContext.GetObservableContext(view)

Utworzenie modułu

Bootstrapper oraz shell zostały utworzone. Czas na implementację modułu, który zostanie dynamicznie wstrzyknięty do regionu w oknie shell. Każdy moduł to zwykle osobna biblioteka dll, która zawiera klasę implementującą interfejs IModule (PRISM), widoki (XAML) oraz inne klasy, zależne już od przyjętego wzorca projektowego (zwykle jest to MVVM). Zacznij od utworzenia pustego modułu:

public class FirstModule:IModule
    {
        public void Initialize()
        {
            throw new NotImplementedException();
        }
    }

Jedyną wymaganą metodą jest Initliaze. Jak sama nazwa sugeruje, służy ona do inicjalizacji modułu, a konkretnie na podpięcie go do stosownego regionu (placeholder) w shell. Zatem w Initialize powinien zostać umieszczony kod podpinający wszelkie widoki (views) do konkretnych regionów. Warto zaznaczyć, że istnieją dwa sposoby kojarzenia widoków z regionem:

  • View Discovery (odkrywanie widoku) – inaczej podejście niejawne. Za pomocą klasy RegionViewRegistry tworzony jest rejestr skojarzeń pomiędzy widokiem a regionem. W mapowaniach nie należy podawać jednak konkretnych instancji klas, a nazwę regionu i typ widoku – instancje będą utworzone w momencie, gdy zajdzie taka potrzeba (a konkretniej, gdy dany region będzie wyświetlony użytkownikowi). Przykład:
regionViewRegistry.RegisterViewWithRegion("MainRegion",typeof(Views.FirstModuleView));
  • View Injection (wstrzyknięcie widoku) – inaczej podejście jawne. Programista jest odpowiedzialny za znalezienie konkretnej instancji regionu i widoku. Sygnatura metody odpowiedzialnej za wstrzyknięcie widoku przyjmuje więc instancję obiektu jako parametr:
IRegionManager.Regions[stirng regionName].Add(view);

W przykładowej aplikacji zostało użyte podejście View Discovery, dlatego implementacja modułu wygląda następująco:

public class FirstModule:IModule
    {
        private readonly IRegionViewRegistry _regionViewRegistry=null;

        public FirstModule(IRegionViewRegistry regionViewRegistry)
        {
            _regionViewRegistry=regionViewRegistry;
        }
        public void Initialize()
        {
            _regionViewRegistry.RegisterViewWithRegion("MainRegion", typeof(Views.FirstModuleView));
        }
    }

Rejestr widoków jest przekazywany w konstruktorze (instancja zostanie automatycznie przekazana z kontenera podczas wywołania metody Resolve). Nazwa regionu (MainRegion) zostanie zdefiniowana w głównym oknie Shell.xaml. Z kolei FirstModuleView to jeden z przykładowych widoków modułu. Widoki definiuje się jako zwykłe kontrolki WPF:

<UserControl x:Class="Module1.Views.FirstModuleView"
             xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="https://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="https://schemas.microsoft.com/expression/blend/2008" 
             mc:Ignorable="d" 
             d:DesignHeight="300" d:DesignWidth="300">
    <Grid>
        <TextBlock Text="Witaj świecie!"   FontSize="20"/>
    </Grid>
</UserControl>

Utworzenie katalogu

Katalog w PRISM zawiera moduły. Definiuje się go w bootstrapper, a konkretnie w metodzie CreateModuleCatalog. Programista może zdefiniować katalog samodzielnie, dodając kolejne instancje modułów. Takie rozwiązanie jest poprawne, jednak bardzo nieeleganckie – PRISM został stworzony z myślą o dynamicznie generowanym interfejsie. Lepszym podejściem jest stworzenie katalogu modułów na podstawie folderu Windows, zawierającego biblioteki DLL. PRISM samodzielnie sprawdzi, jakie biblioteki są w danym folderze i je następnie załaduje. Aby osiągnąć podany efekt, wystarczy przeładować metodę CreateModuleCatalog i zwrócić odpowiednią instancję DirectoryModuleCatalog:

class PrismBootStrapper : UnityBootstrapper
    {
        protected override System.Windows.DependencyObject CreateShell()
        {
            return new Shell();
        }
        protected override void InitializeShell()
        {
            Application.Current.MainWindow = (Window)Shell;
            Application.Current.MainWindow.Show();
        }
        protected override IModuleCatalog CreateModuleCatalog()
        {
            return new DirectoryModuleCatalog() { ModulePath = @"Modules" };
        }
    }

Wystarczy, że do folderu Modules skopiujesz zaimplementowane moduły (w przypadku, jaki został poruszony w tym artykule, jest to jeden moduł). Po uruchomieniu aplikacji moduł zostanie dynamicznie załadowany wraz z interfejsem. Pokazany Shell składa się z TabControl, więc aplikacja będzie zawierała pojedynczą zakładkę z napisem „Hello World”.

Zakończenie

PRISM dostarcza mechanizm, który umożliwia budowanie modularnych aplikacji WPF, Silverlight oraz Windows Phone. W artykule pokazano, jak stworzyć prostą aplikację, która dynamicznie tworzy interfejs na podstawie bibliotek, znajdujących się w danym folderze. Przedstawione informacje to jednak wyłącznie próbka możliwości PRISM. Z pewnością powstaną następne części artykułu, prezentujące m.in. zastosowanie zdarzeń, komend czy usług.Dzięki zastosowaniu się do wytycznych zdefiniowanych przez PRISM (guidance), programista może uzyskać łatwy w utrzymaniu i testowalny kod. Ze względu na wiele poruszanych spraw, do artykułu został dołączony kod źródłowy, który z pewnością ułatwi połączenie przedstawionej wiedzy w całość.

 


          

Piotr Zieliński

Absolwent informatyki o specjalizacji inżynieria oprogramowania Uniwersytetu Zielonogórskiego. Posiada szereg certyfikatów z technologii Microsoft (MCP, MCTS, MCPD). W 2011 roku wyróżniony nagrodą MVP w kategorii Visual C#. Aktualnie pracuje w General Electric pisząc oprogramowanie wykorzystywane w monitorowaniu transformatorów . Platformę .NET zna od wersji 1.1 – wcześniej wykorzystywał głównie MFC oraz C++ Builder. Interesuje się wieloma technologiami m.in. ASP.NET MVC, WPF, PRISM, WCF, WCF Data Services, WWF, Azure, Silverlight, WCF RIA Services, XNA, Entity Framework, nHibernate. Oprócz czystych technologii zajmuje się również wzorcami projektowymi, bezpieczeństwem aplikacji webowych i testowaniem oprogramowania od strony programisty. W wolnych chwilach prowadzi blog o .NET i tzw. patterns & practices.