Mobile Computing · Cross-Platform Development
Wrapping a Native SDK Behind a Shared Interface
Third-party SDKs ship as binaries with their own threading, error, and lifecycle models. A plugin boundary has to normalise all three, and the normalisation is where most integration defects originate.
A cross-platform application rarely owns all of its platform behaviour. Payments, analytics, maps, crash reporting, and media decoding arrive as vendor SDKs, each shipping a binary per platform with an API shaped by the platform it was written for. The shared core needs a single interface over them, and the work of building that interface is not the wrapping itself but the reconciliation of three contracts the vendors do not share: threading, error reporting, and lifecycle.
The reason this belongs to the advanced end of cross-platform work is that the failure modes are not compile errors. A wrapper compiles on both platforms, passes the happy path on both, and then deadlocks on one of them under a condition the vendor documented in a PDF.
Threading contracts are the first incompatibility
Vendors specify which thread their API may be called from, and the two platforms have different conventions for saying so.
An iOS SDK will often state that its API must be called from the main thread, because it touches UIKit objects internally or because it was written before concurrency annotations. An Android SDK will often state the opposite: that its methods block and must not be called from the main thread, because a strict-mode policy or an internal network call would otherwise stall the frame. Both statements are correct for their platform, and they are contradictory for a shared core that wants to call one function, which leaves the wrapper with a choice between 2 executors rather than 1 calling convention. Android’s view hierarchy records the thread that created it and compares against that thread on every layout request, which is why a violation there arrives as an exception naming the hierarchy; on iOS the same violation has no diagnostic at all.
A wrapper that respects the vendor’s rule has to marshal to the right executor per platform. That means the shared interface must not promise synchronous completion, because the call may have to be dispatched to a thread the caller is not on, and the result may arrive later. It also means the wrapper must know whether it is already on the required thread, because dispatching to the main thread from the main thread synchronously is a deadlock in every runtime that has a serial main queue. The dispatch itself is one of the crossings analysed in Marshalling Across the Language Boundary, and it is the expensive kind: it transfers control to another scheduler rather than merely converting a value.
// The wrapper's contract: completion runs on the main thread, and the caller
// must not assume the vendor was called synchronously.
fun fetchQuote(request: QuoteRequest, onResult: (Result<Quote>) -> Unit) {
// The vendor blocks, so it runs on a worker; the result is posted back to
// the main thread because that is where the caller updates the interface.
worker.execute {
val result = runCatching { vendor.fetchBlocking(request) }
mainHandler.post { onResult(result) }
}
}
On iOS the same wrapper would invert the test: the vendor must be called on the main thread, so the wrapper asserts that it is already there and calls inline, and the shared interface above both is identical while the dispatch policy below them is not. That is the whole point of putting the decision in the adapter.
The subtle defect is re-entrancy. A vendor that invokes its completion callback synchronously, on the calling thread, breaks any wrapper that assumes the callback arrives later. A wrapper that holds a lock while calling the vendor will deadlock when the vendor calls back into the wrapper from the same thread. The rule that avoids both is to never hold a lock across a vendor call, which means the wrapper’s state must be designed so that the call can be made without one.
Error models do not map onto each other
The second contract is error reporting, and the platforms disagree at the language level before any vendor convention is involved.
Swift uses throwing functions and typed errors. Kotlin uses exceptions, with checked exceptions largely absent. C APIs return status codes and set an error out-parameter. Objective-C uses an NSError out-parameter with a pointer-to-pointer convention. A vendor SDK typically uses whichever of these matches its platform, and sometimes mixes two.
A shared interface has to pick one error representation and translate. The representation that survives translation with the least loss is a typed result carrying either a value or a structured error with a code, a message, and a category. The category is the part that matters, because it is what the shared code branches on: retryable, permanent, cancelled, and unauthorised are the categories almost every SDK’s errors collapse into, and a wrapper that passes through raw vendor codes forces every caller to learn two vendor vocabularies. Swift’s typed errors and Kotlin’s unchecked exceptions mean the wrapper cannot preserve the vendor’s own distinction between a recoverable failure and a programming error unless it defines one, and most vendor APIs do not draw it either; in practice the categories collapse to about 4.
Translation is lossy in one direction that is worth stating. A vendor that reports errors as exceptions may also use exceptions for programmer errors, and a wrapper that catches everything and converts to a result type will swallow an assertion failure that should have crashed. The distinction between a recoverable error and a violated precondition is not expressible in most vendor APIs, and the wrapper has to decide per call site which exceptions it converts.
Lifecycle and process-level state
The third contract is lifecycle, and it is where cross-platform integration produces the most surprising failures.
An SDK initialised in the application’s startup path holds process-global state: a singleton, a background queue, a registered URL handler, a notification observer. On Android, a process may be restarted with the activity recreated but the process retained; on iOS, the application delegate receives launch and background transitions with different timing. A wrapper that initialises on first use can be initialised twice if two screens race, and a wrapper that initialises at launch can be initialised before the platform has provided the context the SDK needs. Double initialisation is rarely harmless: an SDK that registers for a lifecycle notification can end up with 2 registrations and deliver every event twice.
The correct shape is an explicit initialisation state machine with a single owner and a documented point in the launch sequence. The shared core asks for the SDK through a port, the adapter owns the state machine, and the state machine answers calls made before initialisation by queuing them or by failing with a typed error. Both answers are defensible; the indefensible one is to let the vendor’s own lazy initialisation decide, because the vendor’s initialisation is not aware of the application’s lifecycle.
There is a second lifecycle obligation that is easy to miss: teardown. SDKs that register observers, open sockets, or hold wake locks must be shut down when the application is finished with them, and neither platform guarantees a clean process exit. On Android, a backgrounded process can be killed without any callback at all, so any state that must be flushed has to be flushed earlier, not at exit.
Binary size and symbol collisions
A native SDK ships compiled code. On Android it ships per-ABI shared objects, and the application must decide which ABIs to include: 32-bit and 64-bit ARM are the common pair, x86 variants are needed for emulators, and each included ABI multiplies the size of every native library in the application. On iOS, the SDK ships as a static or dynamic library, and a static library’s unused code is removed by the linker only if the code is compiled into separate sections, which is a property of how the vendor built it rather than of how the application links it.
Symbol collisions are the other compiled-code hazard. Two static libraries that both embed the same third-party C library, or two versions of the same SDK pulled in by different dependencies, produce duplicate symbol errors at link time or, worse, a silently chosen implementation at run time. The linker’s behaviour depends on the platform and on whether the symbols are weak, and the symptom is a function that behaves like the wrong version.
The structural mitigation is to keep vendor code behind a single adapter module per platform so that the dependency graph has one path to each SDK, and to verify the linked symbol set rather than assuming it. The verification is mechanical and cheap compared with diagnosing a duplicate-symbol bug in a release build.
What the shared interface should and should not promise
The interface above the wrapper is the part the shared core sees, and its shape determines how much of the vendor leaks through.
It should promise: a call may complete asynchronously; a call may fail with one of a small number of categories; a call is safe to make from any thread; and initialisation is explicit. That is 4 guarantees, and the refusals below matter as much as the guarantees. It should not promise: a specific threading model, a specific error code, or a specific ordering relative to other vendor calls, unless the vendor guarantees it.
It should also avoid promising cancellation unless the vendor supports it. A wrapper that accepts a cancel handle and silently ignores it produces a caller that believes it has cancelled work and acts on a result that arrives anyway. If the vendor has no cancellation, the interface should not have one, and the caller should be given a way to discard a result it no longer wants, which is a different and honest mechanism.
The interface should carry the vendor’s identifiers in the types it returns. A payment SDK’s transaction identifier, a map SDK’s place identifier, and an analytics SDK’s session identifier are opaque strings that the application must store and pass back. Modelling them as strings rather than as typed wrappers is usually correct, because the vendor defines the format and the application only round-trips it. Modelling them as integers, or as UUIDs generated by the application, is a defect that appears when the vendor’s identifier turns out not to be numeric.
Versioning the wrapper against the vendor
Vendors change their APIs, and a wrapper pins a version. The pin must be explicit in the build, not implied by a dependency resolver that may select a different version on a different day, because the wrapper’s code is written against specific symbols.
2 versioning obligations follow. The first is that the wrapper must fail at build time, not at run time, when the vendor version is outside the supported range; a build-time check is the only one that reaches every developer. The second is that the wrapper’s own interface must be versioned separately from the vendor’s, because the shared core depends on the wrapper and should not have to change when the vendor does. The two version numbers move at different rates, and conflating them makes every vendor upgrade a breaking change for the entire application.
The wrapper is also where a vendor’s platform-specific behaviour is documented. If the vendor’s Android implementation retries internally and its iOS implementation does not, that difference belongs in the wrapper’s contract documentation and, where possible, in the wrapper’s own behaviour, so that the shared core sees one retry policy. Leaving it to the caller means every caller implements the difference twice, and one of the two is wrong. The same principle governs the capability declarations described in The Portable Core Boundary: what a platform can do belongs in the adapter’s contract, not in the caller’s assumptions.
References
- Android NDK: ABI Management — Android Developers
- RFC 4122: A Universally Unique IDentifier (UUID) URN Namespace — IETF
- Itanium C++ ABI — Itanium C++ ABI project
- Android Documentation: Processes and app lifecycle — Android Developers