Jak budować okno ustawień w aplikacji?

Wprowadzenie

Dzisiejszy wpis jest zainspirowany problemem, jaki od czasu do czasu widzę w aplikacjach zespołów programistów, którym pomagam. 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.

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.

Widok ustawień

Tak jak wspomniałem we wstępie, 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ć sobie 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ę.

Darmowy kurs Visual Studio

Pracując z setkami programistów, zauważyłem, że większość osób nie pracuje efektywnie w Visual Studio. W skrajnych przypadkach korzystali z kopiowania z wykorzystaniem menu Edit. Wiem, że to dziwne, ale naprawdę niektórzy tak pracują. Dlatego postanowiłem stworzyć kurs Visual Studio – aby pomóc koleżankom i kolegom w efektywniejszej pracy.

Przygotowałem 30 lekcji e-mail, w których pokażę Ci, w jaki sposób pracować efektywniej i szybciej w Visual Studio. Poznasz dodatki, bez których nie wyobrażam sobie pracy w tym IDE.

Po więcej informacji zapraszam na dedykowaną stronę kursu: Darmowy Kurs Visual Studio.

Quiz C#

Ostatnio przygotowałem również quiz C#, w którym możesz sprawdzić swoją wiedzę. Podejmiesz wyzwanie?

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="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
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();
}
}
view raw App.cs hosted with ❤ by GitHub

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ę:

wpf settings window

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:

wpf settings window 2

Przykład

Na githubie (https://github.com/danielplawgo/OpenClosePrinciple) znajdziesz przykład, który przygotowałem na potrzeby dzisiejszego wpisu. 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.

Szkolenie C# i .NET 5

Szkolenie C# i .NET 5

Zainteresował Ciebie ten temat? A może chcesz więcej? Jak tak to zapraszam na moje autorskie szkolenie o C# oraz .NET.

4 thoughts on “Jak budować okno ustawień w aplikacji?

  • Pingback: dotnetomaniak.pl
  • Osobiście wydaje mi się, że takie rowiązanie nie ma racji bytu. Trudno mi sobie wyobrazić, że odpowiedzialnością widoku jest to czy może być wyświetlony czy nie. Zabija to reużywalność widoków, oddziela od logiki ViewModelu czy Controlera i przypadków użycia. Zupełnie pomija to komendy (np. DelegateCommand), które mają metodę CanExecute. Ttyp double właściwości OrderNumber dopełnia tego wszystkiego, bo to, że widok ma decydować o tym w jakiej kolejności jest wyświetlany na jakiejś liście opcji i każe ustawiać jakąś wartość programiście dodającemu nowy widok wygląda słabo.

    • Hej Janko!

      Dzięki za komentarz! Oczywiście masz rację. Moim celem w tym wpisie było pokazanie problemu (sporo osób, z którymi pracowałem nie widziało problemu na przykład w tym, że moduł ustawień musi referować do wszystkich modułów aplikacji). Do tego pokazanie jakiegoś możliwego kierunku rozwiązania go, bez zbytniego rozbudowania przykładu/wpisu oraz skupiwania się na konkretnej technologii.

      Jeszcze raz dzięki za komentarz!

  • Bardzo fajnie opisujesz, jednak dla mnie trochę za trudna sprawa. 🙂 jestem na początku nauki wiec muszę się cofnąć do samego początku bloga żeby nauczyć się podstaw 🙂 Ale bardzo dużo piszesz co jest dużym plusem, rzadko tak się zdarzą żeby mieć tyle pomysłów na wartościowe wpisy.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.