【Android工程之美】2 LiveData - 使用ViewModel绑定UI和点击事件

2,519 阅读5分钟

前言

好了,这一章我们开始正式接触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/…