跳到主要内容

用Python和Flet制作Trello克隆

让我们用Python和Flet框架制作一个Trello克隆,并将其部署到fly.io上!

trolli-app.gif

本教程的代码可以在这里找到,其中包含了详细的提交说明。在克隆后,请务必运行pip install -r requirements.txt来安装依赖。您还可以在这里找到一个实时演示。

为什么选择Flet?

大多数开发人员无疑都经历过这样的情况:要么是开发了一个最初意在面向特定用户群体的控制台应用程序,但后来发现受众超过了预期;要么是需要为非开发人员开发一个内部工具,这个工具的用户基数可能较小,使用寿命相对较短。在这些情况下,使用过度复杂的工具,如Electron,或者功能丰富的框架,如Flutter(为讽刺而讥笑!),或者尝试快速掌握其他跨平台框架(如.NET MAUI),往往感觉不太合适。我们真正想要的是在我们的逻辑上添加一个外观通用的用户界面,在性能可接受的情况下,并且最好所需的编写时间要少于业务逻辑的编写时间,最好还能使用我们已经熟悉的编程语言来编写这个界面 - 即使用我们已经熟练掌握的语言(目前只有Python库可用,但C#,TypeScript和Golang库也在路上)。这正是Flet平台的目标。

Flet采用了与许多新的UI框架不同的方法,这种方法对于大多数有经验的程序员来说可能更直观。与当前普遍采用的声明式方法不同,Flet选择了命令式模型。

尽管Flet旨在设计简单的GUI,但我们还是尝试制作一个稍微复杂一些的应用,比如一个最小化版本的Trello,我们给它起了一个完全独立产生的名字——Trolli。对于本教程,我假设读者对Flet项目的基本概念和设置已经很熟悉(如果不熟悉,请阅读教程文档),因此我将更多地关注不包含在现有教程中的方面。

定义实体和布局

为了创建我们的克隆版本的MVP,让我们首先定义主要实体(boardsboard_listsitems),确定一个可以接受的设计和布局,并实现一种伪存储库模式,以便在将来的开发中,我们可以从内存数据存储转移到某种持久性存储。

main.py模块中,我们将添加以下代码,并继续定义TrelloApp类。

import flet
from flet import (
Page,
colors
)

if __name__ == "__main__":

def main(page: Page):

page.title = "Flet Trello clone"
page.padding = 0
page.bgcolor = colors.BLUE_GREY_200
app = TrelloApp(page)
page.add(app)
page.update()

flet.app(target=main, view=flet.WEB_BROWSER)

在布局方面,我们可以将应用程序视为一个包含标题栏(appbar)的组件,在标题栏下方是可折叠的导航面板,旁边是活动视图,该视图可以是一个板块、设置、成员或其他我们选择的内容。大致如下:

mock-up.png

因此,应用程序本身的类可能如下所示:

class TrelloApp(flet.Container):
def __init__(self, page: Page):
super().__init__()
self.page = page
self.bgcolor = colors.BLUE_GREY_50
self.direction = flet.Column()

self.appbar = AppBar()
self.sidebar = Sidebar()
self.board = Board()

self.direction.append(self.appbar)
self.direction.append(
flet.Row(
self.sidebar,
self.board
)
)

self.append(self.direction)
from flet import (
Control,
Container,
Icon,
List,
ListItem,
Text,
colors,
icons,
)


class Sidebar(Container):
def __init__(self, parent, page, *args, **kwargs):
super().__init__(*args, **kwargs)
self.parent = parent
self.page = page
self.nav_list = List(color=colors.GREY_100)
self.nav_items = [
ListItem(
controls=[
Icon(icons.HOME),
Text("Home")
],
on_click=self.go_home
),
ListItem(
controls=[
Icon(icons.PERSON),
Text("Profile")
],
on_click=self.go_profile
),
ListItem(
controls=[
Icon(icons.NOTIFICATIONS),
Text("Notifications")
],
on_click=self.go_notifications
),
ListItem(
controls=[
Icon(icons.SETTINGS),
Text("Settings")
],
on_click=self.go_settings
)
]
self.nav_list.controls = self.nav_items
self.controls = [self.nav_list]

def go_home(self, e):
self.parent.active_view = self.create_home_view()

def go_profile(self, e):
self.parent.active_view = self.create_profile_view()

def go_notifications(self, e):
self.parent.active_view = self.create_notifications_view()

def go_settings(self, e):
self.parent.active_view = self.create_settings_view()

def create_home_view(self):
# Create the control for home view
return Container(
controls=[
Text("Welcome to the Home View")
],
alignment="center",
horizontal_alignment="center"
)

def create_profile_view(self):
# Create the control for profile view
return Container(
controls=[
Text("Welcome to the Profile View")
],
alignment="center",
horizontal_alignment="center"
)

def create_notifications_view(self):
# Create the control for notifications view
return Container(
controls=[
Text("Welcome to the Notifications View")
],
alignment="center",
horizontal_alignment="center"
)

def create_settings_view(self):
# Create the control for settings view
return Container(
controls=[
Text("Welcome to the Settings View")
],
alignment="center",
horizontal_alignment="center"
)
from flet import (
UserControl,
Column,
Container,
Row,
Text,
NavigationRail,
NavigationRailDestination,
alignment,
border_radius,
colors,
icons,
padding,
margin,
)
import itertools


class Sidebar(UserControl):

id_counter = itertools.count()

def __init__(self, app_layout, page):
super().__init__()
self.app_layout = app_layout
self.page = page
self.top_nav_items = [
NavigationRailDestination(
label_content=Text("看板"),
label="看板",
icon=icons.BOOK_OUTLINED,
selected_icon=icons.BOOK_OUTLINED
),
NavigationRailDestination(
label_content=Text("成员"),
label="成员",
icon=icons.PERSON,
selected_icon=icons.PERSON
),

]
self.top_nav_rail = NavigationRail(
selected_index=None,
label_type="all",
on_change=self.top_nav_change,
destinations=self.top_nav_items,
bgcolor=colors.BLUE_GREY,
extended=True,
expand=True
)
self.id = next(Sidebar.id_counter)

def build(self):
self.view = Container(
content=Column([
Row([
Text("工作区"),
]),
# 分隔线
Container(
bgcolor=colors.BLACK26,
border_radius=border_radius.all(30),
height=1,
alignment=alignment.center_right,
width=220
),
self.top_nav_rail,
# 分隔线
Container(
bgcolor=colors.BLACK26,
border_radius=border_radius.all(30),
height=1,
alignment=alignment.center_right,
width=220
),
], tight=True),
padding=padding.all(15),
margin=margin.all(0),
width=250,
bgcolor=colors.BLUE_GREY,
)
return self.view

def top_nav_change(self, e):
self.top_nav_rail.selected_index = e.control.selected_index
self.update()

运行主应用程序

flet main.py -d

能够看到结果,并且在进行任何样式更改时进行热重载。例如,尝试将 alignment="center" 添加到容器中的第一行,如下所示...

content=Column([
Row([Text("工作区")], alignment="center")

保存文件后,您应该能够在应用程序窗口中看到更改。

在继续之前,让我们定义基本实体。我们将需要一个 Board 类,它将保持一个列表的列表,其中每个列表都将是 BoardList 对象(抱歉,这里存在不幸的词法冲突 - 对 "list" 的俗称使用源自于应用程序的性质,而对 "list" 的技术使用则源自于 Python 中特定的术语,表示类似数组的数据结构),每个列表又包含一个 Item 对象的列表。如果这让您感到困惑,请花些时间阅读源代码以澄清疑虑。

对于每个实体,我们将使用 id_counter = itertools.count() 在每个类的顶部添加一个应用程序范围内的唯一 ID,并在初始化中调用 next(Board.id_counter)。这样,两个列表或看板可以具有相同的名称,但仍表示不同的实体。

数据访问层和自定义

现在我们已经定义了基本的布局和实体,让我们为应用程序本身添加一些自定义参数。我们还需要创建一个基本的数据访问接口。你可以在data_store.pymemory_store.py文件中看到接口和内存实现的样板代码。这样,我们将来可以更容易地将持久存储解决方案切换到应用中。

下面是更新后的main函数。我们需要在main方法中实例化InMemoryStore类,这样每个用户会话(即每个使用应用程序的新标签)都有自己的存储实例。然后,我们需要将该存储传递给需要访问它的每个组件。

我们还将在assets目录中添加一个新的字体,该目录在app函数的命名参数中指定。

if __name__ == "__main__":

def main(page: Page):

page.title = "Flet Trello克隆"
page.padding = 0
page.theme = theme.Theme(
font_family="Verdana")
page.theme.page_transitions.windows = "cupertino"
page.fonts = {
"Pacifico": "/Pacifico-Regular.ttf"
}
page.bgcolor = colors.BLUE_GREY_200
page.update()
app = TrelloApp(page)

flet.app(target=main, assets_dir="../assets", view=flet.WEB_BROWSER)

应用程序逻辑

现在你可以运行应用程序了,除了名称的字体更漂亮之外,它仍然没有任何功能。现在是填写应用程序逻辑的时候了。尽管这个应用程序可能称为复杂的,但我们不需要将代码分成不同的应用和业务层。仅将数据访问与其他逻辑分离即可完成本教程。但是,进一步的分离可能是值得考虑的事情。

创建视图

首先,我们将添加视图以对应侧边栏导航目的地。我们需要一个视图来显示所有看板,并且还需要一个显示成员窗格的视图,这只是一个占位符,暂时留待以后的教程。我们将这些视图作为控件添加到app_layout.py模块中。

self.members_view = Text("成员视图")

self.all_boards_view = Column([
Row([
Container(
Text(value="你的看板", style="headlineMedium"),
expand=True,
padding=padding.only(top=15)),
Container(
TextButton(
"添加新看板",
icon=icons.ADD,
on_click=self.app.add_board,
style=ButtonStyle(
bgcolor={
"": colors.BLUE_200,
"hovered": colors.BLUE_400
},
shape={
"": RoundedRectangleBorder(radius=3)
}
)
),

padding=padding.only(right=50, top=15))
]),
Row([
TextField(hint_text="搜索所有看板", autofocus=False, content_padding=padding.only(left=10),
width=200, height=40, text_size=12,
border_color=colors.BLACK26, focused_border_color=colors.BLUE_ACCENT, suffix_icon=icons.SEARCH)
]),
Row([Text("没有要显示的看板")])
], expand=True)

由于我们以命令式的范式工作,没有显式的状态管理工具(例如redux或类似工具),我们需要一个“重新加载”视图的方法,以便它的当前状态反映出在其他实体(即侧边栏)中所做的更改。

def hydrate_all_boards_view(self):
self.all_boards_view.controls[-1] = Row([
Container(
content=Row([
Container(
content=Text(value=b.name), data=b, expand=True, on_click=self.board_click),
Container(
content=PopupMenuButton(
items=[
PopupMenuItem(
content=Text(value="删除", style="labelMedium",
text_align="center"),
on_click=self.app.delete_board, data=b),
PopupMenuItem(),
PopupMenuItem(
content=Text(value="归档", style="labelMedium",
text_align="center"),
)
]
),
padding=padding.only(right=-10),
border_radius=border_radius.all(3)
)], alignment="spaceBetween"),
border=border.all(1, colors.BLACK38),
border_radius=border_radius.all(5),
bgcolor=colors.WHITE60,
padding=padding.all(10),
width=250,
data=b
) for b in self.store.get_boards()
], wrap=True)
self.sidebar.sync_board_destinations()

同步导航面板

接下来,我们需要在导航面板中添加一个外观上有所区别的部分来显示我们创建的看板。我们将在侧边栏中添加第二个 bottom_nav_rail,用于表示特定看板是活动视图。这将需要在任何更改当前看板列表时调用 sidebar 组件的 sync_board_destinations 方法。 现在,我们将为顶部和底部导航栏添加一个变更处理程序。

self.top_nav_rail = NavigationRail(
selected_index=None,
label_type="all",
on_change=self.top_nav_change,
destinations=self.top_nav_items,
bgcolor=colors.BLUE_GREY,
extended=True,
height=110
)
self.bottom_nav_rail = NavigationRail(
selected_index=None,
label_type="all",
on_change=self.bottom_nav_change,
extended=True,
expand=True,
bgcolor=colors.BLUE_GREY,
)

def sync_board_destinations(self):
boards = self.store.get_boards()
self.bottom_nav_rail.destinations = []
for i in range(len(boards)):
b = boards[i]
self.bottom_nav_rail.destinations.append(
NavigationRailDestination(
label_content=TextField(
value=b.name,
hint_text=b.name,
text_size=12,
read_only=True,
on_focus=self.board_name_focus,
on_blur=self.board_name_blur,
border="none",
height=50,
width=150,
text_align="start",
data=i
),
label=b.name,
selected_icon=icons.CHEVRON_RIGHT_ROUNDED,
icon=icons.CHEVRON_RIGHT_OUTLINED
)
)
self.view.update()

现在,我们可以添加新的看板,它们会出现在导航栏中。 不幸的是,单击导航栏并不会实际导航到任何位置。

我们可以通过在 app_layout.py 模块中为每个视图添加并同时切换有效/无效的可见性来实现此目的。但这在浏览器环境中不起作用,也不适用于具有返回按钮的移动设备上。我们需要考虑路由。Flet 提供了一个 TemplateRoute 实用类用于匹配 URL。

路由

route1 = TemplateRoute(name="boards", path="/boards/", handler=BoardsHandler)
route2 = TemplateRoute(name="board", path="/board/{board_id}/", handler=BoardHandler)
route3 = TemplateRoute(name="card", path="/card/{card_id}/", handler=CardHandler)

现在我们可以通过 URL 来匹配路由。我们需要使用这些路由来导航到相应的处理程序。

def navigate(self, url: str):
match = self.routing_table.match(url)
if match:
handler = match.handler
handler(self)
else:
# Handle not found error
pass

现在,我们可以在导航栏中选择看板,并通过 URL 导航到相应的处理程序。

希望对你有所帮助! 在main.py模块中,让我们将一个处理程序连接到page.on_route_change事件。

Class TrelloApp:
def __init__(self, page: Page, user=None):

self.page.on_route_change = self.route_change



def initialize(self):
self.page.views.append(
View(
"/",
[
self.appbar,
self.layout
],
padding=padding.all(0),
bgcolor=colors.BLUE_GREY_200
)
)
self.page.update()
# 创建一个用于演示的初始看板
self.create_new_board("My First Board")
self.page.go("/")

def route_change(self, e):
troute = TemplateRoute(self.page.route)
if troute.match("/"):
self.page.go("/boards")
elif troute.match("/board/:id"):
if int(troute.id) > len(self.store.get_boards()):
self.page.go("/")
return
self.layout.set_board_view(int(troute.id))
elif troute.match("/boards"):
self.layout.set_all_boards_view()
elif troute.match("/members"):
self.layout.set_members_view()
self.page.update()

在这里,我们还将更改初始化方法,以便应用程序启动时使用预先制作的看板进行演示。在该方法中,注意我们向页面添加了一个View对象。页面维护了一个Views列表,用于作为其他控件的顶级容器,以跟踪导航历史记录。还需要在layout.py模块中添加相应的set_***_view方法。下面是一个示例的set_board_view方法...

 def set_board_view(self, i):
self.active_view = self.store.get_boards()[i]
self.sidebar.bottom_nav_rail.selected_index = i
self.sidebar.top_nav_rail.selected_index = None
self.sidebar.update()
self.page.update()

现在,如果我们在Web浏览器中运行以下命令启动项目:

flet main.py -d -w

-d标志用于热加载,-w标志用于Web),我们可以添加一些看板,并通过点击或输入board/{i}的URL来访问它们,其中i是以零为基础的看板索引。

更改看板名称

接下来,我们应该包括更改看板名称的功能。与在board_list.py模块中实现的更正式的标题编辑逻辑相比,我将倾向于更加"hacky"的方法,因为我个人不喜欢过于仪式化的编辑流程,特别是在这种低压、流动的应用程序中。我们将利用sidebar.py模块中底部导航栏目的on_focuson_blur事件。下面是我们将添加的处理程序。

def board_name_focus(self, e):
e.control.read_only = False
e.control.border = "outline"
e.control.update()

def board_name_blur(self, e):
self.store.update_board(self.store.get_boards()[e.control.data], {
'name': e.control.value})
self.app_layout.hydrate_all_boards_view()
e.control.read_only = True
e.control.border = "none"
self.page.update()

这样可以非常直观地更改看板名称,而无需使用不必要的对话框或冗余的按钮按下。

让我们还快速编写一个登录过程的桩代码,在以后的版本中可以更完整地实现。现在,我们只需添加以下登录方法,并将其与登录PopupMenuItemon_click事件连接起来。

def login(self, e):

def close_dlg(e):
if user_name.value == "" or password.value == "":
user_name.error_text = "请输入用户名"
password.error_text = "请输入密码"
self.page.update()
return
else:
print("用户名和密码: ", user_name.value, password.value)
user = User(user_name.value, password.value)
if user not in self.store.get_users():
self.store.add_user(user)
self.user = user_name.value
self.page.client_storage.set("current_user", user_name.value)

dialog.open = False
self.appbar_items[0] = PopupMenuItem(
text=f"{self.page.client_storage.get('current_user')} 的个人资料")
self.page.update()

user_name = TextField(label="用户名")
password = TextField(label="密码", password=True)
dialog = AlertDialog(
title=Text("请输入您的登录凭据"),
content=Column([
user_name,
password,
ElevatedButton(text="登录", on_click=close_dlg),
], tight=True),
on_dismiss=lambda e: print("模态对话框已关闭!"),
)
self.page.dialog = dialog
dialog.open = True
self.page.update()

拖放

接下来,我们将为列表和列表中的项目添加重要的拖放功能。

首先,我们从更简单的情况入手,即在板块内重新排序列表。为了给出一些视觉指示,表示将列表拖到目标位置,我们将在 list_will_drag_accept 事件处理程序中修改 board_list 容器的 border 属性,使颜色变暗,并在 list_drag_acceptlist_drag_leave 处理程序中返回浅色颜色。

接下来,我们将 board_list 视图包装在 DragTarget 对象中,然后再将其全部包装在 Draggable 对象中。两者都将传入 "lists" 组的 group 参数。这一点非常重要,因为稍后我们将添加拖放单个项目到不同列表的功能,所以对于那个功能我们将指定不同的组。如果上面的句子中有任何不清楚的地方,请查看相关的文档

现在,视图的组合应该类似于以下内容。

self.view = Draggable(
group="lists",
content=DragTarget(
group="lists",
content=Container(
content=Column([
self.header,
self.new_item_field,
TextButton(content=Row([Icon(icons.ADD), Text("添加卡片", color=colors.BLACK38)], tight=True),
on_click=self.add_item_handler),
self.items,
self.end_indicator
], spacing=4, tight=True, data=self.title),
width=250,
border=border.all(2, colors.BLACK12),
border_radius=border_radius.all(5),
bgcolor=self.color if (
self.color != "") else colors.BACKGROUND,
padding=padding.only(
bottom=10, right=10, left=10, top=5)
),
data=self,
on_accept=self.list_drag_accept,
on_will_accept=self.list_will_drag_accept,
on_leave=self.list_drag_leave
)
)

事件处理程序的定义如下。

def list_drag_accept(self, e):
src = self.page.get_control(e.src_id)
l = self.board.board_lists
to_index = l.index(e.control.data)
from_index = l.index(src.content.data)
l[to_index], l[from_index] = l[from_index], l[to_index]
self.inner_list.border = border.all(2, colors.BLACK12)
self.board.update()
self.update()


def list_will_drag_accept(self, e):
self.inner_list.border = border.all(2, colors.BLACK)
self.update()


def list_drag_leave(self, e):
self.inner_list.border = border.all(2, colors.BLACK12)
self.update()

注意到将不透明度字段用作拖动项在目标位置将被接受的视觉指示的操作。

drag-drop-list

现在来处理稍微复杂一些的情况,即在列表中拖动项(包括可能拖动到同一面板上的另一个列表)。现在,我们希望board_list不仅是其他列表的拖放目标,而且也能够接受从其他列表拖动到它的项,因此我们需要在列表上再添加一个DragTarget包装器,但这次我们将为其分配组名"items",以便它只对项的拖动作出响应。

由于我们可以将列表拖动到现有列表的上方或下方位置,我们将采用与列表拖动实现不同的视觉指示器策略。我们将确保每次将新的item添加到board_list时,它都会与一个视觉指示器(实现为一个简单的容器对象)交替显示。

item.py模块现在需要将其视图包装为DraggableDragTarget,并分配给"items"组,如下所示,还附带了事件处理程序。

def build(self):
self.view = Draggable(
group="items",
content=DragTarget(
group="items",
content=self.card_item,
on_accept=self.drag_accept,
on_leave=self.drag_leave,
on_will_accept=self.drag_will_accept,
),
data=self
)
return self.view

def drag_accept(self, e):
src = self.page.get_control(e.src_id)

# 如果项被放置到自身,则跳过
if (src.content.content == e.control.content):
print("skip")
self.card_item.elevation = 1
self.list.set_indicator_opacity(self, 0.0)
e.control.update()
return

# 项被拖放到同一列表内,但不是自身
if (src.data.list == self.list):
self.list.add_item(chosen_control=src.data,
swap_control=self)
self.card_item.elevation = 1
e.control.update()
return

# 项被添加到不同的列表
self.list.add_item(src.data.item_text, swap_control=self)
# 从可拖动项所属的列表中移除
src.data.list.remove_item(src.data)
self.list.set_indicator_opacity(self, 0.0)
self.card_item.elevation = 1
e.control.update()

def drag_will_accept(self, e):
self.list.set_indicator_opacity(self, 1.0)
self.card_item.elevation = 20 if e.data == "true" else 1
e.control.update()

def drag_leave(self, e):
self.list.set_indicator_opacity(self, 0.0)
self.card_item.elevation = 1
e.control.update()

我们需要一个地方来存放根据拖动事件决定何时以及如何修改board_list对象拥有的项的逻辑。对于这种规模的应用程序,下面的方法似乎是一个完全可行的方法:在不同的位置调用add_item方法时,将可选的关键词参数一并传入,如下所示。

def add_item(self, item: str = None, chosen_control: Draggable = None swap_control: Draggable = None):

controls_list = [x.controls[1] for x in self.items.controls]
to_index = controls_list.index(
swap_control) if swap_control in controls_list else None
from_index = controls_list.index(
chosen_control) if chosen_control in controls_list else None
control_to_add = Column([
Container(
bgcolor=colors.BLACK26,
border_radius=border_radius.all(30),
height=3,
alignment=alignment.center_right,
width=200,
opacity=0.0
)
])

# 重新排列(即从同一列表拖放)
if ((from_index is not None) and (to_index is not None)):
self.items.controls.insert(
to_index, self.items.controls.pop(from_index))
self.set_indicator_opacity(swap_control, 0.0)

# 插入(从其他列表拖动到此列表中间)
elif (to_index is not None):
new_item = Item(self, item)
control_to_add.controls.append(new_item)
self.items.controls.insert(to_index, control_to_add)

# 新增(从其他列表拖动到此列表末尾,或使用添加项目按钮)
else:
new_item = Item(self, item) if item else Item(
self, self.new_item_field.value)
control_to_add.controls.append(new_item)
self.items.controls.append(control_to_add)
self.store.add_item(self.board_list_id, new_item)
self.new_item_field.value = ""

self.view.update()
self.page.update()

并且使用这些更改,我们应该能够在面板内拖动列表,并在不同列表之间拖动项目。

处理页面大小调整

我们需要添加一些页面大小调整的逻辑,以确保如果存在多个无法显示的列表,可以滚动条来查看它们。此逻辑还必须考虑侧边栏的状态 - 展开或未展开。

我们在 board.py 模块中添加一个 resize 方法。

def resize(self, nav_rail_extended, width, height):
self.list_wrap.width = (
width - 310) if nav_rail_extended else (width - 50)
self.view.height = height
self.list_wrap.update()
self.view.update()

并在 app_layout.py 模块中建立 page.on_resize 处理程序。

def page_resize(self, e=None):
if type(self.active_view) is Board:
self.active_view.resize(self.sidebar.visible,
self.page.width, self.page.height)
self.page.update()

作为Web应用部署

当你运行 flet main.py 时,Flet Web服务器(即Fletd)会启动,以便将更新发送到基于Flutter的UI。服务器与UI之间以及服务器与客户端代码之间的通信均使用WebSockets。因此,您应该确保在您部署应用程序的任何地方都具有足够的WebSockets支持。对于本教程,我们将部署到 fly.io,它在免费的计划中提供高达3个VM和3GB存储空间。如果您更习惯使用AWS服务(或者有人为您支付费用😄),您可以考虑适应此部署策略到Fargate。

一旦您安装了flyctl命令行实用工具并创建了帐户,您可以通过运行以下命令进行身份验证

fly auth login

fly.io通过基于Docker容器创建微型VM来工作。因此,您需要在存储库的根目录中有一个 DockerFile,并且还需要一个 fly.toml 配置文件。后者也可以通过运行以下命令创建

fly launch

并按照提示进行操作。如果有必要,您可以参考伴随存储库中指定的提交。

应用部署

一旦你确认了你的 Docker 镜像可以在本地构建和运行成功,你可以通过运行以下命令创建应用:

fly apps create --name <app-name>

然后,使用以下命令部署应用:

fly deploy

如果部署成功,你可以使用以下命令访问应用:

fly apps open

概述

希望通过本教程,读者对使用 Flet 框架开发和部署实际可用的应用有了一些了解。其灵活性、开发速度和开发者体验使其成为在许多不同的用例中选择的一个非常吸引人的工具,目前有越来越多的开发者使用它。