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 有两种办法。
- gradle 引用,最新只能引用到 113 版本,见:Maven Repository: org.chromium.net >> cronet-embedded (mvnrepository.com)
- 直接去 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 官方其实有相关实现,但个人觉得实现的太过复杂:
4. 实现方案 - Cronet 逻辑
我们已知,OKHttp 的拦截器设计支持调用者能够自定义自己的拦截器,每个拦截器都是有机会去消费请求生产响应的。所以我们为了实现如下需求:
- cronet 嵌入到 okhttp 的请求流程中
- cronet 接管 okhttp 的发起请求流程
- 保留原先用于设置请求 UA 等自定义功能的拦截器
我们将自定义一个 Cronet 的拦截器,并且将这个拦截器添加到自定义拦截器的末尾,确保 CronetInteceptor 执行前,之前自定义功能的拦截器都能正常工作。
我们提前预想一下,看看我们的 cronet 拦截器需要实现哪些东西:
- OKhttp request 转 cronet request
- cronet 请求流程
- cronet response 转 OKHttp response
- 可能需要的性能统计逻辑
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)