Организация простой архитектуры в андроид-приложении со связкой 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()
        }
    }
}

Исходный код




К сожалению, не доступен сервер mySQL