用代理模式解决Okhttp日志拦截器在下载文件时的窘境

715 阅读2分钟

okhttp官方拦截器之殇

  众所周知,当我们使用okhttp并且希望打印网络请求的日志时,我们会在okhttp的拦截器链中添加一个日志拦截器,并且在拦截器中输出网络请求和网络相应的各种信息(包括请求头,请求体,url等)。很暖心的是,官方给我们提供了一个日志拦截器HttpLoggingInterceptor,于是乎你喜闻乐见地把日志拦截器添加到了okhttp的拦截器序列中,一切都没问题,日志输出的很清晰,直到你遇上了下载请求,程序出现阻塞,卡死。。。

问题本源

  要说清楚这个问题,我们要先搞清楚所谓的下载请求和普通请求有什么区别,答案是没有区别,他们都是将管道里面的字节流输出到手机中,只不过下载请求的目的地是存储卡,普通请求的目的地是内存(当然普通的请求也是广义上的“下载”,只不过数据一般不是文件而是接口返回的结构化数据)。因此,我们回到okhttp的官方拦截器源码中去看(这里用的是新版的okhttp,已经用kotlin重写)。

class HttpLoggingInterceptor @JvmOverloads constructor(
 private val logger: Logger = Logger.DEFAULT
) : Interceptor {
   //...省略部分代码
   
   @Throws(IOException::class)
   override fun intercept(chain: Interceptor.Chain): Response {
       //...省略部分代码
       val contentType = responseBody.contentType()
       val charset: Charset = contentType?.charset(UTF_8) ?: UTF_8

       if (!buffer.isProbablyUtf8()) {
         logger.log("")
         logger.log("<-- END HTTP (binary ${buffer.size}-byte body omitted)")
         return response
       }

       if (contentLength != 0L) {
         logger.log("")
         //关键,将管道里面的数据写入到字符串中,并用log输出
         logger.log(buffer.clone().readString(charset))
       }
       //...省略部分代码
   }
}

  我们可以看到,这个拦截器的本质就是将管道深拷贝一份,然后输出到字符串中(即内存中),这个对于一般的请求而言没有问题,无非最多就是几kb的字符串数据(后台传回来的结构化信息),但是对于下载请求而言是致命的,因为这个拦截器尝试把一个几mb(甚至几百mb)的内容缓存到内存里面!一个普通的安卓app进程也就几百mb,问题出现在这里。

解决方案

  解决思想非常简单,由于这个拦截器提供了分级的选项,我们可以想办法让它只有遇到非下载请求的时候,才去使用BODY级,而遇到下载请求的时候,切换到HEADER或者BASIC级让它规避把响应体输出到内存即可,但是真的有那么简单吗?我们继续回到源码。

class HttpLoggingInterceptor @JvmOverloads constructor(
 private val logger: Logger = Logger.DEFAULT
) : Interceptor {
   //...
}

  很遗憾,这是一个不可重写的类,官方也没有提供类似的api让我们持有这种能力,笔者看了网上很多的解决方案,大多数是选择第三方框架或者直接复制粘贴源码然后修改,这两种解决方案笔者都不太满意,因此决定另寻一种出路,这里笔者选择了一种设计模式:代理模式
顺便推荐一个适合学习设计模式的网站,图文并茂非常适合初学者理解设计模式:免费在线学习代码重构和设计模式 (refactoringguru.cn)

废话不多说直接上代码:

abstract class HttpLoggingProxy(
    private val client: HttpLoggingInterceptor
) : Interceptor {

    /**
     * 通过请求判断是否需要输出日志
     */
    abstract fun needToLog(request: Request):Boolean

    override fun intercept(chain: Interceptor.Chain): Response {
        val request=chain.request()
        //需要输出日志,用日志拦截器输出
        return if(needToLog(request)){
            client.intercept(chain)
        }
        //不需要输出日志
        else{
            chain.proceed(request)
        }
    }
}

  笔者设计了一个抽象类,去持有被代理的日志拦截器,然后通过needToLog(request: Request)方法去判断是否要输出日志,如果需要输出日志,则使用被代理的拦截器去拦截chain,否则继续把request往下一个拦截器传递。

笔者注:你可以再传入一个level为BASIC的拦截器,如果不需要输出body的情况则用那个拦截器,这样在下载请求中也可以监听一下请求头,url等信息

然后继承抽象类,把你实现的拦截器代理器传入到okhttp的拦截器链中即可

class MyHttpLoggingProxy:HttpLoggingProxy(
    HttpLoggingInterceptor()
) {
    override fun needToLog(request: Request): Boolean {
        //通过请求来判断是否需要下载
        return request.url.toString()=="你喜欢的业务"
    }
}

更进一步:在Retrofit中的使用

  我们再实现一个在Retrofit中使用日志拦截器代理器的方法,由于Retrofit在构建okhttp请求的过程中,会把请求方法(即那个Retrofit的Service接口里面的方法)放入到okhttp的Tag中,因此我们可以通过这个Tag来判断方法是否包含了Stream这个注解,如果包含则说明我们在使用Retrofit完成下载的功能。

/**
 * 返回某个Retrofit定义在方法上的注解,例如[POST],[GET]
 */
fun <T : Annotation> Request.getMethodAnnotation(annotationClass: Class<T>): T? {
    return tag(Invocation::class.java)?.method()?.getAnnotation(annotationClass)
}


fun <T : Annotation> Request.containMethodAnnotation(annotationClass: Class<T>): Boolean {
    return getMethodAnnotation(annotationClass) != null
}

class RetrofitHttpLoggingProxy:HttpLoggingProxy(
    HttpLoggingInterceptor()
) {
    override fun needToLog(request: Request): Boolean {
        //Retrofit方法中是否包含了Streaming这个注解,如果包含则说明是下载,不输出日志
        return !request.containMethodAnnotation(Streaming::class.java)
    }
}

那么这篇文章就到此结束了,很感谢你观看,如果喜欢请点赞关注,如果你有疑问或者有更好的建议请在评论区给我留言,我会尽快回复