以前咱們結合設計模式簡單說了下OkHttp
的大致流程,今天就繼續說說它的核心部分——攔截器
。java
由於攔截器組成的鏈實際上是完成了網絡通訊的整個流程,因此咱們今天就從這個角度說說各攔截器的功能。算法
首先,作一下簡單回顧,從getResponseWithInterceptorChain
方法開始。segmentfault
internal fun getResponseWithInterceptorChain(): Response { // Build a full stack of interceptors. val interceptors = mutableListOf<Interceptor>() interceptors += client.interceptors interceptors += RetryAndFollowUpInterceptor(client) interceptors += BridgeInterceptor(client.cookieJar) interceptors += CacheInterceptor(client.cache) interceptors += ConnectInterceptor if (!forWebSocket) { interceptors += client.networkInterceptors } interceptors += CallServerInterceptor(forWebSocket) val chain = RealInterceptorChain( interceptors = interceptors //... ) val response = chain.proceed(originalRequest) }
這些攔截器會造成一條鏈,組織了請求接口的全部工做。設計模式
以上爲上節內容,不瞭解的朋友能夠返回上一篇文章看看。promise
先拋開攔截器的這些概念不談,咱們回顧下網絡通訊過程
,看看實現一個網絡框架至少要有哪些功能。瀏覽器
請求過程
:封裝請求報文、創建TCP鏈接、向鏈接中發送數據響應過程
:從鏈接中讀取數據、處理解析響應報文而以前說過攔截器的基本代碼格式是這樣:緩存
override fun intercept(chain: Interceptor.Chain): Response { //作事情A response = realChain.proceed(request) //作事情B }
也就是分爲 請求前工做,請求傳遞,獲取響應後工做 三部分。服務器
那咱們試試能不能把上面的功能分一分,設計出幾個攔截器?cookie
攔截器1
: 處理請求前的 請求報文封裝
,處理響應後的 響應報文分析
誒,不錯吧,攔截器1就用來處理 請求報文和響應報文的一些封裝和解析工做。就叫它封裝攔截器吧。網絡
攔截器2
: 處理請求前的 創建TCP鏈接
確定須要一個攔截器用來創建TCP鏈接,可是響應後好像沒什麼須要作鏈接方面的工做了?那就先這樣,叫它鏈接攔截器吧。
攔截器3
:處理請求前的 數據請求(寫到數據流中)
處理響應後的 數據獲取(從數據流拿數據)
這個攔截器就負責TCP鏈接後的 I/O操做,也就是從流中讀取和獲取數據。就叫它 數據IO攔截器 吧。
好了,三個攔截器好像足夠了,我得意滿滿的偷看了一眼okhttp攔截器代碼,7個???我去。。
那再思考思考🤔...,還有什麼狀況沒考慮到呢?好比失敗重試?返回301重定向?緩存的使用?用戶本身對請求的統一處理?
因此又能夠模擬出幾個新的攔截器:
攔截器4
:處理響應後的 失敗重試和重定向功能
沒錯,剛纔只考慮到請求成功,請求失敗了要不要重試呢?響應碼爲30一、302時候的重定向處理?這都屬於要從新請求的部分,確定不能丟給用戶,須要網絡框架本身給處理好。就叫它 重試和重定向攔截器吧。
攔截器5
:處理響應前的 緩存複用
,處理響應後的 緩存響應數據
。還有一個網絡請求有可能的需求就是關於緩存,這個緩存的概念可能有些朋友瞭解的很少,其實它多用於瀏覽器中。
瀏覽器緩存通常分爲兩部分:強制緩存和協商緩存
。
強制緩存
就是服務器會告訴客戶端該怎麼緩存,例如 cache-Control
字段,隨便舉幾個例子:
private
:全部內容只有客戶端能夠緩存,Cache-Control的默認取值max-age=xxx
:表示緩存內容將在xxx秒後失效no-cache
:客戶端緩存內容,可是是否使用緩存則須要通過協商緩存來驗證決定no-store
:全部內容都不會被緩存,即不使用強制緩存,也不使用協商緩存協商緩存
就是須要客戶端和服務器進行協商後再決定是否使用緩存,好比強制緩存過時失效了,就要再次請求服務器,並帶上緩存標誌,例如Etag。
客戶端再次進行請求的時候,請求頭帶上If-None-Match
,也就是以前服務器返回的Etag值。
Etag值就是文件的惟一標示,服務器經過某個算法對資源進行計算,取得一串值(相似於文件的md5值),以後將該值經過etag返回給客戶端
而後服務器就會將Etag
值和服務器自己文件的Etag
值進行比較,若是同樣則數據沒改變,就返回304
,表明你要請求的數據沒改變,你直接用就行啦。
若是不一致,就返回新的數據,這時候的響應碼就是正常的200
。
這個攔截器就是用於處理這些狀況,咱們就叫它 緩存攔截器
吧。
最後就是自定義的攔截器了,要給開發者一個能夠自定義的攔截器,用於統一處理請求或響應數據。
這下好像齊了,至於以前說的7個攔截器還有1個,留個懸念最後再說。
最後再給他們排個序吧:
有點繞,來張圖瞧一瞧:
因此,攔截器的順序也基本固定了:
下面具體看看吧。
在請求以前,咱們通常建立本身的自定義攔截器,用於添加一些接口公共參數,好比把token
加到Header中。
class MyInterceptor() : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { var request = chain.request() request = request.newBuilder() .addHeader("token", "token") .url(url) .build() return chain.proceed(request) }
要注意的是,別忘了調用chain.proceed
,不然這條鏈就沒法繼續下去了。
在獲取響應以後,咱們通常用攔截器進行結果打印,好比經常使用的HttpLoggingInterceptor
。
addInterceptor( HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } )
爲了方便理解,我對源碼進行了修剪✂️:
class RetryAndFollowUpInterceptor(private val client: OkHttpClient) : Interceptor { @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { while (true) { try { try { response = realChain.proceed(request) } catch (e: RouteException) { //路由錯誤 continue } catch (e: IOException) { // 請求錯誤 continue } //獲取響應碼判斷是否須要重定向 val followUp = followUpRequest(response, exchange) if (followUp == null) { //沒有重定向 return response } //賦予重定向請求,再次進入下一次循環 request = followUp } } } }
這樣代碼就很清晰了,重試和重定向的處理都是須要從新請求,因此這裏用到了while循環。
realChain.proceed
方法進行網絡請求。重定向
的時候,就賦予新的請求,並進入下一次循環,從新請求網絡。response
響應結果。class BridgeInterceptor(private val cookieJar: CookieJar) : Interceptor { @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { //添加頭部信息 requestBuilder.header("Content-Type", contentType.toString()) requestBuilder.header("Host", userRequest.url.toHostHeader()) requestBuilder.header("Connection", "Keep-Alive") requestBuilder.header("Accept-Encoding", "gzip") requestBuilder.header("Cookie", cookieHeader(cookies)) requestBuilder.header("User-Agent", userAgent) val networkResponse = chain.proceed(requestBuilder.build()) //解壓 val responseBuilder = networkResponse.newBuilder() .request(userRequest) if (transparentGzip && "gzip".equals(networkResponse.header("Content-Encoding"), ignoreCase = true) && networkResponse.promisesBody()) { val responseBody = networkResponse.body if (responseBody != null) { val gzipSource = GzipSource(responseBody.source()) responseBuilder.body(RealResponseBody(contentType, -1L, gzipSource.buffer())) } } return responseBuilder.build() }
請求前的代碼很簡單,就是添加了一些必要的頭部信息,包括Content-Type、Host、Cookie
等等,封裝成一個完整的請求報文,而後交給下一個攔截器。
而獲取響應後的代碼就有點不是很明白了,gzip
是啥?GzipSource
又是什麼類?
gzip壓縮是基於deflate中的算法進行壓縮的,gzip會產生本身的數據格式,gzip壓縮對於所須要壓縮的文件,首先使用LZ77算法進行壓縮,再對獲得的結果進行huffman編碼,根據實際狀況判斷是要用動態huffman編碼仍是靜態huffman編碼,最後生成相應的gz壓縮文件。
簡單的說,gzip
就是一種壓縮方式,能夠將數據進行壓縮,在添加頭部信息的時候就添加了這樣一個頭部:
requestBuilder.header("Accept-Encoding", "gzip")
這一句其實就是在告訴服務器,客戶端所能接受的文件的壓縮格式,這裏設置了gzip
以後,服務器看到了就能把響應報文數據進行gzip
壓縮再傳輸,提升傳輸效率,節省流量。
因此請求以後的這段關於gzip
的處理其實就是客戶端對壓縮數據進行解壓縮,而GzipSource
是okio庫裏面一個進行解壓縮讀取數據的類。
繼續看緩存攔截器—CacheInterceptor
。
class CacheInterceptor(internal val cache: Cache?) : Interceptor { @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { //取緩存 val cacheCandidate = cache?.get(chain.request()) //緩存策略類 val strategy = CacheStrategy.Factory(now, chain.request(), cacheCandidate).compute() val networkRequest = strategy.networkRequest val cacheResponse = strategy.cacheResponse // 若是不容許使用網絡,而且緩存數據爲空 if (networkRequest == null && cacheResponse == null) { return Response.Builder() .request(chain.request()) .protocol(Protocol.HTTP_1_1) .code(HTTP_GATEWAY_TIMEOUT)//504 .message("Unsatisfiable Request (only-if-cached)") .body(EMPTY_RESPONSE) .sentRequestAtMillis(-1L) .receivedResponseAtMillis(System.currentTimeMillis()) .build().also { listener.satisfactionFailure(call, it) } } // 若是不容許使用網絡,可是有緩存 if (networkRequest == null) { return cacheResponse!!.newBuilder() .cacheResponse(stripBody(cacheResponse)) .build().also { listener.cacheHit(call, it) } } networkResponse = chain.proceed(networkRequest) // 若是緩存不爲空 if (cacheResponse != null) { //304,表示數據未修改 if (networkResponse?.code == HTTP_NOT_MODIFIED) { cache.update(cacheResponse, response) return response } } //若是開發者設置了緩存,則將響應數據緩存 if (cache != null) { if (response.promisesBody() && CacheStrategy.isCacheable(response, networkRequest)) { //緩存header val cacheRequest = cache.put(response) //緩存body return cacheWritingResponse(cacheRequest, response) } } return response } }
仍是分兩部分看:
請求以前
,經過request獲取了緩存,而後判斷緩存爲空,就直接返回code爲504的結果。若是有緩存而且緩存可用,則直接返回緩存。請求以後
,若是返回304
表明服務器數據沒修改,則直接返回緩存。若是cache
不爲空,那麼就把response
緩存下來。這樣看是否是和上面咱們說過的緩存機制對應上了?請求以前就是處理強制緩存
的狀況,請求以後就會處理協商緩存
的狀況。
可是仍是有幾個問題須要弄懂:
一、緩存是怎麼存儲和獲取的?
二、每次請求都會去存儲和獲取緩存嗎?
三、緩存策略(CacheStrategy)究竟是怎麼處理網絡和緩存的?networkRequest何時爲空?
首先,看看緩存哪裏取的:
val cacheCandidate = cache?.get(chain.request()) internal fun get(request: Request): Response? { val key = key(request.url) val snapshot: DiskLruCache.Snapshot = try { cache[key] ?: return null } val entry: Entry = try { Entry(snapshot.getSource(ENTRY_METADATA)) } val response = entry.response(snapshot) if (!entry.matches(request, response)) { response.body?.closeQuietly() return null } return response }
經過cache.get
方法獲取了response緩存,get方法中主要是用到了請求Request的url
來做爲獲取緩存的標誌。
因此咱們能夠推斷,緩存的獲取是經過請求的url做爲key來獲取的。
那麼cache
又是哪裏來的呢?
val cache: Cache? = builder.cache interceptors += CacheInterceptor(client.cache) class CacheInterceptor(internal val cache: Cache?) : Interceptor
沒錯,就是實例化CacheInterceptor
的時候傳進去的,因此這個cache是須要咱們建立OkHttpClient
的時候設置的,好比這樣:
val okHttpClient = OkHttpClient().newBuilder() .cache(Cache(cacheDir, 10 * 1024 * 1024)) .build()
這樣設置以後,okhttp
就知道cache
存在哪裏,大小爲多少,而後就能夠進行服務器響應的緩存處理了。
因此第二個問題也解決了,並非每次請求都會去處理緩存,而是開發者須要去設置緩存的存儲目錄和大小,纔會針對緩存進行這一系列的處理操做。
最後再看看緩存策略方法 CacheStrategy.Factory().compute()
class CacheStrategy internal constructor( val networkRequest: Request?, val cacheResponse: Response? ) fun compute(): CacheStrategy { val candidate = computeCandidate() return candidate } private fun computeCandidate(): CacheStrategy { //沒有緩存狀況下,返回空緩存 if (cacheResponse == null) { return CacheStrategy(request, null) } //... //緩存控制不是 no-cache,且未過時 if (!responseCaching.noCache && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) { val builder = cacheResponse.newBuilder() return CacheStrategy(null, builder.build()) } return CacheStrategy(conditionalRequest, cacheResponse) }
在這個緩存策略生存的過程當中,只有一種狀況下會返回緩存,也就是緩存控制不是no-cache
,而且緩存沒過時狀況下,就返回緩存,而後設置networkRequest爲空。
因此也就對應上一開始緩存攔截器中的獲取緩存後的判斷:
// 若是不容許使用網絡,可是有緩存,則直接返回緩存 if (networkRequest == null) { return cacheResponse!!.newBuilder() .cacheResponse(stripBody(cacheResponse)) .build().also { listener.cacheHit(call, it) } }
繼續,鏈接攔截器,以前說了是關於TCP鏈接
的。
object ConnectInterceptor : Interceptor { @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { val realChain = chain as RealInterceptorChain val exchange = realChain.call.initExchange(chain) val connectedChain = realChain.copy(exchange = exchange) return connectedChain.proceed(realChain.request) } }
代碼看着卻是挺少的,但其實這裏面很複雜很複雜,不着急,咱們慢慢說。
這段代碼就執行了一個方法就是initExchange
方法:
internal fun initExchange(chain: RealInterceptorChain): Exchange { val codec = exchangeFinder.find(client, chain) val result = Exchange(this, eventListener, exchangeFinder, codec) return result } fun find( client: OkHttpClient, chain: RealInterceptorChain ): ExchangeCodec { try { val resultConnection = findHealthyConnection( connectTimeout = chain.connectTimeoutMillis, readTimeout = chain.readTimeoutMillis, writeTimeout = chain.writeTimeoutMillis, pingIntervalMillis = client.pingIntervalMillis, connectionRetryEnabled = client.retryOnConnectionFailure, doExtensiveHealthChecks = chain.request.method != "GET" ) return resultConnection.newCodec(client, chain) } }
好像有一點眉目了,找到一個ExchangeCodec類,並封裝成一個Exchange類。
ExchangeCodec
:是一個鏈接所用的編碼解碼器,用於編碼HTTP請求和解碼HTTP響應。Exchange
:封裝這個編碼解碼器的一個工具類,用於管理ExchangeCodec,處理實際的 I/O。明白了,這個鏈接攔截器(ConnectInterceptor)就是找到一個可用鏈接唄,也就是TCP鏈接,這個鏈接就是用於HTTP請求和響應的。
你能夠把它能夠理解爲一個管道
,有了這個管道,才能把數據丟進去,也才能夠從管道里面取數據。
而這個ExchangeCodec
,編碼解碼器就是用來讀取和輸送到這個管道的一個工具,至關於把你的數據封裝成這個鏈接(管道)須要的格式。
我咋知道的?我貼一段ExchangeCodec代碼你就明白了:
//Http1ExchangeCodec.java fun writeRequest(headers: Headers, requestLine: String) { check(state == STATE_IDLE) { "state: $state" } sink.writeUtf8(requestLine).writeUtf8("\r\n") for (i in 0 until headers.size) { sink.writeUtf8(headers.name(i)) .writeUtf8(": ") .writeUtf8(headers.value(i)) .writeUtf8("\r\n") } sink.writeUtf8("\r\n") state = STATE_OPEN_REQUEST_BODY }
這裏貼的是Http1ExchangeCodec
的write代碼,也就是Http1的編碼解碼器。
很明顯,就是將Header信息一行一行寫到sink中,而後再由sink交給輸出流,具體就不分析了。只要知道這個編碼解碼器就是用來處理鏈接中進行輸送的數據便可。
而後就是這個攔截器的關鍵了,鏈接究竟是怎麼獲取的呢?繼續看看:
private fun findConnection(): RealConnection { // 一、複用當前鏈接 val callConnection = call.connection if (callConnection != null) { //檢查這個鏈接是否可用和可複用 if (callConnection.noNewExchanges || !sameHostAndPort(callConnection.route().address.url)) { toClose = call.releaseConnectionNoEvents() } return callConnection } //二、從鏈接池中獲取可用鏈接 if (connectionPool.callAcquirePooledConnection(address, call, null, false)) { val result = call.connection!! eventListener.connectionAcquired(call, result) return result } //三、從鏈接池中獲取可用鏈接(經過一組路由routes) if (connectionPool.callAcquirePooledConnection(address, call, routes, false)) { val result = call.connection!! return result } route = localRouteSelection.next() // 四、建立新鏈接 val newConnection = RealConnection(connectionPool, route) newConnection.connect // 五、再獲取一次鏈接,防止在新建鏈接過程當中有其餘競爭鏈接被建立了 if (connectionPool.callAcquirePooledConnection(address, call, routes, true)) { return result } //六、仍是要使用建立的新鏈接,放入鏈接池,並返回 connectionPool.put(newConnection) return newConnection }
獲取鏈接的過程很複雜,爲了方便看懂,我簡化了代碼,分紅了6步。
怎麼判斷可用的?主要作了兩個判斷
1)判斷是否再也不接受新的鏈接
2)判斷和當前請求有相同的主機名和端口號。
這卻是很好理解,要這個鏈接是鏈接的同一個地方纔能複用是吧,同一個地方怎麼判斷?就是判斷主機名和端口號
。
還有個問題就是爲何有當前鏈接??明明還沒開始鏈接也沒有獲取鏈接啊,怎麼鏈接就被賦值了?
還記得重試和重定向
攔截器嗎?對了,就是當請求失敗須要重試的時候或者重定向的時候,這時候鏈接還在呢,是能夠直接進行復用的。
第2步和第3步都是從鏈接池獲取鏈接,有什麼不同嗎?
connectionPool.callAcquirePooledConnection(address, call, null, false) connectionPool.callAcquirePooledConnection(address, call, routes, false)
好像多了一個routes
字段?
這裏涉及到HTTP/2的一個技術,叫作 HTTP/2 CONNECTION COALESCING
(鏈接合併),什麼意思呢?
假設有兩個域名,能夠解析爲相同的IP地址,而且是能夠用相同的TLS證書(好比通配符證書),那麼客戶端能夠重用相同的TCP鏈接
從這兩個域名中獲取資源。
再看回咱們的鏈接池,這個routes
就是當前域名(主機名)能夠被解析的ip地址
集合,這兩個方法的區別也就是一個傳了路由地址,一個沒有傳。
繼續看callAcquirePooledConnection
代碼:
internal fun isEligible(address: Address, routes: List<Route>?): Boolean { if (address.url.host == this.route().address.url.host) { return true } //HTTP/2 CONNECTION COALESCING if (http2Connection == null) return false if (routes == null || !routeMatchesAny(routes)) return false if (address.hostnameVerifier !== OkHostnameVerifier) return false return true }
1)判斷主機名、端口號等,若是請求徹底相同就直接返回這個鏈接。
2)若是主機名不一樣,還能夠判斷是否是HTTP/2
請求,若是是就繼續判斷路由地址,證書,若是都能匹配上,那麼這個鏈接也是可用的。
若是沒有從鏈接池中獲取到新鏈接,那麼就建立一個新鏈接,這裏就很少說了,其實就是調用到socket.connect
進行TCP鏈接。
建立了新鏈接,爲何還要去鏈接池獲取一次鏈接呢?
由於在這個過程當中,有可能有其餘的請求和你一塊兒建立了新鏈接,因此咱們須要再去取一次鏈接,若是有能夠用的,就直接用它,防止資源浪費。
其實這裏又涉及到HTTP2的一個知識點:多路複用
。
簡單的說,就是不須要當前鏈接的上一個請求結束以後再去進行下一次請求,只要有鏈接就能夠直接用。
HTTP/2引入二進制數據幀和流的概念,其中幀對數據進行順序標識,這樣在收到數據以後,就能夠按照序列對數據進行合併,而不會出現合併後數據錯亂的狀況。一樣是由於有了序列,服務器就能夠並行的傳輸數據,這就是流所作的事情。
因此在HTTP/2
中能夠保證在同一個域名只創建一路鏈接,而且能夠併發進行請求。
最後一步好理解吧,走到這裏說明就要用這個新鏈接了,那麼就把它存到鏈接池,返回這個鏈接。
這個攔截器確實麻煩,你們好好梳理下吧,我也再來個圖:
鏈接拿到了,編碼解碼器有了,剩下的就是發數據,讀數據了,也就是跟I/O
相關的工做。
class CallServerInterceptor(private val forWebSocket: Boolean) : Interceptor { @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { //寫header數據 exchange.writeRequestHeaders(request) //寫body數據 if (HttpMethod.permitsRequestBody(request.method) && requestBody != null) { val bufferedRequestBody = exchange.createRequestBody(request, true).buffer() requestBody.writeTo(bufferedRequestBody) } else { exchange.noRequestBody() } //結束請求 if (requestBody == null || !requestBody.isDuplex()) { exchange.finishRequest() } //獲取響應數據 var response = responseBuilder .request(request) .handshake(exchange.connection.handshake()) .build() var code = response.code response = response.newBuilder() .body(exchange.openResponseBody(response)) .build() return response } }
這個攔截器 卻是沒幹什麼活,以前的攔截器兄弟們都把準備工做幹完了,它就調用下exchange
類的各類方法,寫入header,body
,拿到code,response
。
這活可乾的真輕鬆啊。
好了,最後補上這個攔截器networkInterceptors
,它也是一個自定義攔截器,位於CallServerInterceptor
以前,屬於倒數第二個攔截器。
那爲何OkHttp
在有了一個自定義攔截器的前提下又提供了一個攔截器呢?
能夠發現,這個攔截器的位置是比較深的位置,處在發送數據的前一刻,以及收到數據的第一刻。
這麼敏感的位置,決定了經過這個攔截器能夠看到更多的信息,好比:
請求以前
,OkHttp處理以後的請求報文數據,好比增長了各類header以後的數據。請求以後
,OkHttp處理以前的響應報文數據,好比解壓縮以前的數據。因此,這個攔截器就是用來網絡調試
的,調試比較底層、更全面的數據。
最後再回顧下每一個攔截器的做用:
addInterceptor(Interceptor)
,這是由開發者設置的,會按照開發者的要求,在全部的攔截器處理以前進行最先的攔截處理,好比一些公共參數,Header均可以在這裏添加。RetryAndFollowUpInterceptor
,這裏會對鏈接作一些初始化工做,以及請求失敗的重試工做,重定向的後續請求工做。BridgeInterceptor
,這裏會爲用戶構建一個可以進行網絡訪問的請求,同時後續工做將網絡請求回來的響應Response轉化爲用戶可用的Response,好比添加文件類型,content-length計算添加,gzip解包。CacheInterceptor
,這裏主要是處理cache相關處理,會根據OkHttpClient對象的配置以及緩存策略對請求值進行緩存,並且若是本地有了可⽤的Cache,就能夠在沒有網絡交互的狀況下就返回緩存結果。ConnectInterceptor
,這裏主要就是負責創建鏈接了,會創建TCP鏈接或者TLS鏈接,以及負責編碼解碼的HttpCodec。networkInterceptors
,這裏也是開發者本身設置的,因此本質上和第一個攔截器差很少,可是因爲位置不一樣,用處也不一樣。這個位置添加的攔截器能夠看到請求和響應的數據了,因此能夠作一些網絡調試。CallServerInterceptor
,這裏就是進行網絡數據的請求和響應了,也就是實際的網絡I/O操做,經過socket讀寫數據。https://www.jianshu.com/p/bfb13eb3a425
http://www.javashuo.com/article/p-tpgkxvnx-bt.html
https://www.jianshu.com/p/02db8b55aae9
https://kaiwu.lagou.com/course/courseInfo.htm?courseId=67#/detail/pc
感謝你們的閱讀,有一塊兒學習的小夥伴能夠關注下個人公衆號——碼上積木❤️❤️
每日一個知識點,聚沙成塔,創建知識體系架構。
這裏有一羣很好的Android小夥伴,歡迎你們加入~