Profesjonalne szkolenia technologii Apple

Programowanie reaktywne

W poniższym artykule chciałbym poruszyć zagadnienie programowania reaktywnego. Jestem programistą iOS, więc przykłady będą napisane w języku Swift. Pierwsza część artykułu będzie wprowadzała w paradygmat programowania reaktywnego. Przedstawię w nim proste przykłady jego użycia. W kolejnych częściach artykułu będę chciał omówić wzorzec MVVM, a następnie przedstawić zastosowanie programowanie reaktywnego i wzorca MVVM.

Czym jest programowanie reaktywne? Jest ono bezpośrednio związane z pojęciami obserwatora (Observer) czyli elementu który obserwuje i sekwencji obserwowalnych (Observable). W sekwencjach obserwowalnych na przestrzeni czasu pojawiają się elementy. Zadaniem obserwatora jest reagowanie na wystąpienie określonych elementów i wykonywanie odpowiedniej akcji. Sekwencje obserwowalne mogą być obserwowane przez wielu obserwatorów, a więc wystąpienie jednego elementu w sekwencji obserwowalnej może powodować wykonanie wielu akcji. Programowanie reaktywne korzysta z funkcji łańcuchowych co oznacza, że każda funkcja odbiera wynik poprzedniej funkcji. Dzięki temu sekwencja obserwowalna może zostać przekształcona w inną sekcwencję obserwowalną np. za pomocą operatorów. W programowaniu reaktywnym mamy dostępny spory zbiór operatorów, które umożliwiają wykonywanie operacji na sekwencjach obserwowalnych np. filtrowania, transformowania.

Przejdźmy teraz do praktyki, na początek prosty przykład. Załóżmy, że mamy aplikację mobilną, która zawiera przycisk „Lokalizuj”. Po jego naciśnięciu aplikacja odpyta webserwis o swój zewnętrzny adres IP, a następnie wyświetli go na ekranie. Równocześnie po otrzymaniu adresu IP odpyta kolejny serwis, który na jego podstawie prześle informację o lokalizacji, a następnie wyświetli ją na ekranie.

Projekt początkowy można pobrać tutaj. W celu pobrania projektu możemy użyć następującego polecenia:

git clone https://github.com/mkowszewicz/MKRXLocalization.git

Następnie należy przejść do katalogu głównego projektu i przełączyć się na gałąź początkową poleceniem:

git checkout start

W projekcie wykorzystano menadżera zależności CocoaPods. W celu pobrania wszystkich wymaganych zależności do zbudowania projektu należy wykonać polecenie w katalogu głównym projektu:

pod install

Musimy pamiętać, że w celu uruchomienia projektu należy otworzyć plik przestrzeni roboczej, czyli w naszym przypadku plik MKRXLocalization.xcworkspace. W projekcie wykorzystujemy następujące biblioteki zewnętrzne:

  1. ModelMapper – biblioteka odpowiedzialna za serializację JSON
  2. Moya – biblioteka pozwalająca na konsumpcje webserwisów
  3. Moya-ModelMapper – biblioteka pozwalająca na serializację JSON z wykorzystaniem elementów programowania reaktywnego
  4. RxCocoa – biblioteka rozszerzająca elementy UI o możliwość programowania reaktywnego
  5. RxSwift – biblioteka pozwalająca rozszerzyć język swift o elementy programowania reaktywnego
  6. RxOptional – biblioteka umożliwiająca obsługę typu opcjonalnego z wykorzystaniem programowania reaktywnego

Mamy wszystko, a więc możemy już zacząć naszą przygodę z programowaniem reaktywnym. Na początek prosty przykład. Otwórzmy plik zawierający implementację kontrolera LocationVC.swift. Dodajmy wymagane importy, rozszerzające Cocoa oraz język Swift o wsparcie dla programowania reaktywnego:

import RxCocoa
import RxSwift

Zdefiniujmy zmienną prywatną typu DisposedBag w klasie kontrolera, którą będziemy używać do zwalniania subskrypcji sekwencji obserwowalnych. Dzięki tej zmiennej w łatwy sposób będziemy mogli przerywać cykle zachowania, które powstają podczas tworzenia subskrypcji, czyli rejestracji obserwatorów. Powstanie cyklu zachowania podczas rejestrowania subskrypcji ma na celu uniknięcie konieczności przechowywania silnej referencji. Takie podejście znacznie ułatwia programowanie, a my musimy jedynie pamiętać o zwolnieniu subskrypcji. Możemy to zrobić ręcznie wywołując metodę dispose, jednak nie jest to zalecane podejście. Lepszym rozwiązaniem jest wykorzystanie zmiennej, do której będziemy dodawać subskrypcje. W momencie zwalniania tej zmiennej automatycznie zostanie wykonana metoda dispose dla każdej subskrypcji, która została do niej dodana. W naszym przykładzie subskrypcje, które dodaliśmy do zmiennej zostaną zwolnione podczas kończenia cyklu życia kontrolera. Pod deklaracją właściwości dotyczących UI dodajmy następujący kod:

private let disposeBag = DisposeBag()

Następnie zdefiniujmy jeszcze zmienną prywatną, w której będziemy przetrzymywać ilość kliknięć w przycisk.

private var countOfClickButton = 0

Dzięki rozszerzeniu RXCocoa, możemy uzyskać dostęp do standardowych kontrolek UI. Każda standardowa kontrolka UI ma właściwość rx pozwalającą wykorzystać dla niej koncepcje programowania reaktywnego. zadeklarujmy metodę setupRx w której umieścmy następujący kod:

localizeButton
		.rx.tap
		.subscribe(onNext: {
			self.countOfClickButton += 1
			self.cityLabel.text = "\(self.countOfClickButton)"
		})
		.disposed(by:disposeBag)

Należy pamiętać, żeby wywołać ją w metodzie viewDidLoad. W powyższym fragmencie kodu widzimy, że dostęp do rozszerzenia zawierającego wsparcie dla programowania reaktywnego uzyskujemy przez właściwość rx. Następnie poprzez właściwość tap uzyskujemy dostęp do sekwencji obserwowalnej w której będą się pojawiały zdarzenia emitowane poprzez kliknięcie przycisku. Metoda subscribe pozwala na przypisanie obserwatora do sekwencji obserwowalnej. W celu obserwowania sekwencji obserwowalnej zawierającej zdarzenia emitowane poprzez kliknięcie przycisku należy zdefiniować domknięcie o nazwie onNext. W domknięciu zwiększamy licznik kliknięć w przycisk oraz odświeżamy wyświetlanie licznika na ekranie urządzenia. Dodatkowo należy pamiętać, że mamy możliwość zdefiniowania następujących domknięć:

  1. onNext: ((E) -> Void) – akcja wykonywana dla każdego nowego elementu w obserwowanej sekwencji
  2. onError: ((Swift.Error) -> Void) – akcja wykonywana podczas wystąpienia błędu w obserwowanej sekwencji
  3. onCompleted: (() -> Void) – akcja wykonywana w momencie poprawnego zakończenia obserwowanej sekwencji
  4. onDisposed: (() -> Void)? – akcja wywoływana w momencie zakończenia obserwowanej sekwencji (zarówno zakończonej poprawnie jak i błędnie)

Następnie metoda disposed pozwoli nam na wyrejestrowania subskrypcji w momencie zakończenia cyklu życia kontrolera, w metodzie przekazujemy prywatną zmienną, którą utworzyliśmy w naszym kontrolerze, służy ona do przechowywania subskrypcji, które trzeba będzie zwolnić. W ten sposób możemy w łatwy sposób przerwać cykl zachowania który powstaje podczas rejestracji obserwatora, czyli wywołania metody subscribe.

Powyższy kod wprowadził nas do świata programowania reaktywnego, pokazał jak w łatwy sposób przypisać obserwatora do sekwencji obserwowalnej. W tym momencie możemy skompilować aplikację i zweryfikować jej działanie. Jak widzimy po kliknięciu przycisku licznik kliknięcia zostaje zwiększony i wyświetlony na ekranie. Jednak nasza aplikacja nie spełnia założeń o których napisałem na początku artykułu. Aby osiągnąć zamierzony cel musimy zmodyfikować nasz kod.

W celu komunikacji z webserwisem wykorzystamy biblioteki Moya pozwalającą na konsumpcje webserwisów, Mapper pozwalającą na serializację JSON oraz jej rozszerzenie Moya-ModelMapper pozwalającą na serializację JSON w świecie programowania reaktywnego. W celu uproszczenia przykładu, projekt zawiera już pewną implementację wymaganą do realizacji zadania. W katalogu Entities znajdują się struktury dotyczące modelu, które zostaną zasilone za pomocą webserwisów. Wykorzystują one bibliotekę ModelMapper w celu serializacji JSON.

//  IpAddressEntity.swift
import Mapper

struct IpAddress: Mappable {
    let ip: String

    init(map: Mapper) throws {
        try ip = map.from("ip")
    }
}

 

//  GeoLocationEntity.swift
import Mapper

struct GeoLocation: Mappable {

    let ip: String
    let contryName: String
    let contryCode: String
    let regionCode: String
    let regionName: String
    let city: String
    let zipCode: String
    let timeZone: String
    let latitude: Double
    let longitude: Double
    let metroCode: Int

    init(map: Mapper) throws {
        try ip = map.from("ip")
        try contryName = map.from("country_name")
        try contryCode = map.from("country_code")
        try regionCode = map.from("region_code")
        try regionName = map.from("region_name")
        try city = map.from("city")
        try zipCode = map.from("zip_code")
        try timeZone = map.from("time_zone")
        try latitude = map.from("latitude")
        try longitude = map.from("longitude")
        try metroCode = map.from("metro_code")
    }
}

W katalogu Networking mamy pliki zawierające definicję typów wyliczeniowych odpowiedzialnych za konfiguracje dostępu do webserwisów. Nie będę wchodził w szczegóły, ale powinniśmy w nich zdefiniować: adres webserwisu, parametry przesyłane do webserwisu, rodzaj metody przesyłającej dane, przykładowe dane, kodowanie. Z tych typów wyliczeniowych będziemy korzystać podczas konsumowania webserwisów.

//  IpifyEndpoint.swift
import Foundation
import Moya

enum Ipify {
    case ip()
}

extension Ipify: TargetType {

    var baseURL: URL { return URL(string: "https://api.ipify.org?format=json")! }

    var path: String {
        switch self {
        case .ip(): return "/"
        }
    }

    var method: Moya.Method {
        return .get
    }

    var parameters: [String: Any]? {
        return nil
    }

    var sampleData: Data {
        switch self {
        case
            .ip():
            return "{\"ip\":\"127.0.0.1\"}".data(using: .utf8)!
        }
    }

    var task: Task {
        return .request
    }

    var parameterEncoding: ParameterEncoding {
        return JSONEncoding.default
    }
}

 

//  FreegeoipEndpoint.swift
import Foundation
import Moya

enum Freegeoip {
    case location(ip: String)
}

extension Freegeoip: TargetType {

    var baseURL: URL { return URL(string: "https://freegeoip.net/json")! }

    var path: String {
        switch self {
        case let .location(ip): return "/\(ip)"
        }
    }

    var method: Moya.Method {
        return .get
    }

    var parameters: [String: Any]? {
        return nil
    }

    var sampleData: Data {
        switch self {
        case .location: return "{\"ip\":\"127.0.0.1\",\"country_code\":\"\",\"country_name\":\"\",\"region_code\":\"\",\"region_name\":\"\",\"city\":\"\",\"zip_code\":\"\",\"time_zone\":\"\",\"latitude\":0,\"longitude\":0,\"metro_code\":0}".data(using: .utf8)!
        }
    }

    var task: Task {
        return .request
    }

    var parameterEncoding: ParameterEncoding {
        return JSONEncoding.default
    }
}

Biblioteka Moya umożliwia komunikację z webserwisami. Do tego celu należy utworzyć obiekt klasy RxMoyaProvider. Jest to klasa generyczna, której przekazujemy jako typ nasz typ wyliczeniowy z konfiguracją. Dzięki temu obiektowi możemy komunikować się z webserwisem wykorzystując koncepcję programowania reaktywnego. Wykonanie zapytania odbywa się za pomocą metody request. Następnie widzimy wywołanie metody debug, która ma zadanie diagnostyczne i w zasadzie moglibyśmy ją pominąć. Otrzymaną sekwencję obserwowalną mapujemy na sekwencję obserwowalną zawierającą nasz obiekt modelu za pomocą operatora transformacji mapObjectOptional, a następnie zwracamy ją w metodzie.

//  IpAddressModel.swift
import Foundation
import Moya
import Mapper
import Moya_ModelMapper
import RxSwift

struct IpAddressModel {
    
    let provider: RxMoyaProvider<Ipify>

    func findIpAddress() -> Observable<IpAddress?> {
        return provider
            .request(Ipify.ip())
            .debug()
            .mapObjectOptional(type: IpAddress.self)
    }
}

 

//  GeoLocationModel.swift
import Foundation
import Moya
import Mapper
import Moya_ModelMapper
import RxSwift

struct GeoLocationModel {

    let provider: RxMoyaProvider<Freegeoip>

    func findLocation(ip: String) -> Observable<GeoLocation?> {
        return provider
            .request(Freegeoip.location(ip: ip))
            .debug()
            .mapObjectOptional(type: GeoLocation.self)
    }
}

Mamy już wszystkie elementy do osiągnięcia zamierzonego celu. Przejdźmy do pliku LocationVC.swift dodajmy import biblioteki Moya, która obsłuży nam komunikację z webserwisem:

import Moya

W kolejnym kroku zadeklarujmy prywatną zmienną klasy odpowiedzialnej za komunikację z webserwisem pobierającym zewnętrzny adres IP. Pod deklaracją zmiennej disposeBag umieśćmy następujący kod:

private var providerIp: RxMoyaProvider<Ipify>!

Następnie usuńmy zawartość metody setupRX i dodajmy w niej następujący kod:

providerIp = RxMoyaProvider<Ipify>()
let ipAddressModel = IpAddressModel(provider: providerIp)

// Get ip address
let ip = localizeButton
		.rx.tap
		.throttle(0.3, scheduler: MainScheduler.instance).flatMapLatest {
    			ipAddressModel.findIpAddress()
		}
		.map {
    			$0?.ip ?? ""
		}
		.distinctUntilChanged()

// Show ip address on the view
ip
	.bind(to: ipAddressLabel.rx.text)
	.disposed(by: disposeBag)

W pierwszych dwóch wierszach tworzymy instancje klasy i struktury odpowiadających za komunikację z webserwisem. W pierwszym wierszu tworzymy obiekt generycznej klasy odpowiedzialnej za komunikację z webserwiem jako typ wskazujemy typ wyliczeniowy zawierający konfigurację połączenia z webserwisem. W drugim wierszu tworzymy instancję struktury, która odpyta webserwis wykorzystując obiekt klasy zdefiniowany w pierwszym wierszu, następnie zserializuje dane do odpowiedniego obiektu i zwróci go w postaci obserwowalnej sekwncji. Następnie subskrybujemy zdarzenie kliknięcia przycisku poprzez operator tap, dokładnie tak samo jak we wcześniejszej wersji kodu. Operator throttle pozwala na odfiltrowanie elementów, pomiędzy którymi odstęp pojawienia się w obserwowanej sekwencji wynosi mniej niż pewna określona wartość, w naszym przypadku jest to 0.3 sekundy, wszystkie elementy, które zostaną wyemitowane w mniejszym odstępie czasu zostaną zignorowane. Następnie transformujemy naszą sekwencję obserwowalną Observable<Void> do sekwencji obserwowalnej Observable<IpAddress> za pomocą operatora transformacji flatMapLatest, ten operator gwarantuje nam, że transformacji zostanie dokonane najbardziej aktualny element, czyli ten, który pojawił się jako ostatni w sekwencji obserwowalnej. Jest to bardzo przydatne podczas pobierania danych z webserwisu, ponieważ może zdarzyć się sytuacja, że kolejność żądań zostanie przetworzona w innej kolejności przez webserwis, a klient dostanie odpowiedzi w niechronologicznej kolejności. Co prawda w naszej aplikacji nie będzie miało to znaczenia, ale myślę, że trzeba było o tym wspomnieć. Następnie wykonujemy kolejną transformację otrzymanej sekwencji obserwowalnej Observable<IpAddress> na Observable<String>, tym razem użyliśmy operatora transformacji map. Na koniec używamy operatora distinctUntilChanged umożliwiającego odfiltrowanie elementów, które są różne od elementów ich poprzedzających.

W zmiennej ip przechowujemy sekwencję obserwowalną w której mogą występować elementy zawierające informacje o adresie IP urządzenia. W tej sytuacji jeśli otrzymamy taki element, to należałoby uaktualnić nasz ekran i wyświetlić w nim adres IP. W tym celu powiążemy sekwencję obserwowalną z właściwością text odpowiedniego elementu UI. Wykonany to za pomocą poniższego kodu:

ip
	.bind(to: ipAddressLabel.rx.text)
	.disposed(by: disposeBag)

W powyższym kodzie możemy zauważyć też wywołanie funkcji disposed, która spowoduje zwolnienie obserwowanej sekwencji na koniec cyklu życia naszego kontrolera. Teraz możemy uruchomić aplikację, po kliknięciu przycisku „Lokalizuj” na ekranie powinien pokazać się nasz zewnętrzny adres IP.

Teraz zajmijmy się pobraniem danych lokalizacyjnych na podstawie adresu IP. Na początku zdefiniujmy zmienne odpowiedzialne za komunikację z webserwisem, w takim sam sposób jak w przypadku webserwisu pobierającego adres IP. Pod zmiennymi odpowiedzialnymi za komunikację z webserwisem pobierającym zewnętrzny adres IP dodajmy następujący kod:

providerLocation = RxMoyaProvider<Freegeoip>()
let geoLocationModel = GeoLocationModel(provider: providerLocation)

Następnie podłączmy się pod naszą sekwencję obserwowalną zawierającą informację na temat adresu IP. W taki sposób, aby dla każdego przychodzącego adresu IP nastąpiło odpytanie webserwisu o dane lokalizacyjne. Wykorzystamy do tego celu operator flatMap dla którego należy zdefiniować domknięcie w którym odpytamy serwis lokalizacyjny. Spowoduje to wyemitowanie sekwencji obserwowalnej zawierającej dane lokalizacyjne Observable<GeoLocation>. Tę sekwencję przypiszemy do zmiennej location.

let location = ip
		.flatMap {
			return geoLocationModel.findLocation(ip: $0)
		}

Zajmijmy się teraz uzyskaniem nazwy miasta skojarzonego z naszym adresem IP. Na początku wykonamy transformację sekwencji obserwowalnej Observable<GeoLocation> do sekwencji obserwowalnejObservable<String> za pomocą znanego nam operatora transformacji map. Taką sekwencję obserwowalną będziemy mogli przypiąć pod odpowiednią kontrolkę UI wykonując poniższy kod:

location
	.map {
		$0?.city ?? ""
	}
	.bind(to: cityLabel.rx.text)
	.disposed(by: disposeBag)

Na sam koniec wykonamy funkcję disposed, która zwolni subskrypcję w przypadku zakończenia cyklu życia kontrolera.

W analogiczny sposób pobierzemy współrzędne lokalizacyjne, w tym celu dodajmy następujący kod:

 location
            .map {
                guard let latitude = $0?.latitude, let longitude = $0?.longitude else {
                    return ""
                }
                return "\(latitude), \(longitude)"
            }
            .bind(to: localizationLabel.rx.text)
            .disposed(by: disposeBag)

Jak widzimy wykonujemy dokładnie takie same czynności jak poprzednio. Dodatkowo możemy zauważyć, że obserwujemy tą samą sekwencję obserwowalną, a więc tak jak pisałem na samym początku, jedna sekwencja obserwowalna może być obserwowana przez wielu obserwatorów. Teraz możemy uruchomić aplikację i zweryfikować jej działanie. Gotową aplikację możemy pobrać tutaj.

Aplikacja działa zgodnie z początkowymi założeniami. Dzięki zastosowaniu koncepcji programowania reaktywnego udało nam się ograniczyć ilość kodu wymaganą do zrealizowania celu. Jeżeli chcielibyśmy uzyskać taki sam efekt stosując programowanie imperatywne musielibyśmy wytworzyć na pewno więcej kodu. Dodatkowo nasz kod ma większy poziom abstrakcji, przez co w łatwiejszy sposób można go modyfikować i jest prostszy do zrozumienia. Programowanie reaktywne ułatwia skupienie się nad rzeczywistą logiką aplikacji, ponieważ stosują programowanie imperatywne, często musimy zapamiętywać różne stany aplikacji przez co nasza logika staje się coraz bardziej rozbudowana i w mniejszym stopniu skupia się na głównym zagadnieniu. Dzięki programowaniu reaktywnemu możemy ograniczyć złożoność eliminując konieczność przechowywania informacji o stanie aplikacji. Dodatkowo programowanie reaktywne umożliwia „luźne” powiązanie elementów w aplikacji, przez co w razie potrzeby możemy je bardzo łatwo zmieniać lub rozbudowywać. Jeżeli chodzi o minusy koncepcji programowania reaktywnego, to według mnie jest to debagowanie. Przynajmniej na początku może sprawić trochę kłopotu. Jednak uwzględniając cały bilans strat i korzyści uważam, że jest to bardzo przydatna technika programowania. Zachęcam do wykorzystania jej w aplikacjach mobilnych.

Źródło zdjęć: opracowanie własne autora.
Artuykuł opublikowany na friweb.pl

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.