If you've ever developed applications with Flutter, you've probably pulled a package from pub.dev
that uses FFI (foreign function interface). These packages call native C APIs and handle all the basic memory management tasks. I'll not delve too deep into details on C interop using dart:ffi
in this article, for that, you should simply read the official documentation.
Programming languages you'd typically associate with the creation of such packages are, well, kinda obviously, C and C++. However, the conditions for developing an FFI Flutter plugin are fairly straightforward: the language must support creating libraries that can be compiled into binary format, these binaries must be compatible with the target platform, and they need to support C/C++ interop. Technically speaking, the C interop is the important one. You can mix C and C++, but you have to be aware of what you're doing.
Besides the usual suspects, Rust comes up in the discussion quite a bit. Funny enough, many FFI-based packages use Rust nowadays. Since I'm not experienced with Rust (beyond being able to write a hello world program and read Rust code with the help of AI), I cannot comment much on the viability of the language for that particular task. As far as I'm aware, flutter_rust_bridge
is a popular choice. Other than C, C++, and Rust, I would imagine that Zig would be a good candidate as well. However, the language and its community are fairly small (as far as I know, Bun.sh is the only Zig's flagship project), so I'm not sure how many Flutter developers even know about it, let alone use it.
So what about Go?
Background
First time I heard of Go was in 2019, and at the time, I was not interested in system programming. Sure, as a student researcher, I worked extensively with C (and to a certain extent with C++), but the language never really drew my attention until sometime in 2022 when I was evaluating an alternative to Nest.js-based microservice. The reason was mostly memory footprint and ease of deployment. I spent a few hours going through the language tour, and found the language extremely easy to work with.
But I only saw the usability of Go as an alternative to Python for scripting/CLI application development, and obviously for backend development. Especially when it comes to gRPC (but that's a story for another time). I never looked at Go as an option for the development of FFI Flutter plugins, until sometime last year, I got acquainted with Tailscale. Tailscale has built its products around Go, including mobile applications. For reference, I'd strongly encourage you to check the source code of their Android client and the Swift library inside the main libtailscale
repository. So, somebody did it... A successful company uses Golang for its clients.
Experiment
The big picture of my experiment is simply this:
- Create an FFI Flutter plugin that uses Golang, supporting Android and iOS
- Create a simple proof-of-concept application that's not a dumb hello world app or a default "sum of two numbers" example.
With that in mind, I set out to develop an RSS reader application called RSSit.
Prerequisites
Before doing anything, let's go over prerequisites:
- We need to make sure that Golang supports C interop
- Can produced binaries be used in Android or iOS applications, and what build tool will be used?
- How to efficiently pass (what might end up being) a large amount of data between Go and Dart?
So let's tackle those one by one.
C, Go, and Cgo
There's a thing called Cgo. Cgo enables the creation of Go packages that call C code. I'm not going to discuss Cgo in detail, as their official documentation should be enough for anyone to get started.
One important note here: because we'll be using Cgo and Go's built in compiler, there's no reason to rely on Clang. The only file left from the default example was the header file declaring exports of compiled libraries, something used by ffigen
package to produce Dart bindings.
Native libraries in Android and iOS
For Android, we'll need to use NDK and produce a dynamic library for each CPU architecture. For iOS, we'll produce static libraries + corresponding header files.
Wait... How do you know that?
Simple. What's the difference between shared and dynamic libraries? Statically linked libraries copy all the library modules used in the program into the final executable image, while dynamically linked libraries load the external shared libraries into the program, binding them to the final image.
Because we're dealing with Cgo, setting CGO_ENABLED=1
is going to be a must. This environment variable controls whether the Cgo tool is used during the build process (which, yes, it is). It determines if Go code can interact with C code and libraries (which, yes, it has to).
Android apps load native libraries dynamically at runtime. iOS applications, on the other hand, are distributed as single bundles, meaning all code has to be included.
When building the Go library, deciding between static/dynamic libraries is handled using buildmode
.
- For static libraries, use
-buildmode=c-archive
- For dynamic libraries, use
-buildmode=c-shared
For Android, multiple CPU architectures have to be supported (ARMv7, ARM64, x86, x86_64). You set the architecture using GOARCH
and the operating system using the GOOS
environment variables. On top of that, the CC
environment variable should link to Android NDK compiler.
#!/usr/bin/env bash
set -euo pipefail
# Library configuration
PROJECT_NAME="rss_it_library"
LIB_NAME="lib${PROJECT_NAME}"
PREBUILD_PATH="../prebuild/Android"
# Build mode configuration
BUILD_MODE="${1:-release}"
GO_BUILD_FLAGS="-trimpath -ldflags=-s"
# Detect OS for NDK path
NDK_VERSION="27.0.12077973"
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
ANDROID_HOME="${HOME}/Android/Sdk"
NDK_PLATFORM="linux-x86_64"
elif [[ "$OSTYPE" == "darwin"* ]]; then
ANDROID_HOME="${HOME}/Library/Android/sdk"
NDK_PLATFORM="darwin-x86_64"
elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then
ANDROID_HOME="${LOCALAPPDATA//\\//}/Android/Sdk"
NDK_PLATFORM="windows-x86_64"
else
echo "Unsupported operating system: $OSTYPE"
exit 1
fi
# Check if NDK exists
NDK_BIN="${ANDROID_HOME}/ndk/${NDK_VERSION}/toolchains/llvm/prebuilt/${NDK_PLATFORM}/bin"
if [ ! -d "$NDK_BIN" ]; then
echo "Error: Android NDK not found at ${NDK_BIN}"
echo "Please install Android NDK ${NDK_VERSION} or update the script with your NDK path"
exit 1
fi
echo "Using Android NDK at: ${NDK_BIN}"
echo "Building ${PROJECT_NAME} for Android..."
# Function to build for a specific architecture
build_for_arch() {
local arch="$1"
local goarch="$2"
local cc="$3"
local dir_name="$4"
local goarm="${5:-}"
echo "Building for ${arch}..."
# Create output directory
mkdir -p "${PREBUILD_PATH}/${dir_name}"
# Set environment variables
export CGO_ENABLED=1
export GOOS=android
export GOARCH="$goarch"
[ -n "$goarm" ] && export GOARM="$goarm"
export CC="${NDK_BIN}/${cc}"
# Build the library
go build $GO_BUILD_FLAGS -buildmode=c-shared \
-o "${PREBUILD_PATH}/${dir_name}/${LIB_NAME}.so" .
# Remove header file
rm -f "${PREBUILD_PATH}/${dir_name}/${LIB_NAME}.h"
echo "Successfully built for ${arch}"
}
# Build for all architectures
build_for_arch "ARMv7" "arm" "armv7a-linux-androideabi21-clang" "armeabi-v7a" "7"
build_for_arch "ARM64" "arm64" "aarch64-linux-android21-clang" "arm64-v8a"
build_for_arch "x86" "386" "i686-linux-android21-clang" "x86"
build_for_arch "x86_64" "amd64" "x86_64-linux-android21-clang" "x86_64"
echo "Android build complete. Libraries are in ${PREBUILD_PATH}"
On iOS, we only need to support two architectures (x86_64 and ARM64), we use Xcode command line tools and the Clang compiler (set to CC
environment variable). I rely on lipo
to create a universal library, which is something you need to support iPhone Simulators.
#!/usr/bin/env bash
set -euo pipefail
# Check if running on macOS
if [[ "$OSTYPE" != "darwin"* ]]; then
echo "Error: This script must be run on macOS"
exit 1
fi
# Library configuration
PROJECT_NAME="rss_it_library"
LIB_NAME="lib${PROJECT_NAME}"
PREBUILD_PATH="../prebuild/iOS"
# Build mode configuration
BUILD_MODE="${1:-release}"
GO_BUILD_FLAGS="-trimpath -ldflags=-s"
# Check for Xcode tools
if ! command -v xcrun &> /dev/null; then
echo "Error: Xcode command line tools not found"
echo "Please install Xcode command line tools with: xcode-select --install"
exit 1
fi
echo "Building ${PROJECT_NAME} for iOS..."
# Minimum iOS version
MIN_IOS_VERSION="11.0"
# Function to build for a specific architecture
build_for_arch() {
local sdk="$1"
local goarch="$2"
local carch="$3"
local device_type="$4"
echo "Building for ${device_type} (${carch})..."
# Create output directory
mkdir -p "${PREBUILD_PATH}/${sdk}/${carch}"
# Get SDK path
SDK_PATH=$(xcrun --sdk "${sdk}" --show-sdk-path)
# Set target triple
if [ "$sdk" = "iphoneos" ]; then
TARGET="${carch}-apple-ios${MIN_IOS_VERSION}"
else
TARGET="${carch}-apple-ios${MIN_IOS_VERSION}-simulator"
fi
# Find Clang compiler
CLANG=$(xcrun --sdk "${sdk}" --find clang)
# Set environment variables
export GOOS=ios
export CGO_ENABLED=1
export GOARCH="${goarch}"
export CC="${CLANG} -target ${TARGET} -isysroot ${SDK_PATH}"
# Build the library
go build ${GO_BUILD_FLAGS} -buildmode=c-archive \
-o "${PREBUILD_PATH}/${sdk}/${carch}/${LIB_NAME}.a" .
# Remove header file
rm -f "${PREBUILD_PATH}/${sdk}/${carch}/${LIB_NAME}.h"
echo "Successfully built for ${device_type} (${carch})"
}
# Build for each platform
build_for_arch "iphonesimulator" "amd64" "x86_64" "iOS Simulator"
build_for_arch "iphonesimulator" "arm64" "arm64" "iOS Simulator (Apple Silicon)"
build_for_arch "iphoneos" "arm64" "arm64" "iOS Device"
echo "iOS build complete. Libraries are in ${PREBUILD_PATH}"
# Ask if user wants to create a universal library
read -p "Create universal library for simulators? (y/n): " create_universal
if [[ "${create_universal}" == "y" ]]; then
echo "Creating universal library for simulators..."
# Create universal directory
UNIVERSAL_DIR="${PREBUILD_PATH}/iphonesimulator/universal"
mkdir -p "${UNIVERSAL_DIR}"
# Create universal binary
lipo -create \
"${PREBUILD_PATH}/iphonesimulator/x86_64/${LIB_NAME}.a" \
"${PREBUILD_PATH}/iphonesimulator/arm64/${LIB_NAME}.a" \
-output "${UNIVERSAL_DIR}/${LIB_NAME}.a"
echo "Universal library created at: ${UNIVERSAL_DIR}/${LIB_NAME}.a"
fi
Finally, in both cases GO_BUILD_FLAGS="-trimpath -ldflags=-s"
is used to reduce the binary size by trimming paths and stripping debugging symbols.
Efficient data serialization/deserialization
Probably the last thing you wanna do is marshal the response to a JSON string, and send it over, only to be decoded on the Dart side. Sure, if your JSON looks like a simple {"status": "success"}
, but RSS/Atom/JSON feeds, especially if [arsing] a large number of them, will produce a massive JSON object.
Not just for this purpose, but in general, I'm against the idea of using human-readable data format for data in motion. There's absolutely no reason for us to rely on JSON in 2025, when we have efficient protocols, like protocol buffers, that use a binary format (aka the wire format).
Let's do a short Q&A with some of the questions I've been asked in regards to the opinion above.
-
"Oh, but how do you test your backends in that case?" If you're only manually testing your backend code with Postman, Insomnia, or whatever HTTP client out there, you're doing something wrong. Your backend should contain automated tests, and (of course) manual QA. With that being said, Postman and Insomnia allow you to work with gRPC, and they will render the response in a human-readable format. Same with
grpcurl
. - "But what about web application? gRPC uses HTTP/2, which is not yet supported!" Just make a proxy.
- "But JSON is a de facto standard, we can't just expect everyone to move over to something else?" XML was a standard before JSON. Many of you who grew up only knowing JSON probably have no clue what SOAP is. Point being, standards, consensuses, and trends change.
-
"But there's too much overhead associated with protocol buffers, I need so many extra steps!" The only "extra step" is writing a common contract between clients and servers, and using a
protoc
compiler to generate client/server code. Besides, speaking of overhead: let's talk about data serialization/deserialization, having to generate OpenAPI specifications, perhaps use a code generator to produce client code from the .json specification file, and don't get me started on request/response validation, streaming requests, SSE, ... - "But it's a whole new language to learn!" Web developers have no problem learning Prisma, but will cry about protocol buffers, whose syntax is much easier to get a hang on (took me a few minutes to get a grasp of it).
Use of goroutines
I was initially worried about whether or not my heavy reliance on goroutines would have any impact and/or would break when used in Flutter FFI, but after doing a bit of digging, I realized that goroutines are compiled into lightweight threads managed by Go's runtime, embedded in the library.
What are my main takeaways?
During this experiment, I learned a few valuable lessons. If I had to produce an executive summary:
- Go is a viable language for the development of FFI Flutter plugins
- Using protocol buffers pays off, as it gives me a common contract between Go and Dart, and the efficient data serialization/deserialization is worth the setup
- I believe that my setup is easily replicable for any use case
As I mentioned in one of my long Twitter threads, the argument of "pick the right tool for the job", which is something I completely stand behind, in my opinion easily applies here. I don't think this experiment should be taken as "everyone needs to start using Go for the development of FFI Flutter plugins", but rather "here's an option".
I'm pretty sure there's a lot of great libraries developed in Go (or there's a lot of great engineers who could produce great libraries in Go). Opening the doors to using Go, and its extensive ecosystem, in Flutter application development, has a bunch of positive implications.
Top comments (3)
You should try using zig to cross compile CGO and not need the make file!
I'm pretty sure Zig is probably the second best thing after using C/C++ in terms of DX.
Nice posting! Can we talk?