07 luty

Reaktywne podejście do Clean Architecture

Do Clean Architecture można podejść na kilka sposobów. Jednym z rozwiązań jest zastosowanie RxJavy 2 i w tym poście przyjrzymy się bliżej temu podejściu.W warstwie prezentacji użyjemy sprawdzonego wzorca Model View Presenter, w warstwie domenowej będziemy mieć UseCase'y z pojedynczą odpowiedzialnością, natomiast w warstwie danych Repository Pattern. Wszystko to będzie sterowane streamami z RxJavy.

W poniższych przykładach użyty został także Dagger2. Nie jest on niezbędny, jednak znacznie ułatwia implementację.

Repository Pattern

Przypuśćmy, że mamy presenter, który pobiera nam użytkowników i ustawia ich dane wywołując odpowiednie metody na widoku. Presenter nie powinien wiedzieć skąd są dane, dlatego początkowo stwórzmy sobie UsersRepository, które będzie korzystać z bazy danych oraz serwisu REST.

@Singleton
public class UsersRepository {

    public static final int DEFAULT_AMOUNT = 5;
    private UsersService usersService;
    private DatabaseManager databaseManager;

    @Inject
    public UsersRepository(UsersService usersService, DatabaseManager databaseManager) {
        this.usersService = usersService;
        this.databaseManager = databaseManager;
    }

    public Flowable<User> loadUsers(String gender) {
        Flowable<User> remoteUsers = usersService.getUsersList(DEFAULT_AMOUNT).flatMap(Flowable::fromIterable);
        Flowable<User> localUsers = databaseManager.getUsers();
        return remoteUsers.concatWith(localUsers).filter(user -> user.getGender().equals(gender));
    }
}

Załóżmy tutaj uproszczoną logikę, jakoby ładowanie użytkowników z repozytorium dostarczało elementy pobrane z serwera jak i z bazy danych. Możemy to zrobić przez skonkatenowanie 2 streamów pochodzących z UsersService i DatabaseManager, obiektów wstrzykiwanych za pomocą Daggera do repozytorium. Oba zwracają dane za pomocą Flowable, jednak serwis dostarcza nam Flowable<List<User>>, zaś baza danych Flowable<User>. Dzięki operatorowi fromIterable przerabiamy stream listy na stream elementów i konkatenujemy oba streamy poprzez concatWith. Dodatkowo metoda loadUsers przyjmuje w argumencie płeć, po jakiej ma filtrować użytkowników.

Use Case

Mamy już gotowe repozytorium, ale presenter nie powinien korzystać z niego bezpośrednio. Do presenterów wstrzykujemy przede wszystkim UseCase’y, które to mogą korzystać z wielu repozytoriów. UseCase'y definiują nam wszystkie akcje, jakie można wykonywać w aplikacji, a każdy z nich ma pojedynczą odpowiedzialność. Stwórzmy sobie więc GetUsersUseCase, który pobiera użytkowników z repozytorium. Nazewnictwo zależy oczywiście od konwencji, ja wybrałem poniższą.

public class GetUsersUseCase extends SingleUseCase<List<User>> {

    private UsersRepository userRepository;
    private String gender;

    @Inject
    public GetUsersUseCase(UsersRepository userRepository) {
        this.userRepository = userRepository;
    }

    public void setGender(String gender) {
        this.gender = gender;
    }

    @Override
    protected Single<List<User>> buildUseCaseSingle() {
        return userRepository.loadUsers(gender).toList();
    }
}

Jak widać możemy tu wyszczególnić metodę buildUseCaseSingle(), którą nadpisujemy z klasy nadrzędnej - SingleUseCase. To co tam się znajduje omówimy za chwilę, natomiast na razie najważniejsze jest to, aby wewnątrz metody buildUseCaseSingle() zwrócić stream, który będzie interesował presentera. W tym przypadku jest to Single zawierający listę użytkowników. Jeśli chcemy wywołać metodę z repository z wybranymi parametrami po prostu tworzymy dodatkowe pole i setter.

Single Use Case
public abstract class SingleUseCase<T> extends UseCase {

    protected abstract Single<T> buildUseCaseSingle();

    private Single<T> buildUseCaseSingleOnIo() {
        return buildUseCaseSingle().compose(RxUtil.applySingleIoSchedulers());
    }

    public void execute(final Consumer<? super T> onSuccess) {
        disposable = buildUseCaseSingleOnIo().subscribe(onSuccess);
    }

    public void execute(final Consumer<? super T> onSuccess, final Consumer<Throwable> onError) {
        disposable = buildUseCaseSingleOnIo().subscribe(onSuccess, onError);
    }
}

SingleUseCase to bazowy UseCase zwracający Single danego typu. Single to po prostu stream zwracający tylko jeden element, po jego zasubskrybowaniu jesteśmy więc w stanie obsłużyć onSuccess bądź onError. Tworzymy w tym celu metodę execute w 2 wariantach: gdy nie mamy zamiaru obsługiwać błędu (jest to niezalecane), oraz w przypadku gdy chcemy błąd obsłużyć. Jako, że metoda buildUseCaseSingle() jest abstrakcyjna, a my właśnie ją zaimplementowaliśmy w GetUsersUseCase, należy z niej w tym miejscu skorzystać. Najpierw ustawiamy odpowiednie Schedulery, tutaj robimy to za pomocą compose i własnej metody z klasy utilowej. W skrócie sprowadza się to do użycia operatorów subscribeOn(Schedulers.io()) oraz observeOn(AndroidSchedulers.mainThread()), dzięki czemu wszystko będzie się pobierać asynchronicznie w tle, a na koniec odpowiednie dane wykorzystamy na głównym wątku.

public static <T> SingleTransformer<T, T> applySingleIoSchedulers() {
      return single -> single.subscribeOn(Schedulers.io())
              .observeOn(AndroidSchedulers.mainThread());
}

W metodach execute subskrybujemy więc zaimplementowaną już metodę i przypisujemy wynik do obiektu disposable, który znajduje się w bazowej abstrakcyjnej klasie UseCase:

public abstract class UseCase {

    protected Disposable disposable = Disposables.disposed();

    public void dispose() {
        disposable.dispose();
    }
}

Robimy to po to, aby w każdej chwili móc przerwać cały stream kiedy tego potrzebujemy. Pozwoli nam na to metoda dispose(), którą presenter będzie wywoływać na UseCase wtedy kiedy np. będzie permanentnie niszczony. Analogicznie możemy stworzyć także FlowableUseCase czy CompletableUseCase.

Presenter

Na koniec wszystko sprowadza się do wstrzyknięcia UseCase do presentera i wywołaniach:

getUsersUseCase.setGender(gender);
getUsersUseCase.execute(this::consumeUsers, this::handleError);

Implementujemy metody consumeUsers i handleError, w których możemy działać na widoku. Kiedy zaś niszczymy nasz widok i odłączamy presentera permanentnie wystarczy wywołać:

getUsersUseCase.dispose();

Możemy to zrobić na wiele sposobów, np. po wstrzyknięciu kilku UseCase'ów do presentera zapisywać je do listy, a w razie potrzeby użyć:

Flowable.fromIterable(useCases).subscribe(UseCase::dispose);

Celowo nie pokazuję pełnej implementacji presentera, bo sposobów na wykonanie MVP jest wiele, a Clean Architecture można użyć praktycznie w każdym z nich. Wystarczy, że presenter będzie używał odpowiednich UseCase’ów.

Podsumowanie

Zdaję sobie sprawę, że jest to tylko pewne wprowadzenie, dlatego pokazuję najważniejsze elementy architektury, aby nie zaciemniać idei. Podejście Clean Architecture można zaimplementować na wiele sposobów, to jest po prostu jeden z nich. Dzięki niemu w prosty sposób możemy otrzymać spójną, testowalną architekturę aplikacji.