Организация простой архитектуры в андроид-приложении со связкой ViewModel+LiveData, Retrofit+Coroutines +3



Без долгих вступлений расскажу, как можно быстро и просто организовать удобную архитекруту вашего приложения. Материал будет полезен тем, кто не очень хорошо знаком с 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"

Манифест


<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):

  1. loki82
    /#20883320 / +1

    Как раз то чего нет в других туториалах. Чётко и по делу. У меня две недели ушло, чтоб придти к такой же реализации с абстрактным классом. Если бы этот пример ещё расширить на Room. Цены бы не было.

  2. uncle_doc
    /#20883426 / +2

    Во первых это слишком простой пример чтобы называться чистой архитектурой, во вторых — присутствуют ошибки: 1) BaseViewModel знает об деталях работы с сетевыми запросами. 2) Модели из «сетевого слоя» используются на стороне UI

    • loki82
      /#20883466

      Разверните поподробней про второй пункт. Почему модели из сетевого слоя не могут использоваться в UI. Если эта модель больше никак не изменяется. Мне например как раз не хватало такого примера. А не кучу абстракций сходу как только начал изучать. Или наоборот без нужных абстракций. Согласен, не совсем mvvm, но представление даёт не плохое.

      • ferosod
        /#20883498 / +1

        Потому что при изменениях на стороне бэкенда (обновили API) придется вносить изменения в presentation (view, UI) слой. То есть, работать-то это будет, даже в реальном проекте, но называться чистой архитектурой не может.

        • loki82
          /#20883538

          Ну это же взять и повторить. И подумать почему так. Если сейчас внести ещё одну абстракцию, она потянет за собой кучу того, что не связано с LiveData, Retrofit. С точки зрения начала чистой архитектуры, что не так? Я в этом месте встал очень и очень жёстко. Умом все понимал, а как работает нет. И как раз этот пример переходный от в этом примере мы получили данные и показали в лог. И мы получили данные засунули в репозиторий и вывели в recyclerView. Ещё попутно зацепив dagger.

          • dimskiy
            /#20884568

            Просто предложенный топикстартером вариант нарушает многие заветы Clean Architecture — поэтому неправильно называть все это "чистой арзитектурой". Но что тогда остается — просто yet another велосипед, коих и так уже не сосчитать. Чтобы разобраться в чистой архитектуре — рекомендую видео одного из ее отцов :) Это будет хорошим введением, но придется еще почитать статей для углубления.

            • loki82
              /#20887946

              А в текстовом варианте это есть? С английским не дружу. Письменно ещё как-то читаю.

      • anegin
        /#20883504

        Про модели — верно. Каждому слою свои модели, часто обмазанные своими аннотациями (например, для room-энтитей, или для моделей, которые будут сериализовываться gson/moshi/kotlinx-serialization). Между слоями модели гоняются через мапперы. К тому же в моделях респонсов сервера желательно все поля сделать nullable — нельзя доверять тому, что приходит извне.

      • uncle_doc
        /#20883530 / +2

        Для этого есть целый список причин и некоторые не достаточно очевидны из-за скудности примера:
        1. В модели из сети данные приходят в одном формате, а на стороне UI зачастую приходится работать с данными в другом формате (даты, суммы, id из справочников и т.д.)
        2. Если в этот пример нужно будет добавить работу с БД — UI слой придется переписать. Кстати, сюда же можно и отнести первый пункт — в БД данные удобно хранить в других структурах и форматах и частенько они не совпадают с тем, что приходит из сети.
        3. Ну и конечно, никто не застрахован от того что сетевая модель останется неизменной (:

        p.s. кучу абстракций городить не нужно, например — поля модели можно вынести в интерфейс.

        • loki82
          /#20883564

          Аааа. Сейчас начну биться об стену. Вот для вас это очевидные вещи. Для меня интерфейсы в java вообще тяжело даются. И этот пример для меня идеальный. Те кто поймёт это, дальше сможет понять и другие вещи. А LiveData это вообще магия. Вот нигде не написано что Observer это и есть callback. Не забывайте, это учебник.

          • dimskiy
            /#20884572

            Значит всему свое время — сначала стоит просто разобраться с базовыми кубиками java-конструктора, а уже потом закапываться в архитектуру и подходы. Книжки вроде "чистого кода", кажется, заходят только через пару лет реального опыта в реальном проекте. Иначе это все пустые слова и воздушные замки

  3. anegin
    /#20883490 / +3

    Пару замечаний:
    — в методе requestWithCallback() в блоке try результат доставляется в main-потоке, а в блоке catch ошибка доставляется уже в io-потоке — потенциальный крэш во viewOneError()
    — data-класс Event вместе с enum Status лучше и компактнее будет выглядеть в виде sealed-класса
    — отдавать MutableLiveData наружу из ViewModel — плохая практика. наружу должна торчать LiveData (через backing-property или какой-нибудь get-метод)

    • loki82
      /#20883506

      requestWithCallback — сам на эти грабли встал. Но это туториал. И разжевано многое чего нет в других местах.

    • pashashik
      /#20883516

      Со всем согласен, учту на будущее, большое спасибо

  4. dimskiy
    /#20884556

    Позвольте немного критики решения.


    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
      /#20884690

      Ключевое предложение в этом туториале:

      Материал будет полезен тем, кто не очень хорошо знаком с mvvm-паттерном и котлиновскими корутинами.

      Фишка в том, что, когда я изучал все эти используемые в статье компоненты, я постоянно натыкался на плохо раскрываемый для понимания материал. Поэтому постарался написать для людей «кто не очень хорошо знаком с mvvm-паттерном и котлиновскими корутинами», более развернуто и наглядно, как это все работает в связке. Статья и так получилась слишком длинная, для чего все это здесь? Я имею ввиду принцип IoC, преобразования моделей в разных слоях и пр. замечания из комментариев выше?

      • dimskiy
        /#20884892

        Это здесь потому, что вы назвали архитектуру Clean. Это налагает определенные нюансы

        • pashashik
          /#20884908

          А ну да, здесь согласен. я ее переменную)

    • pashashik
      /#20884902

      Это как в универе было: один препод тебе рассказывает подробно, по порядку материал, и по нарастающей увеличивает сложность, и ты все понимаешь, а другой с самого начала вываливает кучу непонятной хрени, и ты только к концу семестра, если повезёт, врубишься во все

      • dimskiy
        /#20884960

        Универ вообще плохой пример :) Его сложно воспринимать всерьез.
        Ваше негодование тоже понятно, но непонятную хрень вы ведь тоже добавили? Абстрактная модель, лайв дата, обертки и все вот это. Если уж хочется рассказать просто про MVVM — половину можно выкинуть без потери смысла.

        • pashashik
          /#20884982 / +1

          Не, я наоборот, считаю все замечания максимально ценными. Теперь мне понятно в каком направлении двигаться. А вообще больше хотелось рассказать не про mvvm а именно про связку технологий с использованием корутин. Мне эта тема тяжело заходила.