Skip to content

Flet 声明式重构迁移指南:以简单 CRUD “用户管理器” 为例 ​


如果你一直在使用 Flet,你可能一直以命令式(Imperative)的方式构建应用,甚至可能自己都没有意识到。比如你切换控件的 visible 可见性标志、设置控件的值、动态更新子控件列表,并调用 page.update() —— 这就是命令式方法,意味着你在处理事件回调时直接操作并修改了 UI 控件。

现在,Flet 已经支持声明式(Declarative)编程风格:你无需再直接突变(mutate)各个控件的属性,而是改变数据状态,Flet 会自动且精准地更新 UI。

我们将通过一个微型的 CRUD “用户管理器(User Manager)” 应用来展示这种转变。首先是命令式版本:UI 优先,手动修改控件,然后刷新页面;接着是声明式重构版本:模型优先,使用被观察(observable)的数据类,并通过组件基于状态数据返回 UI。

在这两个示例中,应用的行为完全一致 —— 你可以查看用户列表、行内添加用户、通过行内的保存/取消按钮编辑以及删除用户。以下是该简单应用在这两个版本中的界面外观:

界面列表展示:CRUD 列表

点击行内“编辑”按钮后:CRUD 行内编辑


示例 1 —— 命令式 (Imperative) ​

在命令式版本中,你遵循的是 UI 优先 的思维:决定屏幕的精确外观,以及每次点击按钮时它应该如何改变。事件处理程序直接切换控件的属性(如 visible、value),添加/移除控件,最后调用 page.update() 将这些视觉更改推送到屏幕上。

编辑(Edit)操作会隐藏只读的文本标签和原本的操作按钮,显示输入框和 保存/取消 按钮;保存(Save)操作将输入框中的新值复制回只读标签,并恢复原始视图;取消(Cancel)操作只是简单地恢复原始视图;删除(Delete)操作则从页面容器中直接删除整行控件。

简而言之,业务行为是通过直接操作控件并手动触发重新渲染来实现的,而不是通过演进一个独立的状态模型:

python
import flet as ft

class Item(ft.Row):
    def __init__(self, first_name, last_name):
        super().__init__()

        self.first_name_field = ft.TextField(first_name)
        self.last_name_field = ft.TextField(last_name)
        self.text = ft.Text(f"{first_name} {last_name}")
        self.edit_text = ft.Row(
            [
                self.first_name_field,
                self.last_name_field,
            ],
            visible=False,
        )
        self.edit_button = ft.Button("Edit", on_click=self.edit_item)
        self.delete_button = ft.Button("Delete", on_click=self.delete_item)
        self.save_button = ft.Button("Save", on_click=self.save_item, visible=False)
        self.cancel_button = ft.Button(
            "Cancel", on_click=self.cancel_item, visible=False
        )
        self.controls = [
            self.text,
            self.edit_text,
            self.edit_button,
            self.delete_button,
            self.save_button,
            self.cancel_button,
        ]

    def delete_item(self, e):
        self.page.controls.remove(self)
        self.page.update()

    def edit_item(self, e):
        print("edit_item")
        self.text.visible = False
        self.edit_button.visible = False
        self.delete_button.visible = False
        self.save_button.visible = True
        self.cancel_button.visible = True
        self.edit_text.visible = True
        self.page.update()

    def save_item(self, e):
        self.text.value = f"{self.first_name_field.value} {self.last_name_field.value}"
        self.text.visible = True
        self.edit_button.visible = True
        self.delete_button.visible = True
        self.save_button.visible = False
        self.cancel_button.visible = False
        self.edit_text.visible = False
        self.page.update()

    def cancel_item(self, e):
        self.text.visible = True
        self.edit_button.visible = True
        self.delete_button.visible = True
        self.save_button.visible = False
        self.cancel_button.visible = False
        self.edit_text.visible = False
        self.page.update()

def main(page: ft.Page):
    page.title = "CRUD Imperative Example"

    def add_item(e):
        item = Item(first_name.value, last_name=last_name.value)
        page.add(item)
        first_name.value = ""
        last_name.value = ""
        page.update()

    first_name = ft.TextField(label="First Name", width=200)
    last_name = ft.TextField(label="Last Name", width=200)

    page.add(
        ft.Row(
            [
                first_name,
                last_name,
                ft.Button("Add", on_click=add_item),
            ]
        )
    )

ft.run(main)

示例 2 —— 声明式 (Declarative) ​

在声明式版本中,你遵循的是 模型优先 的思维:将模型视为一组类,其对象中保存的数据是唯一的信任源(Single source of truth)。

在我们的 CRUD 应用中,数据模型由 User 类(包含持久化字段 first_name、last_name)以及一个包含用户列表的顶层 App 类组成,并在 App 类中定义了 add_user(first, last) 和 delete_user(user) 操作逻辑。这两个类都标记了 @ft.observable 装饰器。因此,当为其属性赋值时(例如 user.update(...) 或 app.users.remove(user)),会自动触发重新渲染 —— 整个过程无需再手动调用 page.update()。

UI 界面由标记了 @ft.component 的组件构成,它们直接根据当前的状态数据返回控件树快照。每一行利用自身轻量、短暂的局部值(即 Hook 状态)来决定渲染只读文本还是行内编辑器,而持久的业务数据则留存在模型对象上。事件处理器仅去更新状态数据(例如修改用户数据或添加/移除列表条目),而不是直接操作控件;Flet 会自动检测这些数据的改变并智能重新渲染对应组件。

简而言之:UI = f(state)(UI 是状态的投影函数),User 和 App 类提供了最底层的权威数据:

声明式组件关系

python
from dataclasses import dataclass, field
import flet as ft

@ft.observable
@dataclass
class User:
    first_name: str
    last_name: str

    def update(self, first_name: str, last_name: str):
        self.first_name = first_name
        self.last_name = last_name

@ft.observable
@dataclass
class App:
    users: list[User] = field(default_factory=list)

    def add_user(self, first_name: str, last_name: str):
        if first_name.strip() or last_name.strip():
            self.users.append(User(first_name, last_name))

    def delete_user(self, user: User):
        self.users.remove(user)

@ft.component
def UserView(user: User, delete_user) -> ft.Control:
    # 局部(瞬时)编辑状态 —— 不存储在 User 类中
    is_editing, set_is_editing = ft.use_state(False)
    new_first_name, set_new_first_name = ft.use_state(user.first_name)
    new_last_name, set_new_last_name = ft.use_state(user.last_name)

    def start_edit():
        set_new_first_name(user.first_name)
        set_new_last_name(user.last_name)
        set_is_editing(True)

    def save():
        user.update(new_first_name, new_last_name)
        set_is_editing(False)

    def cancel():
        set_is_editing(False)

    if not is_editing:
        return ft.Row(
            [
                ft.Text(f"{user.first_name} {user.last_name}"),
                ft.Button("Edit", on_click=start_edit),
                ft.Button("Delete", on_click=lambda: delete_user(user)),
            ]
        )

    return ft.Row(
        [
            ft.TextField(
                label="First Name",
                value=new_first_name,
                on_change=lambda e: set_new_first_name(e.control.value),
                width=180,
            ),
            ft.TextField(
                label="Last Name",
                value=new_last_name,
                on_change=lambda e: set_new_last_name(e.control.value),
                width=180,
            ),
            ft.Button("Save", on_click=save),
            ft.Button("Cancel", on_click=cancel),
        ]
    )

@ft.component
def AddUserForm(add_user) -> ft.Control:
    # 使用局部缓冲变量,点击 Add 时触发父类动作
    new_first_name, set_new_first_name = ft.use_state("")
    new_last_name, set_new_last_name = ft.use_state("")

    def add_user_and_clear():
        add_user(new_first_name, new_last_name)
        set_new_first_name("")
        set_new_last_name("")

    return ft.Row(
        controls=[
            ft.TextField(
                label="First Name",
                width=200,
                value=new_first_name,
                on_change=lambda e: set_new_first_name(e.control.value),
            ),
            ft.TextField(
                label="Last Name",
                width=200,
                value=new_last_name,
                on_change=lambda e: set_new_last_name(e.control.value),
            ),
            ft.Button("Add", on_click=add_user_and_clear),
        ]
    )

@ft.component
def AppView() -> list[ft.Control]:
    app, _ = ft.use_state(
        App(
            users=[
                User("John", "Doe"),
                User("Jane", "Doe"),
                User("Foo", "Bar"),
            ]
        )
    )

    return [
        AddUserForm(app.add_user),
        *[UserView(user, app.delete_user) for user in app.users],
    ]

ft.run(lambda page: page.render(AppView))

思维转变:UI = f(state) ​

核心思想在于确定性:在相同的状态(state)下,你的组件总是应当返回相同的 UI 树结构。整个开发流程被划分为两个独立的阶段:

1. 处理事件 → 更新状态 ​

事件处理程序仅修改核心状态数据(例如调用 set_is_editing(True),修改 user.update(...))。它们在后台直接修改内存数据,而不会直接去控制控件的显隐属性或手动触发调用 page.update()。

2. 渲染 → 根据状态推导 UI ​

组件接收当前的数据状态快照并生成对应的控件结构。由于模型数据类使用了 @ft.observable,而组件内部状态使用了 ft.use_state,每当数据被修改时,Flet 便会自动重新运行相应的组件,并将修改后的部分重新渲染在屏幕上。


声明式核心基石:被观察对象 (Observables)、组件 (Components) 与 Hook (Hooks) ​

以下是支持 Flet 声明式编程工作的关键要素:

1. 被观察对象 (Observables) —— 你的数据信任源 ​

@ft.observable 装饰器将一个 Python 数据类(dataclass)声明为响应式。当你修改它的任何属性(例如 user.first_name = "Ada" 或 app.users.append(user))时,Flet 会自动重新渲染所有读取了该字段的组件,而无需手动执行 page.update() 调用。请将 Observables 用于持久化保存的核心领域数据(如存储在数据库或应用全局的数据)。

2. 组件 (Components) —— 返回 UI 的函数 ​

@ft.component 装饰器将一个普通函数转换成一个独立的渲染单元。它接收传入的 props(普通参数),可以使用 Hook 状态,最后返回描述当前状态下 UI 样貌的控件树。组件绝不会命令式地直接去修改页面(page)中的控件树,它们只需返回当前 UI 看起来应当是什么样子。

3. Hook —— 局部的、短暂的 UI 状态 ​

为什么需要 Hook:组件本质上是在每次重新渲染时都会被重新执行调用的普通函数。普通的局部变量在每次重新执行时都会被初始化回默认值,并且修改普通变量也不会向 Flet 传递更新渲染的通知。而 Hook(如 ft.use_state)能够为组件函数在多次重新渲染的周期之间提供持久存储数据状态的能力,并在状态值发生变化时通知 Flet 重新渲染。

Hook 解决的问题:

  • 状态持久化:普通局部变量在重新渲染时重置,而 Hook 状态能够在多次渲染周期中存活。
  • 状态响应性:直接修改局部变量不会刷新 UI,而调用 Hook 的 setter 更改函数会自动通知 Flet 触发重新渲染。
  • 避免回调中闭包的过期状态:事件回调函数中能实时读取到 Hook 的最新更改,而不会产生闭包数据滞后的问题。

请将 Hook 用于只属于单个组件内部的、短暂的、仅与视图呈现相关的局部状态(例如“是否正在编辑?”标志或当前输入框中的临时缓冲文本)。将 Observables 用于需要在多个组件之间共享的持久化核心业务数据。

错误对比示例: ​
python
# 错误方式:普通局部变量在渲染时重置,并且修改不会触发更新
@ft.component
def CounterBroken():
    count = 0
    return ft.Row([
        ft.Text(str(count)),
        ft.Button("+", on_click=lambda _: (count := count + 1)),  # 点击无渲染刷新效果
    ])

# 正确方式:Hook 状态持久存在,并且在值更新后自动触发组件重构渲染
@ft.component
def Counter():
    count, set_count = ft.use_state(0)
    return ft.Row([
        ft.Text(str(count)),
        ft.Button("+", on_click=lambda _: set_count(count + 1)),
    ])

黄金法则:如果一个数据值必须在多次重新渲染中存活,并且它的更新应当同步改变 UI 呈现,切勿使用普通的局部变量 —— 局部视图状态请使用 Hook 状态(use_state),共享或持久的业务数据请使用被观察对象(@ft.observable)。


重构秘籍 (命令式 → 声明式) ​

1) 可见性切换 → 条件渲染 ​
  • 命令式:
    python
    self.text.visible = False
    self.save_button.visible = True
    self.page.update()
  • 声明式:
    python
    return (
        ft.Row([...只读控件...])
        if not is_editing
        else ft.Row([...编辑控件...])
    )
2) 直接修改控件属性 → 修改数据模型 ​
  • 命令式:
    python
    self.text.value = f"{first} {last}"
  • 声明式:
    python
    user.update(new_first_name, new_last_name)
3) 随处可见的 page.update() → 彻底消失 ​

命令式回调函数最终总是以调用 page.update() 手动刷新结束。 声明式代码只需修改 observable 属性或 use_state 状态值,剩下的更新流程由 Flet 在后台自动接管完成。

4) 回调处理器只操作数据状态,而不是控件本身 ​
  • 声明式写法:
    python
    set_is_editing(True)
    set_new_first_name(user.first_name)
5) 将 UI 解耦抽取为独立的 Component 组件 ​
  • UserView = 负责管理单行用户视图(只读或编辑模式切换)的组件。
  • AddUserForm = 独立的、可复用的新用户添加表单组件。

总结 ​

声明式编程风格让你的 UI 界面成为核心状态数据的一种直接的投影函数。在非常简单的原型应用中,两者的差异可能不算太大;但随着屏幕交互逻辑的日益庞杂,你可以非常自然地在对应层级添加局部状态(Hook)和业务数据,而不用担心控件突变的逻辑零散分布在各处。

最终,你将获得更容易理解、更容易维护、更容易扩展的干净代码 —— 再也无需疲于奔命地去排查繁琐的可见性(visible)标志和手动的页面更新(update)了。


关注我,学习大模型、大数据、Python等热门技术。