发布于 2026-01-05 8 阅读
0

Flutter(涵盖所有 6 个平台)与 Python 集成:一份全面的指南

Flutter(涵盖所有 6 个平台)与 Python 集成:一份全面的指南

Flutter 是一个以跨平台应用开发而闻名的 UI 框架。而 Python 是一种用途广泛的编程语言,以其易读性和庞大的库生态系统而著称。

本指南将介绍如何使用Flutter-Python Starter Kit将 Flutter 和 Python 集成到应用程序开发中的过程。

Flutter-Python入门套件部署选项

要点


Flutter + Python + PyInstaller + gRPC

允许在 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 个主要部件:

  1. prepare-sources.sh:一个脚本,用于安装依赖项,从生成 gRPC 存根.proto,创建 Dart/Python 脚手架并将文件复制到 Flutter 和 Python 项目目录。

  2. bundle-python.sh:一个脚本,用于创建一个独立的 Python 可执行文件,并将其作为 Flutter 项目中的资源打包,并更新资源版本。

  3. 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

Enter fullscreen mode Exit fullscreen mode

然后通过终端命令切换到app创建示例目录(稍后我们将对其进行修改):Flutter Counter app

flutter create . --empty
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

步骤 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
Enter fullscreen mode Exit fullscreen mode

首次运行请稍等一两分钟。此命令会安装所需的依赖项,例如 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
Enter fullscreen mode Exit fullscreen mode
  • 在上一步中,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)
Enter fullscreen mode Exit fullscreen mode

更新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)
...
Enter fullscreen mode Exit fullscreen mode

模板文件中已经包含了该NumberSortingService名称(它是硬编码的,没有被 .proto 文件识别)。在实际应用中,必须将其更改为已实现服务的名称。

您可以尝试server.py在终端中运行该命令。如果一切顺利,您将收到一条消息,表明它正在监听 localhost:

user@users-mbp my_project % python3 server/server.py 
gRPC server started and listening on localhost:50055
Enter fullscreen mode Exit fullscreen mode

注意:您可能需要更改 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'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

你应该会收到类似这样的内容:

连接到 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';
Enter fullscreen mode Exit fullscreen mode

b) 通过修改函数初始化 Python main()

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  pyInitResult = initPy();

  runApp(const MainApp());
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
...
Enter fullscreen mode Exit fullscreen mode

请注意,这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)
...
Enter fullscreen mode Exit fullscreen mode

e) 最后切换到 gRPC 客户端进行排序:

ElevatedButton(
  onPressed: () {
    //setState(() => randomIntegers.sort());
    NumberSortingServiceClient(getClientChannel())
                      .sortNumbers(NumberArray(numbers: randomIntegers))
                      .then(
                          (p0) => setState(() => randomIntegers = p0.numbers));
                },
Enter fullscreen mode Exit fullscreen mode

这是完整的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'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

注意:对于 iOS 系统,要让应用连接到远程 gRPC 服务器,请ios/Runner/Info.plist添加以下代码:

    <key>NSAppTransportSecurity</key>
    <dict>
        <key>NSAllowsLocalNetworking</key>
        <true/>
    </dict>
Enter fullscreen mode Exit fullscreen mode

步骤 6:打包 Python 可执行文件

运行bundle-python.sh脚本以创建一个独立的 Python 可执行文件(使用 PyInstaller),并将其作为资源打包到 Flutter 项目中:

./starter-kit/bundle-python.sh --flutterDir ./app --pythonDir ./server
Enter fullscreen mode Exit fullscreen mode

该脚本将运行 PyInstaller/server/server.py并将构建的文件复制到指定位置/app/assets/server_py_flutter_{platform_postfix},它还会向引用文件夹添加assets部分pubspec.yamlassets/

步骤 7:运行和调试

如果您使用的是 VSCode,可以通过 F5 以桌面应用程序的方式运行该应用程序,并获得以下用户界面(左侧 - 正在加载,右侧 - 已加载并排序):

Flutter 和 Python 集成

注意:根据调试器中的异常处理设置,在探测 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