Architektura dokument-widok


Wstęp

Przy tworzeniu aplikacji z użyciem MFC wykorzystywana jest technika programowania, w której dane programu (model) oraz interfejs użytkownika są rozdzielne. Zrozumienie tej techniki oraz jej implementacji w MFC jest niewątpliwie konieczne w przypadku programistów zamierzających korzystać z dobrodziejstw oferowanych przez tą bibliotekę. Wśród korzyści wypływających ze stosowania tej techniki trudno przecenić ułatwienie pielęgnacji programu, dzięki wyraźnemu rozgraniczeniu merytorycznej części programu od interfejsu użytkownika.


Ogólna idea działania

Skoro mówimy o architekturze dokument-widok w kontekście biblioteki MFC to w obszarze naszych zainteresowań znajdą się obiekty dwóch klas: dokumentów oraz widoków (ang. views). Zauważmy, że w tej chwili jeszcze nie mamy na myśli klas języka C++ a jedynie dwa odrębne zbiory funkcjonalne pewnych abstrakcyjnych obiektów. W dalszej części niniejszego opracowania zajmiemy się szczegółowo implementacją tych zbiorów w języku C++.

W omawianej koncepcji obiekt dokumentu jest dysponentem danych aplikacji. Mówiąc o danych aplikacji mamy na myśli dane podlegające obróbce w wyniku działania programu; pojęcie to nie obejmuje danych takich jak preferencje użytkownika czy też rozmaite informacje środowiskowe, które nie są powiązane merytorycznie z danymi obrabianymi. Dokument oprócz zarządzania danymi w pamięci jest odpowiedzialny za ich odczyt oraz zapis z/do pamięci masowej (np. dysk twardy czy też stacja dysków magnetooptycznych). Dokument może również dostarczać interfejs do danych w sytuacjach kiedy są one pobierane z otoczenia aplikacji (np. dostęp do baz danych poprzez sieć). Jak widać dokument pełni wobec widoków funkcje usługowe czyli jest serwerem zaś widoki są klientami.

O ile obiekt dokumentu jest dysponentem danych przetwarzanych przez aplikację, to obiekt widoku odpowiedzialny jest za właściwą wizualizację danych. W obszar jego kompetencji wchodzi nie tylko wyświetlanie danych w stosownej formie (ang. rendering) ale także reagowanie (wizualizacja) na akcje użytkownika programu, np. zaznaczanie bloków danych, edycja danych w oknie etc. Widok uzyskuje dane, które ma przedstawić w postaci graficznej, z obiektu dokumentu. W trakcie pracy obiekt widoku ciągle komunikuje się z dokumentem informując go o zmianach w danych (wynikłych z działań użytkownika programu) lub reagując na niezależne od niego zmiany danych w dokumencie (patrz też: Synchronizacja widoków).

 

Co gdzie umieszczać?

Początkujący z MFC programiści mogą odczuwać dezorientację związaną z dualną strukturą logiczną aplikacji. Większość programujących pod Windows nie przywiązuje dużej wagi do rozdzielności interfejsu użytkownika od funkcjonalnej części programu. Dlatego ważne jest aby zrozumieć koncepcję architektury dokument-widok aby potem umiejętnie rozdzielić kompetencje pomiędzy dokumenty i widoki. Używamy tu słowa "kompetencje" specjalnie aby podkreślić, że ważne jest nie tylko rozmieszczenie danych ale i usług (tutaj: metod języka C++ realizujących konkretne zadania).

Zastanówmy się najpierw, co powinno, a co nie powinno znajdować się w dokumencie. Wiemy, że dokument może być wizualizowany na dowolnie wiele sposobów, tak więc umieszczanie w nim danych związanych ze stroną prezentacyjną danych jest niepraktyczne i w złym stylu. Z drugiej strony, obiekt dokumentu jest jedynym łącznikiem poszczególnych widoków wizualizujących te same dane (umieszczanie w widoku referencji do innego widoku nie dość że jest nieeleganckie, to uzależnia widok od referowanego widoku). Widać z tego wyraźnie, że w dokumencie muszą znaleźć się wszelkie obrabiane/prezentowane dane. Ułatwia to nie tylko zarządzanie danymi, które są skupione w jednym miejscu, ale także synchronizację różnych widoków tego samego dokumentu, gdyż wszelkie zmiany danych w dokumencie są (a przynajmniej powinne) być rozpropagowywane do wszystkich jego widoków.
Jeśli chodzi o usługi to dokument musi zapewnić poprzez nie dostęp do danych, zarówno jeśli chodzi o odczyt jak i modyfikacje (zapis). Dokument może także obsługiwać komunikaty systemowe, które nie zostały obsłużone przez żaden z jego widoków (patrz także: Routing wiadomości systemowych).

A co z widokiem? Po pierwsze: w widoku nie umieszczamy żadnych danych, które podlegają wizualizacji. Jest kilka powodów dla których ta zasada winna być rygorystycznie przestrzegana. Po pierwsze: widoki nie podlegają serializacji. Po drugie: widoki są (i powinny pozostać!) autonomiczne względem siebie co oznacza że nie komunikują się bezpośrednio pomiędzy sobą. Widok komunikuje się jedynie ze swoimi potomkami (np. kontrolkami), macierzystym oknem ramowym (ang. frame window) oraz z dokumentem, do którego jest przypisany.
Co zatem powinno się znaleźć w obiekcie widoku? Wszelkie dane niezbędne do prezentacji danych dokumentu takie jak np. kolory rysowanych elementów, czcionki używane do opisywania (w danych dokumentu powinna się znajdować referencja do czcionki czyli nazwa kroju pisma i jego wymiar logiczny, ale to widok powinien taką czcionkę odnaleźć w systemie i przechowywać handle do niej). To w widoku ma być zawarta informacja o trybie pracy, o zaznaczonych na ekranie elementach etc.

 

Synchronizacja widoków

W porządnie zaprojektowanej aplikacji, dokonanie zmiany danych przez użytkownika automatycznie wymusza aktualizację zawartości wszystkich otwartych widoków tego samego dokumentu. Interfejsy klas pochodnych od CDocument oraz klas pochodnych od CView oferują usługi, które tą aktualizację zapewnią: CDocument::UpdateAllViews oraz CView::OnUpdate.

UpdateAllViews

Metoda UpdateAllViews powinna być wywoływana każdorazowo, po dokonaniu zmiany w dokumencie (zazwyczaj odwołuje się do niej zaraz po wywołaniu metody CDocument::SetModifiedFlag), aby wymusić na wszystkich widokach dokumentu odświeżenie zawartości z uwzględnieniem zmodyfikowanych danych. Ponieważ zmiany te mogą zachodzić wskutek interakcji użytkownika aplikacji z widokiem lub wskutek działania metod (handlerów) dokumentu, zazwyczaj jedynie z wnętrza obiektów widoku i dokumentu zachodzi potrzeba wołania UpdateAllViews. Nigdy zaś nie powinno się wywoływać bezpośrednio metody CView::OnUpdate.

Metoda UpdateAllViews przyjmuje trzy argumenty: wskaźnik do obiektu widoku, który ją wywołał (może być NULL); liczbową wartość będącą "podpowiedzią" dla widoków pozwalającą na optymalizację uaktualniania; wskaźnik do obiektu klasy pochodniej od CObject (może być NULL) również pełniącego rolę "podpowiedzi". Działanie metody UpdateAllViews jest następujące: wykonywana jest enumeracja widoków przypisanych dokumentowi i dla każdego widoku, z wyjątkiem tego do którego metoda otrzymała wskaźnik jako argument (jeżeli ten wskaźnik jest NULL metoda nie czyni wyjątków), jest wywoływana metoda CView::OnUpdate z argumentami takimi jakie je otrzymała metoda UpdateAllViews.

 

Routing i mapowanie wiadomości systemowych

Aby aplikacja nie była "głucha" tzn. aby reagowała na zdarzenia zachodzące w systemie (np. na klikanie myszką przez użytkownika) konieczna jest implementacja obsługi wiadomości o zdarzeniach. Wiadomości te są wysyłane przez Windows do wszystkich działających aplikacji. Zakładamy, że czytelnik posiada niezbędne wiadomości na temat działania tego mechanizmu gdyż są one niezbędne do zrozumienia niniejszej sekcji, w której opisano marszrutowanie (ang. routing) wiadomości systemowych w aplikacji MFC oraz mechanizm ich obsługi czyli mapowanie.

Z punktu widzenia aplikacji, spośród odbieranych wiadomości systemowych można wydzielić dwie istotne klasy: komendy (przez analogię do angielskiego commands) oraz zawiadomienia (ang. notification). Komendy są to wiadomości o wybraniu (przyciśnięciu, kliknięciu) przez użytkownika elementów interfejsu użytkownika tj. kontrolek (przyciski, listy etc.) i menu. Wszystkie komendy są przysyłane do aplikacji we wiadomości WM_COMMAND. Zawiadomienia to z kolei wiadomości generowane przez okna potomne (zwykle są to kontrolki) informujące rodzica o zajściu jakiegoś zdarzenia w oknie potomnym, bądź gdy okno to żąda jakichś informacji od rodzica. Zawiadomienia są wysyłane we wiadomości WM_NOTIFY.

W MFC zaimplementowano bardzo wygodny i sprawny mechanizm marszrutowania wiadomości systemowych. Jego częścią może być każdy obiekt klasy wyprowadzonej z klasy CCmdTarget.

W celu obsługi wiadomości programista musi jedynie zbudować odpowiednią mapę powiązań pomiędzy wiadomościami a ich handlerami. W tym celu najwygodniej jest skorzystać z ClassWizarda, można też zdefiniować mapę samodzielnie. Oto przykładowa mapa komunikatów obsługiwanych w widoku:

BEGIN_MESSAGE_MAP(CTramView, CScrollView)

    //{{AFX_MSG_MAP(CTramView)

    ON_WM_LBUTTONDOWN()

    ON_WM_SETCURSOR()

    ON_WM_LBUTTONDBLCLK()

    ON_WM_CONTEXTMENU()

    ON_COMMAND(ID_EDIT_CLEAR, OnEditClear)

    ON_COMMAND(ID_STATION_PROPERTIES, OnStationProperties)

    ON_COMMAND(ID_START_SIMULATION, OnStartSimulation)

    ON_COMMAND(ID_STATION_BEGIN_LINE, OnStationBeginLine)

    ON_WM_DESTROY()

    //}}AFX_MSG_MAP

END_MESSAGE_MAP()

Makra BEGIN_MESSAGE_MAP oraz END_MESSAGE_MAP definiują mapę komunikatów. Szczegóły implementacyjne są dla programisty nieistotne gdyż mapę wypełnia się jak listę przy użyciu specjalnych makr. Ważne jest tylko aby podać właściwą nazwę klasy, dla której dokonywane jest mapowanie oraz nazwę klasy bezpośrednio nadrzędnej (informacja ta jest wykorzystywana przy przeciążaniu już istniejących handlerów danych komunikatów w klasie nadrzędnej).

Wiadomości Windows zazwyczaj są wysyłane do głównego okna ramowego i wtedy komendy są marszrutowane do innych obiektów. Marszrutowanie komend przebiega przez standardową sekwencję obiektów docelowych (ang. command-target object) aż do momentu, gdy któryś obiektów będzie posiadał zdefiniowany handler obsługujący komendę. W trakcie marszrutowania przez kolejne ogniwa łańcucha każdy obiekt docelowy sprawdza swoją mapę komunikatów aby sprawdzić czy nie ma w niej odnośnika do handlera obsługującego marszrutowaną komendę.

Różne klasy obiektów docelowych sprawdzają swoje mapy komunikatów w różnej kolejności. Zazwyczaj klasa daje szansę obsłużenia komendy pewnej grupie innych obiektów docelowych. Jeżeli żaden z nich nie posiada odpowiedniego handlera klasa sprawdza swoją mapę komunikatów. Jeżeli sama również nie posiada stosownego handlera to komenda może zostać przekazana kolejnym obiektom docelowym. Zamieszczona poniżej tabela pokazuje w jakiej kolejności zachodzi obsługa komend w podstawowych klasach MFC będących obiektami docelowymi. Generalnie obowiązuje następująca ogólna kolejność marszrutowania komend:

  1. Do aktualnie aktywnego potomnego obiektu docelowego.
  2. Do siebie samego.
  3. Do innych obiektów docelowych.

Jak kosztowny jest ten mechanizm? Porównując czasochłonność kodu realizującego marszrutowanie z typowym kodem handlera widać że koszt routingu jest niski. Należy wszakże pamiętać że MFC generuje komendy tylko gdy użytkownik konwersuje z obiektem interfejsu użytkownika.

Standardowe marszrutowanie komend

Kiedy obiekt tego typu odbiera komendę... obsługuje ją lub wiadomość jest przekazywana do obsługi obiektom w następującej kolejności:
Okno ramowe MDI (ang. MDI frame window)
(CMDIFrameWnd)
  1. Aktywne okno potomne CMDIChildWnd
  2. To okno ramowe
  3. Aplikacja (obiekt klasy CWinApp lub potomnej)
Okno ramowe dokumentu
(CFrameWnd, CMDIChildWnd)
  1. Aktywny widok
  2. To okno ramowe
  3. Aplikacja (obiekt klasy CWinApp lub potomnej)
Widok
  1. Ten widok
  2. Dokument związany z tym widokiem
Dokument
  1. Ten dokument
  2. Template dokumentu związane z tym dokumentem
Okno dialogowe
  1. To okno dialogowe
  2. Okno będące właścicielem (owner) tego okna dialogowego
  3. Aplikacja (obiekt klasy CWinApp lub potomnej)

Powyższa tabela zawiera szczegółowe reguły marszrutowania wiadomości systemowych w środowisku (framework) MFC. Aby jednak łatwiej było je zrozumieć przedstawiamy rysunek, który w sposób poglądowy ilustruje zależności komunikacyjne pomiędzy poszczególnymi klasami MFC współpracujących w ramach architektury dokument-widok oraz w aplikacjach dialogowych.

Zależności komunikacyjne pomiędzy klasami MFC

Pogrubione strzałki na rysunku reprezentują kierunki, w których przeważnie następuje komunikacja (przekazywanie wiadomości).

Interfejsy SDI i MDI

Akronimy SDI i MDI pochodzą od anglojęzycznych: Single Document Interdace (interfejs jednodokumentowy) oraz Multiple-Document Interface (interfejs wielodokumentowy). Interfejs SDI charakteryzuje się tym, że w danej chwili aplikacja może pracować i prezentować w oknie tylko jeden dokument (np. tylko jeden tekst). Ograniczenia tego nie posiada interfejs MDI, który pozwala na swobodną pracę z wieloma dokumentami jednego lub wielu typów. Co więcej, możliwa jest też praca z wieloma widokami tego samego dokumentu bez konieczności dzielenia macierzystego okna ramowego widoku na panele. Z punktu widzenia programisty wykorzystującego MFC nie ma istotnych różnic w implementacji obu typów interfejsów - różnice na ogół sprowadzają się do wykorzystania różnych klas bazowych.

Omówienie tych różnic oraz przykłady kodu źródłowego w wariantach dla obu interfejsów Czytelnik znajdzie w dokumencie Aplikacja: typowe funkcje, które należy zaimplementować.

 

Aplikacje dialogowe (dialog-based applications)

Oprócz aplikacji SDI oraz MDI kreator aplikacji MFC umożliwia konstrukcję aplikacji dialogowych czyli takiej, której wszystkie okna są dialogami. Jest to najprostszy typ aplikacji jakie można tworzyć przy użyciu MFC. Z racji tego, że MFC nie udostępnia klas dialogów będących równocześnie widokami architektura dokument-widok nie ma w tym przypadku zastosowania. Programista nadal może korzystać z dobrodziejstw serializacji gdyż jest to cecha klasy niezależna od dokumentu, jednak musi zadbać samodzielnie o stworzenie systemu wymiany danych synchronizacji pomiędzy poszczególnymi dialogami.

 


Strona główna