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


Введение

С 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 по этой ссылке.


Перевод статьи Eric Ampire: MVVM on Android with the Architecture Components + Koin


Поделиться статьей:


Вернуться к статьям

Комментарии

    Ничего не найдено.