likes
comments
collection
share

从Flutter到Compose,为什么都在推崇声明式UI?

作者站长头像
站长
· 阅读数 9

本文正在参加「金石计划」

Compose推出之初,就曾引发广泛的讨论,其中一个比较普遍的声音就是——“🤨这跟Flutter也长得太像了吧?!”

这里说的长得像,实际更多指的是UI编码的风格相似,而关于这种风格有一个专门的术语,叫做声明式UI

对于那些已经习惯了命令式UI的Android或iOS开发人员来说,刚开始确实很难理解什么是声明式UI。就像当初刚踏入编程领域的我们,同样也很难理解面向过程编程面向对象编程的区别一样。

为了帮助这部分原生开发人员完成从命令式UI到声明式UI的思维转变,本文将结合示例代码编写、动画演示以及生活例子类比等形式,详细介绍声明式UI的概念、优点及其应用。

照例,先奉上思维导图一张,方便复习:

从Flutter到Compose,为什么都在推崇声明式UI?


命令式UI的特点

既然命令式UI与声明式UI是相对的,那就让我们先来回顾一下,在一个常规的视图更新流程中,如果采用的是命令式UI,会是怎样的一个操作方式。

以Android为例,首先我们都知道,Android所采用的界面布局,是基于View与ViewGroup对象、以树状结构来进行构建的视图层级。

从Flutter到Compose,为什么都在推崇声明式UI?

当我们需要对某个节点的视图进行更新时,通常需要执行以下两个操作步骤:

  1. 使用findViewById()等方法遍历树节点以找到对应的视图。
  2. 通过调用视图对象公开的setter方法更新视图的UI状态

我们以一个最简单的计数器应用为例:

从Flutter到Compose,为什么都在推崇声明式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()
        }

    }
}

这段代码看起来没有任何难度,也没有明显的问题。但是,假设我们在下一个版本中添加了更多的需求:

从Flutter到Compose,为什么都在推崇声明式UI?

  • 当用户点击"+"号按钮,数字加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) return@setOnClickListener
            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<CounterPage> {
  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("-"))
],

可以结合动画演示来回顾这整个过程:

从Flutter到Compose,为什么都在推崇声明式UI?

最后,用一个公式来总结一下UI、状态与build方法三者的关系,那就是:

从Flutter到Compose,为什么都在推崇声明式UI?

以命令式和声明式分别点一杯奶茶

现在,你能了解命令式UI与声明式UI的区别了吗?如果还是有些抽象,我们可以用一个点奶茶的例子来做个比喻:

当我们用命令式UI的思维方式去点一杯奶茶,相当于我们需要告诉制作者,冲一杯奶茶必须按照煮水、冲茶、加牛奶、加糖这几个步骤,一步步来完成,也即我们需要明确每一个步骤,从而使得我们的想法具体而可操作。

而当我们用声明式UI的思维方式去点一杯奶茶,则相当于我们只需要告诉制作者,我需要一杯“温度适中、口感浓郁、有一点点甜味”的奶茶,而不必关心具体的制作步骤和操作细节。

声明式编程的优点

综合以上内容,我们可以得出声明式UI有以下几个优点:

  • 简化开发:开发者只需要维护状态->UI的映射关系,而不需要关注具体的实现细节,大量的UI实现逻辑被转移到了框架中。

  • 可维护性强:通过函数式编程的方式构建和组合UI组件,使代码更加简洁、清晰、易懂,便于维护。

  • 可复用性强:将UI的设计和实现分离开来,使得同样的UI组件可以在不同的应用程序中使用,提高了代码的可复用性。

总结与展望

总而言之,声明式UI是一种更加高层次、更加抽象的编程方式,其最大的优点在于能极大地简化现有的开发模式,因此在现代应用程序中得到广泛的应用,随着更多框架的采用与更多开发者的加入,声明式UI必将继续发展壮大,成为以后构建用户界面的首选方式。

少侠,请留步!若本文对你有所帮助或启发,还请:

  1. 点赞👍🏻,让更多的人能看到!
  2. 收藏⭐️,好文值得反复品味!
  3. 关注➕,不错过每一次更文!

===> 技术号:「星际码仔」💪

你的支持是我继续创作的动力,感谢!🙏