前言
在Compose官网能了解到目前官方推荐的是单向数据流,将界面用到的数据封装在一个叫UiState里,但是存在一个问题,业务中有许多界面需要展示类似loading这样的元素,如果也放在UiState里的话意味着每个界面都得重新写一遍这些功能,写得多了项目中就有很多这样的样板代码,所以这里讲讲我自己是怎么封装这些功能的。
初步实现
这里以loading为例,首先需要一个类来承载loading状态,这种封装特定功能的类命名为Component,也可以取别的名称比如Usecase。
@Stable
interface LoadingComponent {
val loading: Boolean
fun showLoading(show: Boolean)
}
loading字段用于在Compose中读取,showLoading用于显示、隐藏,至于LoadingComponent的实现我将它放在ViewModel中。
abstract class BaseViewModel : ViewModel() {
private val loadingComponentImpl by lazy { LoadingComponentImpl() }
val loadingComponent: LoadingComponent get() = loadingComponentImpl
protected fun showLoading(show: Boolean) {
loadingComponentImpl.showLoading(show)
}
private class LoadingComponentImpl : LoadingComponent {
private val _loading = mutableStateOf(false)
override val loading: Boolean get() = _loading.value
override fun showLoading(show: Boolean) {
_loading.value = show
}
}
}
这里将实现写在LoadingComponentImpl里面,对外暴露LoadingComponent接口,并写了一个showLoading函数供ViewModel子类快速使用loading。接下来开始写composable,将loading关联起来。
@Composable
fun LoadingComponent(
component: LoadingComponent,
modifier: Modifier = Modifier,
enabled: Boolean = true,
loading: @Composable BoxScope.() -> Unit = {
Box(
modifier = Modifier
.matchParentSize()
.pointerInput(Unit) {
},
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
},
content: @Composable BoxScope.() -> Unit
) {
Box(modifier = modifier) {
content()
val showLoading = component.loading
if (enabled && showLoading) {
loading()
}
}
}
同样还是简单实现了一下,外部可以传enabled来决定是否启用LoadingComponent,然后loading参数可以自定义loading样式,这里写的是默认的圆形进度条样式,并覆盖父布局大小且不允许点击穿透。代码主体部分就简单通过enabled和LoadingComponent的loading字段来判断是否显示loading。
需要注意的地方,LoadingComponent本身是没有固定大小的,因此使用的时候如果LoadingComponent的modifier没有手动设置大小,那么跟随的是content主体内容的大小,这时候如果content为空那么loading是显示不出来的。
使用
到这里简单的封装就结束了,那么平时使用的时候就比较方便了,不需要每个界面都去定义loading,相同的代码一写再写。
class HomeVM : BaseViewModel() {
init {
loadData()
}
fun loadData() {
viewModelScope.launch {
showLoading(true)
// 假设有耗时逻辑
delay(2000)
showLoading(false)
}
}
}
@Composable
fun HomeScreen(vm: HomeVM = viewModel()) {
Column(modifier = Modifier.systemBarsPadding()) {
Text(text = "标题栏")
LoadingComponent(component = vm.loadingComponent) {
Box(modifier = Modifier.fillMaxSize()) {
TextButton(onClick = { vm.loadData() }) {
Text(text = "Load Data")
}
}
}
}
}
完善
从上面可以看到实现还是有点粗糙,在业务中需要使用到loading一般都是因为有耗时操作,而耗时操作一般都会放在协程里面去执行,那完全可以将loading和协程关联起来,并且可能也希望在点击返回键时能隐藏loading并将相应的协程也取消掉,那接下来就继续完善功能。
@Stable
interface LoadingComponent {
val loading: Boolean
val containsCancelable: Boolean
fun showLoading(show: Boolean)
fun cancelLoading()
}
在LoadingComponent接口中新增了containsCancelable,用于判断是否拦截返回键,cancelLoading则用于隐藏loading并将协程取消,然后ViewModel中也要做调整。
abstract class BaseViewModel : ViewModel() {
private val loadingComponentImpl by lazy { LoadingComponentImpl() }
val loadingComponent: LoadingComponent get() = loadingComponentImpl
protected fun showLoading(show: Boolean) {
loadingComponentImpl.showLoading(show)
}
protected fun cancelLoading() {
loadingComponentImpl.cancelLoading()
}
protected fun CoroutineScope.launchWithLoading(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
cancelable: Boolean = true,
block: suspend CoroutineScope.() -> Unit
): Job = with(loadingComponentImpl) {
launchWithLoading(
context = context,
start = start,
cancelable = cancelable,
block = block
)
}
private class LoadingComponentImpl : LoadingComponent {
private val _loading = mutableStateOf(false)
private val loadingJobs = mutableStateMapOf<UUID, Job>()
override val loading: Boolean get() = _loading.value
override val containsCancelable: Boolean get() = loadingJobs.isNotEmpty()
override fun showLoading(show: Boolean) {
_loading.value = show
}
override fun cancelLoading() {
showLoading(false)
val jobs = loadingJobs
if (jobs.isEmpty()) {
return
}
jobs.forEach { job ->
job.value.cancel()
}
jobs.clear()
}
fun CoroutineScope.launchWithLoading(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
cancelable: Boolean = true,
block: suspend CoroutineScope.() -> Unit
): Job = launch(
context = context,
start = start,
block = block
).apply {
showLoading(true)
val jobs = loadingJobs
val key = UUID.randomUUID()
if (cancelable) {
jobs[key] = this
}
invokeOnCompletion {
if (cancelable) {
jobs.remove(key)
}
if (jobs.isEmpty()) {
showLoading(false)
}
}
}
}
}
ViewModel的改动比较多,接下来一一说明。
前面说到,希望loading状态和协程能关联上,因此写了一个launchWithLoading函数,并且增加了loadingJobs字段来存储执行中的Job,用于随时取消相关协程,在launchWithLoading中可以看到,当协程执行结束时会将相关的Job从loadingJobs中移除掉,如果是不可取消的即cancelable为false则不用管,这里还需要判断loadingJobs是否为空才能取消,因为可能有其他loading协程还在执行。
ps:一般不会同时出现两个或以上的loading协程,这里实现的loading样式是一但显示后就不允许点击穿透,因此基本不会出现多个loading协程同时存在,但也需要防止同一时间多次调用launchWithLoading导致的多个loading协程同时存在。
接着看cancelLoading函数,这里就比较简单了,直接遍历loadingJobs取消协程,然后清空。
@Composable
fun LoadingComponent(
component: LoadingComponent,
modifier: Modifier = Modifier,
enabled: Boolean = true,
loading: @Composable BoxScope.() -> Unit = {
Box(
modifier = Modifier
.matchParentSize()
.pointerInput(Unit) {
},
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
},
content: @Composable BoxScope.() -> Unit
) {
Box(modifier = modifier) {
content()
val showLoading = component.loading
val containsCancelable= component.containsCancelable
BackHandler(enabled = enabled && showLoading && containsCancelable) {
component.cancelLoading()
}
if (enabled && showLoading) {
loading()
}
}
}
LoadingComponent函数主要就是加了BackHandler来拦截返回键,如果当前正在显示loading并且有可取消的协程存在,BackHandler就会启用,这时点击返回键就会调用cancelLoading。
定制化
对于一些通用的功能我们一般都会对其进行封装,如果你有好几个项目,那么一般会将这些通用功能抽离到一个基础库当中,还是拿这个LoadingComponent举例子,假设现在LoadingComponent是写在基础库中的,然后不同项目中去引用这个基础库,这时候可能每个项目对于loading样式的设计都是不同的,那一般做法可能是在各自项目中再对LoadingComponent去封装一层,可能是这样的,将LoadingComponent函数的声明复制到项目中,然后可能取个特定的名称,比如MyLoadingComponent等,如下:
@Composable
fun MyLoadingComponent(
component: LoadingComponent,
modifier: Modifier = Modifier,
enabled: Boolean = true,
loading: @Composable BoxScope.() -> Unit = {
// 重新定义了全局通用loading样式,这里省略...
},
content: @Composable BoxScope.() -> Unit
) {
LoadingComponent(
component = component,
modifier = modifier,
enabled = enabled,
loading = loading,
content = content
)
}
这样做其实挺麻烦的,每个项目你都得这么封装一层,无形中又是样板代码。又或者你写在基础库的LoadingComponent就是所有项目通用的样式,你可以在每个项目中直接调用基础库版本,但需求是一直在变的,可能后期就需要对不同项目定制不同的loading样式了,但这时候基础库LoadingComponent的调用已经充斥每个项目了,再封装去更改又得耗费一定的时间精力。那要怎么解决呢,这里说下我自己的办法。
对于composable函数,我们知道默认实现依赖于函数声明的默认参数,那么简单粗暴的做法可能是这样的:
object LoadingComponentDefaults {
var enabled: Boolean = true
var loading: @Composable BoxScope.() -> Unit = {
// 定义了loading样式,这里省略...
}
}
@Composable
fun LoadingComponent(
component: LoadingComponent,
modifier: Modifier = Modifier,
enabled: Boolean = LoadingComponentDefaults.enabled,
loading: @Composable BoxScope.() -> Unit = LoadingComponentDefaults.loading,
content: @Composable BoxScope.() -> Unit
) {
// ...省略实现
}
通过定义一个LoadingComponentDefaults来存放默认实现,然后在某个地方进行初始化,比如Application的onCreate:
class App : Application() {
override fun onCreate() {
super.onCreate()
with(LoadingComponentDefaults) {
enabled = false
loading = {
// 重新定义了loading样式,这里省略...
}
}
}
}
这种做法对于非函数类型的参数没问题,但如果是函数类型例如上面的loading参数,在loading执行中可能需要用到LoadingComponent其他的一些形参,但是写在LoadingComponentDefaults内的loading是拿不到的,因此我的办法是使用面向对象那一套,通过继承来解决,还是看代码:
abstract class Defaults {
abstract class Target<T : Defaults>(defaults: T) {
private var _instance: T = defaults
val instance: T get() = _instance
fun set(defaults: T) {
_instance = defaults
}
}
}
首先定义了一个代表默认实现的基类Defaults,Target用于简化Defaults子类重写。
interface ILoadingComponentDefaults {
val enabled: Boolean
val loading: @Composable BoxScope.() -> Unit
@Composable
fun LoadingComponent(
component: LoadingComponent,
modifier: Modifier,
enabled: Boolean,
loading: @Composable BoxScope.() -> Unit,
content: @Composable BoxScope.() -> Unit
)
}
接着定义一个代表LoadingComponent默认实现的接口,定义接口的原因是因为可以使用委托,后面会说到。
open class LoadingComponentDefaults : Defaults(), ILoadingComponentDefaults {
companion object : Target<LoadingComponentDefaults>(LoadingComponentDefaults())
override val enabled: Boolean = true
final override val loading: @Composable BoxScope.() -> Unit = { /*EMPTY*/ }
@Composable
override fun LoadingComponent(
component: LoadingComponent,
modifier: Modifier,
enabled: Boolean,
loading: @Composable BoxScope.() -> Unit,
content: @Composable BoxScope.() -> Unit
) {
LoadingComponentImpl(
component = component,
modifier = modifier,
enabled = enabled,
loading = loading,
content = content
)
}
@Composable
private fun LoadingComponentImpl(
component: LoadingComponent,
modifier: Modifier,
enabled: Boolean,
loading: @Composable BoxScope.() -> Unit,
content: @Composable BoxScope.() -> Unit
) {
val loadingContent = if (loading !== this.loading) loading else {
{
Box(
modifier = Modifier
.matchParentSize()
.pointerInput(Unit) {
},
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
Box(modifier = modifier) {
content()
val showLoading = component.loading
val containsCancelable = component.containsCancelable
BackHandler(enabled = enabled && showLoading && containsCancelable) {
component.cancelLoading()
}
if (enabled && showLoading) {
loadingContent()
}
}
}
}
定义了LoadingComponentDefaults,继承自Defaults并实现ILoadingComponentDefaults接口,从上往下看吧。
首先companion object继承了Defaults.Target,并传入LoadingComponentDefaults对象代表默认实现。enabled字段没什么说的,子类可以重写更改默认值,loading字段这里使用了final关键字禁止子类重写,并且默认是空实现,这是因为loading的默认实现需要放到LoadingComponent函数中去做才能获取到运行时形参,这里的loading字段仅仅作为占位符使用,用于运行时判断。
接下来说说LoadingComponent函数,这里使用LoadingComponentImpl包了一层,因为Compose会在编译期进行插桩,如果直接将实现代码写在这里,子类重写函数后运行时会报错,那么直接看LoadingComponentImpl。
开头便通过this.loading和形参loading对比,判断是否默认参数,如果loading !== this.loading为true代表外部调用时自定义了样式,否则传入的就是默认参数,使用内部默认实现。
@Composable
fun LoadingComponent(
component: LoadingComponent,
modifier: Modifier = Modifier,
enabled: Boolean = LoadingComponentDefaults.instance.enabled,
loading: @Composable BoxScope.() -> Unit = LoadingComponentDefaults.instance.loading,
content: @Composable BoxScope.() -> Unit
) {
LoadingComponentDefaults.instance.LoadingComponent(
component = component,
modifier = modifier,
enabled = enabled,
loading = loading,
content = content
)
}
这里对LoadingComponent顶层函数进行了修改,将LoadingComponentDefaults的字段设置到对应形参上,函数主体就直接调用LoadingComponentDefaults的LoadingComponent。
这样基础库版本的LoadingComponent就写完了,在其他项目中重写的话是这样的:
class LoadingComponentDefaultsImpl : LoadingComponentDefaults() {
override val enabled: Boolean = true
@Composable
override fun LoadingComponent(
component: LoadingComponent,
modifier: Modifier,
enabled: Boolean,
loading: @Composable BoxScope.() -> Unit,
content: @Composable BoxScope.() -> Unit
) {
val loadingContent = if (loading !== this.loading) loading else {
{
Box(
modifier = Modifier
.matchParentSize()
.pointerInput(Unit) {
},
contentAlignment = Alignment.Center
) {
// 改成文本显示
Text(text = "加载中...")
}
}
}
super.LoadingComponent(
component = component,
modifier = modifier,
enabled = enabled,
loading = loadingContent,
content = content
)
}
}
class App : Application() {
override fun onCreate() {
super.onCreate()
LoadingComponentDefaults.set(LoadingComponentDefaultsImpl())
}
}
写一个实现类继承LoadingComponentDefaults,然后在Application中进行初始化,这样全局就应用新逻辑了。
接口委托
上面定义了ILoadingComponentDefaults接口,说到可以用接口委托,假设现在有一个StateComponent,作用是根据需要显示空数据占位图,错误占位图,这个StateComponent如下:
@Stable
sealed class ViewState {
data object Idle : ViewState()
data object Empty : ViewState()
data class Error(val ex: Throwable) : ViewState()
}
@Stable
interface StateComponent {
val viewState: ViewState
fun showViewState(viewState: ViewState)
fun retry()
}
@Stable
interface IStateComponentDefaults {
val empty: @Composable BoxScope.() -> Unit
val error: @Composable BoxScope.() -> Unit
@Composable
fun StateComponent(
component: StateComponent,
modifier: Modifier,
empty: @Composable (BoxScope.() -> Unit)?,
error: @Composable (BoxScope.() -> Unit)?,
content: @Composable BoxScope.() -> Unit
)
}
open class StateComponentDefaults : Defaults(), IStateComponentDefaults {
companion object : Target<StateComponentDefaults>(StateComponentDefaults())
final override val empty: @Composable BoxScope.() -> Unit = { /*EMPTY*/ }
final override val error: @Composable BoxScope.() -> Unit = { /*EMPTY*/ }
@Composable
override fun StateComponent(
component: StateComponent,
modifier: Modifier,
empty: @Composable (BoxScope.() -> Unit)?,
error: @Composable (BoxScope.() -> Unit)?,
content: @Composable BoxScope.() -> Unit
) {
StateComponentImpl(
component = component,
modifier = modifier,
empty = empty,
error = error,
content = content
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun StateComponentImpl(
component: StateComponent,
modifier: Modifier,
empty: @Composable (BoxScope.() -> Unit)?,
error: @Composable (BoxScope.() -> Unit)?,
content: @Composable BoxScope.() -> Unit
) {
val errorContent = if (error !== this.error) error else {
{
Box(
modifier = Modifier
.matchParentSize()
.clickable {
component.retry()
},
contentAlignment = Alignment.Center
) {
Text(text = "请求错误")
}
}
}
val emptyContent = if (empty !== this.empty) empty else {
{
Box(
modifier = Modifier.matchParentSize(),
contentAlignment = Alignment.Center
) {
Text(text = "暂无数据")
}
}
}
Box(modifier = modifier) {
val contentEnabled by remember(component) {
derivedStateOf {
val result = component.viewState
result == ViewState.Idle ||
(result is ViewState.Empty && emptyContent == null) ||
(result is ViewState.Error && errorContent == null)
}
}
Box(
modifier = Modifier
.graphicsLayer {
alpha = 1f.takeIf { contentEnabled } ?: 0f
}
.pointerInteropFilter { !contentEnabled }
) {
content()
}
if (!contentEnabled) {
Box(modifier = Modifier.matchParentSize()) {
when (component.viewState) {
is ViewState.Error -> {
if (errorContent != null) {
errorContent()
}
}
is ViewState.Empty -> {
if (emptyContent != null) {
emptyContent()
}
}
else -> Unit
}
}
}
}
}
}
@Composable
fun StateComponent(
component: StateComponent,
modifier: Modifier = Modifier,
empty: @Composable (BoxScope.() -> Unit)? = StateComponentDefaults.instance.empty,
error: @Composable (BoxScope.() -> Unit)? = StateComponentDefaults.instance.error,
content: @Composable BoxScope.() -> Unit
) {
StateComponentDefaults.instance.StateComponent(
component = component,
modifier = modifier,
empty = empty,
error = error,
content = content
)
}
虽然长,但其实流程和LoadingComponent是一样的,有一点说一下,由于emptyContent和errorContent依赖父布局大小,所以content在viewState != ViewState.Idle时需要让它还存在Composition中,然后调整透明度,并通过pointerInteropFilter拦截点击。
当想要将LoadingComponent和StateComponent的功能结合起来,那么就可以这样写:
@Stable
interface LoadingStateComponent : LoadingComponent, StateComponent
interface ILoadingStateComponentDefaults : ILoadingComponentDefaults, IStateComponentDefaults {
@Composable
fun LoadingStateComponent(
component: LoadingStateComponent,
modifier: Modifier,
enabled: Boolean,
loading: @Composable BoxScope.() -> Unit,
empty: @Composable (BoxScope.() -> Unit)?,
error: @Composable (BoxScope.() -> Unit)?,
content: @Composable BoxScope.() -> Unit
)
}
open class LoadingStateComponentDefaults(
private val loadingComponentDefaults: ILoadingComponentDefaults = LoadingComponentDefaults.instance,
private val stateComponentDefaults: IStateComponentDefaults = StateComponentDefaults.instance
) : Defaults(), ILoadingStateComponentDefaults,
ILoadingComponentDefaults by loadingComponentDefaults,
IStateComponentDefaults by stateComponentDefaults {
companion object : Target<LoadingStateComponentDefaults>(LoadingStateComponentDefaults())
final override val loading: @Composable BoxScope.() -> Unit
get() = loadingComponentDefaults.loading
final override val empty: @Composable BoxScope.() -> Unit
get() = stateComponentDefaults.empty
final override val error: @Composable BoxScope.() -> Unit
get() = stateComponentDefaults.error
@Composable
override fun LoadingStateComponent(
component: LoadingStateComponent,
modifier: Modifier,
enabled: Boolean,
loading: @Composable BoxScope.() -> Unit,
empty: @Composable (BoxScope.() -> Unit)?,
error: @Composable (BoxScope.() -> Unit)?,
content: @Composable BoxScope.() -> Unit
) {
LoadingStateComponentImpl(
component = component,
modifier = modifier,
enabled = enabled,
loading = loading,
empty = empty,
error = error,
content = content
)
}
@Composable
private fun LoadingStateComponentImpl(
component: LoadingStateComponent,
modifier: Modifier,
enabled: Boolean,
loading: @Composable BoxScope.() -> Unit,
empty: @Composable (BoxScope.() -> Unit)?,
error: @Composable (BoxScope.() -> Unit)?,
content: @Composable BoxScope.() -> Unit
) {
LoadingComponent(
component = component,
modifier = modifier,
enabled = enabled,
loading = loading,
) {
StateComponent(
component = component,
modifier = Modifier,
error = error,
empty = empty,
content = content
)
}
}
}
@Composable
fun LoadingStateComponent(
component: LoadingStateComponent,
modifier: Modifier = Modifier,
enabled: Boolean = LoadingStateComponentDefaults.instance.enabled,
loading: @Composable BoxScope.() -> Unit = LoadingStateComponentDefaults.instance.loading,
error: @Composable (BoxScope.() -> Unit)? = LoadingStateComponentDefaults.instance.error,
empty: @Composable (BoxScope.() -> Unit)? = LoadingStateComponentDefaults.instance.empty,
content: @Composable BoxScope.() -> Unit
) {
LoadingStateComponentDefaults.instance.LoadingStateComponent(
component = component,
modifier = modifier,
enabled = enabled,
loading = loading,
error = error,
empty = empty,
content = content
)
}
结尾
本文主要还是聊了一下我对封装这些功能的看法,如果有其他解决方案欢迎讨论,另外我还封装了接管刷新的RefreshComponent,接管分页的PagingComponent等,在我的小项目里,有兴趣可以看看。