Android QUIC 实践 - 基于 OKHttp 扩展出 Cronet 拦截器

1. QUIC 介绍

QUIC是快速UDP网络连接(英语:Quick UDP Internet Connections)的缩写,这是一种实验性的传输层网络传输协议,由Google公司开发,在2013年实现。QUIC使用UDP协议,它在两个端点间创建连接,且支持多路复用连接。在设计之初,QUIC希望能够提供等同于SSL/TLS层级的网络安全保护,减少数据传输及创建连接时的延迟时间,双向控制带宽,以避免网络拥塞。Google希望使用这个协议来取代TCP协议,使网页传输速度加快,计划将QUIC提交至互联网工程任务小组(IETF),让它成为下一代的正式网络规范。 百度百科

2. CRONET 引入

官方项目里或者说高星开源项目中,支持 Android 上 quic 的靠谱网络库只此一家,Android 工程引入 cronet release 有两种办法。

  1. gradle 引用,最新只能引用到 113 版本,见:Maven Repository: org.chromium.net >> cronet-embedded (mvnrepository.com)
  1. 直接去 GoogleCloud 里去下载 chromium-cronet 的最新版本。比如:下面这个链接就可以下载最新的 121.0.6167.0 版本,优点是版本新,缺点是可能不稳定 chromium-cronet -- 存储桶详情 -- Cloud Storage -- Google Cloud Console

3. 直接用 cronet 不就行了吗?为啥还搭配 OKHTTP 呢?

我之前分享的文章里面就反复提及三个点 - 风险、收益、成本

  • 低风险: 为了最小化线上风险,能够满足快速回退的需求,最好还是让 cronet 能够插拔,变成一个现有网络库的可选功能项
  • 低成本: 许多项目都是深度使用 okhttp,已经基于 okhttp 干了很多基建了,要把那些东西全部切到 cronet 上成本还是比较高的
  • 网络库切换必须是等到线上验证完 cronet 的性能/稳定性后才能够被讨论的话题

当然,考虑归考虑,最终要上线 cronet 的时候,还是推荐大家要么进行网络库替换,要么改造 cronet,让 cronet 能够完美和 okHttp 融合。举个最简单的例子,OKHttp 和 cronet 的线程池是不共用的,那app 运行过程中发起网络请求时,就比单独用 okHttp 或者 cronet 时,会创建更加多的线程。

google 官方其实有相关实现,但个人觉得实现的太过复杂:

google/cronet-transport-for-okhttp: This package allows OkHttp and Retrofit users to use Cronet as their transport layer, benefiting from features like QUIC/HTTP3 support or connection migration. (github.com)

4. 实现方案 - Cronet 逻辑

我们已知,OKHttp 的拦截器设计支持调用者能够自定义自己的拦截器,每个拦截器都是有机会去消费请求生产响应的。所以我们为了实现如下需求:

  • cronet 嵌入到 okhttp 的请求流程中
  • cronet 接管 okhttp 的发起请求流程
  • 保留原先用于设置请求 UA 等自定义功能的拦截器

我们将自定义一个 Cronet 的拦截器,并且将这个拦截器添加到自定义拦截器的末尾,确保 CronetInteceptor 执行前,之前自定义功能的拦截器都能正常工作。

我们提前预想一下,看看我们的 cronet 拦截器需要实现哪些东西:

  1. OKhttp request 转 cronet request
  2. cronet 请求流程
  3. cronet response 转 OKHttp response
  4. 可能需要的性能统计逻辑

CronetEnv.kt

初始化 cronet,配置支持 quic 的域名、端口,注入网络性能打点逻辑

kotlin 复制代码
object CronetEnv {
    private var cronetEngine: CronetEngine? = null
    private val executorService = Executors.newSingleThreadExecutor()

    fun cronetEngine(): CronetEngine? {
        return cronetEngine
    }

    fun initializeCronetEngine(
        context: Context,
    ) {
        if (cronetEngine != null) {
            return
        }
        val builder = CronetEngine.Builder(context)
            .enableHttp2(true)
            .enableQuic(true)
            .enableBrotli(true)
            .addQuicHint("test.quic.com", 443, 443)
        }

        cronetEngine = builder.build()
        cronetEngine?.addRequestFinishedListener(object :
            RequestFinishedInfo.Listener(Executors.newSingleThreadExecutor()) {
            override fun onRequestFinished(requestInfo: RequestFinishedInfo) {
                CronetLogHelper.logMatrix(requestInfo)
            }
        })
    }
}

CronetInterceptor.kt

主要负责:

  • OKHttp request 转换成 cronet request
  • 使用 cronet request 发起请求,并接收响应
kotlin 复制代码
class CronetInterceptor : Interceptor {
    @Throws(IOException::class)
    override fun intercept(chain: Interceptor.Chain): Response {
        if (chain.call().isCanceled()) {
            throw IOException("Canceled")
        }

        //检查 cronet 初始化
        CronetEnv.cronetEngine() ?: return chain.proceed(chain.request())

        //用 cronet 来代理网络请求
        return try {
            val callback = CronetRequestCallback(chain.request())
            val urlRequest = buildRequest(chain.request(), callback, CronetEnv.cronetEngine(), CronetEnv.executorService)
            urlRequest.start()
            callback.blockForResponse()
        } catch (e: Exception) {
            Log.e(TAG, "got exception for ${chain.request().url}, message=${e.message}")
            if (e is IOException) {
                throw e
            } else {
                throw IOException("canceled due to $e", e)
            }
        }
    }

    fun buildRequest(
        request: Request,
        callback: UrlRequest.Callback?,
        cronetEngine: CronetEngine,
        executorService: ExecutorService
    ): UrlRequest {
        val url = request.url.toString()
        val requestBuilder = cronetEngine.newUrlRequestBuilder(url, callback, executorService)
        requestBuilder.setHttpMethod(request.method)
        request.headers.forEach {
            requestBuilder.addHeader(it.first, it.second)
        }

        val requestBody = request.body
        if (requestBody != null) {
            val contentType = requestBody.contentType()
            if (contentType != null) {
                requestBuilder.addHeader("Content-Type", contentType.toString())
            }
            val buffer = Buffer()
            requestBody.writeTo(buffer)
            val uploadDataProvider = UploadDataProviders.create(buffer.readByteArray())
            requestBuilder.setUploadDataProvider(uploadDataProvider, executorService)
        }
        return requestBuilder.build()
    }
}

CronetRequestCallback

主要负责:

  • 接收 croent response 转成 OKHttp response
  • 处理成功、失败、重定向逻辑
kotlin 复制代码
class CronetRequestCallback internal constructor(
    private val originRequest: Request
) : UrlRequest.Callback() {

    private var redirectCount = 0
    private var response: Response
    private var iOException: IOException? = null
    private val responseLock = ConditionVariable()
    private val receivedByteArrayOutputStream = ByteArrayOutputStream()
    private val receiveChannel = Channels.newChannel(receivedByteArrayOutputStream)

    init {
        response = Response.Builder()
            .sentRequestAtMillis(System.currentTimeMillis())
            .request(originRequest)
            .build()
    }

    @Throws(IOException::class)
    fun blockForResponse(): Response {
        responseLock.block()
        if (iOException != null) {
            throw iOException as IOException
        }
        return response
    }

    override fun onRedirectReceived(
        request: UrlRequest,
        responseInfo: UrlResponseInfo,
        newLocationUrl: String
    ) {
        if (redirectCount > 20) {
            request.cancel()
            iOException = ProtocolException("Too many follow-up requests: $redirectCount")
            return
        }
        redirectCount += 1
        request.followRedirect()
    }

    override fun onResponseStarted(request: UrlRequest, responseInfo: UrlResponseInfo) {
        response = toOkResponse(response, responseInfo)
        request.read(ByteBuffer.allocateDirect(32 * 1024))
    }

    @Throws(Exception::class)
    override fun onReadCompleted(
        request: UrlRequest,
        responseInfo: UrlResponseInfo,
        byteBuffer: ByteBuffer
    ) {
        byteBuffer.flip()
        try {
            receiveChannel.write(byteBuffer)
        } catch (e: IOException) {
            iOException = e
        }
        byteBuffer.clear()
        request.read(byteBuffer)
    }

    override fun onSucceeded(request: UrlRequest, responseInfo: UrlResponseInfo) {
        val contentType = response.header("Content-Type", "text/html")
        val mediaType: MediaType? = (contentType
            ?: """text/plain; charset="utf-8"""").toMediaTypeOrNull()
        val responseBody = receivedByteArrayOutputStream.toByteArray().toResponseBody(mediaType)

        val httpStatusCode = responseInfo.httpStatusCode
        if ((httpStatusCode == 204 || httpStatusCode == 205) && responseBody.contentLength() > 0) {
            iOException =
                ProtocolException("HTTP " + httpStatusCode + " had non-zero Content-Length: " + responseBody.contentLength()); }

        val newRequest = originRequest.newBuilder()
            .url(responseInfo.url)
            .build()
        response = response.newBuilder()
            .body(responseBody)
            .request(newRequest).build()
        responseLock.open()
    }

    override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException?) {
        iOException = error
        responseLock.open()
    }

    override fun onCanceled(request: UrlRequest, info: UrlResponseInfo?) {
        if (iOException == null) {
            iOException = IOException("The request was canceled!")
        }
        responseLock.open()
    }

    companion object {
        private fun toOkResponse(response: Response, responseInfo: UrlResponseInfo): Response {
            val protocol = CronetLogHelper.protocolFromCronet(
                responseInfo.negotiatedProtocol
            )
            val headersBuilder = Headers.Builder()
            for ((key, value) in responseInfo.allHeadersAsList) {
                headerBuilder.add(key, value)
            }
            return response.newBuilder()
                .receivedResponseAtMillis(System.currentTimeMillis())
                .protocol(protocol)
                .code(responseInfo.httpStatusCode)
                .message(responseInfo.httpStatusText)
                .headers(headersBuilder.build())
                .build()
        }
    }
}

CronetLogHelper

主要负责:

  • 收集 cronet 请求的性能指标数据,对标 OKHttp 的 EventListener,对各个阶段进行统计。
  • 如果还想统计更多上下文内容,可以通过在前置通过 requestBuilder.addRequestannotation 注入,后续通过 requestFinishedInfo.annotations 取出
ini 复制代码
object CronetLogHelper {
    fun logMatrix(requestFinishedInfo: RequestFinishedInfo) {
        val matrix = requestFinishedInfo.metrics
        val success = requestFinishedInfo.finishedReason == RequestFinishedInfo.SUCCEEDED
        val requestStart = matrix.requestStart?.time ?: 0
        val dnsStart = matrix.dnsStart?.time ?: 0
        val dnsEnd = matrix.dnsEnd?.time ?: 0
        val connectStart = matrix.connectStart?.time ?: 0
        val connectEnd = matrix.connectEnd?.time ?: 0
        val sslStart = matrix.sslStart?.time ?: 0
        val sslEnd = matrix.sslEnd?.time ?: 0
        val sendStart = matrix.sendingStart?.time ?: 0
        val sendEnd = matrix.sendingEnd?.time ?: 0
        val responseStart = matrix.responseStart?.time ?: 0
        val responseEnd = matrix.requestEnd?.time ?: 0
        val requestEnd = matrix.requestEnd?.time ?: 0
        val responseBodySize = matrix.receivedByteCount ?: 0
        val totalTime = matrix.totalTimeMs ?: 0
        val map: MutableMap<String, Any> = mutableMapOf()

        map["url"] = requestFinishedInfo.url.toString()
        map["call_begin"] = requestStart
        map["dns_begin"] = dnsStart
        map["dns_end"] = dnsEnd
        map["connect_begin"] = connectStart
        map["secure_connect_begin"] = sslStart
        map["secure_connect_end"] = sslEnd
        map["connect_end"] = connectEnd
        map["request_begin"] = sendStart
        map["request_end"] = sendEnd
        map["response_begin"] = responseStart
        map["response_end"] = responseEnd
        map["response_body_size"] = responseBodySize
        map["call_end"] = requestEnd
        map["task_interval"] = totalTime
        map["quic_jetsam"] = false

        requestFinishedInfo.responseInfo?.let {
            map["result"] = it.httpStatusCode
            map["protocol"] = it.negotiatedProtocol
            map["is_cache"] = if (it.wasCached()) 1 else 0
        }

        if (!success) {
            map["result"] = -1
            map["error_desc"] = requestFinishedInfo.exception?.message ?: ""
        }
        
        //拿着数据进行日志上报或者其他
    }
}

5.实现方案 - 将 CronetInterceptor 嵌入到 OKHttp

ini 复制代码
OKhttpClient.Builder builder = xxxx;
builder.addInterceptor(xxxx);
builder.addInterceptor(xxxx);
builder.addInterceptor(CronetIntecerptor())

6. 收工

至此我们就在 OKhttp 的基础上,通过嵌入 cronet 来实现了 quic 协议栈的接入。 当然,如果想要在大项目中上线 quic,实际上还需要做的更多,如:

  • 完备的性能监控
  • 完备的报警机制
  • 及时的容灾机制
  • 可能的针对 cronet 的性能优化
  • cronet的完全改造或者网络库的完全替换(两个人在一起总得抹平棱角,当然,也可能是大家保留各自特点各自安好)

你可能感兴趣

Android QUIC 实践 - 基于 OKHttp 扩展出 Cronet 拦截器 - 掘金 (juejin.cn)

Android启动优化实践 - 秒开率从17%提升至75% - 掘金 (juejin.cn)

如何科学的进行Android包体积优化 - 掘金 (juejin.cn)

Android稳定性:Looper兜底框架实现线上容灾(二) - 掘金 (juejin.cn)

基于 Booster ASM API的配置化 hook 方案封装 - 掘金 (juejin.cn)

记 AndroidStudio Tracer工具导致的编译失败 - 掘金 (juejin.cn)

Android 启动优化案例-WebView非预期初始化排查 - 掘金 (juejin.cn)

chromium-net - 跟随 Cronet 的脚步探索大致流程(1) - 掘金 (juejin.cn)

Android稳定性:可远程配置化的Looper兜底框架 - 掘金 (juejin.cn)

一类有趣的无限缓存OOM现象 - 掘金 (juejin.cn)

Android - 一种新奇的冷启动速度优化思路(Fragment极度懒加载 + Layout子线程预加载) - 掘金 (juejin.cn)

Android - 彻底消灭OOM的实战经验分享(千分之1.5 -> 万分之0.2) - 掘金 (juejin.cn)

相关推荐
Kapaseker5 小时前
一文吃透 Kotlin 集合操作符
android·kotlin
三少爷的鞋6 小时前
Main-safe:现代Android 架构真正的分水岭
android
你听得到116 小时前
用户说 App 卡,但说不清在哪?我把 Flutter 监控 SDK 升级成了链路观测工作台
前端·flutter·性能优化
沐怡旸14 小时前
深入解析 Android Performance Analyzer (APA) 底层架构与技术原理
android
李斯维1 天前
从历史的角度看 Android 软件架构
android·架构·android jetpack
不做菜鸟的网工1 天前
BGP特性
网络协议
plainGeekDev1 天前
Activity 间传值 → Navigation 参数
android·java·kotlin
用户41659673693551 天前
Android WebView 加载 file:// 离线页面调试教程
android·前端
plainGeekDev1 天前
onActivityResult → ActivityResult API
android·java·kotlin
随遇丿而安1 天前
第10周:Activity 基础功能与生命周期优化
android