DEV Community

Cover image for Integrating Flutter {all 6 platforms} and Python: A Comprehensive Guide
Maxim Saplin
Maxim Saplin

Posted on • Updated on

Integrating Flutter {all 6 platforms} and Python: A Comprehensive Guide

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 Flutter-Python Starter Kit.

Flutter-Python starter kit deployment options

The Gist


Flutter + Python + PyInstaller + gRPC

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.

Step 0: Get the starter kit

Download the repo 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

Enter fullscreen mode Exit fullscreen mode

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

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

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.

syntax = "proto3";

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

message NumberArray {
  repeated int32 numbers = 1;
}
Enter fullscreen mode Exit fullscreen mode

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:

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

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
Enter fullscreen mode Exit fullscreen mode
  • 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:

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

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

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

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

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.shto /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:)

main.dart, number sorting via 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

You should get something like this:

Image description

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:

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

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

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

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:

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

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:

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

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

Here’s the complete main.dart with number sorting via 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

Note: for iOS, to let the app connect to remote gRPC server, in ios/Runner/Info.plist add this:

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

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:

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

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

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

Top comments (2)

Collapse
 
nflutter profile image
NFlutter

I wonder if NFlutter can be beneficial for this.
NFlutter is a DSL designed to simplify Flutter widget creation with a syntax which is more familiar to Python developers.

check it out: nflutter.github.io