zoukankan      html  css  js  c++  java
  • 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,其实它是对" "的一个封装,表示换行回车的意思。

    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) 简书-----博客园

  • 相关阅读:
    [PDF]阅读、注释最佳软件
    [CentOS 7]挂载ntfs格式U盘
    如何更改键盘按键---KeyTweak?
    ssh 文件上传、文件目录上传和下载
    centos7安装Anaconda(Anaconda3-2020.02-Linux-x86_64)与基本命令使用
    Ubuntu 下SVN常用操作
    程序员常用docker命令
    numpy&pandas
    Deep Learning with pytorch笔记(第三章)
    pytorch中的ReflectionPad2d
  • 原文地址:https://www.cnblogs.com/machao/p/6651904.html
Copyright © 2011-2022 走看看