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 提供了一种高效的方式来增强应用功能,同时确保流畅的用户体验。

相关推荐
朝星2 小时前
Android开发[3]:协程+Flow
android·kotlin
张小潇2 小时前
AOSP15 WMS/AMS系统开发 - WindowManagerService addWindow详解
android
爱吃牛肉的大老虎2 小时前
MySQL优化之系统表分析SQL
android·sql·mysql
Fate_I_C2 小时前
实战案例:用 Kotlin 重写一个 Java Android 工具类
android·java·kotlin
Fate_I_C2 小时前
Kotlin 特有语法糖
android·开发语言·kotlin
Fate_I_C2 小时前
Kotlin 为什么是 Android 开发的首选语言
android·开发语言·kotlin
黄林晴2 小时前
Android CLI 来了!终端一键建项目、控模拟器、给 Agent 喂官方规范
android
常利兵2 小时前
Kotlin 助力 Android 启动“大提速”
android·开发语言·kotlin