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

相关推荐
HQL_seven2 小时前
【Android】BroadcastReceiver 详解
android
_祝你今天愉快4 小时前
Paint Cavans高级绘制:[二] Paint-滤镜,XFERMODE
android
小乖写代码4 小时前
字段获取代码(Android) · Android Input Examples
android·whatsapp·账号注册
booboosui4 小时前
停一个活只有杀进程:界面上少了「说点要求」
android
传奇开心果编程6 小时前
【Flutter踩坑排雷】第28课:平台目录缺失与修复坑|`flutter create` 的“重建”与“覆盖”边界
android·flutter·ios·移动开发·跨平台·dart·声明式ui
longxibo16 小时前
【AIBIZ低代码平台:能长出业务系统的平台】
android·低代码·rxjava
AI_Gump1 天前
Harness篇|DeepSeek Harness 上手机:五条安卓移植路线与 App 内 Linux 拆解
android·linux·人工智能·智能手机·deepseek
疯华整猫1 天前
否决 Flutter 提案那天,我发现自己也说不清 Framework 管什么
android
传奇开心果编程2 天前
【Flutter踩坑排雷】第25课:ScrollController 误用坑|跳转失效、监听泄漏与“控制器未附加”
android·flutter·ios·移动开发·跨平台·dart·声明式ui
慕容雪_2 天前
Capacitor8-Android打包完整手册
android·打包·capacitor