dietphone od środka « Pabloware + Windows Phone

Pabloware + Windows Phone

Niezależny blog firmy Pabloware o systemie Windows® Phone 7

Serializacja binarna wewnątrz aplikacji Dietphone

Za wyborem ręcznej serializacji binarnej przemawia fakt, że projektowana aplikacja należy do tego rodzaju narzędzi, które uruchamia się na chwilę, ale wielokrotnie w ciągu dnia. W przypadku takich aplikacji bardzo ważne jest szybkie uruchamianie się aplikacji. Należy dodać, że w środowisku mobilnym nie mamy do dyspozycji takiej wydajności sprzętu, jak na komputerze biurkowym. Wybrany model serializacji pozwala oszczędzić czas.

Wadą tego rodzaju serializacji, z punktu widzenia programisty, jest konieczność pisania metod serializacyjnych dla każdej klasy. W przypadku projektowanej aplikacji nie był to jednak problem, ponieważ jej model nie zawiera wielu klas. W przeciwnym przypadku ręczna serializacja byłaby bardzo kłopotliwa i wykorzystalibyśmy, za pewną cenę wydajności, mechanizm refleksji .NET, likwidując powyższą kłopotliwość.

Jest natomiast prawdopodobne, że model będzie rozbudowywany. Tutaj wybór ręcznej serializacji również jest korzystny. Można bowiem dowolnie zdecydować co zrobić, kiedy plik danych pochodzi z wcześniejszej wersji aplikacji.

Automatyczne narzędzia serializacji, które potrafią radzić sobie w takich sytuacjach, czynią to za cenę przechowywania identyfikatorów każdej właściwości obiektu, przez co obniża się ich efektywność. Jeszcze gorszą efektywność, niż gotowe narzędzia do serializacji, oferowały bardziej klasyczne zewnętrzne narzędzia bazodanowe, dostępne na obraną platformę. Przez co nie zostały zastosowane. Należy przypomnieć, że obecnie nie jest dostępna na Windows Phone 7 wbudowana baza danych SQL.


Z wbudowanych w bibliotekę Silverlight mechanizmów serializacji, najszybsza okazała się serializacja przy użyciu mechanizmu DataContract do binarnej reprezentacji XML platformy .NET (nie jest to stricte serializacja binarna, jedynie binarne sformatowanie kodu XML). Brak jest natomiast w bibliotece Silverlight binarnego serializatora BinaryFormatter, znanego z pełnej wersji platformy .NET, więc serializację binarną trzeba zapewnić samemu lub korzystać z gotowych narzędzi zewnętrznych. Zastosowanie w aplikacji własnego mechanizmu serializacji binarnej, pozwoliło wczytać na telefonie komórkowym dane 843 produktów w czasie 320 milisekund. Wobec trwania tej operacji przez 4500 milisekund, podczas użycia wspomnianego mechanizmu DataContract. Oszczędność wyniosła zatem 93% czasu.


Mechanizm serializacji jest odizolowany od reszty aplikacji. Założono możliwość wykorzystania go w innych aplikacjach na Windows Phone 7, więc rdzeń kodu serializacji jest całkowicie niezależny od warstwy Model aplikacji. Konieczne było jedynie dodanie bardzo prostych klas zapewniających metody serializacji konkretnych encji warstwy Model. Te klasy są już zależne od warstwy Model. Oczywiście sama warstwa Model jest całkowicie niezależna od mechanizmu przechowywania danych, w tym od serializacji binarnej. Poniżej znajduje się opis wszystkich elementów odpowiedzialnych za serializację w opisywanej aplikacji.

BinaryFile

Jest to abstrakcyjna klasa generyczna, która implementuje interfejs BinarySerializer. Odpowiada plikowi o określonej nazwie i wersji, w którym przechowywane są obiekty danego typu. Umożliwia zapis i odczyt z pliku listy wszystkich obiektów. Jej podklasy konkretne muszą zapewnić zapis i odczyt pojedynczych obiektów ze strumienia binarnego, są to bowiem metody abstrakcyjne tej klasy.

BinarySerializer i BinarySerializerExtensions

Generyczny interfejs BinarySerializer odpowiada za serializację pojedynczego obiektu.

Natomiast klasa BinarySerializerExtensions wprowadza rozszerzenia do klas BinaryWriter oraz BinaryReader (klasy platformy .NET), o możliwość zapisywania i odczytywania obiektów z tych klas, przy użyciu interfejsu BinarySerializer oraz ułatwia zapis i odczyt niektórych innych typów.

Konstrukty BinarySerializer i BinarySerializerExtensions bazują na bardzo ciekawej pracy Kevina Marshalla, w której rolą obiektów, implementujących interfejs IBinarySerializable, jest serializacja samych siebie. Stosując SRP i programowanie generyczne zmieniono nieznacznie to założenie, wprowadzając zasadę wydzielania serializacji obiektów do zewnętrznych klas, implementujących interfejs BinarySerializer.

BinaryStorage i BinaryStorageCreator

Klasa BinaryStorage pełni rolę adaptera, będąc podklasą BinaryFile i implementując przy jej użyciu interfejs Storage, który należy do warstwy Model aplikacji.

Natomiast klasa BinaryStorageCreator implementuje, należący do warstwy Model aplikacji interfejs StorageCreator. Korzysta przy tym z klasy StorageBuilder również wchodzącej w skład warstwy Model aplikacji. Jest to ciekawy przypadek, który zostanie szerzej omówiony.

Zacznijmy od porównania naiwnej wersji kodu z poprawioną wersją kodu. Obie wersje zostały uproszczone (uwzględniono tylko kategorie i produkty oraz pominięto przypisanie BinaryStreamProvider do BinaryFile), aby czytelnik mógł skupić się na istocie problemu.

Pierwsza wersja:

Naiwna wersja klasy BinaryStorageCreator (kod C#)

Druga wersja:

Poprawiona wersja klasy BinaryStorageCreator korzystająca z klasy StorageBuilder (kod C#)

Zamierzonym efektem było utworzenie instancji odpowiedniej podklasy klasy abstrakcyjnej BinaryStorage<T> na podstawie typu generycznego T. Problem częściowo wziął się stąd, że w danym kontekście programista chciał, żeby typ BinaryStorage<T> był dla kompilatora rodzicem typu CategoryBinaryStorage, który dziedziczy z BinaryStorage<Category>, podczas gdy dla kompilatora typ generyczny T może w tym samym kontekście równać się klasie Product, która poza wspólnym typem bazowym nie ma nic wspólnego z Category, a więc mielibyśmy odmienny typ BinaryStorage<Product> zamiast BinaryStorage<Category>. Jak widać klasa o innym typie generycznym jest po prostu inną klasą i kompilator nie może pozwolić na bezpieczne rzutowanie typów w takiej sytuacji. To na programiście spoczywa obowiązek upewnienia się w czasie wykonania programu, że tworzony jest obiekt właściwego typu generycznego. Choć jak pokażemy, można również w tym przypadku skorzystać z kontroli typów języka.

Obrany efekt utworzenia instancji odpowiedniej podklasy BinaryStorage<T>, na podstawie typu generycznego T, można uzyskać w naiwny sposób sprawdzając typ generyczny i tworząc na tej podstawie odpowiedni typ konkretny (jak w pierwszej wersji). Takie rozwiązanie oznacza jednak rezygnację z silnej kontroli typów języka C# i nie jest bezpieczne, ani przejrzyste. Po refactoringu udało się wypracować bezpieczne rozwiązanie, widoczne w drugiej wersji, które można zredukować do samego użycia operatora as. Użyto jednak dodatkowo operatora typeof w warunku, aby nie tworzyć nadmiarowych obiektów dla operatora as, a całość zamknięto w klasę, która poprzez swą konstrukcję, uniemożliwia utworzenie obiektu niewłaściwego typu. W najgorszym przypadku, kiedy programista rozbudowując program zapomni dodać wywołanie metody ProposeStorageForEntity, zobaczy wyjątek „Nie zaimplementowano” i żaden obiekt nie zostanie utworzony.

Jeszcze bardziej eleganckie i krótsze rozwiązanie polegałoby na przeładowaniu metody CreateStorage<T>, za pomocą wymuszenia minimalnej klasy bazowej typu generycznego T, przy użyciu operatora where. Niestety jednak przeładowanie metod różniących się tylko operatorem where nie działa w języku C#, choć można sądzić, że byłoby przydatne w wielu sytuacjach.

BinaryStreamProvider

Obiekt implementujący interfejs BinaryStreamProvider jest używany przez obiekt BinaryFile, do uzyskania wejściowego i wyjściowego strumienia pliku. Wydzielenie tego interfejsu było motywowane zróżnicowanym na platformach Windows i Windows Phone sposobem uzyskiwania strumienia pliku. Poza tym umożliwiło proste przejście od domyślnych plików danych instalowanych wraz z aplikacją, do własnych danych użytkownika aplikacji, bez konieczności kopiowania plików danych.

CategoryBinaryStorage, MealBinaryStorage, MealNameBinaryStorage i ProductBinaryStorage

Są to klasy dziedziczące po abstrakcyjnej, generycznej klasie BinaryStorage i pośrednio po BinaryFile. Ich rolą jest obsługa zapisu i odczytu pojedynczej encji modelu, odpowiednio: kategorii, posiłku (razem ze składnikami posiłku), nazwy posiłku i produktu spożywczego. Dostarczają także nazwy pliku i wersji, dla obsługiwanego przez siebie typu obiektu.

Zawartość warstwy Model w implementacji aplikacji Dietphone

Aplikacja została skonstruowana zgodnie z opisanym w poprzednich wpisach wzorcem architektonicznym MVVM, ale ograniczymy się tutaj do opisu jedynie warstwy Model aplikacji. Ponadto w następnym wpisie zostaną opisane autonomiczne fragmenty kodu, które mogą mieć zastosowanie w innych aplikacjach na system Windows Phone 7.

Calculator

Klasa ta służy do obliczania wartości odżywczych na podstawie zawartości składników odżywczych. Motywacją do jej utworzenia było wydzielenie kodu, używanego w wielu miejscach modelu, do jednej klasy. Obliczanie wartości odżywczych, samo w sobie, jest oczywiście bardzo proste i wygląda następująco (jednostką wejściową są gramy):

Obliczanie wartości odżywczych

Category i MealName

Klasa Category to kategoria, do której obecnie mogą być przypisane tylko produkty. Zaś klasa MealName to nazwa posiłku (np. „śniadanie”). Obie klasy są encjami.

DefaultEntities

Jest to interfejs dostarczający domyślnych encji. Obecnie obsługiwane są encje: nazwa produktu i produkt. Domyślne encje używane są, gdy danej encji nie można znaleźć lub po prostu jej nie określono. Wynika to z zasady projektowej unikania, w takich sytuacjach, wartości null.

Entity i EntityWithId

Entity, to podstawowa klasa encji, która pamięta swojego właściciela we właściwości Owner. Dla bezpieczeństwa, jest to właściwość jednorazowego zapisu i chronionego odczytu.

Klasa EntityWithId, to zgodnie z nazwą, klasa Entity, wzbogacona o identyfikator (typu GUID). Większość encji z niej dziedziczy. Klasa ta umożliwiła uogólnienie wyszukiwania encji po ID.

Factories

Jest to najważniejsza część modelu. Ten właściciel wszystkich encji jest kontenerem, zawierającym fabryki poszczególnych typów encji i korzystającym z nich w celach: udostępnienia wszystkich encji, tworzenia nowych encji i zapisu wszystkich encji. Udostępnia, poprzez swoje właściwości, także implementacje interfejsów: Finder oraz DefaultEntities.

Celem utworzenia tego interfejsu była chęć zapewnienia jednego miejsca, z którego można uzyskać dostęp do całego modelu. Ponieważ każda encja zna obiekt implementujący ten interfejs (swojego właściciela), może ona poruszać się po całym modelu i jest samowystarczalna. To duża zaleta.

Implementacja nie jest klasą statyczną, ani nie wymusza wzorca singletonu, choć obecnie jest tworzona w aplikacji tylko raz. Jest przekazywana do warstwy ViewModel podczas tworzenia tejże, umożliwiając jej komunikację z warstwą Model. Taka strategia tworzenia ułatwia testowanie aplikacji, izolując komponenty wszędzie, gdzie jest to możliwe.

Factory i FactoryCreator

Factory jest to generyczna klasa fabryki encji. Umożliwia odczyt i zapis encji danego typu oraz utworzenie nowej encji. Do właściwego odczytu i zapisu korzysta z obiektu implementującego interfejs Storage. Ponadto przypisuje właściciela do każdej odczytanej lub utworzonej encji.

Natomiast klasa FactoryCreator tworzy obiekty klasy Factory. Korzysta z klasy StorageCreator, skąd uzyskuje obiekt Storage, który przekazuje do tworzonego obiektu Factory. Klasy FactoryCreator używa implementacja Factories.

Finder i FinderExtensions

Interfejs Finder pozwala przeszukiwać model według różnych kryteriów, w tym według ID. Zaś klasa FinderExtensions zapewnia generyczne rozszerzenie (cecha języka C#) każdej listy encji zawierających ID o metodę szukania encji po ID. Korzysta z niej implementacja Finder.

Meal

Jest to encja reprezentująca posiłek. Zawiera listę składników posiłku (czyli obiektów MealItem), które udostępnia na zewnątrz jako ReadOnlyCollection (typ .NET). Umożliwia ich dodawanie, usuwanie, a także jednorazową inicjalizację całej listy oraz kopiowanie jej z innego posiłku. Potrafi również obliczyć swoją wartość odżywczą i udostępnia walidację.

MealItem

MealItem jest encją reprezentującą składnik posiłku. Jest to końcowa klasa, której funkcjonalność budowana jest w następujących, kolejno po sobie dziedziczących klasach:

  1. MealItemBase – pozwala określić: produkt, ilość i jednostkę, a więc w pełni określić składnik posiłku. Referencja do produktu jest pamiętana po jego odnalezieniu.
  2. MealItemWithNutrientsPerUnit – oblicza ilość składników odżywczych i wartość odżywczą w określonym produkcie, przypadających na pojedynczą określoną jednostkę (lub porcję).
  3. MealItemWithNutrients – oblicza ilość składników odżywczych i wartość odżywczą, przypadających na cały składnik posiłku.
  4. MealItemWithValidation – udostępnia walidację.

Pomocnicza klasa UnitUsability sprawdza dostępność informacji o składnikach odżywczych na jednostkę produktu, dla klas: MealItemWithNutrientsPerUnit i MealItemWithValidation.

Product i UnitAbbreviations

Encja Product reprezentuje produkt spożywczy. Potrafi obliczyć niektóre swoje składniki odżywcze i wartość odżywczą oraz je wszystkie przechowuje. Udostępnia również walidację.

Natomiast klasa UnitAbbreviations konwertuje jednostki ilości produktu na tekst i odwrotnie.

Storage, StorageCreator i StorageBuilder

Storage, to generyczny interfejs odczytywania i zapisywania list encji, który jest implementowany poza modelem. Natomiast StorageCreator, to generyczny interfejs tworzenia obiektów Storage (generyczna fabryka abstrakcyjna). Również jest implementowany poza modelem. Jego implementacja jest jedynym obiektem przekazywanym do implementacji Factories podczas jej tworzenia.

Interesująca jest klasa StorageBuilder, stosowana przez obiekty StorageCreator i służąca do automatycznego wybierania właściwej, dla danego typu encji, implementacji Storage. Zostanie ona omówiona, przy okazji jej wykorzystania, w następnym wpisie.

Założenia funkcjonalne Dietphone, przykładowej aplikacji użytkowej dla Windows Phone 7

Aplikacja ma obliczać mierniki wartości odżywczej takie jak wymienniki węglowodanowe (WW) oraz wymienniki białkowo-tłuszczowe (WBT). Założono, że aplikacja oblicza również wartość energetyczną (kcal), pozwalającą wykorzystać aplikację przez osoby stosujące dietę o ustalonej wartości energetycznej.


Samo w sobie obliczanie wartości odżywczej posiłku nie jest skomplikowane, ale wymaga użycia danych o zawartości poszczególnych składników odżywczych (np. białko, tłuszcz), w produktach wchodzących w skład posiłku. Zwykle w posiłku jest przynajmniej kilka produktów, co wprowadza już pewną komplikację, trzeba bowiem odnaleźć dane każdego produktu. Założono więc, że aplikacja będzie posiadać gotową, możliwie pełną, edytowalną bazę produktów, z zapewnieniem ich szybkiego wyszukiwania tekstowego i przeglądania według kategorii, aby można było z niej sprawnie dodawać produkty do posiłku.

Ponadto przydatne podczas przeglądania produktów może być prezentowanie wizualnego znacznika wartości odżywczej produktu, w porównaniu do innych produktów w swojej kategorii. Pozwoli to użytkownikom odnaleźć alternatywne produkty o bardziej korzystnym rozkładzie wartości odżywczych.

Składniki odżywcze w produktach powinny móc być definiowane w odniesieniu do przynajmniej dwóch jednostek ilości produktu: gramów, jako jednostki podstawowej i drugiej jednostki opcjonalnej. Ponadto jednostki powinny móc być łatwo dodawalne do aplikacji przez programistę lub użytkownika, a domyślnie dostępną dodatkową jednostką powinny być mililitry.

Dla wygody użytkownika zasadne jest zapewnienie możliwości zapisania predefiniowanej porcji konkretnego produktu, takiej jak np. szklanka mleka. Co więcej, kiedy użytkownik zdefiniuje dane produktu w odniesieniu np. do szklanki i poda, że szklanka to np. 200ml, aplikacja powinna umożliwić korzystanie z tych danych dla dowolnej określonej w mililitrach ilości produktu. Natomiast ilość w gramach powinna móc być podawana w odniesieniu do 100g, bo w takim ujęciu informacje te są umieszczane na opakowaniach produktów spożywczych.

Po zakończeniu edycji produktu, jego dane powinny być walidowane pod względem spójności wprowadzonych składników odżywczych (np. w odniesieniu do wartości energetycznej, którą można wyliczyć z pozostałych danych), jak również ich kompletności. Podobnie, na koniec edycji posiłku, powinna nastąpić walidacja. W celu wychwycenia popełnionych przez użytkownika podstawowych błędów, podczas wprowadzania posiłku.

Jak wspomniano wcześniej, dodawanie produktów jako składników posiłków, powinno odbywać się jak najsprawniej. Należy to działanie połączyć z podawaniem ilości produktu oraz uwzględnić przy tym predefiniowane porcje produktu, a domyślną jednostką powinny być gramy. Użytkownik podczas dodawania składników posiłku powinien mieć dostępną informację o bieżącej sumie wartości odżywczych (WW, WBT i kcal) w posiłku, przy czym powinna to być informacja dobrze widoczna. Podczas edycji posiłku użytkownik powinien mieć też możliwość usuwania składników oraz zmiany ich ilości.


Ponadto należy zapewnić możliwość wglądu przynajmniej w sumaryczne informacje o każdym posiłku, a najlepiej w listy wszystkich składników każdego posiłku. Dlatego też założono, że każdy posiłek będzie dodawany do dziennika posiłków, z możliwością jego późniejszej edycji i oczywiście wglądu. W celu szybszego odnajdywania, założono uszeregowanie posiłków według chronologii (wskazana jest funkcja szybkiego przejścia do konkretnej daty) oraz możliwość ich filtrowania według nazwy użytego produktu. Przyjęto, że każdy posiłek będzie mógł mieć przypisaną wybieraną z dostosowywalnej listy, opcjonalną nazwę (np. „śniadanie”, „obiad”, „kolacja”) oraz będzie zapamiętana data i godzina ukończenia dodawania w aplikacji tego posiłku, jak również będzie można dodać do niego notatkę.


Zakłada się, że interfejs użytkownika aplikacji mobilnej powinien zostać zaprojektowany w zgodzie ze standardem aplikacji na system Windows Phone 7, a więc powinien być przeznaczony do użycia „w biegu”, bez konieczności wykonywania niebędących niezbędnymi lub zbyt precyzyjnych czynności oraz być zgodny z wytycznymi projektowymi tego systemu i mieć spójny z nim wygląd. Bardzo ważna jest również szybkość działania aplikacji, ze szczególnym uwzględnieniem szybkości uruchamiania, aby aplikacja ta mogła być uruchamiana wielokrotnie w ciągu dnia i użytkownik mógł ją obsłużyć jak najszybciej.

Autor założył również, że przydatne będzie zapewnienie możliwości dostępu do danych zgromadzonych w aplikacji mobilnej także w aplikacji webowej, która w przyszłości mogłaby również zostać przygotowana. Ma to już na tym etapie pewne znaczenie dla implementacji, w której należy uwzględnić gotowość modelu biznesowego na synchronizację danych z serwerem, przy czym założono, że aplikacja będzie zawsze pracować asynchronicznie na lokalnej kopii danych, oczywiście z powodu szybkości i niezawodności takiego rozwiązania.