maui-kmp
0.4.2indexedEnables integration of MAUI with multiplatform code by generating iOS and Android bindings from shared code, offering features like generating iOS bindings from annotated source code.
Enables integration of MAUI with multiplatform code by generating iOS and Android bindings from shared code, offering features like generating iOS bindings from annotated source code.
This toolkit allows you to combine MAUI with Kotlin Multiplatform (KMP) by generating bindings for iOS and Android from Kotlin common code.
Currently the following features are supported:
@Throws → NSError (catchable in C#), with generated wrappers (see below)You can configure the namespace and prefix used for generated C# bindings by adding KSP arguments in your build.gradle.kts:
ksp {
arg("maui.kmp.csharp.ios.namespace", "YourNamespace")
arg("maui.kmp.csharp.ios.frameworkPrefix", "YourPrefix")
}
Parameters:
maui.kmp.csharp.ios.namespace: The namespace for generated C# bindings (required)maui.kmp.csharp.ios.frameworkPrefix: The name of the framework generated by kotlin (required)Example:
ksp {
arg("maui.kmp.csharp.ios.namespace", "MyCompany.Mobile")
arg("maui.kmp.csharp.ios.frameworkPrefix", "Native")
}
This will generate C# bindings like:
namespace MyCompany.Mobile
{
interface NativeBase : ObjCRuntime.INativeObject
{
// ...
}
}
@Throws)On iOS, a synchronous Kotlin function that throws crosses the Kotlin/Native → ObjC boundary as an
unhandled exception and terminates the process — the C# host cannot catch it. Kotlin/Native
only bridges a thrown Exception to an ObjC NSError (which surfaces in C#) when the function is
annotated @Throws. This toolkit makes that the contract:
// Function from DemoSdk
@MauiBinding // The MauiBinding annotation
@Throws(Exception::class) // Add this, so that Kotlin Native generates the ObjC header exposing the Exception
fun start() {
// some logic which can throw an Exception
}
@Throws member the KSP emits, in a separate generated file, an idiomatic C# wrapper that
hides the out NSError and throws NSErrorException instead — so call sites keep their natural shape.
[Export ("startAndReturnError:")] // how the kotlin native generates the method for consumption
bool Start(out NSError error); // it exposes an error and for unit methods returns a bool
// Wraps the Start method, so it is now possible to use it as usual: Start()
public static void Start(this SharedDemoSdk self)
{
self.Start(out var error);
if (error != null)
{
throw new NSErrorException(error); // Throws the exception, so this can be catched inside csharp
}
}
ksp {
// Fail the build if a synchronous @MauiBinding (that is not an async adapter) lacks @Throws.
arg(, )
arg(,
)
arg(, )
arg(, )
}
Async errors (from the Task/ adapters) are delivered per-subscription through the
callback. For cross-cutting observability, the host can additionally broadcast them through a
publisher on the C# side — the example wires an (, the C#
equivalent of a ) that the adapters publish to in , so any number of subscribers
see every async (/ are never published). This is consumer
code, not part of the toolkit — see .
example/ contains a runnable MAUI app used to validate the plugin and the generated bindings
end-to-end (see SDK-94). It exercises the critical interop scenarios — suspend functions, flows,
error handling, C# callbacks, init/teardown lifecycle, threading and nullables — each behind a
button so regressions are immediately visible.
Layout:
The example builds against the local toolkit via a Gradle composite build
(useLocalToolkit=true in example/kotlin/gradle.properties), so changes under kotlin/ are
picked up without publishing. Recipes are in the justfile:
just run # regenerate bindings + build XCFramework + build & launch on a booted iOS simulator
just build # same, but build only (no launch)
just bindings # regenerate ApiDefinitions.cs only
just framework# build the Kotlin/Native XCFramework + copy it to example/maui/binding/ only
The KSP rejects suspend functions and does not bind Flow (Kotlin/Native exposes neither to the
generated ObjC surface in a usable way, there is no message-channel layer to inject the translation).
Instead the example wraps them in plain, non-suspend classes that deliver results via lambdas
(which the bindings do support):
Task<T> wraps a suspend result → bridged to System.Threading.Tasks.Task<T> in C#
(Extra.cs).ObservableFlow<T> wraps a Flow<T> → adapted to IObservable<T> in C#.These wrappers depend only on kotlinx.coroutines — never on any .NET/MAUI type — so the generated
iOS framework stays free of a MAUI dependency.
The gradle plugin uses https://github.com/aasitnikov/fat-aar-android to generate fat aar file. You need to add the following to your buildscript repositories:
maven {
setUrl("https://jitpack.io")
content {
includeGroup("com.github.aasitnikov")
}
}
@MauiBinding function/constructor with @Throws(Exception::class).
Exception becomes catchable in C#; kotlin.Error stays fatal (by design); CancellationException
is always rethrown, never bridged.@MauiBinding(canThrow = false) instead
of adding @Throws. KSP then stops requiring @Throws for it and skips all throwing machinery
(no wrapper, no CreateX factory, no [DisableDefaultCtor]) — the plain new SharedX(...) keeps
working. This is an assertion, not a guard: if such a member does throw at runtime it terminates the
process uncatchably. Combining canThrow = false with @Throws is a build error.@MauiBinding function/constructor needs @Throws(Exception::class) or the build fails
(unless you opt out globally). Functions returning a declared async-adapter type are exempt.@MauiBinding(canThrow = false) exempts a member that never throws (e.g. a
trivial constructor) without disabling the check everywhere. Preferred over the global flag.object/companion lazy init are not covered — a getter that throws on iOS
still terminates the process (out of scope for now).@Throws constructor → consumers get a CreateX(...) factory (and optionally the deprecated
new X(...) shim).internal constructor + a @Throws companion factory (getInstance() / init() / create())
→ consumers call SharedX.Companion.Create() — no new, no wrapper prefix, and none of the
factory/shim/[DisableDefaultCtor] machinery is involved.obj.Foo(...) keeps compiling — it now resolves to the generated
extension wrapper, which throws a catchable NSErrorException instead of crashing. Wrap in try/catch
where you want to handle it.@Throws types: breaking. The plain new SharedX() is removed (the interface
gets [DisableDefaultCtor], because the bgen default ctor would otherwise call the now-absent plain
init and build a half-initialized object). Use <throwsWrapperClassName>.CreateSharedX(...), or
enable emitDeprecatedConstructorShims for a grace period (compiles with an [Obsolete] warning;
it runs the real ctor but cannot surface the error — migrate to the factory).@Throws members change, so the whole binding must be regenerated in one
step when bumping the plugin version.ObservableFlowonErrorErrorBusIObservable<AdapterError>SharedFlowExtra.csExceptionErrorCancellationExceptionexample/maui/binding/Extra.csexample/kotlin/ — the KMP project. composeApp/src/commonMain/.../shared/InteropTest.kt is the
annotated test surface; shared/binding/{Task,ObservableFlow}.kt are the binding-friendly
wrappers that expose suspend/Flow to MAUI (the toolkit does not bind those directly — see
below).example/maui/binding/ — the iOS binding project (ApiDefinitions.cs generated by the KSP +
Extra.cs, the hand-written C# support layer + a NativeReference to the built XCFramework).example/maui/app/ — the runnable .NET MAUI app (net10.0-ios).Surfaced from shared tags and platforms — no rankings paid for.