主题
从 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.0 | 1.0 Alpha —— 全新架构、控件改为 dataclass、声明式 UI |
| 0.80.0 | 1.0 Beta —— 取代 0.28.3 成为 PyPI 上的当前版本 |
| 0.86.0 | 打包与存储重构、进程内 dart-bridge |
| 1.0.0 | 最终版本 |
兼容性策略(至少三个次版本的废弃周期)从 1.0 起生效,但不涵盖这次大跳转:预发布阶段加入的废弃兼容垫片(shim)在 1.0 中已全部移除。
按此顺序迁移
- 先让它跑起来 —— 依赖、入口、重命名的 API、服务。
- 然后修复响应性 —— 单线程模型。这一步最容易被跳过,也正因为如此,一个本应能正常工作的迁移后应用会感觉卡顿。
- 现代化改造按需进行 —— 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.28 | 1.0 |
|---|---|---|
| 延时 | time.sleep(1) | await asyncio.sleep(1) |
| HTTP 请求 | requests.get(url) | 异步客户端,或卸载到线程 |
| 数据库查询 | sqlite3、psycopg2 | 异步驱动,或卸载到线程 |
| 读取大文件 | open(path).read() | aiofiles,或卸载到线程 |
| CPU 密集型工作 | 在池线程上的同步处理器内 | 卸载到线程(见下) |
| 后台循环 | threading.Thread | page.run_task() |
| 调用子进程 | subprocess.run() | asyncio.create_subprocess_exec() |
切换到异步库
大多数流行的阻塞库都有异步对应物:
| 阻塞 | 异步 |
|---|---|
requests | httpx、aiohttp |
sqlite3 | aiosqlite |
psycopg2 | asyncpg、psycopg 3 异步版 |
pymongo | motor |
redis | redis.asyncio |
boto3 | aioboto3 |
paramiko | asyncssh |
open() | aiofiles |
subprocess | asyncio 子进程 |
一次阻塞的 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.text2. 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.28 | 1.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_menu | ft.BrowserContextMenu() |
page.storage_paths | ft.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.28 | 1.0 |
|---|---|
ft.ElevatedButton(...) | ft.Button(...) |
ft.Button(text="Save"),以及所有按钮 | ft.Button(content="Save") |
ft.ConstrainedControl | ft.LayoutControl |
ft.UserControl | 继承某个控件 —— 见第 6 步 |
| 图表控件 | flet-charts 包 |
常量与辅助函数
小写的模块别名已移除;请通过首字母大写的类来取用常量。
| 0.28 | 1.0 |
|---|---|
ft.colors.RED | ft.Colors.RED |
ft.icons.ADD | ft.Icons.ADD |
ft.alignment.center | ft.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.BLACK54 | ft.Colors.BLACK_54 |
模块级的 all() / symmetric() / only() 辅助函数变成了 Padding、Margin、Border 和 BorderRadius 上的类方法,且 symmetric() 只接受关键字参数。
对话框、抽屉与导航
| 0.28 | 1.0 |
|---|---|
page.open(dialog) | page.show_dialog(dialog) |
page.close(dialog) | page.pop_dialog() |
page.dialog = d; d.open = True | page.show_dialog(d) |
page.go(route) | await page.push_route(route) |
page.on_resized | page.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.28 | 1.0 |
|---|---|
Icon.name | Icon.icon |
Card.color | Card.bgcolor |
Card.is_semantic_container | Card.semantic_container |
Checkbox.is_error | Checkbox.error |
Chip.click_elevation | Chip.elevation_on_click |
Switch.label_style | Switch.label_text_style |
Tabs.is_secondary | TabBar.secondary —— 见下 |
Tab.text / Tab.tab_content | Tab.label |
Badge.text | Badge.label |
Markdown.img_error_content | Markdown.image_error_content |
BoxDecoration.shadow | BoxDecoration.shadows |
canvas.Text.text | canvas.Text.value |
NavigationRailDestination.label_content | NavigationRailDestination.label |
Pagelet.bottom_app_bar | Pagelet.bottom_appbar |
on_scroll_interval | scroll_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 会继续收到缺陷与安全修复,但不再有新功能。