探究Jetpack

我不是罗大锤 2021年11月22日 159次浏览

长久以来,Android官方并没有指定一个项目架构的规范,只要能够实现功能,代码怎么编写都是你的自由。但是不同人的技术水平不同,最终编写出来的代码是千差万别的。
为了追求更高的代码质量,慢慢的就有第三方的社区和开发者将一些更加高级的项目架构引入到了Android平台上,如MVP、MVVM等。使用这些架构开发出来的应用程序在代码质量、可读性、易维护性等方面都有着出色的表现,于是这些框架逐渐成为了主流。
后来Google或许意识到了这个情况,终于在2017年,推出了一个官方的架构组件库——Architecture Components,意在帮助开发者编写出更加符合高质量代码规范、更具有架构设计的应用程序。
2018年Google又推出了一个全新的开发组件工具集——Jetpack,并将Architecture Components作为Jetpack的一部分纳入其中。
2019年又有许多新的组件被加入Jetpack中,未来的Jetpack还会不断地继续扩充。

一、Jetpack简介

Jetpack是一个开发工具集,它的主要目的是帮助我们编写出更加简洁的代码,并简化我们的开发过程。Jetpack中的组件有一个特点,它们大部分不依赖于任何Android系统版本,这意味着这些组件通常是定义在AndroidX库当中的,并且拥有非常好的向下兼容性。下图是一张Jetpack的“全家福”:
探究Jetpack_1.png

可以看到,Jetpack的家族还是非常庞大的,主要由基础、架构、行为、界面这4个部分组成。Jetpack并不全是些新东西,只要是能够帮助开发者更好更方便的构建应用程序的组件,Google都将其纳入了Jetpack。

目前Android官方最为推荐的项目架构就是MVVM,因而Jetpack中的许多架构组件是专门为MVVM架构量身打造的。

二、ViewModel

ViewModel可以说是Jetpack中最重要的组件之一了,而ViewModel的一个重要的作用就是可以帮助Activity分担一部分工作,它是专门用于存放与界面相关的数据的。也就是说,只要是界面上能看到的数据,它的相关变量都应该存放在ViewModel中,而不是Activity中,这样可以在一定程度上减少Activity中的逻辑。

另外,ViewModel还有一个非常重要的特性。当手机发生竖屏旋转的时候,Activity会被重新创建,同时存放在Activity中的数据也会丢失。而ViewModel的生命周期和Activity不同,它可以保证在手机屏幕发生旋转的时候不会被重新创建,只有当Activity退出的时候才会跟着Activity一起销毁,因此,将与界面相关的变量存放在ViewModel当中,这样即使旋转手机屏幕,界面上显示的数据也不会丢失。ViewModel的生命周期如图所示:

探究Jetpack_2.png

1.ViewModel的基本用法

由于Jetpack中的组件通常是以AndroidX库的形式发布的,因此一些常用的Jetpack组件会在创建Android项目的时候自动被包含进去。不过如果我们想要使用ViewModel组件,还需要在app/build.gradle文件中添加如下依赖:

dependencies {
    implementation "androidx.lifecycle:lifecycle-extensions:2.1.0"
}

通常来讲,比较好的编程规范是给每一个Activity和Fragment都创建一个对应的ViewModel类,并让它继承自ViewModel,代码如下所示:

class MainViewModel : ViewModel() {
    
}

根据前面所学的知识,所有与界面有关的数据都应该放在ViewModel中,那么这里如果我们要实现一个计数器的功能,就可以在ViewModel中加入一个counter变量用于计数,如下所示:

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

下面实现与ViewModel对应的MainActivity中的代码:

class MainActivity : AppCompatActivity() {
    
    lateinit var viewModel: MainViewModel
    
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        // 获取ViewModel实例,不能直接创建实例
        // 而是要通过ViewModelProvider来获取
        viewModel = ViewModelProvider(this)
            .get(MainViewModel::class.java)
        
        // 按钮事件是给counter加1,并打印加1后的数据
        btn.setOnClickListener {
            viewModel.counter++
            println(viewModel.counter.toString())
        }
    }
}

上面之所以不可以直接去创建ViewModel实例,是因为ViewModel有独立的生命周期,并且生命周期要长于Activity,如果我们在onCreate()方法中创建ViewModel实例,那么每次onCreate()方法执行的时候,ViewModel都会创建一个新的实例,这样当手机屏幕发生旋转的时候,就无法保留其中的数据了。

2.向ViewModel传递参数

上一小节的构造函数中没有任何参数,如果我们确实需要通过构造函数来传递一些参数,应该怎么办?由于所有的ViewModel实例都是通过ViewModel参数来获取的,因此我们没有任何地方可以向ViewModel的构造函数中传递参数。

当然这个问题也不难解决,只需要借助ViewModelProvider.Factory就可以实现了。下面通过具体的例子来学习一下。

修改上一小节的MainViewModel中的代码:

// 给构造函数添加一个参数,用于记录之前保存的值
// 并在初始化的时候赋值给了counter
class MainViewModel(countReservesd: Int): ViewModel() {
    var counnter = countReserved
}

实现ViewModelProvider.Factory接口。新建MainViewModelFactory类:

// 接收countReserved参数
class MainViewModelFactory(private val countReserved: Int) :
        ViewModelProvider.Factory {
    
    // 必须实现create()方法
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        // 创建MainViewModel实例并返回
        // create()方法的执行时机和Activity生命周期无关
        // 所以这里直接创建实例不会产生之前提到的问题
        return MainViewModel(countReserved) as T
    }
}

修改MainActivity中获取MainViewModel实例代码:

class MainActivity : AppCompatActivity() {
    lateinit var viewModel: MainViewModel
    
    override fun onCreate(...) {
        ...
        // 在ViewModelProvider构造函数中额外传入Factory的实例
        viewModel = ViewModelProvider(this, 
            MainViewModelFactory(countReserved))
            .get(MainViewModel::class.java)
        ...
    }
}

三、Lifecycles

在编写Android应用程序的时候,可能会经常遇到需要感知Activity生命周期的情况。比如说某个界面发起了一条网络请求,但是当请求得到相应的时候,界面或许已经关闭了,这个时候就不应该继续对响应的结果进行处理。因此我们需要能够时刻感知到Activity的生命周期以便在适当的时侯进行相应的逻辑控制。

感知Activity的生命周期并不复杂,但问题在于在一个非Activity的类中去感知Activity的生命周期应该怎么办呢?

Lifecycles组件就是为了解决这个问题而出现的,它可以让任何一个类都能轻松感知到Activity的生命周期,同时又不需要在Activity中编写大量的逻辑处理。

1.Lifecycles的基本用法

下面通过具体的例子来学习Lifecycles组件的用法。新建MyObserver类,并实现LifecycleObserver接口:

// MyObserver中可以定义任何方法
// 但如果想要感知Activity生命周期就需要额外的注解才行
class MyObserver : LifecyclesObserver {
    
    // 在Activity的onStart()时触发
    @OnLifecycleEvent(Lifecycle.Event.ON_START)
    fun activityStart() {
        Log.d("MyObserver", "activityStart")
    }
    
   // 在Activity的onStop()时触发
   @onLifecycleEvent(Lifecycle.Event.ON_STOP)
   fun activityStop() {
       Log.d("MyObserver", "activityStop")
   }
}

@OnLifecycleEvent注解生命周期事件的类型一共有7种:ON_CREATE、ON_START、ON_RESUME、ON_PAUSE、ON_STOP、ON_DESTROY和ON_ANY,其中ON_ANY表示匹配Activity的任何生命周期回调,其他的分别对应着Activity中相应的生命周期回调。

下面代码可以将MyObserver添加到Activity中:

lifecycleOwner.lifecycle.addObserver(MyObserver())

首先调用LifecycleOwner的**getLifecycle()方法得到一个Lifecycle对象,然后调用它的addObserver()**方法来观察LifecycleOwner的生命周期,再把MyObserver的实例传进去就可以了。

如果你的Activity继承自AppCompatActivity或者Fragment继承自androidx.fragment.app.Fragment那么它本身就是一个LifecycleOwner实例,这部分工作已经由AndroidX库自动帮我们完成了,也就是说在MainActivity中当中就可以直接这样写:

class MainActivity : AppCompatActivity() {
    override fun onCreate(...) {
        ...
        // 添加这行代码后MyObserver就可以感知到Activity生命周期
        lifecycle.addObserver(MyObserver())
    }
}

上面的方法同样也适用于Fragment。

2.主动获知当前的生命周期状态

// 将Lifecycle对象传进来即可
// 调用lifecycle.currentState来主动获知当前的生命周期状态
class MyObserver(val lifecycle: Lifecycle) : LifecycleObserver {
    ...
}

lifecycle.currentState返回的生命周期是一个枚举类型,一共有INITIALIZED、DESTROYED、CREATED、STARTED、RESUMED这五种状态类型。它们与Activity的生命周期回调所对应的关系如图所示:

探究Jetpack_3.jpg

也就是说当获取的生命周期状态是CREATED的时候,说明onCreate()方法已经执行了,但是onStart()方法还没有执行
当获取的生命周期是STARTED的时候,说明onStart()方法已经执行了, 但是onResume()方法还没有执行,以此类推。

四、LiveData

LiveData是Jetpack提供的一种响应式编程组件它可以包含任何类型的数据,并在数据发生变化的时候通知给观察者LiveData特别适合与ViewModel结合在一起使用,虽然它也可以单独用在别的地方,但是在绝大多数情况下,它是使用在ViewModel当中的。

1.LiveData的基本用法

根据上一小章的ViewModel进行修改,在Activity中对counter进行观察,在counter变量的数据发生变化之后可以主动通知Activity。

修改MainViewModel中的代码:

class MainViewModel(countReserved: Int) : ViewModel() {
    // 将counter变量改成MutableLiveData类型
    // 指定泛型为Int,代表它包含的是整形数据
    // MutableLiveData是一种可变的LiveData,主要有三种读写数据方法
    // getValue():获取LiveData中包含的数据
    // setValue():给LiveData设置数据,只能在主线程调用
    // postValue():在非主线程中给LiveData设置数据
    val counter = MutableLiveData<Int>()
    
    init {
        counter.value = countReserved
    }
    
    // 给counter计数器加1
    fun plusOne() {
        // counter的getValue()语法糖写法
        val count = counter.value ?: 0
        // counter的setValue()语法糖写法
        counter.value = count + 1
    }
    
    // 给counter计数器清零
    fun clear() {
        // counter的setValue()语法糖写法
        counter.value = 0
    }
}

下面修改MainActivity代码:

class MainActivity : AppCompatActivity() {
    ...
    override fun onCreate(...) {
        ...
        plusOneBtn.setOnClickListener {
            viewModel.plusOne()
        }
        clearBtn.setOnClickListener {
            viewModel.clear()
        }
        
        // 调用counter的observer方法来观察数据的变化
        // 第一个参数接收LifecycleOwner对象
        // 第二个参数是一个Observer接口
        // 当counter中数据发生变化时就会回调到这里
        viewModel.counter.observe(this) { count ->
            println(count.toString())    
        }
    }
}

想要对observer使用Kotlin语法特性的话可能需要引入如下依赖:

dependencies {
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"
}

2.LiveData更推荐的做法

上一小节的写法虽然可以正常工作,但其实任然不是最规范的LiveData用法,主要问题就在于我们将counter这个可变的LiveData暴露给了外部。这样即使是在ViewModel的外面也是可以给counter设置数据的,从而破坏了ViewModel数据的封装性,同时也可能带来一定的风险。

比较推荐的做法是,永远只暴露不可变的LiveData给外部,这样在非ViewModel中就只能观察LiveData的数据变化,而不能给LiveData设置数据。下面改造MainViewModel:

class MainViewModel(countReserved: Int) : ViewModel() {
    
    // 对外的counter设置为不可变LiveData
    // 变量的get()方法返回_counter数据
    val counter: LiveData<Int>
        get() = _counter
    
    // 给可变LiveData加上private修饰符
    private val _counter = MutableLiveData<Int>()
    
    init {
        _counter.value = countReserved
    }
    
    fun plusOne() {
        val count = _counter.value ?: 0
        _counter.value = count + 1
    }
    
    fun clear() {
        _counter.value = 0
    }
}

3.map和switchMap

LiveData的基本用法虽说可以满足大部分的开发需求,但是当项目变得复杂了之后,可能会出现一些更加特殊的需求。LiveData为了能够应对各种不同的需求场景,提供了两种转换方法:**map()switchMap()**方法。

3.1.map()方法

map()方法的作用是将实际包含数据的LiveData和仅用于观察数据的LiveData进行转换。下面举例一个使用到这个方法的地方。

比如说有一个User类,User中包含用户的姓名和年龄,定义如下:

data class User(var firstName: String, var lastName: String,
    var age: Int)

我们可以在ViewModel中创建一个相应的LiveData来包含User类型的数据,如下所示:

class MainViewModel(countReserved: Int) : ViewModel() {
    val userLiveData = MutableLiveData<User>()
    ...
}

如果MainActivity中明确只会显示用户的姓名,而完全不关心用户的年龄,那么这个时候还将整个User类型的LiveData暴露给外部,就显得不那么合适了。

map()方法就是专门用于解决这种问题的,它可以将User类型的LiveData自由地转型成任意其他类型的LiveData,下面看一下具体用法:

class MainViewModel(countReserved: Int) : ViewModel() {
    
    // 添加private保证数据的封装性
    private val userLiveData = MutableLiveData<User>()
    
    // 调用map()方法来对LiveData的数据类型进行转换
    // 第一个参数是原始的LiveData对象
    // 第二个参数是一个转换函数,在其中编写具体的转换逻辑即可
    // 这里的逻辑就是把User对象转换成只包含用户姓名的字符串
    // 外部只需观察userName即可
    val userName: LiveData<String> = Transformations.map(userLiveData) { user -> 
        "${user.firstName} ${user.lastName}"
    }
    ...
}

3.2.switchMap()方法

switchMap()的使用场景非常固定,但是可能比map()方法要更加常用。

前面所学的内容都有一个前提:LiveData对象的实例都是在ViewModel中创建的。然而在实际的项目中,不可能一直是这种理想情况,很可能ViewModel中的某个LiveData对象是调用另外的方法获取的

下面就来模拟一下这种情况,新建一个Repository单例类,代码如下所示:

object Repository {
    
    // 返回包含数据的LiveData对象
    fun getUser(userId: String): LiveData<User> {
        val liveData = MutableLiveData<User>()
        liveData.value = User(userId, userId, 0)
        return liveData
    }
}

然后在ViewModel中也定义一个**getUser()**方法,并且让它调用Repository的getUser()方法来获取LiveData对象:

class MainViewModel(...) : ViewModel() {
    ...
    // 在Activity中不能直接使用getUser(userId).observe来观察
    // 因为上述写法会一直观察老的LiveData实例
    // 这个时候switchMap()方法就可以派上用场了
    fun getUser(userId: String): LiveData<User> {
        return Repository.getUser(userId)
    }
}

正如前面所说,switchMap()的使用场景非常固定,如果ViewModel中的某个LiveData对象是调用的另外的方法或取的,那么我们就可以借助switchMap()方法,将这个LiveData对象转换成另一个可观察的LiveData对象。修改MainViewModel代码:

class MainViewModel(...) : ViewModel() {
    ...
    // 用来观察userId的数据变化
    private val userIdLiveData = MutableLiveData<String>()
    
    // 对另一个可观察的LiveData对象进行转换
    val user: LiveData<User> = 
            Transformations.switchMap(userIdLiveData) { userId ->
        // 注意!!!
        // 必须在这个转换函数中返回一个LiveData对象
        // 因为switchMap()工作原理就是将转换函数中返回的LiveData-
        // -转换成另一个可观察的LiveData对象,那么很显然
        // 我们只需调用Repository.getUser()得到的LiveData直接返回即可
        Repository.getUser(userId)
    }
    
    fun getUser(userId: String) {
        userIdLiveData.value = userId
    }
}

为了更清晰的理解switchMap()的用法,再来梳理一遍它的整体工作流程。首先,当外部调用MainViewModel的getUser()方法来获取用户数据时,并不会发起任何请求或者函数调用,只会将传入的userId值设置到userIdLiveData当中。一旦userIdLiveData的数据发生变化,那么观察userIdLiveData的switchMap()方法就会执行,并且调用我们编写的转换函数。然后在转换函数中调用Repository.getUser()方法获取真正的用户数据。同时,switchMap()方法会将Repository.getUser()方法返回的LiveData对象转换成一个可观察的LiveData对象,对于Activity而言,只要去观察这个LiveData对象就可以了。

五、Room

之前学习过SQLite数据库的使用方法,不过当时仅仅是使用了一些原生的API来进行数据的增删改查操作。这些原生API虽然简单易用,但是如果放到大型项目当中的话,会非常容易让项目的的代码变得混乱。

为此市面上出现了诸多专门为Android数据库设计的ORM框架,ORM(Object Relational Mapping)也叫对象关系映射。简单来讲,我们使用的编程语言是面向对象语言,而使用的数据库则是关系型数据库,将面向对象的语言和面向关系的数据库之间建立一种映射关系,这就是ORM了。

ORM的好处就是可以直接用面向对象的思维来和数据库进行交互,绝大多数情况下不用再和SQL语句打交道了,同时也不用担心操作数据库的逻辑会让项目的整体代码变得混乱。

1.使用Room前准备

Room整体结构主要由Entity、DaoDatabase这三部分组成,每个部分都有明确的职责,详细说明如下:

  1. Entity
    用于定义封装实际数据的实体类,每个实体类都会在数据库中有一张对应的表,并且表的列是根据实体类中的字段自动生成的。
  2. Dao
    数据库访问对象,通常会在这里对数据库的各项操作进行封装,在实际编程的时候,逻辑层就不需要和底层数据库打交道了,直接和Dao层进行交互即可。
  3. Database
    用于定义数据库中的关键信息,包括数据库的版本号包含哪些实体类以及提供Dao层的访问实例

使用Room前,还需要在app/build.gradle文件中添加如下的依赖:

plugins {
    ...
    // 由于Room会根据项目中声明的注解来动态生成代码
    // 所以这里一定要使用kapt引入Room的编译时注解库
    // 而启用编译时注解功能一定要先添加kotlin-kapt插件
    // Java项目中使用 annotationProcessor
    id 'kotlin-kapt'
}

dependencies {
    implementation "androidx.room:room-runtime:2.2.5"
    kapt "androidx.room:room-compiler:2.2.5"
}

2.Room基本使用

1.定义Entity

// @Entity注解声明Entity类,对应一张表
@Entity
data class User(var firstName: String, var lastName: String,
    var age: Int) {
    
    // 给每一个实体类都添加一个id字段
    // 注解将id字段声明成主键,并使主键值自动生成(增加)
    @PrimaryKey(autoGenerate = true)
    var id: Long = 0
}

2.定义Dao

Dao是Room用法中最关键的地方,所有访问数据库的操作都是在这里封装的。访问数据库的操作无非就是增删改查这4种,但是业务需求却是千变万化的。而Dao要做的事情就是覆盖所有的业务需求,使得业务方永远只需要与Dao层进行交互,而不必和底层的数据库打交道。

// 使用@Dao注解
@Dao
interface UserDao {
    
    // @Insert表示将参数中传入的User插入数据库,并返回主键id
    @Insert
    fun insertUser(user: User): Long
    
    // @Update表示将参数中传入的User对象更新到数据库中
   @Update
   fun updateUser(newUser: User)
    
   // 如果想要查询数据,或者使用非实体类参数来增删改查数据——
   // ——那么就必须编写SQL语句了
   @Query("SELECT * FROM User")
   fun loadAllUsers(): List<User>
   
   @Query("SELECT * FROM User WHERE age > :age")
   fun loadUsersOlderThan(age: Int): List<User>
   
   // @Delete表示会将传入的User对象从数据库中删除
   @Delete
   fun deleteUser(user: User)
   
   @Query("DELETE FROM User WHERE lastName = :lastName")
   fun deleteUserByLastName(lastName: String): Int
}

3.定义Database

这部分的写法是非常固定的,只需要定义好3个部分的内容:数据库的版本号、包含哪些实体类、以及提供Dao层的访问实例。创建一个AppDatabase.kt:

// 在注解中声明数据库版本以及包含哪些实体类,多个实体类逗号隔开
// 一定要使用抽象类,并提供获取Dao层的抽象方法
@Database(version = 1, entities = [User::class])
abstract class AppDatabase : RoomDatabase() {
    
    //  只用声明方法,具体实现由Room在底层自动完成
    abstract fun userDao(): UserDao
    
    companion object {
        
        // 单例模式
        // 原则上全局只应存在一份AppDatabase实例
        private var instance: AppDatabase? = null
        
        @Synchronized
        fun getDatabase(context: Context): AppDatabase {
            instance?.let {
                return it
            }
            // 构建AppDatabase实例并返回
            // 第一个参数一定要使用applicationContext
            // 不能直接使用context,否则容易出现内存泄漏
            // 第三个参数是数据库名
            return Room.databaseBuilder(
                context.applicationContext,
                AppDatabase::class.java,
                "app_database").build().apply {
                    instance = this
                }
        }
    }
}

4.实现增删改查逻辑

class MainActivity : AppCompatActivity() {
    override fun onCreate(...) {
        ...
        val userDao = AppDatabase.getDatabase(this).userDao()
        val user1 = User("Tom", "Brady", 40)
        val user2 = User("Tom", "Hanks", 63)
        addDataBtn.setOnClickListener {
            thread {
                // 后面删除和更新操作基于这个ID来操作
               user1.id = userDao.insertUser(user1)
               user2.id = userDao.insertUser(user2)
            }
        }
        
        updateDataBtn.setOnClickListener {
            thread {
                user1.age = 42
                userDao.updateUser(user1)
            }
        }
        
        deleteDataBtn.setOnClickListener {
            thread {
                userDao.deleteUserByLastName("Hanks")
            }
        }
        
        queryDataBtn.setOnClickListener {
            thread {
                for (user in userDao.loadAllUsers()) {
                    Log.d("MainActivity", user.toString())
                }
            }
        }
    }
}

5.在主线程中操作数据库

由于数据库操作属于耗时操作,Room默认是不允许在主线程中进行数据库操作的。不过为了方便测试,Room还提供了一个更加简单的方法,如下所示:

Room.databaseBuilder(context.applicationContext, 
    AppDatabase::class.java, "app_database")
    // 只建议在测试环境下使用
    .allowMainThreadQueries()
    .build()

3.Room数据库升级

1.开发阶段暴力升级

如果目前只是在开发测试阶段,不想编写那么麻烦的数据库升级逻辑,Room倒也提供了一个简单粗暴的方法,这个方法只要数据库进行了升级,Room就会将当前的数据库销毁,然后在重新创建,随之而来的副作用就是之前数据库中的所有数据就全部丢失了。

Room.databaseBuilder(context.applicationContext, 
    AppDatabase::class.java, "app_database")
    // 只建议在开发测试阶段使用
    .fallbackToDestructiveMigration()
    .build()

2.需求升级——添加表

随着业务逻辑的升级,现在打算在数据库中添加一张Book表,那么首先要做的就是创建一个Book的实体类,如下所示:

@Entity
data class Book(var name: String, var pages: Int) {
    
    @PrimaryKey(autoGenerate = true)
    var id: Long = 0
}

然后创建一个BookDao接口,并在其中随意定义一些API:

@Dao
interface BookDao {
    
    @Insert
    fun insertBook(book: Book): Long
    
    @Query("SELECT * FROM Book")
    fun loadAllBooks(): List<Book>
}

接下来修改AppDatabase中代码,在里面编写升级的逻辑,如下所示:

// 版本号升级成2,添加Book实体类
@Database(version = 2, entities = [User::class, Book::class])
abstract class AppDatabase : RoomDatabase() {
    ...
    abstract fun bookDao(): BookDao
    
    companion object {
        
        // 表示数据库从1升级到2就执行这个匿名类中升级逻辑
        // 语句必须与Book实体类一致,否则异常
        val MIGRATION_1_2 = object : Migration(1, 2) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL("create table Book (id integer" +
                    "key autoincrement not null, name text not null," +
                    "pages integer not null)")
            }
        }
        
        ...
        
        fun getDatabase(context: Context): AppDatabase {
            instance?.let {
                return it
            }
            return Room.databaseBuilder(context.applicationContext,
                AppDatabase::class.java, "app_database")
                // 将 MIGRATION_1_2 传入
                .addMigrations(MIGRATION_1_2)
                .build().apply {
                    instance = this
                }
        }
    }
}

3.需求升级——修改表

现在Book表只有id、书名、页数这几个字段,而我们想要再加一个作者字段,代码如下所示:

@Entity
// 添加作者字段
data class Book(var name: String, var pages: Int, var author: String) {
    
    @PrimaryKey(autoGenerate = true)
    var id: Long = 0
}

修改AppDatabase中代码:如下所示:

@Database(version = 3, entities = [User::class, Book::class])
abstract class AppDatabase : RoomDatabase() {
    ...
    companion object {
        ...
        val MIGRATION_2_3 = object : Migration(2, 3) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL("alter table Book add column" + 
                    "author text not null default 'unknown'")
            }
        }
        
        ...
        
        fun getDatabase(context: Context): AppDatabase {
            ...
            return Room.databaseBuilder(context.applicationContext,
                AppDatabase::class.java, "app_database")
                .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
                .build().apply {
                    instance = this
                }
        }
    }
}

六、WorkManager

从4.4系统开始AlarmManager的触发事件由精准变为不精准,5.0系统中加入了JobScheduler来处理后台任务,6.0系统中引入了Doze和App Standby模式用于降低手机被后台唤醒的频率,从8.0系统开始直接禁用了Service的后台功能,只允许使用前台Service。当然还有许许多多小细节的修改。

这么频繁的功能和API的变更,让开发者就很难受了,为了解决这个问题,Google推出了WorkManager组件。

WorkManager很适合用于处理一些要求定时执行的任务,它可以根据操作系统的版本自动选择底层是使用AlarmManager实现还是JobScheduler实现,从而降低了我们的使用成本。另外它还支持周期性任务、链式任务处理等功能,是一个非常强大的工具。

WorkManager和Service并不相同,也没有直接的联系,Service是Android系统的四大组件之一,它在没有被销毁的情况下是一直保持在后台运行的。而WorkManager只是一个处理定时任务的工具,它可以保证即使在应用程序退出甚至手机重启的情况下,之前注册的任务仍然将会得到执行,因此WorkManager很适合用于执行一些定期和服务器进行交互的任务,比如周期性地同步数据等等。

另外,使用WorkManager注册的周期性任务不能保证一定会得到执行,这并不是BUG,而是系统为了减少电量消耗,可能会将触发时间临近的几个任务放在一起执行,这样可以大幅地减少CPU被唤醒的次数,从而有效延长电池的使用时间。

1.WorkManager的基本用法

使用前需要先在app/build.gradle文件中添加如下的依赖:

dependencies {
    implementation "androidx.work:work-runtime:2.2.0"
}

WorkManager的基本用法其实非常简单,主要分为以下3步:

  1. 定义一个后台任务,并实现具体的任务逻辑。
  2. 配置该后台任务的运行条件和约束信息,并构建后台任务请求。
  3. 将该后台任务请求传入WorkManager的enqueue()方法中,系统会在合适的时间运行。

首先,定义一个后台任务,这里创建一个SimpleWorker类,代码如下所示:

class SimpleWorker(context: Context, params: WorkerParameters) :
    Worker(context, params) {

    override fun doWork(): Result {
        Log.d("SimpleWorker", "do work in SimpleWorker")
        return Result.success()
    }
}

后台任务的写法非常固定,每一个后台任务都必须继承自Worker类,并调用它唯一的构造函数。然后重写父类中的doWork()方法,在这个方法中编写具体的后台任务逻辑即可。
doWork()方法不会运行在主线程中,因此可以在这里执行耗时逻辑。另外,doWork()方法返回一个
Result
对象,用于表示任务的运行结果,成功返回Result.success(),失败返回Result.failure()。还有一个**Result.retry()**方法,它也代表着失败,只是可以结合WorkRequest.Builder的setBackoffCriteria()方法来重新执行任务。

两种构建方法:

// 构建单次运行的后台任务请求
val request = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
    .build()
  
// 构建周期性运行的后台任务请求
// 为了降低设备性能消耗,运行周期不能短于15分钟
val request = PeriodicWorkRequest.Builder(SimpleWorker::class.java,
    15, TimeUnit.MINUTES).build()

// 将构建出的任务请求传入WorkManager的enqueue()中
// 系统会在合适的时间去运行
WorkManager.getInstance(context).enqueue(request)

在MainActivity中构建:

class MainActivity : AppCompatActivity() {
    ...
    override fun onCreate(...) {
        ...
        doWorkBtn.setOnClickListener {
            val request = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
                .build()
            // 由于没有添加任何约束,基本上会立即运行
            WorkManager.getInstance(this).enqueue(request)
        }
    }
}

2.WorkManager处理复杂的任务

1.让后台任务在指定延迟后运行

只需要借助setInitialDelay()方法就可以了,代码如下:

var request = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
    // 在5分钟后运行
    .setInitialDelay(5, TimeUnit.MINUTES)
    .build()

2.取消后台任务

var request = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
    ...
    // 添加标签可以用于取消任务
    .addTag("simple")
    .build()
    
// 通过标签取消后台任务,可以取消所有含有simple标签的任务
WorkManager.getInstance(this).cancelAllWorkByTag("simple")

// 通过id取消后台任务
WorkManager.getInstance(this).cancelWorkById(request.id)

3.任务失败后重新执行任务

之前如果后台任务doWork()方法中返回了Result.retry(),那么是可以结合**setBackoffCriteria()**方法来重新执行任务的,具体代码如下所示:

val request = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
    ...
    // 第二第三参数用于指定多久后重新执行任务,不能少于10秒
    // 第一个参数指如果任务再次执行失败,下次重试的时间应该以什么形式延迟
    // 分别有LINEAR和EXPONENTIAL两种代表线性和指数增加
    .setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS)
    .build()

4.通过返回的Result对运行结果监听

下面演示通过id对运行结果进行监听,也可以调用**getWorkInfoByTagLiveData()**方法监听同一签名下所有后台任务请求的运行结果,用法是差不多的:

WorkManager.getInstance(this)
    .getWorkInfoByIdLiveData(request.id).observe(this) { workInfo ->
        if (workInfo.state == WorkInfo.State.SUCCEEDED) {
            Log.d("MainActivity", "do work succeeded")
        } else if (workInfo.state == WorkInfo.State.FAILED) {
            Log.d("MainActivity", "do work failed")
        }
    }

5.链式任务

假设这里定义了3个独立的后台任务:同步数据、压缩数据和上传数据。现在我们想要实现先同步、再压缩、最后上传的功能,就可以借助链式任务来实现,代码如下:

val sync = ...
val compress = ...
val upload = ...
WorkManager.getInstance(this)
    // 用于开启一个链式任务
    .beginWith(sync)
    // 上一个任务执行成功后才会执行下一个任务
    .then(compress)
    .then(upload)
    .enqueue()