Back to notes

June 29, 2026 · 13 min read

Design Boundaries for Cross-Platform Native Frameworks on Mobile

A practical look at wrapping a C/C++ business core for iOS, Android, and Flutter, with notes on runtime boundaries, ABI choices, adapters, and release checks.

Background

I recently worked on a type of internal capability layer: a C/C++ implementation at the bottom, wrapped separately for iOS, Android, and Flutter.

It does not own UI, and it does not care how a specific screen is built. It mainly handles relatively stable concerns:

  • business workflows
  • model normalization
  • account/session state
  • uploads
  • entitlement checks
  • error semantics

This kind of thing is often called a “cross-platform Framework” or a “Native Core”. The name is not very important. The boundary is.

I will deliberately avoid concrete project names, API names, and business fields here. I want this post to describe the engineering pattern, not read like a README for a specific SDK. Once the post becomes too tied to one SDK, readers tend to focus on incidental details. The reusable part is the boundary design.

This post mainly answers a few questions:

  • What should belong in a C/C++ core?
  • How do iOS, Android, and Flutter call native code?
  • Why is a C ABI often a better cross-language boundary than C++ classes?
  • When should Flutter use MethodChannel, and when does FFI make sense?
  • What should be checked before a Framework is shipped?

A Framework Is More Than A Package

Different platforms mean different things when they say Framework.

PlatformCommon artifactMain concerns
iOSFramework / XCFrameworkSwift/ObjC API, architecture slices, symbol visibility
AndroidAAR + .soKotlin/Java API, JNI, ABI, manifest, resources
FlutterPlugin packageDart API, MethodChannel, iOS/Android native artifacts

On Apple platforms, a Framework is a very specific artifact. Directory layout, Mach-O binary, Headers, Modules, Swift interface, and Info.plist all follow platform rules. If you need to support both devices and simulators, you usually package the slices into an XCFramework.

On Android, a business SDK is less commonly called a Framework. The more common artifact is an AAR. An AAR can contain Kotlin/Java code, manifest entries, resources, and .so files split by ABI.

Flutter adds another layer. The Dart side sees a package API, while iOS and Android each provide their own platform implementation. That plugin can still carry an iOS XCFramework and Android native libraries internally.

So I now prefer to think of a Framework as a delivery boundary, not as one specific file format. At minimum, it needs to answer:

  • What API should the caller see?
  • What should the platform adapter be responsible for?
  • How much of the native core should be exposed?
  • Which symbols and strings should be hidden from the release artifact?

If the caller still has to parse low-level JSON, manage C strings, and interpret error codes, the complexity did not disappear. It was just pushed onto the integrator.

Layer boundaries in a cross-platform Native Framework

What Belongs In The Native Core

I usually start by asking whether a piece of logic has a “platform smell”.

Better in coreBetter in the platform layer
Request and response normalizationDevice ID
Shared model schemaKeychain / Keystore
Business error semanticsPermission prompts
Retry policyPayment client flow
Pure data transformationFlutter widget / Android Activity lifecycle
Workflows testable with a fake transportConcrete networking lifecycle and thread callbacks

A simpler test is:

  1. Would this logic change if the caller is iOS, Android, or Flutter?
  2. Can it run in a normal CMake test?
  3. Does it have to call a platform SDK?

If the answers are “no, yes, no”, the logic is a good candidate for core.

If a piece of logic needs permission prompts, system storage, purchase flows, the Flutter engine, or the Android lifecycle, it belongs in the platform layer.

For me, the best state for a native core is that it can be tested without real networking, without a simulator, and without a Flutter engine. HTTP can be represented by a fake transport. Platform device information can be injected by the adapter.

There is a common misunderstanding here: the thicker the C++ layer, the more reuse you get. In real projects, I often see the opposite. A thick core makes the platform layer look thin at first. But once iOS and Android diverge in lifecycle, networking, payment, or storage details, that thick core becomes the hardest part to change.

The core should own stable business semantics. How the platform runs is the platform layer’s job.

How It Runs On iOS

Compilation And Distribution

On iOS, C/C++ code is compiled by AppleClang into Mach-O object files, then linked into the App or Framework.

Common architectures are roughly:

  • device: arm64
  • simulator: arm64 or x86_64
  • external distribution: usually an XCFramework containing different slices

Call Path

A common call path looks like this:

Swift API
  -> Objective-C / C bridge
  -> C ABI
  -> C++ core
  -> platform capability injection

The Swift layer should feel like a normal Swift SDK:

  • async/await
  • Swift struct
  • Swift enum
  • throws

The App side should not touch low-level C pointers directly, and it should not need to know when a string inside a response becomes invalid.

Why Not Expose C++ Classes Directly

I avoid exposing C++ classes directly. The C++ ABI is affected by many things:

  • compiler
  • standard library
  • compilation flags
  • exception model
  • name mangling

Using it as a public cross-language boundary makes compatibility much harder to control later.

By comparison, a C ABI is plain, but that is exactly why it works well at this layer. Functions, structs, integers, and string pointers are not fancy, but they are more stable.

In abstract form, it may look like this:

typedef struct native_response {
    int ok;
    int error_code;
    int platform_status;
    int business_code;
    const char* message;
    const char* body;
} native_response;

int native_load_resource(
    native_client* client,
    native_response* response
);

A real SDK should not hand this layer directly to App developers. The Swift adapter should translate it into platform models and platform errors. The C ABI is a contract between the adapter and the core, not the final product API.

How It Runs On Android

Native Libraries And ABI

Android native code is compiled through the NDK into .so files. Each CPU ABI is a separate artifact. They are not interchangeable.

Common ABIs include:

  • arm64-v8a
  • armeabi-v7a
  • x86_64

Many teams only test arm64 devices, so missing ABI issues stay hidden. Then an x86_64 emulator fails because the package does not contain the matching .so. If an SDK is meant to be integrated by others, supported ABIs must be written down clearly. The caller should not have to guess.

Call Path

The Android call chain usually looks like this:

Kotlin API
  -> JNI
  -> C ABI
  -> C++ core
  -> platform capability injection

Kotlin loads the native library with System.loadLibrary, then enters JNI through external fun. JNI converts Java/Kotlin objects, strings, and arrays into data that the C boundary can handle.

Do Not Underestimate JNI

JNI looks like just a bridge. In practice, debugging it involves a lot of details:

  • when a native thread calls back into the JVM, it needs the right JNIEnv
  • the current thread may need to be attached
  • Java objects kept across calls cannot rely on local references
  • pointers obtained from Java strings cannot be held casually for a long time
  • native exceptions and Java exceptions need a clear handling strategy

Also, native code still runs inside the App process. It is not a server, and it is not outside the sandbox. Invalid pointers, memory corruption, and thread races can still crash the App.

Do Not Rush Into FFI For Flutter

When Flutter needs to call C/C++, the first thought is often dart:ffi. It is a good fit for some cases:

  • pure computation
  • little platform dependency
  • simple input and output
  • no complex lifecycle

Image processing, audio processing, compression, encryption, and algorithm modules are natural examples.

But business SDKs often have a different shape. They may need device information, platform storage, file paths, platform networking, purchase flows, and a result delivered back to the Flutter main thread. Letting Dart FFI touch all of those details directly does not necessarily make the system simpler.

Platform Channel is often the more reasonable boundary here:

  • Dart keeps a stable Future-based API and model layer
  • iOS and Android each handle their own platform runtime
  • the native core only owns the business semantics that are truly reusable

A Flutter plugin may look like this:

Dart API
  -> MethodChannel
  -> iOS Swift plugin / Android Kotlin plugin
  -> C ABI or platform SDK
  -> C++ core

This does not mean FFI is bad. It depends on the problem it is solving. If the core capability is simply a pure native library, FFI is direct. If the capability strongly depends on platform context, MethodChannel is usually steadier.

Runtime paths for C and C++ across iOS, Android, and Flutter

My own starting point is roughly:

ScenarioBetter fit
Pure computation library with primitive values or simple memory blocks as input/outputdart:ffi
Business API centered on async workflowsFlutter plugin + MethodChannel
Needs Keychain, Keystore, system settings, or purchase flowsPlatform plugin
Embeds native UI into FlutterPlatform View
Native iOS App integrationSwift SDK
Native Android App integrationKotlin SDK / AAR

This table is not a rule. It is just a starting point. The real question is whether the boundary feels natural.

A C ABI Must Define Memory And Threading

In a cross-language SDK, the C ABI is often the most stable layer, and also the easiest layer to misuse.

I am strongly against exposing a C API directly as the product API. Asking application developers to manage const char*, response lifetimes, payload JSON, and error codes usually does not reduce complexity. It transfers work that the SDK author should handle to the caller.

A C ABI is better used as an adapter boundary. The Swift, Kotlin, or Dart platform layer calls down into it, then translates the result into types that feel natural in that language.

This layer should at least define:

ContractWhat must be clear
String ownershipWhich strings are owned by the client, and which must be released by the caller
Response lifetimeHow long each pointer remains valid
Concurrency modelWhether the same client can be called concurrently
Destruction behaviorWhich pointers become invalid after the client is destroyed
Callback threadWhich thread returns the result, and whether the platform layer must switch to the main thread

These rules look tedious, but they decide whether the SDK remains maintainable. Across Swift, Kotlin, Dart, and C++, memory ownership cannot rely on assumptions.

If the underlying client was not designed as a concurrent workflow engine, the platform layer should serialize calls, or create separate clients for separate sessions. Do not silently push concurrency pressure onto a native object that has no synchronization semantics.

Inject Platform Capabilities Instead Of Hard-Coding Them

I prefer letting the core construct an abstract request, and letting the platform layer actually send it.

The core can describe:

  • method
  • url
  • headers
  • query
  • body
  • multipart files
  • timeout

But it should not bind directly to a concrete networking library.

This brings several practical benefits:

  • the core does not directly depend on URLSession, OkHttp, Dart http, or other platform libraries
  • the platform layer can handle proxies, certificates, cancellation, background threads, and main-thread callbacks itself
  • file paths, system language, and device identifiers can be injected by the adapter
  • the native core can be tested with a fake transport

This boundary matters:

The native core should be stable, but it should not pretend to be the platform.

Configuration And Lifecycle

If an SDK allows itself to be reconfigured with different parameters at any time, it will likely cause problems later.

This kind of framework is better treated as “configure once”:

  1. The first configure call creates the native client.
  2. It sets the base environment.
  3. It injects platform capabilities.
  4. It binds the session storage namespace.
  5. Later calls with the same configuration can be a no-op.
  6. Later calls with different configuration should fail explicitly.

These values usually should not change silently at runtime:

  • backend environment
  • app identity
  • API path mapping
  • session storage namespace
  • native client pointer
  • current device information and current session

If an App truly needs to switch environments, it should explicitly destroy the old instance and create a new one, or restart the process. Do not replace the foundation under a native client that has already run workflows.

What To Check Before Release

The release path for this kind of SDK should not stop at “does it compile”. Compilation is only the first step. The real question is what the release artifact exposes, and what it hides.

A rough release shape may look like this:

C / C++ build
  -> generate native core and C ABI library

Apple package
  -> generate Framework / XCFramework
  -> expose Swift / ObjC public surface
  -> hide internal C / C++ symbols

Android package
  -> generate AAR
  -> include native .so files for target ABIs
  -> let the Kotlin / Java bridge load the native library

Flutter package
  -> expose Dart API
  -> include iOS / Android native artifacts
  -> unify MethodChannel semantics and the error model

Before release, I would at least inspect:

CheckWhy it matters
Public symbolsConfirm that only expected APIs are exposed
C++ implementation symbolsPrevent internal implementation details from leaking
Test URLs, default paths, internal stringsPrevent release artifacts from carrying internal information
Swift/Dart/Kotlin public APIDetect compatibility breaks
AAR ABIConfirm target devices and emulators can load the package
XCFramework slicesConfirm both devices and simulators are covered
Example and smoke testConfirm the integration path works
C API memory and threading contractConfirm the low-level contract did not change

Native code does not automatically make a system safer. What helps is reducing the exported surface, making configuration injection explicit, checking symbols and strings, and keeping smoke tests.

Common Traps

Treating C++ As The Universal Cross-Platform Layer

C++ can reuse business logic, but it cannot erase platform differences.

Permissions, storage, payments, threading, networking, and lifecycle are still platform concerns. If the core tries to absorb every difference, it eventually becomes a middle layer with neither platform feel nor enough isolation.

Exposing The C API Directly To The App

A C API is good as an adapter boundary. It is not a good everyday App development interface.

Business developers should receive natural Swift, Kotlin, or Dart models and errors, not low-level pointers and string lifetime rules.

Ignoring ABI And Architecture Slices

iOS device, iOS simulator, Android arm64, and Android x86_64 are all different artifacts.

Whether a Flutter plugin can run depends not only on Dart code, but also on whether the underlying native artifacts are complete.

Only Verifying Debug Builds

Many native SDK problems only show up in release:

  • symbol visibility
  • strip behavior
  • leftover strings
  • path injection
  • Android minify
  • Swift interface
  • AAR contents
  • XCFramework slices

If the debug example runs, it only proves that the development path works.

Not Defining Memory And Threading Rules

At a cross-language boundary, the most dangerous issue is usually not a missing method. It is unclear ownership.

Who creates, who releases, how long a pointer remains valid, whether calls can run concurrently, and which thread receives callbacks should all be part of the documentation and tests.

Summary

Building a shared Native Framework for iOS, Android, and Flutter is not essentially about compiling C++ for three platforms. Compilation is only the most visible part.

The harder part is the boundary:

  • the C/C++ core owns stable business semantics
  • the C ABI defines the low-level call contract
  • Swift, Kotlin, and Dart translate native capability into APIs that feel natural on each platform
  • the release path proves that implementation details that should stay hidden did not leak out

The value of this kind of engineering is not making all platform code look the same. It is the opposite: a good shared layer lets each platform keep its own style, while moving only the truly stable and truly shared business behavior down into the core.