Jetpack系列(一):告别屏幕旋转烦恼,ViewModel从入门到精通

323 阅读3分钟

Jetpack 简介

Jetpack 是 Google 推出的一套库、工具和指南的集合,旨在帮助开发者轻松构建 Android 应用。它的主要目的是:

将 Android 开发的最佳实践内建在组件中;提供简洁的 API 减少重复性代码;提供向后兼容的组件,组件可以在任何 Android 系统版本上运行。

我们新建一个名为 JetpackTestEmpty Views Activity 项目,就可以开始学习了。

ViewModel

ViewModel 是 Jetpack 中最重要的组件之一。因为在传统的开发模式下,Activity 的任务太重了。既要负责逻辑的处理,又要控制 UI 的展示等。小型项目还好,但是如果在大型项目中还这样写,项目会变得非常臃肿且难以维护,因为它没有任何架构上的划分。

ViewModel 的一个作用就是帮助 Activity 分担任务,专门用于存放与界面相关的数据。也就是界面看得到的数据,它的相关变量需要放在 ViewModel 中,而不是 Activity 中。

另一个作用是在手机屏幕旋转后,保证界面的数据不会丢失。因为当手机发生横竖屏旋转后,Activity 会重新创建,导致存放在 Activity 中的数据丢失。而 ViewModel 的生命周期和 Activity 不同,它在手机屏幕旋转后并不会被重新创建。只有当该 Activity 被销毁,且被移出了任务栈之后(例如按下返回键或是调用 finish() 方法),ViewModel 实例才会跟着销毁(onCleared() 方法被调用)。

ViewModel 的生命周期:

image.png

我们来通过一个计数器示例学习 ViewModel 的基本用法。

ViewModel 的基本用法

使用 ViewModel 需要先添加依赖:

// build.gradle.kts (Module: app) 
dependencies {
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.1")
}

首先,我们为 MainActivity 创建一个对应的 MainViewModel 类,继承自 ViewModel。代码如下:

class MainViewModel : ViewModel() {
}

然后,将与计数相关的 counter 变量放到该 ViewModel 中:

class MainViewModel : ViewModel() {
    var counter = 0   
}

接着,在布局中放置一个 Button,用于触发计数器 +1。放置一个 TextView,用于显示当前的计数。activity_main.xml 文件中的代码如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <TextView
        android:id="@+id/infoText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:textSize="32sp" />

    <Button
        android:id="@+id/plusOneBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="Plus One" />
</LinearLayout>

最后,在 MainActivity 中实现计数器的逻辑。

class MainActivity : AppCompatActivity() {

    private lateinit var viewModel: MainViewModel
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 通过 ViewModelProvider 获取 ViewModel 实例
        viewModel = ViewModelProvider(this)[MainViewModel::class.java]

        binding.plusOneBtn.setOnClickListener {
            viewModel.counter++
            refreshCounter()
        }
        refreshCounter()
    }

    /**
     * 更新当前的计数
     */
    private fun refreshCounter() {
        binding.infoText.text = "count: ${viewModel.counter}"
    }
}

其中,我们使用了 ViewModelProvider(this)[MainViewModel::class.java] 这行代码来获取 ViewModel 实例,那为什么不能直接创建 ViewModel 的实例呢?

因为如果我们直接在 onCreate() 方法中创建 ViewModel 实例,那么每次当 onCreate() 方法被调用时(比如因屏幕旋转导致Activity 重新创建),都会获取一个新的 MainViewModel 实例。这样,上一次实例的数据就会丢失,使得 ViewModel 失去了在配置变更中保存数据的核心意义。

ViewModelProvider(this) 会创建一个与 Activity 生命周期绑定的 Provider。

  • 如果你是第一次请求,它会创建一个新的 ViewModel 实例,并保存起来,以便后续使用;

  • 如果不是第一次,它会返回存在的旧实例。

运行程序,并点击界面中的按钮,即可增加计数:

image.png

即使屏幕发生旋转,界面中的数据也不会丢失。

其实,我们可以使用 activity-ktx 库中的属性委托 by viewModels() 来简化上述代码。

首先,添加依赖:

// build.gradle.kts
dependencies {
    implementation("androidx.activity:activity-ktx:1.10.1")
}

然后,修改 MainActivity。代码如下:

class MainActivity : AppCompatActivity() {
    
    private lateinit var binding: ActivityMainBinding

    // 使用属性委托声明
    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.plusOneBtn.setOnClickListener {
            viewModel.counter++
            refreshCounter()
        }
        refreshCounter()
    }

    /**
     * 更新当前的计数
     */
    private fun refreshCounter() {
        binding.infoText.text = "count: ${viewModel.counter}"
    }
}

向 ViewModel 传递参数

怎么向 ViewModel 传递一些参数呢?这需要用到 ViewModelProvider.Factory。现在,我们来实现即使在退出应用后重新打开的情况下,数据也不会丢失。

首先,给 MainViewModel 添加一个主构造函数,并带有一个 countReserved 参数,表示之前保存的数据。

class MainViewModel(countReserved: Int) : ViewModel() {
    var counter = countReserved
}

然后,创建 MainViewModelFactory 类,实现 ViewModelProvider.Factory 接口。

class MainViewModelFactory(private val countReserved: Int) : ViewModelProvider.Factory {

    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return MainViewModel(countReserved) as T
    }
}

这个 Factory 的 create() 方法是用于指定创建 ViewModel 实例的方式的。

另外,在布局中添加一个“清零”按钮,用于清零计数器。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical"
    android:padding="16dp">

    <TextView
        android:id="@+id/infoText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:textSize="32sp" />

    <Button
        android:id="@+id/plusOneBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="24dp"
        android:text="Plus One" />

    <Button
        android:id="@+id/clearBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:text="Clear" />
</LinearLayout>

最后,修改 MainActivity

class MainActivity : AppCompatActivity() {

    private lateinit var viewModel: MainViewModel
    private lateinit var binding: ActivityMainBinding
    private lateinit var sp: SharedPreferences

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 恢复之前的数据
        sp = getPreferences(Context.MODE_PRIVATE)
        val countReserved = sp.getInt("count_reserved", 0)

        // 创建我们自定义的 Factory 实例
        val factory = MainViewModelFactory(countReserved)

        // 将 Factory 传给 ViewModelProvider 来获取 ViewModel 实例
        viewModel = ViewModelProvider(this, factory)[MainViewModel::class.java]

        binding.plusOneBtn.setOnClickListener {
            viewModel.counter++
            refreshCounter()
        }

        binding.clearBtn.setOnClickListener {
            viewModel.counter = 0
            refreshCounter()
        }

        refreshCounter()
    }

    private fun refreshCounter() {
        binding.infoText.text = "count: ${viewModel.counter}"
    }

    override fun onPause() {
        super.onPause()
        // 保存当前的数据
        sp.edit {
            putInt("count_reserved", viewModel.counter)
        }
    }
}

可以看到,当需要传递参数时,只需在创建 ViewModelProvider 时,将自定义的 Factory 实例传入第二个参数即可。它会使用这个 Factory 来创建 MainViewModel 实例。

运行程序,退出应用并重新打开,计数值是不会丢失的。

image.png

同样地,属性委托也支持 Factory 模式,如下:

class MainActivity : AppCompatActivity() {

    private val viewModel: MainViewModel by viewModels {
        // 恢复之前的数据
        sp = getPreferences(Context.MODE_PRIVATE)
        val countReserved = sp.getInt("count_reserved", 0)
        // 需要返回我们自定义的 Factory 实例
        MainViewModelFactory(countReserved)
    }

    private lateinit var binding: ActivityMainBinding
    private lateinit var sp: SharedPreferences

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.plusOneBtn.setOnClickListener {
            viewModel.counter++
            refreshCounter()
        }

        binding.clearBtn.setOnClickListener {
            viewModel.counter = 0
            refreshCounter()
        }

        refreshCounter()
    }

    private fun refreshCounter() {
        binding.infoText.text = "count: ${viewModel.counter}"
    }

    override fun onPause() {
        super.onPause()
        // 保存当前的数据
        sp.edit {
            putInt("count_reserved", viewModel.counter)
        }
    }
}