Flutter(涵盖所有 6 个平台)与 Python 集成:一份全面的指南
Flutter 是一个以跨平台应用开发而闻名的 UI 框架。而 Python 是一种用途广泛的编程语言,以其易读性和庞大的库生态系统而著称。
本指南将介绍如何使用Flutter-Python Starter Kit将 Flutter 和 Python 集成到应用程序开发中的过程。
要点
允许在 Flutter 支持的所有六个平台上使用 Python 代码,包括 macOS、Windows、Linux、Android、iOS 和 Web。Python 代码和运行时环境被打包成独立的桌面平台可执行文件,而移动和 Web 平台则使用远程托管版本。该系统依赖于 gRPC 协议定义,以确保 Flutter 客户端和 Python 服务器之间 API 的一致性,并由代码生成工具处理样板代码,使开发人员能够专注于业务逻辑。
先决条件
- Flutter SDK
- Python 3.9+
- Chocolatey 包管理器和 Git Bash(Windows 版)
- 决定使用 Nuitka(而不是 PyInstaller)时
- 最新发布的官方 Python 版本(非操作系统自带版本)
- 请确保已将 Python 添加到 PATH 系统环境变量中。
- 推荐使用 VSCode 作为集成开发环境 (IDE)。
概述
这Flutter-Python Starter Kit是一个开源项目。它包含一系列脚本和源文件,可以自动执行许多原本需要开发人员手动完成的操作。它的作用是将成熟且维护良好的技术(见上文)整合在一起,使它们协同工作。
入门套件包含 3 个主要部件:
-
prepare-sources.sh:一个脚本,用于安装依赖项,从生成 gRPC 存根.proto,创建 Dart/Python 脚手架并将文件复制到 Flutter 和 Python 项目目录。 -
bundle-python.sh:一个脚本,用于创建一个独立的 Python 可执行文件,并将其作为 Flutter 项目中的资源打包,并更新资源版本。 -
templates:一个包含现成 Dart 和 Python 文件的文件夹,可以解决许多问题,例如在 Python 端启动 gRPC 服务器、提取和启动独立可执行文件、启动 gRPC 客户端通道等。
现在,让我们深入了解 Flutter 和 Python 集成的逐步过程。
示例项目
我们将构建一个非常简单的应用程序,该程序生成一个随机数数组,将其发送到 Python,通过 NumPy 对其进行排序,然后返回到用户界面。
该指南展示了如何从零开始创建解决方案,但同样的原理/步骤也可以轻松应用于现有的代码库。
步骤 0:获取入门套件
下载代码库并将starter-kit文件夹放到项目根目录下。
步骤 1:准备 Flutter 和 Python 项目
进入项目目录,app分别为 Flutter 部分和serverPython 部分创建目录。目录结构如下所示:
my_project/
|-- app/ (Flutter app)
|-- server/ (Python module)
|-- starter-kit
然后通过终端命令切换到app创建示例目录(稍后我们将对其进行修改):Flutter Counter app
flutter create . --empty
server/暂时留空
步骤 2:在.proto文件中定义 gRPC 服务
在项目根目录下创建一个service.proto文件,用于指定数字排序的 gRPC 服务。该文件将定义 API,Python 服务器和 Flutter 客户端都将使用该 API。
syntax = "proto3";
service NumberSortingService {
rpc SortNumbers (NumberArray) returns (NumberArray) {}
}
message NumberArray {
repeated int32 numbers = 1;
}
步骤 3:生成 gRPC 绑定和辅助函数
从项目根目录运行prepare-sources.sh脚本。它将根据service.proto文件生成必要的 Dart/Flutter(客户端)和 Python(服务器)gRPC 绑定。您可能需要先授予它执行权限:
chmod 755 ./starter-kit/prepare-sources.sh; chmod 755 ./starter-kit/bundle-python.sh
./starter-kit/prepare-sources.sh --proto ./service.proto --flutterDir ./app --pythonDir ./server
首次运行请稍等一两分钟。此命令会安装所需的依赖项,例如 gRPC 工具和 PyInstaller,为 Dart 和 Python 生成 gRPC 存根,并创建其他辅助文件。
完成后,您应该会看到app/lib/grpc_generatedFlutter 应用和server/grpc_generatedPython 模块的新文件。
步骤 4:在 Python 中实现 gRPC 服务
如果我们检查/server目录,会发现它不再为空:
my_project/
|-- server/ (Python module)
|-- grpc_generated/
|-- requirements.txt
|-- server.py
- 在上一步中,protoc 编译器创建了 Python 存根
grpc_generated/,添加了requirements.txtgRPC 依赖项,复制了server.py启动新 gRPC 服务器的模板代码。
让我们添加并实现以下number_sorting.py示例中定义的服务:grpc_generated/service_pb2_grpc.pygrpc_generated/service_pb2.py
from concurrent import futures
import numpy as np
from grpc_generated import service_pb2_grpc
from grpc_generated import service_pb2
class NumberSortingService(service_pb2_grpc.NumberSortingService):
def SortNumbers(self, request, context):
arr = np.array(request.numbers)
result = np.sort(arr)
print(f"Sorted {len(result)} numbers")
return service_pb2.NumberArray(numbers=result)
更新server.py文件,加入NumberSortingService实现代码。
...
# TODO, import generated gRPC stubs
from grpc_generated import service_pb2_grpc
# TODO, import yor service implementation
from number_sorting import NumberSortingService
...
# TODO, add your gRPC service to self-hosted server, e.g.
service_pb2_grpc.add_NumberSortingServiceServicer_to_server(NumberSortingService(), server)
...
模板文件中已经包含了该NumberSortingService名称(它是硬编码的,没有被 .proto 文件识别)。在实际应用中,必须将其更改为已实现服务的名称。
您可以尝试server.py在终端中运行该命令。如果一切顺利,您将收到一条消息,表明它正在监听 localhost:
user@users-mbp my_project % python3 server/server.py
gRPC server started and listening on localhost:50055
注意:您可能需要更改 gRPC 服务器的启动方式server.py,例如更改localhost为[::]远程部署或设置 TLS。
步骤 5:更新 Flutter 应用以使用 gRPC 客户端
prepare-sources.sh对/app文件夹所做的更改
- 添加了对
pubspec.yaml(grpc, path, path_provider, protobuf)的依赖项 - 在“创建文件”中
lib/grpc_generated/- 用于数字排序服务的 Dart 客户端实现
- gRPC 健康检查服务的客户端实现(用于在启动时检查服务器是否正常运行)
- 原生和 Web 客户端通道辅助类(无论您运行的是 Web 应用还是原生应用,都会抽象化连接到 gRPC 的过程)
- Python 服务器初始化辅助类(提取资源、检查版本、启动和终止进程)
要将我们的 Flutter 应用连接到 Python,我们只需要修改main.dart文件即可。
无需 gRPC 的 UI
我们先来实现无需任何 gRPC 绑定即可对数组进行排序的 UI。
转换MainApp为有状态组件(重构工具里有个很方便的选项),添加随机列表供用户查看,以及一些用户界面……或者直接复制粘贴下面的文件:)
main.dart,通过 Dart 进行数字排序
import 'dart:math';
import 'package:flutter/material.dart';
void main() {
runApp(const MainApp());
}
class MainApp extends StatefulWidget {
const MainApp({Key? key}) : super(key: key);
@override
MainAppState createState() => MainAppState();
}
class MainAppState extends State<MainApp> {
List<int> randomIntegers =
List.generate(40, (index) => Random().nextInt(100));
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Container(
padding: const EdgeInsets.all(20),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
randomIntegers.join(', '),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
setState(() {
randomIntegers =
List.generate(40, (index) => Random().nextInt(100));
});
},
style: ElevatedButton.styleFrom(
minimumSize:
const Size(140, 36), // Set minimum width to 120px
),
child: const Text('Regenerate List'),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
setState(() => randomIntegers.sort());
},
style: ElevatedButton.styleFrom(
minimumSize:
const Size(140, 36), // Set minimum width to 120px
),
child: const Text('Sort'),
),
],
),
),
),
);
}
}
你应该会收到类似这样的内容:
连接到 Python
现在让我们运用这些生成的文件,并将排序操作交给 Python。
a) 在程序开头导入必要的 gRPC 绑定和辅助文件main.dart:
import 'package:flutter/material.dart';
import 'package:app/grpc_generated/client.dart';
import 'package:app/grpc_generated/init_py.dart';
import 'package:app/grpc_generated/init_py_native.dart';
import 'package:app/grpc_generated/service.pbgrpc.dart';
b) 通过修改函数初始化 Python main():
void main() {
WidgetsFlutterBinding.ensureInitialized();
pyInitResult = initPy();
runApp(const MainApp());
}
initPy()这是一个辅助方法,负责启动服务器和设置客户端通道。它还会提取--dart-define可以传递给构建/运行命令的参数(定义主机、连接端口以及是否必须从资源中提取服务器的标志)。
请注意,该方法返回的是一个Future不会被等待的对象,而是保存到一个全局变量中。这样做是特意的,因为 Python 服务器启动可能很耗时,我们不希望 UI 卡顿。此外,也可能出现错误。我们将使用全局变量FutureBuilder来辅助 Python 初始化进度的 UI 更新。
c) 添加WidgetsBindingObserver对应用程序关闭事件的响应
然后关闭Python服务器:
class MainAppState extends State<MainApp> with WidgetsBindingObserver {
@override
Future<AppExitResponse> didRequestAppExit() {
shutdownPyIfAny();
return super.didRequestAppExit();
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
...
请注意,这shutdownPyIfAny()是提供的辅助函数,它会发出操作系统命令来关闭服务器进程。server_py_flutter_osx在 macOS 上,它使用进程名称按名称搜索进程。默认名称可以通过--exeName运行参数进行覆盖prepare-sources.sh。` @Flutter` _osx、` @Flutter`_lin和_win.exe`@Flutter` 后缀会在构建过程中自动添加,用于区分不同 Flutter 平台上的资源。
d)FutureBuilder用于显示 Python 初始化状态:
...
SizedBox(
height: 50,
child:
// Add FutureBuilder that awaits pyInitResult
FutureBuilder<void>(
future: pyInitResult,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Stack(
children: [
SizedBox(height: 4, child: LinearProgressIndicator()),
Positioned.fill(
child: Center(
child: Text(
'Loading Python...',
),
),
),
],
);
} else if (snapshot.hasError) {
// If error is returned by the future, display an error message
return Text('Error: ${snapshot.error}');
} else {
// When future completes, display a message saying that Python has been loaded
// Set the text color of the Text widget to green
return const Text(
'Python has been loaded',
style: TextStyle(
color: Colors.green,
),
);
}
},
),
),
const SizedBox(height: 16)
...
e) 最后切换到 gRPC 客户端进行排序:
ElevatedButton(
onPressed: () {
//setState(() => randomIntegers.sort());
NumberSortingServiceClient(getClientChannel())
.sortNumbers(NumberArray(numbers: randomIntegers))
.then(
(p0) => setState(() => randomIntegers = p0.numbers));
},
这是完整的main.dart 文件,其中包含使用 Python 进行数字排序的功能。
import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:app/grpc_generated/client.dart';
import 'package:app/grpc_generated/init_py.dart';
import 'package:app/grpc_generated/init_py_native.dart';
import 'package:app/grpc_generated/service.pbgrpc.dart';
Future<void> pyInitResult = Future(() => null);
void main() {
WidgetsFlutterBinding.ensureInitialized();
pyInitResult = initPy();
runApp(const MainApp());
}
class MainApp extends StatefulWidget {
const MainApp({Key? key}) : super(key: key);
@override
MainAppState createState() => MainAppState();
}
class MainAppState extends State<MainApp> with WidgetsBindingObserver {
@override
Future<AppExitResponse> didRequestAppExit() {
shutdownPyIfAny();
return super.didRequestAppExit();
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
List<int> randomIntegers =
List.generate(40, (index) => Random().nextInt(100));
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Container(
padding: const EdgeInsets.all(20),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text.rich(
TextSpan(
children: [
const TextSpan(
text: 'Using ',
),
TextSpan(
text: '$defaultHost:$defaultPort',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
TextSpan(
text:
', ${localPyStartSkipped ? 'skipped launching local server' : 'launched local server'}',
),
],
),
),
const SizedBox(height: 16),
SizedBox(
height: 50,
child:
// Add FutureBuilder that awaits pyInitResult
FutureBuilder<void>(
future: pyInitResult,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Stack(
children: [
SizedBox(height: 4, child: LinearProgressIndicator()),
Positioned.fill(
child: Center(
child: Text(
'Loading Python...',
),
),
),
],
);
} else if (snapshot.hasError) {
// If error is returned by the future, display an error message
return Text('Error: ${snapshot.error}');
} else {
// When future completes, display a message saying that Python has been loaded
// Set the text color of the Text widget to green
return const Text(
'Python has been loaded',
style: TextStyle(
color: Colors.green,
),
);
}
},
),
),
const SizedBox(height: 16),
Text(
randomIntegers.join(', '),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
setState(() {
randomIntegers =
List.generate(40, (index) => Random().nextInt(100));
});
},
style: ElevatedButton.styleFrom(
minimumSize:
const Size(140, 36), // Set minimum width to 120px
),
child: const Text('Regenerate List'),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
//setState(() => randomIntegers.sort());
NumberSortingServiceClient(getClientChannel())
.sortNumbers(NumberArray(numbers: randomIntegers))
.then(
(p0) => setState(() => randomIntegers = p0.numbers));
},
style: ElevatedButton.styleFrom(
minimumSize:
const Size(140, 36), // Set minimum width to 120px
),
child: const Text('Sort'),
),
],
),
),
),
);
}
}
注意:对于 iOS 系统,要让应用连接到远程 gRPC 服务器,请ios/Runner/Info.plist添加以下代码:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
步骤 6:打包 Python 可执行文件
运行bundle-python.sh脚本以创建一个独立的 Python 可执行文件(使用 PyInstaller),并将其作为资源打包到 Flutter 项目中:
./starter-kit/bundle-python.sh --flutterDir ./app --pythonDir ./server
该脚本将运行 PyInstaller/server/server.py并将构建的文件复制到指定位置/app/assets/server_py_flutter_{platform_postfix},它还会向引用文件夹添加assets部分。pubspec.yamlassets/
步骤 7:运行和调试
如果您使用的是 VSCode,可以通过 F5 以桌面应用程序的方式运行该应用程序,并获得以下用户界面(左侧 - 正在加载,右侧 - 已加载并排序):
注意:根据调试器中的异常处理设置,在探测 Python 服务器时,由于辅助类吞噬了异常,您可能会触发断点。
根据具体情况,您可能不希望服务器从资源启动,而是使用您在调试器中启动的服务器。或者,如果您运行的是移动客户端,则可能没有自托管服务器。为了帮助您进行各种设置,您可以:
- 传入要
server.py监听的端口号,例如python3 server.py 8080 - 使用` --build`
--dart-define和`--run`参数来运行 Flutter 的构建/运行命令。porthostuseRemote
入门套件中提供的示例中,有一个launch.json文件app/.vscode,其中包含一些针对不同情况的启动配置。
另请注意,调试 Web 客户端时,您需要设置一个 Web 代理来处理来自客户端的入站连接并将其转发到 gRPC。工具包中的示例也涵盖了这一点,它依赖于(这个)[ https://github.com/improbable-eng/grpc-web/ ] 命令行代理,这可以避免您使用 Envoy/Docker。
结论
本指南展示了 Flutter 和 Python 集成的完整案例,该案例可以推广到任何其他代码库。建议的解决方案具有一些独特之处,例如 Python 部分与 Flutter 完全隔离在一个独立的进程中(因此不会阻塞 UI 或导致 UI 崩溃),可以管理子服务器进程的生命周期,提供可集成到构建管道中的预编写 shell 脚本文件等等。完整列表请参见“满足的要求”部分。
建议的方法并非唯一,还有其他解决方案,我将在下一篇文章中介绍。然而,我最终创建这个入门套件的主要原因是,所有建议的方法都不完善(大多数教程都留有悬念,许多重要问题没有得到解答),或者平台支持有限。
文章来源:https://dev.to/maximsaplin/integrating-flutter-all-6-platforms-and-python-a-compressive-guide-4ipo


