Организация простой архитектуры в андроид-приложении со связкой ViewModel+LiveData, Retrofit+Coroutines
Без долгих вступлений расскажу, как можно быстро и просто организовать удобную архитекруту вашего приложения. Материал будет полезен тем, кто не очень хорошо знаком с mvvm-паттерном и котлиновскими корутинами.
Итак, у нас стоит простая задача: получить и обработать сетевой запрос, вывести результат во вью.
Наши действия: из активити (фрагмента) вызываем нужный метод ViewModel -> ViewModel обращается к ретрофитовской ручке, выполняя запрос через корутины -> ответ сетится в лайвдату в виде ивента -> в активити получая ивент передаём данные во вью.
Настройка проекта
Зависимости
//Retrofit implementation 'com.squareup.retrofit2:retrofit:2.6.2' implementation 'com.squareup.retrofit2:converter-gson:2.6.2' implementation 'com.squareup.okhttp3:logging-interceptor:4.2.1' //Coroutines implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0' //ViewModel lifecycle implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0' implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0-rc01"
Манифест
Настройка ретрофита
Создаем котлиновский объект NetworkService. Это будет наш сетевой клиент — синглтон
UPD синглтон используем для простоты понимания. В комментариях указали, что правильнее использовать инверсию контроля, но это отдельная тема
object NetworkService < private const val BASE_URL = " http://www.mocky.io/v2/" // HttpLoggingInterceptor выводит подробности сетевого запроса в логи private val loggingInterceptor = run < val httpLoggingInterceptor = HttpLoggingInterceptor() httpLoggingInterceptor.apply < httpLoggingInterceptor.level = HttpLoggingInterceptor.Level.BODY >> private val baseInterceptor: Interceptor = invoke < chain ->val newUrl = chain .request() .url .newBuilder() .build() val request = chain .request() .newBuilder() .url(newUrl) .build() return@invoke chain.proceed(request) > private val client: OkHttpClient = OkHttpClient .Builder() .addInterceptor(loggingInterceptor) .addInterceptor(baseInterceptor) .build() fun retrofitService(): Api < return Retrofit.Builder() .baseUrl(BASE_URL) .addConverterFactory(GsonConverterFactory.create()) .client(client) .build() .create(Api::class.java) >>
Api интерфейс
Используем замоканные запросы к фэйковому сервису.
Приостановим веселье, здесь начинается магия корутин.
Помечаем наши функции ключевым словом suspend fun . .
Ретрофит научился работать с котлиновскими suspend функциями с версии 2.6.0, теперь он напрямую выполняет сетевой запрос и возвращает объект с данными:
interface Api < @GET("5dcc12d554000064009c20fc") suspend fun getUsers( @Query("page") page: Int ): ResponseWrapper@GET("5dcc147154000059009c2104") suspend fun getUsersError( @Query("page") page: Int ): ResponseWrapper >
ResponseWrapper — это простой класс-обертка для наших сетевых запросов:
class ResponseWrapper : Serializable
data class Users( @SerializedName("count") var count: Int?, @SerializedName("items") var items: List? )
ViewModel
Создаем абстрактный класс BaseViewModel, от которого будут наследоваться все наши ViewModel. Здесь остановимся подробнее:
abstract class BaseViewModel : ViewModel() < var api: Api = NetworkService.retrofitService() // У нас будут две базовые функции requestWithLiveData и // requestWithCallback, в зависимости от ситуации мы будем // передавать в них лайвдату или колбек вместе с параметрами сетевого // запроса. Функция принимает в виде параметра ретрофитовский suspend запрос, // проверяет на наличие ошибок и сетит данные в виде ивента либо в // лайвдату либо в колбек. Про ивент будет написано ниже fun requestWithLiveData( liveData: MutableLiveData, request: suspend () -> ResponseWrapper) < // В начале запроса сразу отправляем ивент загрузки liveData.postValue(Event.loading()) // Привязываемся к жизненному циклу ViewModel, используя viewModelScope. // После ее уничтожения все выполняющиеся длинные запросы // будут остановлены за ненадобностью. // Переходим в IO поток и стартуем запрос this.viewModelScope.launch(Dispatchers.IO) < try < val response = request.invoke() if (response.data != null) < // Сетим в лайвдату командой postValue в IO потоке liveData.postValue(Event.success(response.data)) >else if (response.error != null) < liveData.postValue(Event.error(response.error)) >> catch (e: Exception) < e.printStackTrace() liveData.postValue(Event.error(null)) >> > fun requestWithCallback( request: suspend () -> ResponseWrapper, response: (Event) -> Unit) < response(Event.loading()) this.viewModelScope.launch(Dispatchers.IO) < try < val res = request.invoke() // здесь все аналогично, но полученные данные // сетим в колбек уже в главном потоке, чтобы // избежать конфликтов с // последующим использованием данных // в context классах launch(Dispatchers.Main) < if (res.data != null) < response(Event.success(res.data)) >else if (res.error != null) < response(Event.error(res.error)) >> > catch (e: Exception) < e.printStackTrace() // UPD (подсказали в комментариях) В блоке catch ивент передаем тоже в Main потоке launch(Dispatchers.Main) < response(Event.error(null)) >> > > >
Ивенты
Крутое решение от Гугла — оборачивать дата классы в класс-обертку Event, в котором у нас может быть несколько состояний, как правило это LOADING, SUCCESS и ERROR.
data class Event(val status: Status, val data: T?, val error: Error?) < companion object < fun loading(): Event < return Event(Status.LOADING, null, null) >fun success(data: T?): Event < return Event(Status.SUCCESS, data, null) >fun error(error: Error?): Event < return Event(Status.ERROR, null, error) >> > enum class Status
Вот как это работает. Во время сетевого запроса мы создаем ивент со статусом LOADING. Ждем ответа от сервера и потом оборачиваем данные ивентом и отправляем его с заданным статусом дальше. Во вью проверяем тип ивента и в зависимости от состояния устанавливаем разные состояния для вью. Примерно на такой-же философии строится архитектурный паттерн MVI
ActivityViewModel
class ActivityViewModel : BaseViewModel() < // Создаем лайвдату для нашего списка юзеров val simpleLiveData = MutableLiveData>() // Получение юзеров. Обращаемся к функции requestWithLiveData // из BaseViewModel передаем нашу лайвдату и говорим, // какой сетевой запрос нужно выполнить и с какими параметрами // В данном случае это api.getUsers // Теперь функция сама выполнит запрос и засетит нужные // данные в лайвдату fun getUsers(page: Int) < requestWithLiveData(simpleLiveData) < api.getUsers( page = page ) >> // Здесь аналогично, но вместо лайвдаты используем котлиновский колбек // UPD Полученный результат мы можем обработать здесь перед отправкой во вью fun getUsersError(page: Int, callback: (data: Event) -> Unit) < requestWithCallback(< api.getUsersError( page = page ) >) < callback(it) >> >
MainActivity
class MainActivity : AppCompatActivity() < private lateinit var activityViewModel: ActivityViewModel override fun onCreate(savedInstanceState: Bundle?) < super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) activityViewModel = ViewModelProviders.of(this).get(ActivityViewModel::class.java) observeGetPosts() buttonOneClickListener() buttonTwoClickListener() >// Наблюдаем за нашей лайвдатой // В зависимости от Ивента устанавливаем нужное состояние вью private fun observeGetPosts() < activityViewModel.simpleLiveData.observe(this, Observer < when (it.status) < Status.LOADING ->viewOneLoading() Status.SUCCESS -> viewOneSuccess(it.data) Status.ERROR -> viewOneError(it.error) > >) > private fun buttonOneClickListener() < btn_test_one.setOnClickListener < activityViewModel.getUsers(page = 1) >> // Здесь так же наблюдаем за Ивентом, используя колбек private fun buttonTwoClickListener() < btn_test_two.setOnClickListener < activityViewModel.getUsersError(page = 2) < when (it.status) < Status.LOADING ->viewTwoLoading() Status.SUCCESS -> viewTwoSuccess(it.data) Status.ERROR -> viewTwoError(it.error) > > > > private fun viewOneLoading() < // Пошла загрузка, меняем состояние вьюх >private fun viewOneSuccess(data: Users?) < val usersList: MutableList? = data?.items as MutableList? usersList?.shuffle() usersList?.let < Toast.makeText(applicationContext, "$", Toast.LENGTH_SHORT).show() > > private fun viewOneError(error: Error?) < // Показываем ошибку >private fun viewTwoLoading() <> private fun viewTwoSuccess(data: Users?) <> private fun viewTwoError(error: Error?) < error?.let < Toast.makeText(applicationContext, error.errorMsg, Toast.LENGTH_SHORT).show() >> >
Работа с сетью в Android с использованием корутин и Retrofit
Чем больше я читал и смотрел доклады про корутины в Kotlin, тем больше я восхищался этим средством языка. Недавно в Kotlin 1.3 вышел их стабильный релиз, а значит, настало время начать погружение и опробовать корутины в действии на примере моего существующего RxJava-кода. В этом посте мы сфокусируемся на том, как взять существующие запросы к сети и преобразовать их, заменив RxJava на корутины.
Откровенно говоря, перед тем как я попробовал корутины, я думал, что они сильно отличаются от того, что было раньше. Однако, основной принцип корутин включает те же понятия, к которым мы привыкли в реактивных потоках RxJava. Для примера давайте возьмем простую конфигурацию RxJava для создания запроса к сети из одного моего приложения:
- Определяем сетевой интерфейс для Ретрофита, используя Rx-адаптер (retrofit2:adapter-rxjava2). Функции будут возвращать объекты из Rx-фреймворка, такие как Single или Observable. (Здесь и далее используются функции, а не методы, так как предполагается, что старый код был также написан на Kotlin. Ну или сконвертирован из Java через Android Studio).
- Вызываем определенную функцию из другого класса (например репозитория, или активити).
- Определяем для потоков, на каком Scheduler-е они будут выполняться и возвращать результат (методы .subscribeOn() и .observeOn()).
- Сохраняем ссылку на объект для отписки (например в CompositeObservable).
- Подписываемся на поток эвентов.
- Отписываемся от потока в зависимости от событий жизненного цикла Activity.
Это основной алгоритм работы с Rx (не учитывая функции маппинга и детали других манипуляций с данными). Что касается корутин – принцип сильно не меняется. Та же концепция, меняется только терминология.
- Определяем сетевой интерфейс для Ретрофита, используя адаптер для корутин. Функции будут возвращать Deferred объекты из API корутин.
- Вызываем эти функции из другого класса (например репозитория, или активити). Единственное отличие: каждая функция должна быть помечен как отложенная (suspend).
- Определяем dispatcher, который будет использован для корутина.
- Сохраняем ссылку на Job-объект для отписки.
- Запускаем корутин любым доступным способом.
- Отменяем корутины в зависимости от событий жизненного цикла Activity.
Как можно заметить из приведенных выше последовательностей, процесс выполнения Rx и корутин очень похож. Если не учитывать детали реализации, это означает, что мы можем сохранить подход, который у нас есть – мы только заменяем некоторые вещи, чтобы сделать нашу реализацию coroutine-friendly.
Первый шаг, который мы должны сделать – позволить Ретрофиту возвращать Deferred-объекты. Объекты типа Deferred представляют собой неблокирующие future, которые могут быть отменены, если нужно. Эти объекты по сути представляют собой корутинную Job, которая содержит значение для соответствующей работы. Использование Deferred типа позволяет нам смешать ту же идею, что и Job, с добавлением возможности получить дополнительные состояния, такие как success или failure – что делает его идеальным для запросов к сети.
Если вы используете Ретрофит с RxJava, вероятно, вы используете RxJava Call Adapter Factory. К счастью, Джейк Вортон написал её эквивалент для корутин.
Мы можем использовать этот call adapter в билдере Ретрофита, и затем имплементировать наш Ретрофит-интерфейс так же, как было с RxJava:
private fun makeService(okHttpClient: OkHttpClient): MyService
Теперь посмотрим на интерфейс MyService, который использован выше. Мы должны заменить в Ретрофит-интерфейсе возвращаемые Observable-типы на Deferred. Если раньше было так:
@GET("some_endpoint") fun getData(): Observable>
@GET("some_endpoint") fun getData(): Deferred>
Каждый раз, когда мы вызовем getData() – нам вернется объект Deferred – аналог Job для запросов к сети. Раньше мы как-то так вызывали эту функцию с RxJava:
override fun getData(): Observable> < return myService.getData() .map < result ->result.map < myDataMapper.mapFromRemote(it) >> >
В этом RxJava потоке мы вызываем нашу служебную функцию, затем применяем map-операцию из RxJava API с последующим маппингом данных, вернувшихся из запроса, в что-то, используемое в UI слое. Это немного поменяется, когда мы используем реализацию с корутинами. Для начала, наша функция должна быть suspend (отложенной), для того, чтобы сделать ленивую операцию внутри тела функции. И для этого вызывающая функция должна быть также отложенной. Отложенная функция – неблокирующая, и ею можно управлять после того, как она будет первоначально вызвана. Можно ее стартануть, поставить на паузу, возобновить или отменить.
override suspend fun getData(): List
Теперь мы должны вызвать нашу служебную функцию. На первый взгляд, мы выполняем то же самое, но нужно помнить, что теперь мы получаем Deferred вместо Observable.
override suspend fun getData(): List
Из-за этого изменения мы не можем больше использовать цепочку map-операция из RxJava API. И даже в этой точке нам не доступны данные – мы только имеем Deferred-инстанс. Теперь мы должны использовать функцию await() для того, чтобы дождаться результата выполнения запроса и затем продолжить выполнение кода внутри функции:
override suspend fun getData(): List
В этой точке мы получаем завершенный запрос и данные из него, доступные для использования. Поэтому мы можем теперь совершать операции маппинга:
override suspend fun getData(): List < val result = myService.getData().await() return result.map < myDataMapper.mapFromRemote(it) >>
Мы взяли наш Ретрофит-интерфейс вместе с вызывающим классом и использовали корутины. Теперь же мы хотим вызвать этот код из наших Activity или фрагментов и использовать данные, которые мы достали из сети.
В нашей Activity начнем с создания ссылки на Job, в которую мы сможем присвоить нашу корутинную операцию и затем использовать для управления, например отмены запроса, во время вызова onDestroy().
private var myJob: Job? = null override fun onDestroy()
Теперь мы можем присвоить что-то в переменную myJob. Давайте посмотрим на наш запрос с корутинами:
myJob = CoroutineScope(Dispatchers.IO).launch < val result = repo.getLeagues() withContext(Dispatchers.Main) < //do something with result >>
В этом посте я не хотел бы углубляться в Dispatchers или исполнение операций внутри корутинов, так как это тема для других постов. Вкратце, что здесь происходит:
- Создаем инстанс CoroutineScope, используя IO Dispatcher в качестве параметра. Этот диспатчер используется для совершения блокирующих операций ввода-вывода, таких как сетевые запросы.
- Запускаем наш корутин функцией launch – эта функция запускает новый корутин и возвращает ссылку в переменную типа Job.
- Затем мы используем ссылку на наш репозиторий для получения данных, выполняя сетевой запрос.
- В конце мы используем Main диспатчер для совершения работы на UI-потоке. Тут мы сможем показать полученные данные пользователям.
В следующем посте автор обещает копнуть поглубже в детали, но текущего материала должно быть достаточно для начала изучения корутинов.
В этом посте мы заменили RxJava-реализацию ответов Ретрофита на Deferred объекты из API корутин. Мы вызываем эти функции для получения данных из сети, и затем отображем их в нашем активити. Надеюсь, вы увидели, как мало изменений нужно сделать, чтобы начать работать с корутинами, и оценили простоту API, особенно в процессе чтения и написания кода.
В комментариях к оригинальному посту я нашел традиционную просьбу: покажите код целиком. Поэтому я сделал простое приложение, которое при старте получает расписание электричек с API Яндекс.Расписаний и отображает в RecyclerView. Ссылка: https://github.com/AndreySBer/RetrofitCoroutinesExample
Еще хотелось бы добавить, что корутины кажутся неполноценной заменой RxJava, так как не предлагают равноценного набора операций для синхронизации потоков. В этой связи стоит посмотреть на реализацию ReactiveX для Kotlin: RxKotlin.