文章

Compose 纪元下的 Android 生命周期感知

在基于 View 的 Android 开发中大部分同学都知道要处理 onStop() 等生命周期,奇怪的是在 Compose 中不仅很多人忽略了这个问题,甚至连官方都后知后觉。再加上 LiveData, StateFlow, mutableStateOf 等数据承载类层出不穷,官方文档大都只演示某一个组件相关的用法,忽略生命周期问题,美其名曰「简单起见」,误导了很多小伙伴(也包括我)。

这篇就是总结一下不同数据类、不同 UI 框架下生命周期的处理问题。

生命周期

传统生命周期

Android 生命周期是老生常谈的话题了。回想 7 年前梦开始的时候,Android 的生命周期还高度依赖 Activity / Fragment 的回调。那时的代码丑陋不堪,一不注意就会漏写哪一部分。

后来 Android 官方推出了 lifecycle 库集中处理这些东西。个人认为 lifecycle 框架最大的贡献是让每个组件自己处理生命周期,而不像以前那样各自暴露 API 让开发者在各个生命周期的回调中调用,再也不用担心忘记了。

比如我们有一个监听位置的类,需要和生命周期绑定:

class LocationListener {
    fun start() { /*register system location service*/ }
    fun stop() { /* disconnect to system location service */}
}

那么没有 lifecycle 之前需要这么使用:

class MapActivity : ComponentActivity() {
  private lateinit var locationListener: LocationListener

  override fun onStart() {
    super.onStart()
    locationListener.start()
  }

  override fun onStop() {
    super.onStop()
    locationListener.stop()
  }
}

这一个感觉还好,如果多来几个就愈发像屎💩。

现在,可以让 LocationListener 自己感知生命周期:

class LocationListener(lifecycleOwner: LifecycleOwner) : DefaultLifecycleObserver {
  init {
    lifecycleOwner.lifecycle.addObserver(this)
  }

  override fun onStart(owner: LifecycleOwner) { /* register system location service */ }
  override fun onStop(owner: LifecycleOwner) { /* disconnect to system location service */ }
}

Activity 就解放了:

class MapActivity : AppCompatActivity() {
  private lateinit var locationListener: LocationListener

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    locationListener = LocationListener(this) // 啥都不用做
  }
}

这是一个反模式,仅用于演示。实际开发中应该由 Data Layer 统一提供数据。

我们似乎都一直在讨论如何响应 ActivityFragment 的生命周期而忽略 View,因为这两个包揽万象:它们既反应了页面切换,也反应了应用进入后台。直到有一天...Compose 出现了。

Compose 生命周期

在 View 体系里,其实 View 有也有自己的生命周期,比较典型的回调是 onAttachedToWindow()onDetachedFromWindow()。但大部分情况下我们不关心这个,因为数据一般只在页面 (screen) 级别维护,Activity / Fragment 恰好承载的也是一个页面,所以数据可以自然地忽略 View 直接与页面绑定。

Compose 的到来搅乱了这个局面。如果说 Fragment 是一层套娃可以实现单个 Activity 多个页面,那 Compose 就是二层套娃,能做到单个组件多个页面。纯 Compose 应用推荐使用单一 Activity,零 Fragment 的架构。显然,这时候某些 Compose 组件代表的就是一个页面,它生命周期至关重要。

但只关注 Compose 是不够的,它能反映组件的进入与销毁,但不能反映 App 切到后台的情况。比如用户切换到其他应用,此时我们的应用处于后台,没有被关闭,页面也没有退出,那么 Compose 还是存在的,但这时候不应该浪费资源去继续更新数据、更新 UI 了。

在 Compose 的文档里经常只关注自己的生命周期而忽略 Android 组件的。这是因为 Compose 是跨平台框架,它不会也不应该过多介绍某一具体平台的问题。但不代表这些问题可以忽略。

Google 应该反思这一点,Compose 目前来讲还是 Android 为主,这种及早的剥离文档,且在 Android 文档中又没有专门指出的问题很容易误导开发者。

至此我们应该达成共识:Compose 应用中应该处理两个生命周期:Compose 组件的与 Android 原生组件的。

具体来讲大概这么实现:

@Composable
fun MapScreen(lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current) {
  DisposableEffect(key1 = lifecycleOwner) {
    // 进入组件时执行,lifecycleOwner 改变后重新执行(先回调 onDispose)
    val observer = LifecycleEventObserver { owner, event ->
      if (event == Lifecycle.Event.ON_STOP){ /* 应用不可见了但组件还在 */ }
    }
    lifecycleOwner.lifecycle.addObserver(observer)
    onDispose {
      // 组件不在了但应用可能还位于前台
      // 对于当前组件 MapScreen 来说就代表页面切换
      lifecycleOwner.lifecycle.removeObserver(observer)
    }
  }
}

这里不用上面 LocationListener 的例子写一遍了,因为太恶心,也正好印证了这是这一种反模式。

在 Compose 架构的应用中,Compose 组件很多不写在 Activity 里面,所以要把 LifecycleOwner 作为参数传递。LocalLifecycleOwner 是 Compose 提供的一种隐式传参方案 CompositionLocal

可观测的数据

刚刚 Compose 生命周期中 LocationListener 的例子已经证明了不好好设计架构会给自己挖多少坑。除非你是大公司成员,有自己的成熟的架构,否则还是老老实实读一下官方的最佳实践吧。

Android 建议使用 ViewModel 为 UI 提供数据与状态。为了让界面及时响应数据变更,还应该把数据包装为可观测对象。为了避免应用处于后台时仍然刷新,对这些数据的监听应该感知生命周期。

对于可观测数据来说,感知生命周期有三个方面:

  1. UI 组件销毁后不再接收更新数据,否则在数据更新回调里设置 UI 可能抛出异常。
  2. 应用切到后台时不再接收更新数据,因为此时刷新 UI 没有意义,浪费资源。
  3. 界面不再接收更新数据时,数据源也不应该再产生新的数据以节约资源。

ViewModel

目前数据与状态保存在 ViewModel 中应该已经被大多数开发者接受了,这是一个 Android 官方在维护的库,可以很好地处理「页面切换」相关的生命周期问题,还会在屏幕旋转等事件导致 Activity 重建的情况下保留数据来避免多余的加载操作。

ViewModel 的使用也很简单,Activity / Fragment 中如下:

class MapActivity : AppCompatActivity() {
    private val model: MapViewModel by viewModels()
}

Compose 中如下:

@Composable
fun MapScreen(
  lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
  viewModel: MapViewModel = viewModel() // 获取 ViewModel
)

要注意的是 Compose 中没有页面 (screen) 的概念,所以记得一定要在我们自己定义的页面组件中去获取 ViewModel 才能把它绑定到正确的生命周期上。不要把获取到的 ViewModel 传递给其他 Compose 函数,只能传递它里面的数据。

如果 ViewModel 有构造参数会稍微麻烦一点,此时可以考虑使用依赖注入库,比如 koinHilt

LiveData

LiveData 是比较早的一批实现,对 Java 友好,可惜还没彻底推广开就被 Kotlin 的 Flow 给抢了饭碗。LiveData 利用了 lifecycle 提供的那一套 API 实现自动订阅/取消订阅,并在内部提供了回调用于告知上游数据源停止刷新(产生新数据)。用 LiveData 可以这样改写前面的 LocationListener:

class LocationLivaData : MutableLiveData<String>() {
    override fun onActive() { /* register system location service */ }
    override fun onInactive() { /* disconnect to system location service */ }
    private fun newLocation(loc:String){
        value = loc // or postValue(loc) in other thread
    }
}

class MapViewModel : ViewModel() {
    val locationLivaData: LiveData<String> = LocationLivaData()
}

Activity 中的用法如下:

class MapActivity : AppCompatActivity() {
  private val model: MapViewModel by viewModels()

  override fun onCreate(savedInstanceState: Bundle?) {
    model.locationLivaData.observe(this) { newLoc -> }
  }

无论是切到其他页面,或应用进入后台,Activity 都会停止接收数据更新,同时 LocationLivaData 也会得到通知,不再产生新的数据,满足了三个方面的要求。

Compose 中提供了扩展函数 LiveData.observeAsState()(需要 androidx.compose.runtime:runtime-livedata:$composeVersion 依赖)把 LiveData 一键转为 State

@Composable
fun MapScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    viewModel: MapViewModel = viewModel()
) {
    val loc by viewModel.locationLivaData.observeAsState()
}

这个函数的内部实现与我们刚才在「Compose 生命周期」中写的几乎一样:

  1. 默认使用 LocalLifecycleOwner 获取生命周期。
  2. 使用 DisposableEffect 注册 LiveData 监听,并在 onDispose 回调中取消监听。

DisposableEffect 本身可感知 Compose 组件的退出,Lifecycle 可以感知 Activity 的生命周期事件,因此也满足了三个方面的要求。

Flow

Flow 是 Kotlin 协程的概念,可表示一组数据,常用多返回值或持续更新场景。

作为演示,这里有一个生产无穷数据的 flow:

class MapViewModel : ViewModel() {
  val num = flow {
    var x = 0
    while (true) {
      println("new $x")
      emit(x++)
      kotlinx.coroutines.delay(1000)
    }
  }
}

从 Flow 中取得数据用的是挂起函数 collect(),因此它的取消自然地委托给协程。lifecycle 组件提供了lifecycle.repeatOnLifecycle{} 函数,它监听 lifecycle 并在指定状态下取消内部协程,在它管理的协程里读取 Flow 也就能实现生命周期感知。

注意一点,是取消然后需要时重启协程,不是挂起和恢复!

Activity 中如下:

class MapActivity : AppCompatActivity() {
  private val model: MapViewModel by viewModels()

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // 启动一个协程,因为 repeatOnLifecycle 本身也是挂起函数
    lifecycleScope.launch { 
      // 再启动一个协程 collect flow,会在 Start 时启动,Stop 时自动取消
      lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
        model.num.collect{ /* new value */}
      }
    }
  }
}

Compose 中,有个极具迷惑性的便捷函数 collectAsState(),需要提供一个默认值。因为 FlowLiveData 不同,它可能没有值(不是 null,而是没有任何值,根据需求可以把 null 作为默认值)。看着很靠谱都不对?但如果这么写就要掉进坑了,看一个例子:

@Composable
fun MapScreen(
  lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
  viewModel: MapViewModel = viewModel()
) {
  val num by viewModel.num.collectAsState(0)
  Text(text = num.toString())
}

这么写的话,回到主屏幕或切换到其他应用,后台会持续打印 new...若这是一个位置监听器,毫无疑问会过渡消耗电量。但如果 MapScreen 这个组件不显示了,打印就会停止。这说明 collectAsState() 只响应了 Compose 的生命周期而忽略了 Android 原生的。它的源码完全没有牵扯 LocalLifecycleOwner 也能够说明这一点。

为解决这个问题,同样需要 lifecycle.repeatOnLifecycle{} 函数。注意与 LaunchedEffect{} 区分开,后者只响应 Compose 生命周期,两个各司其职。实现如下:

@Composable
fun MapScreen(
  lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
  viewModel: MapViewModel = viewModel()
) {
  var num by remember { mutableStateOf(0) }
  val lifecycle = LocalLifecycleOwner.current
  LaunchedEffect(lifecycleOwner, viewModel.num) { // 启动一个协程
    // 再启动一个协程 collect flow,会在 Start 时启动,Stop 时自动取消
    lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
      viewModel.num.collect { num = it }
      // 注意这里不能写其他代码,collect 会挂起协程
    }
  }
  Text(text = num.toString())
}

处理 Activity Stop 时,带有生命周期感知的 collectAsState 与普通版本的区别如图:

没有便捷函数不能用是不是很头疼?Lifecycle 团队在 androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha03 中给 Compose 也添加了便捷函数:collectAsStateWithLifecycle() ,默认响应 Start/Stop 回调,也可以配置为其他状态。用起来超级方便:

@Composable
fun MapScreen(viewModel: MapViewModel = viewModel()) {
    val num by viewModel.num.collectAsStateWithLifecycle(initialValue = 0)
    Text(text = num.toString())
}

(果然 Compose 才是一等公民)

Compose 团队对其做出了解释。collectAsState() 是 Compose 全平台的 API,自然只考虑 Compose 自己的生命周期。collectAsStateWithLifecycle() 与 Android 甚至 Lifecycle 库绑定,所以由他们来提供支持。

Flow 还有许多变种,比如 StateFlow,顾名思义,就是有状态的 Flow,除了默认不支持 lifecycle 外和 LiveData 比较像,但因为是协程的组件所以更强大。Flow 适合数据源生产数据,StateFlow 适合用作 ViewMode 自己维护的状态。各种 Flow 在 UI 中的使用大同小异,基本都是那一套 collect API。

mutableStateOf

mutableStateOf 是 Compose 下面的函数,本来是用于 Compose 内部的状态保存的,但因为官方某些文档的写法,有人也学者把它用到了 ViewModel 里,这样还能少一层转换。

乍一看似乎没什么问题,但它完全丧失了感知 Android 生命周期的能力。开发者需要手动维护额外的状态来标记当前 Activity 所处的阶段,在手动进行阶段检查/回调,基于最新的阶段来判断要不要更新数据。从工程角度几乎是不可实现的。

除此之外,MutableState 虽然单线程并发安全,但不是多线程安全的。如果在其他线程更新它需要包一层 MutableStateFlow.update{},而 MutableStateFlow 直接用 update{} 函数就行了。

最后,ViewModel 应该与 UI 框架解耦,而 State 是属于 Compose 框架的,甚至难以在 Activity 或其他组件中直接使用。

因此,不推荐在 ViewModel 中直接使用 Compose 的 State。

参考