跳至正文
Ende Sun
返回博客

移动开发

Android 上的 Google Play 服务:可用性检查、用 Credential Manager 实现 Google 登录与推送通知

阅读需 11 分钟

Android 上的 Google Play 服务:可用性检查、用 Credential Manager 实现 Google 登录与推送通知

Filmoteca 是我项目归档里的一款 Android 应用,最初只是一个用 Kotlin 写的简单应用,用来记录看过的电影。读硕士期间,我给它加了两项依赖 Google Play 服务的功能:用 Google 账号登录的界面,以及基于 Firebase Cloud Messaging 的推送通知。这两项功能代码都不多,几乎所有麻烦都出在配置上,这篇文章讲的就是这些。

Play 服务到底是什么

Google Play 服务是预装在带 Google Play 的 Android 设备上的系统组件。它为应用提供定位、地图、登录等大量能力,而不需要把这些代码都打包进 APK。每个 API 都是独立的 Gradle 依赖,用到什么就引入什么:

dependencies {
    implementation("com.google.android.gms:play-services-location:<version>")
    // one artifact per API: ads, games, ML Kit, nearby, ...
}

由此带来两个实际影响。第一,有些 API 需要在 manifest 中额外声明:比如广告 SDK,如果缺少应用 ID 的 meta-data,应用一启动就会崩溃。第二,Play 服务并不保证一定存在——在某些市场,没有 Google Play 的设备很常见——所以应用在依赖它之前必须先检查。

先检查是否可用

检查代码很短,值得放在每个依赖 Play 服务的功能入口处:

fun ensurePlayServices(activity: Activity): Boolean {
    val api = GoogleApiAvailability.getInstance()
    val result = api.isGooglePlayServicesAvailable(activity)
    if (result == ConnectionResult.SUCCESS) return true

    if (api.isUserResolvableError(result)) {
        // e.g. outdated Play Services: let the system offer an update
        api.getErrorDialog(activity, result, REQUEST_PLAY_SERVICES)?.show()
    } else {
        Log.w("PlayServices", "This device is not supported")
    }
    return false
}

区分可解决的错误(服务版本过旧或被禁用,用户可以自行修复)和不受支持的设备很重要:前者要引导用户处理,后者则应隐藏该功能,让应用的其余部分照常运行。

没人讲清楚的 OAuth 客户端与 SHA-1

在 Google 知道是哪个应用发起请求之前,Google 登录是无法工作的。需要在 Google Cloud 控制台中创建一个Android 类型的 OAuth 客户端 ID,填写两项信息:应用的包名,以及为构建包签名的证书的 SHA-1 指纹。

指纹可以从 keystore 中读取,也可以让 Gradle 为每个构建变体打印出来:

keytool -list -v -keystore ~/.android/debug.keystore
./gradlew signingReport

我在这里踩坑后得到两条教训:

  • debug 和 release 构建使用不同的密钥签名,因此 SHA-1 也不同。两个都要登记,否则登录在你自己的电脑上正常,到了真正发布的版本里却会失败。
  • keystore 密码不应该写在 build.gradle 里。 在课程练习中,为了尽快跑通,我把密码直接写进了签名配置。现在我把它们放在版本控制之外,构建时再读取:
val props = Properties().apply {
    rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use { load(it) }
}

android {
    signingConfigs {
        create("release") {
            storeFile = file(props.getProperty("signing.storeFile", "release.keystore"))
            storePassword = props.getProperty("signing.storePassword")
            keyAlias = props.getProperty("signing.keyAlias")
            keyPassword = props.getProperty("signing.keyPassword")
        }
    }
}

从 GoogleSignInClient 到 Credential Manager

我最先学到的是经典的 GoogleSignInClient 流程:构建请求个人资料和邮箱的 GoogleSignInOptions,创建客户端,在 onStart 中检查 getLastSignedInAccount(),用 startActivityForResult 启动 getSignInIntent(),再在 onActivityResult 中读取账号。它能用,但一次登录被拆散在四个回调里,而且这套 API 现在已经被弃用。

取而代之的是 Credential Manager,它是 Jetpack 中统一处理密码、通行密钥(passkey)和联合登录的 API。配合 Google ID 库,整个登录过程只需一次挂起调用:

val credentialManager = CredentialManager.create(context)

val googleIdOption = GetGoogleIdOption.Builder()
    .setFilterByAuthorizedAccounts(false)
    .setServerClientId(WEB_CLIENT_ID) // the web client ID of your backend, not the Android one
    .build()

val request = GetCredentialRequest.Builder()
    .addCredentialOption(googleIdOption)
    .build()

suspend fun signIn(activity: Activity) {
    try {
        val result = credentialManager.getCredential(activity, request)
        val credential = result.credential
        if (credential is CustomCredential &&
            credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL
        ) {
            val google = GoogleIdTokenCredential.createFrom(credential.data)
            // send google.idToken to the backend and verify it there
        }
    } catch (e: GetCredentialException) {
        Log.w("Auth", "Sign-in failed or cancelled", e)
    }
}

这次迁移让我的理解有了几点变化:

  • 带 SHA-1 的 Android OAuth 客户端仍然必不可少;但代码里传入的是 Web 客户端 ID,因为 ID 令牌本来就是要交给你的服务器去验证的。
  • 应用不应该单方面信任收到的个人资料。ID 令牌要发送到后端,由后端验证后再建立会话。
  • 退出登录使用 credentialManager.clearCredentialState(ClearCredentialStateRequest()),这样账号选择器就不会悄悄沿用上一个账号。

用 Firebase Cloud Messaging 实现推送通知

FCM 是从服务器向 Android、iOS 和 Web 客户端发送消息的免费通道。它的接入方式和登录如出一辙:配置为主,代码很少。

  1. 创建 Firebase 项目,用包名和 debug 版 SHA-1 注册应用。
  2. 把 google-services.json 下载到 app 模块中,并应用 com.google.gms.google-services 插件。
  3. 添加 Firebase BoM 和消息组件,保证各个库的版本一致。
  4. 在 manifest 中声明一个 FirebaseMessagingService,设置 android:exported="false",并添加 MESSAGING_EVENT 意图过滤器。
  5. 通过 meta-data 设置默认的通知图标、颜色和通知渠道。如果不设置图标,Android 会把应用图标显示成一个白色剪影。

真正需要重视的是注册令牌,也就是某一次应用安装的“地址”:

class PushService : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        // send it to the backend, replacing the previous one
    }

    override fun onMessageReceived(message: RemoteMessage) {
        // show a notification or update local state
    }
}

当用户重新安装应用、清除应用数据、在新设备上恢复应用,或者实例 ID 被删除时,令牌都会变化。如果后端只保存首次启动时的令牌,这些用户就会在毫无察觉的情况下收不到通知。应把 onNewToken 当作唯一可信的来源,每次触发都同步更新服务器。在较新的 Android 版本上,应用还必须在运行时申请通知权限,之后才能显示任何通知。

我现在遵循的检查清单

  • 在使用任何 Play 服务功能之前先检查 GoogleApiAvailability,并做好优雅降级。
  • 为每一个用于签名发布版本的密钥登记 SHA-1。
  • keystore 密码和客户端密钥一律不进代码仓库。
  • 新的登录代码使用 Credential Manager,并在服务器端验证 ID 令牌。
  • 按安装实例保存 FCM 令牌,并在 onNewToken 中及时更新。