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.
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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
51CTO HarmonyOS Developer Community
The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
