Flet-Python全栈框架剖析
前后端分离架构
在Flet架构中,前端可以是基于浏览器的Web,或者windows桌面端exe程序,或者移动端的apk等等,由于前后端代码都写到一起的,要去理解哪些是前端代码,哪些是后端代码就很费解了。
我们先分析一个简单的页面如下,页面显示了服务端的ini配置文件的值。左边是ini文件列表,下一级菜单是sections,右边是每个section下面的配置名称和值,可以修改保存。
暂时不涉及复杂的交互,按照目前的前后端分离的架构,前端进行页面渲染,然后向后端去请求数据并动态响应展示。保存也通过接口传递给后端,修改ini文件。
如上图所示,分离架构里面,服务端主要提供RestFul接口服务,只需要提供数据查询和更新即可,不用关注用户动作和呈现,前端去定义数据展示形态,实现用户查询动作和更新动作的捕获,并将请求发送到后端,根据后端范围的数据动态渲染。
Flet的前后端代码合一
Flet比较神奇,比方说前面例子中的Web服务,页面的展示、动作定义和数据获取都在Python中实现的,感觉好像是Python的代码Run起来之后,Server端也把前端的事情也干了。
我贴一下代码来看看,首先是主入口代码,这里基本上就是定义主页面,启动一个flet服务。
import logging
from GTUtility.config.manager_ini import Inis
import flet as ft
from manager_flet.parse.parse_ini import IniControls
def main(page: ft.Page):
# page.title = f"base mode测试"
page.vertical_alignment = ft.MainAxisAlignment.START
page.theme_mode = ft.ThemeMode.LIGHT
logging.warning("页面启动开始")
config_data = Inis(conf=Params.conf_dir)
config_data.load()
columns = ft.Column(expand=True)
controls = IniControls(expand=8)
controls.load(config_data=config_data)
columns.controls.append(ft.Container(expand=1, content=ft.Tabs(expand=10, tabs=[
ft.Tab(text="INI配置", content=controls), ft.Tab(text="基础数据配置", content=ft.Text("第二个菜单预留"))
])))
page.controls.append(columns)
page.update()
logging.warning("页面启动完成")
if __name__ == __main__:
# upload_dir=open_ai_dir,
ft.app(name="index", port=38090, target=main, view=ft.WEB_BROWSER, web_renderer="html")
Inis类是我自己封装的加载和管理ini的数据处理对象; 而iniControls是定义的展示视图,是一个自定义Controls类,用来定义页面展示元素和事件响应。Inis类可以理解为纯后端数据处理的类,由于前后端开发时间比较长,用Flet的时候还是习惯将数据处理也页面处理分开
另外比较有意思的是main入库,需要有一个page,在flet中,不管是Web还是APP,page可以看做前端的页面主容器,页面的最终展示可以看成是通过page.update()来渲染的。官网与介绍,每个flet都有一个page,下面可以包含多个view,通过view的切换来完成视图的切换,呈现不同的前端页面。
Flet定义前端展示效果和事件处理
不管是BS还是CS,用户层面上最重要的两个动作就是展示和事件响应。参见前面范例的代码,controls是自定义的ini展示组件,将这个组件append到系统的columns中,最终将columns添加到主页面page里面,在page里面展示,我们这个范例里面只有一个view。有前端基础的很容易理解这个处理。
columns = ft.Column(expand=True)
controls = IniControls(expand=8)
controls.load(config_data=config_data)
columns.controls.append(ft.Container(expand=1, content=ft.Tabs(expand=10, tabs=[
ft.Tab(text="INI配置", content=controls), ft.Tab(text="基础数据配置", content=ft.Text("第二个菜单预留"))
])))
page.controls.append(columns)
page.update()
再来深入看下自定义的控件实现,build函数是重写了父类的函数,看实现就知道主要是完成前端显示元素构建和布局设置。
class IniControls(ft.UserControl):
def get_on_change(self, key: str, cfg, save_button):
"""
生成事件处理函数
:param key:
:return:
"""
section, option = key.split(".")
# 定义根据事件更新的处理函数
def update(e):
cfg.set(section=section, option=option, value=e.data)
# 有数据更新,保存按钮生效
save_button.disabled = False
save_button.update()
def _save_cfg(self, e, file_name):
try:
self.config_data.cfg_ini[file_name].save()
except Exception as esave:
logging.error(f"保存异常:{esave}")
else:
logging.warning(f"{file_name}更新保存")
e.control.disabled = True
e.control.update()
def _build_section(self, columns_tree, columns_content, file_name, section, cfg: ConfigParser):
def click_on_section(e):
columns_content.controls.clear()
save_button = ft.TextButton(text="保存", on_click=lambda e: self._save_cfg(e, file_name), disabled=True)
# 增加保存按钮
columns_content.controls.append(ft.Container(content=save_button))
# 展开
for option in cfg.options(section=section):
self._build_cfg_param(columns_content, section, option, cfg, save_button)
columns_content.update()
section_control = get_list_title_for_expand(
label=section,
on_click=click_on_section,
init_expand_status=False,
text_css={"width": 180}, level=2
)
section_control.visible = False
columns_tree.controls.append(section_control)
return section_control
def build(self):
rows = ft.Row(vertical_alignment=ft.CrossAxisAlignment.START, expand=True)
columns_tree = ft.Column(scroll=ft.ScrollMode.AUTO)
columns_content = ft.Column(scroll=ft.ScrollMode.AUTO, expand=True)
# 按照文件遍历构造
for file_name, cfg in self.config_data.cfg_ini.items():
self._build_cfg(columns_tree, columns_content, file_name, cfg.cfg)
columns_tree.scroll = ft.ScrollMode.ALWAYS
columns_content.scroll = ft.ScrollMode.ALWAYS
rows.controls.append(ft.Container(content=columns_tree))
# 间隔
rows.controls.append(ft.VerticalDivider(width=5, color=ft.colors.BLUE, thickness=3))
rows.controls.append(ft.Container(content=columns_content))
return ft.Container(content=rows)
其他的内容我们暂时不关注,先重点看看保存按钮,为每个保存按钮添加self._save_cfg点击处理函数,有点像一个回调函数,这就是一个前端页面动作响应。
save_button = ft.TextButton(text="保存", on_click=lambda e: self._save_cfg(e, file_name), disabled=True)
# 增加保存按钮
columns_content.controls.append(ft.Container(content=save_button))
# 展开
for option in cfg.options(section=section):
self._build_cfg_param(columns_content, section, option, cfg, save_button)
而保存动作是保存到后端,看下self._save_config函数。我们save到后端,同时对控件状态进行了更新,e.control.disabled的目的是保存之后设置按钮状态并更新页面。
def _save_cfg(self, e, file_name):
try:
self.config_data.cfg_ini[file_name].save()
except Exception as esave:
logging.error(f"保存异常:{esave}")
else:
logging.warning(f"{file_name}更新保存")
e.control.disabled = True
e.control.update()
按照flet的思路,我们根据web的一个案例进行了分析,这个写法确实没有前后端的概念了这是因为Flet是SDUI架构,官网也进行了阐述,我们下一小节来描述。
Flet的SDUI架构
参见官网
https://flet.dev/docs/guides/python/mobile-support
SDUI架构全称是Server-driven UI,简单理解就是服务端驱动UI,如下图,在我们的Web应用中,User program和Flet server都运行在Server端,Flet client可以是Web、exe或者apk应用。
我们的所有Python程序构成User program模块,完成业务流程, 与Flet client的交互通过Flet框架中的Flet server,这部分是由go实现。
每个模块详细说明如下:
- User program:用户面程序,支持后端数据处理、Flet前端页面控件定义、事件定义以及页面刷新等等所有业务相关处理
- Flet server:传递前端(web或者apk等)事件以及后端处理结果,直接驱动用户页面展示,通过websockets与连接通信
- Flet client:浏览器或者apk等等,完成用户界面展示和动作捕获,并与Flet server交互
可以看出来SDUI架构,动作响应和页面刷新都需要由Server端驱动,Server端需要干很多以前前端干的是事情,websockets连接对资源消耗也更大,所以Flet引入了Flet Server并用go来实现作为底层通道,解决Python性能较差的问题,但Flet仍然存在两个弊端
- 前后端除了数据交互,还有前端行为和响应的交互,会增大交互时延,可能影响用户操作体验
- 每个客户端都会开辟Websockets通道,增大长连接的资源占用
- 对于本机场景,或者使用Flet做客户端,对接其他HTTP Server场景,也需要部署Flet Server?
Flet 纯客户端场景
在官方博客<Flet mobile update>
https://flet.dev/blog/flet-mobile-update 中,官方介绍了Flet新的桌面架构,去掉了Go的Flet Server(Fletd),这部分封装到Python里面,与Flet Client通信也去掉websocket,windows上采用tcp, linux和mac等系统采用unix pipes。
因为是单客户端场景,并发少,缩短交互路径能够减少时延,提升响应速度。
如上图,该场景下我称之为纯客户端场景,用Python+Flutter去驱动Flet Client,如果要跟服务端通信,可以像前端一样,通过requests等库与第三方服务端通信,基于HTTP。
移动端APP也是类似的,只是按照官方所述,需要注意Python程序需要引入比较纯粹的python,或者必须支持在ARM64架构上编译。