前言
首先表明,这篇博客 80% 的内容是翻译自 Christopher Elias 的文章 《Understanding MVVM pattern for Android in 2021》。作者的原文题目翻译过来是 《理解 2021 年适用于 Android 的 MVVM 模式》,怕被喷标题党(因为感觉这个题目略大),所以我不太敢直接用原文题目Orz
本着尊重原创的精神,我是征得原作者同意后才敢翻译的,喏↓↓↓↓↓↓↓↓
网上介绍 MVVM 的文章有很多,讲得也都很棒!既然网上已经有那么多介绍 MVVM 的文章了,为什么我还是想要翻译这篇呢?
这篇文章它最吸引我的地方在于,作者在数据的获取到将数据渲染到界面的过程中抽象出了一个 State 类,将获取数据后的所有可能结果都封装到这个 State 类中,有很好的高内聚低耦合性,并且结合 Jetpack 组件中的 ViewModel、LiveData 简直不要太好用!所以我想要将这篇文章翻译成中文,一来是希望通过笔记的形式加深自己的印象,二来呢也是希望能让更多人看到这一优秀的实现方式。
基于我的理解,实现了一个小 demo。需求很简单,打开 APP,模拟从网络获取数据(一个水果名 List),并渲染到界面上,如下图。这里给出我的实现。
这里同时贴出 Christopher Elias 的 实现。这是一个大的项目,其中包含了这种实现方式,如果只是想要理解这种设计方式,我觉得看我的实现应该就足够了。Chris 的代码对于不熟悉 Kotlin 的人(譬如我)可能有点难以理解,他用到很多 Kotlin 的高级特性,代码写的非常漂亮,读一读大佬的实现还是可以学到一些东西的。
好啦,那废话不多说,我们开始。
以下内容不做特别说明均是原文翻译。
最后给出了我的实现。
我几乎可以 100% 确定你一定听过 MVC、MVP、MVVM、MVI、MV…。为了能够理解 MVVM 我们需要了解一些基础知识(别担心,我会直接挑重点讲的)。
问题是什么?
当我们开发 Android 应用程序时,我们倾向于将所有逻辑放进 Activitys、Fragments、Views 等等。
所以到最后,我们的视图做的就不仅仅是渲染 UI 了。他们可以将数据保存到 SharedPreferences、数据库中,甚至可以发起网络请求,并在一个地方处理所有这些额外的任务。
软件设计模式
软件设计模式是在软件设计中,对于给定上下文的常见问题的通用、可重用的解决方案。
维基百科
以上是一个关于软件设计模式的非常简短的定义,如果你想更深入了解,网上有很多资源可供参考。
好的,我们已经知道了问题所在,并且我们也知道有方法可以去解决它。
MVVM
V 表示 View,它可以是一个 Activity、Fragment,现在它甚至可以是 Composables 了!ViewModel 表示 Jetpack 组件中的 ViewModel,它是一个可以不受界面配置变化影响而存在的类。
OK,然后让我们把它们组装在一起,我们的 View 去订阅 ViewModel,然后对 Model 的变化做出响应。最后,轮到了 M,M 表示 Model,它是 emmmm 我的 model 是……
等等,什么是 “Model”?网上有很多文章告诉你说 Model 是你获取数据的“地方”,是你的仓库(repository)的所在地,等等。我认为这是错的,我将告诉你为什么。
Model 其实并不是什么新的概念,它最初是由 Trygve Reenskaug 于1979年定义的,作为 MVC 体系结构的一部分。
“Model 对表示状态、结构和用户心理模型负责。”
“View 负责展示它从一个或者多个 Model 获取的数据。”
“使 View 依赖于 Model,并且 Model 在发生改变的时候发送适当的信息给它的依赖者。”
可以用下面这个图做个总结:
模型应该代表着视图当前的状态,可以是加载、成功,或者一个失败的状态。然后视图需要根据当前的状态去渲染 UI。
代码
假设我们需要在应用中展示一个电影列表。我们可以用下面这个类来表示状态:
/**
* Represents the state to render the UI in MovieListFragment.
*
* @param isLoading if true we have to show a progress bar, else hide the progress bar.
* @param movies this list will be submited into recyclerview adapter.
* @param error OneTimeEvent that wraps a failure object for display a Toast, Snackbar, etc only once.
*/
data class MovieListUiState(
val isLoading: Boolean = false,
val movies: List<MovieUi> = emptyList(),
val error: OneTimeEvent<Failure>? = null
)
复制代码
啥是 OneTimeEvent?它只是一个普通的功能类,可以使我们只消耗一个对象一次,这样就可以避免当用户回到屏幕时显示 snackbars、toast 两次。
啥是 Failure?它其实是一个密封类(Sealed Class),可以表示任何类型的错误,你可以使用 Exception、String 等类型的错误表示,只要是可以清楚地告诉你的代码出了什么问题就行。
接下来的问题是,我们如何优雅地渲染界面?
class MovieListFragment : Fragment(R.layout.fragment_movie_list) {
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initView()
collectUiState()
}
private fun initView() {
binding.rvMovies.adapter = MovieListAdapter()
}
private fun collectUiState() {
viewLifecycleOwner.lifecycleScope.launch {
moviesViewModel.uiState.collect { state ->
renderUiState(state)
}
}
}
private fun renderUiState(state: MovieListUiState) {
with(state) {
// Progress
binding.progressBarMovies.isVisible = isLoading
// Bind movies.
(binding.rvMovies.adapter as MovieListAdapter)
.submitList(movies)
// Empty view
binding.tvMoviesEmpty.isVisible = !isLoading && movies.isEmpty()
// Display error if any. Only once.
error?.let {
it.consumeOnce { failure ->
Toast.makeText(
requireContext(),
"$failure",
Toast.LENGTH_LONG
).show()
}
}
}
}
...
}
复制代码
这里解释一下上面代码中的 3 个方法。
-
initView() 只负责初始化 RecyclerView 的 Adapter。
-
collectUiState() 获取 UI 状态 Flow。除了 Flow 以外,也可以使用 LiveData,这不重要。
-
renderUiState(state: MovieListUiState) 负责根据当前状态渲染界面。
最后,那 ViewModel 呢?ViewModel 是准备数据的(数据可以来自于你的 Repository 等),并且使用返回的结果去修改状态。
好了,以上就是全部啦!现在你应该知道了在你的 MVVM 架构中究竟啥是 Model。
接下来的几天我将上传更多的内容,以帮助您在 2021 年开发出很优秀的 Android 应用。这是一个我的 Playground 项目,并且我将在接下来的博客中写到其中所用到的知识。
我的实现
好啦,以上就是原作者的原文翻译。原作者的实现非常漂亮简洁,其中的 OneTimeEvent 是我第一次了解到,我觉得这是一个非常好的值得借鉴的地方,以后可以用在自己的项目中。
接下来,我将贴出基于我的理解实现的 demo。
上图是我的项目结构,非常的一目了然吧(狗头)。
我画了个不太标准的示意图:
首先看一下我这里的 State :
data class MainActivityUIState (
val isLoading: Boolean = false,
val fruits: List<Fruit> = emptyList(),
val error: String? = null
)
复制代码
跟 Chris 实现一样,只是这里简单的使用一个 String 来表示错误信息。
接下来是我的 Model 部分的实现:
class FruitRepository {
fun getFruitsFromRemote(onGetFruitsListener: OnGetFruitsListener) {
Thread.sleep(1500)
onGetFruitsListener.onSuccess(generateFruits())
}
private fun generateFruits(): List<Fruit> {
val fruits: MutableList<Fruit> = ArrayList()
fruits.apply {
add(Fruit("apple"))
add(Fruit("orange"))
add(Fruit("watermelon"))
add(Fruit("banana"))
add(Fruit("peach"))
add(Fruit("pineapple"))
add(Fruit("strawberry"))
add(Fruit("pear"))
}
return fruits
}
interface OnGetFruitsListener {
fun onSuccess(fruits: List<Fruit>)
fun onFailed(error: String)
}
}
复制代码
getFruitsFromRemote() 方法中通过 Thread.sleep(1500) 模拟网络请求的过程。代码也非常好理解。
接下来是 ViewModel 中,获取 UIState 部分的代码:
private fun initMainActivityUIState() {
mainActivityUIState.value = MainActivityUIState(isLoading = true, fruits = emptyList(), error = null)
Thread(Runnable { kotlin.run {
fruitRepository.getFruitsFromRemote(object : FruitRepository.OnGetFruitsListener{
override fun onSuccess(fruits: List<Fruit>) {
// mainActivityUIState.value = MainActivityUIState(isLoading = false, fruits = fruits, error = null)
mainActivityUIState.postValue(MainActivityUIState(isLoading = false, fruits = fruits, error = null))
}
override fun onFailed(error: String) {
// mainActivityUIState.value = MainActivityUIState(isLoading = false, fruits = emptyList(), error = error)
mainActivityUIState.postValue(MainActivityUIState(isLoading = false, fruits = emptyList(), error = error))
}
})
} }).start()
}
复制代码
也非常好理解,就是开启了一个新线程去请求数据。这里需要注意的是在子线程中修改 LiveData 的值必须使用 postValue。
最后是 View 部分的代码啦:
private fun initView() {
activityMainBinding.rvFruits.layoutManager = LinearLayoutManager(this)
mainActivityViewModel.getMainActivityUIState().observe(this,
Observer<MainActivityUIState> { t -> renderUIState(t) })
}
private fun renderUIState(state: MainActivityUIState?) {
Log.e(TAG, "render UI")
with(state!!) {
activityMainBinding.progressBar.isVisible = isLoading
activityMainBinding.tvEmpty.isVisible = !isLoading && fruits.isEmpty()
activityMainBinding.rvFruits.adapter = FruitAdapter(fruits)
}
}
复制代码
关键方法就是上面的这两个,初始化界面和渲染界面,也是非常的好理解,并且代码非常简洁。
好啦,以上就是全部内容了!
非常感谢 Christopher Elias 同意我翻译这篇优秀的文章,从他的文章和代码中我也学到了很多。
欢迎关注我呀~(微信:MCLzone)