开源 | 如何写一个好用的 JetPack Compose 状态页组件

简介: 世界很大,也很小,组件很多,也很少。

引言

世界很大,也很小,组件很多,也很少。

关于开发中常见的状态页组件,我们已经见了很多,但是在 JetPack Compose 中该如何去写呢?虽然也有大佬写了相关demo ,但是如果要应用到实际中,不免有些捉襟见肘 。

本篇要解决的就是如何定制一个符合 实际开发 的状态页工具,并分析具体原理与设计思路。

效果图

网络异常,图片无法展示
|

这个效果图很简单,就是普通的一个状态页,所以也没什么值得说的,我们接下来分析一下,如果要实现一个状态页组件,需要有哪些基础功能。

需求分析

  • 支持 composeview
  • 分层设计,按需引入
  • 支持全局/局部配置默认缺省页
  • 支持全局重试与防抖处理

看完基本条件,其实也都不难,在 View 中设计一个状态页组件,大家都知道怎么做,但是 Compose呢? 那么我们下面就开始构思一下,如何设计这个状态页组件 StateX

基本思路

其实只要写过 compose 的代码,应该都明白,其实更简单了。因为 compose 是声明式的编程思想,即我们可以理解为数据驱动,所以最简单的做法:

定义一个变量,然后每次更改这个变量,变量改变之后,相应的使用这个变量的地方就会触发重组,于是我们可以随手写出下面的伪代码:

   val state = mutableStateOf (Loading)
   when(state){
     Loading -> {}
      Error -> {}
      Content -> {
       //加载错误了, 更改状态即可
       state = Error
     }
      xxx
 }

没错,在 compose 中实现就是这么简单,原理也很好理解。

不足之处

但如果你真的这样去写了,你可能已经进入一个圈套?试想一下,这个真的符合我们实际业务场景吗?

我们先还原一个真实的业务场景。

2345_image_file_copy_2.jpg

这是一个展示用户点赞排行榜的列表页,按照我们常规的思路,我们会怎么写:

  1. 先展示loading
  2. 请求数据
  3. 请求成功-设置数据,错误-显示缺省页

这个思路没有问题,在传统 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 中触发请求逻辑?

可以做,但是怎么做呢?虽然我知道这样能做,但是具体该怎么封装好呢?

于是有没有一个简便的,封装好的组件供我参考或者拿来就用呢?

2345_image_file_copy_3.jpg

为了解决上述问题,我写了一个简单组件 StateX ,大家可以自行copy更改,下面开始分析一下设计思路。

解析 StateX

要设计一个可以供 composeView 都可以使用的组件,不可避免的就需要两个model,分层去设计,并且支持按需引入,对于共有的模块,还需要单独提到基础组件里,于是 StateX 分为三个模块:

  • basic 基础层,放了一些compose与view共用的基础配置
  • compose 属于compose的单独model
  • view 属于view层的单独model

基础层-Basic 设计

既然要支持 composeView ,那么基础需要哪些功能呢?

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

我们定义了一个基础接口,其代表了 composeview 公用的接口, 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)
}

使用方式

image.jpeg

如图所示,我们在 viewModel 中定义了一个当前状态,并且定义了加载数据的方法, 在Ui部分,我们使用了一个 rememberState 这个方法缓存当前的 state 状态,在这里方法中我们还可以初始化 state 的部分回调,并且启用了加载数据,这将触发 onRefresh 回调,即加载页面数据,从而调用了我们 ViewModel 内部的 getData() 方法,当数据加载完成,我们便可以直接驱动这个 state 展现当前加载成功状态,从而触发外部的重组,于是我们的 StateCompose 将展示成功页面。

小彩蛋:

为了满足有些时候我们可能不想在 viewModel 中管理状态,我也提供了另一个扩展 rememberState

从而缓存一个 IStateCompose 的状态,但是这种场景实则不多,所以根据自身业务而定吧。

一切就是这么简单,在 compose 中如何使用状态页,已经分享大家了,至于大家要怎么改,可以参考 StateX

至于 view 部分的设计,大家一看源码就可以知道,并且大家已经 view 使用了多年,这个也不是本篇要讲的重点。

总结

本篇是 Compose 落地实践中比较常见的一篇,借此实践便于大家更好的理解 Compose 的编程思想。后续我将继续深追 Compose 的部分源码设计以及在实际落地中的场景解决方案。

目录
相关文章
|
2天前
|
存储 设计模式 数据库
构建高效的安卓应用:探究Android Jetpack架构组件
【4月更文挑战第20天】 在移动开发的世界中,构建一个既高效又可维护的安卓应用是每个开发者追求的目标。随着Android Jetpack的推出,Google为开发者提供了一套高质量的库、工具和指南,以简化应用程序开发流程。本文将深入探讨Jetpack的核心组件之一——架构组件,并展示如何将其应用于实际项目中,以提升应用的响应性和稳定性。我们将通过分析这些组件的设计原则,以及它们如何协同工作,来揭示它们对于构建现代化安卓应用的重要性。
|
2天前
|
Android开发 开发者
什么是Android Jetpack,它包括哪些组件?
什么是Android Jetpack,它包括哪些组件?
45 0
|
2天前
|
存储 缓存 编译器
探索 Jetpack Compose 内核:深入 SlotTable 系统
探索 Jetpack Compose 内核:深入 SlotTable 系统
80 1
|
2天前
|
IDE API 开发工具
Google I/O :Android Jetpack 最新变化(四)Compose
Google I/O :Android Jetpack 最新变化(四)Compose
119 0
|
2天前
|
前端开发 API Android开发
Jetpack Compose 实现波浪加载效果
Jetpack Compose 实现波浪加载效果
77 0
|
2天前
|
前端开发 算法 PHP
Jetpack Compose Runtime : 声明式 UI 的基础
Jetpack Compose Runtime : 声明式 UI 的基础
51 0
|
2天前
|
XML 前端开发 IDE
在 Compose 中使用 Jetpack 组件库
在 Compose 中使用 Jetpack 组件库
124 0
|
7月前
|
存储 算法 Android开发
Jetpack-Compose 学习笔记(三)—— Compose 的自定义“View”(下)
Jetpack-Compose 学习笔记(三)—— Compose 的自定义“View”(下)
40 0
|
2天前
|
设计模式 前端开发 数据库
构建高效Android应用:使用Jetpack架构组件实现MVVM模式
【4月更文挑战第21天】 在移动开发领域,构建一个既健壮又易于维护的Android应用是每个开发者的目标。随着项目复杂度的增加,传统的MVP或MVC架构往往难以应对快速变化的市场需求和复杂的业务逻辑。本文将探讨如何利用Android Jetpack中的架构组件来实施MVVM(Model-View-ViewModel)设计模式,旨在提供一个更加模块化、可测试且易于管理的代码结构。通过具体案例分析,我们将展示如何使用LiveData, ViewModel, 和Repository来实现界面与业务逻辑的分离,以及如何利用Room数据库进行持久化存储。最终,你将获得一个响应迅速、可扩展且符合现代软件工
26 0
|
2天前
|
存储 数据库 Android开发
构建高效安卓应用:采用Jetpack架构组件优化用户体验
【4月更文挑战第12天】 在当今快速发展的数字时代,Android 应用程序的流畅性与响应速度对用户满意度至关重要。为提高应用性能并降低维护成本,开发者需寻求先进的技术解决方案。本文将探讨如何利用 Android Jetpack 中的架构组件 — 如 LiveData、ViewModel 和 Room — 来构建高质量的安卓应用。通过具体实施案例分析,我们将展示这些组件如何协同工作以实现数据持久化、界面与逻辑分离,以及确保数据的即时更新,从而优化用户体验并提升应用的可维护性和可测试性。