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,做到跨平台业务逻辑一份代码,导航实现交给各平台。

模块总览:

在这里插入图片描述

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

Logo

一站式 AI 云服务平台

更多推荐