Calling F#/.NET code from Flutter with Mono
I'm obliged to issue a severe warning to anyone who found their way here.
DO NOT DO anything I explain below.
Seriously.
I'd sooner recommend juggling a pair of flaming chainsaws. Everything I'm about to say is a terrible idea and you should ignore it completely.
Why?
Because although it's possible to glue the Mono and Flutter runtimes together, it will leave your application looking like the software equivalent of the Somme circa 1916.
Only try this yourself if you really, really want an application that:
- not testable end-to-end
- double the binary size it was originally
- dependent on C, CMake, F# and MSBuild, most of which are foreign (or at least new) to your average mobile developer
- only buildable in parts, since no single build system can address the assembly dependency/versioning issues you'll experience on the .NET side with the conventional Android/Objective-C/Swift build system.
If you really need .NET in a mobile application, use Xamarin.
If you really want to use Flutter, rewrite your library in Dart.
I beg you - don't mix and match.
I did it, and ended up with such a mess that I went back and took the second option. In less time than it took me to do the below, I might add.
I honestly can't think of a single situation where the trade-off will be net positive.
With that out of the way, let's look at the motivation.
Bridging Flutter and .NET with Mono
Say you have an existing cross-platform mobile application written in Flutter/Dart.
One day you happen upon a marvellous .NET library written in (say) F# that takes your application from humdrum to unicorn.
You're itching to integrate the two. How do you do it?
First, a quick recap of what a Flutter application looks like.
A single Dart code base with a nice layout/rendering framework that runs on both Android and iOS via the Dart VM.
On Android, your application code and the framework itself are compiled to bytecode, which is then JIT-compiled by the Dart VM and executed by the operating system on the CPU.
Apple does not allow JIT-compiled code on iOS, so the code is AOT-compiled and skips the bytecode-JIT compile step.
Bear in mind I'm using a fairly loose definition of "operating system" here, encompassing emulators, simulators, system libraries and native APIs.
Now on the .NET side, things look reasonably similar. F# code, compiled to CIL (bytecode), JIT-compiled via the runtime (.NET or Mono) and executed by the OS on the CPU.
For a Flutter application to interop with native code, the two runtimes (or static libraries, in an AOT context) need to communicate.
Although this is under development, Dart does not currently support native interop. Flutter can only communicate with native code via platform channels in Java (for Android) or Objective C/Swift. These are straightforward and well-documented, so I won't go into those here.
To interop with a F#/.NET assembly, we'll need to embed a native CLR capable of running on arm64, armv7 or x86 (for emulators), as well as the iOS and Android operating systems.
Mono is the only runtime that fits the bill here. Neither .NET Framework nor .NET Core are available for mobile architectures - I assume this is because Microsoft acquired Xamarin (which runs on Mono under the hood), and supporting two cross-platform CLR implementations wouldn't be prudent.
Embedding Mono
So, how do we "embed" Mono? What does that even mean?
Similar to .NET Framework, a Mono "installation" is a set of libraries (either statically or dynamically compiled) containing basic CLR types (String, Object, etc), system libraries (file, memory allocation, etc), a JIT compiler and instructions ("trampolines") needed to invoke JIT-compiled code. Methods in your own .NET assembly are invoked via the Mono runtime.
"Embedding" Mono basically means:
- Building/deploying the correct Mono libraries for the given OS/architecture combination
- Initializing the Mono runtime to ensure all base libraries are correctly loaded
- Glue code to change data going in/coming out of Mono from "native" data types to "Mono" types (e.g. converting JNI JStrings or Objective-C NSString to Mono Strings)
- Glue code to locate your own assembly (and the methods/classes you want to use) so you can pass your data in and handle any exceptions.
Note this all occurs in native (C) code - so on Android, for example, your application will end up looking like this:
By now, you might be starting to understand why the complexity just isn't worth it.
Compiling Mono
To begin with, you'll need to compile the Mono runtime and a cross-compiler (since all assemblies need to be AOT-compiled for iOS).
Clone the repository at https://github.com/mono/mono/ and follow the instructions in the sdks directory (note this requires an existing Mono installation to bootstrap, as well as automake and ninjna).
For Android, it's theoretically possible to do this on Windows, but I had major issues via both cygwin and WSL. In the end, I built all the Android-specific libraries on Linux. For the iOS libraries, everything needs to be built on MacOS.
Under sdks/out, you'll end up with Mono builds for each architecture/OS combination:
- ./android-arm64-v8a-release
- ./android-armeabi-v7a-release
- ./ios-bcl
- ./ios-cross64-release
- ./ios-target64-release
This should build the base class libraries (BCLs - standard CLI libraries handling core tasks like assembly loading, security, etc). However, if it does not, you may need to run autogen and make in the root directory first:
{{< highlight shell >}} ./autogen.sh --with-monodroid # for Android ./autogen.sh --with-monotouch # for iOS make {{< / highlight >}}
Compile your own assembly
Let's say the F# module you want to invoke looks like this:
{{< highlight fsharp >}} module Hello
[
Compile this with fsharpc (or dotnet build), targeting your main project as a .NET 4.6.2+ console application.
Mono requires an assembly entry point to setup the correct AppDomain paths, and there's some incompatibility with .NET Core apps. Note the EntryPoint annotation.
[Android] Copy libraries
On Android, zip all project DLLs, BCLs and config together.
{{< highlight shell >}} mkdir myproj mkdir myproj/lib mkdir myproj/lib/mono mkdir myproj/lib/mono/4.5 mkdir myproj/etc mkdir myproj/etc/mono cp $SRCDIR/hello.dll myproj cp -R $MONO_REPO_DIR/mcs/class/lib/monotouch/.dll myproj/lib # replace monotouch with monodroid for Android cp -R $MONO_REPO_DIR/mcs/class/lib/monotouch/Facades/.dll myproj/lib mv myproj/lib/mscorlib.dll myproj/lib/mono/4.5 cp $MONO_REPO_DIR/sdks/builds/ios-target64-release/data/config myproj/etc/mono # replace ios-target-64 with android-arm64-v8a-release for Android {{< / highlight >}}
I've used the environment variables SRCDIR and MONO_REPO_DIR arbitrarily - you will need to set these to the correct directories.
The config file is needed as Mono maps certain DLLs to specific native libraries, depending on the chosen platform.
On my build, these config files are the same between architectures (e.g. armv8 vs armv7), so it doesn't matter whether you choose ios-target-64/ios-target-32/etc. However, this may change in future, so take care.
There's nothing particularly important about this folder structure. As we'll see shortly, Mono allows us to manually set the config and base library directories manually.
However, two points to note:
- Mono expects to find mscorlib.dll under the mono/4.5 subfolder
- Mono looks for native remapped libraries in the same directory as its DLLs, meaning we will need to copy our architecture-specific libraries to the myproj/lib directory inside our application.
In your Flutter pubspec.yaml, add this zip file as an asset. You'll need to write your own script at startup to unzip these libraries to the application's ${dataDir}/app_flutter subdirectory. I've created a gist here that will help.
Ideally, I'd integrate this with Gradle build, but I couldn't figure out a way to embed files other than dynamic libraries (.so).
I assume Android requires all non-library files to be packaged as application assets.
[iOS] AOT compile libraries and link via Xcode
On iOS, all assemblies need to be AOT-compiled and copied via Xcode (rather than zipped and extracted at runtime).
I encountered a lot of difficulty in getting this all to work.
A successful compile doesn't mean the libraries will actually run.
There are a lot of strong, hidden dependencies under the hood that mean you need to use the correct version of mscorlib.dll, FSharp.Core.dll, compile switches, and so on.
Roughly speaking, I needed to:
-
use the FSharp.Core.dll from the Xamarin.iOS binaries repository. Don't copy from NuGet or your project build folder - I believe an older build is required under Mono.
-
use exactly the same BCLs from your Mono build, not the assemblies used to build your project
-
compile with --static and -direct-icalls for mscorlib.
This shouldn't be necessary if you link against the icall library, but Mono crashed every time without it, complaining that the icall lookup table couldn't be found.
- create your own ld/assembler scripts for each architecture.
Again, this shouldn't be needed, but my Mono installation wasn't calling the native assembler/linker correctly, so I had to set this up manually.
When you compile, use the correct tool-prefix switch (i.e. tool-prefix=aarch64-, or tool-prefix=armv7s-)
{{< highlight shell >}} $ cat /usr/local/bin/aarch64-as as -arch arm64 $@
$ cat /usr/local/bin/aarch64-ld clang -Xlinker -v -Xlinker -F/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/usr/lib/ -arch arm64 $@
$ cat /usr/local/bin/armv7s-as as -arch armv7 $@
$ cat /usr/local/bin/armv7s-ld clang -Xlinker -v -Xlinker -F/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/usr/lib/ -arch armv7s $@ {{< / highlight >}}
{{< highlight shell >}} mono --aot=full,static,tool-prefix=aarch64,direct-icalls mscorlib.dll {{< / highlight >}}
Rinse and repeat for all BCLs and project assemblies (excluding the static and direct-icalls switches for DLLs other than mscorlib.dll).
[Android] Link libraries via CMake
I won't cover the ins-and-outs of including CMake in your build pipeline via the Gradle/Flutter build process. Long story short, you'll need to copy libmonosgen-2.0.so for each architecture to the corresponding folders in your Flutter project's android/src/main/jniLibs directory:
{{< highlight shell >}} cp $MONO_REPO_DIR/sdks/out/android-arm64-v8a-release/lib/libmonosgen-2.0.so $FLUTTER_PROJECT_DIR/android/src/main/jniLibs/arm64-v8a cp $MONO_REPO_DIR/sdks/out/android-armeabi-v7a-release/lib/libmonosgen-2.0.so $FLUTTER_PROJECT_DIR/android/src/main/jniLibs/armeabi-v7a
repeat for all architectures
{{< / highlight >}}
Your CMakeLists.txt file will need to include the following:
{{< highlight shell >}} link_directories("${Project_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}") add_library(hello SHARED hello.c) target_include_directories (parser PUBLIC "$MONO_REPO_DIR/sdks/out/android-x86-release/include/mono-2.0" "$GLIB_SRC_DIR" "$GLIB_SRC_DIR/glib" "$GLIB_BUILD_DIR/glib" ) target_link_libraries(hello monosgen-2.0 android log gcc m) {{< / highlight >}}
[iOS] Link libraries via Xcode
In Xcode, you'll need to link your entire application with the Mono runtime and the libraries you just compiled (static or otherwise). You'll also need to copy the original assemblies (even though the actual compiled code is statically linked, the runtime still needs the reference metadata from the assemblies to load this code).
Under your Runner - Build Phases - "Link Binary with Libraries", link in all AOT-compiled libraries. For dynamic libraries, these also need to be included under "Embed Frameworks".
For dynamic libraries, you will also need to use install-name-tool to change the ID of each dynamic library and its rpath.
You'll also need to include both glib and Mono header files.
Do not try and set these directly via the Pods project in XCode - these settings will be overwritten. Set these via the ios/yourapp.podspec file as follows:
{{< highlight shell >}}
s.pod_target_xcconfig = {
'USER_HEADER_SEARCH_PATHS' => '/usr/local/lib/glib-2.0/include /usr/local/include/glib-2.0 $MONO_REPO_DIR/include/mono-2.0',
'ALWAYS_SEARCH_USER_PATHS' => 'YES' }
{{< / highlight >}}
The standard Mono SDK build doesn't include x86-64 binaries, so if you want to run on the simulator, you'll need to manually lipo all the libmonosgen-2.0-compat.dylib files into one "fat" library and reference that instead:
{{< highlight shell >}} lipo $MONO_REPO_DIR/sdks/out/target-ios-target-64/libmonosgen-2.0.compat.dylib $MONO_REPO_DIR/sdks/out/target-ios-target-32/libmonosgen-2.0.dylib repeat for all architectures -create -output libmonosgen-2.0_fat.dylib {{< / highlight >}}
C code to load the Mono runtime and invoke assembly method
This step is quite involved, and becomes very app-specific once the runtime is loaded.
Refer to the Mono documentation for further information on converting native types to pass to the Mono runtimes, finding methods/classes, and so on.
I'll just cover the runtime initialization here.
Note your interop code will need to build against glib: {{< highlight shell >}} yum install glib # Android brew install glib # ios {{< / highlight >}}
One concern I have is that glibconfig.h is a generated/architecture-specific file containing typedefs for certain sizes. Although I didn't experience any issues from this, I can't guarantee that this is OK. Someone with more experience would need to comment.
Roughly speaking, the native code will:
- Find the absolute path of the app install directory
- Find the subdirectory where XCode copied the dynamic libraries and DLLs
- Call mono_set_dirs on this path
- [iOS] Call mono_aot_register_module on any statically compiled DLLs
- [iOS] Call mono_jit_set_aot_mode(MONO_AOT_MODE_FULL);
- Call mono_jit_init("myapp");
- Call mono_domain_assembly_open (myDomain, path_to_your_dll);
Here's a direct copy and paste for the iOS portion of my code (note I actually bundled all Mono libraries/DLLs as a separate framework, so yours may look slightly different):
{{< highlight C >}} char* subdir = "/Frameworks/ParserAOT.framework/lib/"; assembly_path = malloc(strlen(path) + strlen(subdir)); strcpy(assembly_path, path); strcat(assembly_path, subdir); parser_dll_path = malloc(strlen(assembly_path) + strlen(ASSEMBLY_FILE_NAME)); strcpy(parser_dll_path, assembly_path); strcat(parser_dll_path, ASSEMBLY_FILE_NAME);
config_path = malloc(strlen(assembly_path) + strlen("config")); strcat(config_path, assembly_path); strcat(config_path, "config");
mono_set_dirs(assembly_path, assembly_path); mono_aot_register_module(mono_aot_module_mscorlib_info); mono_config_parse (config_path); mono_jit_set_aot_mode(MONO_AOT_MODE_FULL); myDomain = mono_jit_init("myapp"); MonoAssembly *assembly = mono_domain_assembly_open (myDomain, parser_dll_path); {{< / highlight >}}
Bridge Dart/Java/Objective C code to native
Your Dart code will need to send a message to your Java/Objective C code via platform channel. This is pretty straightforward so I won't cover it here.
On the Android/Java side, you'll then need to bounce this method to native code via JNI method. This involves adding a method signature in your Java class like:
{{< highlight Java >}} public native String invokeJNI(String method, String data); {{< / highlight >}}
...and a matching C method like:
{{< highlight Java >}} JNIEXPORT jobjectArray JNICALL Java_com_avinium_parser_ParserPlugin_invokeJNI(JNIEnv *env, jobject obj, jstring method, jstring json) { {{< / highlight >}}
Again, read up on JNI for a proper implementation.
For iOS apps, invoke your C method directly from your Objective C code.
Postscript
There you go. A very high-level view of what's needed to get a Flutter application to communicate with a F# (or any .NET) assembly.
Worth it? Definitely not.
This was an insane amount of work for zero eventual benefit.
If I'm totally honest, I should have given up halfway through.
I only persisted because the OCD took over and I was compelled to finish what I started, no matter how daft.
As a side note - you may think that Mono's mkbundle will package everything together as a single shared library - the Mono runtime, BCLs, your application DLL and its dependencies.
This will actually work - but for x86_64 only. mkbundle is intended for desktop binaries and will not handle mobile.