Jetpack Compose - 封装特定功能

2,075 阅读11分钟

前言

在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样式,这里写的是默认的圆形进度条样式,并覆盖父布局大小且不允许点击穿透。代码主体部分就简单通过enabledLoadingComponentloading字段来判断是否显示loading。

需要注意的地方,LoadingComponent本身是没有固定大小的,因此使用的时候如果LoadingComponentmodifier没有手动设置大小,那么跟随的是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来存放默认实现,然后在某个地方进行初始化,比如ApplicationonCreate

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
        }
    }
}

首先定义了一个代表默认实现的基类DefaultsTarget用于简化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的字段设置到对应形参上,函数主体就直接调用LoadingComponentDefaultsLoadingComponent

这样基础库版本的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是一样的,有一点说一下,由于emptyContenterrorContent依赖父布局大小,所以contentviewState != ViewState.Idle时需要让它还存在Composition中,然后调整透明度,并通过pointerInteropFilter拦截点击。

当想要将LoadingComponentStateComponent的功能结合起来,那么就可以这样写:

@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等,在我的小项目里,有兴趣可以看看。