横竖屏切换引发问题的优雅处理

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@Nullableandroidx.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(这是一个类名):

image-20211121123400159.png

截图来源于安卓官方网站(输入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)
}

这里总体上对协程的协程作用域和取消、viewModelScopelifeCycleScopeLifeCycleLifeCycleOwner等各部分的综合利用,当前场景还有什么比传入一个参数便能解决问题的更优雅的方式吗?

3.3.5 同理的定时任务问题与解决

这里,联想到还有一种场景,定时请求,比如每隔一个固定时间进行一次刷新请求(概念上,定时任务逻辑应该也可以算是异步中的一种场景)。

定时任务的方案很多,比如HandlerTimerScheduledExecutorService等等。

这里用协程给出一个定时任务的参考方案,代码如下:

private fun loopAsyncInConfig() {
    configLifecycleOwner.lifecycleScope.launch(block = suspendBlock)
}

private val suspendBlock: suspend CoroutineScope.() -> Unit = {
    debugLog("loopAsyncInConfig coroutine runs")
    ...
    delay(3000L)
    loopAsyncInConfig()
}

通过这种方式启动的定时操作,可以在启动协程的所用的协程作用域被取消时而自动停止,也就是说,这部分逻辑,并不需要去相应的声明周期里作额外的操作,不用再担心定时任务没有及时调用停止方法而造成的问题。

这里用了前面内容封装的configLifecycleOwnerlifeCycleScope来启动定时任务,这样每次定时任务都会在横竖屏后而停止。

通常,用Activity或Fragment的lifeCycleScopeviewModelScope作为定时任务的启动作用域也较为通用。

4. 样例工程说明

实践是检验真理的唯一标准,上述的所有设计和效果验证的工程Demo,见:

https://github.com/TeaCChen/ActivityRotateDemo

注:工程中部分代码带有个人写法封装习惯,与文中的实例代码并不完全一致,本文为了更直接的阐述代码关键,省略了Demo中的一些代码封装细节。

5. 方案总结

1)用ViewBinding+Kotlin空安全机制,解决对于视图元素访问的空安全问题;

2)用自定义LifeCycleOwner的方式,解决异步数据更新后对于页面的操作问题;

3)用协程中的设计(协程作用域和协程取消),解决异步时的数据保存问题;

ViewBinding、Kotlin空安全、LifeCycle与LifeCycleOwner、协程这些都是开发中较为常见的内容。

其中仅有横竖屏切换中需要自定义封装LifeCycleOwner和使用逻辑,其他部分仅需在日常开发中注意对相应参数的使用即可。

即使是对于LifeCycleOwner的自定义和使用,得益于Jetpack库中lifecycle组件的设计,理解、使用和封装都较为简单,完整代码见工程Demo的BaseConfigExtraActivityCommonLifecycleOwner

如对Jetpack中的lifecycle组件和Activity本身的生命周期疑问,工程Demo中亦有相应的日志输出内容可供查看。

6. 一些看法

在Jetpack之前,Android开发经常会有各种八仙过海各显神通的设计和开发方案,但Jetpack以后,往官方组件靠拢处理或许是个更好的选择。

本文实例代码用到的ViewModelLiveDataLifeCyclelifeCycleScopeviewModelScope这些内容,仅仅是Jetpack中的lifecyle组件的一部分,更多内容:https://developer.android.google.cn/jetpack/androidx/releases/lifecycle

理解活用已有资源,本身即优雅。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,723评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,003评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,512评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,825评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,874评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,841评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,812评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,582评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,033评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,309评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,450评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,158评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,789评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,409评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,609评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,440评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,357评论 2 352

推荐阅读更多精彩内容