Integrating Flutter {all 6 platforms} and Python: A Comprehensive Guide
Tags: flutter, python, crossplatform, tutorial
Published: 2023-09-11 09:15:54

Flutter is a UI framework known for cross-platform application development. While Python is a versatile programming language famed for its readability and vast library ecosystem. 

This guide will cover the process of integrating Flutter and Python for app development using the <a href="https://github.com/maxim-saplin/flutter_python_starter">Flutter-Python Starter Kit. </a>

![Flutter-Python starter kit deployment options](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7emukudbwvkczjarjnz8.png)

## The Gist

----------------------------------
<center>Flutter ＋ Python ＋ PyInstaller ＋ gRPC</center>
----------------------------------

Enable the use of Python code across all six platforms supported by Flutter, including macOS, Windows, Linux, Android, iOS, and Web. The Python code and runtime are packaged as self-contained executables for desktop platforms, while a remotely hosted version is utilized for mobile and web platforms. The system relies on gRPC proto definition for a consistent API between the Flutter client and Python server, with code generation tools handling the boilerplate tasks, allowing developers to focus on the business logic.

## Prerequisites

- Flutter SDK
- Python 3.9+
- Chocolately package manager and Git Bash (for Windows)
- When deciding to use Nuitka (instead of PyInstaller)
  - Recent release official release of Python (not the one provided by OS)
  - Ensure Python is added to the PATH system environment variable.
- VSCode is recommended as IDE

## Overview

The `Flutter-Python Starter Kit` is an open-source project. It is a bundle of scripts and source files that automate a number of actions that would otherwise require developers to do them manually. What is does is putting together established and well-maintained technologies (see above) making them work together.

The starter kit consists of 3 main components:

1. `prepare-sources.sh`: A script that installs dependencies, generates gRPC stubs from a `.proto`, creates Dart/Python scaffolding and copies files to the Flutter and Python project directories.

2. `bundle-python.sh`: A script that creates a self-contained Python executable and bundles it as an asset in the Flutter project, updates asset version.

3. `templates`: A folder with ready-made Dart and Python files that solve many problems, such as starting a gRPC server on the Python side, extracting and launching standalone executable, firing up gRPC client channels, etc.

Now, let’s dive into the step-by-step process of integrating Flutter and Python.

## Sample Project

We’re going to build a very simple app that generates an array of random numbers, sends it to Python, sorts them via NumPy and returns back to the UI.

The guide showcases creating the solution from the scratch **yet** the same principles/steps can be easily applied to existing code base.

[Complete sources of the example are here.](https://github.com/maxim-saplin/flutter_python_starter/tree/main/example)

### Step 0: Get the starter kit

Download [the repo](https://github.com/maxim-saplin/flutter_python_starter) and put the `starter-kit` folder to the root of your project.

### Step 1: Preparing Flutter and Python projects

Go to the project directory and create `app` for the Flutter part and `server` for the Python part. The structure will look like that:

```

my_project/
|-- app/ (Flutter app)
|-- server/ (Python module)
|-- starter-kit

```

Then switch to `app` directory create sample `Flutter Counter app` (which we’ll modify latter) via the terminal command:

```
flutter create . --empty
```

Leave `server/` empty for the time being

### Step 2: Define gRPC service in `.proto` file

At the root of the project, create a `service.proto` file to specify the number sorting gRPC service. This file will define the API, which both the Python server and the Flutter client will use.

```proto
syntax = "proto3";

service NumberSortingService {
  rpc SortNumbers (NumberArray) returns (NumberArray) {}
}

message NumberArray {
  repeated int32 numbers = 1;
}
```

### Step 3: Generating gRPC bindings and helpers

From the root of the project folder, run the `prepare-sources.sh` script. It will generate the necessary Dart/Flutter (client) and Python (server) gRPC bindings from the `service.proto` file. You might need to give it execute permission first:

```bash
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
```

Give it a minute or two for the first run. This command installs the required dependencies, such as gRPC tools and PyInstaller, generates gRPC stubs for both Dart and Python, and creates additional helper files.

Upon completion, you should see new files in `app/lib/grpc_generated` for the Flutter app and `server/grpc_generated` for the Python module.

### Step 4: Implementing the gRPC service in Python

If we check `/server` directory, it is no longer empty:

```
my_project/
|-- server/ (Python module)
    |-- grpc_generated/
    |-- requirements.txt
    |-- server.py
```

- during previous step, protoc compiler created Python stubs in `grpc_generated/`, added `requirements.txt` with gRPC dependencies, copied `server.py` template code that spins up a new gRPC server.

Let’s add `number_sorting.py` and implement the service defined in `grpc_generated/service_pb2_grpc.py` and `grpc_generated/service_pb2.py`:

```python
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)
```

Update the `server.py` file to include the `NumberSortingService` implementation. 

```Python
...
# 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)
...
```

It happens that the template file already has `NumberSortingService` in it (it is hard-coded and not taken for .proto). In a real app this must be changed to name of the implemented service.

You can try running the `server.py` in the terminal. If all went fine you’ll get a message that is it listening at localhost:

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

Note: you might want to change the way gRPC server is started in `server.py`, i.e. change `localhost` to `[::]` for remote deployment OR set up TLS.

### Step 5: Updating the Flutter app to use the gRPC client

#### Changes made by `prepare-sources.sh`to `/app` folder

- Added dependencies to `pubspec.yaml` (grpc, path, path_provider, protobuf)
- Made files in `lib/grpc_generated/`
  - Dart client implementation for number sorting service
  - Client implementation for gRPC health check service (used to check if the server is up and running upon launch)
  - Native and Web client channel helper classes (abstracts away connecting to gRPC no matter if you’re running web or native app)
  - Python server init helper classes (extracting assets, checking its version, launching and killing processes)

To connect our Flutter app to Python, we will only have to make changes `main.dart` file.

#### UI without gRPC

Let’s start by implementing the UI for sorting an array without any gRPC bindings.

Convert `MainApp` to stateful widget (there’s a handy Refactor option for that), add the random list to stare, some UI... Or just copy and paste the below file:)

{% details **main.dart**, number sorting via 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'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
```
{% enddetails %}

You should get something like this:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dctfu725homrwplca2dd.png)

#### Connecting to Python

Now let’s put to action those generated files and handover the sorting operation to Python.

a) Import the necessary gRPC bindings and helper files at the beginning of `main.dart`:

```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) Initialize Python by changing the `main()` function:

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

  runApp(const MainApp());
}
```

`initPy()` is the helper method that takes care of spinning the server and setting up client channels. It also extracts `--dart-define` params that can be passed with build/run commands (the define host, port to connect and flag if server must be extracted from the assets).

Note that the method returns a `Future` which is not awaited but rather saved to a global var. This is done on purpose since Pyhton server launch can be time consuming and we do not want the UI to hang. Beside, there can be errors. Latter or we’ll use `FutureBuilder` to help with UI updates for Python init progress.

c) Add `WidgetsBindingObserver` to respond to app close event

And shut down the Python server:

```Dart
class MainAppState extends State<MainApp> with WidgetsBindingObserver {
  @override
  Future<AppExitResponse> didRequestAppExit() {
    shutdownPyIfAny();
    return super.didRequestAppExit();
  }

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }
...
```

Note the `shutdownPyIfAny()` is the one provided but the helper and issues an OS command to close the server process. It uses process name `server_py_flutter_osx` on macOS to search for process by name. The default name can be overriden via `--exeName` parameter when running `prepare-sources.sh`. The `_osx`, `_lin` and `_win.exe` postfixes are added automatically during build process and used to discern assets on different Flutter platforms. 

d) Use `FutureBuilder` to display the status of Python initialisation:

```Dart
...
  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) And finally switch to gRPC client doing the sorting:

```Dart
ElevatedButton(
  onPressed: () {
    //setState(() => randomIntegers.sort());
    NumberSortingServiceClient(getClientChannel())
                      .sortNumbers(NumberArray(numbers: randomIntegers))
                      .then(
                          (p0) => setState(() => randomIntegers = p0.numbers));
                },
```

{% details Here’s the complete **main.dart** with number sorting via Python %}
```Dart
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'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
```
{% enddetails %}

**Note:** for iOS, to let the app connect to remote gRPC server, in `ios/Runner/Info.plist` add this:
  ```xml
    <key>NSAppTransportSecurity</key>
    <dict>
        <key>NSAllowsLocalNetworking</key>
        <true/>
    </dict>
  ```

### Step 6: Bundling the Python executable

Run the `bundle-python.sh` script to create a self-contained Python executable (using PyInstaller) and bundle it as an asset in the Flutter project:

```bash
./starter-kit/bundle-python.sh --flutterDir ./app --pythonDir ./server
```

The script will run PyInstaller against `/server/server.py` and copy the built file to `/app/assets/server_py_flutter_{platform_postfix}`, it will also add `assets` section to `pubspec.yaml` referencing `assets/` folder.

### Step 7: Running and Debugging

If you’re using VSCode, you can run the app via F5 as a desktop app and get the following UI (left - loading, right - loaded and sorted):

![Flutter and Python integrated](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4obpmqpdmpfn4s5jggvv.png)

Note: depending on your exception handling settings in Debugger you might hit breakpoints due to exceptions that are swallowed by helper classes while probing Python server.

Depending on specific scenario you might want to have the server not being started from the asset but  use the one you’ve started in the debugger. Or if you are running mobile client, you don’t have self hosted server. To help with different kinds of of setups you can:

- Pass in port number to `server.py` to listen to, e.g. `python3 server.py 8080`
- Use `--dart-define` and `port`, `host`, `useRemote` arguments with build/run commands for Flutter

With the example provided along the starter kit, there’s a [launch.json](https://github.com/maxim-saplin/flutter_python_starter/blob/main/example/app/.vscode/launch.json) filer under `app/.vscode` that has a few launch configurations for different cases.

Also note that when debugging a web client you need to set-up a web proxy that will handle inbound connections from the client and forwards them to gRPC. The example coming with the kits also has this covered relying (on this)[ https://github.com/improbable-eng/grpc-web/] command line proxy that can save you from going the Envoy/docker route.

## Conclusion

In the guide you’ve seen end-to-end case with Flutter and Python integration, which can be extrapolated to any other code base. There’re few distinctive features to the suggested solution, such as Python part being completely isolated from Flutter in a separate process (and hence non-blocking the UI or not crashing it), managing the lifecycle of the child server process, availability of pre-cooked shell script files that can be integrated into build pipelines and many more. See full list in [Requirements fulfilled](https://github.com/maxim-saplin/flutter_python_starter/blob/main/README.md#requirements-fulfilled) section.

The suggested approach is not the only one and there’re alternative solutions which I will cover in the next post. Yet the key reason I’ve ended up creating the starter kit is that none of the suggested ways were complete (most tutorials are open ended with many important questions left unanswered) or were limited in platforms support.