Port an existing C/C++ app to Flutter with Dart FFI

Marvin Sanchez
Level Up Coding
Published in
10 min readMar 13, 2022

--

The Challenge

Take an existing C/C++ app, upgrade its UI using Flutter, and run the modernized app on all platforms (Linux, Windows, Android, macOS, iOS).

I wrote an open sourced text editor Ashlar Text. It was written in C/C++ with the Qt framework. I made several attempts before to make app the cross-platform including running on the mobile device. I had already gone so far as to ditching Qt and trying out Curses and even making my own SDL-based UI framework. I had some success here (see TAshlar which can run on both a terminal or on an SDL based window) — but still no stable, publish worthy mobile app.

I was ready to give up on the cross-platform idea and Ashlar Text Android. But then I discovered Flutter UI. Ashlar Text is now in the PlayStore as Ashlar Code. The editor code, directory and file management, git integration, sftp, the vscode compatible syntax highlighting and theming all run in native C/C++. But the butter smooth UI presentation is all Flutter.

Ashar Text (Linux) — Download Ashlar Code to see how it runs on Android

Our challenge therefore — porting and running some ancient app to new platforms, especially the mobile device — would be a doable and fun endeavor.

Our Goal — Rogue 5.4 on the Mobile

There are a lot of C/C++ open sourced apps in github that are just begging to be ported over to the mobile platform. But there is probably not an app with a more ancient UI than one based on Curses. And a classic dungeon crawling game of Rogue would be a good candidate for an upgrade.

Screenshot from The Rogue Archive

We should be able to do the following:

  1. Port the dungeon crawler, Rogue — the grandmother of all fantasy role-playing games — to all major platforms;
  2. Render its UI with Flutter;
  3. It must be playable on the mobile — with virtual keyboard and touch interface;
  4. Optional: Publish the app in the Google PlayStore;
  5. Optional: The console text UI looks way too dated — spice up the app with a little bit of graphics;

First Things First

Learn something new — Dart FFI

Before we get ahead of ourselves, we must tackle first the obstacle we will immediately face. Flutter is written in Dart. Note that Flutter is the UI framework — Dart is the language used in Flutter. And while the Dart language looks very similar to C and C++, we must find a way to bridge our code written in C/C++ to the Dart language.

Flutter provides a package for this — FFI. FFI stands for foreign function interface. We will learn how to use it here.

There is an official way of calling native code in Flutter — Binding to native code using dart:ffi. This involves creating a Flutter plugin to be used on your Flutter app. You may prefer that route if you have a Flutter based app and want to extend its functionalities with native code. Or if you need to create a plugin like SQLite (written in C) which adds to any Flutter App database functionalities. And then share the plugin to the world.

However, the concept we have right now is the other way around. We have a C/C++ or native app. And we want to upgrade its UI with Flutter. In a sense, Flutter itself is merely the UI plugin for our C/C++ app.

So here, we will approach FFI in an slightly different way.

The Foreign Function Interface

The FFI allows us to bind our C/C++ code to Dart — or map our native functions to Dart functions. It provides a means for both languages to talk to each other.

We are targeting the Holy Grail of cross-platform development — Linux, Android, Windows, macOS, iOS. The build environment for each platform have its own nuances. Linux is using CMake for compiler configuration. In Android, we have Gradle — which luckily for us — can incorporate CMake as an external build. Windows also uses CMake to configure Visual Studio build tools. In macOS and iOS, we’ll encounter Xcode.

In a normal Flutter App, none of these would have mattered. CMake, Gradle, Xcode — these are taken cared of by Flutter’s build environment. We simply call flutter build linux or flutter build macos. But since we’re bold enough to bring our C/C++ code to Flutter, we’ll have to get familiar with some new build setups and tweaks.

FFI on Linux

We’ll build for Linux first. The build system for Flutter in Linux uses CMake.

If don’t have a Linux system, you may start with Windows instead but make sure to read through this Linux guide. If your Linux system also has the Android build environment, we’ll be able to setup the Android build easily afterwards.

Start by creating the“Hello Word” Flutter application:

flutter create testffi --platforms=linux,android,macos,windows

Alternatively, you can git clone the sample app for the article and just browse through the code as you read through.

Create a directory libs in the project folder. This is where our native code will reside. We call it “libs” as we may also add other third-party codes or libraries here.

Create next a file CMakeLists.txt.

testffi/libs/CMakeLists.txt

Add the following lines:

cmake_minimum_required(VERSION 3.10)                         project(api LANGUAGES CXX C)                         add_library(api
SHARED
./api.cpp
)

Here, we tell CMake to build our shared native library called api. It will be built as libapi.so.

Create api.cpp which will contain our C/C++ code. As a starter, use the following example:

// testffi/libs/api.cpp#define EXPORT extern "C" __attribute__((visibility("default"))) __attribute__((used))#include <cstring>EXPORT
int add(int a, int b) {
return a + b;
}
EXPORT
char* capitalize(char *str) {
static char buffer[1024];
strcpy(buffer, str);
buffer[0] = toupper(buffer[0]);
return buffer;
}

The EXPORT definition allows us to export our C++ code with C-style symbols used by Flutter/Dart.

We have as examples two simple native functions —add which takes two integers as parameters, adds the two numbers, and returns an integer; and another capitalize which takes a char* string as input, manipulates the string, and returns a char* string.

Edit linux/CMakeLists.txt to add our newly created subdirectory ./libs.

set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")...# add this line
add_subdirectory("./libs")

IMPORTANT: We created our libs folder in the project or root directory so that it is easily accessible from the other platform setups. However, adding a subdirectory in CMake requires that the directory to be part of the source tree.

cd linux
ln -s ../libs ./libs

This creates a symbolic link to testffi/libs directory from testff/linux/libs. And this satisfies CMake.

Alternatively, you can create the libs folder directly under testffi/linux — but you’ll have to update all the relative paths in the CMakeLists files.

First Build

If we run flutter build linux our api library will now be built as libapi.so as part of the build process. The output will be at:

testfii/build/linux/x86/release/libs/libapi.so

We need this libapi.so file to be located in the bundle directory so that our library can easily be located and loaded. So we further modify linux/CMakeLists.txt:

# === Installation ===...# add this line at the very end of the install commands
install(FILES ${PROJECT_BINARY_DIR}/libs/libapi.so DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime)

This will copy our api library to the correct location on every successful build. If we run the build again our libapi.so will be correctly copied:

testffi/build/linux/x86/release/bundle/lib/libapi.so

Quick Review

We have built our libapi.so. So far so good. Here is a summary of what we just did:

  1. Create a libs folder to hold our native C/C++ code. Currently, we just have libs/api.cpp;
  2. Create a libs/CMakeLists.txt to instruct CMake how to build our native library called api;
  3. Modify linux/CMakeLists.txt to include our library in the Linux build process, and to copy the output library libapi.so to the correct location;

Easy as 1, 2, 3.

Calling Native C/C++ from Flutter/Dart

Before we proceed, we add the ffi package to our project.

flutter pub add ffi

Modify lib/main.dart and add the following code:

import 'dart:ffi';
import 'package:ffi/ffi.dart';

class FFIBridge {
static bool initialize() {
nativeApiLib =(DynamicLibrary.open('libapi.so')); // android and linux
final _add = nativeApiLib.lookup<NativeFunction<Int32 Function(Int32, Int32)>>('add');
add = _add.asFunction<int Function(int, int)>();
final _cap = nativeApiLib.lookup<NativeFunction<Pointer<Utf8> Function(Pointer<Utf8>)>>('capitalize');
_capitalize = _cap.asFunction<Pointer<Utf8> Function(Pointer<Utf8>)>();
return true;
}
static late DynamicLibrary nativeApiLib;
static late Function add;
static late Function _capitalize;
static String capitalize(String str) {
final _str = str.toNativeUtf8();
Pointer<Utf8> res = _capitalize(_str);
calloc.free(_str);
return res.toDartString();
}
}

The above code defines a class FFIBridge. This is where the binding or the mapping of our native code to Dart happens. FFIBrdige has a static function initialize which we call upon running our app.

void main() {
FFIBridge.initialize();
runApp(const MyApp());
}
  1. The initialize function loads our library libapi.so
nativeApiLib = DynamicLibrary.open('libapi.so')

2. It then looks up our native function add:

final _add = nativeApiLib.lookup<NativeFunction<Int32 Function(Int32, Int32)>>('add');

3. And this returns a reference to the native function which will be mapped to the dart function FFIBridge.add — see the class member variable static late Function add;

add = _add.asFunction<int Function(int, int)>();

Now we have a binding of the native function to a Dart function. We may now call the int FFIBridge.add(int, int) function anywhere from our Dart code.

Easy as 1, 2, 3 again.

A similar process is done for the native function capitalize. However, we need a wrapper function to convert Dart String to Pointer<Utf8> when passing our string argument to the native function. And also to convert the returned native string Pointer<Utf8> back to Dart String.

static String capitalize(String dart_str) {
... // convert dart_str
_res_native_str = _capitalize( _native_str )
... // convert _res_native_str
return _res_dart_str
}

Build and Run

flutter build linux -v

Our native function calls in action:

Column(children: [
Text('capitalize flutter=${FFIBridge.capitalize('flutter')}'),
Text('1+2=${FFIBridge.add(1, 2)}'),
]);
Our Flutter App in Linux

FFI on Android

If you followed correctly the Linux instructions, and your Linux app can call the native functions correctly, then the Android step will be very, very quick:

Modify android/app/build.gradle:

android {    externalNativeBuild {
cmake {
path "../../libs/CMakeLists.txt"
}
}

We simply add an externalNativeBuild definition to the Gradle build configuration. And it points to the same CMakeLists.txt file that we wrote for Linux.

flutter build apk -v

You now have a Flutter Android app with native C/C++ calls.

The Source Code

For those who skipped all the cut-&-paste but want to see the app actually build and run:

git clone https://github.com/icedman/flutter_ffi_test/
cd flutter_ffi_test
flutter pub get
flutter run -v

FFI on Windows

CMake is also used in the build system for Windows. The setup is similar to Linux with some minor differences.

First, instead of using the CMakeLists.txt for Linux as we did in Android, we modify testffi/windows/runner/CMakeLists.txt. Add our library configuration at the very end:

add_library(api
SHARED
../../libs/api.cpp
../../libs/exports.def
)

The relative paths differ from the Linux CMakeLists but they point to the very same C++ files. This will have the effect of having our api built right next to our app executable. Unlike Linux, the Windows app directory structure does not have the bundle or the bundle/lib directories. Our library — api.dll on Windows — should be located in the same directory as our testffi executable.

testffi/build/windows/runner/api.dll
testffi/build/windows/runner/testffi.exe

The second difference is the exports.def file. Create this file in the libs folder with the following content:

LIBRARY api
EXPORTS
add
capitalize

Every new function that we want to export should be defined in the exports.def file.

And also modify api.app EXPORT definition:

#ifdef WIN32
#define EXPORT __declspec(dllexport)
#else
#define EXPORT extern "C" __attribute__((visibility("default"))) __attribute__((used))
#endif

These changes will correctly expose the native functions in our library — api.dll.

We should also update our FFIBridge class:

nativeApiLib = Platform.isMacOS || Platform.isIOS ? DynamicLibrary.process() // macos and ios
: (DynamicLibrary.open(Platform.isWindows // windows
? 'api.dll'
: 'libapi.so')); // android and linux

Linux and Android load libapi.so, Windows loads api.dll. Our macOS and iOS setup will load native function symbols directly from the runner app library process without loading an external library.

flutter build windows -v

FFI on macOS and iOS

The build system in macOS and iOS is handled by Xcode — no CMake. We incorporate our native C/C++ code directly in the Runner app. Open macos/Runner.xcodeproj.

Add a new group — without creating a new folder. Call it libs.

Add our api.cpp.

Add our files onto the new Api group

That was surprisingly easy for macOS. We follow the same method for the iOS build configuration. Disclaimer: I actually haven’t tested iOS yet.

nativeApiLib = Platform.isMacOS || Platform.isIOS ? DynamicLibrary.process()

The native function is looked up differently as they are not loaded from a separate dynamic library but from the Flutter Runner library itself.

flutter build macos -v

Quick Review

  1. We use the dart:ffi package to load and bind our native library to Dart;
  2. Android setup is matter of adding an externalNativeBuild definition in gradle, pointing it to and reuse the CMakeLists used in Linux;
  3. Windows setup is a little diffrent —we modify the windows/runner/CMakeLists.txt and add there our library build configuration;
  4. Windows require an exports.def file;
  5. Windows also has a different export function signature — __declspec(dllexport) — for dynamic link libraries (DLL);
  6. macOS and iOS lookup native function slightly differently. No CMake — but dealing with Xcode isn’t difficult;

Write Once Run Anywhere

We’ve built a Flutter App which calls native C/C++ code and which runs on Linux, Windows, Android, macOS, (and to be done and confirmed — iOS).

Before I was using Flutter, I found that setting up, building, and testing for Android alone with native external build very cumbersome. This article shows that using Flutter as our build environment takes a lot of the pain away. And we’ve covered several platforms simply by editing a few files. And now that our build environments are setup and ready for Flutter/Dart + Native C/C++, we should — in theory at least — be assured that our code will run on all our targeted platforms. We should now be able to truly Write Once Run Anywhere.

We will put this theory to the test when we port and build our Rogue 5.4 app in the second part of the article — and complete our challenge.

Preview — Rogue 5.4 on Flutter

To be continued

--

--