使用 WebViewCompat.navigate 增强了网页导航功能

WebViewCompat.navigateWebView.loadUrl 的增强型替代方案,可在 WebView 中对网页加载、历史记录管理和导航生命周期跟踪进行精细控制。

之前,使用 loadUrl 启动网页导航存在明显的限制:

  • 无法替换历史记录条目:您无法替换当前历史记录条目,因此无法在不向后退堆栈添加条目的情况下导航到新网页。
  • 解耦的回调:在 WebViewClient 中,没有直接机制将特定的 loadUrl 调用与后续回调事件相关联。
  • 未保存额外的标头:传递给 loadUrl 的自定义标头未保存为 WebView 状态的一部分,因此在恢复状态时会丢失。

WebViewCompat.navigate API 通过引入以下功能来解决这些问题:

  • 导航历史记录条目替换:用于替换 WebView 历史记录堆栈中的当前网页。
  • 相关回调跟踪:返回一个 Navigation 对象,该对象可作为导航生命周期所有阶段的唯一标识符。
  • 支持保存状态标头:额外的标头会可靠地保存在 WebView 状态软件包中,以便在恢复状态时可以重复使用。

主要功能和限制

在采用 WebViewCompat.navigate 之前,请考虑以下操作规则和限制:

  • 线程安全:您必须在界面(主)线程上调用 WebViewCompat.navigate

  • 取消和优先级:无法明确取消飞行中的导航。不过,在同一 WebView 上发起新的 navigate 调用会取代任何有效的导航。

  • URI 方案支持:支持标准(例如 https:http:)和自定义 URI 方案。不支持 javascript: 方案。

  • 网址大小限制:支持的网址字符串长度上限为 2 MB。

  • 功能检查:在调用 API 之前,请务必使用 WebViewFeature.isFeatureSupported 检查功能是否可用,以保持不同 WebView APK 版本之间的兼容性。

启动导航并跟踪生命周期

如需配置导航并跟踪其生命周期,请执行以下操作:

  1. WebView 设置期间使用 WebViewCompat.addNavigationListener 注册 NavigationListener 实现,以接收结构化的生命周期回调。注册一次监听器(而不是在每次导航调用时注册),以防止内存泄漏和重复执行回调。
  2. 使用 NavigationParameters.Builder 构建 NavigationParameters 实例,以指定可选行为,例如历史记录替换或自定义 HTTP 标头。
  3. 调用 WebViewCompat.navigate,并传递 WebView 实例、目标网址和参数。

WebViewCompat.navigate 返回一个唯一标识相应请求的 Navigation 对象。在 NavigationListener 回调中,将此对象与传入的 Navigation 参数进行比较,以跟踪该特定导航。

实现示例

以下示例演示了如何配置导航参数、调用 WebViewCompat.navigate 以及监听导航生命周期事件:

Kotlin

class WebNavigationManager(private val webView: WebView) {
    // Track the navigation instance returned by the API
    private var currentNavigation: Navigation? = null

    init {
        // 1. Define listener to observe navigation lifecycle events
        val listener = object : NavigationListener {
            override fun onNavigationStarted(navigation: Navigation) {
                if (navigation == currentNavigation) {
                    // Navigation started
                }
            }

            override fun onNavigationRedirected(navigation: Navigation) {
                if (navigation == currentNavigation) {
                    // Navigation encountered a redirect
                }
            }

            override fun onNavigationCompleted(navigation: Navigation) {
                if (navigation == currentNavigation) {
                    if (navigation.didCommit()) {
                        // Navigation committed successfully
                    } else if (navigation.didCommitErrorPage()) {
                        // Navigation committed an error page
                        val statusCode = navigation.statusCode
                        val error = navigation.webResourceError
                    }
                }
            }

            override fun onFirstContentfulPaintMillis(page: Page, durationMillis: Long) {
                // Match page with current navigation
                if (page == currentNavigation?.page) {
                    // Page rendering started (First Contentful Paint achieved)
                }
            }
        }

        // 2. Register listener on the main thread
        WebViewCompat.addNavigationListener(webView, listener)
    }

    @UiThread
    fun navigateToPage(url: String) {
        // Check feature availability
        if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEBVIEW_NAVIGATE_EXPERIMENTAL_V1)) {
            // Fall back to standard loadUrl if navigate API is unavailable
            webView.loadUrl(url)
            return
        }

        // 3. Configure navigation parameters
        val params = NavigationParameters.Builder()
            .setShouldReplaceCurrentEntry(true)
            .addAdditionalHeaders(
                mapOf("X-Test-Navigate-Header" to "TestValue")
            )
            .build()

        // 4. Initiate navigation on the UI thread
        currentNavigation = WebViewCompat.navigate(webView, url, params)
    }
}

Java

public class WebNavigationManager {

    private Navigation mCurrentNavigation;
    private final WebView mWebView;

    public WebNavigationManager(@NonNull WebView webView) {
        mWebView = webView;
        setupListener();
    }

    private void setupListener() {
        // 1. Define listener to observe navigation lifecycle events
        NavigationListener listener = new NavigationListener() {
            @Override
            public void onNavigationStarted(@NonNull Navigation navigation) {
                if (navigation.equals(mCurrentNavigation)) {
                    // Navigation started
                }
            }

            @Override
            public void onNavigationRedirected(@NonNull Navigation navigation) {
                if (navigation.equals(mCurrentNavigation)) {
                    // Navigation encountered a redirect
                }
            }

            @Override
            public void onNavigationCompleted(@NonNull Navigation navigation) {
                if (navigation.equals(mCurrentNavigation)) {
                    if (navigation.didCommit()) {
                        // Navigation committed successfully
                    } else if (navigation.didCommitErrorPage()) {
                        // Navigation committed an error page
                        int statusCode = navigation.getStatusCode();
                        WebResourceErrorCompat error = navigation.getWebResourceError();
                    }
                }
            }

            @Override
            public void onFirstContentfulPaintMillis(@NonNull Page page, long durationMillis) {
                if (mCurrentNavigation != null && page.equals(mCurrentNavigation.getPage())) {
                    // Page rendering started (First Contentful Paint achieved)
                }
            }
        };

        // 2. Register listener on the main thread
        WebViewCompat.addNavigationListener(mWebView, listener);
    }

    @UiThread
    public void navigateToPage(@NonNull String url) {
        // Check feature availability
        if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEBVIEW_NAVIGATE_EXPERIMENTAL_V1)) {
            // Fall back to standard loadUrl if navigate API is unavailable
            mWebView.loadUrl(url);
            return;
        }

        // 3. Configure navigation parameters
        NavigationParameters params = new NavigationParameters.Builder()
            .setShouldReplaceCurrentEntry(true)
            .addAdditionalHeaders(Collections.singletonMap(
                "X-Test-Navigate-Header", "TestValue"
            ))
            .build();

        // 4. Initiate navigation on the UI thread
        mCurrentNavigation = WebViewCompat.navigate(mWebView, url, params);
    }
}

使用 HTTP 标头传播应用状态

Web 应用通常需要来自宿主 Android 应用的上下文来协调后端逻辑或自定义 Web 内容。将查询参数附加到网址以传递此信息可能会使网址杂乱无章,干扰缓存,并暴露内部应用状态。

我们建议改用自定义 HTTP 标头传递应用上下文。通过使用 WebViewCompat.navigateNavigationParameters,您可以安全地将这些数据发送到服务器。此外,WebView 在状态恢复期间会保留这些标头,从而确保 Web 内容在配置更改期间保持一致。请注意,此持久性仅在使用 WebViewCompat.navigate 时适用。如果您使用 WebView.loadUrl,自定义标头不会保存在 WebView 状态 bundle 中,并且会在恢复时丢失。

常见应用场景

传递宿主应用上下文的常见用例包括:

  • 应用版本 (X-App-Version):传递宿主应用的发布版本(例如 BuildConfig.VERSION_NAME)有助于后端服务器验证原生 JavaScript 桥接兼容性、控制功能或提示用户更新旧版应用。
  • 客户端平台 (X-Client-Platform):明确将宿主环境标识为 Android,可让服务器提供量身定制的界面或路由商店链接,而无需依赖 User-Agent 字符串解析。

实现示例

以下示例演示了如何将应用版本和客户端平台传递给 Web 服务器:

Kotlin

// Attach host app metadata so the server can verify compatibility and tailor content
val params = NavigationParameters.Builder()
    .addAdditionalHeaders(
        mapOf(
            "X-App-Version" to BuildConfig.VERSION_NAME,
            "X-Client-Platform" to "Android"
        )
    )
    .build()

// Use navigate instead of loadUrl to retain custom headers across state restoration
WebViewCompat.navigate(webView, "https://www.example.com", params)

Java

// Attach host app metadata so the server can verify compatibility and tailor content
Map<String, String> headers = new HashMap<>();
headers.put("X-App-Version", BuildConfig.VERSION_NAME);
headers.put("X-Client-Platform", "Android");

NavigationParameters params = new NavigationParameters.Builder()
    .addAdditionalHeaders(headers)
    .build();

// Use navigate instead of loadUrl to retain custom headers across state restoration
WebViewCompat.navigate(webView, "https://www.example.com", params);

故障模式和错误处理

WebViewCompat.navigate API 提供了用于处理配置错误和运行时导航失败的不同机制:

参数无效异常

传递无效实参会触发同步 IllegalArgumentException。常见原因包括:

  • 为必需的非 null 参数(webViewurlparams)传递 null
  • 提供不受支持的网址协议,例如 javascript:
  • 传递不符合 RFC 2616 规范的格式错误的 HTTP 标头键或值。

如果在网络请求或网页加载期间发生故障(例如 HTTP 404 状态代码、DNS 解析失败或 SSL 错误),WebViewCompat.navigate 仍会返回有效的 Navigation 对象。

导航完成后,检查 onNavigationCompleted 回调中的 Navigation 实例上的以下方法,以诊断失败原因:

保存状态软件包管理

当您使用 NavigationParameters 传递其他标头时,WebView 会将这些标头保存在其已保存的状态软件包中,以便在恢复状态时可以重复使用这些标头。不过,大量标头可能会大幅增加保存状态 Bundle 的大小。

如果您需要限制软件包大小以防止在保存 Android 状态期间出现 TransactionTooLargeException,请使用 WebViewCompat.saveState。此方法可让您设置以字节为单位的最大软件包大小限制,并可选择性地排除前向历史记录项:

Kotlin

// Save state with a maximum bundle size limit (for example, 64 KB)
val maxSizeBytes = 64 * 1024
val includeForwardState = false
val outState = Bundle()

WebViewCompat.saveState(webView, outState, maxSizeBytes, includeForwardState)

Java

// Save state with a maximum bundle size limit (for example, 64 KB)
int maxSizeBytes = 64 * 1024;
boolean includeForwardState = false;
Bundle outState = new Bundle();

WebViewCompat.saveState(webView, outState, maxSizeBytes, includeForwardState);

生成的软件包仍与标准 WebView.restoreState 方法兼容。

迁移和实施建议

为确保在 WebView 中导航时获得最佳性能和稳定性,请遵循以下建议:

  • loadUrl 迁移到 navigate:将所有旧版 WebView.loadUrl 调用迁移到 WebViewCompat.navigate。这样可确保历史记录管理的一致性,并确保标头始终作为保存状态的一部分进行保存。

  • 始终验证功能支持:在调用 API 之前,请使用 WebViewFeature.isFeatureSupported 确认运行时支持,以防出现旧版 WebView。

  • 关联导航实例:使用返回的 Navigation 对象来区分并发导航或在管理多个 WebView 实例时过滤回调。

  • 在初始化期间注册一次监听器:由于 WebViewCompat.addNavigationListener 会添加监听器,而不是替换现有监听器,因此请在 WebView 设置期间注册一次 NavigationListener,以避免内存泄漏和在后续导航中重复执行回调。

  • 监控保存状态大小:传递大型标头载荷时,请使用具有明确大小边界的 WebViewCompat.saveState,以避免保存过多的状态数据。

其他资源

如需详细了解嵌入式 Web 功能和性能优化,请参阅以下指南: