专栏名称: 郭霖
Android技术分享平台,每天都有优质技术文章推送。你还可以向公众号投稿,将自己总结的技术心得分享给大家。
目录
相关文章推荐
郭霖  ·  使用Hilt来协助封装网络请求 ·  3 天前  
郭霖  ·  Android 跨进程+解耦的数据持久化方案 ·  6 天前  
郭霖  ·  Android ... ·  5 天前  
stormzhang  ·  来自李子柒的压迫感 ·  4 天前  
鸿洋  ·  理解Android ... ·  5 天前  
51好读  ›  专栏  ›  郭霖

Android 使用拦截器结合协程实现无感知的 Token 预刷新方案

郭霖  · 公众号  · android  · 2024-11-13 08:00

正文



/   今日科技快讯   /


近日,在第十五届中国国际航空航天博览会上,中国国际航空股份有限公司(以下简称“国航”)与中国商用飞机有限责任公司(以下简称“中国商飞”)签署C929客机首家用户框架协议,意向成为C929宽体客机的全球首家用户。


/   作者简介   /


本篇文章转自江天一色无纤尘的博客,文章主要分享了使用拦截器结合协程实现无感知Token预刷新,相信会对大家有所帮助!


原文地址:

https://juejin.cn/post/7408851018500833334


/   前言   /


在应用中,我们通常使用 Token 作为用户认证的凭证。为了安全起见,Token 一般设置较短的有效期,并通过 refreshToken 进行续期。传统的做法是当服务端返回 Token 过期的响应(如 401)时,再进行刷新,但这种方式可能导致用户体验不佳(如突然的登录状态丢失、请求失败等)。网上关于 Android 开发中 Token 的无感刷新文章也比较少,且大多是请求失败再进行刷新。因此,我这里提供一种预刷新方案,在 Token 接近过期时提前进行刷新。


/   Token刷新相关参数   /


首先简要说明一下有关 Token 刷新的几个参数。


  • Access Token(访问令牌) :一种用于验证客户端请求的短期凭证。当用户登录时,服务器会生成一个访问令牌并将其发送给客户端。客户端在每次请求时将该令牌包含在请求头中,以证明用户的身份。由于访问令牌的有效期较短,因此需要定期刷新。

  • Refresh Token(刷新令牌) :一种用于获取新访问令牌的长期凭证。与访问令牌不同,刷新令牌通常具有较长的有效期。当访问令牌过期时,客户端可以使用刷新令牌请求服务器颁发一个新的访问令牌,而无需让用户重新登录。

  • 过期时间:指的是访问令牌和刷新令牌的有效期。访问令牌的过期时间较短,一般为几分钟到一小时不等,而刷新令牌的过期时间较长,通常为几天、几周甚至更久。合理设置过期时间能够在确保安全性的同时,提升用户体验,减少频繁登录的需求。为了进一步提升安全性,当刷新令牌也过期时,用户通常需要重新登录以获取新的凭证。


/   实现思路   /


我的目标是确保 Token 在接近过期时无感刷新,避免用户因 Token 过期而体验到任何中断。首先定义一个拦截器,继承自 Interceptor,在这里实现Token的刷新逻辑,并把该拦截器添加到 Retrofit 的拦截器链中。在拦截器中会检查当前的 Token 是否快要过期,如果是,则提前刷新 Token。


具体思路如下:


  1. 提前刷新时间计算:在每次请求之前,都会检查 Token 的有效期。如果发现 Token 即将在 5 分钟内过期,就会进行刷新。

  2. 双重锁检查:为了避免多个请求同时触发 Token 刷新,导致并发问题,这里使用了双重锁检查的方式来确保只有一个线程会进行 Token 刷新。

  3. 刷新失败处理:如果 Token 刷新失败,会引导用户重新登录,并确保用户可以继续正常使用应用。


代码实现


以下是 TokenInterceptor 的具体实现代码:


class TokenInterceptor : Interceptor {
    @RequiresApi(Build.VERSION_CODES.O)
    override fun intercept(chain: Interceptor.Chain): Response {
        val tokenBean = CacheUtil.getToken()
        val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")

        // 检查Token过期时间
        if (!tokenBean.expireTime.isNullOrEmpty()) {
            val expireTime = tokenBean.expireTime.substring(019).let { LocalDateTime.parse(it, formatter) }
            val currentTime = LocalDateTime.now()

            // 提前5分钟刷新Token
            val refreshTime = expireTime.minus(5, ChronoUnit.MINUTES)
            if (expireTime != null && currentTime.isAfter(refreshTime)) {
                synchronized(this) {
                    if (currentTime.isAfter(refreshTime)) { // 双重锁检查
                        runBlocking {
                            val newToken = refreshAuthToken(tokenBean.refreshToken ?: "")
                            newToken?.let { token ->
                                CacheUtil.setToken(token)
                            }
                        }
                    }
                }
            }
        }

        // 添加Token到请求头
        val builder = chain.request().newBuilder().apply {
            addHeader("token", CacheUtil.getToken().token ?: "")
        }
        return chain.proceed(builder.build())
    }

    private suspend fun refreshAuthToken(refreshToken: String): Token? {
        return withContext(Dispatchers.IO) {
            try {
                val response = refreshApi.refreshToken(refreshToken)
                if (response.code == 200 && response.data != null) {
                    response.data
                } else { // refreshToken过期等失败情况
                    handleTokenRefreshFailure()
                    null
                }
            } catch (e: Exception) {
                e.printStackTrace()
                null
            }
        }
    }

    private fun handleTokenRefreshFailure() {
        //处理失败情况,跳转到登录界面
    }
}


协程关键点解析:


  • 该拦截器运行在子线程中,通过 runBlocking 阻塞当前线程并调用 refreshAuthToken 方法,挂起当前协程,等待 newToken 返回。

  • withContext 同样会挂起当前函数,使得 refreshAuthToken 能够返回 withContext(Dispatchers.IO) 中获取的结果。

  • refreshApi.refreshToken(refreshToken) 是一个挂起函数,结合 Retrofit 可以实现挂起并等待结果返回。

  • 通过一系列的挂起,在if (currentTime.isAfter(refreshTime))条件内实现了对 Token 的请求刷新,刷新完毕后 builder 会添加最新的 Token 继续执行当前被挂起的网络请求。


流程图



误区提示


注意不要使用相同的 Retrofit 实例构建 refreshApi 和正常请求的代理对象。正常请求的 Retrofit 对象中包含自定义的 TokenInterceptor 拦截器,如果 refreshApi 也使用了这个 Retrofit 对象,刷新 Token 的请求将被拦截器捕获,导致递归调用并陷入无限循环。因此,必须使用不带 TokenInterceptor 的 Retrofit 实例来构建刷新 Token 的代理对象。


/   总结   /


实测结果显示,在 Token 过期时,多个请求并发执行刷新逻辑时,用户几乎不会察觉到任何延迟。通过这种无感知的 Token 预刷新方案,可以有效减少 Token 过期带来的请求失败问题,同时提升了用户的体验。如果你也在处理类似的需求,希望这个方案能给你带来帮助,也欢迎一起讨论实现方案和技术细节。


推荐阅读:
我的新书,《第一行代码 第3版》已出版!
Kotlin异步Web框架,Ktor 3.0 来啦!
手把手教你在Android上用机器学习轻松训练专属分类器



欢迎关注我的公众号

学习技术或投稿



长按上图,识别图中二维码即可关注