Tworzenie wieloplatformowych aplikacji Xamarin z wykorzystaniem frameworka MvvmCross

Autorzy: Sylwester Wieczorkowski

01.06.2016
CZYM JEST MVVMCROSS?

Jak sama nazwa wskazuje, MvvmCross to framework wspomagający tworzenie wieloplatformowych aplikacji zgodnych z wzorcem MVVM (Model-View-ViewModel). Wspiera wiele popularnych typów projektów .NET, takich jak:
  • Xamarin.Android
  • Xamarin.iOS
  • Xamarin.Mac
  • WinRT (Windows 8.1, Windows Phone 8.1)
  • Universal Windows Platform (UWP) (Windows 10)
  • Windows Presentation Foundation (WPF)
Dostarcza również mechanizmów wiązania danych (ang. data binding) dla platform, które natywnie korzystają z wzorca MVC (Model-View-Controller).

Aplikacje MvvmCross zazwyczaj składają się z dwóch podstawowych części:
  • projektu części wspólnej będącego przenośną bibioletką klas .NET czyli PCL (Portable Class Library), która zwiera wszystkie viewmodele, modele oraz interfejsy usług platformowych, czyli całą logikę biznesową, obsługę bazy danych oraz warstwę dostępu do usług sieciowych, 
  • natywnego projektu platformowego zawierającego definicję interfejsu użytkownika oraz implementację usług platformowych.
Dobrą praktyką jest utworzenie dodatkowego projektu PCL w celu rozdzielenia logiki biznesowej aplikacji i warstwy dostępu do danych. Rysunek 1 przedstawia opisany wcześniej podział dla projektu realizowanego w technologii Xamarin.Android oraz Xamarin.iOS.

Rysunek 1. Architektura solucji MvvmCross

Ilość współdzielonego kodu waha się w zależności od typu aplikacji. Oczywiście im więcej natywnych API wykorzystuje nasza aplikacja, tym mniejsza część rozwiązania zostanie ponownie wykorzystana. W przypadku aplikacji biznesowych jesteśmy w stanie współdzielić ok. 70-80% całego rozwiązania.

Aby rozpocząć swoją przygodę z frameworkiem MvvmCross, należy utworzyć solucję zawierającą wszystkie niezbędne projekty – przynajmniej jedną bibliotekę PCL oraz natywne projekty dla platform, które zamierzamy wspierać.

Następnie do każdego z nich dodać paczkę NuGet MvvmCross – Starter Pack oraz wykonać kilka podstawowych kroków konfiguracyjnych opisanych w plikach znajdujących się w katalogu ToDo-MvvmCross. Cały projekt wraz z dokumentacją oraz wideo tutorialami dostępny jest na GitHubie pod adresem https://github.com/MvvmCross/MvvmCross.


PODSTAWOWE ELEMENTY FRAMEWORKA

W każdej aplikacji MvvmCross znajduje się dokładnie jedna implementacja klasy App, która dziedziczy po klasie MvxApplication (Listing 1). W metodzie Initialize odbywa się zarejestrowanie punktu wejścia (ang. entry point), czyli pierwszego viewmodelu, który zostanie utworzony po wejściu do naszej aplikacji (w naszym przypadku ProductsViewModel), a także rejestracja typów wstrzykiwanych po stronie części wspólnej.

Listing 1. Przykładowa implementacja klasy App w projekcie PCL
public class App : MvvmCross.Core.ViewModels.MvxApplication
{
    public override void Initialize()
    {
        CreatableTypes()
            .EndingWith("Service") 
            .AsInterfaces() 
            .RegisterAsLazySingleton();
RegisterAppStart<ViewModels.ProductsViewModel>; Mvx.RegisterType<IProductRepository>( () => new ProductWebRepository("http://webservice/api/product/")); } }

MvvmCross dostarcza statyczną klasę Mvx, która pełni rolę kontenera wstrzykiwania zależności i jest odpowiedzialna za zarządzanie implementacjami rejestrowanymi zarówno po stronie części wspólnej, jak i po stronie projektów platformowych w klasie Setup (Listing 2).

Listing 2. Implementation of the Setup class in the Xamarin.Android project
public class Setup : MvxAndroidSetup
{
    public Setup(Context applicationContext) : base(applicationContext)
    {
    }
protected override IMvxApplication CreateApp() { return new Core.App(); }
protected override IMvxTrace CreateDebugTrace() { return new DebugTrace(); }
protected override void InitializePlatformServices() { base.InitializePlatformServices(); Mvx.RegisterType<ICallerService, DroidCallerService>(); Mvx.RegisterType<IEmailService, DroidEmailService>(); Mvx.RegisterType<IPopupService, DroidPopupService>(); } }

Klasa Setup jest swego rodzaju bootstrapperem dla MvvmCrossa, który znajduje się w każdym projekcie platformowym. Dla przykładu posłużę się projektem Xamarin.Android (Listing 2). Podstawowym zadaniem tej klasy jest utworzenie instancji klasy App, jak również dostosowanie frameworka do specyfiki naszej aplikacji. Klasa MvxAndroidSetup, po której dziedziczy klasa Setup, dostarcza serii wirtualnych metod, które należy przeciążyć, aby m.in. zarejestrować wszystkie platformowe usługi (odwołujące się do natywnego API). Są one wykorzystywane po stronie części wspólnej w celu wykonywania instrukcji specyficznych dla każdej z platform – mechanizm odwróconego sterowania (ang. Inversion of Control).

Kolejnym istotnym elementem rozwiązania MvvmCross jest viewmodel, który pełni funkcję kontenera zawierającego właściwości oraz komendy odpowiedzialne za zmianę stanu oraz zachowania skojarzonego z nim widoku.

Listing 3. Fragment przykładowego viewmodelu
public class ProductsViewModel : MvxViewModel
{
    private IProductRepository productRepository;

    private List<Product> products;
    public List<Product> Products
    {
        get { return products; }
        set { SetProperty(ref products, value); }
    }

    private bool isAddButtonEnabled;
    public bool IsAddButtonEnabled
    {
        get { return isAddButtonEnabled; }
        set
        {
            isAddButtonEnabled = value;
            RaisePropertyChanged(() => IsAddButtonEnabled);
        }
    }
private IMvxCommand adddProductCommand; public IMvxCommand AddProductCommand { get { adddProductCommand = adddProductCommand ?? new MvxCommand(
() => ShowViewModel<AddProductViewModel>());
return adddProductCommand; } }
public ProductsViewModel(IProductRepository productRepository) { this.productRepository = productRepository; }
... }

Bazowa klasa przedstawionego fragmentu viewmodelu (Listing 3) zawiera implementację interfejsów INotifyPropertyChanged, INotifyCollectionChanged oraz metody takie jak SetProperty czy RaisePropertyChanged, które umożliwiają odświeżanie elementów widoku, tj. wywołanie zdarzenia informującego o zmianie określonych właściwości. Komendy implementowane z wykorzystaniem klasy MvxCommand pozwalają zdefiniować obsługę poszczególnych akcji wykonywanych przez użytkownika, np. przejście do kolejnego widoku. MvxViewModel dostarcza wielu przydatnych metod odpowiedzialnych m.in. za nawigację pomiędzy viewmodelami (ShowViewModel) lub obsługę cyklu życia widoku. Zadaniem kontenera Mvx jest automatyczne wstrzykiwanie zależności do tworzonych viewmodeli.


DEFINIOWANIE INTEFEJSU UŻYTKOWNIKA, CZYLI W JAKI SPOSÓB TWORZYĆ WIDOKI

Mechanizm wiązania danych jest naturalnym elementem ekosystemu Windows (WPF, WinRT i UWP), a co za tym idzie – sposób tworzenia widoków nie odbiega od natywnego podejścia. Skupmy się zatem na platformach Android oraz iOS, dla których natywnym wzorcem jest MVC, w którym kontrolery (iOS) oraz aktywności/ fragmenty (Android) odgrywają kluczową rolę. Niebywałą zaletą frameworka MvvmCross jest fakt, iż w przeciwieństwie do Xamarin.Forms wszystkie widoki, układy, kontrolki definiujemy całkowicie natywnie, korzystając z natywnych mechanizmów i narzędzi.

Xamarin.Android

W przypadku Androida (Listing 4) tworzymy pliki xml (bądź axml), w których wykorzystujemy 100% natywnego API, aby zbudować układy dla poszczególnych widoków. MvvmCross zapewnia właściwość local:MvxBind, którą możemy wykorzystać w celu związania właściwości elementów widoku (kontrolek, układów) z odpowiednimi właściwościami viewmodelu zgodnie z zapisem przedstawionym na powyższym listingu.

Listing 4. Definicja układu dla systemu Android 
<?xml version="1.0" encoding="utf-8"?> 
<LinearLayout xmlns:android="http://schemas.android.com/apk/ res/android"
    xmlns:local="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:padding="20dp">

    <LinearLayout
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:layout_marginBottom="20dp">

    <Button
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="0.5"
        android:text="Add Product"
        local:MvxBind="Click AddProductCommand;
Enabled IsAddButtonEnabled" /> <Button android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="0.5" android:text="Remove All" local:MvxBind="Click RemoveAllProductsCommand; Enabled IsRemoveAllButtonEnabled" /> </LinearLayout> <MvxListView android:layout_width="fill_parent" android:layout_height="wrap_content" android:divider="@null" android:scrollbars="none" android:footerDividersEnabled="false" android:overScrollFooter="@android:color/transparent" local:MvxItemTemplate="@layout/view_product_item" local:MvxBind="ItemsSource Products" />
</LinearLayout>

Ponadto framework dostarcza dodatkowego zestawu kontrolek. Jedną z nich jest kontrolka umożliwiająca wyświetlenie listy elementów – MvxListView, która pozwala za pomocą właściwości local:MvxItemTemplate określić szablon komórki danej listy. Implementacja adaptera staje się zbędna, a co za tym idzie – nie produkujemy nadmiarowego kodu po stronie projektu platformowego.

Listing 5. Implementacja aktywności w projekcie Xamarin.Android

W przypadku MvvmCrossa kontrolery służą jedynie do załadowania widoku oraz powiązania widoku z odpowiednim viewmodelem (Listing 5).

[Activity]
public class ProductsActivity : MvxActivity
{
    protected override void OnCreate(Bundle bundle)
    {
        base.OnCreate(bundle);
        SetContentView(Resource.Layout.layout_products_activity);
    }
}

Oczywiście, jeżeli aplikacja wymaga dostarczenia pewnych specyficznych funkcjonalności/zachowań na danej platformie, mogą one zostać zaimplementowane w kontrolerach. Należy jednak pamiętać, iż zmniejszamy w ten sposób ilość współdzielonego kodu, a potencjalne różnice, niespójności w zachowaniu aplikacji wydłużają czas poprawiania błędów oraz implementowania dodatkowych zmian.

Xamarin.iOS


Tworzenie widoku dla systemu iOS zaczynamy od dodania klasy kontrolera wraz z plikiem z rozszerzeniem xib reprezentującym widok. Edycja plików xib odbywa się za pomocą Xamarin Studio (środowiska dostarczanego przez firmę Xamarin) bądź narzędzia Xcode Interface Builder wbudowanego w Xcode (Rysunek 2).

Rysunek 2. Tworzenie widoku w Xcode Interface Builder

Po ułożeniu widoku z wciśniętym przyciskiem ctrl przeciągamy elementy, które chcemy związać z viewmodelem do odpowiedniego pliku nagłówkowego. Nasze zmiany zostaną automatycznie zsynchronizowane do języka C#, a konkretniej do klasy utworzonego kontrolera (Listing 6). Właściwości kontrolera oznaczone atrybutem Outlet nazywamy outletami. Za ich pośrednictwem uzyskujemy dostęp do poszczególnych elementów interfejsu użytkownika.

Listing 6. Część klasy kontrolera zawierająca zsynchronizowane outlety 
[Register ("ProductsViewController")]
partial class ProductsViewController
{
    [Outlet]
    UIKit.UIButton AddProductButton { get; set; }

    [Outlet]
    UIKit.UITableView ProductList { get; set; }

    [Outlet]
    UIKit.UIButton RemoveAllButton { get; set; }
}

Po załadowaniu widoku tworzymy zestaw wiążący właściwości dostępnych outletów z właściwościami danego viewmodelu (Listing 7). Metoda Bind przyjmuje obiekt (najczęściej outlet), który będziemy rozważać. Metoda For określa właściwość obiektu, która zostanie związana z właściwością viewmodelu określoną przez metodę To. W przypadku pominięcia metody For następuje związanie domyślnej właściwości danego outletu.

Listing 7. Główna część klasy kontrolera – wiązanie danych
public partial class ProductsViewController : BaseViewController
{
    public ProductsViewController() : base("ProductsViewController", null)
    {
    }

    public override void ViewDidLoad()
    { 
        base.ViewDidLoad();
        InitializeBinding();
    }

    private void InitializeBinding()
    { 
        var set = this.CreateBindingSet<ProductsViewController, ProductsViewModel>();

        var source = new ProductListDataSource(ProductList);
        ProductList.Source = source;
        set.Bind(source).For(mn => mn.ItemsSource).To(mn => mn.Products);

        set.Bind(AddProductButton).To(mn => mn.AddProductCommand);
        set.Bind(AddProductButton).For(mn => mn.Enabled).To(mn => mn.IsAddButtonEnabled);

        set.Bind(RemoveAllButton).To(mn => mn.RemoveAllProductsCommand);
        set.Bind(RemoveAllButton).For(mn => mn.Enabled).To(mn => mn.IsRemoveAllButtonEnabled);

        set.Apply();
    }
}


ZAAWANSOWANE WIĄZANIE DANYCH – TWORZENIE KONWERTERÓW ORAZ NIESTANDARDOWYCH WIĄZAŃ


Nierzadko złożone widoki wymagają dodatkowej konwersji (tłumaczenia) bindowanych danych, tj. zmiany typu lub formatu właściwości viewmodelu, która zostaje związana z właściwością określonej kontrolki. W tym celu istnieje możliwość zdefiniowania konwertera implementującego abstrakcyjną klasę MvxValueConverter (Listing 8).

Listing 8. Konwerter tłumaczący wartość bool na odpowiedni UIColor
public class BoolToTextColorConverter : MvxValueConverter
{
    protected override UIColor Convert(bool value, Type targetType, object parameter, CultureInfo culture)
    {
        return value ? UIColor.Red : UIColor.Black;
    }
}

Jak zastosować zdefiniowany konwerter? W przypadku Xamarin. iOS sprowadza się to do wywołania metody WithConversion, która przyjmuje instancję konwertera (Listing 9).

Listing 9. Wiązanie danych z konwerterem w projekcie Xamarin.iOS
set.Bind(EmailTextField).For(x => x.TextColor)
    .To (x => x.IsErrorVisible)
    .WithConversion(new BoolToTextColorConverter());

Xamarin.Android zdaje się być jeszcze bardziej intuicyjny – wystarczy opakować bindowaną właściwość w nazwę naszego konwertera (Listing 10).

Listing 10. Wiązanie danych z konwerterem w projekcie Xamarin.Android
<EditText
    style="@style/EmailEditText"
    local:MvxBind="TextColor BoolToTextColor(IsErrorVisible)" />

Niejednokrotnie zdarza się, iż określona właściwość viewmodelu determinuje zmianę kilku właściwości kontrolki lub wymaga zmiany kontrolki, która może zostać dokonana jedynie poprzez wywołanie na niej jednej lub serii metod. W takiej sytuacji niezbędny staje się mechanizm umożliwiający rejestrację niestandardowych data bidingów. mamy do czynienia np. w przypadku, gdy zachodzi konieczność skojarzenia określonej grupy viewmodeli z fragmentami wyświetlanymi w obrębie głównej aktywności – nawigacja typu flyout.

Listing 11. Przykład definicji niestandardowego data bindingu dla Xamarin.Android
public class TextViewWithHtmlBinding : MvxConvertingTargetBinding 
{
    public TextViewWithHtmlBinding(TextView textView)
        : base(textView)
    {
    }

    public override Type TargetType
    {
        get
        { 
            return typeof(TextView);
        }
    }

    protected override void SetValueImpl(object target, object value)
    {
        var textView = target as TextView;
        textView.MovementMethod = LinkMovementMethod.Instance;
        textView.SetText(Html.FromHtml((string)value), TextView.BufferType.Spannable);
    }
}

Pierwszym krokiem jest utworzenie klasy dziedziczącej po klasie MvxConvertingTargetBinding zgodnie z zamieszczonym przykładem (Listing 11). W przedstawionym przykładzie zdefiniowaliśmy binding dla wartości tekstowej zawierającej znaczniki HTML. Aby zinterpretować je prawidłowo, należy zastosować metodę SetText z odpowiednimi parametrami oraz zmienić właściwość MovementMethod kontrolki TextView – nie jesteśmy w stanie zrobić tego efektywnie, bazując na domyślnych wiązaniach.

Listing 12. Rejestracja niestandardowego wiązania w klasie Setup
protected override void FillTargetFactories(IMvxTargetBindingFactoryRegistry registry)
{ 
    registry.RegisterCustomBindingFactory<TextView>("TextWithHtml", x => new TextViewWithHtmlBinding(x)); 

    base.FillTargetFactories(registry); 
}

Następnie przeciążamy metodę FillTargetFactories w klasie Setup, rejestrując fabrykę utworzonego bindingu pod wybraną nazwą dla odpowiedniego typu kontrolki (Listing 12). Cały proces przebiega analogicznie dla systemu Xamarin.iOS. Zarejestrowane wiązanie może być z powodzeniem wykorzystywane w całej aplikacji w taki sam sposób, jak standardowe, predefiniowane data bindingi.


ZMIANA STANDARDOWEGO SCHEMATU NAWIGACJI


Każda z platformowych paczek MvvmCrossa zawiera domyślny prezenter implementujący interfejs IMvxViewPresenter. Prezenter odpowiada za dostarczenie schematu nawigacji pomiędzy poszczególnymi widokami. Domyślnie wykorzystuje mechanizm refleksji do kojarzenia kontrolerów z odpowiadającymi im viewmodelami, dlatego też kluczowym elementem jest nadawanie kontrolerom nazw odpowiadających nazwom viewmodeli znajdujących się w projekcie PCL. Jeżeli z jakiegoś powodu chcemy zmienić domyślny schemat nawigacji, wystarczy przeciążyć odpowiednie metody domyślnego prezentera (Listing 13), a następnie zwrócić go w metodzie CreateViewPresenter w klasie Setup (Listing 14). Z taką sytuacją mamy do czynienia np. w przypadku, gdy zachodzi konieczność skojarzenia określonej grupy viewmodeli z fragmentami wyświetlanymi w obrębie głównej aktywności – nawigacja typu flyout.

Listing 13. Przeciążenie domyślnego prezentera
public class DroidPresenter : MvxAndroidViewPresenter
{
    public override void Close(IMvxViewModel viewModel)
    {
        Activity.FinishAffinity();
    }
public override void Show(MvxViewModelRequest request) { base.Show(request); } }
Listing 14. Nadpisane domyślnego prezentera w klasie Setup
protected override IMvxAndroidViewPresenter CreateViewPresenter()
{
    return new DroidPresenter();
}


TESTY JEDNOSTKOWE


Niewątpliwie testy jednostkowe są jednym z najważniejszych elementów procesu zapewniania jakości wytwarzanego oprogramowania. Wykorzystanie frameworka MvvmCross wymusza na programistach tworzenie testowalnej architektury całego rozwiązania, dzieki czemu bez większego nakładu pracy jesteśmy w stanie pisać testy jednostkowe dla poszczególnych elementów naszej logiki biznesowej. Idealnie w tej roli sprawdza się rekomendowany przez Xamarina framework NUnit.

Pisanie testów należy rozpocząć od dodania do naszego projektu z testami następującego zestawu paczek NuGet: MvvmCross, MvvmCross.Core oraz MvvmCross.Tests. Naturalnie należy również zadbać o podpięcie referencji do projektu z logiką biznesową aplikacji, a także narzędzia do szybkiego imitowania obiektów – jednym z popularniejszych jest framework Moq.

Listing 15. Tworzenie testów jednostkowych z wykorzystaniem Nunit oraz MvvmCross.Test

[TestFixture] 
public class AddProductViewModelTests : MvxIoCSupportingTest
{ 
    [SetUp]
    public new void Setup()
    { 
        base.Setup();

        Ioc.RegisterType(() => new Mock<IProductRepository>().Object);
    }

    [TestCase("", "2.5", false)]
    [TestCase("Product 1", "", false)]
    [TestCase("", "", false)]
    [TestCase(null, null, false)]
    [TestCase("Product 1", "2.5", true)]
    public void IsAddButtonEnabled_NamePrice(string name, string price, bool expectedValue) 
    {
        var addProductViewModel = Mvx.IocConstruct<AddProductViewModel>();

        addProductViewModel.Name = name;
        addProductViewModel.Price = price;
        var actualValue = addProductViewModel.IsAddButtonEnabled;

        Assert.AreEqual(expectedValue, actualValue);
    }
}

Paczka MvvmCross.Tests zawiera klasę MvxIoCSupportingTest, która jest klasą bazową każdej nowo utworzonej klasy testującej (Listing 15). Za pomocą właściwości Ioc rejestrujemy wszystkie typy niezbędne do utworzenia viewmodelu, który zamierzamy testować (w przykładzie mockuję IProductRepository z wykorzystaniem frameworka Moq). Kontener Mvx odpowiada za utworzenie viewmodelu oraz dostarczenie wszystkich niezbędnych zależności. Następnie przypisujemy testowe wartości do określonych właściwości viewmodelu i badamy zachowanie pozostałych. W praktyce zdeterminują one zmiany stanu widoku skojarzonego z testowanym viewmodelem. Klasę SetUp oraz przypadki testowe określamy, korzystając ze standardowych atrybutów frameworka NUnit.


WTYCZKI ORAZ DODATKOWE KOMPONENTY


Framework MvvmCross dostarcza niezliczonej ilości wtyczek oraz bibliotek dostępnych zarówno na GitHubie pod adresem https:// github.com/MvvmCross/MvvmCross-Plugins, jak również w postaci paczek NuGet. Są to między innymi komponenty upraszczające obsługę bazy danych, dostępności sieci, połączeń, lokalizacji, operacji na plikach, wysyłania wiadomości e-mail, integracji z serwisami społecznościowymi, pobierania oraz przechowywania danych w pamięci podręcznej. Wystarczy dodać odpowiednią paczkę do wszystkich projektów w solucji, a następnie korzystać z dostępnego API po stronie części wspólnej. Istnieje również możliwość tworzenia własnych, wewnętrznych pluginów lub rozwijania istniejących zgodnie z instrukcją dostępną pod adresem https://github.com/MvvmCross/MvvmCross/wiki/MvvmCross-plugins.


PODSUMOWANIE 

W niniejszym artykule zostały przedstawione tylko wybrane, najbardziej kluczowe mechanizmy frameworka MvvmCross. Omawiane rozwiązanie jest regularnie rozwijane, dostarczając coraz to nowych możliwości. Jest to niezaprzeczalnie najlepsze rozwiązanie dla rozbudowanych, wymagających aplikacji biznesowych Xamarin. Dzięki natywnym metodom budowania interfejsu użytkownika możemy zapewnić świetny UX, a przy tym współdzielić znaczącą część całego rozwiązania łącznie z testowalną logiką biznesową aplikacji. Miałem przyjemność brać udział w złożonych projektach realizowanych za pomocą tej technologii składających się z dziesiątek ekranów oraz funkcjonalności, takich jak aplikacje do bankowości mobilnej dla poważnych zagranicznych klientów, które od momentu opublikowania otrzymują najwyższe oceny od setek użytkowników, co jest najlepszym dowodem świadczącym o skuteczności przedstawionego rozwiązania.