本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布
这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战
Hi , :)
世界很大,也很小,组件很多,也很少。
关于开发中常见的状态页组件,我们已经见了很多,但是在 JetPack Compose
中该如何去写呢?虽然也有大佬写了相关demo ,但是如果要应用到实际中,不免有些捉襟见肘 。
本篇要解决的就是如何定制一个符合 实际开发 的状态页工具,并分析具体原理与设计思路。
本篇中的实现思路不是很好,可以在读完本篇后,转到下一篇,换一种角度重新设计。
效果图
这个效果图很简单,就是普通的一个状态页,所以也没什么值得说的,我们接下来分析一下,如果要实现一个状态页组件,需要有哪些基础功能。
需求分析
- 支持
compose
与view
- 分层设计,按需引入
- 支持全局/局部配置默认缺省页
- 支持全局重试与防抖处理
- ...
看完基本条件,其实也都不难,在 View
中设计一个状态页组件,大家都知道怎么做,但是 Compose
呢? 那么我们下面就开始构思一下,如何设计这个状态页组件 StateX。
基本思路
其实只要写过 compose
的代码,应该都明白,其实更简单了。因为 compose
是声明式的编程思想,即我们可以理解为数据驱动,所以最简单的做法:
定义一个变量,然后每次更改这个变量,变量改变之后,相应的使用这个变量的地方就会触发重组,于是我们可以随手写出下面的伪代码:
val state = mutableStateOf (Loading)
when(state){
Loading -> {}
Error -> {}
Content -> {
//加载错误了, 更改状态即可
state = Error
}
xxx
}
没错,在 compose
中实现就是这么简单,原理也很好理解。
不足之处
但如果你真的这样去写了,你可能已经进入一个圈套?试想一下,这个真的符合我们实际业务场景吗?
我们先还原一个真实的业务场景。
这是一个展示用户点赞排行榜的列表页,按照我们常规的思路,我们会怎么写:
- 先展示loading
- 请求数据
- 请求成功-设置数据,错误-显示缺省页
这个思路没有问题,在传统 view
中我们一般都是这样实现,但是 compose
中呢,我们按照上面的思路写一个伪代码。
@Composable
fun Test() {
var state = remember {
mutableStateOf(StateEnum.LOADING)
}
when (state.value) {
StateEnum.LOADING -> {
}
StateEnum.CONTENT -> {
// 展示成功
}
StateEnum.ERROR -> {
// 展示错误
}
}
// 获取结果
val data = getData()
if (data is Success) {
state.value = StateEnum.CONTENT
} else if (data is Error) {
state.value = StateEnum.ERROR
}
}
这个流程对吗?如果真这样写,那么恭喜你,你已经陷入了老路子,代码也将死循环。
成也 重组 ,败也 重组 ,传统的
view
中,属于命令回调式,因为相应的方法只会在命令时执行,我们不必担心无关方法被调用。而在compose
中,重组会执行所有调用的地方,并判断是否需要执行,我们必须要考虑如何避免重复的重组。
所以如果上述改变 state
后,接下来还会继续执行 getData() ,那么该怎么做呢?
如何解决?
你可能会想,既然如此,那我直接在 CONTENT 中写请求逻辑不就行吗?
可以,但是问题来了,那 Loading 还怎么展示?
那我直接去 Loading 中触发请求逻辑?
可以做,但是怎么做呢?虽然我知道这样能做,但是具体该怎么封装好呢?
于是有没有一个简便的,封装好的组件供我参考或者拿来就用呢?
为了解决上述问题,我写了一个简单组件 StateX ,大家可以自行copy更改,下面开始分析一下设计思路。
解析 StateX
要设计一个可以供 compose
与 View
都可以使用的组件,不可避免的就需要两个model,分层去设计,并且支持按需引入,对于共有的模块,还需要单独提到基础组件里,于是 StateX
分为三个模块:
- basic 基础层,放了一些compose与view共用的基础配置
- compose 属于compose的单独model
- view 属于view层的单独model
感谢 @掘金-Range(业内俗称东哥)的 StateLayout,view部分的核心代码来自这里,原因足够简单易用。
基础层-Basic 设计
既然要支持 compose
与 View
,那么基础需要哪些功能呢?
enum class StateEnum {
LOADING,
EMPTY,
ERROR,
CONTENT
}
interface IState {
val state: StateEnum
var enableNullRetry: Boolean
var enableErrorRetry: Boolean
/** 显示加载成功
* @param [tag] 可以传递任意数据,会在回调处收到
* */
fun showContent(tag: Any? = null)
...
}
我们定义了一个基础接口,其代表了 compose
与 view
公用的接口, StateEnum
代表了对应的状态枚举。
但是 compose 与 view 的配置项怎么设置呢?
因为两者的配置肯定不同,那么有没有一种方式也能统一这两者的设置。
为了便于设置,我定义了一个 StateX
的静态类。
object StateX {
/** 默认点击防抖时间 */
var defaultClickTime = 600L
/** 空数据重试开关 */
var enableNullRetry = true
/** 异常重试开关 */
var enableErrorRetry = true
}
乍一看好像并没有什么,这个静态类只是对应了一些基本的共用配置项,和其他model的配置项似乎关联不大。但是 Kotlin
支持扩展函数与方法,这样,通过唯一的 StateX 入口,我们便可以在相应的 compose
与 view
的model中增加基于 StateX 的扩展函数,便于增加配置项。就是这么简单。
compose层设计
配置设计
配置层是一个简单的类,同时我们定义了一个 internal
修饰的静态 StateComposeConfig 对象,以便组件内部访问,同时定义了 StateX 的扩展函数 composeConfig ,从而完成对 compose-config
的初始化,是不是比较简单。
class StateComposeConfig {
...
internal var emptyComponent: stateComponentBlock = {}
...
internal var onContent: stateBlock? = null
...
fun onContent(block: stateBlock) {
this.onContent = block
}
...
fun emptyComponent(component: stateComponentBlock) {
this.emptyComponent = component
}
}
/** 内部使用的StateCompose配置 */
internal val composeConfig by lazy(LazyThreadSafetyMode.PUBLICATION) {
StateComposeConfig()
}
/** 配置state-compose的配置 */
fun StateX.composeConfig(config: StateComposeConfig.() -> Unit) {
composeConfig.apply(config)
}
接口设计
相应的接口这里,我们需要 compose
也能感知到加载 失败
,错误
,成功
,loading
,同时附带了当前状态所对应的 value
。
interface IStateCompose : IState {
/** 当前state附带的value */
val tag: Any?
/** 错误时的回调 */
fun onError(block: stateBlock)
...
}
具体实现类
具体的实现类 StateComposeImpl 也是非常简单简洁,我们在内部保留了一个 _internalState
变量,其代表当前状态,并且使用 State
包装,这样当我们调用 showXxx() 方法显示具体状态时,我们内部就会对相应的状态以及附带的 value
进行更新,从而 _internalState
就会更新,然后触发调用处的重组。
之所以要保留一个 tag
,是因为在实际中,我们一般在显示错误页面时,相应的文案都是根据具体错误更新,而非一成不变,所以需要缓存一个当前状态所对应的 tag
,这样便于我们在重组时使用。
class StateComposeImpl constructor(stateEnum: StateEnum = StateEnum.CONTENT) : IStateCompose {
// 这里是一个类型别名,只是为了省去方法参数中多余的写法,
// 坏处就是可能会降低可读性,具体根据自身而定
// internal typealias stateBlock = (tag: Any?) -> Unit
// 刷新时的回调,可以在这里回调里做数据加载,加载完成后调用showContent即可。
private var onRefresh: stateBlock? = null
// 异常回调,默认使用的全局错误回调
private var onError: stateBlock? = composeConfig.onError
...
/** 当前内部可变状态 */
private var _internalState by mutableStateOf(StateEnum.CONTENT)
/** 当前状态内部缓存的tag */
private var _internalTag: Any?
override val state: StateEnum
get() = _internalState
override val tag: Any?
get() = _internalTag
override fun onError(block: stateBlock) {
this.onError = block
}
...
override fun showError(tag: Any?) {
onError?.invoke(tag)
newState(StateEnum.ERROR, tag)
}
...
private fun newState(newState: StateEnum, tag: Any?) {
_internalState = newState
_internalTag = tag
}
}
StateCompose
StateCompose 就是我们对外提供的一个具体 Compose
组件,外部只需要传入相应的控制器,同时也可以重写相应的状态对应的 component
,默认使用的是全局定义的。另外,我们在 Error
回调里对错误进行了防抖处理,并且在重试时会调用 showLoading() 方法,从而触发 onRefresh
的回调 刷新。
@Composable
fun StateCompose(
stateControl: IStateCompose,
loadingComponentBlock: stateComponentBlock
= composeConfig.loadingComponent,
...
contentComponentBlock: stateComponentBlock,
) {
when (stateControl.state) {
StateEnum.LOADING ->
loadingComponentBlock(stateControl, stateControl.tag)
StateEnum.CONTENT ->
contentComponentBlock(stateControl, stateControl.tag)
StateEnum.ERROR ->
if (stateControl.enableErrorRetry) {
StateBoxComposeClick(block = {
stateControl.showLoading(null)
}) {
errorComponentBlock(stateControl, stateControl.tag)
}
} else errorComponentBlock(stateControl, stateControl.tag)
...
}
}
扩展工具
为了便于更好的解决实际存在的问题,直接在 ui 中解决不了,那么我们就拉上 viewModel
,为此提供了以下扩展便于使用:
/** 在ViewModel中生成一个 IStateCompose
* @param stateEnum 默认的状态
* */
inline fun ViewModel.lazyState(
stateEnum: StateEnum = StateEnum.CONTENT,
crossinline obj: StateComposeImpl.() -> Unit = {}
): Lazy<IStateCompose> = lazy(LazyThreadSafetyMode.PUBLICATION) {
StateComposeImpl(stateEnum).apply(obj)
}
/**
* 当state在ViewModel中缓存时,可以使用这个方法便于对state做初始化相关
* 这样的好处就是可以将唯一初始化的东西放在这个 [block] 回调中,而不用担心重复初始化
* @param composeState 要记住的状态State
* */
@Composable
inline fun rememberState(
composeState: IStateCompose,
crossinline block: IStateCompose.() -> Unit = {}
): IStateCompose = currentComposer.cache(false) {
composeState.apply(block)
}
/**
* 记录state的状态,直接生成一个新的IStateCompose
* @param stateEnum 默认的状态
* @param block 对于IStateCompose的回调使用
* */
@Composable
inline fun rememberState(
stateEnum: StateEnum = StateEnum.CONTENT,
crossinline block: IStateCompose.() -> Unit = {}
): IStateCompose = currentComposer.cache(false) {
StateComposeImpl(stateEnum).apply(block)
}
使用方式
如图所示,我们在 viewModel
中定义了一个当前状态,并且定义了加载数据的方法, 在Ui部分,我们使用了一个 rememberState
这个方法缓存当前的 state
状态,在这里方法中我们还可以初始化 state
的部分回调,并且启用了加载数据,这将触发 onRefresh
回调,即加载页面数据,从而调用了我们 ViewModel
内部的 getData() 方法,当数据加载完成,我们便可以直接驱动这个 state
展现当前加载成功状态,从而触发外部的重组,于是我们的 StateCompose
将展示成功页面。
小彩蛋:
为了满足有些时候我们可能不想在
viewModel
中管理状态,我也提供了另一个扩展rememberState
。从而缓存一个
IStateCompose
的状态,但是这种场景实则不多,所以根据自身业务而定吧。
一切就是这么简单,在 compose
中如何使用状态页,已经分享大家了,至于大家要怎么改,可以参考 StateX 。
至于 view
部分的设计,大家一看源码就可以知道,并且大家已经 view
使用了多年,这个也不是本篇要讲的重点。
总结
本篇是 Compose
落地实践中比较常见的一篇,借此实践便于大家更好的理解 Compose
的编程思想。后续我将继续深追 Compose
的部分源码设计以及在实际落地中的场景解决方案。
如果本文对你有所帮助,欢迎点赞支持一下,大家加油 :)