相較於傳統的 Android View,Jetpack Compose 在 Android 開發上還有一個新的概念,那就是 Stateful (有狀態的) 還有 Stateless (無狀態的),想要理解它們的差別,最簡單最好理解的例子應該就屬於文字輸入框了。
對於 Android 開發者來說, EditText 大家一定都很熟悉,該元件的使用方式如下:
setText
getText().toString()
addOnTextChangedListener()
這些對於剛入行的開發者來說是很容易理解,也很好使用的 API,但是這樣的設計卻有一個很大的缺陷!EditText 是無法輕易擁有 Single source of truth 的!假設現在有一份資料從 ViewModel 傳送到 View ,並且使用 setText
的方式將這份資料儲存在 EditText 中,這時候,如果他們要符合 single source of truth 這概念的話,就只能允許在其中一個地方修改資料,而這個地方很理所當然的會是 ViewModel 這邊,但是 EditText 這邊卻可以經由鍵盤的輸入任意改變 EditText 中的值,在鍵盤輸入的這一瞬間就導致了資料不同步,換句話說,EditText 這個元件一直都保有自己的狀態,而這種類型的元件我們叫它 Stateful。
其實還有很多其他元件也是 Stateful 的,像是 CheckBox 還有 RadioButton,相信經驗豐富的 Android 開發者一定經歷過 RecyclerView 配上 CheckBox 資料不同步的情況吧,往下滑再滑回來一切就長得不一樣了,而這也是依賴 Stateful 元件常犯的錯誤。
那 Jetpack Compose 的文字輸入框又是怎樣運作的呢?
@Composable
fun TextFieldSample(name: String) {
Row {
TextField(value = name, onValueChange = {})
}
}
Jetpack Compose 的文字輸入框是一個叫做 TextField 的元件,其中前兩個參數是必填的,但現在可以先不用管它,直接留白,將它跑在手機上看看會發生什麼事:
不管怎麼在鍵盤上輸入,就是不會更新!這到底是發生了什麼事呢?
文字輸入框在 Jetpack Compose 中是一個 Stateless 的元件,也就是說他沒有任何狀態,也不會主動更新,唯一的更新方式是藉由外面的元件提供的狀態,來跟 TextField 互動,下面做一個簡單的例子:
@Composable
fun TextFieldSample(name: String) {
Row {
StatefulTextField(name)
}
}
@Composable
fun StatefulTextField(name: String) {
var text by remember { mutableStateOf(name)}
TextField(value = text, onValueChange = { text = it })
}
我在這裡建立了一個 StatefulTextField
來包裝了 TextField ,經由這個包裝之後這個元件就變成了一個 Stateful 的元件,行為就變得跟之前的 EditText 一樣了。那我做了什麼事呢?首先是我在這個 Composable function 裡面新增了一個變數: text ,用來當作這個 component 的狀態,他的型別為 String。另外使用了之前講過的關鍵字 by
還有 State
,來讓這個 text 在被改變的時候可以觸發 Recomposition,remember
則是讓這個 text 在下次 render 的時候還記得 Recomposition 之前的值。
至於 TextField 這邊,第一個參數應該不用說明,就是要顯示的文字這樣而已,第二個參數 onValueChange
呢,則是鍵盤輸入的 callback,這個 callback 所回傳的值會是鍵盤輸入改變完之後完整的字串,所以我直接用這邊的 callback 來改變原輸入:text = it
。
小提示:除了 by 之外,還可以用內建的 destructure 的方式達到一樣的需求:
@Composable
fun StatefulTextField(name: String) {
val (text, onTextChanged) = remember { mutableStateOf(name)}
TextField(value = text, onValueChange = onTextChanged)
}
相較於原本的 EditText, Jetpack Compose 版本的 TextField 給了我們更大的彈性,有了它我們可以很容易的去限制打的字數、顯示 [] 當作密碼或是輸入密碼時讓 [] 延後再出現,各式各樣客製化的需求都變成了可能。在原本的 Android View 我們能怎麼做?當然只能上 stackoverflow 或是去官方文件看能不能找到相對應的 API 了,現在只要稍微動動腦,敲敲幾行程式,這些功能都很容易可以做出來。
// 只是多一行 if 判斷式就可以限制字數
@Composable
fun LimitedTextField(name: String, textLength: Int) {
var text by remember { mutableStateOf(name)}
TextField(value = text, onValueChange = {
if (it.length <= textLength) text = it
})
}
在便利貼應用程式中,其中還有一個功能是編輯便利貼的文字內容,在這裡我們將使用一個全版的頁面來做文字編輯,UI 完成之後會像下面這樣子:
超級簡單的對吧?接下來就來看看程式碼吧!請暫時先忽略 EditTextViewModel 的實作,本篇的重點都在 View 層,明天會講到 EditTextViewModel 的。
@Composable
fun EditTextScreen(
editTextViewModel: EditTextViewModel,
onLeaveScreen: () -> Unit,
) {
val text by editTextViewModel.text.subscribeAsState(initial = "") // [1] text from ViewModel
editTextViewModel.leavePage
.toMain()
.subscribeBy( onNext = { onLeaveScreen() }) // [3]
Box(modifier = Modifier
.fillMaxSize()
.background(Color.White)
.background(TransparentBlack)
) {
TextField(
value = text,
onValueChange = editTextViewModel::onTextChanged, // [2] pass lambda from ViewModel
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(fraction = 0.8f),
colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.Transparent,
textColor = Color.White
),
textStyle = MaterialTheme.typography.h5
)
// Close icon
IconButton(
modifier = Modifier.align(Alignment.TopStart),
onClick = editTextViewModel::onCancelClicked
) {
val painter = painterResource(id = R.drawable.ic_close)
Icon(painter = painter, contentDescription = "Close", tint = Color.White)
}
// Check icon
IconButton(
modifier = Modifier.align(Alignment.TopEnd),
onClick = editTextViewModel::onConfirmClicked
) {
val painter = painterResource(id = R.drawable.ic_check)
Icon(painter = painter, contentDescription = "Check", tint = Color.White)
}
}
}
跟 EditorScreen 一樣,EditTextScreen
這邊我也搭配了一個 ViewModel 給它: EditTextViewModel
。為了讓狀態可控可管,這邊文字的內容是由 EditTextViewModel
控制的,文字的內容是藉由 subscribe editTextViewModel.text
這一個 Observable 來收到最新的資料,至於鍵盤輸入的 callback ,也會再去呼叫 editTextViewModel::onTextChanged
這個函式,這個模式是不是有點眼熟呢?沒錯,我只是將剛剛 StatefulTextField 所做的事情照樣搬到 ViewModel 裡面去罷了。因此很明顯的EditTextScreen
在這裡也是一個 stateless UI 元件。
至於其他程式碼的內容,大致上都是相對容易理解的,除了 editTextViewModel.leavePage
這部分之外 [3],我在這裡寫了一個 custom extension function :subscribeBy
,主要是因為我想做的事情只能發生一次,所以不能用 State 接起來,不然會因為記住這狀態而讓同樣的事件一再發生,如果還不是很能理解的話,這裡有個 Android 開發者比較熟悉的元件可以做類比,他就是 LiveData 的 SingleLiveEvent (連結)。
所以為了達到“只發生一次”的這個需求,我必須的要用到 Jetpack Compose 的 Side effect - DisposableEffect
。DisposableEffect
會在當下的 Composable function 被回收時執行,以目前來說,這個 Composable function 就是 EditTextScreen
。我們也可以把這個 side effect 想像成是 Activity 的 onDestroy(),當EditTextScreen
結束要被回收時才會去執行。以下是 subscribeBy
的實作內容:
@Composable
fun <R, T : R> Observable<T>.subscribeBy(
onNext: (T) -> Unit = {},
onError: (Throwable) -> Unit = {},
onComplete: () -> Unit = {},
) {
DisposableEffect(this) {
val disposable = subscribe(onNext, onError, onComplete)
onDispose { disposable.dispose() }
}
}
關於 Jetpack Compose 的 side effect 想要更深入研究的可以參考官方文件:https://developer.android.com/jetpack/compose/side-effects
今天主要帶大家認識 Stateful 跟 Stateless 的概念,以一般的原則來說,是盡量要設計出 Stateless 的元件為優先,以達到最大化重用,最大化彈性的目的,但是也不能因此解讀為“Stateful 的 composable function 是不好的”,因為就是有一些情況需要用到 Stateful 元件,而且他們也扮演了很重要的角色,在我看來, Stateful 跟 Stateless 就是另外一種層面的職責分離。對於剛接觸的人來說,今天的範例也許很少沒什麼感覺,但在之後的章節中這個職責分離會再次出現的,相信到時候的範例會比這個更有說服力!