Nav3-Router Replaces ARouter with a KSP-Powered, Dual-Track Navigation Framework for Compose
Goodbye ARouter! Building a Dual-Track Routing Framework for the Compose Era Based on Google's Official Navigation 3 + KSP
In the Jetpack Compose era, the traditional ARouter, based on Activity/XML architecture and reflection mechanisms, can no longer meet the demands of modern single-Activity state-driven routing. This article designs and implements a Nav3-Router dual-track routing framework from scratch, based on Android's latest official Navigation 3 (androidx.navigation3:1.1.4) engine.
The full text covers: KSP compile-time type safety and URL dynamic decoupling, multi-module zero-configuration DSL initialization, main thread synchronization safety lock, 404 fault-tolerant degradation, process death restoration, NavEntryDecorator decorator onion-skin system, and Compose shared element morphing transitions. Complete source code and architecture design diagrams are included. Welcome to discuss! https://github.com/dalingge/nav3-router
📐 Architecture Design and Physical Layering
The project strictly follows modular boundaries of high cohesion and low coupling:
┌──────────────────────────────────────────────────────────┐
│ App Business Layer (UI & ViewModels) │
└────────────────────────────┬─────────────────────────────┘
│ (Fluent DSL Chained Init & Dual-Track Navigation)
┌────────────────────────────▼─────────────────────────────┐
│ Framework Runtime (:nav-runtime) │
│ - Minimal Chained Configuration Bus (NavCenter) │
│ - Process Death Restoration (saveState / restoreState) │
│ - DeepLink / Push One-Click Dispatch (handleIntent & IntentResolver)│
│ - 404 Fault-Tolerant Degradation & Chain of Responsibility (RouteHandler)│
│ - Runtime Interceptor Chain (RouteInterceptor) & Decorators (NavEntry)│
└────────────────────────────┬─────────────────────────────┘
│ (KSP Compile-Time Scanning)
┌────────────────────────────▼─────────────────────────────┐
│ Pure Annotation Module (:nav-annotation) │
│ - @Screen (Pure Compile-Time Route Marker) │
│ - @Required (Required Parameter Validation Marker) │
└────────────────────────────┬─────────────────────────────┘
│ (Underlying Proxy)
┌────────────────────────────▼─────────────────────────────┐
│ Android Official Engine (androidx.navigation3)│
│ - NavDisplay (Scene Rendering / Multi-Pane Split / Auto State Restore)│
│ - NavEntry (Official ViewModelStoreOwner & State Persistence)│
└──────────────────────────────────────────────────────────┘
🌟 Core Features
- Process Death Restoration: Seamlessly serializes the navigation stack into a URL list stored in a
Bundle. When the app is killed by the system in the background and restored, the entire page stack is completely rebuilt with one click, with 0 risk of Parcelable crashes. - DeepLink / Push Decoupled Dispatch (
IntentResolver): Unified handling of Scheme external launches, push notifications, desktop widgets, and NFC. Supports custom complex encrypted push payload parsing via theIntentResolverstrategy interface. - Compile-Time Route Duplication Check: KSP automatically scans all
@Screenpaths at compile time. If a duplicate route is found, the build is directly interrupted, eliminating the hidden risk of silent online overwrites at the source. - 404 Fault-Tolerant Degradation Mechanism (
Fallback Route): Supports configuringsetFallbackRoute("app/not_found"). When a user misclicks or an incorrect link is delivered, it automatically degrades smoothly, never showing a white screen or crashing. - Chain of Responsibility Pre-processing (
RouteHandler): Based on the chain of responsibility pattern, it allows minimal extension for H5 domain whitelist detection, external system browser launching, and custom Scheme interception. - Required Parameter Security Defense (
@Required): Use@Requiredto mark core route parameters. If a parameter is missed during navigation, an explicit exception is thrown at runtime for quick problem location. - Official Nav 3 Native Integration: Directly proxies
navigation3'sNavDisplayandNavEntry, natively enjoying official lifecycle management and automatic ViewModel release. - Minimal Fluent DSL Initialization: Complete animation configuration, interceptor registration, multi-module route loading, and home page stacking in one go via
NavCenterchained calls. - Zero-Configuration Multi-Module Architecture: KSP automatically analyzes sub-module package names to generate unique extension functions (e.g.,
NavCenter.initUser()), with no naming conflicts and 0 lines of Gradle configuration. NavEntryDecoratorDecorator Onion-Skin System: Natively integratesrememberViewModelStoreNavEntryDecorator(), automatically triggeringonCleared()when a page is popped off the stack.- Dual-Layer Transition Animations and Shared Element Morphing: Supports global transitions, local
@Screen(enterTransition = ...)overrides, and.sharedElementKey("key")for seamless cross-page zoom morphing.
📌 @Screen Route Naming Conventions
In @Screen(route = "..."), route is the globally unique path identifier for the page. Please strictly follow the following 5 conventions:
| Convention | Rule Description | ✅ Correct Example | ❌ Incorrect Example |
|---|---|---|---|
| 1. Pure Path Format | Never include Query parameters (parameters are dynamically appended during navigation) | @Screen(route = "app/detail") |
@Screen(route = "app/detail?id={id}") |
| 2. Modular Two-Level Path | Recommend [module]/[screen] to avoid cross-module conflicts |
@Screen(route = "shop/cart") |
@Screen(route = "cart") |
3. No Leading / |
Uniformly omit the leading slash to improve route table matching efficiency | @Screen(route = "user/login") |
@Screen(route = "/user/login") |
| 4. All Lowercase Snake Case | Follow standard URL protocol format to prevent case mismatch | @Screen(route = "shop/order_detail") |
@Screen(route = "Shop/OrderDetail") |
| 5. Global Uniqueness | Paths must be unique within the same App; duplicates trigger a compile-time error | Globally unique Path | Multiple pages configured with the same route |
🚀 Quick Start
1. Add Dependencies
The framework has been published to Maven Central. Declare dependencies in build.gradle.kts:
plugins {
id("com.google.devtools.ksp") version "2.3.10"
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10"
}
dependencies {
// Nav3-Router Core
implementation("io.github.dalingge:nav-annotation:1.0.1") // Pure annotation module
implementation("io.github.dalingge:nav-runtime:1.0.1") // Runtime core module
ksp("io.github.dalingge:nav-compiler:1.0.1") // KSP compiler
// Official Navigation 3 Lifecycle Library
implementation("androidx.lifecycle:lifecycle-viewmodel-navigation3:1.1.4")
// Kotlinx Serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
}
// Multi-module projects need to configure the module name for generating NavCenter.initXxx() extension functions
ksp {
arg("NAV_MODULE_NAME", "user")
}
2. Initialization (Including Process Restoration, DeepLink Dispatch, and 404 Degradation)
Complete configuration and restoration decoupling in MainActivity via the NavCenter chained API:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 1. Fluent DSL initialization for all configurations
NavCenter
.init(this) // Bind Context
.setFallbackRoute("app/not_found") // Enable 404 fault-tolerant degradation route
.addRouteHandler(WebViewHandler("app/webview", setOf("app.cn")))// H5 whitelist goes to local WebView
.addRouteHandler(BrowserHandler(this)) // Non-whitelist H5 goes to system browser
.addEntryDecorator { rememberViewModelStoreNavEntryDecorator() }// Inject official ViewModel scope isolation
.addEntryDecorator(AnalyticsEntryDecorator()) // Inject custom full tracking and onPop cleanup
.setDefaultTransition(DefaultSlideTransition()) // Configure global transition animation
.addGlobalInterceptor(AppLoginInterceptor()) // Register global login interceptor
.initUser() // Auto-load :feature-user module routes
.initShop() // Auto-load :feature-shop module routes
.initApp() // Auto-load :app module routes
// 2. Decoupled restoration logic trilogy
val isRestored = NavCenter.restoreState(savedInstanceState) // A. Attempt to restore from process death state
val isIntentHandled = NavCenter.handleIntent(intent) // B. Attempt to launch from DeepLink / Push notification
// C. If no process restoration and no external launch, push the root home page
if (!isRestored && !isIntentHandled && NavCenter.primaryStack.backstack.isEmpty()) {
NavCenter.navigate(HomeScreenDestination())
}
setContent {
MaterialTheme {
NavCenter.Render()
}
}
}
// 3. Respond to stack persistence saving before background process is killed
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
NavCenter.saveState(outState)
}
// 4. Respond to new external Scheme / Push launches in singleTop/singleTask mode
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
NavCenter.handleIntent(intent)
}
override fun onBackPressed() {
if (!NavCenter.pop()) {
super.onBackPressed()
}
}
}
💡 Core Usage Guide
1. Process Death Restoration 🆕
When the user switches the App to the background and the system kills the process due to low memory, the framework automatically serializes and saves the navigation stack. All pages are automatically restored when the App is reopened:
// 1. Automatically persist the current Backstack before Activity destruction
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
NavCenter.saveState(outState)
}
// 2. One-click restore the entire page stack from before being killed in onCreate
val isRestored = NavCenter.restoreState(savedInstanceState)
2. DeepLink & Push Notification One-Click Dispatch (IntentResolver) 🆕
The framework automatically handles Scheme and push launches via handleIntent. If your push contains complex encrypted Payloads, you can implement the IntentResolver strategy interface for injection:
// Custom encrypted push Payload parsing strategy
class CustomPushIntentResolver : IntentResolver {
override fun resolve(intent: Intent?): String? {
if (intent == null) return null
// Prioritize parsing standard Scheme URI (e.g., myapp://shop/detail?id=10086)
val schemeUrl = intent.dataString
if (!schemeUrl.isNullOrEmpty()) return schemeUrl
// Decrypt the target link inside push extras like JPush/Getui
val encryptedData = intent.getStringExtra("PUSH_PAYLOAD") ?: return null
return decryptPushUrl(encryptedData)
}
}
// Chained configuration injection:
NavCenter.setIntentResolver(CustomPushIntentResolver())
// Trigger launch:
NavCenter.handleIntent(intent)
3. Declaring Pages, Required Parameters @Required, and Local Transitions
@Serializable
data class UserProfile(val id: Int, val name: String)
// Detail page: Use @Required to mark required parameters; throws a security exception at runtime if not passed
@Composable
@Screen(route = "app/detail", needLogin = true)
fun DetailScreen(@Required detailId: Int, user: UserProfile) { ... }
// Dialog page: Locally override with BottomSheetTransition slide-in animation
@Composable
@Screen(
route = "app/bottom_dialog",
enterTransition = BottomSheetTransition::class
)
fun BottomDialogScreen() { ... }
4. Custom RouteInterceptor Interceptor
Implement the RouteInterceptor interface (belongs to :nav-runtime) for fast synchronous interception and transparent redirection:
class AppLoginInterceptor : RouteInterceptor {
override fun intercept(url: String): InterceptResult {
val uri = Uri.parse(url)
val path = uri.path?.removePrefix("/") ?: uri.schemeSpecificPart
val meta = NavRegistry.getMeta(path)
if (meta?.needLogin == true && !UserSession.isLoggedIn) {
val encodedTarget = URLEncoder.encode(url, "UTF-8")
return InterceptResult.Redirect("app/login?redirect=$encodedTarget")
}
return InterceptResult.Proceed
}
}
5. Shared Element Morphing Transitions
@Composable
@Screen(route = "app/home")
fun HomeScreen() {
val avatarKey = "user_avatar_10086"
Row(modifier = Modifier.clickable {
NavCenter.navigate(DetailScreenDestination(detailId = 1, user = UserProfile(1, "A")))
}) {
Image(
painter = painterResource(R.drawable.avatar),
contentDescription = null,
modifier = Modifier
.size(50.dp)
.sharedElementKey(key = avatarKey) // Bind Shared Key
)
Text("Click to view larger image")
}
}
6. Pure Kotlin Unit Testing (Navigator)
class HomeViewModel(private val navigator: Navigator) : ViewModel() {
fun openDetail(userId: Int) {
navigator.navigate(DetailScreenDestination(detailId = userId, user = UserProfile(userId, "Aleyn")))
}
}
// Pure Kotlin unit test (no Android/Robolectric environment required)
@Test
fun testOpenDetail() {
val fakeNavigator = FakeNavigator()
val viewModel = HomeViewModel(fakeNavigator)
viewModel.openDetail(10086)
assertEquals("app/detail", fakeNavigator.lastDestination?.route)
}
🚀 Advanced Features
1. Cross-Module UI-Less Service Discovery (@Service & IService)
To support decoupled calls for UI-less business logic in large componentized projects (e.g., :feature-shop needs to call the payment service of the :feature-pay module, but the two modules cannot have a direct Gradle dependency), the framework provides a zero-reflection service discovery mechanism based on KSP automatic registration.
A. Define the service interface in the common base library:
// Define the interface in the common base module (:core-common), must inherit IService
interface PayService : IService {
fun pay(orderId: String, amount: Double): Boolean
}
B. Use @Service annotation to expose in the service implementation module:
// Implement the interface in the business implementation module (:feature-pay) and mark with @Service
// contract specifies the interface exposed to the outside, path is an optional string path identifier
@Service(contract = PayService::class, path = "pay/service")
class PayServiceImpl : PayService {
override fun pay(orderId: String, amount: Double): Boolean {
Log.d("PayService", "Deducting $amount for order $orderId")
return true
}
}
C. Get and call without coupling in any business module (KSP compile-time strong type binding, 0 reflection):
// Method 1: Get by interface Class (recommended)
val payService = NavCenter.getService<PayService>()
payService?.pay(orderId = "10086", amount = 199.0)
// Method 2: Get by Path string
val payServiceByPath = NavCenter.getService<PayService>("pay/service")
payServiceByPath?.pay(orderId = "10086", amount = 199.0)
2. Dynamic Path / URL Rewriting Service (PathReplaceService)
Used for A/B testing, online URL dynamic correction, or server-delivered route mapping. Rewrites the original URL at the very front of route initiation:
A. Implement the PathReplaceService interface:
// A/B test dynamic path replacer
class ABTestPathReplacer : PathReplaceService {
override fun replace(rawUrl: String): String {
// If the original URL is "pay/detail" and the user is in the A/B test group, rewrite to the 2.0 detail page
if (rawUrl == "pay/detail" && ABTestEngine.isGroupA()) {
return "pay/detail_v2"
}
return rawUrl
}
}
B. Register in the NavCenter chained configuration (supports registering multiple rewriting strategy chains):
NavCenter
.addPathReplaceService(ABTestPathReplacer()) // Supports registering multiple rewriting strategies
.initUser()
.navigate(HomeScreenDestination())
3. Green Channel to Bypass Interception (greenChannel = true)
In emergency rescue, high-privilege direct access, or specific business scenarios, if you need to forcefully skip all global and private interceptors (RouteInterceptor), you can enable the greenChannel option during navigation:
// Force direct access to the detail page, skipping the global login interceptor and VIP permission interceptor
NavCenter.navigate(DetailScreenDestination(detailId = 100, user = user)) {
greenChannel = true // Enable green channel, bypass interception
}
4. Global Overlay Floating Layer Mechanism (showOverlay & dismissOverlay)
In a navigation system based on NavDisplay, pages annotated with @Screen are recognized as a brand new route Scene. If you want to pop up a cashier, global Loading, version update dialog, etc., across modules without switching the current route page and keeping the background current page fully visible, you can use the global Overlay floating layer mechanism.
Use Cases:
- Cross-Module Service Dialogs: e.g., providing a cashier in the
:feature-paymodule, when called by:feature-shop, it directly slides out a dialog above the current checkout page, keeping the checkout page intact as the background. - Global Loading/Status Dialogs: Global floating prompts that ignore the route page.
- Global Update/Announcement Dialogs: Independent of the navigation stack, not occupying route
backstackdepth.
A. Show a global Overlay floating layer:
// Call in Service implementation class, ViewModel, or anywhere; the floating layer will be directly superimposed on top of the current visible page
NavCenter.showOverlay {
// Put any pure Compose dialog component (e.g., ModalBottomSheet / Dialog)
PayDialog(
orderId = "ORDER_10086",
amount = 199.0,
onPaySuccess = {
NavCenter.dismissOverlay() // Close the floating layer
NavCenter.popWithResult("pay_result", true) // Return result
},
onDismiss = {
NavCenter.dismissOverlay() // Close the floating layer
}
)
}
B. Close the global Overlay floating layer:
NavCenter.dismissOverlay()
C. Elegant use in :feature-pay cross-module service (combining PayService service discovery to achieve zero UI coupling for pulling up the current page dialog):
@Service(contract = PayService::class, path = "pay/service")
class PayServiceImpl : PayService {
override fun showPayDialog(orderId: String, amount: Double) {
// Directly pop up the cashier above the current active page
NavCenter.showOverlay {
PayDialog(
orderId = orderId,
amount = amount,
onPaySuccess = {
NavCenter.dismissOverlay()
NavCenter.popWithResult("pay_result", true)
},
onDismiss = {
NavCenter.dismissOverlay()
NavCenter.popWithResult("pay_result", false)
}
)
}
}
}
📖 API Quick Reference
| API | Function Description |
|---|---|
NavCenter.init(context) |
Bind global context |
NavCenter.saveState(bundle) |
Serialize the current Backstack into a Bundle (for process death) 🆕 |
NavCenter.restoreState(bundle) |
Restore the page stack from before being killed from a Bundle, returns restoration result 🆕 |
NavCenter.handleIntent(intent) |
One-click parse and dispatch Scheme / DeepLink / Push notification navigation 🆕 |
NavCenter.setIntentResolver(resolver) |
Dynamically set custom DeepLink / Push parsing strategy 🆕 |
NavCenter.setFallbackRoute(route) |
Configure 404 route degradation fallback path |
NavCenter.addRouteHandler(handler) |
Register chain of responsibility pre-processor (e.g., WebViewHandler) |
NavCenter.navigate(dest, navOptions) |
Strongly-typed navigation (supports SingleTop / PopUpTo / ClearTask), supports chained calls |
NavCenter.navigate(url, navOptions) |
URL dynamic navigation (auto URL encoding/decoding & parameter matching), supports chained calls |
NavCenter.addEntryDecorator(decorator) |
Dynamically inject page Decorator (e.g., rememberViewModelStoreNavEntryDecorator) |
NavCenter.setDefaultTransition(transition) |
Register global default transition animation (e.g., DefaultSlideTransition) |
NavCenter.addGlobalInterceptor(interceptor) |
Dynamically register runtime interceptor (RouteInterceptor) |
NavCenter.initXxx() |
Multi-module KSP auto-generated fluent route initialization extension function |
Modifier.sharedElementKey(key) |
Bind a shared element Key to a component |
NavCenter.pop() |
Pop the top page off the stack (main thread synchronous, return value absolutely reliable) |
NavCenter.popWithResult(key, value) |
Pop with result, synchronously returns boolean status |
NavCenter.getResult<T>(key) |
Reactively listen for returned results inside Composable (supports nullable null results) |
NavCenter.Render() |
Official Navigation 3 UI rendering main entry point |
NavCenter.getService<T>() |
Cross-module discover and get UI-less service instance by interface Class (0 reflection) 🆕 |
NavCenter.getService<T>(path) |
Cross-module discover and get service instance by Path string 🆕 |
NavCenter.addPathReplaceService(service) |
Register dynamic path/URL rewriting strategy (for A/B testing, dynamic mapping) 🆕 |
NavCenter.navigate(dest) { greenChannel = true } |
Enable green channel, skip all interceptors during navigation to force direct access to the target page 🆕 |
@Service(contract = KClass, path = "") |
Cross-module service exposure annotation, KSP compile-time automatic service implementation registration 🆕 |
NavCenter.showOverlay { content } |
Superimpose any Compose floating layer on top of the current active page (keeping the background current page visible) 🆕 |
NavCenter.dismissOverlay() |
Close the current global Overlay floating layer 🆕 |
NavCenter.currentOverlay |
The Composable state object of the current floating layer (for custom rendering logic) 🆕 |
📄 License
Copyright 2024 Nav3-Router Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.