WebView 的现代替代方案:适用于 Jetpack Compose 的 AndroidX Browser

androidx 库中的 browser 库是什么

androidx.browser 库通过 Android Custom Tabs(自定义标签页) 帮助我们在用户默认浏览器中显示网页。例如,在新闻类应用中,当用户点击某条特定新闻的详情页以访问特定网站时,我们可以在应用内通过用户默认的浏览器程序来展示该网页。这个浏览器可能是 Chrome,或者是用户设置的任何其他默认浏览器。这种方法在利用浏览器功能和安全特性的同时,确保了更好的用户体验。

为什么选择 Android Custom Tabs?

在 Android 应用中展示网页是一个常见的需求。虽然许多应用仍在使用 WebView 在应用内展示网页,但这种做法存在几个明显的缺陷:

  • 沉重的上下文切换与功能缺失:虽然直接调用外部浏览器可以提供完整的功能,但对于用户来说,这种从应用跳转到浏览器的切换感非常沉重。与此同时,WebView 虽然留在应用内,却不具备成熟浏览器的特性(如导航控制或地址栏),且无法像 Custom Tabs 那样进行深度定制。
  • Compose 集成挑战 :除此之外,WebView 属于传统的 View 系统,因此无法直接集成到 Compose 项目中。虽然可以使用 AndroidView 等互操作 API 来桥接,但这并非一种理想或优雅的解决方案。

在 Compose 项目中集成 在应用或模块的 build.gradle 文件中,为所需的构件(artifacts)添加依赖项:

arduino 复制代码
dependencies {
    implementation "androidx.browser:browser:1.8.0"
}

使用 CustomTabsIntent.Builder 创建一个 CustomTabsIntent,并通过调用 launchUrl() 并传递 Uri 来启动自定义标签页(Custom Tab):

kotlin 复制代码
val url = "https://www.google.com"
val context = LocalContext.current

val intent = CustomTabsIntent.Builder()
    .build()

Button(onClick = {
    intent.launchUrl(context, Uri.parse(url))
}) {
    Text(text = "Open Website")
}

定制外观

kotlin 复制代码
val intent = CustomTabsIntent.Builder()  
...  
.setShowTitle(true)  
.setStartAnimations(MainActivity.this, R.anim.slide_in_right, R.anim.slide_out_left)  
.setExitAnimations(MainActivity.this, android.R.anim.slide_in_left, android.R.anim.slide_out_right)  
.setUrlBarHidingEnabled(true)  
.build();
  • .setShowTitle(true) --- 设置是否在自定义标签页中显示网页标题

  • .setStartAnimations(...).setExitAnimations(...) --- 设置用户进入和离开网页时的进入与退出动画

  • .setUrlBarHidingEnabled(true) --- 开启滚动时隐藏地址栏的功能,以便为用户提供更多浏览网页内容的自由空间。

这些仅仅是一些基础的定制选项。除此之外,你还可以实现更多高级功能,例如添加自定义菜单项 ,或者集成 Chrome 特有的功能(如远程调试、数据节省模式)等等。

预热

Custom Tabs的最大实际优势之一是浏览器可以在用户点击按钮之前准备好。

浏览器服务早期绑定。然后:

  • warmup(0L) 在浏览器端预先启动工作
  • CustomTabsSession 被创建
  • mayLaunchUrl() 提示可能的导航目标

这意味着当登录开始时,浏览器不是冷启动。Google自己的文档说warmup()可以在打开链接时节省高达700ms。

这对认证特别相关,因为:

  • 企业页面可能很沉重
  • 重定向经常快速发生
  • 用户感知延迟在第一次交互时最重要

WebView路径仍然可以调整,但它在应用进程内付出更多渲染和生命周期成本。

链接服务

kotlin 复制代码
serviceConnection = object : CustomTabsServiceConnection() {
    override fun onCustomTabsServiceConnected(
        name: ComponentName,
        client: CustomTabsClient
    ) {
        customTabsClient = client
        client.warmup(0L)  // 预热浏览器引擎
        customTabsSession = createSession(client)  // 创建导航会话
    }
    
    override fun onServiceDisconnected(name: ComponentName?) {
        customTabsClient = null
        customTabsSession = null
    }
}

CustomTabsClient.bindCustomTabsService(context, packageName, serviceConnection!!)

调用 warmup(0L) 在幕后让浏览器准备好 -- 它启动渲染引擎、启动JavaScript引擎、设置网络,并准备好显示网页所需的一切。虽然 0L 参数现在没什么用,但你仍然需要包含它。仅仅调用warmup就做了很多事情,即使你还没有要求任何特定的URL。

mayLaunchUrl

设置浏览器会话后,有 mayLaunchUrl()。这个让你给浏览器一个关于用户可能接下来打开哪个链接的提示。

kotlin 复制代码
fun prepareUrl(url: Uri) {
    if (customTabsSession == null) {
        customTabsClient?.let {
            customTabsSession = createSession(it)
        }
    }
    customTabsSession?.mayLaunchUrl(url, null, null)
}

mayLaunchUrl() 的工作方式:它给浏览器一个提示,你可能很快会访问一个页面。所以,在幕后,浏览器开始准备 -- 它查找URL、打开连接,甚至可能提前抓取一些资源。所有这一切都在你点击那个链接之前发生。所以当你这样做时,页面几乎可以立即弹出。

这在Custom Tab启动之前被调用:

scss 复制代码
performanceManager.prepareUrl(targetUri)

launcher.launch(customTabsIntent.intent) 启动时,浏览器已经代表用户做了大量设置工作。

单点登录实战

使用Android Custom Tabs来实现单点登录,比用WebView更合适。 当登录流程包含以下内容时,Custom Tabs特别出色:

  • 共享浏览器Cookie
  • Google/Microsoft/Okta/Auth0式登录
  • 跨多个域的重定向
  • 企业SSO
  • 密码管理器自动填充
  • 设备/浏览器信任假设

此时,浏览器不仅仅是渲染器,它成为安全模型的一部分。

  • 浏览器处理安全关键的浏览器功能
  • 减少应用中的安全责任
  • 利用系统浏览器的安全更新
  • 用户凭据从不通过应用进程
  • 更少的耦合意味着更少的安全问题

1. 创建登录Url

kotlin 复制代码
fun buildCustomTabsLoginUrl(url: String? = "10.0.2.2:3000/"): String {
    val base = url  // 模拟器localhost
    return "${base}?callbackUrl=androidcustomtabs://login"
}

2. 打开Custom Tab

应用使用准备好的Custom Tabs会话启动页面:

kotlin 复制代码
fun openWithCustomTabs(
    keyboardController: SoftwareKeyboardController?,
    context: Context,
    url: String,
    performanceManager: CustomTabsPerformanceManager
): String? {
    val targetUri = buildCustomTabsLoginUrl(url).toUri()
    
    if (!performanceManager.isSupported()) {
        return "No Custom Tabs supporting browser found on this device."
    }
    
    performanceManager.prepareUrl(targetUri)
    
    val customTabsIntent = CustomTabsIntent.Builder(performanceManager.getSession())
        .setShowTitle(true)
        .setUrlBarHidingEnabled(true)
        .setShareState(CustomTabsIntent.SHARE_STATE_ON)
        .setInstantAppsEnabled(false)
        .build()
        .apply {
            intent.`package` = performanceManager.getPackageName()
            intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
            intent.data = targetUri
        }
    
    performanceManager.markLaunchStarted()
    customTabsIntent.launchUrl(context, targetUri)
    keyboardController?.hide()
    return null
}

3. 创建会话并监听事件

kotlin 复制代码
private fun createSession(client: CustomTabsClient): CustomTabsSession? {
    return client.newSession(object : CustomTabsCallback() {
        override fun onNavigationEvent(navigationEvent: Int, extras: Bundle?) {
            val now = SystemClock.elapsedRealtime()
            
            when (navigationEvent) {
                NAVIGATION_STARTED -> {
                    if (launchStartTimeMs > 0L)
                        navStartedAtMs = now - launchStartTimeMs
                }
                
                TAB_SHOWN -> {
                    if (launchStartTimeMs > 0L && firstPaintAtMs == null)
                        firstPaintAtMs = now - launchStartTimeMs
                }
                
                NAVIGATION_FINISHED -> {
                    val visibleMs = firstPaintAtMs ?: (now - launchStartTimeMs)
                    val fullMs = now - launchStartTimeMs
                    
                    context.toast(
                        "Custom Tabs metrics\n" +
                        "NAV ${navStartedAtMs}ms › paint ${visibleMs}ms › full ${fullMs}ms"
                    )
                }
            }
        }
    })
}

4. 用户输入登录详情

用户在登录页面上输入凭据。

5.### 成功登录后,Web通过深层链接回调重定向回Android

当登录成功且页面不在WebView内时,它重定向回应用。

实际重定向像这样构建:

kotlin 复制代码
function redirectToApp(source) {
    const callbackUrl = getCallbackUrl();
    const target = new URL(callbackUrl);
    target.searchParams.set('email', state.session.email);
    target.searchParams.set('name', state.session.name);
    target.searchParams.set('source', source || state.session.source || 'custom-tabs');
    target.searchParams.set('transport', 'deep-link');
    target.searchParams.set('token', state.session.jwt);
    window.location.href = target.toString();
}

产生一个像这样的URI(带有有效令牌): androidcustomtabs://login?email=demo@sso.com&name=Demo%20User&source=login&transport=deep-link&token=...

5. Android接收并处理回调

xml 复制代码
<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data 
        android:scheme="androidcustomtabs" 
        android:host="login" />
</intent-filter>

由于MainActivity设置为 launchMode="singleTask",当深层链接到达时,在现有活动实例上调用 onNewIntent 而不是创建新的:

kotlin 复制代码
class MainActivity : ComponentActivity() {
    var deepLinkIntent by mutableStateOf<Intent?>(null)
    private set
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        deepLinkIntent = intent
    }
    
    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)
        deepLinkIntent = intent
    }
}

6. 恢复会话

BrowserDemoApp.kt监视该深层链接并恢复会话,因为 deepLinkIntent 是响应式 mutableStateOf 属性。BrowserDemoApp通过 LaunchedEffect 监视它:

kotlin 复制代码
LaunchedEffect(activity?.deepLinkIntent) {
    extractSessionFromUri(activity?.deepLinkIntent?.data)?.let { 
        currentSession = it
        errorMessage = ""
        context.toast("Custom Tabs returned to app for ${it.email}")
    }
}

总结

在这篇文章中,我们探讨了 androidx.browser 库和 Android Custom Tabs 如何提供一种现代、安全且用户友好的 WebView 替代方案,用于在 Android 应用中展示网页内容。凭借与用户默认浏览器的无缝集成、增强的安全性以及丰富的定制选项,Custom Tabs 提供了一种高效的方式来增强应用功能,同时确保流畅的用户体验。

相关推荐
松仔log4 小时前
Java中级——组合和继承
android·java·开发语言
我命由我123457 小时前
Jetpack Compose - MaterialExpressiveTheme 与 MaterialTheme、ColorScheme
android·java·开发语言·java-ee·kotlin·android jetpack·android runtime
lilian2338 小时前
Harmony os 技术实战|拼豆制图06:收藏 ID、生成记录与重启恢复怎么不打架
android·java·数据库·harmonyos
2601_955759628 小时前
ClaudeAPI成本中心与业务标签设计指南
android·java·数据库
余衫马9 小时前
2026最新版!Android Studio 极速安装与配置指南
android·ide·android studio
AFinalStone9 小时前
Android 7系统异常问题排查(五)Framework层(下)—System Server崩溃
android·tombstone·系统异常
AFinalStone9 小时前
Android 7系统异常问题排查(八)系统追踪—Trace机制与性能诊断
android·系统异常
极客猴子12 小时前
Android录音转写频繁卡顿?多款APP长时间会议场景稳定性实测
android·人工智能·智能手机·飞书
Kapaseker12 小时前
Boolean 变量到底该怎么命名?
android·kotlin
AFinalStone13 小时前
Android 7系统异常问题排查(六)应用异常(上)—ANR机制全解
android·系统异常