vflutter_ffi
Write application logic in V, call it from Flutter over dart:ffi.
Built on Flutter's plugin_ffi template. No platform channels, no method-channel
serialisation — Dart calls V through the C ABI directly.
How it works
V compiles to C. That is the whole trick:
src/vflutter.v --[ v -shared -gc none ]--> C --[ NDK / clang / MSVC ]--> libvflutter.so
V never cross-compiles for the target. It only emits C, and the platform's own
toolchain owns the ABI, sysroot and flags. That is why Android arm64, iOS
arm64, Linux, macOS and Windows all work from one source file.
| Platform | Built by | Artifact |
|---|---|---|
| Android | NDK via android/build.gradle → src/CMakeLists.txt |
libvflutter.so per ABI |
| Linux / Windows | Flutter's CMake → src/CMakeLists.txt |
shared library, auto-bundled |
| iOS / macOS | CocoaPods compiles pre-generated C | static archive in the app binary |
iOS is the odd one out: Xcode's build sandbox can't run v, and App Store
builds want a static archive. So tool/gen_ios_sources.sh generates the C ahead
of time into ios/Classes/, and that is checked in. Re-run it whenever
src/vflutter.v changes.
The two rules
Everything that goes wrong with this bridge goes wrong in one of two ways.
1. Only C types cross the boundary.
int, f64, &char, voidptr. Never a V string, array, map, option or
sumtype — those have V-specific layouts Dart cannot read. Convert at the edge.
2. The library is built -gc none, so V code must free its own temporaries.
This is the one that bites. Boehm GC can't be cross-compiled per-ABI without
pain, and a C-ABI library with explicit ownership doesn't need it — but it means
every intermediate allocation inside an exported function leaks unless freed:
@[export: 'vf_greet']
fn vf_greet(name &char) &char {
n := unsafe { cstring_to_vstring(name) } // allocates
res := 'Hello, ${n}, from V!' // allocates
out := unsafe { res.str } // handed to the caller
unsafe { n.free() } // <-- without this, ~40 bytes/call
return out
}
Measured on this repo: omitting n.free() leaks ~12 MB per 300k calls. With it,
RSS is flat at 300k and 900k calls.
Ownership across the boundary: V allocates, Dart copies, V frees. The Dart
wrapper in lib/vflutter_ffi.dart does the vf_free in a finally, so callers
never hold a pointer and never leak.
Quick start
With just:
just mandelbrot # build the V library and run the fractal explorer
just web # serve the same app with V as WebAssembly (no browser opened)
just test # analyze + the example's test suite
just bench # V vs Dart timings for the same frame
just --list # everything else
Flutter is expected at ~/flutter/bin; set FLUTTER_BIN if yours lives
elsewhere.
Usage
import 'package:vflutter_ffi/vflutter_ffi.dart' as v;
await v.initialize(); // required on web, a formality on native
v.add(20, 22); // 42
v.greet('Flutter'); // "Hello, Flutter, from V!"
await v.greetAsync('isolate'); // same, off the UI isolate
-gc none means no stop-the-world phase and no thread-local runtime state, so
calls are safe from any isolate. Use Isolate.run for anything long enough to
jank a frame.
For bulk data, let Dart own the buffer and have V fill it in place — then
nothing crosses the boundary that needs freeing, and independent slices can be
computed concurrently:
final view = v.FractalView(width: 800, height: 600, maxIter: 500);
final pixels = await v.renderParallel(view, tiles: 4); // RGBA8888
Web
The same V source also runs in the browser. just web compiles it to
WebAssembly with emscripten and serves the example app at
http://localhost:8080 — it prints the URL and waits rather than launching a
browser, so open it yourself (WEB_PORT=9000 just web to move it). Hot
restart still works. dart:ffi does not exist on web, so a second backend
drives the wasm module over dart:js_interop instead:
lib/src/backend_native.dart dart:ffi
lib/src/backend_web.dart dart:js_interop + emscripten
lib/vflutter_ffi.dart picks one with a conditional import
Nothing has to be installed for this. tool/bin/emcc is a
DotSlash file that pins emscripten by content
hash; the toolchain is fetched on first use and cached in ~/.cache/dotslash.
Only dotslash itself needs to be on PATH.
The two backends are held to being the same, not merely similar: just parity
renders a frame with each kernel and compares them byte for byte, and they
agree exactly — across different compilers, different libm implementations
(glibc vs musl) and different word sizes (64-bit vs wasm32). Speed is close
too, ~2-5% off native for the same frame:
800x600 maxIter 100: 68.1 ms native / 69.4 ms wasm
800x600 maxIter 500: 149.3 ms native / 156.3 ms wasm
Two differences are real and surfaced in the API:
await v.initialize()before anything else. A wasm module cannot be
instantiated synchronously. On native it resolves immediately.v.supportsIsolateParallelismis false on web. There are no isolates, so
renderParallelstill splits the frame and still produces identical pixels,
but the bands run in sequence.
One trap worth knowing about
V's vmemcpy silently skips any copy whose source or destination is
<= 0xFFFF — a null-pointer heuristic that is sound on 64-bit desktop, where
the low 64 KB is never mapped. On wasm32 emscripten packs static data down at
address ~1300, so every copy out of a string literal does nothing and V strings
come back as runs of zero bytes, with no crash and no diagnostic. It only
appears at -O1 and above, which makes it look like an optimiser bug.
tool/build_wasm.sh passes -sGLOBAL_BASE=1048576 to move static data, the
stack and the heap clear of that check.
Is V faster than Dart?
For the numeric kernel in the example: no, not meaningfully. Measured at
800x600 / 500 iterations on an 8-core Linux box, V through C takes ~152 ms
against Dart AOT's ~165 ms — inside the noise of a ~10% margin. Dart's AOT
compiler is good at scalar floating-point loops.
What the bridge does buy you is the ability to write the logic in V — or
reuse V you already have — with a C ABI that is cheap to call and safe to call
concurrently. Pick it for the language, not for an expected speedup. And build
with optimisation on: at -O0 the same kernel is roughly 2x slower than Dart,
which is what tool/build.sh and src/CMakeLists.txt now guard against.
Adding a function
- Export it in
src/vflutter.vwith@[export: 'vf_yourthing'], freeing temporaries. - Declare it in
src/vflutter.h. - Wrap it in
lib/vflutter_ffi.dart. ./tool/gen_ios_sources.shto refresh the iOS C../tool/build.shto smoke-test the host build.
Step 2 also feeds dart run ffigen --config ffigen.yaml if you'd rather
generate the raw bindings than hand-write them.
Status
Verified on Linux with V 0.5.2, Dart 3 and Flutter 3.47: host build, string
round-trip, isolate dispatch, 900k-call memory stability, and the example app
built and run as a release Linux binary (CMake -> v -> NDK/clang path
included).
The web path is verified end to end as well — wasm built with emscripten 6.0.9,
the module exercised headlessly under node, the kernel compared byte for byte
against the native build, and the release web app loaded in headless Chrome
with its rendered canvas checked for an actual fractal. just ci runs all of
it.
The Android/iOS/Windows glue is written to the standard plugin_ffi contract
but is not exercised here — it needs the respective toolchains.
Layout
src/vflutter.v the V source — the only file you normally edit
src/vflutter.h C declarations (ffigen input, Xcode input)
src/CMakeLists.txt V -> C -> shared lib; shared by Android/Linux/Windows
lib/vflutter_ffi.dart the Dart API callers use
lib/src/backend_*.dart the two backends: dart:ffi and wasm/js_interop
ios/, macos/ pre-generated C + podspec (static archive)
android/build.gradle NDK build via externalNativeBuild
tool/build.sh host build + export smoke test
tool/gen_ios_sources.sh regenerate ios/ and macos/ C after editing the V source
tool/bin/emcc DotSlash file pinning emscripten — nothing to install
tool/build_wasm.sh V -> C -> wasm for the web build
tool/test_wasm.mjs headless check of the wasm module (no browser needed)
tool/check_parity.sh native vs wasm kernel, byte for byte
tool/check_web.sh loads the built web app in headless Chrome
example/ Flutter app exercising the bridge (see example/README.md)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 |
|