Skip to content

从 Flet 0.28 迁移到 1.0 ​

Flet 1.0 并不是 0.28.x 的就地替代(drop-in replacement)。本指南按「最快让应用跑起来」的顺序,梳理一个已有的 0.28 应用需要做的改动。

为什么跨度这么大 ​

并没有 0.29 到 0.69。1.0 这条线从 0.28.3 分叉而出,并以预发布系列的形式公开开发,因此横跨四个开发版本的变更会一次性到来:

版本名称
0.28.3最后一个 0.x 版本(也称 v0)
0.70.01.0 Alpha —— 全新架构、控件改为 dataclass、声明式 UI
0.80.01.0 Beta —— 取代 0.28.3 成为 PyPI 上的当前版本
0.86.0打包与存储重构、进程内 dart-bridge
1.0.0最终版本

兼容性策略(至少三个次版本的废弃周期)从 1.0 起生效,但不涵盖这次大跳转:预发布阶段加入的废弃兼容垫片(shim)在 1.0 中已全部移除。

按此顺序迁移

  1. 先让它跑起来 —— 依赖、入口、重命名的 API、服务。
  2. 然后修复响应性 —— 单线程模型。这一步最容易被跳过,也正因为如此,一个本应能正常工作的迁移后应用会感觉卡顿。
  3. 现代化改造按需进行 —— components、hooks 与声明式 router 都是可选的,你的命令式代码依旧可用。

从第三步开始做起,是最常见的卡住方式。

第 1 步:更新依赖 ​

bash
pip install 'flet[all]' --upgrade

扩展包现在与 flet 严格同步版本,因此要一起升级:

bash
pip install --upgrade flet flet-audio flet-video

如果你的应用用过图表,它们已迁移到独立的包:

bash
pip install flet-charts

第 2 步:更新应用入口 ​

ft.app() 与 ft.app_async() 已移除,参数 target 现在改名为 main:

python
# 0.28
ft.app(target=main)
ft.app(target=main, view=ft.AppView.WEB_BROWSER)
python
# 1.0
ft.run(main)
ft.run(main, view=ft.AppView.WEB_BROWSER)

当你的 Flet 应用是从已有的异步代码启动时使用 ft.run_async()。

TypeError: 'module' object is not callable

这是残留的 ft.app(...) 调用会报出的错误。flet.app 现在是一个定义了 run() 的模块,所以这个名字仍然能解析 —— 只是它不再是一个函数了。

main 可以是同步 def,也可以是 async def —— 两者都支持。还新增了一个 before_main 参数,用于那些「页面已存在、但 main 运行之前」必须完成的初始化。

第 3 步:你的应用现在运行在单线程上 ​

这是 1.0 中最重要的行为变更,也最有可能让一个本应正常的迁移后应用显得「坏了」。

发生了什么变化 ​

在 0.28 中,Flet 会把每一个同步事件处理器派发到线程池:

python
# 0.28 内部的行为
if asyncio.iscoroutinefunction(handler):
    await handler(ce)
else:
    self.run_thread(handler, ce)   # <- 一个池线程

所以处理器里的阻塞代码永远不会冻结 UI:它占用一个池线程,而应用的其余部分照常运行。

在 1.0 中,同步处理器会直接在应用的事件循环上调用:

python
# 1.0 内部的行为
if get_param_count(event_handler) == 0:
    event_handler()
else:
    event_handler(e)

每个应用只有一个事件循环,而向客户端发送 UI 更新用的也是同一个循环。当你的处理器运行时,其他一切都会停下:不再派发任何事件,更新也无法到达屏幕。同步的 main() 出于同样的原因被内联执行。

现象 ​

升级后如果出现以下任何情况,原因都在这一步:

  • 在某一操作运行期间窗口停止重绘
  • 你设置的进度环或旋转图标始终不出现
  • 所有更新都在工作结束后「一次性」出现
  • 在操完完成前点击其他控件毫无反应
  • 在桌面端,操作系统把窗口标记为「未响应」

必须全部改成异步吗? ​

不必。同步处理器依然可用,并且对于「设置属性」「追加控件」这类快速、非阻塞的工作来说是正确的选择。只有当你有需要 await 的东西时,才需要 async def。

在 1.0 中处理器也可以完全不带参数,这在你不需要事件对象时很方便:

python
def handle_click():
    counter.value += 1

ft.Button(content="Add", on_click=handle_click)

该怎么做 ​

工作0.281.0
延时time.sleep(1)await asyncio.sleep(1)
HTTP 请求requests.get(url)异步客户端,或卸载到线程
数据库查询sqlite3、psycopg2异步驱动,或卸载到线程
读取大文件open(path).read()aiofiles,或卸载到线程
CPU 密集型工作在池线程上的同步处理器内卸载到线程(见下)
后台循环threading.Threadpage.run_task()
调用子进程subprocess.run()asyncio.create_subprocess_exec()

切换到异步库 ​

大多数流行的阻塞库都有异步对应物:

阻塞异步
requestshttpx、aiohttp
sqlite3aiosqlite
psycopg2asyncpg、psycopg 3 异步版
pymongomotor
redisredis.asyncio
boto3aioboto3
paramikoasyncssh
open()aiofiles
subprocessasyncio 子进程

一次阻塞的 HTTP 调用会变成:

python
# 0.28 - 没问题,因为它运行在池线程上
def load_data(e):
    response = requests.get("https://api.example.com/items")
    items.value = response.text
    page.update()
python
# 1.0 - 异步库
async def load_data(e):
    async with httpx.AsyncClient() as client:
        response = await client.get("https://api.example.com/items")
    items.value = response.text

没有异步版本时 ​

把阻塞调用卸载到线程。按优先级:

1. asyncio.to_thread() —— 当你需要拿回结果时。这是默认答案:

python
async def load_data(e):
    response = await asyncio.to_thread(requests.get, "https://api.example.com/items")
    items.value = response.text

2. page.run_thread() —— 在 Flet 共享线程池上「即发即忘」地执行。page.run_thread() 还会在回调内部重新建立页面上下文,并且在 web 上能优雅降级(改为内联执行而不是失败):

python
def save_log(e):
    page.run_thread(write_log_file, "clicked")

3. page.loop.run_in_executor() —— 当你想要自己的线程池时,例如限制同时运行的任务数量:

python
pool = ThreadPoolExecutor(max_workers=2)

async def convert(e):
    await page.loop.run_in_executor(pool, transcode, path)

CPU 密集型工作 ​

对于纯 Python 代码,线程并不能带来真正的并行,因为 GIL 同一时刻只允许一个线程执行 Python 字节码。线程确实能让 UI 保持响应 —— 解释器在线程间切换得足够频繁,循环得以继续运行 —— 但工作本身并不会变快,而且它会与循环争抢解释器。

对于真正需要 CPU 的工作:

  • Subinterpreters(Python 3.14+)在一个进程内用多个核心运行 Python。这也是在 iOS 和 Android 上可行的选项。
  • Multiprocessing 提供完全隔离,并允许你硬性取消一个 worker,但仅限桌面端 —— 移动操作系统不允许应用派生子进程。
  • 释放 GIL 的 C 扩展(NumPy、Pillow、数据库驱动)在普通线程中能正常并行。

从耗时工作中上报进度 ​

在 0.28 中,你会在运行于池线程的循环内调用 page.update()。这不再有效,因为循环永远没机会把更新发出去:

python
# 1.0 - 已坏:UI 冻结,进度条只在到 100% 时才出现
def process(e):
    for i in range(100):
        do_chunk(i)
        progress.value = (i + 1) / 100
        progress.update()      # 已排队,但在我们返回之前什么都不会发出

如果每个分块很短,把处理器写成一个生成器。每一次 yield 都会刷出待发的更新,并让循环喘口气:

python
def process(e):
    for i in range(100):
        do_chunk(i)
        progress.value = (i + 1) / 100
        yield                  # 更新在此处到达屏幕

这是能生效的最小改动,但工作仍然运行在事件循环上,所以每个分块要控制在几毫秒以内。

如果分块很重,把分块卸载到线程,并在分块之间 yield 以触发中间更新。单独的 await 并不会触发自动更新:

python
async def process(e):
    for i in range(100):
        await asyncio.to_thread(do_chunk, i)
        progress.value = (i + 1) / 100
        yield

如果你需要真正的并行,把整个任务交给一个 executor,并在 future 完成时上报进度:

python
async def process(e):
    done = 0
    with InterpreterPoolExecutor() as pool:
        futures = [page.loop.run_in_executor(pool, do_chunk, i) for i in range(100)]
        for future in asyncio.as_completed(futures):
            await future
            done += 1
            progress.value = done / 100
            progress.update()

do_chunk 必须定义在模块顶层,这样才能生效 —— 规则见 Subinterpreters。

后台任务与定时器 ​

用 page.run_task() 替代后台线程:

python
# 0.28
def main(page: ft.Page):
    def clock():
        while True:
            time_text.value = datetime.now().strftime("%H:%M:%S")
            page.update()
            time.sleep(1)

    threading.Thread(target=clock, daemon=True).start()
python
# 1.0
def main(page: ft.Page):
    async def clock():
        while True:
            time_text.value = datetime.now().strftime("%H:%M:%S")
            time_text.update()
            await asyncio.sleep(1)

    page.add(time_text)
    page.run_task(clock)

传入协程函数,而不是已调用的协程:page.run_task(clock),不要写成 page.run_task(clock())。否则会抛出 TypeError。

run_task() 返回一个 future,因此一个启动后台工作的控件可以在退出时再次把它停掉:

python
@ft.control
class Clock(ft.Text):
    def did_mount(self):
        self._task = self.page.run_task(self.tick)

    def will_unmount(self):
        self._task.cancel()

    async def tick(self):
        while True:
            self.value = datetime.now().strftime("%H:%M:%S")
            self.update()
            await asyncio.sleep(1)

平台差异 ​

浏览器中没有线程

作为静态网站发布的应用在浏览器中运行于 Pyodide —— 一个单线程的 WebAssembly 运行时。asyncio.to_thread() 在那里不可用,page.run_thread() 会内联执行其回调,subinterpreters 与 multiprocessing 也均不可用。

对于静态 web 目标,处理长任务的唯一选项是:分块并 yield、使用真正的异步库,或把工作转移到服务端。而动态网站在你的 Python 服务端作为普通 CPython 进程运行,因此线程与桌面端一样可用。

关于并发模型(包括该用哪种卸载原语)的更完整论述,见 Async apps。

PubSub 行为不同

同步的 PubSub 订阅者仍然运行在线程池上,而不是事件循环上,因此一个阻塞的订阅者不会冻结 UI。真正改变的是事件处理器。

第 4 步:服务不再添加到页面 ​

非可视的功能 —— 文件选择器、共享偏好、剪贴板、传感器 —— 在 1.0 中是 服务(services)。你创建一个并使用它,无需把任何东西添加到页面:

python
# 0.28
def main(page: ft.Page):
    file_picker = ft.FilePicker(on_result=handle_result)
    page.overlay.append(file_picker)
    page.update()

    page.add(ft.ElevatedButton("Pick files", on_click=lambda _: file_picker.pick_files()))
python
# 1.0
def main(page: ft.Page):
    file_picker = ft.FilePicker()
    selection = ft.Text()

    async def pick_files(e):
        files = await file_picker.pick_files(allow_multiple=True)
        if files:
            selection.value = ", ".join(f.name for f in files)

    page.add(selection, ft.Button(content="Pick files", on_click=pick_files))

两件事同时变了:服务在你构造它时就自行注册,而对话框方法变成了可 await 的,并直接返回结果 —— 不再需要去接 on_result 的回路。

如果你是按 0.8x 文档做的

预发布系列要求 page.services.append(service)。为了显式的生命周期控制,这仍然可用,但已不再必要。

复用服务时保留引用

当你需要复用某个服务时,把它保存在一个控件字段或被事件处理器捕获的变量里。Flet 会注销那些不再有存活 Python 引用的服务。

对于一次被 await 的临时调用(例如 await ft.SharedPreferences().get("key")),临时服务是没问题的:这次调用会让服务一直存活到结束。

这些页面成员已被服务取代,并在 1.0 中移除:

0.281.0
page.client_storage.set(k, v)await ft.SharedPreferences().set(k, v)
page.set_clipboard(v)await ft.Clipboard().set(v)
page.get_clipboard()await ft.Clipboard().get()
page.launch_url(url)await ft.UrlLauncher().launch_url(url)
page.can_launch_url(url)await ft.UrlLauncher().can_launch_url(url)
page.close_in_app_web_view()await ft.UrlLauncher().close_in_app_web_view()
page.browser_context_menuft.BrowserContextMenu()
page.storage_pathsft.StoragePaths()

所有服务方法都是 async,因此都要 await:

python
prefs = ft.SharedPreferences()

async def save(e):
    await prefs.set("theme", "dark")
    value = await prefs.get("theme")

这些控件也变成了服务,不再添加到 page.overlay:Audio、AudioRecorder、Flashlight、Geolocator、HapticFeedback、InterstitialAd、PermissionHandler、SemanticsService、ShakeDetector。

在 web 应用中打开选择器

浏览器只会在处理一次点击的过程中打开文件选择器,而到你的处理器运行时,这个时机已经过去了。在 web 上,请改用 client action。参见 FilePicker。

第 5 步:更新是自动的 ​

Flet 会在每个事件处理器之后、以及 main() 返回之后自动为你调用 update(),因此一个 0.28 应用中大多数的 page.update() 调用现在都是多余的:

python
# 0.28
def add_item(e):
    page.controls.append(ft.Text("New item"))
    page.update()
python
# 1.0 - 更新自行发生
def add_item(e):
    page.controls.append(ft.Text("New item"))

已有的调用是无害的:显式的 update() 会抑制自动的那次,因此无论哪种方式你都只得到一次更新。你可以逐步把它们迁走。

三个相关变更:

  • 处理器内的 yield 会在处理器中途刷出一次更新。这取代了在循环内调用 page.update()。
  • ft.context.disable_auto_update() 会为当前处理器关闭自动更新,适用于你想自己把大量变更合并成一次更新时。
  • control.update() 现在会在控件尚未加入页面时抛出异常。在 0.28 中它只是静默地什么都不做。应通过构造函数的参数来设置初始值,而不是在构造期间调用 update()。

详见 Auto-update。

第 6 步:UserControl 已移除 ​

控件在 1.0 中是 dataclass,ft.UserControl 已不存在。一个自定义控件继承自它所构建自的那个控件:

python
# 0.28
class Task(ft.UserControl):
    def __init__(self, text):
        super().__init__()
        self.text = text

    def build(self):
        return ft.Row([ft.Checkbox(), ft.Text(self.text)])
python
# 1.0
@ft.control
class Task(ft.Row):
    text: str = ""

    def init(self):
        self.controls = [ft.Checkbox(), ft.Text(self.text)]

移植时需要注意:

  • 字段必须有类型注解才算字段。text: str = "" 可行;text = "" 不行。如果你不确定类型,Any 也可以。
  • 对于可变的或类级别的默认值,使用 field(default_factory=...);对于 int、str、bool 使用普通字面量。
  • __init__ 变为 init(),并且你不需要调用 super().__init__()。
  • build() 作为定义子控件的钩子仍然存在,did_mount() / will_unmount() 不变。
  • 不要赋值 self.page —— page 是每个控件上的只读属性。

完整的规则见 Custom controls。

第 7 步:重命名与移动的 API ​

控件与基类 ​

0.281.0
ft.ElevatedButton(...)ft.Button(...)
ft.Button(text="Save"),以及所有按钮ft.Button(content="Save")
ft.ConstrainedControlft.LayoutControl
ft.UserControl继承某个控件 —— 见第 6 步
图表控件flet-charts 包

常量与辅助函数 ​

小写的模块别名已移除;请通过首字母大写的类来取用常量。

0.281.0
ft.colors.REDft.Colors.RED
ft.icons.ADDft.Icons.ADD
ft.alignment.centerft.Alignment.CENTER
ft.animation.Animation(...)ft.Animation(...)
ft.padding.all(10)ft.Padding.all(10)
ft.padding.symmetric(0, 10)ft.Padding.symmetric(vertical=0, horizontal=10)
ft.margin.all(5)ft.Margin.all(5)
ft.border.all(1)ft.Border.all(1)
ft.border_radius.all(8)ft.BorderRadius.all(8)
ft.Colors.BLACK54ft.Colors.BLACK_54

模块级的 all() / symmetric() / only() 辅助函数变成了 Padding、Margin、Border 和 BorderRadius 上的类方法,且 symmetric() 只接受关键字参数。

对话框、抽屉与导航 ​

0.281.0
page.open(dialog)page.show_dialog(dialog)
page.close(dialog)page.pop_dialog()
page.dialog = d; d.open = Truepage.show_dialog(d)
page.go(route)await page.push_route(route)
page.on_resizedpage.on_resize

抽屉仍然挂在页面上,但用方法来打开:

python
# 1.0
page.drawer = ft.NavigationDrawer(controls=[...])

async def open_drawer(e):
    await page.show_drawer()       # 或 page.close_drawer()

page.end_drawer 同理,对应 show_end_drawer() 与 close_end_drawer()。

事件 ​

  • e.target 现在是一个 int,而不是字符串。
  • 处理器可以接收零个或一个参数。
  • DragTarget.on_will_accept 收到的是 DragWillAcceptEvent —— 用 e.accept 而不是 e.data。
  • DragTarget.on_leave 收到的是 DragTargetLeaveEvent —— 用 e.src_id 而不是 e.data。
  • 带类型的事件写作 ft.Event[ft.Button]。

属性重命名 ​

0.281.0
Icon.nameIcon.icon
Card.colorCard.bgcolor
Card.is_semantic_containerCard.semantic_container
Checkbox.is_errorCheckbox.error
Chip.click_elevationChip.elevation_on_click
Switch.label_styleSwitch.label_text_style
Tabs.is_secondaryTabBar.secondary —— 见下
Tab.text / Tab.tab_contentTab.label
Badge.textBadge.label
Markdown.img_error_contentMarkdown.image_error_content
BoxDecoration.shadowBoxDecoration.shadows
canvas.Text.textcanvas.Text.value
NavigationRailDestination.label_contentNavigationRailDestination.label
Pagelet.bottom_app_barPagelet.bottom_appbar
on_scroll_intervalscroll_interval
scroll_to(key=...)scroll_to(scroll_key=...),并在控件上用 key=ft.ScrollKey(v)
SegmentedButton(selected={"1"})SegmentedButton(selected=["1"]) —— 是列表而非集合
CupertinoDialogAction.is_default_action.default
CupertinoDialogAction.is_destructive_action.destructive

同样的 is_default_action / is_destructive_action 重命名也适用于 CupertinoActionSheetAction 与 CupertinoContextMenuAction。

SafeArea 的边含义已静默改变

在 0.28 中,SafeArea(left=False) 表示「不要给左边缘加内边距」。在 1.0 中,这些标志变成了 avoid_intrusions_left / _top / _right / _bottom,而 left、top、right、bottom 是每个控件都有的普通定位属性,用于在 Stack 内布局。

旧代码仍然能跑,但它现在是在定位控件,而不是配置安全区域。这是本指南中唯一一个会静默失败(而非抛异常)的重命名,因此请用 grep 找出它:

python
# 0.28
ft.SafeArea(content=body, left=False, right=False)

# 1.0
ft.SafeArea(content=body, avoid_intrusions_left=False, avoid_intrusions_right=False)

Tabs ​

Tabs 被拆分成了三个控件。Tabs 现在提供共享状态,用 TabBar 作为头部、用 TabBarView 作为内容:

python
# 0.28
ft.Tabs(
    selected_index=0,
    tabs=[
        ft.Tab(text="Overview", content=ft.Text("Overview content")),
        ft.Tab(text="Settings", content=ft.Text("Settings content")),
    ],
)
python
# 1.0
ft.Tabs(
    length=2,
    content=ft.Column(
        controls=[
            ft.TabBar(tabs=[ft.Tab(label="Overview"), ft.Tab(label="Settings")]),
            ft.TabBarView(
                controls=[ft.Text("Overview content"), ft.Text("Settings content")],
            ),
        ],
    ),
)

length 必须与标签页数量一致。is_secondary 移到了 TabBar.secondary。

主题 ​

Theme.primary_swatch、primary_color、primary_color_dark、primary_color_light 与 shadow_color 已移除。用 Theme.color_scheme_seed 作为种子色,用 ColorScheme.primary / ColorScheme.shadow 作为各个独立颜色。

方法 ​

方法不再带有 _async 后缀;普通名字就是异步版本。Dropdown.on_change 现在在可编辑模式下输入文本时也会触发,而新增的 on_select 会在从列表中选中某项时触发。

有遗漏?

所有已移除的废弃 API列出了 1.0 从预发布系列中丢弃的内容,而破坏性变更与废弃按版本组织。对于任何此处未列出的内容,请发起一个 discussion,以便更正本指南。

第 8 步:打包、文件与存储 ​

如果你用 flet build 打包应用,0.86 中有几处变更特别会影响 0.28 应用:

  • 你的应用包是只读的,且工作目录移动到了一个可写的、应用私有的目录。对打包文件的相对读取 —— open("seed.json") —— 需要改用 __file__、importlib.resources 或 assets/。FLET_APP_STORAGE_DATA 现在映射到操作系统的 application-support 目录,并新增了 FLET_APP_STORAGE_CACHE。参见应用文件以未打包的只读包形式分发。
  • 默认内置 Python 3.14。 如果你依赖没有 3.14 二进制文件的原生 wheel,请用 --python-version 3.12 固定版本。参见默认内置 Python 版本现为本 3.14。
  • 你的应用默认会被编译为 .pyc,这能加快冷启动。用 --no-compile-app 退出。参见应用与包默认编译为 .pyc。
  • 在 Android 上,site-packages 以压缩形式分发,部分包需要 extract_packages。参见 Android:site-packages 以压缩形式分发。

资源只按文件名引用 —— ft.Image(src="bear.svg"),而不是 src="assets/bear.svg"。

新增内容(完全可选) ​

迁移这些都不是必须的。你命令式的 0.28 风格代码在 1.0 中依旧可用。

  • Declarative UI —— @ft.component、use_state 和 hooks,适用于状态较重的界面。
  • Router —— 带视图栈的声明式路由。
  • Client actions —— 受手势限制的工作,例如打开选择器或写入剪贴板,无需与 Python 往返一次即可完成。这正是对这些功能在浏览器中可靠的原因。
  • Subinterpreters —— 一个进程内的多核 Python,包括在移动端。

停留在 0.28.x ​

如果你暂时不迁移,固定版本即可:

toml
dependencies = ["flet==0.28.3"]

0.28.x 会继续收到缺陷与安全修复,但不再有新功能。


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