AndroidKMP之导航和WebView
AndroidKMP之导航和WebView
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 页面内路由跳转
web(wasm/js)
└── WebNavigator → 浏览器环境,打开新标签 window.open
desktop(jvm)
└── DesktopNavigator → 桌面端,调用系统默认浏览器打开链接
interface INavigator {
fun navigate(route: String)
fun navigateToArticleDetail(url:String, title:String)
fun popBack()
}
package com.example.kmpwandemo
import androidx.navigation.NavHostController
class AndroidNavigator(
private val navController: NavHostController
): INavigator {
override fun navigate(route: String) {
if(navController.graph.id != 0){
navController.navigate(route)
}
}
override fun navigateToArticleDetail(url: String, title: String) {
val encodeUrl = java.net.URLEncoder.encode(url, "UTF-8")
val encodeTitle = java.net.URLEncoder.encode(title, "UTF-8")
val destRoute = "articleDetail?url=$encodeUrl&title=$encodeTitle"
println("====准备跳转:$destRoute")
navController.navigate(destRoute)
}
override fun popBack() {
navController.popBackStack()
}
}
package com.example.kmpwandemo
import java.awt.Desktop
import java.net.URI
class DesktopNavigator : INavigator{
override fun navigate(route: String) {
}
override fun navigateToArticleDetail(url: String, title: String) {
if(Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)){
Desktop.getDesktop().browse(URI(url))
}
}
override fun popBack() {
}
}
package com.example.kmpwandemo
import kotlinx.browser.window
class WebNavigator : INavigator{
override fun navigate(route: String) {
}
override fun navigateToArticleDetail(url: String, title: String) {
window.open(url, "_blank")
}
override fun popBack() {
}
}
4.Android卡片跳转详情:
@Composable
fun AppNavHost() {
val navController = rememberNavController()
val navigator = remember { AndroidNavigator(navController) }
NavHost(
navController = navController,
startDestination = "articleList"
) {
composable("articleList") {
ArticleListScreen(navigator)
}
composable(route = "articleDetail?url={url}&title={title}") { backStackEntry ->
val url = backStackEntry.arguments?.getString("url") ?: ""
val title = backStackEntry.arguments?.getString("title") ?: ""
println("====详情页接收 url=$url, title=$title")
ArticleWebDetailScreen(navigator, url, title)
}
}
}
5.WebView详情页
package com.example.kmpwandemo.viewmodel
import android.net.http.SslError
import android.webkit.SslErrorHandler
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import com.example.kmpwandemo.INavigator
import com.google.accompanist.web.AccompanistWebViewClient
import com.google.accompanist.web.WebView
import com.google.accompanist.web.rememberWebViewNavigator
import com.google.accompanist.web.rememberWebViewState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ArticleWebDetailScreen(
navigator: INavigator,
url: String,
title: String
) {
val webViewState = rememberWebViewState(url)
val webNavigator = rememberWebViewNavigator()
var isLoading by remember { mutableStateOf(true) }
var loadError by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(title, maxLines = 1) },
navigationIcon = {
IconButton(onClick = {
println("====点击返回按钮, web canGoBack:${webNavigator.canGoBack}")
if (webNavigator.canGoBack) {
webNavigator.navigateBack()
println("====执行webView回退")
} else {
navigator.popBack()
println("====页面栈popBack,退出详情页")
}
}) {
Icon(
painter = painterResource(id = android.R.drawable.ic_menu_revert),
contentDescription = "返回"
)
}
}
)
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
WebView(
state = webViewState,
navigator = webNavigator,
modifier = Modifier.fillMaxSize(),
onCreated = { webView ->
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.settings.databaseEnabled = true
webView.settings.loadsImagesAutomatically = true
webView.settings.cacheMode = android.webkit.WebSettings.LOAD_DEFAULT
},
client = object : AccompanistWebViewClient() {
override fun onPageStarted(view: WebView, url: String?, favicon: android.graphics.Bitmap?) {
super.onPageStarted(view, url, favicon)
isLoading = true
loadError = false
}
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
isLoading = false
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest?,
error: WebResourceError?
) {
super.onReceivedError(view, request, error)
isLoading = false
loadError = true
}
override fun onReceivedSslError(
view: WebView,
handler: SslErrorHandler,
error: SslError
) {
handler.proceed()
}
}
)
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
if (loadError) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("网页加载失败,请检查网络", color = MaterialTheme.colorScheme.error)
}
}
}
}
}
6.桌面端跳转:
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "KMPWanDemo",
) {
val customTypography = rememberCustomTypography()
val desktopNavigator = DesktopNavigator()
MaterialTheme(typography = customTypography) {
ArticleListScreen(desktopNavigator)
}
}
}
7.web跳转:
package com.example.kmpwandemo
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import androidx.compose.material3.MaterialTheme
import com.example.kmpwandemo.viewmodel.ArticleListScreen
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
ComposeViewport {
val customTypography = rememberCustomTypography()
val webNavigator = WebNavigator()
MaterialTheme(typography = customTypography) {
ArticleListScreen(webNavigator)
}
}
}
8.预览加载:
@Composable
@Preview
fun AppItem(){
val customTypography = rememberCustomTypography()
// 构造模拟测试数据
val mockArticle = Article(
id = 1001L,
title = "KMP Compose Multiplatform 开发入门",
author = "测试作者",
shareUser = "",
niceDate = "2026-09-12",
link = "https://wanandroid.com",
desc = "这是一篇KMP开发介绍文章",
chapterName = "Android",
superChapterName = "移动开发",
collect = false,
zan = 99,
publishTime = 1757865600000L,
fresh = true,
tags = emptyList()
)
MaterialTheme(typography = customTypography) {
ArticleItem(article = mockArticle, onClickItem = {})
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(showBackground = true)
@Composable
fun ArticleWebDetailScreenPreview() {
val previewNavigator = PreviewNavigator()
MaterialTheme {
ArticleWebDetailScreen(
navigator = previewNavigator,
url = "https://wanandroid.com",
title = "WanAndroid文章详情"
)
}
}
9.实现效果:
Android:

web:

桌面:


10.源码地址:
https://gitee.com/jackning_admin/kmpwan-demo
更多推荐




所有评论(0)