前言
好了,这一章我们开始正式接触viewmodel,先来看本章实现的效果。谷歌官方为了演示MVVM实现了一个本地数据源的TODO APP 仓库地址 (github.com/android/arc…),我们整个系列的目标是实现一个相同界面带有网络储存的版本。为了不一下引入太多概念,本章便直接使用object储存数据,再之后的章节中会不断完善。

动手
依赖
在module的build.gradle中启动kotlin annotation插件,并新增UI组件依赖
apply plugin: 'kotlin-kapt'
dependencies {
...
implementation "com.google.android.material:material:$materialVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:1.1.0"
}
在project的build.gradle中指定版本号
ext {
...
materialVersion = '1.1.0'
}
Task Model
首先,这是个管理todo的app,得有模型 我们在根目录下建立一个data文件夹,新建Task data class
data class Task(
var title: String = "",
var description: String = "",
var isCompleted: Boolean = false,
var id: String = UUID.randomUUID().toString()
)
由于没有数据库,我们在同级目录下建立一个Tasks的object,当作数据源并预先填充进一些数据(下一章就把它干掉)
object Tasks {
var tasks: List<Task> = listOf(
Task("春眠不觉晓", "处处闻啼鸟"),
Task("人生若只如初见", "何事秋风悲画扇")
)
}
TasksFragment
新建一个TasksFragment及其对应的ViewModel和layout(以下简称三件套)用来展示首屏的task列表。 在layout中放一个RecyclerView作为容器。
class TasksFragment : Fragment() {
private lateinit var viewModel: TasksViewModel
private lateinit var binding: TasksFragmentBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
viewModel = TasksViewModel()
binding = TasksFragmentBinding.inflate(inflater, container, false).apply {
viewmodel = viewModel
lifecycleOwner = this@TasksFragment
}
return binding.root
}
}
注意,这个lifecycleOwner = this@TasksFragment很重要,没有这一句的话之后对livedata的任何修改都不会触发fragment view的更新。
TasksLayout
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="viewmodel"
type="xyz.yuanxiaoqing.todo.tasks.TasksViewModel" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<RelativeLayout
android:id="@+id/tasks_container_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="true"
android:orientation="vertical">
<LinearLayout
android:id="@+id/tasks_linear_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/tasks_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>
</LinearLayout>
</RelativeLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>
RecyclerView
我们知道,RecyclerView有三个重要概念,layoutManager、Adapter、ViewHolder,对于layoutManager,可以直接在xml中使用app:layoutManager声明。对于gridView这种需要参数传入的情况,可以这样传入
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/tasks_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:spanCount="2"/>
创建adapter
class TasksAdapter(private val viewModel: TasksViewModel) :
ListAdapter<Task, TasksAdapter.ViewHolder>(TaskDiffCallback()) {
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = getItem(position)
holder.bind(viewModel, item)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder.from(parent)
}
class ViewHolder private constructor(val binding: TaskItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(viewModel: TasksViewModel, item: Task) {
binding.viewmodel = viewModel
binding.task = item
binding.executePendingBindings()
}
companion object {
fun from(parent: ViewGroup): ViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = TaskItemBinding.inflate(layoutInflater, parent, false)
return ViewHolder(binding)
}
}
}
}
/**
* 计算两个item是否相同
*/
class TaskDiffCallback : DiffUtil.ItemCallback<Task>() {
override fun areItemsTheSame(oldItem: Task, newItem: Task): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Task, newItem: Task): Boolean {
return oldItem == newItem
}
}
ViewModel
class TasksViewModel(val tasksListener: TasksListener) : ViewModel() {
private val _items = MutableLiveData<List<Task>>(Tasks.tasks)
val items: LiveData<List<Task>> = _items
}
自定义BindAdapter
好了,依据mvvm管理,我们应该把recyclerView绑定到数据源viewmodel.items上,但是recyclerview的xml中并不提供这个配置项,怎么办呢。那只能自己创造条件了。我们新建一个TaskListBinding文件。
@BindingAdapter("app:items")
fun setItems(listView: RecyclerView, items: List<Task>?) {
items?.let {
(listView.adapter as TasksAdapter).submitList(items)
}
}
@BindingAdapter可以为xml元素创建setter,实现架构上的优雅,BindingAdapter更多用法参见官方文档 developer.android.com/topic/libra…
阶段小结
运行,效果如下,完美。任务展示列表已经准备好了,是时候为他添加点新数据了。现在开始添加一个编辑添加数据用的fragment。

AddEditTask
这个页面中允许用户编辑任务的title和description,允许用户执行saveTask操作,然后返回到上级页面。 同样的操作先准备好三件套(fragment, viewmodel, layout)
ViewModel
class AddEditTaskViewModel : ViewModel() {
val title = MutableLiveData<String>()
val description = MutableLiveData<String>()
fun saveTask(view: View) {
// TODO
}
}
因为title和description需要接受用户输入,是双向绑定关系,因此这里直接将MutableLiveData暴露给外部。
Layout
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="viewmodel"
type="xyz.yuanxiaoqing.todo.addedittask.AddEditTaskViewModel" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/coordinator_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="8dp"
android:paddingTop="16dp"
android:paddingRight="8dp"
android:paddingBottom="16dp">
<EditText
android:id="@+id/add_task_title_edit_text"
android:background="@null"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="标题"
android:imeOptions="flagNoExtractUi"
android:maxLines="1"
android:text="@={viewmodel.title}"
android:textStyle="bold"
android:textAppearance="@style/TextAppearance.AppCompat.Title" />
<EditText
android:id="@+id/add_task_description_edit_text"
android:background="@null"
android:layout_width="match_parent"
android:layout_height="350dp"
android:gravity="top"
android:hint="描述"
android:imeOptions="flagNoExtractUi"
android:text="@={viewmodel.description}" />
</LinearLayout>
</ScrollView>
</FrameLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/save_task_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:src="@drawable/ic_done"
android:onClick="@{viewmodel::saveTask}"
app:fabSize="normal"
app:layout_anchor="@id/container"
app:layout_anchorGravity="bottom|right|end" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>
AddEditTaskFragment
class AddEditTaskFragment : Fragment() {
private lateinit var viewModel: AddEditTaskViewModel
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
viewModel = AddEditTaskViewModel()
val binding = AddEditTaskFragmentBinding.inflate(inflater, container, false).apply {
viewmodel = viewModel
lifecycleOwner = this@AddEditTaskFragment
}
return binding.root
}
}
处理saveTask事件
用户在输入title和description后点击确认,回到上一页,如果title和description有一个缺失,则通过toast通知用户补全信息。
经过分析,由于viewmodel不应该感知任何Android框架的存在,有两件事在tasks中是无法实现的,一个是弹出toast通知用户,另一个是退出当前页面。
我们新建一个接口,将这两个方法在接口中定义。
interface AddEditTaskListener {
fun onTasksUpdated()
fun onToast(msg: String)
}
在fragment中实现这个接口
class AddEditTaskFragment : Fragment(), AddEditTaskListener {
...
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
viewModel = AddEditTaskViewModel(this)
...
}
override fun onTasksUpdated() {
(activity as MainActivity).switchTasks()
}
override fun onToast(msg: String) {
Toast.makeText(activity, msg, Toast.LENGTH_SHORT).show()
}
}
然后,在viewmodel中使用listener
class AddEditTaskViewModel(val addEditTaskListener: AddEditTaskListener) : ViewModel() {
...
fun saveTask(view: View) {
if (title.value.isNullOrEmpty() || description.value.isNullOrEmpty()) {
addEditTaskListener.onToast("标题和描述不能为空!")
return
}
Tasks.tasks = Tasks.tasks + Task(title.value!!, description.value!!)
addEditTaskListener.onTasksUpdated()
}
}
在TasksFragment中添加跳转
同样操作,我们修改UI,新建一个接口用来处理跳转到新建任务页面。
新建一个按钮
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
...
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/add_task_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:src="@drawable/ic_add"
app:fabSize="normal"
android:visibility="gone"
android:onClick="@{() -> viewmodel.createTask()}"
app:layout_anchor="@id/tasks_container_layout"
app:layout_anchorGravity="bottom|right|end" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>
新建一个接口
interface TasksListener {
fun createTask()
}
修改vm
class TasksViewModel(val tasksListener: TasksListener) : ViewModel() {
...
fun createTask() {
tasksListener.createTask()
}
}
fragment中实现接口
class TasksFragment : Fragment(), TasksListener {
...
override fun createTask() {
(activity as MainActivity).switchToOnCreateTask()
}
}
大功告成 完整代码见 github.com/yxq2233234/…