前言
世界很大,也很小,組件很多,也很少。
關于開發中常見的狀態頁組件,我們已經見了很多,但是在 JetPack Compose
中該如何去寫呢?雖然也有大佬寫了相關demo ,但是如果要應用到實際中,不免有些捉襟見肘 。
本篇要解決的就是如何定制一個符合 實際開發 的狀態頁工具,并分析具體原理與設計思路。
Android開發進階學習——jetpack Compose的快速上手以及未來趨勢_嗶哩嗶哩_bilibili
效果圖
[圖片上傳失敗...(image-712a17-1637071955512)]
這個效果圖很簡單,就是普通的一個狀態頁,所以也沒什么值得說的,我們接下來分析一下,如果要實現一個狀態頁組件,需要有哪些基礎功能。
需求分析
- 支持
compose
與view
- 分層設計,按需引入
- 支持全局/局部配置默認缺省頁
- 支持全局重試與防抖處理
- ...
看完基本條件,其實也都不難,在 View
中設計一個狀態頁組件,大家都知道怎么做,但是 Compose
呢? 那么我們下面就開始構思一下,如何設計這個狀態頁組件 StateX。
基本思路
其實只要寫過 compose
的代碼,應該都明白,其實更簡單了。因為 compose
是聲明式的編程思想,即我們可以理解為數據驅動,所以最簡單的做法:
定義一個變量,然后每次更改這個變量,變量改變之后,相應的使用這個變量的地方就會觸發重組,于是我們可以隨手寫出下面的偽代碼:
val state = mutableStateOf (Loading)
when(state){
Loading -> {}
Error -> {}
Content -> {
//加載錯誤了, 更改狀態即可
state = Error
}
xxx
}
沒錯,在 compose
中實現就是這么簡單,原理也很好理解。
不足之處
但如果你真的這樣去寫了,你可能已經進入一個圈套?試想一下,這個真的符合我們實際業務場景嗎?
我們先還原一個真實的業務場景。
這是一個展示用戶點贊排行榜的列表頁,按照我們常規的思路,我們會怎么寫:
- 先展示loading
- 請求數據
- 請求成功-設置數據,錯誤-顯示缺省頁
這個思路沒有問題,在傳統 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 中觸發請求邏輯?
可以做,但是怎么做呢?雖然我知道這樣能做,但是具體該怎么封裝好呢?
于是有沒有一個簡便的,封裝好的組件供我參考或者拿來就用呢?
為了解決上述問題,我寫了一個簡單組件 StateX ,大家可以自行copy更改,下面開始分析一下設計思路。
解析 StateX
要設計一個可以供 compose
與 View
都可以使用的組件,不可避免的就需要兩個model,分層去設計,并且支持按需引入,對于共有的模塊,還需要單獨提到基礎組件里,于是 StateX
分為三個模塊:
- basic 基礎層,放了一些compose與view共用的基礎配置
- compose 屬于compose的單獨model
- view 屬于view層的單獨model
感謝 @掘金-Range(業內俗稱東哥)的 StateLayout,view部分的核心代碼來自這里,原因足夠簡單易用。
基礎層-Basic 設計
既然要支持 compose
與 View
,那么基礎需要哪些功能呢?
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)
...
}
我們定義了一個基礎接口,其代表了 compose
與 view
公用的接口, 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)
}
使用方式
如圖所示,我們在 viewModel
中定義了一個當前狀態,并且定義了加載數據的方法, 在Ui部分,我們使用了一個 rememberState
這個方法緩存當前的 state
狀態,在這里方法中我們還可以初始化 state
的部分回調,并且啟用了加載數據,這將觸發 onRefresh
回調,即加載頁面數據,從而調用了我們 ViewModel
內部的 getData() 方法,當數據加載完成,我們便可以直接驅動這個 state
展現當前加載成功狀態,從而觸發外部的重組,于是我們的 StateCompose
將展示成功頁面。
小彩蛋:
為了滿足有些時候我們可能不想在
viewModel
中管理狀態,我也提供了另一個擴展rememberState
。從而緩存一個
IStateCompose
的狀態,但是這種場景實則不多,所以根據自身業務而定吧。
一切就是這么簡單,在 compose
中如何使用狀態頁,已經分享大家了,至于大家要怎么改,可以參考 StateX 。
至于 view
部分的設計,大家一看源碼就可以知道,并且大家已經 view
使用了多年,這個也不是本篇要講的重點。
總結
本篇是 Compose
落地實踐中比較常見的一篇,借此實踐便于大家更好的理解 Compose
的編程思想。后續我將繼續深追 Compose
的部分源碼設計以及在實際落地中的場景解決方案。