横竖屏切换引发问题的优雅处理
1. 引言
页面在横竖屏切换时,如何更好地控制视图和控制数据?本文目标是针对横竖屏切换的开发痛点问题来一波优雅的处理方案。
其中涉及到的主要知识点:ViewBinding、Kotlin空安全、LiveData、LifeCycle、Kotlin协程。
2. 问题描述
有的放矢,问题如下:
1)横竖屏页面中视图元素访问的空安全问题——横竖屏页面元素不一致时,竖屏状态下访问仅横屏状态存在的视图元素出现空指针问题;
2)横竖屏切换时异步更新数据对于页面的操作问题——竖屏时的异步数据请求返回时可能已经处于横屏状态,造成不必要的操作甚至问题;
3)异步时与横竖屏切换时的数据保存问题——多次横竖屏切换时多次请求可能导致数据反复刷新覆盖甚至旧值替换新值;
3. 问题解决
3.1 视图元素的空安全问题
注:此部分仅讨论命令式UI(即Jetpack Compose之前的视图开发设计,以xml为例),不讨论声明式UI(Jetpack Compose)。
3.1.1 问题场景
假定某个Activity中的横竖屏同名xml中含以下元素(以xml中的id指代):
-
common_tv(横竖屏均有)
-
portrait_tv(竖屏有,横屏没有)
-
landscape_tv(横屏有,竖屏没有)
如果直接通过findViewById
获得上面视图元素,那么有以下情况:
common_tv,横竖屏中均不为null;
portrait_tv,竖屏不为null,横屏为null;
landscape_tv,横屏不为null,竖屏为null;
在Java当中,空指针是运行时异常,开发时并不一定时刻能注意到横竖屏访问视图元素的空安全问题。
Kotlin虽然号称空安全,但是很可惜,findViewById
在Kotlin中也不是绝对的空安全;
比如下面代码,基于上面场景,在横屏状态下仍会触发空指针,但竖屏情况下不会:
val tv = findViewById<TextView>(R.id.portrait_tv)
tv.text = ""
这里可能会想到,将所有视图变量都显式声明成可空,那么Kotlin不就强制判空避免空指针了吗?
是的,但是这样个人认为非常不可靠而且不优雅!
不可靠,是因为每个视图变量都显式声明成可空,在实际开发中数量一多非常麻烦,而且增加工作量,执行度不会高。
不优雅,比如common_tv这个,就不可能为null,产生了不必要的判空。
3.1.2 ViewBinding的空安全
ViewBinding是目前谷歌推荐的方案。
对于上述的视图元素场景,生成的ViewBinding类(Java)代码中的视图元素如下:
@NonNull
public final TextView commonTv;
@Nullable
public final TextView landscapeTv;
@Nullable
public final TextView portraitTv;
也就是说,在具体的Binding类生成后,其实已经根据xml的实际情况,注解了相应的id元素是否会为空。
不过,即使加上@Nullable
注解的变量,Java中不判空也一样可以编译运行,更多的是起到一个提示开发者注意判空的作用,不判空使用归入于警告(warning)级别。
但是Kotlin中,对于ViewBinding对象中注解为可空的属性,则会强制判空,不判空使用归入于错误(error)级别。
也就是说,在Java代码中,使用ViewBinding里的视图属性,运行时仍然有空指针风险;而Kotlin代码中,相对更可靠。
而且,注意commonTv
属性的注解是@NonNull
,这样在Kotlin中是不需要判空的(多余的判空Kotlin代码中反而会有warning,非必要调用提示),这样,其实就兼具了安全性以及优雅(可空的强制判空保证安全,非可空的不判空保证优雅)。
viewBinding.commonTv.text = ""
/* 不判空直接调用的方式会提示错误,如viewBinding.portraitTv.text = "" */
viewBinding.portraitTv?.text = ""
一些关于ViewBinding的额外说明:
-
在ViewBinding具体Java代码中,对于可空的元素,还有自动生成注释说明该元素在哪部分存在和在哪部分缺失。
-
ViewBinding里的注解
@NonNull
和@Nullable
是androidx.annotation
包下的,部分源码中会有android.annotation
包(注意少个x)下的可空注解,注意后者在Kotlin中没有不判空则报错的效果。
关于xml中的视图元素访问,ButterKnife(视图元素的注解实例化)和kotlin-android-extensions(KAE,即Kotlin中通过视图id直接访问元素)都宣告废弃(Deprecated)了,这两者的替代方案都指向了ViewBinding。
3.2 异步更新数据对于页面的操作问题
这里仅讨论ViewModel+LiveData组合拳在横竖屏切换中的使用场景痛点。
当前的痛点:某个异步请求在竖屏下触发,回调更新竖屏时UI时可能已经切换到横屏,造成不必要的视图元素访问甚至交互上的重复操作;
对于不必要的视图访问,如3.1中讨论可用ViewBinding+Kotlin空安全来适配,避免出错;
对于交互上的重复操作,如加载状态的操作调用,如默认非加载操作,异步请求后处于加载状态,但切换横竖屏状态过程中已经使得视图处于非加载状态,所以此时要对取消加载状态的代码会有重复调用;
处理方案有两种
1)允许更新UI回调,通过判空或者在UI更新操作以及重复性调用代码兼容;
2)切断更新UI回调,即根据当前横竖屏方向来切断UI更新操作。
个人认为,第二种方案会更优雅。
下面就Activity销毁重建和Activity不销毁重建两种情况来讨论。
3.2.1 Activity销毁重建
LiveData本身设计会跟随LifeCycleOwner
的销毁而移除相应的观察者,而Activity是LifeCycleOwner
的实现类,所以Activity销毁(即使后面重建)并不用考虑对旧Activity对象的观察者移除,只需要关心对新Activity对象的观察者新增条件。
所以,此时解决方案代码示例如下(假定某种更新UI操作只需在竖屏中触发):
if (isPortrait) {
viewModel.portraitStrLiveData.observe(this) {
/* 更新UI操作代码省略 */
}
}
这样增加一个条件,即可解决portraitStrLiveData
数据在更新后Activity处于横屏时仍会触发观察者回调的问题。
3.2.2 Activity不销毁重建
这种情况要比3.2.1中复杂许多,Activity不销毁重建(不重走生命周期),所以LifeCycle
也不会感知到Activity发生了横竖屏变化。
注意:这里的LifeCycle并不是指Activity/Fragment中的生命周期,而是Jetpack中的生命周期处理组件LifeCycle
(这是一个类名):
截图来源于安卓官方网站(输入lifecycle关键字):
https://developer.android.google.cn/jetpack
虽然Activity中的生命周期不发生变化,但是横竖屏发生的时候会触发onConfigurationChanged
,所以,只要产生一个对象LifeCycleOwner
对象,使其在onConfigurationChanged
发生时销毁,其他时候的生命周期和Activity匹配即可。
注:想法和实践代码主要参考于Fragment
源码中的FragmentViewLifecycleOwner
的设计和作用。
所以,定义一个类CommonLifecycleOwner
:
/**
* 该类的实现简单参照了Fragment源码中的FragmentViewLifecycleOwner设计和功能使用
*/
class CommonLifecycleOwner : LifecycleOwner {
private var _lifecycleRegistry: LifecycleRegistry? = null
private val lifecycleRegistry: LifecycleRegistry
get() = _lifecycleRegistry ?: LifecycleRegistry(this).also {
_lifecycleRegistry = it
}
override fun getLifecycle(): Lifecycle {
return lifecycleRegistry
}
fun handleLifecycleEvent(event: Lifecycle.Event) {
lifecycleRegistry.handleLifecycleEvent(event)
}
fun setCurrentState(state: Lifecycle.State) {
lifecycleRegistry.currentState = state
}
}
在Activity中增加属性:
/**
* 初始化化为null,设计上需要使用configLifecycleOwner时才实例化赋值,
* 在横竖屏切换时若实例已经存在,则使其声明周期走到DESTROYED中并将幕后属性重置null,
*/
private var _configLifecycleOwner: CommonLifecycleOwner? = null
protected val configLifecycleOwner: LifecycleOwner
get() = _configLifecycleOwner ?: CommonLifecycleOwner().also {
debugLog("new CommonViewLifecycleOwner@${it.identifyStr}")
_configLifecycleOwner = it
/* 初始化时将新的configLifecycleOwner与当前页面的LifeCycle状态同步起来 */
it.setCurrentState(lifecycle.currentState)
it.lifecycle.addObserver(LifecycleEventObserver { owner, event ->
debugLog("$event from ${owner.identifyStr}")
})
}
_configLifecycleOwner
与Activity之间的生命周期同步:
init {
lifecycle.addObserver(LifecycleEventObserver { owner, event ->
debugLog("$event from ${owner.identifyStr}")
/* 将当前的_configLifecycleOwner(若已存在)与Activity的生命周期同步 */
_configLifecycleOwner?.handleLifecycleEvent(event)
})
}
横竖屏切换时对_configLifecycleOwner
生命周期控制处理:
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
/* 如果上一个View的LifeCycleOwner存在,则将其置入ON_DESTROY的生命周期并重新置空 */
_configLifecycleOwner?.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
_configLifecycleOwner = null
}
理想状态下,上述代码应该封装在Activity的基类当中而不应在具体业务的Activity子类中,基类仅向子类暴露
configLifecycleOwner
,当且仅当子类使用configLifecycleOwner
才会创建CommonLifecycleOwner
类型对象。
最后,在业务上的Activity中对LiveData的观察方法使用方式更改为如下:
if (isPortrait) {
viewModel.portraitStrLiveData.observe(configLifecycleOwner) {
/* 更新UI操作代码省略 */
}
}
注:在每次onConfigurationChanged
中也需增加上述代码,否则在下一次切换竖屏时不会获得回调。
这样,这样就使得在设计观察者的时候,和3.2.1中Activity销毁重建的方式几乎一样,仅换了一个参数就可以达到LiveData的观察回调仅在竖屏中发生的效果。
另外,在其他情况下(如横竖屏销毁重建,页面不支持横竖屏切换等),configLifecycleOwner
的对象生命周期乎与Activity本身作为LifeCycleOwner
的基本一致。
注:严谨表述上其实并非一致,因为configLifecycleOwner
只有在使用的时候才会创建对应的对象,只有创建以后的生命周期才一致,但这个细节一般并不影响实际功能。
3.2.3 小结
使用ViewModel+LiveData的前提下,横竖屏切换场景,异步更新数据对于页面的操作问题,总结如下:
- 对于Activity销毁重建,只需要设置LiveData观察之前,增加对当前屏幕方向的判断;
- 对于Activity不销毁重建,需要封装符合场景的LifeCycleOwner类并控制其生命周期,再判断屏幕方向和设置LiveData监听;
这里有个疑问,既然安卓源码里的Fragment中封装了getViewLifecycleOwner()
,为什么不在Activity里封装一个getConfigLifeCycleOwner()
呢?
个人猜测可能有如下情况:
- 可能有类似功能API设计,但是没有被发现(一开始其实想在源码里找,没找到才进行封装);
-
ViewModel
有横竖屏切换不销毁的特性,理想上使用的数据应该都放入ViewMoel
中,减低了Activity在横竖屏切换时数据保存的麻烦,所以可能更建议销毁重建的横竖屏切换方式; - 使用
LifeCycle
可轻松定制开发者需要的特定生命周期控制,所以没必要每种情况都进行封装。正如3.2.2中使用方式其实只是对lifecycle相关组件已有设计的使用,如对LifeCycle组件有所了解,其实会发现封装部分的逻辑其实非常简单。
3.2.4 对数据流的适配
3.2.2中的configLifecycleOwner
封装方式,对于LiveData将来的替代品(官方推荐从LiveData迁移到Kotlin Flow),也同样适用。
以谷歌文档的样例代码为例:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
latestNewsViewModel.uiState.collect { uiState ->
when (uiState) {
is LatestNewsUiState.Success -> showFavoriteNews(uiState.news)
is LatestNewsUiState.Error -> showError(uiState.exception)
}
}
}
}
上述样例代码来源于:https://developer.android.google.cn/kotlin/flow/stateflow-and-sharedflow?hl=zh_cn
适配横竖屏的异步流监听,那么只需要其所用的LifeCycleOwner
从Activity引用换成configLifecycleOwner
即可,如下:
configLifecycleOwner.lifecycleScope.launch {
configLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
latestNewsViewModel.uiState.collect { uiState ->
when (uiState) {
is LatestNewsUiState.Success -> showFavoriteNews(uiState.news)
is LatestNewsUiState.Error -> showError(uiState.exception)
}
}
}
}
所以3.2.2中的封装不仅适用于LiveData,也适用于Kotlin的数据流。
注:repeatOnLifecycle
在依赖库androidx.lifecycle:lifecycle-runtime-ktx
的2.4.0版本才新增,依赖库版本升级的风险先不谈,但是2.4.0版本会要求compileSdkVersion
最低为31(Android12),否则运行项目时会有以下错误:
The minCompileSdk (31) specified in a
dependency's AAR metadata (META-INF/com/android/build/gradle/aar-metadata.properties)
is greater than this module's compileSdkVersion (android-30).
Dependency: androidx.lifecycle:lifecycle-runtime:2.4.0.
3.3 异步时与横竖屏切换时的数据保存问题
3.3.1 问题引出
如果解决了3.1和3.2中的问题,应该满足大多场景了,但细想会发现,上面只是处理了视图方面的问题,并没有处理数据保存方面的问题。
场景:在第一次竖屏时发起请求数据(记为请求A),切换横屏再切换回竖屏,这时候对同一接口发出第二次请求(记为请求B),这时候请求A和请求B两者的结果都返回,会将结果写入到同一个LiveData对象当中。
问题:请求A和请求B的返回结果顺序无法确定先后,存在旧值替代新值的风险;两次LiveData的写入触发两次视图更新;这里以两次为例,还可能有更多多次的值替代和视图更新风险。
3.3.2 问题分析
一般来说,请求和页面展示,可以细分为以下步骤:
1)请求发起;
2)请求结果返回或回调;
3)请求结果保存;
4)将请求结果更新到UI上;
此前的3.1和3.2小节的关注点其实都在第4点上,而第1第2点一般情况下也是网络层的封装统一,如需处理上述问题,自然落在请求结果保存步骤上,更具体一点,落在了LiveData的setValue
方法调用前截断逻辑。
本文仅讨论协程中的处理。
3.3.3 问题代码
某个ViewModel中,假定有以下代码:
fun asyncPortraitStr() {
viewModelScope.launch {
delay(5000L)
withContext(Dispatchers.Main.immediate) {
portraitStrLiveData.value = "Portrait String"
}
}
}
由于横竖屏切换时,ViewModel对象总不会销毁,所以通过viewModelScope
产生的协程不会被取消,因此如果每次竖屏调用一次该方法,且5秒内多次横竖屏,最后一次竖屏时,那么将会触发多次LiveData的回调。
如果在横竖屏切换时将已启动的协程进行取消,那么将不会有多次回调以及数据保存问题。
温馨提醒,协程的取消是需要协作的,并不是协程取消就必然能立即停止协程!
既然viewModelScope
在横竖屏切换时没有取消协程,那么就将其协程启动后的Job
实例返回并在横竖屏切换时调用取消,不就可以解决问题?
是的,可以解决,但是这种方式并不优雅:
一来这样页面有多少个请求就得收集多少个,容易遗漏,拓展和维护都麻烦;
二来需要在页面中保存Job对象,这样页面中持有了Job对象引用,这样即使协程执行完成,Job对象也无法及时被回收,这时候弱引用是个更好的建议,但进一步显麻烦。
3.3.4 优雅解决
优雅的处理方式,应该是通过协程作用域和利用协程的结构化并发。
比如将上述asyncPortraitStr
增加协程作用域参数:
fun asyncPortraitStr(scope: CoroutineScope) {
scope.launch {
delay(5000L)
withContext(Dispatchers.Main.immediate) {
portraitStrLiveData.value = "Portrait String"
}
}
}
Activity中调用如下:
if (isPortrait) {
asyncPortraitStr(lifecycleScope)
}
一句话说完,就是增加协程作用域参数,并传入lifeCycleScope
。
如果横竖屏切换Activity会销毁重建,那么asyncPortraitStr
方法中启动的协程也会跟随Activity的销毁而被取消。
如果横竖屏切换Activity不销毁重建,那么lifeCycleScope
也不会取消协程,这时候承接3.2.2中对于configLifecycleOwner
的封装逻辑,将lifeCycleScope
换成configLifecycleOwner.lifeCycle
也就可以解决问题,如下:
if (isPortrait) {
asyncPortraitStr(configLifecycleOwner.lifecycleScope)
}
这里总体上对协程的协程作用域和取消、viewModelScope
、lifeCycleScope
、LifeCycle
、LifeCycleOwner
等各部分的综合利用,当前场景还有什么比传入一个参数便能解决问题的更优雅的方式吗?
3.3.5 同理的定时任务问题与解决
这里,联想到还有一种场景,定时请求,比如每隔一个固定时间进行一次刷新请求(概念上,定时任务逻辑应该也可以算是异步中的一种场景)。
定时任务的方案很多,比如Handler
、Timer
、ScheduledExecutorService
等等。
这里用协程给出一个定时任务的参考方案,代码如下:
private fun loopAsyncInConfig() {
configLifecycleOwner.lifecycleScope.launch(block = suspendBlock)
}
private val suspendBlock: suspend CoroutineScope.() -> Unit = {
debugLog("loopAsyncInConfig coroutine runs")
...
delay(3000L)
loopAsyncInConfig()
}
通过这种方式启动的定时操作,可以在启动协程的所用的协程作用域被取消时而自动停止,也就是说,这部分逻辑,并不需要去相应的声明周期里作额外的操作,不用再担心定时任务没有及时调用停止方法而造成的问题。
这里用了前面内容封装的configLifecycleOwner
的lifeCycleScope
来启动定时任务,这样每次定时任务都会在横竖屏后而停止。
通常,用Activity或Fragment的lifeCycleScope
和viewModelScope
作为定时任务的启动作用域也较为通用。
4. 样例工程说明
实践是检验真理的唯一标准,上述的所有设计和效果验证的工程Demo,见:
https://github.com/TeaCChen/ActivityRotateDemo
注:工程中部分代码带有个人写法封装习惯,与文中的实例代码并不完全一致,本文为了更直接的阐述代码关键,省略了Demo中的一些代码封装细节。
5. 方案总结
1)用ViewBinding+Kotlin空安全机制,解决对于视图元素访问的空安全问题;
2)用自定义LifeCycleOwner的方式,解决异步数据更新后对于页面的操作问题;
3)用协程中的设计(协程作用域和协程取消),解决异步时的数据保存问题;
ViewBinding、Kotlin空安全、LifeCycle与LifeCycleOwner、协程这些都是开发中较为常见的内容。
其中仅有横竖屏切换中需要自定义封装LifeCycleOwner和使用逻辑,其他部分仅需在日常开发中注意对相应参数的使用即可。
即使是对于LifeCycleOwner的自定义和使用,得益于Jetpack库中lifecycle组件的设计,理解、使用和封装都较为简单,完整代码见工程Demo的BaseConfigExtraActivity
和CommonLifecycleOwner
。
如对Jetpack中的lifecycle组件和Activity本身的生命周期疑问,工程Demo中亦有相应的日志输出内容可供查看。
6. 一些看法
在Jetpack之前,Android开发经常会有各种八仙过海各显神通的设计和开发方案,但Jetpack以后,往官方组件靠拢处理或许是个更好的选择。
本文实例代码用到的ViewModel
、LiveData
、LifeCycle
、lifeCycleScope
、viewModelScope
这些内容,仅仅是Jetpack中的lifecyle组件的一部分,更多内容:https://developer.android.google.cn/jetpack/androidx/releases/lifecycle
理解活用已有资源,本身即优雅。