Odkryj zalety czystej architektury Androida

Opublikowany: 2022-03-11

Co wolałbyś: dodać nową funkcję do bardzo dobrze działającej aplikacji o okropnej architekturze, czy naprawić błąd w dobrze zaprojektowanej, ale wadliwej aplikacji na Androida? Osobiście zdecydowanie wybrałbym drugą opcję. Dodanie nowej funkcji, nawet prostej, może być bardzo pracochłonne w aplikacji, biorąc pod uwagę wszystkie zależności ze wszystkiego w każdej klasie. Pamiętam jeden z moich projektów na Androida, w którym kierownik projektu poprosił mnie o dodanie małej funkcji — na przykład pobieranie danych i wyświetlanie ich na nowym ekranie. Była to aplikacja napisana przez jednego z moich kolegów, który znalazł nową pracę. Funkcja nie powinna zająć więcej niż pół dnia roboczego. Byłem bardzo optymistyczny…

Po siedmiu godzinach sprawdzania, jak działa aplikacja, jakie są moduły i jak się ze sobą komunikują, wykonałem kilka próbnych implementacji tej funkcji. To było piekło. Mała zmiana w modelu danych wymusiła dużą zmianę na ekranie logowania. Dodanie żądania sieciowego wymagało zmian implementacji prawie wszystkich ekranów oraz klasy GodOnlyKnowsWhatThisClassDoes . Zmiany koloru przycisków spowodowały dziwne zachowanie podczas zapisywania danych w bazie danych lub całkowitą awarię aplikacji. W połowie następnego dnia powiedziałem kierownikowi projektu: „Mamy dwa sposoby na zaimplementowanie tej funkcji. Po pierwsze, mogę poświęcić na to jeszcze trzy dni i w końcu zaimplementować go w bardzo brudny sposób, a czas wdrożenia każdej kolejnej funkcji lub poprawki błędów będzie rósł wykładniczo. Lub mogę przepisać aplikację. Zajmie mi to dwa lub trzy tygodnie, ale zaoszczędzimy czas na przyszłe zmiany aplikacji”. Na szczęście zgodził się na drugą opcję. Jeśli kiedykolwiek miałem wątpliwości, dlaczego dobra architektura oprogramowania w aplikacji (nawet bardzo mała) jest ważna, ta aplikacja całkowicie je rozwiała. Ale jakiego wzorca architektury Androida powinniśmy użyć, aby uniknąć takich problemów?

W tym artykule chciałbym pokazać przykład czystej architektury w aplikacji na Androida. Główne idee tego wzorca można jednak dostosować do każdej platformy i języka. Dobra architektura powinna być niezależna od szczegółów, takich jak platforma, język, system bazy danych, dane wejściowe lub wyjściowe.

Przykładowa aplikacja

Stworzymy prostą aplikację na Androida do rejestracji naszej lokalizacji z następującymi funkcjami:

  • Użytkownik może utworzyć konto o nazwie.
  • Użytkownik może edytować nazwę konta.
  • Użytkownik może usunąć konto.
  • Użytkownik może wybrać aktywne konto.
  • Użytkownik może zapisać lokalizację.
  • Użytkownik może zobaczyć listę lokalizacji dla użytkownika.
  • Użytkownik może zobaczyć listę użytkowników.

Czysta architektura

Warstwy są głównym rdzeniem czystej architektury. W naszej aplikacji użyjemy trzech warstw: prezentacji, domeny i modelu. Każda warstwa powinna być oddzielona i nie musi wiedzieć o innych warstwach. Powinien istnieć we własnym świecie i co najwyżej dzielić mały interfejs do komunikacji.

Obowiązki warstwy:

  • Domena: zawiera zasady biznesowe naszej aplikacji. Powinien zawierać przypadki użycia, które odzwierciedlają cechy naszej aplikacji.
  • Prezentacja: przedstawia dane użytkownikowi, a także zbiera niezbędne dane, takie jak nazwa użytkownika. To jest rodzaj wejścia/wyjścia.
  • Model: dostarcza dane dla naszej aplikacji. Odpowiada za pozyskiwanie danych ze źródeł zewnętrznych i zapisywanie ich do bazy danych, serwera w chmurze itp.

Które warstwy powinny wiedzieć o innych? Najprostszym sposobem uzyskania odpowiedzi jest myślenie o zmianach. Weźmy warstwę prezentacji — zaprezentujemy coś użytkownikowi. Jeśli zmieniamy coś w prezentacji, czy powinniśmy również dokonać zmiany w warstwie modelowej? Wyobraź sobie, że mamy ekran „Użytkownik” z nazwą użytkownika i ostatnią lokalizacją. Jeśli chcemy zaprezentować dwie ostatnie lokalizacje użytkownika zamiast tylko jednej, nie powinno to mieć wpływu na nasz model. Mamy więc pierwszą zasadę: warstwa prezentacji nie wie o warstwie modelu.

I odwrotnie – czy warstwa modelu powinna wiedzieć o warstwie prezentacji? Znowu – nie, bo jeśli zmienimy np. źródło danych z bazy danych na sieć, to nie powinno to nic zmieniać w UI (jeśli myślałeś o dodaniu tutaj loadera – tak, ale możemy też mieć loader UI podczas korzystania z bazy danych). Więc dwie warstwy są całkowicie oddzielne. Świetnie!

A co z warstwą domeny? Jest to najważniejszy, ponieważ zawiera całą główną logikę biznesową. To tutaj chcemy przetwarzać nasze dane przed przekazaniem ich do warstwy modelowej lub przedstawieniem ich użytkownikowi. Powinna być niezależna od jakiejkolwiek innej warstwy — nie wie nic o bazie danych, sieci ani interfejsie użytkownika. Ponieważ jest to rdzeń, inne warstwy będą komunikować się tylko z tą. Dlaczego chcemy mieć to całkowicie niezależne? Reguły biznesowe prawdopodobnie będą się zmieniać rzadziej niż projekty interfejsu użytkownika lub coś w bazie danych lub pamięci sieciowej. Z tą warstwą będziemy się komunikować za pośrednictwem dostarczonych interfejsów. Nie używa żadnego konkretnego modelu ani implementacji interfejsu użytkownika. To są szczegóły i pamiętaj – szczegóły się zmieniają. Dobra architektura nie ogranicza się do szczegółów.

Na razie dość teorii. Zacznijmy kodować! Ten artykuł kręci się wokół kodu, więc – dla lepszego zrozumienia – powinieneś pobrać kod z GitHub i sprawdzić, co jest w środku. Utworzono trzy tagi Git — architektura_v1, architektura_v2 i architektura_v3, które odpowiadają częściom artykułu.

Technologia aplikacji

W aplikacji używam Kotlina i Daggera 2 do wstrzykiwania zależności. Ani Kotlin, ani Dagger 2 nie są tutaj potrzebne, ale to znacznie ułatwia sprawę. Możesz się zdziwić, że nie używam RxJava (ani RxKotlin), ale nie znalazłem jej tutaj do użytku, a nie lubię korzystać z żadnej biblioteki tylko dlatego, że jest na wierzchu i ktoś mówi, że jest koniecznością. Jak powiedziałem – język i biblioteki to szczegóły, więc możesz używać tego, co chcesz. Wykorzystywane są również niektóre biblioteki testów jednostkowych Androida: JUnit, Robolectric i Mockito.

Domena

Najważniejszą warstwą w naszym projekcie architektury aplikacji na Androida jest warstwa domeny. Zacznijmy od tego. To tutaj będzie nasza logika biznesowa i interfejsy do komunikacji z innymi warstwami. Głównym rdzeniem są przypadki UseCase , które odzwierciedlają to, co użytkownik może zrobić z naszą aplikacją. Przygotujmy dla nich abstrakcję:

 abstract class UseCase<out Type, in Params> { private var job: Deferred<OneOf<Failure, Type>>? = null abstract suspend fun run(params: Params): OneOf<Failure, Type> fun execute(params: Params, onResult: (OneOf<Failure, Type>) -> Unit) { job?.cancel() job = async(CommonPool) { run(params) } launch(UI) { val result = job!!.await() onResult(result) } } open fun cancel() { job?.cancel() } open class NoParams }

Postanowiłem wykorzystać tutaj współprogramy Kotlina. Każdy UseCase musi zaimplementować metodę uruchamiania, aby dostarczyć dane. Ta metoda jest wywoływana w wątku w tle, a po otrzymaniu wyniku jest dostarczana w wątku interfejsu użytkownika. Zwracany typ to OneOf<F, T> — możemy zwrócić błąd lub sukces z danymi:

 sealed class OneOf<out E, out S> { data class Error<out E>(val error: E) : OneOf<E, Nothing>() data class Success<out S>(val data: S) : OneOf<Nothing, S>() val isSuccess get() = this is Success<S> val isError get() = this is Error<E> fun <E> error(error: E) = Error(error) fun <S> success(data: S) = Success(data) fun oneOf(onError: (E) -> Any, onSuccess: (S) -> Any): Any = when (this) { is Error -> onError(error) is Success -> onSuccess(data) } }

Warstwa domeny potrzebuje własnych jednostek, więc kolejnym krokiem jest ich zdefiniowanie. Na razie mamy dwie jednostki: User i UserLocation :

 data class User(var id: Int? = null, val name: String, var isActive: Boolean = false) data class UserLocation(var id: Int? = null, val latitude: Double, val longitude: Double, val time: Long, val userId: Int)

Teraz, gdy wiemy, jakie dane zwrócić, musimy zadeklarować interfejsy naszych dostawców danych. Będą to IUsersRepository i ILocationsRepository . Muszą być zaimplementowane w warstwie modelowej:

 interface IUsersRepository { fun setActiveUser(userId: Int): OneOf<Failure, User> fun getActiveUser(): OneOf<Failure, User?> fun createUser(user: User): OneOf<Failure, User> fun removeUser(userId: Int): OneOf<Failure, User?> fun editUser(user: User): OneOf<Failure, User> fun users(): OneOf<Failure, List<User>> } interface ILocationsRepository { fun locations(userId: Int): OneOf<Failure, List<UserLocation>> fun addLocation(location: UserLocation): OneOf<Failure, UserLocation> }

Ten zestaw działań powinien wystarczyć do dostarczenia niezbędnych danych dla aplikacji. Na tym etapie nie decydujemy o sposobie przechowywania danych – to szczegół, od którego chcemy być niezależni. Na razie nasza warstwa domen nawet nie wie, że jest na Androidzie. Postaramy się utrzymać ten stan (tak jakby wyjaśnię później).

Ostatnim (lub prawie ostatnim) krokiem jest zdefiniowanie implementacji dla naszych UseCase , które będą wykorzystywane przez dane prezentacji. Wszystkie są bardzo proste (podobnie jak prosta jest nasza aplikacja i dane) – ich działanie ogranicza się do wywołania odpowiedniej metody z repozytorium, np.:

 class GetLocations @Inject constructor(private val repository: ILocationsRepository) : UseCase<List<UserLocation>, UserIdParams>() { override suspend fun run(params: UserIdParams): OneOf<Failure, List<UserLocation>> = repository.locations(params.userId) }

Abstrakcja Repository sprawia, że ​​nasze UseCases bardzo łatwe do przetestowania — nie musimy przejmować się siecią ani bazą danych. Można go naśladować w dowolny sposób, więc nasze testy jednostkowe będą testować rzeczywiste przypadki użycia, a nie inne, niepowiązane ze sobą klasy. Dzięki temu nasze testy jednostkowe będą proste i szybkie:

 @RunWith(MockitoJUnitRunner::class) class GetLocationsTests { private lateinit var getLocations: GetLocations private val locations = listOf(UserLocation(1, 1.0, 1.0, 1L, 1)) @Mock private lateinit var locationsRepository: ILocationsRepository @Before fun setUp() { getLocations = GetLocations(locationsRepository) } @Test fun `should call getLocations locations`() { runBlocking { getLocations.run(UserIdParams(1)) } verify(locationsRepository, times(1)).locations(1) } @Test fun `should return locations obtained from locationsRepository`() { given { locationsRepository.locations(1) }.willReturn(OneOf.Success(locations)) val returnedLocations = runBlocking { getLocations.run(UserIdParams(1)) } returnedLocations shouldEqual OneOf.Success(locations) } }

Na razie warstwa domeny jest skończona.

Model

Jako programista Androida prawdopodobnie wybierzesz Room, nową bibliotekę Androida do przechowywania danych. Ale wyobraźmy sobie, że kierownik projektu zapytał, czy możesz odłożyć decyzję dotyczącą bazy danych, ponieważ kierownictwo próbuje wybrać między Room, Realm i jakąś nową, superszybką biblioteką pamięci. Potrzebujemy trochę danych, aby rozpocząć pracę z interfejsem użytkownika, więc na razie zachowamy je w pamięci:

 class MemoryLocationsRepository @Inject constructor(): ILocationsRepository { private val locations = mutableListOf<UserLocation>() override fun locations(userId: Int): OneOf<Failure, List<UserLocation>> = OneOf.Success(locations.filter { it.userId == userId }) override fun addLocation(location: UserLocation): OneOf<Failure, UserLocation> { val addedLocation = location.copy(id = locations.size + 1) locations.add(addedLocation) return OneOf.Success(addedLocation) } }

Prezentacja

Dwa lata temu napisałem artykuł o MVP jako bardzo dobrej strukturze aplikacji na Androida. Kiedy Google ogłosił wspaniałe komponenty architektury, które znacznie ułatwiły tworzenie aplikacji na Androida, MVP nie jest już potrzebne i może zostać zastąpione przez MVVM; jednak niektóre pomysły z tego wzorca są nadal bardzo przydatne - jak ten o głupich poglądach. Powinni dbać tylko o wyświetlanie danych. Aby to osiągnąć, skorzystamy z ViewModel i LiveData.

Projekt naszej aplikacji jest bardzo prosty – jedna czynność z dolną nawigacją, w której dwa wpisy w menu pokazują fragment locations lub fragment users . W tych widokach używamy ViewModels, które z kolei wykorzystują UseCase z warstwy domeny, utrzymując komunikację zgrabną i prostą. Na przykład tutaj jest LocationsViewModel :

 class LocationsViewModel @Inject constructor(private val getLocations: GetLocations, private val saveLocation: SaveLocation) : BaseViewModel() { var locations = MutableLiveData<List<UserLocation>>() fun loadLocations(userId: Int) { getLocations.execute(UserIdParams(userId)) { it.oneOf(::handleError, ::handleLocationsChange) } } fun saveLocation(location: UserLocation, onSaved: (UserLocation) -> Unit) { saveLocation.execute(UserLocationParams(location)) { it.oneOf(::handleError) { location -> handleLocationSave(location, onSaved) } } } private fun handleLocationSave(location: UserLocation, onSaved: (UserLocation) -> Unit) { val currentLocations = locations.value?.toMutableList() ?: mutableListOf() currentLocations.add(location) this.locations.value = currentLocations onSaved(location) } private fun handleLocationsChange(locations: List<UserLocation>) { this.locations.value = locations } }

Małe wyjaśnienie dla tych, którzy nie są zaznajomieni z ViewModels — nasze dane są przechowywane w zmiennej location. Gdy uzyskujemy dane z przypadku użycia getLocations , są one przekazywane do wartości LiveData . Ta zmiana powiadomi obserwatorów, aby mogli zareagować i zaktualizować swoje dane. Do danych we fragmencie dodajemy obserwatora:

 class LocationsFragment : BaseFragment() { ... private fun initLocationsViewModel() { locationsViewModel = ViewModelProviders.of(activity!!, viewModelFactory)[LocationsViewModel::class.java] locationsViewModel.locations.observe(this, Observer<List<UserLocation>> { showLocations(it ?: emptyList()) }) locationsViewModel.error.observe(this, Observer<Failure> { handleError(it) }) } private fun showLocations(locations: List<UserLocation>) { locationsAdapter.locations = locations } private fun handleError(error: Failure?) { toast(R.string.user_fetch_error).show() } }

Przy każdej zmianie lokalizacji po prostu przekazujemy nowe dane do adaptera przypisanego do widoku recyklera — i właśnie tam idzie normalny przepływ Androida do wyświetlania danych w widoku recyklera.

Ponieważ używamy ViewModel w naszych widokach, ich zachowanie jest również łatwe do przetestowania — możemy po prostu zakpić z ViewModels i nie przejmować się źródłem danych, siecią lub innymi czynnikami:

 @RunWith(RobolectricTestRunner::class) @Config(application = TestRegistryRobolectricApplication::class) class LocationsFragmentTests { private var usersViewModel = mock(UsersViewModel::class.java) private var locationsViewModel = mock(LocationsViewModel::class.java) lateinit var fragment: LocationsFragment @Before fun setUp() { UsersViewModelMock.intializeMock(usersViewModel) LocationsViewModelMock.intializeMock(locationsViewModel) fragment = LocationsFragment() fragment.viewModelFactory = ViewModelUtils.createFactoryForViewModels(usersViewModel, locationsViewModel) startFragment(fragment) } @Test fun `should getActiveUser on start`() { Mockito.verify(usersViewModel).getActiveUser() } @Test fun `should load locations from active user`() { usersViewModel.activeUserId.value = 1 Mockito.verify(locationsViewModel).loadLocations(1) } @Test fun `should display locations`() { val date = Date(1362919080000)//10-03-2013 13:38 locationsViewModel.locations.value = listOf(UserLocation(1, 1.0, 2.0, date.time, 1)) val recyclerView = fragment.find<RecyclerView>(R.id.locationsRecyclerView) recyclerView.measure(100, 100) recyclerView.layout(0,0, 100, 100) val adapter = recyclerView.adapter as LocationsListAdapter adapter.itemCount `should be` 1 val viewHolder = recyclerView.findViewHolderForAdapterPosition(0) as LocationsListAdapter.LocationViewHolder viewHolder.latitude.text `should equal` "Lat: 1.0" viewHolder.longitude.text `should equal` "Lng: 2.0" viewHolder.locationDate.text `should equal` "10-03-2013 13:38" } }

Możesz zauważyć, że warstwa prezentacji jest również podzielona na mniejsze warstwy z wyraźnymi obramowaniami. Widoki typu activities , fragments , ViewHolders itp. odpowiadają jedynie za wyświetlanie danych. Wiedzą tylko o warstwie ViewModel — i używają jej tylko do pobierania lub wysyłania użytkowników i lokalizacji. Jest to ViewModel, który komunikuje się z domeną. Implementacje ViewModel są takie same dla widoku, jak przypadki użycia dla domeny. Parafrazując, czysta architektura jest jak cebula — ma warstwy, a warstwy mogą również mieć warstwy.

Wstrzykiwanie zależności

Stworzyliśmy wszystkie klasy dla naszej architektury, ale jest jeszcze jedna rzecz do zrobienia – potrzebujemy czegoś, co wszystko połączy. Warstwy prezentacji, domeny i modelu są utrzymywane w czystości, ale potrzebujemy jednego modułu, który będzie brudny i będzie wiedział wszystko o wszystkim – dzięki tej wiedzy będzie mógł połączyć nasze warstwy. Najlepszym sposobem na to jest użycie jednego z powszechnych wzorców projektowych (jedna z zasad czystego kodu zdefiniowanej w SOLID) — wstrzykiwania zależności, które tworzy dla nas odpowiednie obiekty i wstrzykuje je do pożądanych zależności. Użyłem tutaj Daggera 2 (w połowie projektu zmieniłem wersję na 2.16, która ma mniej boilerplate’u), ale możesz użyć dowolnego mechanizmu. Ostatnio bawiłem się trochę biblioteką Koin i myślę, że warto spróbować. Chciałem go tutaj użyć, ale miałem spore problemy z podszyciem ViewModels podczas testowania. Mam nadzieję, że znajdę sposób, aby szybko je rozwiązać — jeśli tak, mogę przedstawić różnice w tej aplikacji podczas korzystania z Koin i Dagger 2.

Możesz sprawdzić aplikację dla tego etapu na GitHub za pomocą tagu architecture_v1.

Zmiany

Skończyliśmy nasze warstwy, przetestowaliśmy aplikację — wszystko działa! Z wyjątkiem jednej rzeczy – nadal musimy wiedzieć, z jakiej bazy danych nasz kierownik projektu chce korzystać. Załóżmy, że przyszli do Ciebie i powiedzieli, że zarząd zgodził się na korzystanie z Room, ale nadal chcą mieć możliwość korzystania z najnowszej, superszybkiej biblioteki w przyszłości, więc musisz mieć na uwadze potencjalne zmiany. Również jeden z interesariuszy zapytał, czy dane można przechowywać w chmurze i chce poznać koszt takiej zmiany. Nadszedł więc czas, aby sprawdzić, czy nasza architektura jest dobra i czy możemy zmienić system przechowywania danych bez zmian w warstwie prezentacyjnej lub domenowej.

Zmień 1

Pierwszą rzeczą przy korzystaniu z Room jest zdefiniowanie encji dla bazy danych. Mamy już kilka: User i UserLocation . Wszystko, co musimy zrobić, to dodać adnotacje, takie jak @Entity i @PrimaryKey , a następnie możemy ich użyć w naszej warstwie modelu z bazą danych. Świetnie! To doskonały sposób na złamanie wszystkich zasad architektury, które chcieliśmy zachować. W rzeczywistości jednostka domeny nie może być w ten sposób przekonwertowana na jednostkę bazy danych. Wyobraź sobie, że chcemy również pobrać dane z sieci. Moglibyśmy użyć trochę więcej klas do obsługi odpowiedzi sieciowych — konwertując nasze proste encje, aby działały z bazą danych i siecią. To najkrótsza droga do przyszłej katastrofy (i płacz: „Kto do cholery napisał ten kod?”). Potrzebujemy oddzielnych klas encji dla każdego używanego typu przechowywania danych. Nie kosztuje to dużo, więc zdefiniujmy poprawnie jednostki Room:

 @Entity data class UserEntity( @PrimaryKey(autoGenerate = true) var id: Long?, @ColumnInfo(name = "name") var name: String, @ColumnInfo(name = "isActive") var isActive: Boolean = false ) @Entity(foreignKeys = [ ForeignKey(entity = UserEntity::class, parentColumns = [ "id" ], childColumns = [ "userId" ], onDelete = CASCADE) ]) data class UserLocationEntity( @PrimaryKey(autoGenerate = true) var id: Long?, @ColumnInfo(name = "latitude") var latitude: Double, @ColumnInfo(name = "longitude") var longitude: Double, @ColumnInfo(name = "time") var time: Long, @ColumnInfo(name = "userId") var userId: Long )

Jak widać, są one prawie takie same jak encje domeny, więc istnieje duża pokusa, aby je scalić. To tylko przypadek – przy bardziej skomplikowanych danych podobieństwo będzie mniejsze.

Następnie musimy zaimplementować UserDAO i UserLocationsDAO , naszą AppDatabase , a na końcu implementacje dla IUsersRepository i ILocationsRepository . Tutaj jest mały problem — ILocationsRepository powinien zwrócić UserLocation , ale otrzymuje UserLocationEntity z bazy danych. To samo dotyczy klas związanych z User . W przeciwnym kierunku przekazujemy UserLocation , gdy baza danych wymaga UserLocationEntity . Aby rozwiązać ten problem, potrzebujemy Mappers między naszą domeną a jednostkami danych. Skorzystałem z jednej z moich ulubionych funkcji Kotlina – rozszerzeń. Stworzyłem plik o nazwie Mapper.kt i umieściłem tam wszystkie metody mapowania między klasami (oczywiście jest to w warstwie modelu — domena tego nie potrzebuje):

 fun User.toEntity() = UserEntity(id?.toLong(), name, isActive) fun UserEntity.toUser() = User(this.id?.toInt(), name, isActive) fun UserLocation.toEntity() = UserLocationEntity(id?.toLong(), latitude, longitude, time, userId.toLong()) fun UserLocationEntity.toUserLocation() = UserLocation(id?.toInt(), latitude, longitude, time, userId.toInt())

Małe kłamstwo, o którym wspomniałem wcześniej, dotyczy encji domenowych. Napisałem, że nic nie wiedzą o Androidzie, ale to nie do końca prawda. Do encji User dodałem adnotację @Parcelize i rozszerzyłem tam Parcelable , dzięki czemu możliwe jest przekazanie encji do fragmentu. W przypadku bardziej skomplikowanych struktur powinniśmy zapewnić własne klasy danych warstwy widoku i utworzyć mapery, takie jak między domeną a modelami danych. Dodanie Parcelable do encji domeny to małe ryzyko, które odważyłem się podjąć – jestem tego świadomy i w przypadku jakichkolwiek zmian w encji User stworzę osobne klasy danych do prezentacji i Parcelable z warstwy domeny.

Ostatnią rzeczą do zrobienia jest zmiana naszego modułu wstrzykiwania zależności, aby zapewnić nowo utworzoną implementację Repository zamiast poprzedniej MemoryRepository . Po zbudowaniu i uruchomieniu aplikacji możemy przejść do PM, aby pokazać działającą aplikację z bazą danych Room. Możemy również poinformować kierownika projektu, że dodanie sieci nie zajmie zbyt wiele czasu i że jesteśmy otwarci na każdą bibliotekę pamięci, jaką sobie życzy zarząd. Możesz sprawdzić, które pliki zostały zmienione — tylko te w warstwie modelu. Nasza architektura jest naprawdę schludna! Każdy kolejny typ magazynu można zbudować w ten sam sposób, po prostu rozszerzając nasze repozytoria i zapewniając odpowiednie implementacje. Oczywiście może się okazać, że potrzebujemy wielu źródeł danych, takich jak baza danych i sieć. Co wtedy? Nic z tego, musielibyśmy po prostu stworzyć trzy implementacje repozytorium – jedną dla sieci, jedną dla bazy danych i główną, w której wybrane zostałoby właściwe źródło danych (np. jeśli mamy sieć, ładuje się z sieci, a jeśli nie, załaduj z bazy danych).

Możesz sprawdzić aplikację dla tego etapu na GitHub z tagiem architecture_v2.

Tak więc dzień prawie się skończył – siedzisz przed komputerem z filiżanką kawy, aplikacja jest gotowa do wysłania do Google Play, gdy nagle przychodzi do Ciebie kierownik projektu i pyta „Czy mógłbyś dodać funkcję, która może zapisać aktualną lokalizację użytkownika z GPS?”

Zmień 2

Wszystko się zmienia… zwłaszcza oprogramowanie. Dlatego potrzebujemy czystego kodu i czystej architektury. Jednak nawet najczystsze rzeczy mogą być brudne, jeśli kodujemy bez zastanowienia. Pierwszą myślą przy wdrażaniu pobierania lokalizacji z GPS byłoby dodanie całego kodu uwzględniającego lokalizację w działaniu, uruchomienie go w naszym SaveLocationDialogFragment i utworzenie nowego UserLocation z odpowiednimi danymi. To może być najszybszy sposób. Ale co, jeśli nasz szalony PM przyjdzie do nas i poprosi nas o zmianę pobierania lokalizacji z GPS na innego dostawcę (np. coś takiego jak Bluetooth lub sieć)? Zmiany wkrótce wymkną się spod kontroli. Jak możemy to zrobić w czysty sposób?

Lokalizacja użytkownika to dane. A uzyskanie lokalizacji jest UseCase — myślę więc, że nasze warstwy domeny i modelu również powinny być tutaj zaangażowane. Mamy więc do zaimplementowania jeszcze jeden przypadek GetCurrentLocationUseCase . Potrzebujemy również czegoś, co zapewni nam lokalizację — interfejsu ILocationProvider , aby uniezależnić UseCase od szczegółów, takich jak czujnik GPS:

 interface ILocationProvider { fun getLocation(): OneOf<Failure, SimpleLocation> fun cancel() } class GetCurrentLocation @Inject constructor(private val locationProvider: ILocationProvider) : UseCase<SimpleLocation, UseCase.NoParams>() { override suspend fun run(params: NoParams): OneOf<Failure, SimpleLocation> = locationProvider.getLocation() override fun cancel() { super.cancel() locationProvider.cancel() } }

Widać, że mamy tutaj jedną dodatkową metodę — anuluj. Dzieje się tak, ponieważ potrzebujemy sposobu na anulowanie aktualizacji lokalizacji GPS. Nasza implementacja Provider , zdefiniowana w warstwie modelowej, przebiega tutaj:

 class GPSLocationProvider constructor(var activity: Activity) : ILocationProvider { private var locationManager: LocationManager? = null private var locationListener: GPSLocationListener? = null override fun getLocation(): OneOf<Failure, SimpleLocation> = runBlocking { val grantedResult = getLocationPermissions() if (grantedResult.isError) { val error = (grantedResult as OneOf.Error<Failure>).error OneOf.Error(error) } else { getLocationFromGPS() } } private suspend fun getLocationPermissions(): OneOf<Failure, Boolean> = suspendCoroutine { Dexter.withActivity(activity) .withPermission(Manifest.permission.ACCESS_FINE_LOCATION) .withListener(PermissionsCallback(it)) .check() } private suspend fun getLocationFromGPS(): OneOf<Failure, SimpleLocation> = suspendCoroutine { locationListener?.unsubscribe() locationManager = activity.getSystemService(Context.LOCATION_SERVICE) as LocationManager locationManager?.let { manager -> locationListener = GPSLocationListener(manager, it) launch(UI) { manager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0L, 0.0f, locationListener) } } } override fun cancel() { locationListener?.unsubscribe() locationListener = null locationManager = null } }

Ten dostawca jest przygotowany do współpracy z współprogramami Kotlin. Jeśli pamiętasz, metoda uruchamiania UseCase s jest wywoływana w wątku w tle — więc musimy się upewnić i odpowiednio oznaczyć nasze wątki. Jak widać, tutaj musimy przekazać działanie — niezwykle ważne jest, aby anulować aktualizacje i wyrejestrować się ze słuchaczy, gdy już ich nie potrzebujemy, aby uniknąć wycieków pamięci. Ponieważ implementuje ILocationProvider , możemy łatwo zmodyfikować go w przyszłości na innego dostawcę. Możemy też łatwo przetestować obsługę bieżącej lokalizacji (automatycznie lub ręcznie), nawet bez włączania GPS w naszym telefonie – wystarczy, że podmienimy implementację, aby zwrócić losowo skonstruowaną lokalizację. Aby to zadziałało, musimy dodać nowo utworzony UseCase do LocationsViewModel . Z kolei ViewModel musi mieć nową metodę, getCurrentLocation , która faktycznie wywoła przypadek użycia. Wystarczy kilka drobnych zmian w interfejsie użytkownika, aby go wywołać i zarejestrować GPSProvider w Dagger — i voila, nasza aplikacja jest gotowa!

Streszczenie

Próbowałem pokazać, jak możemy stworzyć aplikację na Androida, która jest łatwa w utrzymaniu, testowaniu i zmienianiu. Powinno być również łatwe do zrozumienia – jeśli ktoś nowy przychodzi do Twojej pracy, nie powinien mieć problemu ze zrozumieniem przepływu danych ani struktury. Jeśli mają świadomość, że architektura jest czysta, mogą być pewni, że zmiany w interfejsie użytkownika nie wpłyną na nic w modelu, a dodanie nowej funkcji nie zajmie więcej niż przewidywano. Ale to nie koniec podróży. Nawet jeśli mamy ładnie ustrukturyzowaną aplikację, bardzo łatwo jest ją złamać przez niechlujne zmiany kodu „tylko na chwilę, po prostu do pracy”. Pamiętaj – nie ma kodu „tylko na razie”. Każdy kod, który łamie nasze zasady, może pozostać w bazie kodu i może być źródłem przyszłych, większych przerw. Jeśli przejdziesz do tego kodu zaledwie tydzień później, będzie wyglądało na to, że ktoś zaimplementował w tym kodzie silne zależności i aby go rozwiązać, będziesz musiał przekopać się przez wiele innych części aplikacji. Dobra architektura kodu jest wyzwaniem nie tylko na początku projektu — jest wyzwaniem dla każdej części życia aplikacji na Androida. Myślenie i sprawdzanie kodu powinno być rozliczane za każdym razem, gdy coś się zmieni. Aby to zapamiętać, możesz na przykład wydrukować i zawiesić diagram architektury Androida. Możesz też nieco wymusić niezależność warstw, dzieląc je na trzy moduły Gradle, w których moduł domeny nie jest świadomy pozostałych, a moduły prezentacji i modelu nie używają się nawzajem. Ale nawet to nie może zastąpić świadomości, że bałagan w kodzie aplikacji zemści się na nas, gdy najmniej się tego spodziewamy.