Alamofire源碼解讀系列(十一)之多表單(MultipartFormData)

本篇講解跟上傳數據相關的多表單

前言

我相信應該有不少的開發者不明白多表單是怎么一回事,然而事實上,多表單確實很簡單。試想一下,如果有多個不同類型的文件(png/txt/mp3/pdf等等)需要上傳給服務器,你打算怎么辦?如果你一個一個的上傳,那我無話可說,但是如果你想一次性上傳,那么就要考慮服務端如何識別這些不同類型的數據呢?

服務端對不同類型數據的識別解決方案就是多表單。客戶端與服務端共同制定一套規范,彼此使用該規則交換數據就完全ok了,

在本篇中我會帶來多表單的格式說明和實現多表單的過程的說明,我會在整個解讀過程中,先給出設計思想,然后再講解源碼。

多表單格式

我們先看一個多變單的格式例子:

POST / HTTP/1.1
         [[ Less interesting headers ... ]]
         Content-Type: multipart/form-data; boundary=735323031399963166993862150
         Content-Length: 834
         
         --735323031399963166993862150
         Content-Disposition: form-data; name="text1"
         
         text default
         735323031399963166993862150
         Content-Disposition: form-data; name="text2"
         
         aωb
         735323031399963166993862150
         Content-Disposition: form-data; name="file1"; filename="a.txt"
         Content-Type: text/plain
         
         Content of a.txt.
         735323031399963166993862150
         Content-Disposition: form-data; name="file2"; filename="a.html"
         Content-Type: text/html
         
         <!DOCTYPE html><title>Content of a.html.</title>
         735323031399963166993862150
         Content-Disposition: form-data; name="file3"; filename="binary"
         Content-Type: application/octet-stream
         
         aωb
         735323031399963166993862150--

通過上邊的內容,我們可以分析出來下邊的幾點知識:

  • Content-Type: multipart/form-data; boundary=735323031399963166993862150 通過Content-Type來說明當前數據的類型為multipart/form-data,這樣服務器就知道客戶端將要發送的數據是多表單了。多表單說白了就是把各種數據拼接起來,要想區分數據,必須添加一個界限標識符。因此通過boundary設置邊界。這些設置不能省略
  • Content-Length: 834 告訴服務端數據的總長度,大家留意一下這個字段,在后邊的代碼中會有一個屬性來提供這個數據,我們最終上傳的數據都是二進制流,因此知道獲取到Data就能計算大小
  • --735323031399963166993862150 735323031399963166993862150我們已經知道它表示的是邊界。如果在前邊添加了--就表示是多表單的開始邊界,與之對應的是735323031399963166993862150--
  • Content-Disposition: form-data; name="file1"; filename="a.txt" 對內容的進一步說明
  • Content-Type: text/html 表示對表單內該數據的類型的說明
  • 735323031399963166993862150-- 結束邊界

上邊的例子只是演示了一個比較簡單的表單樣式,表單中嵌套表單也有可能。在實際開發處理中,需要根據不同的組成部分獲取Data,最后拼接成一個整體的Data。

封裝

總體上我們需要拼接出像上邊示例中的結構的數據,因此我們把這些步驟進行拆分:

Boundary

關于邊界,通過上邊的分析,我們知道有3中類型的邊界:

  1. 開始邊界
  2. 內部邊界
  3. 結束邊界

因此設計一個枚舉來封裝邊界類型:

 enum BoundaryType {
            case initial, encapsulated, final
        }

除了邊界的類型之外,我們要生成邊界字符串,通常該字符串采用隨機生成的方式:

static func randomBoundary() -> String {
            return String(format: "alamofire.boundary.%08x%08x", arc4random(), arc4random())
        }

上邊的代碼有一個小的知識點,%08x為整型以16進制方式輸出的格式字符串,會把后續對應參數的整型數字,以16進制輸出。08的含義為,輸出的16進制值占8位,不足部分左側補0。于是,如果執行printf("0x%08x", 0x1234);會輸出0x00001234。

因為最終上傳的數據是Data類型,因此需要一個轉換函數,把邊界轉換成Data類型:

static func boundaryData(forBoundaryType boundaryType: BoundaryType, boundary: String) -> Data {
            let boundaryText: String

            switch boundaryType {
            case .initial:
                boundaryText = "--\(boundary)\(EncodingCharacters.crlf)"
            case .encapsulated:
                boundaryText = "\(EncodingCharacters.crlf)--\(boundary)\(EncodingCharacters.crlf)"
            case .final:
                boundaryText = "\(EncodingCharacters.crlf)--\(boundary)--\(EncodingCharacters.crlf)"
            }

            return boundaryText.data(using: String.Encoding.utf8, allowLossyConversion: false)!
        }

在Alamofire中,上邊的代碼組成了BoundaryGenerator,表示邊界生產者。上邊代碼中用到了EncodingCharacters.crlf,其實它是對"\r\n"的一個封裝,表示換行回車的意思。

BodyPart

針對多表單中的內一個表單也需要做一個封裝成一個對象,其內部需要作出下邊這些說明:

  • headers: HTTPHeaders 這個是對數據的描述
  • bodyStream: InputStream 數據來源,Alamofire中使用InputStream統一進行處理
  • bodyContentLength: UInt64 該數據的大小
  • hasInitialBoundary = false 是否包含初始邊界
  • hasFinalBoundary = false 是否包含結束邊界

因此設計的代碼如下:

 /// 對每一個body部分的描述,這個類只能在MultipartFormData內部訪問,外部無法訪問
    class BodyPart {
        let headers: HTTPHeaders
        let bodyStream: InputStream
        let bodyContentLength: UInt64
        var hasInitialBoundary = false
        var hasFinalBoundary = false

        init(headers: HTTPHeaders, bodyStream: InputStream, bodyContentLength: UInt64) {
            self.headers = headers
            self.bodyStream = bodyStream
            self.bodyContentLength = bodyContentLength
        }
    }

MultipartFormData

MultipartFormData被設計為一個對象,在SessionManager.swift那一篇文章中我們會介紹MultipartFormData的具體用法。總之,MultipartFormData必須給我們提供一下的幾個功能:

  • 提供一些在請求時需要的參數
  • 提供各種數據拼接的入口
  • 為了性能,如果數據過大,提供把數據寫入文件的功能

接下來,我們就跟著上邊這些設計思想來一步一步的分析核心代碼的來源。

屬性

公開或者私有的屬性有下邊幾個:

open var contentType: String { return "multipart/form-data; boundary=\(boundary)" }

    /// The content length of all body parts used to generate the `multipart/form-data` not including the boundaries.
    /// 這里的0表示初始值,$0表示計算結果類型,$1表示數組元素類型
    public var contentLength: UInt64 { return bodyParts.reduce(0) { $0 + $1.bodyContentLength } }

    /// The boundary used to separate the body parts in the encoded form data.
    public let boundary: String

    private var bodyParts: [BodyPart]
    private var bodyPartError: AFError?
    private let streamBufferSize: Int

我們對他們做一些簡單的說明:

  • contentType: String 我們在上邊已經詳細講過這個屬性了
  • contentLength: UInt64 獲取數據的大小,該屬性是一個計算屬性
  • boundary: String 表示邊界,在初始化中會使用BoundaryGenerator來生成一個邊界字符串
  • bodyParts: [BodyPart] 是一個集合,包含了每一個數據的封裝對象BodyPart
  • bodyPartError: AFError?
  • streamBufferSize: Int 設置stream傳輸的buffer大小

初始化方法

初始化方法就一個:

 /// Creates a multipart form data object.
    ///
    /// - returns: The multipart form data object.
    public init() {
        self.boundary = BoundaryGenerator.randomBoundary()
        self.bodyParts = []

        ///
        /// The optimal read/write buffer size in bytes for input and output streams is 1024 (1KB). For more
        /// information, please refer to the following article:
        ///   - https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/Streams/Articles/ReadingInputStreams.html
        ///

        self.streamBufferSize = 1024
    }

Body Parts

我們想象一下,如果有很多種不同類型的文件要拼接到一個對象中,該怎么辦?我們分析一下:

  1. 首先應該考慮輸入源的問題,因為在開發中可能使用的輸入源有3種

    • Data 直接提供Data類型的數據,比如把一張圖片編碼成Data,然后拼接進來
    • fileURL 通過一個文件的本地URL來獲取數據,然后拼接進來
    • Stream 直接通過stream導入數據
  2. 明確了數據的輸入源之后,我們還要考慮提供哪些參數來描述這些數據,這很有必要,比如只傳遞一個Data,服務端根本不知道應該如何解析它。根據不同的需求,需要提供一下參數:

    • name 與數據相關的名字
    • mimeType 表示數據的類型
    • fileName 表示數據的文件名稱,
    • length 表示數據大小
    • stream 表示輸入流
    • headers 數據的headers
  3. 根據第二步中的參數設計函數,函數的目的就是把每一條數據封裝成BodyPart對象,然后拼接到bodyParts數組中

通過上邊的分析呢,我們接下來的任務就是設計各種包含不同參數的函數。結合上邊第一步和第二步的內容,我們分析后的結果如下:

  • 由BodyPart的初始化方法init(headers: HTTPHeaders, bodyStream: InputStream, bodyContentLength: UInt64),我們知道,給出headersstreamlength我們就能生成BodyPart對象,然后把它拼接到數組中就行了,因此該函數已經設計ok

       public func append(_ stream: InputStream, withLength length: UInt64, headers: HTTPHeaders) {
              let bodyPart = BodyPart(headers: headers, bodyStream: stream, bodyContentLength: length)
              bodyParts.append(bodyPart)
          }
    
  • 如果每次都是用上邊的函數拼接數據,我會瘋掉。因為必須要對它的3個參數非常了解才行,相信大多數人都會有這種想法。因此,這就說明上邊的函數是最底層的函數方案。之所以稱為最底層,因為他可定義的靈活性很高,使用起來也很麻煩。我們接下來要考慮的就是如何減少開發過程中的使用障礙

  • 其實,到此為止,我們正處在一個編程中非常經典的概念中。大家可以自己去了解尾調函數的概念。那么現在要設計一個包含最多參數的函數,這個函數會成為其他函數的內部實現基礎。我們把headers這個參數去掉,這個參數可以根據namemimeTypefileName計算出來,因此有了下邊的函數:

      public func append(
              _ stream: InputStream,
              withLength length: UInt64,
              name: String,
              fileName: String,
              mimeType: String)
          {
              let headers = contentHeaders(withName: name, fileName: fileName, mimeType: mimeType)
              append(stream, withLength: length, headers: headers)
          }
    

    這里邊出現了一個陌生的函數contentHeaders(withName: name, fileName: fileName, mimeType: mimeType),它的功能是根據namemimeTypefileName計算出headers,其內部實現如下:

        private func contentHeaders(withName name: String, fileName: String? = nil, mimeType: String? = nil) -> [String: String] {
          var disposition = "form-data; name=\"\(name)\""
          if let fileName = fileName { disposition += "; filename=\"\(fileName)\"" }
    
          var headers = ["Content-Disposition": disposition]
          if let mimeType = mimeType { headers["Content-Type"] = mimeType }
    
          return headers
      }
    
  • 上邊的函數還是太麻煩,那么我們開始考慮如果我傳入的數據是個Data類型呢?能對Data進行描述的有3個參數:namemimeTypefileName,因此我們會設計3個函數,首先是設計參數最多的函數,作為其他兩個函數的內部實現基礎:

       public func append(_ data: Data, withName name: String, fileName: String, mimeType: String) {
              let headers = contentHeaders(withName: name, fileName: fileName, mimeType: mimeType)
              let stream = InputStream(data: data)
              let length = UInt64(data.count)
      
              append(stream, withLength: length, headers: headers)
          }
    

    在上邊我們已經介紹過了contentHeaders函數的作用,上邊的代碼中,根據data生成InputStream和length是關鍵。接下來我們把參數減少一個,先減少fileName,因為fileName是一個可選的參數:

       public func append(_ data: Data, withName name: String, mimeType: String) {
          let headers = contentHeaders(withName: name, mimeType: mimeType)
          let stream = InputStream(data: data)
          let length = UInt64(data.count)
    
          append(stream, withLength: length, headers: headers)
      }
    

    我們在去掉一個參數:mimeType,mimeType也是一個可選的參數:

      public func append(_ data: Data, withName name: String) {
          let headers = contentHeaders(withName: name)
          let stream = InputStream(data: data)
          let length = UInt64(data.count)
    
          append(stream, withLength: length, headers: headers)
      }   
    
  • 對于處理Data類型的數據的函數已經寫完了,接下來我們繼續設計fileURL類型數據的處理函數。首先就是包含namemimeTypefileNamefileURL

      public func append(_ fileURL: URL, withName name: String, fileName: String, mimeType: String) {
              let headers = contentHeaders(withName: name, fileName: fileName, mimeType: mimeType)
      
              //============================================================
              //                 Check 1 - is file URL?
              //============================================================
      
              guard fileURL.isFileURL else {
                  setBodyPartError(withReason: .bodyPartURLInvalid(url: fileURL))
                  return
              }
      
              //============================================================
              //              Check 2 - is file URL reachable?
              //============================================================
      
              do {
                  let isReachable = try fileURL.checkPromisedItemIsReachable()
                  guard isReachable else {
                      setBodyPartError(withReason: .bodyPartFileNotReachable(at: fileURL))
                      return
                  }
              } catch {
                  setBodyPartError(withReason: .bodyPartFileNotReachableWithError(atURL: fileURL, error: error))
                  return
              }
      
              //============================================================
              //            Check 3 - is file URL a directory?
              //============================================================
      
              var isDirectory: ObjCBool = false
              let path = fileURL.path
      
              guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) && !isDirectory.boolValue else
              {
                  setBodyPartError(withReason: .bodyPartFileIsDirectory(at: fileURL))
                  return
              }
      
              //============================================================
              //          Check 4 - can the file size be extracted?
              //============================================================
      
              let bodyContentLength: UInt64
      
              do {
                  guard let fileSize = try FileManager.default.attributesOfItem(atPath: path)[.size] as? NSNumber else {
                      setBodyPartError(withReason: .bodyPartFileSizeNotAvailable(at: fileURL))
                      return
                  }
      
                  bodyContentLength = fileSize.uint64Value
              }
              catch {
                  setBodyPartError(withReason: .bodyPartFileSizeQueryFailedWithError(forURL: fileURL, error: error))
                  return
              }
      
              //============================================================
              //       Check 5 - can a stream be created from file URL?
              //============================================================
      
              guard let stream = InputStream(url: fileURL) else {
                  setBodyPartError(withReason: .bodyPartInputStreamCreationFailed(for: fileURL))
                  return
              }
      
              append(stream, withLength: bodyContentLength, headers: headers)
          }
    

    上邊的函數很長,但是思想很簡單,根據fileURL生成InputStream,但其中對可能出現的錯誤的處理,值得我們學習,我用黑色粗色的字體來記錄。

    1. 通過fileURL.isFileURL判斷fileURL是不是一個file的URL
    2. 通過fileURL.checkPromisedItemIsReachable()判斷該fileURL是不是可達的
    3. 判斷fileURL是不是一個文件夾,而不是具體的數據
    4. 通過FileManager.default.attributesOfItem(atPath: path)[.size] as? NSNumber判斷fileURL指定的文件能不能被讀取
    5. 通過InputStream(url: fileURL)判斷能不能通過fileURL創建InputStream

    綜上所述,當需要把文件寫入fileURL中,或者從fileURL中讀取數據時,一定要像上邊那樣對所有可能出錯的情況做出處理。

encode() -> Data

通過上一小節的append方法,我們已經能夠把數據拼接到bodyParts數組中了,接下來考慮的是怎么數組中的模型拼接成一個完整的Data。

這里有一個編碼的小技巧,必須先檢測有沒有錯誤發生,如果有錯誤發生,那么就沒必要繼續encode了。

public func encode() throws -> Data {
        if let bodyPartError = bodyPartError {
            throw bodyPartError
        }

        var encoded = Data()

        bodyParts.first?.hasInitialBoundary = true
        bodyParts.last?.hasFinalBoundary = true

        for bodyPart in bodyParts {
            let encodedData = try encode(bodyPart)
            encoded.append(encodedData)
        }

        return encoded
    }

上邊的代碼做了3件事:

  1. 檢查錯誤
  2. 給數組中第一個數據設置開始邊界,最后一個數據設置結束邊界
  3. 把bodyPart對象轉換成Data類型,然后拼接到encoded中

上邊的函數出現了一個新的函數;encode(_ bodyPart: BodyPart) throws -> Data

private func encode(_ bodyPart: BodyPart) throws -> Data {
        var encoded = Data()

        let initialData = bodyPart.hasInitialBoundary ? initialBoundaryData() : encapsulatedBoundaryData()
        encoded.append(initialData)

        let headerData = encodeHeaders(for: bodyPart)
        encoded.append(headerData)

        let bodyStreamData = try encodeBodyStream(for: bodyPart)
        encoded.append(bodyStreamData)

        if bodyPart.hasFinalBoundary {
            encoded.append(finalBoundaryData())
        }

        return encoded
    }

上邊的代碼做了四件事:

  1. 在文章的開頭我們就講解了多表單的結構,第一步就是把邊界轉換成Data
  2. 把header轉換成Data
  3. 把數據轉換成Data
  4. 如果有結束邊界,把結束邊界轉換成Data

在上邊的函數中出現了5個輔助函數:

  • initialBoundaryData() 生成開始邊界Data

      private func initialBoundaryData() -> Data {
              return BoundaryGenerator.boundaryData(forBoundaryType: .initial, boundary: boundary)
          }
    
  • encapsulatedBoundaryData() 生成內容中間的邊界Data

      private func encapsulatedBoundaryData() -> Data {
              return BoundaryGenerator.boundaryData(forBoundaryType: .encapsulated, boundary: boundary)
          }
    
  • finalBoundaryData() 生成結束邊界Data

      private func finalBoundaryData() -> Data {
              return BoundaryGenerator.boundaryData(forBoundaryType: .final, boundary: boundary)
          }
    
  • encodeHeaders(for bodyPart: BodyPart) -> Data 生成headerData

       private func encodeHeaders(for bodyPart: BodyPart) -> Data {
              var headerText = ""
      
              for (key, value) in bodyPart.headers {
                  headerText += "\(key): \(value)\(EncodingCharacters.crlf)"
              }
              headerText += EncodingCharacters.crlf
      
              return headerText.data(using: String.Encoding.utf8, allowLossyConversion: false)!
          }
    
  • encodeBodyStream(for bodyPart: BodyPart) throws -> Data 生成數據Data

       private func encodeBodyStream(for bodyPart: BodyPart) throws -> Data {
              let inputStream = bodyPart.bodyStream
              inputStream.open()
              defer { inputStream.close() }
      
              var encoded = Data()
      
              while inputStream.hasBytesAvailable {
                  var buffer = [UInt8](repeating: 0, count: streamBufferSize)
                  let bytesRead = inputStream.read(&buffer, maxLength: streamBufferSize)
      
                  if let error = inputStream.streamError {
                      throw AFError.multipartEncodingFailed(reason: .inputStreamReadFailed(error: error))
                  }
      
                  if bytesRead > 0 {
                      encoded.append(buffer, count: bytesRead)
                  } else {
                      break
                  }
              }
      
              return encoded
          }
    

    上邊的代碼中有兩點需要注意,defer { inputStream.close() }可以定義代碼塊結束后執行的語句,通過while讀取stream中數據的典型代碼。

把拼接后的數據寫入fireURL

在Alamofire中,如果編碼后的數據超過了某個值,就會把該數據寫入到fileURL中,在發送請求的時候,在fileURL中讀取數據上傳。

public func writeEncodedData(to fileURL: URL) throws {
        if let bodyPartError = bodyPartError {
            throw bodyPartError
        }

        if FileManager.default.fileExists(atPath: fileURL.path) {
            throw AFError.multipartEncodingFailed(reason: .outputStreamFileAlreadyExists(at: fileURL))
        } else if !fileURL.isFileURL {
            throw AFError.multipartEncodingFailed(reason: .outputStreamURLInvalid(url: fileURL))
        }

        guard let outputStream = OutputStream(url: fileURL, append: false) else {
            throw AFError.multipartEncodingFailed(reason: .outputStreamCreationFailed(for: fileURL))
        }

        outputStream.open()
        /// 新的 defer 關鍵字為此提供了安全又簡單的處理方式:聲明一個 block,當前代碼執行的閉包退出時會執行該 block。
        defer { outputStream.close() }

        self.bodyParts.first?.hasInitialBoundary = true
        self.bodyParts.last?.hasFinalBoundary = true

        for bodyPart in self.bodyParts {
            try write(bodyPart, to: outputStream)
        }
    }

上邊的代碼在檢查完錯誤后,創建了一個outputStream,通過這個outputStream來把數據寫到fileURL中。

注意,通過上邊的函數可以看出,Alamofire并沒有使用上邊的encode函數來生成一個Data,然后再寫入fileURL。這是因為大文件往往我們是通過append(fileURL)方式拼接進來的,并沒有把數據加載到內存。

上邊的代碼中出現了一個輔助函數write(bodyPart, to: outputStream)

private func write(_ bodyPart: BodyPart, to outputStream: OutputStream) throws {
        try writeInitialBoundaryData(for: bodyPart, to: outputStream)
        try writeHeaderData(for: bodyPart, to: outputStream)
        try writeBodyStream(for: bodyPart, to: outputStream)
        try writeFinalBoundaryData(for: bodyPart, to: outputStream)
    }

該函數出現了4個輔助函數:

 private func writeInitialBoundaryData(for bodyPart: BodyPart, to outputStream: OutputStream) throws {
        let initialData = bodyPart.hasInitialBoundary ? initialBoundaryData() : encapsulatedBoundaryData()
        return try write(initialData, to: outputStream)
    }

    private func writeHeaderData(for bodyPart: BodyPart, to outputStream: OutputStream) throws {
        let headerData = encodeHeaders(for: bodyPart)
        return try write(headerData, to: outputStream)
    }

    private func writeBodyStream(for bodyPart: BodyPart, to outputStream: OutputStream) throws {
        let inputStream = bodyPart.bodyStream

        inputStream.open()
        defer { inputStream.close() }

        while inputStream.hasBytesAvailable {
            var buffer = [UInt8](repeating: 0, count: streamBufferSize)
            let bytesRead = inputStream.read(&buffer, maxLength: streamBufferSize)

            if let streamError = inputStream.streamError {
                throw AFError.multipartEncodingFailed(reason: .inputStreamReadFailed(error: streamError))
            }

            if bytesRead > 0 {
                if buffer.count != bytesRead {
                    buffer = Array(buffer[0..<bytesRead])
                }

                try write(&buffer, to: outputStream)
            } else {
                break
            }
        }
    }

    private func writeFinalBoundaryData(for bodyPart: BodyPart, to outputStream: OutputStream) throws {
        if bodyPart.hasFinalBoundary {
            return try write(finalBoundaryData(), to: outputStream)
        }
    }

由于上邊函數的思想我們在文章中都講過了,這里就不提了。除了上邊的函數,還有兩個寫數據的輔助函數:

private func write(_ data: Data, to outputStream: OutputStream) throws {
        var buffer = [UInt8](repeating: 0, count: data.count)
        data.copyBytes(to: &buffer, count: data.count)

        return try write(&buffer, to: outputStream)
    }

    private func write(_ buffer: inout [UInt8], to outputStream: OutputStream) throws {
        var bytesToWrite = buffer.count

        while bytesToWrite > 0, outputStream.hasSpaceAvailable {
            let bytesWritten = outputStream.write(buffer, maxLength: bytesToWrite)

            if let error = outputStream.streamError {
                throw AFError.multipartEncodingFailed(reason: .outputStreamWriteFailed(error: error))
            }

            bytesToWrite -= bytesWritten

            if bytesToWrite > 0 {
                buffer = Array(buffer[bytesWritten..<buffer.count])
            }
        }
    }

對于上邊的函數,大家了解下就行了。那么到這里為止,MultipartFormData我們就已經分析完成了。

總結

上邊漏掉了下邊這一個函數:

  private func mimeType(forPathExtension pathExtension: String) -> String {
        if
            let id = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, pathExtension as CFString, nil)?.takeRetainedValue(),
            let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)?.takeRetainedValue()
        {
            return contentType as String
        }
/// 如果是一個二進制文件,通常遇到這種類型,軟件丟回提示使用其他程序打開
        return "application/octet-stream"
    }

當Content-Type使用了application/octet-stream時,往往客戶端就會給出使用其他程序打開的提示。大家平時有沒有見過這種情況呢?

由于知識水平有限,如有錯誤,還望指出

鏈接

Alamofire源碼解讀系列(一)之概述和使用 簡書-----博客園

Alamofire源碼解讀系列(二)之錯誤處理(AFError) 簡書-----博客園

Alamofire源碼解讀系列(三)之通知處理(Notification) 簡書-----博客園

Alamofire源碼解讀系列(四)之參數編碼(ParameterEncoding) 簡書-----博客園

Alamofire源碼解讀系列(五)之結果封裝(Result) 簡書-----博客園

Alamofire源碼解讀系列(六)之Task代理(TaskDelegate) 簡書-----博客園

Alamofire源碼解讀系列(七)之網絡監控(NetworkReachabilityManager) 簡書-----博客園

Alamofire源碼解讀系列(八)之安全策略(ServerTrustPolicy) 簡書-----博客園

Alamofire源碼解讀系列(九)之響應封裝(Response) 簡書-----博客園

Alamofire源碼解讀系列(十)之序列化(ResponseSerialization) 簡書-----博客園

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,501評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,673評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,610評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,939評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,668評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,004評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,001評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,173評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,705評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,426評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,656評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,139評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,833評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,247評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,580評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,371評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,621評論 2 380

推薦閱讀更多精彩內容