MVVM на Android с компонентами архитектуры + библиотека Koin

Android

Введение

С MVVM (ModelView-ViewModel) процесс разработки графического интерфейса для пользователей делится на две части. Первая — это работа с языком разметки или кодом GUI. Вторая — разработка бизнес-логики или логики бэкенда (модель данных). Часть View model в MVVM — это конвертер значений. Это значит, что view model отвечает за конвертирование объектов данных из модели в такой вид, чтобы с объектами было легко работать. Если смотреть с этой стороны, то view model — это скорее модель, чем представление. Она контролирует большую часть логики отображения. Модель представления может реализовывать паттерн медиатор. Для этого организуется доступ к логике бэкенда вокруг набора юз-кейсов, поддерживаемых представлением.

В этом туториале мы попробуем определить каждый компонент паттерна MVVM, чтобы создать небольшое приложение на Android в соответствии с ним.

На следующей картинке — разные элементы, которые мы собираемся создать при помощи компонента Architecture и библиотеки Koin для внедрения зависимостей.

Архитектуру ниже можно разделить на три различные части.

Представление

Содержит структурное определение того, что пользователи получат на экранах. Вы можете поместить сюда статическое и динамическое содержимое (анимацию и смену состояний). Тут может не быть никакой логики приложения. Для нашего случая в представлении может быть активность или фрагмент.

Модель представления

Этот компонент связывает модель и представление. Отвечает за управление ссылками данных и возможных конверсий. Здесь появляется биндинг. В Android мы не беспокоимся об этом, потому что можно напрямую использовать класс AndroidViewModel или ViewModel.

Модель

Это уровень бизнес-данных и он не связан ни с каким особенным графическим представлением. В Android, согласно “чистой” архитектуре, модель может содержать базу данных, репозиторий и класс бизнес-логики. Картинка ниже описывает взаимодействие между разными компонентами.

Как реализовать паттерн MVVM

Чтобы реализовать паттерн MVVM, важно начать с компонентов, которым для работы нужен другой компонент. Это и есть зависимость.

А с момента появления компонента архитектуры, логичное общее решение  — реализовать Android-приложения при помощи модели с изображения ниже. Там вы увидите стрелки, которые ведут от представления (активности/фрагмента) к модели.

А это значит, что View знает о View-Model, а не наоборот, и View Model знает о Model, и не наоборот. То есть у представления будет связь с моделью представления, а у модели представления будет связь с моделью. Строго в таком порядке, никак иначе. Благодаря такой архитектуре приложение легко поддерживать и тестировать.

Чтобы программировать быстро и эффективно, вам нужно начать с моделирования, так как модели не нужны другие компоненты для работы.

Сценарий приложения и реализация модели

Чтобы понять, как функционирует паттерн MVVM, мы напишем небольшое приложение, в котором будут все компоненты с предыдущей картинки. Мы создадим программу, которая покажет данные. Мы их взяли по этой ссылке. Приложение будет сохранять данные локально для того, чтобы потом оно работало в режиме оффлайн.

Пользовательская модель

@Entity(tableName = "users")
data class GithubUser(
    @PrimaryKey val id: Long,
    val login: String,
    val avatar_url: String
)

Приложение будет обрабатывать данные такой структуры. А для простоты я выберу всего лишь некоторые параметры. У класса GithubUser есть room-аннотация и у данных в локальной БД будет такая же структура, как и у данных в API.

У пространства DAO есть только два метода. Один — добавление информации в БД. Второй — ее извлечение.

@Dao
interface UserDao {

    @Query("SELECT * FROM users")
    fun findAll(): LiveData<List<GithubUser>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun add(users: List<GithubUser>)
}
//UserDao.kt

Пространство базы данных выглядит так:

@Database(entities = [GithubUser::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    abstract val userDao: UserDao
}
//AppDatabase.kt

Во второй части мы реализуем Webservice, который отвечает за получение данных онлайн. Для того будем пользоваться retrofit+coroutines.

interface UserApi {

    @GET("users")
    fun getAllAsync(): Deferred<List<GithubUser>>
}
//UserApi.kt

Если вы хотите узнать, как пользоваться Retrofit вместе с сопрограммами, загляните сюда.

В третьей части мы реализуем репозиторий. Этот класс будет отвечать за определение источника данных. Для нашего случая их два, так что репозиторий будет только получать данные онлайн, чтобы потом сохранить их в локальной базе данных.

class UserRepository(private val userApi: UserApi, private val userDao: UserDao) {

    val data = userDao.findAll()

    suspend fun refresh() {
        withContext(Dispatchers.IO) {
            val users = userApi.getAllAsync().await()
            userDao.add(users)
        }
    }
}

Как сами видите, у репозитория есть конструктор с двумя параметрами. Первый — это класс, который представляет онлайн-данные, а второй — представляет данные оффлайн.


View-Model

После того, как мы описали модель и все ее части, пора ее реализовать. Для этого возьмем класс, родителем которого является класс ViewModel Android Jetpack.

class UserViewModel(private val userRepository: UserRepository) : ViewModel() {

    private val _loadingState = MutableLiveData<LoadingState>()
    val loadingState: LiveData<LoadingState>
        get() = _loadingState

    val data = userRepository.data

    init {
        fetchData()
    }

    private fun fetchData() {
        viewModelScope.launch {
            try {
                _loadingState.value = LoadingState.LOADING
                userRepository.refresh()
                _loadingState.value = LoadingState.LOADED
            } catch (e: Exception) {
                _loadingState.value = LoadingState.error(e.message)
            }
        }
    }
}

Класс ViewModel создан для того, чтобы хранить и управлять данными, связанными с UI относительно жизненного цикла. Он позволяет данным пережить изменения конфигурации, например, повороты экрана.

View-model берет репозиторий в качестве параметра. Этот класс “знает” все источники данных для нашего приложения. В начальном блоке view-model мы обновляем данные БД. Это делается вызовом метода обновления репозитория. А еще у view-model есть свойство data. Оно получает данные локально напрямую. Это гарантия, что у пользователя всегда будет что-то в интерфейсе, даже если устройство не в сети.

Подсказка: я пользовался вспомогательным классом, который помогал мне управлять состоянием загрузки

data class LoadingState private constructor(val status: Status, val msg: String? = null) {
    companion object {
        val LOADED = LoadingState(Status.SUCCESS)
        val LOADING = LoadingState(Status.RUNNING)
        fun error(msg: String?) = LoadingState(Status.FAILED, msg)
    }

    enum class Status {
        RUNNING,
        SUCCESS,
        FAILED
    }
}

Представление

Это последний компонент архитектуры. Он напрямую общается с представлением-моделью, получает данные и, например, передает их в recycler-view. В нашем случае представление — это простая активность.

class MainActivity : AppCompatActivity() {

    private val userViewModel by viewModel<UserViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        userViewModel.data.observe(this, Observer {
            // Todo: Populate the recyclerView here
            it.forEach { githubUser ->
                Toast.makeText(baseContext, githubUser.login, Toast.LENGTH_SHORT).show()
            }
        })

        userViewModel.loadingState.observe(this, Observer {
            when (it.status) {
                LoadingState.Status.FAILED -> Toast.makeText(baseContext, it.msg, Toast.LENGTH_SHORT).show()
                LoadingState.Status.RUNNING -> Toast.makeText(baseContext, "Loading", Toast.LENGTH_SHORT).show()
                LoadingState.Status.SUCCESS -> Toast.makeText(baseContext, "Success", Toast.LENGTH_SHORT).show()
            }
        })
    }
}

В представлении происходит отслеживание того, как изменяются данные, как они автоматически обновляются на уровне интерфейса. Для нашего случая в представлении также отслеживается состояние операций загрузки в фоновом режиме. В процесс включено свойство loadingState, которое мы определили выше.

Вот вы и увидели, как я получил экземпляр view-model, используя для этого внедрение. А как это сработает, мы увидим дальше.

Конкретизация объектов и внедрение зависимостей

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

Так мы создадим важные объекты. Нашему приложению они нужны там же и нам останется только вызвать их в разные точки программы. Для этого и нужна магия библиотеки Koin.

val viewModelModule = module {
    viewModel { UserViewModel(get()) }
}


val apiModule = module {
    fun provideUserApi(retrofit: Retrofit): UserApi {
        return retrofit.create(UserApi::class.java)
    }

    single { provideUserApi(get()) }
}

val netModule = module {
    fun provideCache(application: Application): Cache {
        val cacheSize = 10 * 1024 * 1024
        return Cache(application.cacheDir, cacheSize.toLong())
    }

    fun provideHttpClient(cache: Cache): OkHttpClient {
        val okHttpClientBuilder = OkHttpClient.Builder()
            .cache(cache)

        return okHttpClientBuilder.build()
    }

    fun provideGson(): Gson {
        return GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.IDENTITY).create()
    }


    fun provideRetrofit(factory: Gson, client: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://api.github.com/")
            .addConverterFactory(GsonConverterFactory.create(factory))
            .addCallAdapterFactory(CoroutineCallAdapterFactory())
            .client(client)
            .build()
    }

    single { provideCache(androidApplication()) }
    single { provideHttpClient(get()) }
    single { provideGson() }
    single { provideRetrofit(get(), get()) }

}

val databaseModule = module {

    fun provideDatabase(application: Application): AppDatabase {
        return Room.databaseBuilder(application, AppDatabase::class.java, "eds.database")
            .fallbackToDestructiveMigration()
            .allowMainThreadQueries()
            .build()
    }


    fun provideDao(database: AppDatabase): UserDao {
        return database.userDao
    }

    single { provideDatabase(androidApplication()) }
    single { provideDao(get()) }
}

val repositoryModule = module {
    fun provideUserRepository(api: UserApi, dao: UserDao): UserRepository {
        return UserRepository(api, dao)
    }

    single { provideUserRepository(get(), get()) }
}
//Module.kt

ВModule.kt есть объявление объекта, который нужен приложению. А в представлении мы берем inject, который говорит Koin, что нужен объект view-model. Библиотека в свою очередь старается найти этот объект в модуле, который мы определили ранее. Когда найдёт, назначит ему свойство userViewModel. А если не найдёт, то выдаст исключение. В нашем случае, код скомпилируется правильно, у нас есть экземпляр view-model в модуле с соответствующим параметром.

Похожий сценарий применится к репозиторию внутри view-model. Экземпляр будет получен из модуля Koin, потому что мы уже создали репозиторий с нужным параметром внутри модуля Koin.

Заключение

Самая сложная работа инженера ПО — это не разработка, а поддержка. Чем больше кода имеет под собой хорошую архитектуру, тем проще поддерживать и тестировать приложение. Вот почему важно пользоваться паттернами. С ними проще создать стабильно работающие программы, а не бомбу.

Вы можете найти полный код приложения у меня на GitHub по этой ссылке.

Специально для сайта ITWORLD.UZ. Новость взята с сайта NOP::Nuances of programming