1.前言
前面两篇讲解了KMP基础知识,瀑布流和网络请求,这篇来讲解一下KMP项目中的WebView网页加载和导航NavHostController的使用,直接上代码。
2.NavHostController
2.1 概念:
NavHostController是导航状态持有者 + 导航调度器,对应 XML 时代的 NavController,专门给 Compose(含 KMP)用。
2.2 状态本质:栈(BackStack)
内部维护一个 BackStack 栈,栈里每一项是 NavBackStackEntry:
- 保存路由目标 ID/route 字符串、参数、savedStateHandle、ViewModelStore
- 栈结构:push(navigate)、pop(返回)、popUpTo、popBackStack
和 Activity 任务栈思路相似,但完全在 Compose 侧,不是系统 Activity 栈,纯内存状态。
NavHostController 职责:
- 维护 BackStack 可变状态(用
MutableState,Compose 可观察) navigate(route):创建新NavBackStackEntry,入栈,触发状态变更 → Compose 重组popBackStack():弹出栈顶 entry,恢复上一个状态- 暴露
currentBackStackEntry,给 NavHost 读取当前路由
2.3 NavHost
NavHost(navController, startDestination) 是路由渲染容器:
- 监听
navController.currentBackStackEntryState - 当栈变化,Compose 重组,根据当前 entry 的 route,执行对应的
composable(route)lambda,渲染目标页面 - 页面不会自动销毁:BackStackEntry 保存在内存;pop 之后 entry 才会被标记销毁,ViewModel 跟着清。
2.4 KMP和Android原生ComposeNavigation差异
- 原生 Android:
androidx.navigation.compose - KMP:
org.jetbrains.androidx.navigation:navigation-compose(JetBrains 移植的 androidx navigation 到多平台)
API 几乎一致,但平台实现不同:
- Android:底层复用 androidx navigation 逻辑
- iOS/Desktop/Web:纯 Kotlin 重写导航栈,没有 AndroidX 依赖
2.5 关键对象
rememberNavController():Compose remember,把 controller 绑定到当前组合位置,重组不会重建NavBackStackEntry:单条路由记录,持有SavedStateHandle(跨配置变更保存参数)、ViewModelStore(每个页面独立 ViewModel)SavedStateHandle:键值存储,类似 Bundle,存路由参数,屏幕旋转 / 进程重建可恢复
2.6 导航生命周期
不是 Activity 生命周期,是BackStackEntry 生命周期:
- navigate:entry 入栈 → ON_RESUME
- 新页面覆盖旧页面:旧页面变为 ON_STOPPED(还在栈,ViewModel 存活,Composable 不重组)
- popBackStack:entry 出栈 → ON_DESTROY,ViewModel 清除
⚠️ 坑点:页面只是停止重组,Composable 实例、状态、ViewModel 还在内存,只有 pop 才销毁;深层页面多了会内存累积。
3.导航封装:
核心思想:
共享模块(common)只定义导航抽象接口 INavigator,不依赖任何 Compose Navigation / NavHostController;Android 平台实现 AndroidNavigator 包装原生 NavHostController,iOS 后续可以写 IOSNavigator 包装 iOS 的导航组件。 目的:common 业务代码不直接导入 navigation-compose,业务层只依赖 INavigator,做到跨平台业务逻辑一份代码,导航实现交给各平台。
模块总览:
common
└── INavigator 【统一抽象,业务只依赖这个接口】
androidApp
└── AndroidNavigator → 封装 NavHostController,Compose 页面内路由跳转
<span>web</span>(wasm/js)
└── WebNavigator → 浏览器环境,打开新标签 window<span>.open</span>
<span>desktop</span>(jvm)
└── DesktopNavigator → 桌面端,调用系统默认浏览器打开链接
<span>interface</span> <span>INavigator</span> {
<span><span>fun</span> <span>navigate</span><span>(route: <span>String</span>)</span></span>
<span><span>fun</span> <span>navigateToArticleDetail</span><span>(url:<span>String</span>, title:<span>String</span>)</span></span>
<span><span>fun</span> <span>popBack</span><span>()</span></span>
}
<span>package</span> com.example.kmpwandemo
<span>import</span> androidx.navigation.NavHostController
<span>class</span> <span>AndroidNavigator</span>(
<span>private</span> <span>val</span> navController: NavHostController
): INavigator {
<span>override</span> <span><span>fun</span> <span>navigate</span><span>(route: <span>String</span>)</span></span> {
<span>if</span>(navController.graph.id != <span>0</span>){
navController.navigate(route)
}
}
<span>override</span> <span><span>fun</span> <span>navigateToArticleDetail</span><span>(url: <span>String</span>, title: <span>String</span>)</span></span> {
<span>val</span> encodeUrl = java.net.URLEncoder.encode(url, <span>"UTF-8"</span>)
<span>val</span> encodeTitle = java.net.URLEncoder.encode(title, <span>"UTF-8"</span>)
<span>val</span> destRoute = <span>"articleDetail?url=<span>$encodeUrl</span>&title=<span>$encodeTitle</span>"</span>
println(<span>"====准备跳转:<span>$destRoute</span>"</span>)
navController.navigate(destRoute)
}
<span>override</span> <span><span>fun</span> <span>popBack</span><span>()</span></span> {
navController.popBackStack()
}
}
<span>package</span> com.example.kmpwandemo
<span>import</span> java.awt.Desktop
<span>import</span> java.net.URI
<span>class</span> <span>DesktopNavigator</span> : <span>INavigator</span>{
<span>override</span> <span><span>fun</span> <span>navigate</span><span>(route: <span>String</span>)</span></span> {
}
<span>override</span> <span><span>fun</span> <span>navigateToArticleDetail</span><span>(url: <span>String</span>, title: <span>String</span>)</span></span> {
<span>if</span>(Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)){
Desktop.getDesktop().browse(URI(url))
}
}
<span>override</span> <span><span>fun</span> <span>popBack</span><span>()</span></span> {
}
}
<span>package</span> com.example.kmpwandemo
<span>import</span> kotlinx.browser.window
<span>class</span> <span>WebNavigator</span> : <span>INavigator</span>{
<span>override</span> <span><span>fun</span> <span>navigate</span><span>(route: <span>String</span>)</span></span> {
}
<span>override</span> <span><span>fun</span> <span>navigateToArticleDetail</span><span>(url: <span>String</span>, title: <span>String</span>)</span></span> {
window.<span>open</span>(url, <span>"_blank"</span>)
}
<span>override</span> <span><span>fun</span> <span>popBack</span><span>()</span></span> {
}
}
4.Android卡片跳转详情:
@Composable
fun AppNavHost() {
val <span>navController</span> = rememberNavController()
val <span>navigator</span> = remember { AndroidNavigator(navController) }
NavHost(
<span>navController</span> = navController,
<span>startDestination</span> = <span>"articleList"</span>
) {
composable("articleList") {
ArticleListScreen(navigator)
}
composable(<span>route</span> = <span>"articleDetail?url={url}&title={title}"</span>) { backStackEntry ->
val <span>url</span> = backStackEntry.arguments?.getString(<span>"url"</span>) ?: <span>""</span>
val <span>title</span> = backStackEntry.arguments?.getString(<span>"title"</span>) ?: <span>""</span>
println("====详情页接收 <span>url</span>=<span>$url</span>, title=<span>$title</span><span>")
ArticleWebDetailScreen(navigator, url, title)
}
}
}
</span>
5.WebView详情页
<span>package</span> com.example.kmpwandemo.viewmodel
<span>import</span> android.net.http.SslError
<span>import</span> android.webkit.SslErrorHandler
<span>import</span> android.webkit.WebResourceError
<span>import</span> android.webkit.WebResourceRequest
<span>import</span> android.webkit.WebView
<span>import</span> androidx.compose.foundation.layout.Box
<span>import</span> androidx.compose.foundation.layout.fillMaxSize
<span>import</span> androidx.compose.foundation.layout.padding
<span>import</span> androidx.compose.material3.CircularProgressIndicator
<span>import</span> androidx.compose.material3.ExperimentalMaterial3Api
<span>import</span> androidx.compose.material3.Icon
<span>import</span> androidx.compose.material3.IconButton
<span>import</span> androidx.compose.material3.MaterialTheme
<span>import</span> androidx.compose.material3.Scaffold
<span>import</span> androidx.compose.material3.Text
<span>import</span> androidx.compose.material3.TopAppBar
<span>import</span> androidx.compose.runtime.Composable
<span>import</span> androidx.compose.runtime.getValue
<span>import</span> androidx.compose.runtime.mutableStateOf
<span>import</span> androidx.compose.runtime.remember
<span>import</span> androidx.compose.runtime.setValue
<span>import</span> androidx.compose.ui.Alignment
<span>import</span> androidx.compose.ui.Modifier
<span>import</span> androidx.compose.ui.res.painterResource
<span>import</span> com.example.kmpwandemo.INavigator
<span>import</span> com.google.accompanist.web.AccompanistWebViewClient
<span>import</span> com.google.accompanist.web.WebView
<span>import</span> com.google.accompanist.web.rememberWebViewNavigator
<span>import</span> com.google.accompanist.web.rememberWebViewState
<span>@OptIn(ExperimentalMaterial3Api::class)</span>
<span>@Composable</span>
<span><span>fun</span> <span>ArticleWebDetailScreen</span><span>(
navigator: <span>INavigator</span>,
url: <span>String</span>,
title: <span>String</span>
)</span></span> {
<span>val</span> webViewState = rememberWebViewState(url)
<span>val</span> webNavigator = rememberWebViewNavigator()
<span>var</span> isLoading <span>by</span> remember { mutableStateOf(<span>true</span>) }
<span>var</span> loadError <span>by</span> remember { mutableStateOf(<span>false</span>) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(title, maxLines = <span>1</span>) },
navigationIcon = {
IconButton(onClick = {
println(<span>"====点击返回按钮, web canGoBack:<span>${webNavigator.canGoBack}</span>"</span>)
<span>if</span> (webNavigator.canGoBack) {
webNavigator.navigateBack()
println(<span>"====执行webView回退"</span>)
} <span>else</span> {
navigator.popBack()
println(<span>"====页面栈popBack,退出详情页"</span>)
}
}) {
Icon(
painter = painterResource(id = android.R.drawable.ic_menu_revert),
contentDescription = <span>"返回"</span>
)
}
}
)
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
WebView(
state = webViewState,
navigator = webNavigator,
modifier = Modifier.fillMaxSize(),
onCreated = { webView ->
webView.settings.javaScriptEnabled = <span>true</span>
webView.settings.domStorageEnabled = <span>true</span>
webView.settings.databaseEnabled = <span>true</span>
webView.settings.loadsImagesAutomatically = <span>true</span>
webView.settings.cacheMode = android.webkit.WebSettings.LOAD_DEFAULT
},
client = <span>object</span> : AccompanistWebViewClient() {
<span>override</span> <span><span>fun</span> <span>onPageStarted</span><span>(view: <span>WebView</span>, url: <span>String</span>?, favicon: <span>android</span>.<span>graphics</span>.<span>Bitmap</span>?)</span></span> {
<span>super</span>.onPageStarted(view, url, favicon)
isLoading = <span>true</span>
loadError = <span>false</span>
}
<span>override</span> <span><span>fun</span> <span>onPageFinished</span><span>(view: <span>WebView</span>, url: <span>String</span>?)</span></span> {
<span>super</span>.onPageFinished(view, url)
isLoading = <span>false</span>
}
<span>override</span> <span><span>fun</span> <span>onReceivedError</span><span>(
view: <span>WebView</span>,
request: <span>WebResourceRequest</span>?,
error: <span>WebResourceError</span>?
)</span></span> {
<span>super</span>.onReceivedError(view, request, error)
isLoading = <span>false</span>
loadError = <span>true</span>
}
<span>override</span> <span><span>fun</span> <span>onReceivedSslError</span><span>(
view: <span>WebView</span>,
handler: <span>SslErrorHandler</span>,
error: <span>SslError</span>
)</span></span> {
handler.proceed()
}
}
)
<span>if</span> (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
<span>if</span> (loadError) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(<span>"网页加载失败,请检查网络"</span>, color = MaterialTheme.colorScheme.error)
}
}
}
}
}
6.桌面端跳转:
fun main() = application {
Window(
<span>onCloseRequest</span> = ::exitApplication,
<span>title</span> = <span>"KMPWanDemo"</span>,
) {
val <span>customTypography</span> = rememberCustomTypography()
val <span>desktopNavigator</span> = DesktopNavigator()
MaterialTheme(<span>typography</span> = customTypography) {
ArticleListScreen(desktopNavigator)
}
}
}
7.web跳转:
<span>package</span> com.example.kmpwandemo
<span>import</span> androidx.compose.ui.ExperimentalComposeUiApi
<span>import</span> androidx.compose.ui.window.ComposeViewport
<span>import</span> androidx.compose.material3.MaterialTheme
<span>import</span> com.example.kmpwandemo.viewmodel.ArticleListScreen
<span>@OptIn(ExperimentalComposeUiApi::class)</span>
<span><span>fun</span> <span>main</span><span>()</span></span> {
ComposeViewport {
<span>val</span> customTypography = rememberCustomTypography()
<span>val</span> webNavigator = WebNavigator()
MaterialTheme(typography = customTypography) {
ArticleListScreen(webNavigator)
}
}
}
8.预览加载:
@Composable
@Preview
fun AppItem(){
val <span>customTypography</span> = rememberCustomTypography()
// 构造模拟测试数据
val <span>mockArticle</span> = Article(
<span>id</span> = <span>1001</span>L,
<span>title</span> = <span>"KMP Compose Multiplatform 开发入门"</span>,
<span>author</span> = <span>"测试作者"</span>,
<span>shareUser</span> = <span>""</span>,
<span>niceDate</span> = <span>"2026-09-12"</span>,
<span>link</span> = <span>"https://wanandroid.com"</span>,
<span>desc</span> = <span>"这是一篇KMP开发介绍文章"</span>,
<span>chapterName</span> = <span>"Android"</span>,
<span>superChapterName</span> = <span>"移动开发"</span>,
<span>collect</span> = <span>false</span>,
<span>zan</span> = <span>99</span>,
<span>publishTime</span> = <span>1757865600000</span>L,
<span>fresh</span> = <span>true</span>,
<span>tags</span> = emptyList()
)
MaterialTheme(<span>typography</span> = customTypography) {
ArticleItem(<span>article</span> = mockArticle, <span>on</span>ClickItem = {})
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(<span>showBackground</span> = <span>true</span>)
@Composable
fun ArticleWebDetailScreenPreview() {
val <span>previewNavigator</span> = PreviewNavigator()
MaterialTheme {
ArticleWebDetailScreen(
<span>navigator</span> = previewNavigator,
<span>url</span> = <span>"https://wanandroid.com"</span>,
<span>title</span> = <span>"WanAndroid文章详情"</span>
)
}
}
9.实现效果:
Android:
web:
桌面:
对 KMP 多端导航架构有实操参考价值,适合正在做 Compose Multiplatform 跨平台应用、需要统一导航抽象与 WebView 集成的开发者阅读。