Kod zamknięty na modyfikację, a otwarty na zmiany. Jak zbudować okno ustawień?
W prawie każdej aplikacji, wcześniej czy później, znajdziemy jakiś widok ustawień. Na ogół znajdują się w nim przyciski pozwalające otworzyć kolejne okna/widoki, w których użytkownik może zmienić konfigurację aplikacji. Bardzo często, gdy zaczynamy tworzyć aplikację, liczba ustawień jest dość mała, ale z czasem bardzo się rozrasta. Do tego dochodzi rozbudowana logika, który chowa i pokazuje opcje na podstawie uprawnień użytkownika lub załadowanych modułów aplikacji.
W efekcie powstaje mały potworek, który musi zostać zmieniony podczas, na przykład, dodawania nowej funkcjonalności. Bardzo często w tym miejscu łamiemy regułę otwarte–zamknięte i utrzymanie tej części aplikacji jest dość trudne.
Daniel Plawgo. .NET Developer w SoftwareHut, Trainer, Consultant, Bloger (plawgo.pl). Od wielu lat jest związany ze społecznością Microsoft. Początkowo był Student Partnerem i prowadził grupę .NET Eastgroup na Wydziałe Matematyki i Informatyki UWM. Później był Student Consultantem odpowiedzialnym za studenckie grupy .NET z północnej Polski. Od prawie 10 lat prowadzę zawodową grupę .NET OLMUG u nas w Olsztynie.
W tym wpisie chciałbym Ci pokazać trochę inne podejście do tworzenia takich okien ustawień. Tak naprawdę jest to przykład pokazujący, jak w praktyce można zastosować regułę otwarte–zamknięte, aby nasz kod był zamknięty na modyfikację, a otwarty na zmiany. Dodanie nowego widoku z ustawieniami będzie sprowadzało się do dodania nowej klasy, która będzie implementowała odpowiedni interfejs. W aplikacji automatycznie pojawi się ta opcja, bez konieczności zmiany istniejącego kodu. W przykładzie posłużę się aplikacją WPF, ale to podejście można zastosować w innych typach aplikacji oraz w innych miejscach w kodzie.
Spis treści
Widok ustawień
Tak jak wspomniałem, nie chcemy, aby okno ustawień wiedziało o wszystkich możliwych widokach oraz zawierało logikę wyświetlania. Utrzymanie tego staje się z czasem dość problematyczne, dlatego spróbujemy troszeczkę odwrócić zależność i napisać okno ustawień tak, aby w praktyce nie trzeba było nic w nim zmieniać, w szczególności podczas dodawania nowego widoku ustawień.
W tym celu warto zdefiniować interfejs, który będzie implementował wszystkie widoki ustawień. Oto jak może wyglądać przykładowy interfejs:
public interface ISettingsView { string Title { get; } double OrderNumber { get; } void Show(); bool CanShow(ApplicationContext context); }
Zawiera on dwie właściwości oraz dwie metody. Właściwość Title
to nazwa widoku ustawień. Wykorzystamy ją do wyświetlenia tekstu w przycisku i później jako sam tytuł widoku ustawień. OrderNumber
służy do ustalania kolejności przycisków w oknie ustawień. Zwróć uwagę, że tutaj użyłem typu double, dzięki czemu później można zawsze bez problemu wstawić kolejny widok ustawień między dwa już istniejące, bez konieczności zmiany tej właściwości w istniejących widokach (jak byłoby to w przypadku użycia typu int).
Metoda Show
pokazuje widok ustawień. W niej, poza pokazaniem okna, będziemy również ładowali dane potrzebne w widoku. Ostatnia metoda, CanShow
, służy do zdecydowania, czy dany widok powinien być wyświetlony, czy nie. W tym rozwiązaniu przenosimy logikę chowania/pokazywania z okna ustawień do poszczególnych widoków. To one będą decydowały, kiedy mają być widoczne. Dzięki temu cały kod związany z określonym widokiem będziemy mieli w jednym miejscu. A do tego bardzo uprości się samo okno ustawień.
Do metody CanShow
przekazujemy obiekt reprezentujący kontekst aplikacji, w którym możemy mieć informacje o zalogowanym użytkowniku, uprawnieniach, używanych modułach aplikacji itp. W przykładzie wygląda on tak:
public class ApplicationContext { public bool IsAdmin { get; set; } }
W realnej aplikacji wyglądałby on trochę inaczej, tutaj jednak chodzi bardziej o samą ideę.
Dwa testowe widoki
W aplikacji dodałem dwa testowe widoki, które zawierają bardzo proste implementacje interfejsu ISettingsView. Są to puste okna z WPF-a, które nic istotnego nie wyświetlają, a jedynie implementują nasz interfejs:
public partial class GeneralSettingsView : Window, ISettingsView { public GeneralSettingsView() { Title = "General Settings"; InitializeComponent(); } public double OrderNumber => 1; public bool CanShow(ApplicationContext context) { return true; } }
public partial class ChangePasswordView : Window, ISettingsView { public ChangePasswordView() { Title = "Change Password"; InitializeComponent(); } public double OrderNumber => 2; public bool CanShow(ApplicationContext context) { return true; } }
W zależności od potrzeb interfejs może zawierać inne dodatkowe właściwości lub metody. Jeden z projektów, który robiłem, wymagał pogrupowania widoków w grupy, dlatego utworzyliśmy enuma dla grup i każdy z widoków określał, w której grupie jest. W innym projekcie potrzebowaliśmy wyświetlać ikonki w przyciskach, wtedy też dodaliśmy odpowiednią właściwość w interfejsie.
Okno ustawień
W oknie ustawień wykorzystamy to, że widoki implementują interfejs ISettingsView
. Jako zależność okna przekażemy listę obiektów implementujących ten interfejs, którą następnie okno wyświetli w formie przycisków. Następnie użytkownik będzie mógł kliknąć któryś z nich, a okno pokaże odpowiedni widok.
Kod okna ustawień wygląda tak:
public partial class SettingsWindow : Window, ISettingsWindowWindow { public ObservableCollection<ISettingsView> SettingsViews { get; set; } public SettingsWindow(IEnumerable<ISettingsView> settingsViews, ApplicationContext applicationContext) { InitializeComponent(); var processedSettingsViews = settingsViews .Where(v => v.CanShow(applicationContext)) .OrderBy(v => v.OrderNumber); SettingsViews = new ObservableCollection<ISettingsView>(processedSettingsViews); DataContext = this; } private void Button_Click(object sender, RoutedEventArgs e) { var button = sender as Button; if(button == null) { return; } var view = button.DataContext as ISettingsView; if(view == null) { return; } view.Show(); } } public interface ISettingsWindowWindow { void Show(); }
Po wstrzyknięciu listy z widokami ustawień do konstruktora, filtrujemy tę listę poprzez wywołanie metody CanShow
poszczególnych widoków. Tak jak wspomniałem, w tym przypadku to widok będzie decydował, czy ma się wyświetlić, a nie samo okno ustawień. Następnie sortujemy listę widoków z wykorzystaniem właściwości OrderNumber
.
Mając już przygotowaną listę widoków do wyświetlenia, zapisuję ją jako kolekcję obserwowalną i całe okno ustawiam jako kontekst danych, aby za chwilę móc zbindować się do tej listy w xamlu.
Sam xaml wygląda tak:
<Window x_Class="OpenClosePrinciple.SettingsWindow" xmlns_x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns_d="http://schemas.microsoft.com/expression/blend/2008" xmlns_mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns_local="clr-namespace:OpenClosePrinciple" mc_Ignorable="d" Title="Open Close Principle" Height="450" Width="800"> <Grid> <ListBox ItemsSource="{Binding SettingsViews}" HorizontalContentAlignment="Stretch"> <ListBox.ItemTemplate> <DataTemplate> <Button Content="{Binding Title}" Click="Button_Click" /> </DataTemplate> </ListBox.ItemTemplate> </ListBox> </Grid> </Window>
Tutaj nie dzieje się nic wielkiego; znajduje się tu prosta lista, która dla każdego widoku ustawień wyświetla przycisk z nazwą widoku.
W kodzie behind okna znajduje się metoda obsługi kliknięcia przycisku z nazwą widoku ustawień. Wyciąga ona z kontekstu danych widok i wywołuje na nim metodę Show
. W efekcie użytkownik zobaczy wybrany widok ustawień.
Kod przykładu w tym miejscu jest daleki od ideału. Można by tutaj wykorzystać viewmodele
, ale nie chciałem dodatkowo komplikować przykładu, a jedynie skupić się na tym, jak można podejść do tego w trochę inny sposób, nie łamiąc przy okazji reguły otwarte–zamknięte.
Użycie kontenera Autofac
Kluczem w tym przykładzie oraz podejściu jest to, że jakiś mechanizm (w tym przypadku autorejestracja z kontenera Autofac) będzie automatycznie wyszukiwał wszystkie implementacje interfejsu ISettingsView
i wstrzykiwał je do okna ustawień. Dzięki temu później, aby dodać nowy widok, będziemy musieli tylko dodać klasę, która implementuje ISettingsView
.
W przykładzie użycie kontenera znajduje się w klasie App:
public partial class App : Application { protected IContainer BuildContainer() { var builder = new ContainerBuilder(); builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly()) .AsImplementedInterfaces(); var applicationContext = new ApplicationContext() { IsAdmin = true }; builder.RegisterInstance(applicationContext) .AsSelf(); return builder.Build(); } protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); var container = BuildContainer(); MainWindow = container.Resolve<ISettingsWindowWindow>() as Window; MainWindow.Show(); } }
Metoda BuildContainer
buduje instancje kontenera Autofac. Po pierwsze rejestruje wszystkie typy z aplikacji pod ich interfejsami. Właśnie ta autorejestracja spowoduje, że wszystkie widoki ustawień zostaną rejestrowane w kontenerze i później wyświetlone w oknie ustawień. Dodatkowo w tym miejscu rejestrujemy jeszcze instancje klasy kontekstu aplikacji, która jest przekazywana do metody CanShow
poszczególnych widoków.
W metodzie OnStartup
tworzę instancję kontenera poprzez wywołanie metody BuildContainer
. Następnie za pomocą kontenera tworzę instancję okna ustawień. Na końcu okno jest pokazywane użytkownikowi i ustawione jako główne okno aplikacji, aby jego zamknięcie powodowało również zamknięcie programu.
W efekcie otrzymamy taką aplikację:
Program może nie wygląda specjalnie imponująco, ale istotne tutaj jest to, co się dzieje pod spodem.
Dodanie nowego widoku ustawień
Mając tak przygotowaną aplikację, możemy już sprawdzić, jak fajnie się ona zachowa, gdy dodamy nowy widok ustawień. Na przykład widok ustawień administracyjnych aplikacji, dostępny tylko dla administratora.
Utworzyłem prosty widok AdminSettingsView
, którego kod wygląda tak:
public partial class AdminSettingsView : Window, ISettingsView { public AdminSettingsView() { InitializeComponent(); } public double OrderNumber => 10; public bool CanShow(ApplicationContext context) { return context.IsAdmin; } }
Po utworzeniu nowego okna WPF dodałem w nim implementację interfejsu ISettingsView
(właściwość OrderNumber
oraz metodę CanShow
).
Już w tym momencie w aplikacji pojawi się nowy widok. Nie trzeba robić nic więcej:
Przykład
Na githubie znajdziesz przykład, który przygotowałem na potrzeby tego artykułu. Po jego pobraniu można od razu uruchomić aplikację.
Podsumowanie
Widok ustawień aplikacji jest fajnym miejscem, w którym można zastosować regułę otwarte–zamknięte. Jest to szczególnie przydatne, gdy Twoja aplikacja jest rozbudowana i składa się z wielu niezależnych modułów. Wtedy moduł ustawień nie musi zależeć od innych modułów aplikacji. Wystarczy tylko, że interfejs ISettingsView
jest zdefiniowany we wspólnym miejscu, a i tak okno ustawień wyświetli wszystkie potrzebne widoki z całej aplikacji.
Jeśli interesuje Cię reguła otwarte–zamknięte, to zapraszam do przeczytania innego wpisu, w którym ta reguła jest pokazana – jak zastąpić rozbudowanego switcha w aplikacji.
Artykuł pierwotnie ukazał się na plawgo.pl. Zdjęcie główne artykułu pochodzi z unsplash.com.