С MVVM (Model—View-ViewModel) процесс разработки графического интерфейса для пользователей делится на две части. Первая — это работа с языком разметки или кодом GUI. Вторая — разработка бизнес-логики или логики бэкенда (модель данных). Часть View model в MVVM — это конвертер значений. Это значит, что view model отвечает за конвертирование объектов данных из модели в такой вид, чтобы с объектами было легко работать. Если смотреть с этой стороны, то view model — это скорее модель, чем представление. Она контролирует большую часть логики отображения. Модель представления может реализовывать паттерн медиатор. Для этого организуется доступ к логике бэкенда вокруг набора юз-кейсов, поддерживаемых представлением.
В этом туториале мы попробуем определить каждый компонент паттерна MVVM, чтобы создать небольшое приложение на Android в соответствии с ним.
На следующей картинке — разные элементы, которые мы собираемся создать при помощи компонента Architecture и библиотеки Koin для внедрения зависимостей.
Архитектуру ниже можно разделить на три различные части.
ПредставлениеСодержит структурное определение того, что пользователи получат на экранах. Вы можете поместить сюда статическое и динамическое содержимое (анимацию и смену состояний). Тут может не быть никакой логики приложения. Для нашего случая в представлении может быть активность или фрагмент.
Модель представленияЭтот компонент связывает модель и представление. Отвечает за управление ссылками данных и возможных конверсий. Здесь появляется биндинг. В Android мы не беспокоимся об этом, потому что можно напрямую использовать класс AndroidViewModel или ViewModel.
МодельЭто уровень бизнес-данных и он не связан ни с каким особенным графическим представлением. В Android, согласно “чистой” архитектуре, модель может содержать базу данных, репозиторий и класс бизнес-логики. Картинка ниже описывает взаимодействие между разными компонентами.
Чтобы реализовать паттерн 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)
}
}
}
Как сами видите, у репозитория есть конструктор с двумя параметрами. Первый — это класс, который представляет онлайн-данные, а второй — представляет данные оффлайн.
После того, как мы описали модель и все ее части, пора ее реализовать. Для этого возьмем класс, родителем которого является класс 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
Комментарии