從Flutter到Compose,為什麼都在推崇宣告式UI?
theme: juejin
本文正在參加「金石計劃」
Compose推出之初,就曾引發廣泛的討論,其中一個比較普遍的聲音就是——“🤨這跟Flutter也長得太像了吧?!”
這裡說的長得像,實際更多指的是UI編碼的風格相似,而關於這種風格有一個專門的術語,叫做宣告式UI。
對於那些已經習慣了命令式UI的Android或iOS開發人員來說,剛開始確實很難理解什麼是宣告式UI。就像當初剛踏入程式設計領域的我們,同樣也很難理解面向過程程式設計和面向物件程式設計的區別一樣。
為了幫助這部分原生開發人員完成從命令式UI到宣告式UI的思維轉變,本文將結合示例程式碼編寫、動畫演示以及生活例子類比等形式,詳細介紹宣告式UI的概念、優點及其應用。
照例,先奉上思維導圖一張,方便複習:
命令式UI的特點
既然命令式UI與宣告式UI是相對的,那就讓我們先來回顧一下,在一個常規的檢視更新流程中,如果採用的是命令式UI,會是怎樣的一個操作方式。
以Android為例,首先我們都知道,Android所採用的介面佈局,是基於View與ViewGroup物件、以樹狀結構來進行構建的檢視層級。
當我們需要對某個節點的檢視進行更新時,通常需要執行以下兩個操作步驟:
- 使用findViewById()等方法遍歷樹節點以找到對應的檢視。
- 通過呼叫檢視物件公開的setter方法更新檢視的UI狀態
我們以一個最簡單的計數器應用為例:
這個應用唯一的邏輯就是“當用戶點選"+"號按鈕時數字加1”。在傳統的Android實現方式下,程式碼應該是這樣子的:
``` class CounterActivity : AppCompatActivity() {
var count: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_counter)
val countTv = findViewById<TextView>(R.id.count_tv)
countTv.text = count.toString()
val plusBtn = findViewById<Button>(R.id.plus_btn)
plusBtn.setOnClickListener {
count += 1
countTv.text = count.toString()
}
}
} ```
這段程式碼看起來沒有任何難度,也沒有明顯的問題。但是,假設我們在下一個版本中添加了更多的需求:
- 當用戶點選"+"號按鈕,數字加1的同時在下方容器中新增一個方塊。
- 當用戶點選"-"號按鈕,數字減1的同時在下方容器中移除一個方塊。
- 當數字為0時,下方容器的背景色變為透明。
現在,我們的程式碼變成了這樣:
``` class CounterActivity : AppCompatActivity() {
var count: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_counter)
// 數字
val countTv = findViewById<TextView>(R.id.count_tv)
countTv.text = count.toString()
// 方塊容器
val blockContainer = findViewById<LinearLayout>(R.id.block_container)
// "+"號按鈕
val plusBtn = findViewById<Button>(R.id.plus_btn)
plusBtn.setOnClickListener {
count += 1
countTv.text = count.toString()
// 方塊
val block = View(this).apply {
setBackgroundColor(Color.WHITE)
layoutParams = LinearLayout.LayoutParams(40.dp, 40.dp).apply {
bottomMargin = 20.dp
}
}
blockContainer.addView(block)
when {
count > 0 -> {
blockContainer.setBackgroundColor(Color.parseColor("#FF6200EE"))
}
count == 0 -> {
blockContainer.setBackgroundColor(Color.TRANSPARENT)
}
}
}
// "-"號按鈕
val minusBtn = findViewById<Button>(R.id.minus_btn)
minusBtn.setOnClickListener {
if(count <= 0) [email protected]
count -= 1
countTv.text = count.toString()
blockContainer.removeViewAt(0)
when {
count > 0 -> {
blockContainer.setBackgroundColor(Color.parseColor("#FF6200EE"))
}
count == 0 -> {
blockContainer.setBackgroundColor(Color.TRANSPARENT)
}
}
}
}
} ```
已經開始看得有點難受了吧?這正是命令式UI的特點,側重於描述怎麼做,我們需要像下達命令一樣,手動處理每一項UI的更新,如果UI的複雜度足夠高的話,就會引發一系列問題,諸如:
- 可維護性差:需要編寫大量的程式碼邏輯來處理UI變化,這會使程式碼變得臃腫、複雜、難以維護。
- 可複用性差:UI的設計與更新邏輯耦合在一起,導致只能在當前程式使用,難以複用。
- 健壯性差:UI元素之間的關聯度高,每個細微的改動都可能一系列未知的連鎖反應。
宣告式UI的特點
而同樣的功能,假如採用的是宣告式UI,則程式碼應該是這樣子的:
```
class _CounterPageState extends State
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Column( children: [ // 數字 Text( _count.toString(), style: const TextStyle(fontSize: 48), ), Row( mainAxisSize: MainAxisSize.min, children: [ // +"號按鈕 ElevatedButton( onPressed: () { setState(() { _count++; }); }, child: const Text("+")), // "-"號按鈕 ElevatedButton( onPressed: () { setState(() { if (_count == 0) return; _count--; }); }, child: const Text("-")) ], ), Expanded( // 方塊容器 child: Container( width: 60, padding: const EdgeInsets.all(10), color: _count > 0 ? const Color(0xFF6200EE) : Colors.transparent,
child: ListView.separated(
itemCount: _count,
itemBuilder: (BuildContext context, int index) {
// 方塊
return Container(width: 40, height: 40, color: Colors.white);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(color: Colors.transparent, height: 10);
},
),
))
],
),
);
} }
```
在這樣的程式碼中,我們幾乎看不到任何操作UI更新的程式碼,而這正是宣告式UI的特點,它側重於描述做什麼,而不是怎麼做,開發者只需要關注UI應該如何呈現,而不需要關心UI的具體實現過程。
開發者要做的,就只是提供不同UI與不同狀態之間的對映關係,而無需編寫如何在不同UI之間進行切換的程式碼。
所謂狀態,指的是構建使用者介面時所需要的資料,例如一個文字框要顯示的內容,一個進度條要顯示的進度等。Flutter框架允許我們僅描述當前狀態,而轉換的工作則由框架完成,當我們改變狀態時,使用者介面將自動重新構建。
下面我們將按照通常情況下,用宣告式UI實現一個Flutter應用所需要經歷的幾個步驟,來詳細解析前面計數器應用的程式碼:
- 分析應用可能存在的各種狀態
根據我們前面對於“狀態”的定義,我們可以很容易地得出,在本例中,數字(_count值)本身即為計數器應用的狀態,其中還包括數字為0時的一個特殊狀態。
- 提供每個不同狀態所對應要展示的UI
build方法是將狀態轉換為UI的方法,它可以在任何需要的時候被框架呼叫。我們通過重寫該方法來宣告UI的構造:
對於頂部的文字,只需宣告每次都使用最新返回的狀態(數字)即可:
Text(
_count.toString(),
...
),
對於方塊容器,只需聲明當_count的值為0時,容器的背景顏色為透明色,否則為特定顏色:
Container(
color: _count > 0 ? const Color(0xFF6200EE) : Colors.transparent,
...
)
對於方塊,只需宣告返回的方塊個數由_count的值決定:
ListView.separated(
itemCount: _count,
itemBuilder: (BuildContext context, int index) {
// 方塊
return Container(width: 40, height: 40, color: Colors.white);
},
...
),
- 根據使用者互動或資料查詢結果更改狀態
當由於使用者的點選數字發生變化,而我們需要重新整理頁面時,就可以呼叫setState方法。setState方法將會驅動build方法生成新的UI:
// "+"號按鈕
ElevatedButton(
onPressed: () {
setState(() {
_count++;
});
},
child: const Text("+")),
// "-"號按鈕
ElevatedButton(
onPressed: () {
setState(() {
if (_count == 0) return;
_count--;
});
},
child: const Text("-"))
],
可以結合動畫演示來回顧這整個過程:
最後,用一個公式來總結一下UI、狀態與build方法三者的關係,那就是:
以命令式和宣告式分別點一杯奶茶
現在,你能瞭解命令式UI與宣告式UI的區別了嗎?如果還是有些抽象,我們可以用一個點奶茶的例子來做個比喻:
當我們用命令式UI的思維方式去點一杯奶茶,相當於我們需要告訴製作者,衝一杯奶茶必須按照煮水、沖茶、加牛奶、加糖這幾個步驟,一步步來完成,也即我們需要明確每一個步驟,從而使得我們的想法具體而可操作。
而當我們用宣告式UI的思維方式去點一杯奶茶,則相當於我們只需要告訴製作者,我需要一杯“溫度適中、口感濃郁、有一點點甜味”的奶茶,而不必關心具體的製作步驟和操作細節。
宣告式程式設計的優點
綜合以上內容,我們可以得出宣告式UI有以下幾個優點:
-
簡化開發:開發者只需要維護狀態->UI的對映關係,而不需要關注具體的實現細節,大量的UI實現邏輯被轉移到了框架中。
-
可維護性強:通過函數語言程式設計的方式構建和組合UI元件,使程式碼更加簡潔、清晰、易懂,便於維護。
-
可複用性強:將UI的設計和實現分離開來,使得同樣的UI元件可以在不同的應用程式中使用,提高了程式碼的可複用性。
總結與展望
總而言之,宣告式UI是一種更加高層次、更加抽象的程式設計方式,其最大的優點在於能極大地簡化現有的開發模式,因此在現代應用程式中得到廣泛的應用,隨著更多框架的採用與更多開發者的加入,宣告式UI必將繼續發展壯大,成為以後構建使用者介面的首選方式。