AndroidKMP之导航和WebView

文章来源声明: 原文作者:Android小渣渣; 来源站点:掘金; 原文链接:https://juejin.cn/post/7685982162483298345; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

对 KMP 多端导航架构有实操参考价值,适合正在做 Compose Multiplatform 跨平台应用、需要统一导航抽象与 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 职责:

  1. 维护 BackStack 可变状态(用 MutableState,Compose 可观察)
  2. navigate(route):创建新 NavBackStackEntry,入栈,触发状态变更 → Compose 重组
  3. popBackStack():弹出栈顶 entry,恢复上一个状态
  4. 暴露 currentBackStackEntry,给 NavHost 读取当前路由

2.3 NavHost

NavHost(navController, startDestination)路由渲染容器

  • 监听 navController.currentBackStackEntry State
  • 当栈变化,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 生命周期

  1. navigate:entry 入栈 → ON_RESUME
  2. 新页面覆盖旧页面:旧页面变为 ON_STOPPED(还在栈,ViewModel 存活,Composable 不重组)
  3. popBackStack:entry 出栈 → ON_DESTROY,ViewModel 清除

⚠️ 坑点:页面只是停止重组,Composable 实例、状态、ViewModel 还在内存,只有 pop 才销毁;深层页面多了会内存累积。

3.导航封装:

核心思想:

共享模块(common)只定义导航抽象接口 INavigator,不依赖任何 Compose Navigation / NavHostController;Android 平台实现 AndroidNavigator 包装原生 NavHostController,iOS 后续可以写 IOSNavigator 包装 iOS 的导航组件。 目的:common 业务代码不直接导入 navigation-compose,业务层只依赖 INavigator,做到跨平台业务逻辑一份代码,导航实现交给各平台。

模块总览:

image.png

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>
        )
    }
}

image.png

9.实现效果:

Android:

image.png

web:

image.png

桌面:

image.png

image.png

10.源码地址:

gitee.com/jackning_ad…