
Shipping the SDK Is Where Trust Starts
Series
Mobile SDK Design
5 of 7 in the series
A series about designing mobile SDKs that are easy to adopt, clear to integrate, and safe under production constraints.
Article 1
Designing a Mobile SDK That Developers Actually Want to Use
Article 2
The Public API Surface Is the Product
Article 3
Initialization, State, and Thread Safety in Mobile SDKs
Article 4
Developers Do Not Mind Errors. They Mind Unclear Errors.
Article 5
Shipping the SDK Is Where Trust Starts
Article 6
Secure Defaults for Mobile SDKs
Article 7
Same Concepts, Native Experience
Distribution, versioning, binary size, and startup cost are part of SDK design. Host-app tech leads evaluate all of it, not just the API.
Distribution Friction is the failure mode where an SDK works correctly in the author's sample app but fails in real host apps because packaging, shrinker rules, binary size, or dependency policy were treated as afterthoughts.
Distribution Friction does not appear in development. It appears when a tech lead runs a release build with minification enabled and sees ClassNotFoundException. It appears when a developer adds the SDK and discovers it conflicts with a networking library already in the project. It appears when an XCFramework that built correctly in one environment fails to import in another because the Swift compiler version changed.
The API can be well-designed and the SDK can still be a friction problem to adopt. Distribution quality is what determines whether a tech lead approves the evaluation.
Android: Consumer ProGuard Rules
When a host app builds a release APK or AAB, R8 or ProGuard minifies the code. By default, the minifier removes classes and methods that appear unused to static analysis. It has no knowledge of which SDK classes are accessed through reflection, callback registration, or other patterns it cannot trace statically.
The result: an SDK that works in debug builds will produce ClassNotFoundException, NoSuchMethodError, or silent behavior differences in release builds if consumer rules are missing or incomplete.
An Android AAR can ship its own consumer rules. These rules are automatically applied to any host app that depends on the AAR. This is not optional polish. It is a required shipping artifact.
// build.gradle.kts (SDK library module)
android {
defaultConfig {
consumerProguardFiles("consumer-rules.pro")
}
}Consumer rules should be as small as possible. Public APIs referenced directly by host-app code are usually retained naturally by R8. Add keep rules only for dynamic access patterns the shrinker cannot trace: reflection, serialization by class name, generated adapters, and callback methods invoked indirectly.
# consumer-rules.pro
# Result types accessed by class name (e.g., through serialization or a generated adapter)
# - R8 cannot trace these references statically
-keep class com.example.sdk.PaymentResult$* { *; }
# Callback interface methods invoked through the listener registration pattern
-keepclassmembers interface com.example.sdk.SDKListener {
<methods>;
}The required validation: build a sample host app that uses the SDK in a release variant with minifyEnabled = true. If the release build fails, crashes at runtime, or behaves differently from the debug build, the consumer rules are broken or incomplete. This test should be a required gate before every SDK release, not a nice-to-have.
Android: Transitive Dependency Conflicts
A tech lead evaluating the SDK will inspect its transitive dependency tree. An SDK that drags in five versions of a networking library, or that requires an older version of a library the host app already uses, signals that the SDK was not designed with its integration environment in mind.
# Inspect the SDK's full transitive dependency graph
./gradlew :sdk:dependencies --configuration releaseRuntimeClasspathA realistic conflict scenario: the host app depends on okhttp:4.12.0. The SDK declares a dependency on okhttp:3.14.9. By default, Gradle resolves the conflict by selecting the highest requested version. That can still break the SDK if it was only tested against the older major. If the host app uses strict constraints, dependency locking, or failOnVersionConflict(), the same conflict may fail the build rather than silently selecting a version.
Practical guidance:
- Minimize the dependency surface. If a library is a pure implementation detail (a JSON parser, a networking client), prefer Android platform APIs where they are sufficient.
- When a shared dependency cannot be removed, document the required version range explicitly in the README and the POM.
- If version conflicts are unavoidable and the dependency is not part of the public API, consider shading it into the SDK's own package namespace. This adds build complexity but isolates the conflict.
An SDK that adds a small, well-understood set of transitive dependencies is a much easier adoption conversation than one that adds an undocumented dependency tree.
iOS: Swift Module Stability
Binary XCFrameworks written in Swift require explicit module stability configuration. Without it, the framework is compiled against a specific Swift compiler version. Any host app using a different compiler version will see:
error: Module compiled with Swift X.Y cannot be imported by the Swift A.B compilerThis error appears after a host app developer upgrades Xcode. It is outside the developer's control. The SDK author must ensure the framework is compiler-version stable before distributing it.
Enable Swift module stability in the SDK's Xcode project build settings:
BUILD_LIBRARY_FOR_DISTRIBUTION = YESThis setting emits a .swiftinterface file alongside the compiled module. The interface file is stable across Swift compiler versions and is what downstream host apps import. Any binary XCFramework distributed to external developers must be built with this setting enabled. Verifying it is on should be part of every release checklist.
iOS: XCFramework Distribution and Code Signing
XCFramework bundles device and simulator slices into a single distributable. It is the Apple-recommended binary distribution format and the correct baseline for any SDK distributed as a compiled binary.
Starting with Xcode 15, Xcode verifies the origin of signed XCFramework dependencies. When an expected signature is missing, invalid, or unexpectedly changes, Xcode surfaces a warning or error in the host app. For distributed binary SDKs, signing the XCFramework with a stable, trusted identity gives host apps a stronger origin signal and avoids verification failures after Xcode upgrades.
Sign the XCFramework before packaging it, using the identity appropriate for your distribution context:
codesign --timestamp -s "<SIGNING_IDENTITY>" PaymentsSDK.xcframeworkThe signed and zipped framework is then referenced in the SPM package manifest:
// Package.swift
.binaryTarget(
name: "PaymentsSDK",
url: "https://releases.example.com/sdk/1.2.0/PaymentsSDK.xcframework.zip",
checksum: "sha256-hash-of-the-zip-file"
)Generate the checksum from the zip file itself, not from the XCFramework contents:
swift package compute-checksum PaymentsSDK.xcframework.zipThe checksum must be updated with every release. An SPM package with a stale checksum will fail to resolve for host apps.
CocoaPods Positioning
CocoaPods is in maintenance mode. The CocoaPods project has published a plan for its trunk to become read-only, with a target date of December 2, 2026. Existing integrations will continue to work for some time, but new SDK guidance should not present CocoaPods as equal to SPM and XCFramework distribution.
For SDKs with existing CocoaPods users, a .podspec should remain published for backward compatibility. But it should not be the primary distribution channel in new documentation. New SDK projects should default to SPM with a binary XCFramework.
The practical transition: make SPM the documented primary path. Mention CocoaPods as a legacy option that will be maintained but not extended.
Binary Size and Startup Impact
A host-app tech lead cares about what the SDK adds to their app beyond functionality.
Android binary size: Monitor AAR size and the SDK's contribution to the host app's method count. Each dependency increases dex size, build time, and the host app's optimization surface. For older or constrained app configurations, additional methods can also contribute to multidex and 64K-method pressure. Use the Gradle dependencies task to track the dependency surface across releases.
iOS framework size: Track du -sh PaymentsSDK.xcframework across releases. Unintentional size increases between releases often signal that a dependency was accidentally linked into the framework target rather than kept as a separate module.
App startup impact: The work the SDK does in Application.onCreate() on Android and in application(_:didFinishLaunchingWithOptions:) on iOS runs on every app launch. The bootstrap layer must complete in milliseconds. Any blocking I/O, large file read, expensive computation, or network call in the bootstrap path adds directly to app startup time.
Measure the SDK's startup contribution explicitly, on a mid-range device, before each release. If the bootstrap time increases between releases, investigate before shipping.
Open-Source vs Closed-Source Distribution
Binary distribution (XCFramework or AAR) is the right default for closed-source SDKs. It controls the shipped surface, protects implementation details, and gives the SDK author full control over what version of the code is in use.
Source distribution is usually better for open-source SDKs. Host apps that integrate open-source SDKs as source can:
- Audit the code before adoption, which is a hard requirement for many security-conscious organizations
- Apply local patches without waiting for an upstream release
- Pin to a specific commit when a release is unstable
A hybrid approach: publish source code publicly but distribute precompiled binaries by default. Advanced integrators can substitute local builds. This removes the barrier for security audits while giving most integrators the convenience of binary distribution. Supporting this requires explicit project configuration on both platforms so that the local build can be swapped in without modifying the host app's build system.
The choice changes the trust conversation. A binary-only SDK asks integrators to trust the vendor's build process. An open-source SDK asks them to trust the code. Be explicit about which one you are asking for.
Breaking Change Taxonomy
Not all breaking changes produce compile errors. The most dangerous category of breaking change is one that passes CI and appears as a runtime failure in the host app, often reported by a user rather than caught during development.
| Category | Example | Compile Error? |
|---|---|---|
| API surface change | Renamed method, removed public class | Yes |
| New required parameter | Added required config parameter to initialize() | Yes |
| Behavior change | Callback now arrives on main thread | No |
| Minimum OS bump | minSdkVersion raised from 21 to 24 | Sometimes |
| Dependency range change | Minimum OkHttp version raised to 4.x | Sometimes |
| Shrinker rule change | Removed a keep rule for a result sealed class | No (release only) |
Behavioral breaking changes deserve particular attention. If the SDK changes which thread delivers a completion callback, alters the conditions under which a specific error code is returned, or changes initialization timing, the host app's behavior changes without any signal at compile time. Host app developers may not discover the change until a user reports a crash or a UI anomaly.
Every breaking change, including behavioral ones, should increment the major version and appear in the migration guide. The changelog entry should name the category: "Behavioral change: completion callbacks are now delivered on a background thread."
Migration Guides
A migration guide is how the SDK team respects the time of every developer who has already integrated the SDK.
A migration guide that says "update your import statements" is not useful. A migration guide that explains what changed, why it changed, and shows before-and-after code on both platforms is.
Migration guide structure:
- What changed, stated plainly: one sentence.
- Why it changed: one or two sentences on the practical reason.
- Before and after code, on both platforms where applicable.
- Behavioral differences: what the old behavior was, what the new behavior is.
- Version support window: if both APIs are available during a transition period, state when the old one is removed.
Example: breaking initialization change (1.x to 2.x)
This change splits the single initialize() call into the two-layer bootstrap() + configure() pattern described in the first article in this series.
What changed: initialization is now a two-step process. SDK.initialize() is replaced by SDK.bootstrap() followed by an optional asynchronous SDK.configure() call.
Why it changed: the previous single-call initialization blocked the main thread while fetching remote configuration. The new pattern separates startup from configuration and makes the SDK usable immediately after bootstrap.
Before (1.x, Android):
// Old: synchronous, blocking, all required parameters upfront
SDK.initialize(
context = applicationContext,
apiKey = BuildConfig.SDK_API_KEY,
userId = auth.currentUserId()
)After (2.x, Android):
// New: non-blocking bootstrap, async configure
SDK.bootstrap(context, apiKey = BuildConfig.SDK_API_KEY)
lifecycleScope.launch {
SDK.configure { userId = auth.currentUserId() }
}Before (1.x, iOS):
// Old: synchronous, single call
SDK.initialize(
apiKey: "key",
userId: auth.currentUserId()
)After (2.x, iOS):
// New: separate bootstrap and configure
SDK.bootstrap(apiKey: "key")
Task {
try await SDK.configure(userId: auth.currentUserId())
}Behavioral difference: in 1.x, the SDK was not usable until initialize() returned. In 2.x, core APIs are available immediately after bootstrap(). Context-dependent APIs require configure() to complete and return a typed error until it does.
The 5-Minute Integration Test
This test definition is used consistently across the series:
Starting from a blank host app, using only the README, a developer should install the SDK, initialize it, and call one SDK method successfully within five minutes. If any step requires leaving the README, asking for help, guessing lifecycle order, or debugging an unclear error, the test fails at that step.
The test should be run by someone who did not build the SDK. An author who tests their own SDK will unconsciously fill in the gaps that only they can see.
A failed step identifies exactly where the distribution, documentation, or API design broke down:
- Failed at installation: the package distribution or setup documentation is broken.
- Failed at initialization: the initialization design or README instructions need work.
- Failed at the first API call: the API shape or autocomplete documentation needs work.
- Failed due to an unclear error: the error model needs work.
The 5-minute test is not a proxy for "does the SDK work." It is a direct measure of whether the first-run experience is good enough to earn adoption.
Release Readiness Checklists
Android:
- AAR builds successfully for a release variant with
minifyEnabled = true consumer-rules.prois committed, covers required dynamic/reflective/serialized surfaces, and has been validated in a minified host app- Transitive dependency tree is inspected and documented
- SDK method count and AAR size are recorded and compared against the previous release
- Breaking changes are annotated in the changelog with their category
- Major version is incremented for any breaking change, including behavioral ones
- Migration guide is written for any breaking change
iOS:
- XCFramework is built with
BUILD_LIBRARY_FOR_DISTRIBUTION = YES - XCFramework is signed with a valid signing identity appropriate to the distribution context
- SPM package checksum is updated to match the new release zip
- A sample app integrates the SDK via the SPM binary package (not via the Xcode workspace) and builds successfully
- Framework binary size is recorded and compared against the previous release
- Breaking changes are annotated in the changelog with their category
- Major version is incremented for any breaking change, including behavioral ones
- Migration guide is written for any breaking change
Both platforms:
- The 5-minute integration test passes in a blank project, run by someone who did not build the SDK
- Sandbox environment validates expected behavior without production credentials
- Changelog is reviewed and complete before tagging the release
- Semantic version correctly reflects the change type: major for breaking, minor for additive, patch for fixes