跪拜 Guibai
← Back to the summary

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


📌 @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:

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.