從Flutter到Compose,為什麼都在推崇宣告式UI?

語言: CN / TW / HK

theme: juejin

本文正在參加「金石計劃」

Compose推出之初,就曾引發廣泛的討論,其中一個比較普遍的聲音就是——“🤨這跟Flutter也長得太像了吧?!”

這裡說的長得像,實際更多指的是UI編碼的風格相似,而關於這種風格有一個專門的術語,叫做宣告式UI

對於那些已經習慣了命令式UI的Android或iOS開發人員來說,剛開始確實很難理解什麼是宣告式UI。就像當初剛踏入程式設計領域的我們,同樣也很難理解面向過程程式設計面向物件程式設計的區別一樣。

為了幫助這部分原生開發人員完成從命令式UI到宣告式UI的思維轉變,本文將結合示例程式碼編寫、動畫演示以及生活例子類比等形式,詳細介紹宣告式UI的概念、優點及其應用。

照例,先奉上思維導圖一張,方便複習:


命令式UI的特點

既然命令式UI與宣告式UI是相對的,那就讓我們先來回顧一下,在一個常規的檢視更新流程中,如果採用的是命令式UI,會是怎樣的一個操作方式。

以Android為例,首先我們都知道,Android所採用的介面佈局,是基於View與ViewGroup物件、以樹狀結構來進行構建的檢視層級。

當我們需要對某個節點的檢視進行更新時,通常需要執行以下兩個操作步驟:

  1. 使用findViewById()等方法遍歷樹節點以找到對應的檢視。
  2. 通過呼叫檢視物件公開的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 { int _count = 0;

@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應用所需要經歷的幾個步驟,來詳細解析前面計數器應用的程式碼:

  1. 分析應用可能存在的各種狀態

根據我們前面對於“狀態”的定義,我們可以很容易地得出,在本例中,數字(_count值)本身即為計數器應用的狀態,其中還包括數字為0時的一個特殊狀態。

  1. 提供每個不同狀態所對應要展示的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); }, ... ),

  1. 根據使用者互動或資料查詢結果更改狀態

當由於使用者的點選數字發生變化,而我們需要重新整理頁面時,就可以呼叫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必將繼續發展壯大,成為以後構建使用者介面的首選方式。