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 职责:

  1. 维护 BackStack 可变状态(用 MutableState,Compose 可观察)
  2. navigate(route):创建新 NavBackStackEntry,入栈,触发状态变更 → Compose 重组
  3. popBackStack():弹出栈顶 entry,恢复上一个状态
  4. 暴露 currentBackStackEntry,给 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,做到跨平台业务逻辑一份代码,导航实现交给各平台。

模块总览:

kotlin 复制代码
common
 └── INavigator 【统一抽象,业务只依赖这个接口】
androidApp
 └── AndroidNavigator → 封装 NavHostController,Compose 页面内路由跳转
web(wasm/js)
 └── WebNavigator → 浏览器环境,打开新标签 window.open
desktop(jvm)
 └── DesktopNavigator → 桌面端,调用系统默认浏览器打开链接
kotlin 复制代码
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卡片跳转详情:

kotlin 复制代码
@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详情页

kotlin 复制代码
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.桌面端跳转:

kotlin 复制代码
fun main() = application {
    Window(
        onCloseRequest = ::exitApplication,
        title = "KMPWanDemo",
    ) {
        val customTypography = rememberCustomTypography()
        val desktopNavigator = DesktopNavigator()
        MaterialTheme(typography = customTypography) {
            ArticleListScreen(desktopNavigator)
        }
    }
}

7.web跳转:

kotlin 复制代码
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.预览加载:

kotlin 复制代码
@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

相关推荐
五彩小白9 小时前
自回归和上下文
android
事圆则缓11 小时前
Android 多渠道打包实战:Product Flavors、签名、AAB 与发包校验
android
树下困觉眯13 小时前
View绘制流程学习--获取WindowManager的三种方式
android·系统架构·view design
vilya14 小时前
我怎么给手机 GUI Agent 做双通道感知:无障碍树为主,投屏像素兜底
android·人工智能
李子红了时15 小时前
阿狸舞台APP(安卓端手机EncFS解密+压缩包解压+视频播放)
android·音视频
事圆则缓15 小时前
Android 使用 Jenkins 实现 CI/CD,并用 SonarQube 建立质量门禁
android·ci/cd·jenkins
其实防守也摸鱼16 小时前
DeepSeek Harness 开源贡献手记:从 Issue 到 Merge 的完整旅程
android·数据库·学习·ai·oracle·自动化
恋猫de小郭16 小时前
Android CLI 支持 AI Agent 通过 Device Streaming 调试云真机
android·前端·flutter
素师良码1 天前
第5篇:显示驱动必备调试工具浅析
android
维克兜率天1 天前
【维克】配对交易的季节性:哪些品种适合长拿?
android·开发语言·笔记·python·算法·kotlin·量化