Итак, у нас стоит простая задача: получить и обработать сетевой запрос, вывести результат во вью.
Наши действия: из активити (фрагмента) вызываем нужный метод 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"
Манифест
<manifest ...>
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
Настройка ретрофита
Создаем котлиновский объект 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<Users>
@GET("5dcc147154000059009c2104")
suspend fun getUsersError(
@Query("page") page: Int
): ResponseWrapper<Users>
}
ResponseWrapper — это простой класс-обертка для наших сетевых запросов:
class ResponseWrapper<T> : Serializable {
@SerializedName("response")
val data: T? = null
@SerializedName("error")
val error: Error? = null
}
Дата класс Users
data class Users(
@SerializedName("count")
var count: Int?,
@SerializedName("items")
var items: List<Item?>?
) {
data class Item(
@SerializedName("first_name")
var firstName: String?,
@SerializedName("last_name")
var lastName: String?
)
}
ViewModel
Создаем абстрактный класс BaseViewModel, от которого будут наследоваться все наши ViewModel. Здесь остановимся подробнее:
abstract class BaseViewModel : ViewModel() {
var api: Api = NetworkService.retrofitService()
// У нас будут две базовые функции requestWithLiveData и
// requestWithCallback, в зависимости от ситуации мы будем
// передавать в них лайвдату или колбек вместе с параметрами сетевого
// запроса. Функция принимает в виде параметра ретрофитовский suspend запрос,
// проверяет на наличие ошибок и сетит данные в виде ивента либо в
// лайвдату либо в колбек. Про ивент будет написано ниже
fun <T> requestWithLiveData(
liveData: MutableLiveData<Event<T>>,
request: suspend () -> ResponseWrapper<T>) {
// В начале запроса сразу отправляем ивент загрузки
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 <T> requestWithCallback(
request: suspend () -> ResponseWrapper<T>,
response: (Event<T>) -> 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<out T>(val status: Status, val data: T?, val error: Error?) {
companion object {
fun <T> loading(): Event<T> {
return Event(Status.LOADING, null, null)
}
fun <T> success(data: T?): Event<T> {
return Event(Status.SUCCESS, data, null)
}
fun <T> error(error: Error?): Event<T> {
return Event(Status.ERROR, null, error)
}
}
}
enum class Status {
SUCCESS,
ERROR,
LOADING
}
Вот как это работает. Во время сетевого запроса мы создаем ивент со статусом LOADING. Ждем ответа от сервера и потом оборачиваем данные ивентом и отправляем его с заданным статусом дальше. Во вью проверяем тип ивента и в зависимости от состояния устанавливаем разные состояния для вью. Примерно на такой-же философии строится архитектурный паттерн MVI
ActivityViewModel
class ActivityViewModel : BaseViewModel() {
// Создаем лайвдату для нашего списка юзеров
val simpleLiveData = MutableLiveData<Event<Users>>()
// Получение юзеров. Обращаемся к функции requestWithLiveData
// из BaseViewModel передаем нашу лайвдату и говорим,
// какой сетевой запрос нужно выполнить и с какими параметрами
// В данном случае это api.getUsers
// Теперь функция сама выполнит запрос и засетит нужные
// данные в лайвдату
fun getUsers(page: Int) {
requestWithLiveData(simpleLiveData) {
api.getUsers(
page = page
)
}
}
// Здесь аналогично, но вместо лайвдаты используем котлиновский колбек
// UPD Полученный результат мы можем обработать здесь перед отправкой во вью
fun getUsersError(page: Int, callback: (data: Event<Users>) -> 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<Users.Item>? = data?.items as MutableList<Users.Item>?
usersList?.shuffle()
usersList?.let {
Toast.makeText(applicationContext, "${it}", 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()
}
}
}
Исходный код
Комментарии (21)
uncle_doc
13.11.2019 20:59+2Во первых это слишком простой пример чтобы называться чистой архитектурой, во вторых — присутствуют ошибки: 1) BaseViewModel знает об деталях работы с сетевыми запросами. 2) Модели из «сетевого слоя» используются на стороне UI
loki82
13.11.2019 21:09Разверните поподробней про второй пункт. Почему модели из сетевого слоя не могут использоваться в UI. Если эта модель больше никак не изменяется. Мне например как раз не хватало такого примера. А не кучу абстракций сходу как только начал изучать. Или наоборот без нужных абстракций. Согласен, не совсем mvvm, но представление даёт не плохое.
ferosod
13.11.2019 21:19+1Потому что при изменениях на стороне бэкенда (обновили API) придется вносить изменения в presentation (view, UI) слой. То есть, работать-то это будет, даже в реальном проекте, но называться чистой архитектурой не может.
loki82
13.11.2019 21:29Ну это же взять и повторить. И подумать почему так. Если сейчас внести ещё одну абстракцию, она потянет за собой кучу того, что не связано с LiveData, Retrofit. С точки зрения начала чистой архитектуры, что не так? Я в этом месте встал очень и очень жёстко. Умом все понимал, а как работает нет. И как раз этот пример переходный от в этом примере мы получили данные и показали в лог. И мы получили данные засунули в репозиторий и вывели в recyclerView. Ещё попутно зацепив dagger.
dimskiy
14.11.2019 08:36Просто предложенный топикстартером вариант нарушает многие заветы Clean Architecture — поэтому неправильно называть все это "чистой арзитектурой". Но что тогда остается — просто yet another велосипед, коих и так уже не сосчитать. Чтобы разобраться в чистой архитектуре — рекомендую видео одного из ее отцов :) Это будет хорошим введением, но придется еще почитать статей для углубления.
loki82
14.11.2019 20:38А в текстовом варианте это есть? С английским не дружу. Письменно ещё как-то читаю.
anegin
13.11.2019 21:22Про модели — верно. Каждому слою свои модели, часто обмазанные своими аннотациями (например, для room-энтитей, или для моделей, которые будут сериализовываться gson/moshi/kotlinx-serialization). Между слоями модели гоняются через мапперы. К тому же в моделях респонсов сервера желательно все поля сделать nullable — нельзя доверять тому, что приходит извне.
uncle_doc
13.11.2019 21:28+2Для этого есть целый список причин и некоторые не достаточно очевидны из-за скудности примера:
1. В модели из сети данные приходят в одном формате, а на стороне UI зачастую приходится работать с данными в другом формате (даты, суммы, id из справочников и т.д.)
2. Если в этот пример нужно будет добавить работу с БД — UI слой придется переписать. Кстати, сюда же можно и отнести первый пункт — в БД данные удобно хранить в других структурах и форматах и частенько они не совпадают с тем, что приходит из сети.
3. Ну и конечно, никто не застрахован от того что сетевая модель останется неизменной (:
p.s. кучу абстракций городить не нужно, например — поля модели можно вынести в интерфейс.loki82
13.11.2019 21:42Аааа. Сейчас начну биться об стену. Вот для вас это очевидные вещи. Для меня интерфейсы в java вообще тяжело даются. И этот пример для меня идеальный. Те кто поймёт это, дальше сможет понять и другие вещи. А LiveData это вообще магия. Вот нигде не написано что Observer это и есть callback. Не забывайте, это учебник.
dimskiy
14.11.2019 08:38Значит всему свое время — сначала стоит просто разобраться с базовыми кубиками java-конструктора, а уже потом закапываться в архитектуру и подходы. Книжки вроде "чистого кода", кажется, заходят только через пару лет реального опыта в реальном проекте. Иначе это все пустые слова и воздушные замки
anegin
13.11.2019 21:17+3Пару замечаний:
— в методе requestWithCallback() в блоке try результат доставляется в main-потоке, а в блоке catch ошибка доставляется уже в io-потоке — потенциальный крэш во viewOneError()
— data-класс Event вместе с enum Status лучше и компактнее будет выглядеть в виде sealed-класса
— отдавать MutableLiveData наружу из ViewModel — плохая практика. наружу должна торчать LiveData (через backing-property или какой-нибудь get-метод)loki82
13.11.2019 21:23requestWithCallback — сам на эти грабли встал. Но это туториал. И разжевано многое чего нет в других местах.
dimskiy
14.11.2019 08:29Позвольте немного критики решения.
Api-класс сделан синглтоном — тестировать (мокать) будет сложнее. Лучше бы это был обычный класс, зависимость на который вы передавали бы через DI-фреймворк.
Cоздание внешних зависимости внутри класса — уже плохо для тестирования и поддержки, а вы еще и синглтон используете в этой зависимости:
abstract class BaseViewModel : ViewModel() { var api: Api = NetworkService.retrofitService()
К тому же, такая жесткая зависимость создается в базовом классе, который в реальной жизни будут использовать все другие модели. Лучше бы взять принцип IoC, который активно используется в Clean Architecture — это избавит от жестких связей и позволит менять зависимость как угодно в рамках интерфейса. Опять же, полезно не только для тестов, но и для реальной жизни и непрогнозируемого развития приложения.
Все эти громоздкие конструкции с try-catch, на мой взгляд, только ухудшают читаемость кода… Почему бы не использовать Rx, с его идеальной логикой обработки ошибок и все теми же состояниями Complete, Error уже из коробки? Если уж использовать try-catch по старинке, то лучше вынести эти блоки в отдельные приватные методы — серьезно улучшите читаемость.
Если вам зашла лайв дата, можно использовать ее адаптер для ретрофита и получать сетевые модели без этих странных конвертаций на уровне ViewMode, которая вообще не должна этим заниматься (не ее зона ответственности):
liveData.postValue(Event.success(response.data))
Сложилось впечатление, что под «чистой» архитектурой вы понимаете что-то иное, не подход Clean Architecture. И это даже не цепляясь к преобразованиям модели между уровнями (которые не всегда нужны) и обращению к сети прямо из ViewModel (что тоже возможно в совсем маленьких приложениях).
pashashik Автор
14.11.2019 09:34Ключевое предложение в этом туториале:
Материал будет полезен тем, кто не очень хорошо знаком с mvvm-паттерном и котлиновскими корутинами.
Фишка в том, что, когда я изучал все эти используемые в статье компоненты, я постоянно натыкался на плохо раскрываемый для понимания материал. Поэтому постарался написать для людей «кто не очень хорошо знаком с mvvm-паттерном и котлиновскими корутинами», более развернуто и наглядно, как это все работает в связке. Статья и так получилась слишком длинная, для чего все это здесь? Я имею ввиду принцип IoC, преобразования моделей в разных слоях и пр. замечания из комментариев выше?
pashashik Автор
14.11.2019 10:27Это как в универе было: один препод тебе рассказывает подробно, по порядку материал, и по нарастающей увеличивает сложность, и ты все понимаешь, а другой с самого начала вываливает кучу непонятной хрени, и ты только к концу семестра, если повезёт, врубишься во все
dimskiy
14.11.2019 10:39Универ вообще плохой пример :) Его сложно воспринимать всерьез.
Ваше негодование тоже понятно, но непонятную хрень вы ведь тоже добавили? Абстрактная модель, лайв дата, обертки и все вот это. Если уж хочется рассказать просто про MVVM — половину можно выкинуть без потери смысла.pashashik Автор
14.11.2019 10:43+1Не, я наоборот, считаю все замечания максимально ценными. Теперь мне понятно в каком направлении двигаться. А вообще больше хотелось рассказать не про mvvm а именно про связку технологий с использованием корутин. Мне эта тема тяжело заходила.
loki82
Как раз то чего нет в других туториалах. Чётко и по делу. У меня две недели ушло, чтоб придти к такой же реализации с абстрактным классом. Если бы этот пример ещё расширить на Room. Цены бы не было.