Откройте для себя преимущества чистой архитектуры Android
Опубликовано: 2022-03-11Что бы вы предпочли: добавить новую функцию в очень хорошо работающее приложение с ужасной архитектурой или исправить ошибку в хорошо спроектированном, но глючном приложении для Android? Лично я бы однозначно выбрал второй вариант. Добавление новой функции, даже простой, может стать очень трудоемким в приложении, учитывая все зависимости от всего в каждом классе. Я помню один из своих Android-проектов, где менеджер проекта попросил меня добавить небольшую функцию — что-то вроде загрузки данных и отображения их на новом экране. Это было приложение, написанное одним из моих коллег, который нашел новую работу. Функция не должна занимать более половины рабочего дня. Я был очень оптимистичен…
После семи часов изучения того, как работает приложение, какие в нем модули и как они взаимодействуют друг с другом, я сделал несколько пробных реализаций этой функции. Это был ад. Небольшое изменение в модели данных привело к большим изменениям на экране входа в систему. Добавление сетевого запроса потребовало изменения реализации почти всех экранов и класса GodOnlyKnowsWhatThisClassDoes
. Изменение цвета кнопки вызывало странное поведение при сохранении данных в базу данных или полный сбой приложения. В середине следующего дня я сказал своему руководителю проекта: «У нас есть два способа реализовать эту функцию. Во-первых, я могу потратить на это еще три дня и, наконец, реализовать это очень грязно, а время реализации каждой следующей фичи или багфикса будет расти в геометрической прогрессии. Или я могу переписать приложение. Это займет у меня две-три недели, но мы сэкономим время для будущих изменений приложения». К счастью, он согласился на второй вариант. Если у меня когда-либо и возникали сомнения, почему хорошая архитектура программного обеспечения в приложении (даже очень маленьком) важна, то это приложение полностью развеяло их. Но какой шаблон архитектуры Android мы должны использовать, чтобы избежать таких проблем?
В этой статье я хотел бы показать вам пример чистой архитектуры в приложении для Android. Однако основные идеи этого шаблона можно адаптировать для любой платформы и языка. Хорошая архитектура не должна зависеть от таких деталей, как платформа, язык, система баз данных, ввод или вывод.
Пример приложения
Мы создадим простое приложение для Android для регистрации нашего местоположения со следующими функциями:
- Пользователь может создать учетную запись с именем.
- Пользователь может изменить имя учетной записи.
- Пользователь может удалить учетную запись.
- Пользователь может выбрать активную учетную запись.
- Пользователь может сохранить местоположение.
- Пользователь может видеть список местоположений для пользователя.
- Пользователь может видеть список пользователей.
Чистая архитектура
Слои являются основным ядром чистой архитектуры. В нашем приложении мы будем использовать три слоя: представление, домен и модель. Каждый слой должен быть разделен и не должен знать о других слоях. Он должен существовать в своем собственном мире и, самое большее, иметь небольшой интерфейс для общения.
Обязанности слоя:
- Домен: содержит бизнес-правила нашего приложения. Он должен предоставлять варианты использования, которые отражают особенности нашего приложения.
- Презентация: представляет данные пользователю, а также собирает необходимые данные, такие как имя пользователя. Это своего рода ввод/вывод.
- Модель: Предоставляет данные для нашего приложения. Отвечает за получение данных из внешних источников и их сохранение в базу данных, облачный сервер и т.д.
Какие слои должны знать о других? Самый простой способ получить ответ — подумать об изменениях. Возьмем уровень презентации — мы что-то представим пользователю. Если мы изменим что-то в представлении, должны ли мы также внести изменения в слой модели? Представьте, что у нас есть экран «Пользователь» с именем пользователя и последним местоположением. Если мы хотим представить два последних местоположения пользователя вместо одного, это не должно повлиять на нашу модель. Итак, у нас есть первый принцип: уровень представления ничего не знает о слое модели.
И, наоборот, должен ли слой модели знать о слое представления? Опять же — нет, потому что если мы изменим, например, источник данных из базы данных в сеть, то это ничего не должно изменить в UI (если вы думали добавить сюда загрузчик — да, но мы можем иметь и UI-загрузчик при использовании базы данных). Таким образом, два слоя полностью разделены. Здорово!
А как насчет доменного слоя? Это самый важный, потому что он содержит всю основную бизнес-логику. Именно здесь мы хотим обработать наши данные перед передачей их на уровень модели или представлением пользователю. Он должен быть независим от любого другого уровня — он ничего не знает о базе данных, сети или пользовательском интерфейсе. Поскольку это ядро, другие слои будут взаимодействовать только с ним. Почему мы хотим, чтобы это было полностью независимым? Бизнес-правила, вероятно, будут меняться реже, чем дизайн пользовательского интерфейса или что-то в базе данных или сетевом хранилище. Мы будем общаться с этим слоем через некоторые предоставленные интерфейсы. Он не использует какую-либо конкретную модель или реализацию пользовательского интерфейса. Это детали, и помните — детали меняются. Хорошая архитектура не привязана к деталям.
Хватит пока теории. Приступаем к кодированию! Эта статья вращается вокруг кода, поэтому для лучшего понимания вам следует скачать код с GitHub и проверить, что внутри. Созданы три тега Git — архитектура_v1, архитектура_v2 и архитектура_v3, которые соответствуют частям статьи.
Технология приложений
В приложении я использую Kotlin и Dagger 2 для внедрения зависимостей. Здесь не нужны ни Kotlin, ни Dagger 2, но это значительно упрощает задачу. Вы можете быть удивлены, что я не использую ни RxJava (ни RxKotlin), но я не нашел его здесь применимым, и я не люблю использовать какую-либо библиотеку только потому, что она на высоте, и кто-то говорит, что она обязательна. Как я уже сказал, язык и библиотеки — это детали, поэтому вы можете использовать то, что хотите. Также используются некоторые библиотеки модульных тестов Android: JUnit, Robolectric и Mockito.
Домен
Самый важный слой в дизайне архитектуры нашего приложения для Android — это слой предметной области. Давайте начнем с него. Здесь будет наша бизнес-логика и интерфейсы для связи с другими уровнями. Основным ядром являются UseCase
s, которые отражают то, что пользователь может делать с нашим приложением. Подготовим для них абстракцию:
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 }
Здесь я решил использовать сопрограммы Kotlin. Каждый UseCase
использования должен реализовать метод запуска для предоставления данных. Этот метод вызывается в фоновом потоке, и после получения результата он доставляется в поток пользовательского интерфейса. Возвращаемый тип — OneOf<F, T>
— мы можем вернуть ошибку или успех с данными:
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) } }
Слою предметной области нужны свои собственные объекты, поэтому следующим шагом будет их определение. На данный момент у нас есть две сущности: User
и 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)
Теперь, когда мы знаем, какие данные возвращать, мы должны объявить интерфейсы наших поставщиков данных. Это будут IUsersRepository
и ILocationsRepository
. Они должны быть реализованы на уровне модели:
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> }
Этого набора действий должно быть достаточно, чтобы предоставить необходимые данные для приложения. На данном этапе мы не решаем, как будут храниться данные — это деталь, от которой мы хотим быть независимыми. На данный момент наш уровень домена даже не знает, что он на Android. Мы постараемся сохранить это состояние (вроде как. Я объясню позже).
Последний (или почти последний) шаг — определить реализации для наших UseCase
s, которые будут использоваться презентационными данными. Все они очень просты (так же, как просто наше приложение и данные) — их операции ограничены вызовом соответствующего метода из репозитория, например:
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) }
Абстракция Repository
делает наши UseCases
очень простыми для тестирования — нам не нужно заботиться о сети или базе данных. Его можно сымитировать любым способом, поэтому наши модульные тесты будут проверять реальные варианты использования, а не другие, несвязанные классы. Это сделает наши модульные тесты простыми и быстрыми:
@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) } }
На данный момент слой домена закончен.
Модель
Как разработчик Android, вы, вероятно, выберете Room, новую библиотеку Android для хранения данных. Но давайте представим, что менеджер проекта спросил, можете ли вы отложить решение о базе данных, потому что руководство пытается выбрать между Room, Realm и какой-то новой сверхбыстрой библиотекой хранения. Нам нужны некоторые данные, чтобы начать работать с пользовательским интерфейсом, поэтому мы просто сохраним их в памяти:
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) } }
Презентация
Два года назад я написал статью о MVP как об очень хорошей структуре приложения для Android. Когда Google анонсировала замечательные компоненты архитектуры, которые значительно упростили разработку приложений для Android, MVP больше не нужен и может быть заменен MVVM; тем не менее, некоторые идеи из этого паттерна по-прежнему очень полезны, например идея о глупых представлениях. Они должны заботиться только об отображении данных. Для этого мы будем использовать ViewModel и LiveData.
Дизайн нашего приложения очень прост — одно действие с нижней навигацией, в которой два пункта меню показывают фрагмент locations
или фрагмент users
. В этих представлениях мы используем ViewModels, которые, в свою очередь, используют UseCase
из доменного уровня, сохраняя коммуникацию аккуратной и простой. Например, вот 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 } }
Небольшое пояснение для тех, кто не знаком с ViewModels — наши данные хранятся в переменной location. Когда мы получаем данные из варианта использования getLocations
, они передаются в значение LiveData
. Это изменение уведомит наблюдателей, чтобы они могли отреагировать и обновить свои данные. Добавляем наблюдатель за данными во фрагменте:
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() } }
При каждом изменении местоположения мы просто передаем новые данные адаптеру, назначенному представлению ресайклера, и именно здесь происходит обычный поток Android для отображения данных в представлении ресайклера.
Поскольку мы используем ViewModel в наших представлениях, их поведение также легко проверить — мы можем просто имитировать ViewModels и не заботиться об источнике данных, сети или других факторах:

@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" } }
Вы можете заметить, что слой представления также разделен на более мелкие слои с четкими границами. Представления, такие как activities
, fragments
, ViewHolders
и т. д., отвечают только за отображение данных. Они знают только о слое ViewModel и используют только его для получения или отправки пользователей и местоположений. Это ViewModel, которая взаимодействует с доменом. Реализации ViewModel для представления такие же, как и варианты использования для домена. Перефразируя, чистая архитектура подобна луковице — у нее есть слои, а у слоев тоже могут быть слои.
Внедрение зависимости
Мы создали все классы для нашей архитектуры, но осталось сделать еще кое-что — нам нужно что-то, что соединяет все вместе. Слои представления, предметной области и модели остаются чистыми, но нам нужен один модуль, который будет грязным и будет знать все обо всем — благодаря этим знаниям он сможет соединить наши слои. Лучший способ сделать это — использовать один из распространенных шаблонов проектирования (один из принципов чистого кода, определенных в SOLID) — внедрение зависимостей, которое создает для нас правильные объекты и внедряет их в желаемые зависимости. Здесь я использовал Dagger 2 (в середине проекта я сменил версию на 2.16, в которой меньше шаблонов), но вы можете использовать любой механизм, который вам нравится. Недавно я немного поигрался с библиотекой Koin, и я думаю, что ее тоже стоит попробовать. Я хотел использовать его здесь, но у меня было много проблем с имитацией ViewModels при тестировании. Я надеюсь, что найду способ быстро решить их — если это так, я могу представить различия для этого приложения при использовании Koin и Dagger 2.
Вы можете проверить приложение для этого этапа на GitHub с тегом architecture_v1.
Изменения
Мы закончили наши слои, протестировали приложение — все работает! За исключением одного — нам все еще нужно знать, какую базу данных хочет использовать наш PM. Предположим, они пришли к вам и сказали, что руководство согласилось использовать Room, но они все еще хотят иметь возможность использовать новейшую сверхбыструю библиотеку в будущем, поэтому вам нужно помнить о возможных изменениях. Также один из заинтересованных лиц спросил, можно ли хранить данные в облаке, и хочет узнать стоимость такого изменения. Итак, настало время проверить, хороша ли наша архитектура и можем ли мы изменить систему хранения данных без каких-либо изменений в представлении или уровне предметной области.
Изменить 1
Первое, что нужно сделать при использовании Room, — это определить сущности для базы данных. У нас уже есть некоторые: User
и UserLocation
. Все, что нам нужно сделать, это добавить аннотации, такие как @Entity
и @PrimaryKey
, и тогда мы сможем использовать их на уровне нашей модели с базой данных. Здорово! Это отличный способ нарушить все архитектурные правила, которые мы хотели сохранить. На самом деле сущность домена не может быть преобразована в сущность базы данных таким образом. Просто представьте, что мы также хотим загрузить данные из сети. Мы могли бы использовать еще несколько классов для обработки сетевых ответов — преобразования наших простых сущностей, чтобы заставить их работать с базой данных и сетью. Это кратчайший путь к будущей катастрофе (и крик: «Кто, черт возьми, написал этот код?»). Нам нужны отдельные классы сущностей для каждого типа хранилища данных, который мы используем. Это не требует больших затрат, поэтому давайте правильно определим объекты 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 )
Как видите, они почти не отличаются от доменных сущностей, поэтому есть большой соблазн их объединить. Это просто случайность — с более сложными данными сходство будет меньше.
Далее нам нужно реализовать UserDAO
и UserLocationsDAO
, нашу AppDatabase
и, наконец, реализации для IUsersRepository
и ILocationsRepository
. Здесь есть небольшая проблема ILocationsRepository
должен возвращать UserLocation
, но получает UserLocationEntity
из базы данных. То же самое относится и к классам, связанным с User
. В обратном направлении мы передаем UserLocation
, когда базе данных требуется UserLocationEntity
. Чтобы решить эту проблему, нам нужны Mappers
между нашим доменом и объектами данных. Я использовал одну из своих любимых функций Kotlin — расширения. Я создал файл с именем Mapper.kt
и поместил туда все методы для сопоставления между классами (конечно, он находится в слое модели — домену это не нужно):
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())
Маленькая ложь, о которой я упоминал ранее, касается доменных сущностей. Я писал, что они ничего не знают об Android, но это не совсем так. Я добавил аннотацию @Parcelize
к сущности User
и расширил там Parcelable
, что позволило передать сущность во фрагмент. Для более сложных структур мы должны предоставить собственные классы данных уровня представления и создать сопоставления, например, между моделями домена и данными. Добавление Parcelable
в сущность предметной области — это небольшой риск, на который я осмелился пойти — я знаю об этом, и в случае каких-либо изменений сущности User
я создам отдельные классы данных для представления и Parcelable
из слоя предметной области.
Последнее, что нужно сделать, это изменить наш модуль внедрения зависимостей, чтобы предоставить только что созданную реализацию Repository
вместо предыдущей MemoryRepository
. После того, как мы создадим и запустим приложение, мы можем перейти к PM, чтобы показать работающее приложение с базой данных Room. Мы также можем сообщить продакт-менеджеру, что добавление сети не займет слишком много времени и что мы открыты для любой библиотеки хранения, которую захочет руководство. Вы можете проверить, какие файлы были изменены — только файлы в слое модели. Наша архитектура действительно хороша! Таким же образом можно построить любой следующий тип хранилища, просто расширив наши репозитории и предоставив соответствующие реализации. Конечно, может оказаться, что нам потребуется несколько источников данных, таких как база данных и сеть. Что тогда? Ничего особенного, нам просто нужно создать три реализации репозитория — одну для сети, одну для базы данных и основную, где будет выбран правильный источник данных (например, если у нас есть сеть, загрузка из сети, а если нет, то загрузить из базы данных).
Вы можете проверить приложение для этого этапа на GitHub с тегом architecture_v2.
Итак, день почти закончен — вы сидите за компьютером с чашкой кофе, приложение готово к отправке в Google Play, как вдруг к вам подходит менеджер проекта и спрашивает: «Не могли бы вы добавить функцию, которая может сохранить текущее местоположение пользователя из GPS?»
Изменить 2
Все меняется… особенно программное обеспечение. Вот почему нам нужен чистый код и чистая архитектура. Однако даже самые чистые вещи могут быть грязными, если мы программируем, не задумываясь. Первой мыслью при реализации получения местоположения из GPS было бы добавить весь код, учитывающий местоположение, в действие, запустить его в нашем SaveLocationDialogFragment
и создать новый UserLocation
с соответствующими данными. Это может быть самый быстрый способ. Но что, если к нам придет наш сумасшедший ПМ и попросит изменить получение местоположения с GPS на какого-нибудь другого провайдера (например, что-то вроде Bluetooth или сети)? Изменения скоро выйдут из-под контроля. Как мы можем сделать это чистым способом?
Местоположение пользователя — это данные. И получение местоположения — это UseCase
, поэтому я думаю, что здесь также должны быть задействованы наши уровни предметной области и модели. Таким образом, у нас есть еще один UseCase
для реализации — GetCurrentLocation
. Нам также нужно что-то, что предоставит нам местоположение — интерфейс ILocationProvider
, чтобы сделать UseCase
независимым от деталей, таких как датчик 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() } }
Как видите, здесь у нас есть еще один метод — отмена. Это потому, что нам нужен способ отменить обновления местоположения GPS. Наша реализация Provider
, определенная на уровне модели, находится здесь:
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 } }
Этот провайдер готов работать с сопрограммами Kotlin. Если вы помните, метод run UseCase
вызывается в фоновом потоке, поэтому мы должны убедиться и правильно пометить наши потоки. Как видите, здесь мы должны передать активность — крайне важно отменять обновления и отменять регистрацию в слушателях, когда они нам больше не нужны, чтобы избежать утечек памяти. Так как он реализует ILocationProvider
, мы можем легко изменить его в будущем на какого-нибудь другого провайдера. Мы также можем легко протестировать обработку текущего местоположения (автоматически или вручную), даже не включая GPS в нашем телефоне — все, что нам нужно сделать, это заменить реализацию, чтобы возвращать случайно созданное местоположение. Чтобы заставить его работать, мы должны добавить только что созданный UseCase
в LocationsViewModel
. ViewModel, в свою очередь, должен иметь новый метод getCurrentLocation
, который фактически вызовет вариант использования. Всего несколько небольших изменений пользовательского интерфейса, чтобы вызвать его и зарегистрировать GPSProvider в Dagger — и вуаля, наше приложение готово!
Резюме
Я пытался показать вам, как мы можем разработать приложение для Android, которое легко поддерживать, тестировать и изменять. Он также должен быть простым для понимания — если кто-то новый придет к вам на работу, у него не должно возникнуть проблем с пониманием потока данных или структуры. Если они знают, что архитектура чиста, они могут быть уверены, что изменения в пользовательском интерфейсе ничего не повлияют на модель, а добавление новой функции не займет больше времени, чем предполагалось. Но это не конец пути. Даже если у нас есть хорошо структурированное приложение, его очень легко сломать грязными изменениями кода «только на мгновение, просто для работы». Помните — не существует кода «только сейчас». Каждый код, нарушающий наши правила, может сохраняться в кодовой базе и может стать источником будущих, более серьезных нарушений. Если вы придете к этому коду всего через неделю, это будет выглядеть так, как будто кто-то внедрил в этот код какие-то сильные зависимости, и чтобы решить эту проблему, вам придется копаться во многих других частях приложения. Хорошая архитектура кода — это вызов не только в начале проекта — это вызов на любом этапе жизненного цикла Android-приложения. Обдумывание и проверка кода должны учитываться каждый раз, когда что-то будет меняться. Чтобы запомнить это, вы можете, например, распечатать и повесить диаграмму архитектуры Android. Вы также можете немного усилить независимость слоев, разделив их на три модуля Gradle, где модуль домена не знает о других, а модули представления и модели не используют друг друга. Но даже это не заменит осознание того, что беспорядок в коде приложения отомстит нам, когда мы меньше всего этого ожидаем.