如何寫一個好用的 JetPack Compose 狀態頁組件

前言

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

關于開發中常見的狀態頁組件,我們已經見了很多,但是在 JetPack Compose 中該如何去寫呢?雖然也有大佬寫了相關demo ,但是如果要應用到實際中,不免有些捉襟見肘 。

本篇要解決的就是如何定制一個符合 實際開發 的狀態頁工具,并分析具體原理與設計思路。
Android開發進階學習——jetpack Compose的快速上手以及未來趨勢_嗶哩嗶哩_bilibili

效果圖

[圖片上傳失敗...(image-712a17-1637071955512)]

這個效果圖很簡單,就是普通的一個狀態頁,所以也沒什么值得說的,我們接下來分析一下,如果要實現一個狀態頁組件,需要有哪些基礎功能。

需求分析

  • 支持 composeview
  • 分層設計,按需引入
  • 支持全局/局部配置默認缺省頁
  • 支持全局重試與防抖處理
  • ...

看完基本條件,其實也都不難,在 View 中設計一個狀態頁組件,大家都知道怎么做,但是 Compose 呢? 那么我們下面就開始構思一下,如何設計這個狀態頁組件 StateX

基本思路

其實只要寫過 compose 的代碼,應該都明白,其實更簡單了。因為 compose 是聲明式的編程思想,即我們可以理解為數據驅動,所以最簡單的做法:

定義一個變量,然后每次更改這個變量,變量改變之后,相應的使用這個變量的地方就會觸發重組,于是我們可以隨手寫出下面的偽代碼:

   val state = mutableStateOf (Loading)

   when(state){

     Loading -> {}

      Error -> {}
  
      Content -> {
       //加載錯誤了, 更改狀態即可
       state = Error
     }

      xxx

 }

沒錯,在 compose 中實現就是這么簡單,原理也很好理解。


不足之處

但如果你真的這樣去寫了,你可能已經進入一個圈套?試想一下,這個真的符合我們實際業務場景嗎?

我們先還原一個真實的業務場景。

這是一個展示用戶點贊排行榜的列表頁,按照我們常規的思路,我們會怎么寫:

  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 中觸發請求邏輯?

可以做,但是怎么做呢?雖然我知道這樣能做,但是具體該怎么封裝好呢?

于是有沒有一個簡便的,封裝好的組件供我參考或者拿來就用呢?

image-20211103200037374

為了解決上述問題,我寫了一個簡單組件 StateX ,大家可以自行copy更改,下面開始分析一下設計思路。

解析 StateX

要設計一個可以供 composeView 都可以使用的組件,不可避免的就需要兩個model,分層去設計,并且支持按需引入,對于共有的模塊,還需要單獨提到基礎組件里,于是 StateX 分為三個模塊:

  • basic 基礎層,放了一些compose與view共用的基礎配置
  • compose 屬于compose的單獨model
  • view 屬于view層的單獨model

感謝 @掘金-Range(業內俗稱東哥)的 StateLayout,view部分的核心代碼來自這里,原因足夠簡單易用。


基礎層-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 入口,我們便可以在相應的 composeview 的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-20211104155419940

如圖所示,我們在 viewModel 中定義了一個當前狀態,并且定義了加載數據的方法, 在Ui部分,我們使用了一個 rememberState 這個方法緩存當前的 state 狀態,在這里方法中我們還可以初始化 state 的部分回調,并且啟用了加載數據,這將觸發 onRefresh 回調,即加載頁面數據,從而調用了我們 ViewModel 內部的 getData() 方法,當數據加載完成,我們便可以直接驅動這個 state 展現當前加載成功狀態,從而觸發外部的重組,于是我們的 StateCompose 將展示成功頁面。

小彩蛋:

為了滿足有些時候我們可能不想在 viewModel 中管理狀態,我也提供了另一個擴展 rememberState。

從而緩存一個 IStateCompose 的狀態,但是這種場景實則不多,所以根據自身業務而定吧。

一切就是這么簡單,在 compose 中如何使用狀態頁,已經分享大家了,至于大家要怎么改,可以參考 StateX

至于 view 部分的設計,大家一看源碼就可以知道,并且大家已經 view 使用了多年,這個也不是本篇要講的重點。

總結

本篇是 Compose 落地實踐中比較常見的一篇,借此實踐便于大家更好的理解 Compose 的編程思想。后續我將繼續深追 Compose 的部分源碼設計以及在實際落地中的場景解決方案。

Android開發進階學習——jetpack Compose的快速上手以及未來趨勢_嗶哩嗶哩_bilibili

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容