
Flet 做桌面端小工具是真方便Python 写完直接跑界面比 Tkinter 顺眼得多。但有一个问题几乎每个刚上手的人都会遇到程序一启动窗口永远出现在屏幕左上角和任务栏挤在一起看着特别别扭。窗口屏幕居中这个需求很小但做法其实有讲究直接赋值坐标有时候灵有时候不灵里面牵扯到 Flet 的窗口生命周期、底层窗口引擎还有不同操作系统的差异。这篇文章我把自己常用的 Flet 窗口屏幕居中自定义模板完整拆开讲从最简单的写法到封装成通用模板再把背后的实现原理一起说清楚看完你不仅能解决居中问题还能顺手理解 Flet 桌面窗口到底是怎么工作的。1. Flet窗口为何默认靠左上角先搞懂Flet的窗口模型1.1 Flet桌面端的窗口形态很多人把 Flet 当成“Python 写 Flutter”的工具这个理解大方向没错但窗口管理的实现路径容易被忽略。Flet 在桌面端运行的时候Python 进程会启动一个本地的 WebView 容器由它承载 Flutter 渲染引擎编译出来的前端界面Python 端通过 WebSocket 和前端通信。也就是说你在 Python 里写的page.add(...)并不是直接调用操作系统原生控件而是给 Flutter 层发指令。这个架构带来一个直接结果Flet 窗口本身属于“宿主窗口”它由 Flet 的桌面运行时创建而不是由 Python 直接创建。所以窗口的位置和尺寸必须通过 Flet 抽象出来的page.window相关接口去操作不能用 PyQt 那套move()或者 Tkinter 的geometry()思路硬套。Flet 在窗口上的默认策略是先创建一个默认大小窗口位置交给操作系统安排。Windows 上通常就是左上角原点附近macOS 上可能会稍微偏移Linux 取决于窗口管理器。换句话说Flet 没有“自动居中”这个默认行为因为 Flutter 本身也不知道你的业务场景期望窗口出现在哪个位置。1.2 Flet窗口位置控制相关的APIFlet 老版本0.21.0 之前里窗口控制方法是page.window_center()这种风格而新版本统一收敛到了page.window属性对象上。我刚入坑时用的是旧 API后来升级版本踩了一脚兼容性的坑现在主流写法是围绕page.window的几个关键属性属性含义page.window.width窗口宽度逻辑像素page.window.height窗口高度逻辑像素page.window.left窗口左边距屏幕坐标page.window.top窗口上边距屏幕坐标page.window.screen_width当前显示器分辨率宽度page.window.screen_height当前显示器分辨率高度Flet 还提供了page.window.center()方法某些版本中可以直接调用让窗口居中。但这个方法在跨平台场景下并不总是如你预期后面我会细说为什么center()有时候“失灵”。这里要先建立一个认知窗口位置是宿主窗口的几何属性Flet 的 Python 端只能通过异步消息控制它没法像普通桌面框架那样直接同步修改。所以你写居中逻辑时本质上是在给 Flutter 层下发一条 “改变窗口几何位置” 的消息这条消息能不能立即生效取决于窗口是否已经完全初始化。2. 窗口屏幕居中方案的三个演进版本2.1 最朴素的直接赋值居中很多教程里给的示例是这种import flet as ft def main(page: ft.Page): page.title 居中示例 page.window.width 800 page.window.height 600 page.window.left (page.window.screen_width - 800) // 2 page.window.top (page.window.screen_height - 600) // 2 page.add(ft.Text(窗口应该居中)) ft.app(targetmain)这段代码看起来很清晰设置窗口宽高然后用屏幕尺寸减去窗口尺寸除以 2 得到左上角坐标。但实际运行时你会发现有时候窗口还是出现在左上角尤其是在page还没有完成初始化时就读取screen_width拿到的可能是空值或者 0。这个问题我在 Windows 10 上复现过好几次。原因在于main函数执行的时候Flutter 前端可能还在启动阶段page.window.screen_width还没有被赋上真实值你算出来的坐标自然就是错的。再加上不同版本 Flet 对窗口属性的默认值处理不一致这种直接赋值写法属于“运气好就居中运气不好就翻车”。2.2 等页面加载完成后再居中既然问题是读取过早那就让居中逻辑等页面加载完再执行。Flet 提供了page.on_loaded回调在 Flutter 前端完成初始绘制之后触发。这个时机窗口已经存在于操作系统里屏幕尺寸、窗口尺寸这些参数都能拿到真实值。import flet as ft def center_window(page: ft.Page, width: int, height: int): page.window.width width page.window.height height def on_loaded(e): page.window.left (page.window.screen_width - page.window.width) // 2 page.window.top (page.window.screen_height - page.window.height) // 2 page.update() def main(page: ft.Page): page.title 居中示例 page.window.width 800 page.window.height 600 page.on_loaded on_loaded page.add(ft.Text(等待加载完成后居中))把居中操作挪到on_loaded里稳定性提升非常明显。核心原因是on_loaded触发时Flet 运行时的前端页面已经挂载完毕page.window下的几何属性都能拿到有效值此时再设置left和top是真正意义上在操作系统层面移动窗口。不过这个版本依然有一个小瑕疵窗口在创建后会先出现在默认位置等on_loaded触发时才跳转到屏幕中央。如果程序启动慢用户会看到一个“从左上角闪到中间”的过程体验不够精致。2.3 自适应多显示器的居中写法现代开发环境里双屏很常见。上面那种算法有一个问题它永远只根据 “当前显示器” 的尺寸计算但 Flet 的screen_width拿到的主显示器还是鼠标所在显示器不同平台行为不一致。如果用户把窗口拖到副屏再修改窗口大小你会发现窗口不会自动在副屏居中而是回到主屏计算出来的坐标。更稳妥的做法是引入系统屏幕工作区的概念。Windows 系统里任务栏会占用屏幕底部一部分空间真正的可视区域是工作区Work Area比整个屏幕分辨率要小。如果直接拿screen_height去算窗口底部可能会被任务栏挡住一部分。import flet as ft def center_on_work_area(page: ft.Page, width: int, height: int): # 先设置窗口尺寸 page.window.width width page.window.height height # 等待loaded再读取屏幕参数计算居中坐标 def _center(e): screen_w page.window.screen_width screen_h page.window.screen_height # 扣除任务栏的大致高度Windows典型的任务栏高度为40或48 work_h screen_h - 48 left max((screen_w - width) // 2, 0) top max((work_h - height) // 2, 0) page.window.left left page.window.top top page.update() page.on_loaded _center这个版本在实践中已经能满足大多数场景了。它通过max(0, ...)防止窗口出现在负坐标区域同时用扣除任务栏的估算高度让窗口在视觉上更接近屏幕正中心。当然如果你是精确派可以调用系统 API 获取工作区但我个人觉得做 Flet 小工具没必要引入额外系统依赖估算值足够。3. 自定义模板的设计与完整实现3.1 模板要解决的四个问题既然“窗口居中”在几乎每个 Flet 桌面应用里都要用到每次重复写on_loaded回调就太傻了。做一个自定义模板本质上是把这套逻辑抽象成四个能力统一设置窗口大小避免散落在每个main函数里。处理居中时机不需要用户关心on_loaded。兼容多显示器和不同操作系统的坐标差异。预留扩展点比如无边框窗口、全屏切换等。围绕这四个问题我设计了三种不同粒度的模板按使用场景自由选。3.2 函数式模板一行调用完成居中最轻量的模板是函数式风格它不侵入 Flet 的类体系只是一个辅助函数。适合旧项目改造或者不想引入面向对象风格的时候用。import flet as ft def setup_centered_window( page: ft.Page, width: int 900, height: int 650, min_width: int 600, min_height: int 400, center_on_show: bool True, ): Flet 窗口居中模板 - 函数式版本 page.window.width width page.window.height height page.window.min_width min_width page.window.min_height min_height if not center_on_show: return def _center(e): try: screen_w page.window.screen_width or 1920 screen_h page.window.screen_height or 1080 left max((screen_w - width) // 2, 0) top max((screen_h - height) // 2, 0) page.window.left left page.window.top top page.update() except Exception: # 极端环境下读取失败不阻塞业务 pass page.on_loaded _center调用方式非常直接import flet as ft from window_template import setup_centered_window def main(page: ft.Page): setup_centered_window(page, width1024, height700) page.title 我的应用 page.add(ft.Text(内容)) ft.app(targetmain)函数式模板适合快速落地。我在开源项目里把center_on_show暴露出来如果某些页面希望全屏启动后再居中可以关闭这个选项。示例代码里加了一层try/except这不是保险主义而是我确实在某次 Flet 版本升级后遇到过screen_width返回None导致//运算抛 TypeError 的案例。3.3 类式模板把窗口参数封装成可复用组件当模板要管理的窗口行为变多比如状态栏、系统托盘、开机自启函数式就会变得臃肿。这时候用类封装更合适。类式模板能天然地把窗口参数和业务逻辑区分开同时让子类可以覆盖居中策略。import flet as ft class CenteredWindowTemplate: def __init__( self, page: ft.Page, title: str Flet App, width: int 960, height: int 640, min_width: int 480, min_height: int 300, bgcolor: str #fafafa, ): self.page page self.page.title title self.page.window.width width self.page.window.height height self.page.window.min_width min_width self.page.window.min_height min_height self.page.bgcolor bgcolor self.width width self.height height self.page.on_loaded self._on_loaded def _on_loaded(self, e): self.center_on_screen() self.after_window_loaded() def center_on_screen(self): screen_w self.page.window.screen_width screen_h self.page.window.screen_height if not screen_w or not screen_h: return left int((screen_w - self.width) / 2) top int((screen_h - self.height) / 2) self.page.window.left max(left, 0) self.page.window.top max(top, 0) self.page.update() def after_window_loaded(self): 子类覆写执行窗口加载完成后的业务逻辑 pass def build_content(self): 子类覆写返回页面控件 return ft.Text(默认内容) def mount(self): self.page.add(self.build_content())使用方式import flet as ft from window_template import CenteredWindowTemplate class MyAppWindow(CenteredWindowTemplate): def after_window_loaded(self): print(窗口已加载并居中) def build_content(self): return ft.Column([ ft.Text(这是一个类式模板示例, size24), ft.ElevatedButton(按钮, on_clicklambda e: print(点击)), ]) def main(page: ft.Page): app MyAppWindow(page, title类式模板, width800, height560) app.mount() ft.app(targetmain)类式模板最有价值的部分是after_window_loaded钩子。实际开发中窗口居中之后往往伴随一系列初始化工作比如加载配置文件、检查更新、连数据库这些操作如果放在main里会晚于on_loaded执行还是早于执行完全取决于代码位置很容易出错。把钩子挂在模板里子类只需要关心业务覆盖不用关注 Flet 的回调时机。3.4 装饰器模板与页面级模板函数和类之外还有两种进阶封装。装饰器模板适合追求“零侵入”的场景直接给main函数加一个装饰器就拿到了居中能力def centered(width960, height640): def decorator(func): functools.wraps(func) def wrapper(page: ft.Page, *args, **kwargs): page.window.width width page.window.height height def _on_loaded(e): sw page.window.screen_width or 1920 sh page.window.screen_height or 1080 page.window.left max((sw - width) // 2, 0) page.window.top max((sh - height) // 2, 0) page.update() page.on_loaded _on_loaded return func(page, *args, **kwargs) return wrapper return decorator centered(width1000, height680) def main(page: ft.Page): page.title 装饰器居中 page.add(ft.Text(内容))页面级模板则是在ft.View上做文章。如果你的 Flet 应用用到多页面导航每个View可以有不同的物理窗口参数吗实际上在同一个窗口内View改变的是页面内容不能改窗口尺寸。所以页面级模板我一般只在page.views切换时同步更新窗口标题窗口居中还是统一在应用启动时做一次。这块的取舍逻辑是装饰器模板适合写一次性脚本、快速原型类式模板适合做正式项目函数式模板适合老项目插拔。不要迷信某一种根据你的代码风格去选就行。4. 实现原理深入Flet居中背后的坐标体系和生命周期4.1 Flet窗口的坐标模型要真正理解居中代码为什么这样写得先搞懂 Flet 窗口的坐标模型。Flet 桌面端的窗口坐标系和大多数 GUI 框架一致以屏幕左上角为原点向右为 X 轴正向向下为 Y 轴正向单位为逻辑像素。window.left和window.top表示窗口左上角在这个坐标系里的位置。窗口尺寸则是window.width和window.height。这两个值值得留个心眼它不是 Flutter Canvas 的逻辑宽高而是宿主窗口的物理尺寸在 Windows 上就是像素在 mac 上会乘以 DPI 缩放系数。如果你在高 DPI 屏幕上跑 Flet设置的 800 宽可能看起来和预期不一致因为操作系统的缩放会把逻辑值映射到物理像素。居中计算的数学本质非常简单窗口左上角 X (屏幕宽 - 窗口宽) / 2Y (屏幕高 - 窗口高) / 2。这个公式天然要求屏幕宽和窗口宽处于同一个坐标系。Flet 的screen_width返回的主屏幕宽度是逻辑像素值与window.width的度量单位一致所以能直接相减。如果你混用了物理像素和逻辑像素比如在 Windows 缩放 150% 的屏幕上直接用 Win32 API 拿物理屏幕宽度再和 Flet 的window.width计算出来的窗口位置会明显偏离中心。4.2 为什么“一启动就居中”经常失效很多人在main函数里直接写居中代码失效不是 Flet 的 bug而是窗口生命周期还没走到可修改位置的状态。这里要理解 Flet 的启动流程Python 端调用ft.app(targetmain)。Flet 运行时创建宿主 WebView 窗口此时窗口对象存在但 Flutter 前端还没有加载。main(page)被调用你开始设置各种page属性。Flutter 前端加载完成与 Python 端建立通信。page.on_loaded回调触发。关键点在第 2 步和第 3 步之间。宿主窗口在main执行时其实已经创建了所以page.window.width这种赋值是生效的。但screen_width这个属性是从 Flutter 端查询后返回的它依赖前端完成初始化。你在第 3 步读取它时拿到的是一个尚未填充的空值。这就是page.on_loaded存在的意义它把居中操作延迟到第 5 步之后此时前端已经完成了布局screen_width是可靠数据。Flet 某些版本里提供的page.window.center()方法内部实现也一样本质上是通过消息通道让窗口宿主执行一次居中操作。这个 API 有时候不生效多半原因是 Flutter 端还没有完成窗口参数的绑定消息发出去了但没到真正的原生窗口对象上。4.3 窗口引擎的差异与兼容Flet 桌面端在不同操作系统上使用的窗口宿主不一样。Windows 上用的是基于 MSHTML 或 WebView2 的宿主窗口macOS 上是 WKWebViewLinux 上根据桌面环境可能是 GTK 或 WebKitGTK。窗口位置、屏幕尺寸这些信息Flet 封装成了统一的page.window接口但底层实现差异仍然会在边缘场景暴露出来。最典型的是 Linux 的 Wayland 会话。Wayland 出于安全设计不允许客户端自行指定全局坐标位置窗口位置由合成器管理。你在 Wayland 下设置window.left和window.top可能直接被忽略或者窗口管理器把你的坐标当成一个“建议值”最终落点还是由合成器决定。这种情况下居中逻辑只能在 X11 会话里稳定工作Wayland 下建议直接依赖page.window.center()让合成器处理。Windows 和 macOS 对顶层窗口的位置控制相对开放设置left/top都能即时生效。但 macOS 上窗口工具栏、标题栏的高度会影响视觉居中效果有时需要额外调整top的偏移量。我这边的做法是允许模板子类重写center_on_screen针对不同平台做差异化偏移。5. 常见问题与排查技巧实录5.1 窗口位置为空的None错误这是一个高频报错。错误信息一般是TypeError: unsupported operand type(s) for //: NoneType and int。原因就是page.window.screen_width返回了None。我排查过几次触发条件主要有两个Flet 前端还没初始化完成main函数内部就读取屏幕属性。在某些远程桌面环境或者虚拟机里宿主窗口创建失败但 Flet 没有抛出异常属性保持为 None。应对方案就一条所有屏幕属性读取都放在on_loaded回调里并且加上兜底值。screen_w page.window.screen_width or 1920 screen_h page.window.screen_height or 1080兜底值虽然不精确但总比程序崩溃好。我见过不少生产环境的小工具用户显示器分辨率千奇百怪兜底值至少能保证窗口不会永远停在左上角。5.2 窗口从左上角跳到中间的画面跳动当你用on_loaded做居中时会先看到窗口出现在左上角然后快速跳进屏幕中央。这个视觉跳跃在慢速设备上非常明显。解决思路有两种。第一种是启动时直接把窗口设置为一个接近目标大小的矩形并放在屏幕正中央的估算位置等on_loaded后再精确定位。但这种方式很容易引出一个更糟糕的问题窗口先在其他位置闪烁然后跳到中间观感更差。第二种是我个人更推荐的把window.visible设为 False等居中完成后再让它可见。Flet 0.21 及以上版本支持page.window.visible属性。def main(page: ft.Page): page.window.visible False page.window.width 800 page.window.height 600 def on_loaded(e): page.window.left (page.window.screen_width - 800) // 2 page.window.top (page.window.screen_height - 600) // 2 page.window.visible True page.update() page.on_loaded on_loaded这种“先隐藏窗口 - 计算位置 - 再显示”的模式在桌面开发里叫 “闪屏抑制”很多原生应用都用这个思路。Flet 里用page.window.visible控制表现稳定强烈推荐。5.3 高DPI和分辨率变化的适配窗口居中是一次性操作但用户可能在程序运行过程中切换屏幕分辨率或者把窗口从主屏拖到副屏。这时候窗口不会自动重新居中。处理方案是在page.on_resize回调里重新判断是否需要居中。但有个细节用户主动拖动窗口不应该被强制拉回中心否则体验会很怪。我的做法是只监控“窗口尺寸变化时自动把中心点保持在当前屏幕中心”对主动拖动不做干预。考虑到 Flet 的page.window.left和top在拖动过程中会同步更新可以通过一个简单的开关来判断用户是否正在调整窗口。不过这个逻辑对小程序来说有点过度设计我自己通常只在设置面板里放一个“窗口居中”按钮用户点一下就行。6. 模板化之后还能扩展什么6.1 将居中模板扩展成启动器居中只是窗口管理的一部分。实际桌面应用还需要“记住上次窗口位置”“开机自启时窗口落点在屏幕右下角”“多实例程序窗口层叠”这些需求。我在模板基础上扩展了一个WindowLauncher类它把居中逻辑和配置持久化绑定在一起。程序退出时把窗口位置保存到本地 JSON 文件下次启动时优先恢复上次位置如果用户没动过位置或者配置文件不存在再执行默认居中。这个逻辑在 Flet 里实现成本很低就是把window.left和top在应用关闭回调里写文件启动时读文件。import json from pathlib import Path CONFIG_PATH Path.home() / .my_flet_app / window_config.json def save_window_state(page: ft.Page): data { left: page.window.left, top: page.window.top, width: page.window.width, height: page.window.height, } CONFIG_PATH.parent.mkdir(parentsTrue, exist_okTrue) CONFIG_PATH.write_text(json.dumps(data), encodingutf-8) def load_window_state(page: ft.Page): if not CONFIG_PATH.exists(): return False data json.loads(CONFIG_PATH.read_text(encodingutf-8)) page.window.left data[left] page.window.top data[top] return True这个扩展思路不复杂但把一个通用的小模板变成了真正能用于交付项目的基线代码。6.2 针对Flutter/Flet版本升级的维护经验Flet 迭代速度很快窗口 API 在 0.21.0、0.23.0 等多个版本都发生过调整。维护模板时最容易翻车的点是属性名变更和方法被移除。我自己的策略是在模板代码里加一个版本断言用page.flet_version判断主版本提示不兼容。尽量只使用page.window属性对象下的稳定 API比如left、top、width、height这些跨版本更稳定。不在模板里直接依赖page.window_center()这种旧方法已废弃的方法在不同版本里行为可能不一致。我实测下来Flet 升级最痛苦的是大型项目而不是这种几十行的模板。就算 API 变了改动范围也就是center_on_screen一个方法。比起每次从零写窗口管理模板的维护成本低到可以忽略。我自己在多个 Flet 项目里重复用过这套模板最大的体感是窗口居中看起来是个小功能但它牵扯到运行时初始化顺序、系统窗口引擎差异、屏幕参数读取时机这些底层细节稍不注意就会翻车。把这部分逻辑沉淀成模板以后新项目启动只需要几秒钟配置省下来的时间足够多做两个界面。如果你也被窗口位置问题困扰直接拿上面的代码去用遇到特殊平台再把center_on_screen覆盖掉就行。