导航与WebView:在Navigation中集成WebView

说实话,WebView 在 Android 开发里是个老话题了。但放到 Navigation 组件里,事情就变得有点微妙。我见过不少团队,把 WebView 直接扔进 Fragment 里,然后返回键一按,整个页面就退出了——用户还在看文章呢,这体验太糟糕了。

今天我们就来聊聊,怎么在 Navigation 架构下,把 WebView 玩得转。

为什么要在 Navigation 里集成 WebView?

你想想看,现在很多 App 都是混合开发。部分页面用原生,部分页面用 H5。如果每个 WebView 页面都单独开一个 Activity,那 Navigation 的「单 Activity 架构」就形同虚设了。

我个人习惯的做法是:把 WebView 封装成一个独立的 Fragment,然后通过 Navigation 来管理它的生命周期和返回栈。这样,WebView 的页面跳转、返回键处理,都能和原生页面保持一致。

核心思路:WebView 不是「页面」,而是「组件」。它应该被 Fragment 包裹,由 Navigation 控制进出。

第一步:创建 WebViewFragment

我们先写一个基础的 WebViewFragment。注意,这里有个坑——WebView 的销毁时机。我曾经在项目里遇到过,Fragment 被销毁了,但 WebView 还在后台加载,导致内存泄漏。

class WebViewFragment : Fragment() {
    private var _binding: FragmentWebviewBinding? = null
    private val binding get() = _binding!!
    
    private lateinit var webView: WebView
    private var currentUrl: String? = null
    
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentWebviewBinding.inflate(inflater, container, false)
        return binding.root
    }
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        setupWebView()
        loadUrl(arguments?.getString("url") ?: "about:blank")
    }
    
    private fun setupWebView() {
        webView = binding.webView.apply {
            settings.javaScriptEnabled = true
            settings.domStorageEnabled = true
            settings.loadWithOverviewMode = true
            settings.useWideViewPort = true
            
            webViewClient = object : WebViewClient() {
                override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
                    currentUrl = url
                    // 可以在这里显示加载进度
                }
                
                override fun shouldOverrideUrlLoading(
                    view: WebView?,
                    request: WebResourceRequest?
                ): Boolean {
                    // 这里可以拦截特定 URL,跳转到原生页面
                    return false
                }
            }
        }
    }
    
    override fun onDestroyView() {
        super.onDestroyView()
        // 重要:清理 WebView
        binding.webView.destroy()
        _binding = null
    }
}

小技巧:在 onDestroyView 里调用 webView.destroy(),而不是 onDestroy。因为 Fragment 的视图可能被重建,但 WebView 实例需要彻底清理。

第二步:处理 WebView 的返回键

这是最容易被忽略的地方。默认情况下,用户在 WebView 里点返回,会直接退出 Fragment。但用户的本意是「回退到上一页」。

怎么做?我们需要拦截 Fragment 的返回事件,先让 WebView 自己处理。

class WebViewFragment : Fragment() {
    // ... 前面的代码 ...
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        setupWebView()
        loadUrl(arguments?.getString("url") ?: "about:blank")
        
        // 拦截返回键
        requireActivity().onBackPressedDispatcher.addCallback(
            viewLifecycleOwner,
            object : OnBackPressedCallback(true) {
                override fun handleOnBackPressed() {
                    if (webView.canGoBack()) {
                        webView.goBack()
                    } else {
                        // 没有历史记录了,交给 Navigation 处理
                        isEnabled = false
                        requireActivity().onBackPressedDispatcher.onBackPressed()
                    }
                }
            }
        )
    }
}

嗯,这里要注意一点:OnBackPressedCallbackisEnabled 属性。当 WebView 可以回退时,我们拦截返回事件;当不能回退时,我们把控制权交还给 Navigation。这样,用户按返回键时,体验就和浏览器一样自然。

第三步:WebView 与 Fragment 通信

实际项目中,WebView 经常需要和原生代码交互。比如,H5 页面点击「购买」按钮,需要调起原生的支付页面。

我推荐使用 JavaScriptInterface 来实现。但要注意,这个接口是双向的——H5 可以调用原生,原生也可以调用 H5。

H5 调用原生

// 定义一个接口类
class WebAppInterface(private val fragment: WebViewFragment) {
    
    @JavascriptInterface
    fun showToast(message: String) {
        // 在 UI 线程执行
        fragment.requireActivity().runOnUiThread {
            Toast.makeText(fragment.requireContext(), message, Toast.LENGTH_SHORT).show()
        }
    }
    
    @JavascriptInterface
    fun navigateToNative(destination: String) {
        // 通过 Navigation 跳转到原生页面
        val action = WebViewFragmentDirections.actionWebViewToNative(destination)
        fragment.findNavController().navigate(action)
    }
}

// 在 setupWebView 中注册
webView.addJavascriptInterface(WebAppInterface(this), "AndroidBridge")

H5 端调用方式:

// JavaScript 代码
AndroidBridge.showToast("Hello from H5!");
AndroidBridge.navigateToNative("payment");

安全警告:@JavascriptInterface 方法会在后台线程执行。如果你要更新 UI,记得切到主线程。另外,不要暴露敏感操作,防止 XSS 攻击。

原生调用 H5

有时候,我们需要从原生主动通知 H5。比如,用户登录成功后,告诉 H5 刷新用户信息。

// 在 Fragment 中调用
fun notifyLoginSuccess(userId: String) {
    val script = "javascript:onLoginSuccess('$userId')"
    webView.evaluateJavascript(script, null)
}

H5 端需要定义这个函数:

function onLoginSuccess(userId) {
    console.log("User logged in: " + userId);
    // 刷新页面数据
}

整体架构图

下面这张图,展示了 Navigation 与 WebView 的完整交互流程。我画的时候特意把「返回键处理」单独标出来了,因为这是最容易出问题的地方。

Navigation + WebView 架构图 NavController WebViewFragment WebView 导航到 包裹 返回键处理流程 用户按返回键 WebView.canGoBack() ? 是 → webView.goBack() 否 → 交给 Navigation 处理 退出 Fragment WebView ↔ Fragment 通信 H5 → 原生:@JavascriptInterface 原生 → H5:evaluateJavascript() 示例:H5 点击按钮 → 调起原生支付 示例:登录成功 → 通知 H5 刷新

避坑指南

我在实际项目中踩过不少 WebView 的坑,这里列几个最常见的:

  • WebView 内存泄漏:记得在 onDestroyView 中调用 destroy(),并且将 WebView 从父容器中移除。
  • 返回键被吞掉:如果你用了多个 OnBackPressedCallback,注意它们的优先级。后注册的优先级更高。
  • JavaScriptInterface 线程问题:所有 @JavascriptInterface 方法都在后台线程执行,更新 UI 必须切到主线程。
  • URL 拦截:在 shouldOverrideUrlLoading 中,如果要跳转到原生页面,记得返回 true 阻止 WebView 加载。

我的经验:我曾经在一个电商项目里,WebView 加载的商品详情页,用户点「加入购物车」后,H5 通过 JavaScriptInterface 通知原生,原生再通过 Navigation 跳转到购物车页面。整个过程非常流畅,用户完全感觉不到是在混合开发。

总结

Navigation 组件和 WebView 的集成,说白了就是三件事:

  1. 把 WebView 封装到 Fragment 里,用 Navigation 管理生命周期
  2. 拦截返回键,让 WebView 先处理页面回退
  3. 用 JavaScriptInterface 和 evaluateJavascript 实现双向通信

做到这三点,你的 WebView 在 Navigation 架构下就能跑得很稳。下次遇到混合开发的需求,不妨试试这个方案。


公众号:蓝海资料掘金营,微信 deep3321