Mobile Development 41 min read

JD's KMP Split Delivery: Per-Component Builds & Compiler Fixes for iOS & HarmonyOS

JD engineers detail their migration from monolithic KMP delivery to per-component builds for iOS and HarmonyOS, sharing a stdlib/runtime carrier, fixing bridge numbering mismatches and type ID issues in the KN compiler, implementing custom Gradle plugins and CI/CD for independent XCFramework/HAR publishing, enabling on-demand integration and faster builds.

JD Tech
JD Tech
JD Tech
JD's KMP Split Delivery: Per-Component Builds & Compiler Fixes for iOS & HarmonyOS

Background and Previous Delivery Model

JD uses Kotlin Multiplatform (KMP) to reuse business logic across Android, iOS, and HarmonyOS. Previously, each component maintained its own source repository, dependencies, and versions, then packaged into platform-specific libraries for app integration.

Android: Delivered via AAR/Gradle Module, dependencies managed by Gradle.

iOS: All KMP business modules bundled into a single Umbrella Framework.

HarmonyOS: All KMP business modules aggregated into one HAR.

In the iOS and HarmonyOS aggregate packages, business modules shared a single Kotlin standard library (stdlib) and native runtime. Modifying any component required regenerating and republishing the entire aggregate package. Although source code and versions were managed per component, the delivery artifacts remained monolithic.

If each component compiled a native library containing all dependencies, stdlib, runtime, and parts of dependency code would be duplicated. The split required deciding which code each component generates and which goes into a shared "common base" (called stdlib carrier and runtime carrier). Business components reference symbols from the base; linking resolves implementations without duplicating stdlib/runtime bodies. Android continues using AAR delivery.

Aggregate mode: all business modules delivered with a single artifact
Aggregate mode: all business modules delivered with a single artifact

Figure 1: Aggregate mode — all business modules delivered together

Transformation Goals and Overall Solution

The goal is to let iOS and HarmonyOS build and publish native artifacts per component while sharing the common base. Gradle manages dependencies; plugins and CI/CD handle packaging and publishing for all three platforms, then add new versions to integration manifests.

For each target architecture and build configuration, separate stdlib and runtime carriers are generated. Business components reference base symbols via functions and types; linking finds implementations at link time, without carrying the main stdlib/runtime code . Android keeps AAR delivery.

HarmonyOS split compilation builds on an open-source solution (Huawei Cross-Platform Framework Community KMP Edition) with further optimizations. iOS modular compilation, Gradle plugins, component templates, and three-platform CI/CD are self-developed.

Overall solution: HarmonyOS uses open-source base with optimizations; iOS modular delivery implemented in-house
Overall solution: HarmonyOS uses open-source base with optimizations; iOS modular delivery implemented in-house

Figure 2: Overall solution — HarmonyOS adopts open-source scheme with continued optimization; iOS split delivery implemented in-house

KN Compiler Basics: Huawei HarmonyOS Open-Source Solution

The referenced open-source solution is the Huawei Cross-Platform Framework Community KMP Edition . It generates an independent .so per component for subsequent HAR packaging. The compiler uses klib (Kotlin/Native intermediate library) as the unit to decide which .so provides which code. Each compilation reads required dependencies but only generates the current component's implementation, keeping external symbol references for other components.

Component Dependency Example

Component A depends on component B:

Compiling A's .so: generates A's implementation; keeps external symbol references for B and the base.

Compiling B's .so: generates B's implementation.

Stdlib and native runtime each generate a base .so; business .so s do not duplicate their main implementations. The stdlib base retains public APIs for business component calls.

The scheme uses moduleIncludes and outputModule to select code generated in this run, and emitStdlib, emitRuntime to control whether to emit base library implementations. LLVM linkage attributes are adjusted, and llvm.used marks required entry points to prevent dead-code elimination.

The adopted open-source version required recompiling all integrated components before release — even if only A changed, other components had to be recompiled and repackaged as HARs, unable to reuse old packages.

HarmonyOS: KN Compiler Subsequent Optimizations

HarmonyOS optimizations mainly address cross- .so bridge numbering mismatches and type ID inconsistencies.

4.1 Fixing Cross- .so Bridge Numbering Mismatch

When Kotlin calls C/N-API, the compiler generates a small thunk called a bridge function . Bridge functions in the same file are numbered sequentially by processing order: knbridge0, knbridge1, etc. The numbers are just order, not fixed semantics.

Component A depends on B. When compiling A, the compiler may also read and process C/N-API call code from B. The same call site must produce the same bridge name when compiling A and B.

Problem: Compiling B processes the full code; compiling A may only process the parts of B actually used, changing the numbering order. Example from the article:

Compiling B: processes "create object" then "read callback param";
  knbridge0 → create object
  knbridge1 → read callback param
Compiling A: only processes used "read callback param";
  knbridge0 → read callback param

Thus A thinks knbridge0 is "read callback param", but B's knbridge0 is "create object". At runtime, the system looks up by function name and cannot distinguish the two interpretations, so A's call may land on the wrong function.

Bridge numbering mismatch: same bridge name maps to different functions; unified read/process order restores consistency
Bridge numbering mismatch: same bridge name maps to different functions; unified read/process order restores consistency

Figure 3: Same bridge name corresponds to different functions; unified read and processing order restores consistency

Fix: During each component's compilation, read the full code of relevant business klibs and process in the same order. This ensures "create object" always maps to knbridge0 and "read callback param" to knbridge1, aligning caller and provider. Reading full code is for unified numbering only — each component still generates only its own business code. This may increase compile time and memory; future work can generate stable bridge names based on function name and parameter types instead of relying on processing order.

4.2 Fixing Cross- .so Type ID Issues

Global Hierarchy Analysis (GHA) assigns IDs ( classId / interfaceId) based on class/interface inheritance, writing type-check and interface-call info into compilation output. At runtime, these accelerate is, as, and interface method lookup.

Monolithic compilation shares one type set and numbering. After splitting, A and B compile their .so s independently; the same type may get different IDs. When A uses its own numbering to check an object created by B, is / as can fail even if linking succeeds.

Fix: Disable GHA in HarmonyOS split builds, switching to type checking and interface calls that do not rely on global numbering . Business testing confirmed no significant runtime performance impact.

iOS: Self-Developed KN Compiler Modular Compilation

iOS modular compilation and inter-Framework linking are self-implemented. Business components and common bases each generate a static Framework, then wrapped as XCFramework. At app link time, the system linker ld64 resolves symbol references across Frameworks.

5.1 Symbols, Bridges, and Shared State

Symbol Export: Components generate their own implementations, keep symbol references for dependencies. C/Objective-C interop (cinterop) code generated per component is explicitly defined. Entry points needed by other Frameworks are marked retained to avoid removal by dead_strip.

Duplicate Definitions: Shared cinterop klibs may produce duplicate enums or Objective-C base classes across Frameworks. For semantically identical duplicates, LLVM weak_odr allows the linker to merge them, avoiding duplicate symbol conflicts. This applies only to confirmed consistent definitions.

Objective-C & Runtime State: Private class names get module suffixes to avoid clashes. Each business Framework registers its own type adaptation table to prevent overwrites. Shared configuration is provided by the runtime base; Objective-C runtime injection logic adds guards against duplicate execution.

Ordinary cross-component Kotlin calls still use native symbols without an extra business bridge layer.

iOS also faced the numbering issue when judging types of objects created in other Frameworks. Disabling GHA was first applied on iOS, then ported to HarmonyOS split builds.

5.2 Stdlib On-Demand Export

Stdlib is pruned per the set of iOS components in the current integration. Base-internal unused functions might still be called by business Frameworks, so pruning must account for those calls.

Example: optimized iOS Release build with stdlibPruneMode=input:

Pass participating business klibs. Enable emitStdlib=true and stdlibPruneMode=input, pass business klibs and their platform dependencies for the current iOS target. Compiler reads business function bodies and type info to find required stdlib implementations. These business codes only participate in analysis; actual generation remains in each component.

Collect used symbols, fill indirect dependencies. Compiler aggregates directly used stdlib functions, constructors, fields, types, then checks vtables, itables, auto-generated Objective-C call bridges, and global variable initializers — these also reference stdlib. Traversing call and type relations yields the "must-retain symbol set" . Example: business enum's reference to kotlin.Enum inherited methods may appear only in vtable; some type casts only in generated Objective-C bridges. Missing these causes link or init failures.

Before inlining/devirtualization, save symbol requirements for later merge. Base compilation may inline or rewrite a call away, but other independently compiled Frameworks still need the original symbol.

Delete unused code (DCE). Dead Code Elimination starts from business-required symbols and necessary runtime entry points, retains their direct/indirect dependencies (functions, types, init code), deletes the rest. No need to keep all stdlib public functions by default.

Protect LLVM link entries. After IR-level pruning, prevent LLVM from further mistaken deletions. Retained functions in the base keep ExternalLinkage and are added to llvm.used preserve list. They may have no callers in the base but are needed by other Frameworks. Type info and some static globals are similarly protected.

Link-time implementation provided by base. Stdlib main body is generated by the base; business Frameworks keep external references. Host app links all static Frameworks; stdlib base supplies definitions.

Input mode: analyze business needs → prune unused code → protect link symbols → link Frameworks
Input mode: analyze business needs → prune unused code → protect link symbols → link Frameworks

Figure 4: Input mode — analyze business demands → prune unused code → protect link symbols → link Frameworks

Custom branch also supports passing symbol manifests via files: business compilation outputs required symbols line by line via stdlibDepsOutput; base reads and merges via stdlibDepsInput, fills dependencies, then prunes. input mode directly analyzes business klibs without first compiling business Frameworks to collect manifests.

Exported symbols are for native linking only; they do not expose the entire stdlib as Swift/Objective-C APIs. When participating components or dependencies change, symbol requirements must be re-analyzed and the base regenerated if needed.

Custom Gradle Plugin: Per-Component HAR and XCFramework Generation

The plugin provides HarmonyOS and iOS single-component packaging tasks, selecting the component via moduleName. Business declares dependencies in Gradle source sets, versions managed via version catalog. Script specifies component and version; plugin sets compiler parameters, arranges public interfaces, packages into HAR or XCFramework. Example using aggregate module KMPBom component A:

# iOS
./gradlew :KMPBom:podPublishReleaseModuleXCFramework \
  -PmoduleName=com.example:moduleA -Pversion=1.1.0
# HarmonyOS
./gradlew :KMPBom:assembleModuleHarReleaseOhosArm64 \
  -PmoduleName=com.example:moduleA -Pversion=1.1.0

Changing moduleName to std or runtime builds the corresponding base. Plugin sets moduleIncludes, outputModule, emitStdlib, emitRuntime per module config.

6.1 Determining Code Generated per Component

Gradle resolves the variant for the component coordinates, version, and target platform, obtaining required klibs. Before native link tasks, plugin reads klib manifest: identifies libraries via unique_name, finds dependencies via depends.

Plugin generates moduleIncludes telling compiler "which klib code goes into each artifact"; outputModule specifies "which artifact to generate this run". Example iOS business compilation for A depending on B:

moduleIncludes={moduleA:[com.example:moduleA];moduleB:[com.example:moduleB]}
outputModule=moduleA
emitStdlib=false
emitRuntime=false

Compiler reads A and B klibs, generates only moduleA group implementation. B's implementation comes from B's library; stdlib/runtime from their respective bases.

Dependency lookup and grouping differ:

HarmonyOS: Starts from target klib's depends, recursively finds components and dependencies, groups business klibs. Stdlib, platform libs, cinterop handled separately, not in normal business groups.

iOS: Reads target component's manifest from Gradle-resolved iosArm64 klib, builds groups via depends, uses dependency's short_name for group name. Plugin does not recursively fetch next-level dependencies; retains platform lib and cinterop group info.

6.2 HarmonyOS: Preparing Independent HAR Project from klib

When component publishes klib, plugin writes KSP-generated content and component info (name, version, OHPM dependencies, N-API entry flag). Aggregate module plugin reads these at packaging.

Packaging in four steps:

Select code to generate. Determine target component via moduleName; by default business libs do not generate stdlib/runtime bodies. Special artifacts std, runtime configured via kmp-har-config.json5 (also adjust module name, compiler args, HAR info).

Prepare libraries to link, generate .so . Before linking, read linkerOpts from manifest, select native lib params for current component, keep platform-required params. Install corresponding OHPM deps, add .so dir to search path, invoke custom compiler. moduleIncludes decides Kotlin code generation; native library linking handled separately.

Prepare HAR project. Read component info from target klib, prefer ArkTS template, merge only current component's type declarations and exports. Copy generated .so and required headers, write package name, version, component deps, base deps. If N-API entry needed, prepare loader init code and CMake config for hvigor compile; bases without this entry carry only their own .so.

Generate HAR. Run ohpm install and hvigorw assembleHar, collect artifacts named by module.

6.3 iOS: Export Interfaces per Component, Generate XCFramework

iOS plugin reuses CocoaPods build tasks, setting parameters per build target on top of existing static Framework config:

Business component: Determine code to generate, avoid regenerating stdlib/runtime bodies.

Stdlib: Enable emitStdlib and stdlibPruneMode=input, directly analyze business klib symbol needs.

Runtime: Only enable runtime body generation.

Plugin clears aggregate project's original export setting (which exported all components), makes business Framework export only current component; stdlib/runtime bases do not export business components. Before link tasks, plugin reapplies these settings to prevent other Gradle configs from reverting export scope.

Plugin also sets CocoaPods name, Framework name, and podspec version per target component. After per-arch static Frameworks are generated, CocoaPods task combines them into XCFramework with podspec for pipeline upload.

Plugin writes actual compiler params and export config to build/parameter/ios-cocoapods.gradle.kts, keeping a copy per module for debugging missing symbols, interface exports, naming issues.

6.4 Propagating KMP Inter-Component Dependencies to HAR/XCFramework

Example A depends on B: after split, B still publishes independently; dependency maps to A.har→B.har and A.xcframework→B.xcframework. Compiler keeps A's symbol references to B; package managers install both artifacts per dependency declarations.

HarmonyOS: Make A.har declare dependency on B.har. Implemented by plugin:

Read A's klib manifest for dependencies, find B, recursively find B's deps.

By component coordinates, locate corresponding klib, read ohpmName from component info for HAR package name; default naming rule if not configured.

Write these package names into A's

oh-package.json5
dependencies

. When business component omits stdlib/runtime bodies, add both base HAR dependencies. Native OHPM deps declared in component also propagated via component info.

App installs A; OHPM fetches declared deps. Each HAR's .so packaged into app by platform; runtime dynamic linker resolves cross-library calls. B's main implementation stays in its own HAR.

Auto-written component and base HAR dependency versions currently use *, not directly inheriting KMP build-time versions; integration must still specify compatible versions.

iOS: Link A.xcframework and B.xcframework via Pod dependencies. Two XCFrameworks stored independently; companion podspec declares package deps. iOS plugin:

Read current component's KMP deps, combine with Gradle resolution results, determine dependent component's Pod name and version.

Generate spec.dependency for B in A's podspec, declare required stdlib/runtime bases. If B depends on C, declare C when generating B's podspec.

App integrates A's Pod; CocoaPods fetches B, C, and bases per declarations, adds to project. App link resolves symbol references across Frameworks. export only controls Objective-C interface export; cannot replace podspec package dependency declaration.

6.5 Independent Component Updates Without Full Regeneration

iOS example: as long as existing deps and bases remain compatible, specify component via moduleName to generate only its XCFramework.

Assume app already integrates A, B, C and two bases. Now modify A:

Only A's business logic changed, existing deps still compatible. Generate and publish only new A package; app updates A version; B, C, bases keep old artifacts.

A needs functions missing from old base. E.g., A calls new stdlib function pruned from old base. Must aggregate new A's symbol needs with existing B, C, rebuild stdlib base retaining all required functions.

Validate new package alongside continued old packages; after pass, update app's dependencies.

First integration with no existing artifacts requires separate packaging for each business component and both bases. Currently manual decision on which components to rebuild; plugin does not auto-detect from code changes.

iOS update scenarios: update only A, or update A and stdlib base; validate new with old then integrate
iOS update scenarios: update only A, or update A and stdlib base; validate new with old then integrate

Figure 5: iOS update scenarios — update only A, or update A with stdlib base; validate new and old together before integration

Custom CI/CD: One Component Build, Publish Three-Platform Artifacts

Triggering a component build runs pipeline for Android, iOS, HarmonyOS compilation and packaging, publishing AAR, XCFramework, HAR; then adds integration, writing component version into platform integration manifests.

Two-layer templates complete three-platform build; after artifact publish, add integration, update platform integration manifests for subsequent builds
Two-layer templates complete three-platform build; after artifact publish, add integration, update platform integration manifests for subsequent builds

Figure 6: Two-layer templates complete three-platform build; artifacts published, integration added, platform integration manifests updated for subsequent builds

7.1 Two-Layer Templates: Outer Aggregate Module, Inner Business Component

component-template/
├── settings.gradle.kts          # Register iOS, HarmonyOS version configs
├── 集成单-iOS.json
├── 集成单-鸿蒙.json
├── gradle/
│   ├── bom.ios.versions.toml    # iOS component coordinates & versions
│   └── bom.ohos.versions.toml   # HarmonyOS component coordinates & versions
├── Script/kmp_build.sh
└── KMPBom/                      # Outer: aggregate module
    ├── build.gradle.kts         # Reference both version configs, declare component deps
    └── moduleA/                 # Inner: business component
        ├── build.gradle.kts
        └── src/

Both modules belong to same Gradle build:

Inner business component: Holds business source, declares own dependencies, compiles and publishes klibs for each native target plus Android AAR.

Outer aggregate module: Uses iOS/HarmonyOS version configs to locate needed components, then uses plugin to generate XCFramework or HAR for selected component. It reads published klibs, does not directly include inner source via project(...). settings.gradle.kts registers both tomls as Gradle version catalogs: iOS uses iosBoms, HarmonyOS uses ohosBoms. Outer KMPBom/build.gradle.kts adds deps via them. Example: api(iosBoms.componentA) adds componentA with version from bom.ios.versions.toml.

kotlin {
  sourceSets {
    iosMain.dependencies {
      api(iosBoms.componentA)  // version from bom.ios.versions.toml
    }
    ohosMain.dependencies {
      api(ohosBoms.componentA) // version from bom.ohos.versions.toml
    }
  }
}

Outer prepares compile dependencies; moduleName decides which component to package this run.

New component onboarding: rename inner module, declare business deps, register component coordinates and version in both tomls, add component to outer deps.

7.2 Pre-Build: Fetch Integration Manifests, Update Project Dependency Versions

Platform integration manifest lists components and versions selected for that platform. Before iOS/HarmonyOS builds, fetch manifest into template project, update project dependency versions:

Fetch platform integration manifests. Save iOS and HarmonyOS manifests to project root as 集成单-iOS.json, 集成单-鸿蒙.json for build scripts.

Update project dependency versions. Script writes component versions from manifest into gradle/bom.ios.versions.toml or gradle/bom.ohos.versions.toml.

If iOS manifest specifies A as 1.0.0, script sets A's version to 1.0.0 in bom.ios.versions.toml. Outer still references via api(iosBoms.componentA), but Gradle now resolves A 1.0.0 klib. HarmonyOS analogous.

Script only updates existing component versions; does not auto-add new components to aggregate module.

7.3 Two-Phase Build: Inner Publishes klib, Outer Produces Platform Artifacts

Inner component publishes klib first. Inner compiles current source to klibs for selected native targets. Template defaults to publishing to local Maven repo for outer consumption.

Outer aggregate module then packages. Script updates current component's dependency version to newly published klib version; other components keep manifest versions. Outer obtains these klibs, runs XCFramework/HAR tasks for moduleName specified component.

If inner publishes A 1.1.0 klib, outer changes A's dependency from 1.0.0 to 1.1.0, then generates platform artifacts. This modifies current project deps; platform integration manifests update only after three-platform artifacts published and integration added.

Under same platform/architecture, compatible KMP native libs must share compiler version, build mode (Debug/Release), GC and runtime config, Objective-C export config, split parameters — validated together.

With the aforementioned HarmonyOS open-source version, pre-release still requires recompiling all integrated components and packaging each as HAR.

Android follows existing flow generating and publishing AAR, Gradle manages component deps. Three platforms share publish entry, each packages per platform convention.

7.4 Publish Three-Platform Artifacts, Add Integration, Write Platform Integration Manifests

After specifying component and version, pipeline completes build, packaging, artifact verification, then sequentially:

Publish three-platform artifacts. Upload Android AAR, iOS XCFramework, HarmonyOS HAR; ensure versions fetchable.

Add integration. Select components and their published versions to include in this integration.

Write corresponding platform integration manifests. New components add entries; existing components update versions for subsequent builds.

After A 1.1.0 three-platform artifacts published and integration added, write to platform manifests, then notify. Other components' next builds re-fetch manifests, bringing A 1.1.0 into their outer aggregate module deps.

Publish script lists artifacts and sizes. If a component's package suddenly grows, check if it correctly references common base and if moduleIncludes mistakenly includes other components' klibs.

Pre-delivery verification required:

Symbol linking: Check undefined symbols, duplicate definitions, exported symbols, dependent libs; confirm cross-component calls resolve to implementations.

Component integration test: Combine components, verify cross-component calls, OHOS load order, is / as, interface method calls, global init, Objective-C/N-API bridges.

Config compatibility: Verify compiler version, base binary interface (ABI), build mode, split params; ensure packages work together.

Discovered bridge numbering and type checking issues should be added to cross-component regression tests to prevent reintroduction.

Landing Benefits

HarmonyOS startup speedup: Reduced app launch latency.

KMP compile speedup: Reduced single compilation time.

Matrix app on-demand integration: Matrix apps integrate KMP component artifacts per app's business needs — Android AAR, iOS XCFramework, HarmonyOS HAR — avoiding unnecessary KMP components.

Summary and Outlook

This work completed follow-up optimizations on HarmonyOS open-source solution, and self-developed iOS modular compilation, linking adaptation, and stdlib on-demand export. Gradle plugin, two-layer component template, and CI/CD integrated split capability into build and release flow, enabling separate delivery of business components and common bases.

Next steps: continue optimizing package size, reducing redundancy in stdlib, interop, and bridge code , and validate cross-component linking and runtime results after pruning.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

iOSCI/CDCompilerHarmonyOSGradleKotlin MultiplatformKMPStdlib Pruning
JD Tech
Written by

JD Tech

Official JD technology sharing platform. All the cutting‑edge JD tech, innovative insights, and open‑source solutions you’re looking for, all in one place.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.