开源 | 如何写一个好用的 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 的部分源码设计以及在实际落地中的场景解决方案。

目录
相关文章
|
6月前
|
存储 缓存 Android开发
安卓Jetpack Compose+Kotlin, 使用ExoPlayer播放多个【远程url】音频,搭配Okhttp库进行下载和缓存,播放完随机播放下一首
这是一个Kotlin项目,使用Jetpack Compose和ExoPlayer框架开发Android应用,功能是播放远程URL音频列表。应用会检查本地缓存,如果文件存在且大小与远程文件一致则使用缓存,否则下载文件并播放。播放完成后或遇到异常,会随机播放下一首音频,并在播放前随机设置播放速度(0.9到1.2倍速)。代码包括ViewModel,负责音频管理和播放逻辑,以及UI层,包含播放和停止按钮。
|
6月前
|
存储 数据库 Android开发
安卓Jetpack Compose+Kotlin,支持从本地添加音频文件到播放列表,支持删除,使用ExoPlayer播放音乐
为了在UI界面添加用于添加和删除本地音乐文件的按钮,以及相关的播放功能,你需要实现以下几个步骤: 1. **集成用户选择本地音乐**:允许用户从设备中选择音乐文件。 2. **创建UI按钮**:在界面中创建添加和删除按钮。 3. **数据库功能**:使用Room数据库来存储音频文件信息。 4. **更新ViewModel**:处理添加、删除和播放音频文件的逻辑。 5. **UI实现**:在UI层支持添加、删除音乐以及播放功能。
|
2月前
|
测试技术 数据库 Android开发
深入解析Android架构组件——Jetpack的使用与实践
本文旨在探讨谷歌推出的Android架构组件——Jetpack,在现代Android开发中的应用。Jetpack作为一系列库和工具的集合,旨在帮助开发者更轻松地编写出健壮、可维护且性能优异的应用。通过详细解析各个组件如Lifecycle、ViewModel、LiveData等,我们将了解其原理和使用场景,并结合实例展示如何在实际项目中应用这些组件,提升开发效率和应用质量。
52 6
|
3月前
|
安全 Java Android开发
探索安卓应用开发的新趋势:Kotlin和Jetpack Compose
在安卓应用开发领域,随着技术的不断进步,新的编程语言和框架层出不穷。Kotlin作为一种现代的编程语言,因其简洁性和高效性正逐渐取代Java成为安卓开发的首选语言。同时,Jetpack Compose作为一个新的UI工具包,提供了一种声明式的UI设计方法,使得界面编写更加直观和灵活。本文将深入探讨Kotlin和Jetpack Compose的特点、优势以及如何结合使用它们来构建现代化的安卓应用。
82 4
|
5月前
|
存储 移动开发 Android开发
使用kotlin Jetpack Compose框架开发安卓app, webview中h5如何访问手机存储上传文件
在Kotlin和Jetpack Compose中,集成WebView以支持HTML5页面访问手机存储及上传音频文件涉及关键步骤:1) 添加`READ_EXTERNAL_STORAGE`和`WRITE_EXTERNAL_STORAGE`权限,考虑Android 11的分区存储;2) 配置WebView允许JavaScript和文件访问,启用`javaScriptEnabled`、`allowFileAccess`等设置;3) HTML5页面使用`<input type="file">`让用户选择文件,利用File API;
|
6月前
|
JavaScript Java Android开发
kotlin安卓在Jetpack Compose 框架下跨组件通讯EventBus
**EventBus** 是一个Android事件总线库,简化组件间通信。要使用它,首先在Gradle中添加依赖`implementation &#39;org.greenrobot:eventbus:3.3.1&#39;`。然后,可选地定义事件类如`MessageEvent`。在活动或Fragment的`onCreate`中注册订阅者,在`onDestroy`中反注册。通过`@Subscribe`注解方法处理事件,如`onMessageEvent`。发送事件使用`EventBus.getDefault().post()`。
|
6月前
|
安全 JavaScript 前端开发
kotlin开发安卓app,JetPack Compose框架,给webview新增一个按钮,点击刷新网页
在Kotlin中开发Android应用,使用Jetpack Compose框架时,可以通过添加一个按钮到TopAppBar来实现WebView页面的刷新功能。按钮位于右上角,点击后调用`webViewState?.reload()`来刷新网页内容。以下是代码摘要:
|
6月前
|
缓存 Android开发 Kotlin
【安卓app开发】kotlin Jetpack Compose框架 | 先用OKhttp下载远程音频文件再使用ExoPlayer播放
使用 Kotlin 的 Jetpack Compose 开发安卓应用时,可以结合 OkHttp 下载远程音频文件和 ExoPlayer 进行播放。在 `build.gradle` 添加相关依赖后,示例代码展示了如何下载音频并用 ExoPlayer 播放。代码包括添加依赖、下载文件、播放文件及简单的 Compose UI。注意,示例未包含完整错误处理和资源释放,实际应用需补充这些内容。
|
6月前
|
JavaScript 前端开发 Android开发
kotlin安卓在Jetpack Compose 框架下使用webview , 网页中的JavaScript代码如何与native交互
在Jetpack Compose中使用Kotlin创建Webview组件,设置JavaScript交互:`@Composable`函数`ComposableWebView`加载网页并启用JavaScript。通过`addJavascriptInterface`添加`WebAppInterface`类,允许JavaScript调用Android方法如播放音频。当页面加载完成时,执行`onWebViewReady`回调。
|
6月前
|
存储 Android开发 Kotlin
开发安卓app OKhttp下载后使用MediaPlayer播放
在Android Jetpack Compose应用程序中,要使用OkHttp下载远程音频文件并在本地播放,你需要完成以下几个步骤: 1. **添加依赖**:确保`build.gradle`文件包含OkHttp和Jetpack Compose的相关依赖。 2. **下载逻辑**:创建一个`suspend`函数,使用OkHttp发起网络请求下载音频文件到本地。 3. **播放逻辑**:利用`MediaPlayer`管理音频播放状态。 4. **Compose UI**:构建用户界面,包含下载和播放音频的按钮。