Mobile Development 34 min read

TianQu: Pure Kotlin Coroutine-Driven KMP/CMP Router for HarmonyOS, Android & iOS

TianQu is a pure Kotlin, coroutine-driven routing framework for Kotlin Multiplatform/Compose Multiplatform that supports HarmonyOS, Android, and iOS, featuring compile-time KSP scanning, type-safe navigation, cross-module service discovery, ViewModel lifecycle binding, concurrent preloading, and dynamic feature loading.

51CTO HarmonyOS Developer Community
51CTO HarmonyOS Developer Community
51CTO HarmonyOS Developer Community
TianQu: Pure Kotlin Coroutine-Driven KMP/CMP Router for HarmonyOS, Android & iOS

Platform Support

TianQu supports three platforms: HarmonyOS, Android, and iOS, all marked as production-ready.

Core Features

Pure Coroutine-Driven Routing Chain : Uses suspend functions throughout — awaitNavigateForResult eliminates callback nesting for page results; RouterGuard interceptors are suspend functions allowing network requests, dynamic module downloads, or permission dialogs without thread switching; RouterHandler 404 handling and external routing run in coroutine context for async fallback.

Pure KSP Compile-Time Scanning : Zero-intrusion, no manual registration, supports incremental compilation.

Automatic Cross-Module Aggregation : Feature modules generate sub-route tables; the app module automatically aggregates all sub-modules, solving multi-module componentization.

Powerful Parameter Passing : Supports URL path variables ( /user/{id}), query parameters ( ?id=1), complex large objects via extra, and type-safe data-class-based passing using Kotlinx Serialization.

Cross-Module Service Discovery : Enables interface sinking and dependency inversion with rememberService<T>() for coroutine-safe singleton loading.

Lifecycle & ViewModel Binding : Provides dedicated page scope with tianquViewModel<T>(), cross-platform @InjectViewModel auto-injection via tianQuViewModelInject<T>(), and automatic coroutine cancellation on page pop to prevent leaks.

Multi-Type Routing : Supports full-screen ( RouteType.SCREEN) and dialog ( RouteType.DIALOG) via the same Navigator interface, including dialog-to-screen result return.

Concurrent Preloading Engine : Uses CompletableDeferred -based RoutePreloader to load target-page data concurrently during transition animations (300–500 ms), achieving “instant open” experience.

Offline DeepLink Intent Caching : Built-in unbounded Channel queues intents during cold start or high-frequency external launches, guaranteeing no lost navigation events.

Advanced Navigation Behaviors : Includes launch modes ( SINGLE_TOP, SINGLE_TASK) with zero-recomposition parameter reuse, multi-back-stack for tabs, custom transitions, Compose shared-element animations, and global 404 fallback.

Integration & Configuration

1. Add Repositories

In settings.gradle.kts, add the Huawei Maven repository first (required for HarmonyOS Kotlin/CMP forks), then Google and Maven Central.

pluginManagement { repositories { maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/") google() mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositories { maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/") google() mavenCentral() } }

2. Version Catalog (gradle/libs.versions.toml)

[versions] tianqu-router-annotations = "1.0.8" tianqu-router-processor = "1.0.8" tianqu-router-runtime = "1.0.8" ksp = "2.2.21-2.0.4" [libraries] tianqu-router-annotations = { module = "io.gitee.zhongte:tianqu-router-annotations", version.ref = "tianqu-router-annotations" } tianqu-router-processor = { module = "io.gitee.zhongte:tianqu-router-processor", version.ref = "tianqu-router-processor" } tianqu-router-runtime = { module = "io.gitee.zhongte:tianqu-router-runtime", version.ref = "tianqu-router-runtime" } [plugins] ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

3. Feature Module Configuration

Apply KSP, depend on annotations and runtime, set tianqu.moduleName KSP argument, ensure Java 17, and add KSP-generated source directory to commonMain. Also enforce KSP task ordering.

plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.ksp) } kotlin { jvmToolchain(17) sourceSets { commonMain.dependencies { implementation(libs.tianqu.router.annotations) implementation(libs.tianqu.router.runtime) } } } ksp { arg("tianqu.moduleName", project.name) } dependencies { add("kspCommonMainMetadata", libs.tianqu.router.processor) } kotlin.sourceSets.commonMain { kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin") } tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach { if (name != "kspCommonMainKotlinMetadata") { dependsOn("kspCommonMainKotlinMetadata") } }

4. App Module Configuration

Declare tianqu.isApp=true in KSP args, depend on runtime and all feature modules, and apply same Java 17 and source-set setup.

ksp { arg("tianqu.moduleName", project.name) arg("tianqu.isApp", "true") } kotlin { jvmToolchain(17) sourceSets { commonMain.dependencies { implementation(libs.tianqu.router.annotations) implementation(libs.tianqu.router.runtime) implementation(project(":feature-a")) implementation(project(":feature-b")) } } } dependencies { add("kspCommonMainMetadata", libs.tianqu.router.processor) } kotlin.sourceSets.commonMain { kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin") } tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach { if (name != "kspCommonMainKotlinMetadata") { dependsOn("kspCommonMainKotlinMetadata") } }

Basic Navigation & Global Initialization

1. Initialize RouterHost

In shared App.kt, call rememberAppTianQuState to assemble framework, injecting GlobalRouteAggregator.routers and services, setting start route, guards, and event handler.

val guards = remember { listOf(object : RouterGuard { override fun matches(context: RouterContext): Boolean = context.url.startsWith("/user") override suspend fun canActivate(context: RouterContext, chain: GuardChain): Boolean { println("🚀 [Local Guard] Enter User module, URL: ${context.url}") return chain.proceed(context) } }) } val navigator = rememberTianQuApp { routes = GlobalRouteAggregator.routers serviceProviders = GlobalRouteAggregator.services startRoute = "/main_tab" this.guards = guards onRouteEvent = { event, nav -> when (event) { is RouterEvent.NotFound -> nav.navigateTo("/main_tab") is RouterEvent.Navigated -> println("✅ Navigated: ${event.url}") } } } MaterialTheme { RouterHost(navigator = navigator) }

2. Obtaining Navigator

Via LocalNavigator.current in Compose, or from coroutine context using rememberRouterScope() and coroutineContext[Navigator].

3. Page State Preservation

Framework uses rememberSaveableStateHolder; developers must use rememberSaveable (not remember) for UI state that survives back-stack removal/re-entry.

4. Back Press / Swipe-Back Interception

Built-in cross-platform BackHandler auto-mounted by rememberAppTianQuState. Default: pop when stack size > 1. Disable globally via enableBackHandler = false. On HarmonyOS, override onBackPress (not onBackPressed) in Index.ets. Per-screen interception uses BackHandler(enabled = condition) { ... } with child-first priority.

5. Launch Modes

Three modes via @Router(launchMode = ...): STANDARD (default, new instance each time), SINGLE_TOP (reuse top if same), SINGLE_TASK (reuse existing in stack, pop above). Reuse triggers recomposition only when parameters materially change.

6. Custom Transitions

Extend BaseTransitionStrategy, annotate with @Transition(name = "CustomName"), reference in @Router(transition = "CustomName"). KSP auto-collects.

Parameter Passing Scenarios

Scenario 1: URL Path & Query

navigator.navigateTo("app://shijing.tianqu/user/1001?source=home_banner&vip=true") @Router(path = "/user/{id}") @Composable fun UserDetailScreen(context: RouterContext) { val userId = context.pathParams["id"] val source = context.queryParams["source"]?.firstOrNull() val isVip = context.queryParams["vip"]?.firstOrNull()?.toBoolean() ?: false Text("User ID: $userId, Source: $source, VIP: $isVip") }

Scenario 2: Complex Objects via Extra

data class UserProfile(val name: String, val age: Int, val isVip: Boolean) val profile = UserProfile("Kotlin Developer", 25, true) navigator.navigateTo("/profile", extra = profile) @Router(path = "/profile") @Composable fun ProfileScreen(context: RouterContext) { val profileData = context.extra as? UserProfile if (profileData != null) Text("Hello, ${profileData.name}, age ${profileData.age}") else Text("No complex object received") }

Scenario 3: Type-Safe with Kotlinx Serialization

Add serialization plugin and JSON library. Define @Serializable data class UserDetailArgs(...). Navigate with

navigator.navigateArgs(path = "/typesafe_demo", args = UserDetailArgs(...))

. Retrieve via context.getTypedArgs<UserDetailArgs>().

Concurrent Data Preloading

Implement RoutePreloader with suspend preload(context) returning data. Register via remember { mapOf("/demo_preload" to UserDetailPreloader()) } in rememberAppTianQuState (must be stable map) or dynamically via navigator.registerPreloader. In target screen, use rememberPreloadData<T>() which suspends until data arrives, showing loading during animation.

Page Result Return

Target page calls navigator.popBackStack(result = anyObject). Caller uses awaitNavigateForResult in navigator.coroutineScope.launch (must use rememberSaveable for result state) or callback-based navigateWithResult.

Cross-Module Service Discovery

Define interface in common module. Implement in feature module with @Service annotation. Retrieve in Compose via rememberService<UserService>() (suspend-safe) or anywhere via ServiceManager.getService<UserService>().

ViewModel Lifecycle Binding

Three ways: 1) tianquViewModel<T>() — reflection-based, no-arg constructor only, fails on iOS. 2) tianquViewModel<T>(factory) — custom factory, supports parameters, cross-platform. 3) tianQuViewModelInject<T>() with @InjectViewModel on ViewModel — KSP generates factory, no reflection, cross-platform, zero boilerplate (currently no-arg only). All bind to page lifecycle; onCleared() called on pop, cancelling bound coroutines.

Dynamic Feature Loading

Use RouterGuard with suspend canActivate to download module (e.g., delay 2s), then navigator.registerDynamicRoutes(listOf(RouterNode(...))), return true. Caller shows loading UI around navigator.push("/dynamic_feature") in a coroutine.

Other Advanced Capabilities

Multi-Back-Stack & Tab Persistence : Use rememberSaveableStateHolder() and SaveableStateProvider(key) to preserve per-tab state.

Compose Shared Element Transitions : Tag source and destination composables with Modifier.routerSharedBounds(key = "shared_id").

Global 404 Fallback : Handle RouterEvent.NotFound in onRouteEvent to redirect.

Offline DeepLink Caching : Call DeepLinkManager.dispatch(url) at native entry; Navigator consumes cached intents via Channel after init.

Dialog Routes : Annotate @Router(type = RouteType.DIALOG); control dismiss via BackHandler and clickable background.

Source code and documentation available at https://github.com/peiyunfei/TianQu.

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.

iOSViewModelAndroidService DiscoveryHarmonyOSRoutingCoroutinesNavigationKotlin MultiplatformKSPCompose MultiplatformDynamic Feature Loading
51CTO HarmonyOS Developer Community
Written by

51CTO HarmonyOS Developer Community

The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.

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.