美文网首页
IOS框架封装:上报日志库和网络请求库

IOS框架封装:上报日志库和网络请求库

作者: 时光啊混蛋_97boy | 来源:发表于2022-01-12 13:58 被阅读0次

    原创:知识点总结性文章
    创作不易,请珍惜,之后会持续更新,不断完善
    个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈哈,这些文章记录了我的IOS成长历程,希望能与大家一起进步
    温馨提示:由于简书不支持目录跳转,大家可通过command + F 输入目录标题后迅速寻找到你所需要的内容

    目录

    • 一、上报日志库
      • 1、上报非200状态码的网络请求错误
      • 2、底层数据上报方法的实现
      • 3、上报其他日志类型
    • 二、网络请求库 Alamofire
      • 1、外层调用的网络请求方法 apiGet 的实现
      • 2、拼接请求的URL
      • 3、拼接网络请求的公共参数
      • 4、创建URLRequest请求
      • 5、处理请求响应的信息
    • Demo

    一、上报日志库

    1、上报非200状态码的网络请求错误

    需要上报的场景有很多,比如上报网络请求的性能、启动APP的时间、网络请求错误的原因等,这里以上报HTTP错误为例来看看如何使用封装好的工具类。在这里我们传入了请求的url路径、请求方法、请求参数、错误码和错误描述这些信息。

    LogReport.default.reportHttpError(path,
                                      requestParameter: parameters ?? [:],
                                      responseCode: code,
                                      requestMethod: requestHttpMethod,
                                      page: "",
                                      describe: (error as NSError).description)
    

    让我们进入到该上报方法中来看看其是如何实现的。首先会去判断是否已经初始化了该工具类,倘若并没有初始化则不进行上报而是直接返回。

        /// SDK 上报网络请求错误
        /// - Parameters:
        ///   - url: 请求URL
        ///   - requestParameter: 请求参数
        ///   - responseCode: 响应码
        ///   - requestMethod: 请求方式
        ///   - page: 请求的页面
        ///   - describe: 错误描述
        open func reportHttpError(_ url:String,
                                  requestParameter:[String:Any],
                                  responseCode:Int,
                                  requestMethod:RequestHttpMethod = RequestHttpMethod.get,
                                  page:String = "",
                                  describe:String = ""){
            if isInitAppId() {
                print("not set `AppId`,stop report`reportHttpError`")
                return
            }
        }
    

    isInitAppId该方法直接判断appId属性是否有值,倘若有值就表示初始化了该工具类。

    func isInitAppId() -> Bool {
        let trimAppId = self.appId.trimmingCharacters(in: .whitespaces)
        return trimAppId.isEmpty
    }
    

    倘若已经初始化了,那么我们新创建了一个字典,在字典中传入了需要上报的相关参数,这些参数包括我们传入的请求参数,也包括了我们额外添加了一些参数,比如上报日志类型等,上报类型包括网络请求失败、业务失败、图片加载失败、自定义错误四种类型,我们举的例子是网络请求失败。

    /// 日志类型
    public enum LogErrorType:String {
        case requestError = "net"// 非200状态码的网络请求
        case businessError = "business"// 请求成功但业务失败
        case imageError = "img"// 图片加载失败
        case customError = "custom"// 自定义错误
    }
    

    这些额外的参数还包括创建时间,getTimeStamp用来获取创建时间的时间戳。

    public func getTimeStamp() -> CLongLong {
        let timeInterval: TimeInterval = Date.init().timeIntervalSince1970
        let millisecond = CLongLong(round(timeInterval*1000))
        return millisecond
    }
    

    针对于GetPost两种请求方法,url和参数的处理是不同的,对于Get请求,我们通过urlAddCompnentByGetMethod方法来实现了拼接参数的功能。

    public func urlAddCompnentByGetMethod(_ url:String ,_ dic:[String:Any])-> String {
        var t_url = url;
        dic.forEach { key,value in
            // 先判断链接是否带?
            if t_url.contains("?") {
                // ?号是否在最后一个字符
                if t_url.last == "?" {
                    t_url += "\(key)=\(value)"
                } else {
                    // 最后一个字符是否是&
                    if t_url.last == "&" {
                        t_url += "\(key)=\(value)"
                    } else {
                        t_url += "&\(key)=\(value)"
                    }
                }
            } else {
                // 不带问号
                t_url += "?\(key)=\(value)"
            }
        }
        return t_url
    }
    

    对于Post请求,我们通过dicToJson方法将请求参数字典转化为了JSON字符串。

    public func dicToJson(_ dic:[String:Any]) -> String {
        guard let jsonData = try? JSONSerialization.data(withJSONObject: dic, options: []),
              let strJson = String(data: jsonData, encoding: .utf8)
        else {
            return ""
        }
        return strJson
    }
    

    通过上面一系列处理,我们拿到了想要上报的字典数据,然后调用dataReport方法来进行上报。

    var errorDictionary:[String:Any] = [:]
    errorDictionary["type"] = LogErrorType.requestError.rawValue
    errorDictionary["createTime"] = LogTool.default.getTimeStamp()
    errorDictionary["page"] = page
    errorDictionary["method"] = requestMethod.rawValue
    if requestMethod.rawValue.lowercased() == "get"  {
        let newUrl = LogTool.default.urlAddCompnentByGetMethod(url,requestParameter)
        errorDictionary["url"] = newUrl
    } else {
        errorDictionary["url"] = url
        errorDictionary["params"] = LogTool.default.dicToJson(requestParameter)
    }
    errorDictionary["code"] = responseCode
    errorDictionary["msg"] = describe
    
    dataReport([errorDictionary])
    

    2、底层数据上报方法的实现

    当我们拿到需要上报的网络请求错误信息后将其传入给了dataReport方法,接下来我们看看该方法是如何实现的,该方法虽然参数很多,但都提供了一套默认值,这里我们只传入了第一个参数errors错误信息列表。

    /// 数据上报
    /// - Parameters:
    ///   - errors: 上报错误列表
    ///   - customs: 上报的埋点数据
    ///   - events: 上报的埋点事件
    ///   - times: 重试次数
    ///   - identifier: 标识Id
    func dataReport(_ errors:[[String:Any]] = [],
                    customs:[[String:Any]] = [],
                    events:[[String:Any]] = [],
                    times:Int = 0,
                    identifier:String = String(LogTool.default.getTimeStamp())) {
        ...
    }
    

    倘若数据上报失败,我们会尝试重新进行上报,倘若重复3次仍然失败那么就放弃上报该事件。

    let nextTimes = times + 1
    guard nextTimes < 4 else {
        return
    }
    

    在上报之前,我们还需要获取完整的url地址,由域名加path构成,倘若使用了自定义域名则使用该值。

    let url:String = (environment == HostType.custom ? customDomain + reportPath : environment.rawValue + reportPath)
    
    /// 请求环境
    public enum HostType:String {
        case dev = "https://api-dev.doctorwork.com/"
        case qa = "https://api-qa.doctorwork.com/"
        case pre = "https://api-pre.doctorwork.com/"
        case prod = "https://api.doctorwork.com/"
        case custom = ""
    }
    /// 自定义域名
    public  var customDomain:String = ""
    

    environmentcustomDomainreportPath这3个变量是在哪里进行赋值的呢?environmentreportPath变量可以使用默认值。至于customDomain变量,我们添加了setBaseUrl方法,用来设置自定义域名和路径提供给外界使用。

    fileprivate var environment = HostType.dev
    fileprivate var reportPath = "web-monitor/api/v1/native/report"
    

    environmentappId一起在setWithAppId方法中进行设置。上报的时候还需要获取一些公共参数,这些公共参数就是系统信息,我们在该方法中也设置了这些公共参数。

    fileprivate var publicParameters:[String:Any] = [:]
    
    func setWithAppId(_ appId:String, channel:String = "App Store", environment:HostType = .dev){
        self.appId = appId
        self.environment = environment
        
        let systemInfo = LogTool.default.getSystemInfo()
        self.publicParameters["appId"] = appId
        self.publicParameters["uuid"] = systemInfo["uuid"]
        self.publicParameters["channel"] = channel
        self.publicParameters["model"] = systemInfo["model"]
        self.publicParameters["brand"] = "Apple"
        self.publicParameters["system"] = systemInfo["systemName"]
        self.publicParameters["systemV"] = systemInfo["systemVersion"]
        self.publicParameters["appV"] = systemInfo["appVersion"]
        self.publicParameters["appStartTime"] = LogTool.default.getTimeStamp()
    }
    

    getSystemInfo用来获取系统的相关信息。

    public func getSystemInfo() -> [String:String] {
        let uuid = UIDevice.current.identifierForVendor?.uuidString ?? ""
        let model = UIDevice.current.model
        let systemName = UIDevice.current.systemName
        let systemVersion = UIDevice.current.systemVersion
        var systemDic:[String:String] = ["uuid":uuid,
                                         "model":model,
                                         "systemName":systemName,
                                         "systemVersion":systemVersion]
        
        if let infoDictionary = Bundle.main.infoDictionary {
            let bundleIdentifier = infoDictionary["CFBundleIdentifier"] as? String ?? ""
            let bundleName = infoDictionary["CFBundleName"] as? String ?? ""
            let appVersion = infoDictionary["CFBundleShortVersionString"] as? String ?? ""
            let appBuild = infoDictionary["CFBundleVersion"]as? String ?? ""
            let infoDic:[String:String] = ["bundleIdentifier":bundleIdentifier,
                                           "bundleName":bundleName,
                                           "appVersion":appVersion,
                                           "appBuild":appBuild]
            systemDic.merge(infoDic) { (keyValue, param) -> String in
                return keyValue
            }
        }
    
        return systemDic;
    }
    

    我们再将上报错误列表、上报的埋点数据、上报的埋点事件、公共参数4个字典合并在了一起,作为最终上报的参数字典。

    var body = publicParameters;
    body.merge(["errors":errors,"customs":customs,"events":events]) { (keyValue, param) -> Any in
        return keyValue
    }
    

    最后一步就是调用request请求方法来进行上报日志,倘若未上报成功,延迟10秒之后重新进行上报。

    LogNetwork.default.request(url, method: RequestHttpMethod.post, parameters: body) { [weak self](data, response, error) in
        let dataString = String.init(data: data ?? Data.init(), encoding: String.Encoding.utf8);
        guard let weakSelf = self, dataString != "OK" else { return }
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 10) {
            weakSelf.dataReport(errors, customs: customs,events: events, times: nextTimes, identifier: identifier)
        }
    }
    

    这里传入了HTTP的请求方式,总共包括以下几种方式。

    /// 请求类型
    public enum RequestHttpMethod:String {
        case connect = "CONNECT"
        case delete = "DELETE"
        case get = "GET"
        case head = "HEAD"
        case options = "OPTIONS"
        case patch = "PATCH"
        case post = "POST"
        case put = "PUT"
        case trace = "TRACE"
    }
    

    为了将错误信息上报到服务器,所以我们需要使用Post进行网络请求,这里直接使用了系统提供的URLSession。在request方法中我们传入了url、请求参数、请求头、完成回调,然后判断了url是否存在,不存在就直接返回,存在就通过异步的方式发起了网络请求。

    func request(_ urlString:String, method:RequestHttpMethod, headers:[String:String] = [:], parameters:[String:Any]?, complete:requestComplete?) {
        
        guard let url = URL.init(string: urlString) else {
            if complete != nil { complete!(nil,nil,nil) }
            return
        }
        
        DispatchQueue.global().async {
            ...
        }
    }
    

    接下来就是使用系统URLSession进行网络请求的模版,我们根据URL构建了URLRequest并设置了其请求头和请求方法。

    var request:URLRequest = URLRequest.init(url: url);
    request.httpMethod = method.rawValue
    request.allHTTPHeaderFields = headers
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    

    有一点需要注意的是我们根据请求方式的不同,来决定请求参数如何添加到request之中,倘若是getheaddelete这几种请求方式就将请求参数直接拼接在了url后面,倘若是其他请求方式比如说post,那么就将请求参数变成json字符串放入到请求体中。

    let encodeRequest = self.encodeParameters(parameters, into: request)
    
    fileprivate func encodeParameters(_ parameters: [String:Any]?, into request: URLRequest) -> URLRequest {
        guard let parameters = parameters else { return request }
        guard let url = request.url else { return request }
        guard let method = request.httpMethod else { return request }
        
        var request = request
        if [RequestHttpMethod.get.rawValue, RequestHttpMethod.head.rawValue, RequestHttpMethod.delete.rawValue].contains(method),
           var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
            
            components.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value as? String) };
            guard let newURL = components.url else { return request }
            request.url = newURL
        } else {
            if (JSONSerialization.isValidJSONObject(parameters)){
                request.httpBody = try? JSONSerialization.data(withJSONObject: parameters, options: []);
            }
        }
        
        return request
    }
    

    我们可以打印看看当把参数编进request请求之后,urlhttpMethodhttpBody都变成了什么值。

    print("request.url => \(String(describing: encodeRequest.url))")
    print("request.httpMethod => \(String(describing: encodeRequest.httpMethod))")
    print("request.httpBody => \(String(describing: encodeRequest.httpBody))")
    

    接下来就是使用URLSession来创建请求任务进行网络请求了。通过requestComplete闭包我们将数据回传了出去。

    typealias requestComplete = ((Data?, URLResponse?, Error?) -> Void)
    let session:URLSession = URLSession.shared
    
    let task:URLSessionTask = self.session.dataTask(with: encodeRequest) { (data, response, error) in
        DispatchQueue.main.async {
            if complete != nil { complete!(data,response,error) }
        }
    }
    task.resume()
    

    同样地这里我们也可以将响应信息打印出来看看都是什么。

    print("response.data => \(String(describing: String.init(data: data ?? Data.init(), encoding: String.Encoding.utf8)))")
    print("response.response => \(String(describing: response))")
    print("response.error => \(String(describing: error))")
    

    3、上报其他日志类型

    我们之前是以HTTP请求错误为例来分析了如何实现日志上报的工具类封装,现在我们来考虑一下其他上报类型,包括上报业务请求错误、上报图片加载失败、上报自定义错误等类型。

    上报业务请求错误

    上报业务请求的方式基本同前面上报HTTP错误基本一次,只是在入参中多了一项业务状态码。

    /// 上报业务请求错误
    /// - Parameters:
    ///   - url: 请求URL
    ///   - requestParameter: 请求参数
    ///   - businessCode: 业务状态码
    ///   - requestMethod: 请求方式
    ///   - page: 请求页面
    ///   - describe: 错误描述
    open func reportBusinessError(_ url:String,
                                  requestParameter:[String:Any],
                                  businessCode:Int,
                                  requestMethod:RequestHttpMethod = RequestHttpMethod.get,
                                  page:String = "",
                                  describe:String = ""){
        ...
    }
    

    由于上报参数存在和之前上报HTTP存在重复,所以我们考虑直接将拼接请求参数这一块代码封装成为一个方法直接调用。

    fileprivate func mergeRequestParameters(_ url:String,
                                            requestParameter:[String:Any],
                                            requestMethod:RequestHttpMethod = RequestHttpMethod.get,
                                            page:String = "",
                                            describe:String = "") -> [String:Any] {
        var errorDictionary:[String:Any] = [:]
        errorDictionary["createTime"] = LogTool.default.getTimeStamp()
        errorDictionary["page"] = page
        errorDictionary["method"] = requestMethod.rawValue
        if requestMethod.rawValue.lowercased() == "get"  {
            let newUrl = LogTool.default.urlAddCompnentByGetMethod(url,requestParameter)
            errorDictionary["url"] = newUrl
        } else {
            errorDictionary["url"] = url
            errorDictionary["params"] = LogTool.default.dicToJson(requestParameter)
        }
        errorDictionary["msg"] = describe
        
        return errorDictionary
    }
    

    这样在使用的时候只需要对返回的字典添加每种上报类型中独有的部分即可,比如上报业务错误就变成了。

    var errorDictionary = mergeRequestParameters(url, requestParameter: requestParameter, requestMethod: requestMethod, page: page, describe: describe)
    errorDictionary["type"] = LogErrorType.businessError.rawValue
    errorDictionary["code"] = businessCode
    self.dataReport([errorDictionary])
    

    在网络请求成功,但是仍然出现错误的情况下,就可能是由于我们的业务出现了错误,这时候就可以调用该方法来上报错误信息。

    switch response.result {
    case .success:
        let ret: (data: Any?, error: NetWorkingError?)!
        if ret.error == nil {
            success?(ret.data)
        } else {
            let error = ret.error!
            failure?(error)
            LogReport.default.reportBusinessError(path,
                                                  requestParameter: parameters ?? [:],
                                                  businessCode: Int(error.code),
                                                  requestMethod: requestHttpMethod,
                                                  describe: error.serviceMessage)
        }
    case .failure(let error):
    
    上报图片加载失败
    /// 上报图片加载失败
    /// - Parameters:
    ///   - url: 请求URL
    ///   - page: 请求页面
    ///   - describe: 错误描述
    open func reportImageError(_ url:String,
                               page:String = "",
                               describe:String = ""){
        if self.isInitAppId() {
            print("not set `AppId`,stop report`reportImageError`")
            return
        }
        var errorDictionary:[String:Any] = [:]
        errorDictionary["type"] = LogErrorType.imageError.rawValue
        errorDictionary["createTime"] = LogTool.default.getTimeStamp()
        errorDictionary["page"] = page
        errorDictionary["url"] = url
        errorDictionary["msg"] = describe
        self.dataReport([errorDictionary])
    }
    
    上报自定义错误
    /// 上报自定义错误
    /// - Parameters:
    ///   - page: 请求页面
    ///   - describe: 错误描述
    open func reportCustomsError(_ page:String, describe:String){
        if self.isInitAppId() {
            print("not set `AppId`,stop report`reportCustomsError`")
            return
        }
        var errorDictionary:[String:Any] = [:]
        errorDictionary["type"] = LogErrorType.customError.rawValue
        errorDictionary["createTime"] = LogTool.default.getTimeStamp()
        errorDictionary["page"] = page
        errorDictionary["msg"] = describe
        self.dataReport([errorDictionary])
    }
    
    自定义埋点
    /// 自定义埋点
    /// - Parameters:
    ///   - key: 埋点Key
    ///   - dictionary: 埋点数据
    open func reportPointLog(_ key:String, dictionary:[String:Any]){
        if self.isInitAppId() {
            print("not set `AppId`,stop report`reportPointLog`")
            return
        }
        var customDictionary:[String:Any] = [:]
        customDictionary["name"] = key
        customDictionary["content"] = dictionary
        customDictionary["createTime"] = LogTool.default.getTimeStamp()
        self.dataReport(customs:[customDictionary])
    }
    
    App启动耗时上报
    /// App启动耗时上报
    /// - Parameters:
    ///   - isHot: 是否是热启动
    ///   - page: 当前页
    ///   - duration: 耗时时间 单位:(毫秒 ms)
    open func reportStartUpEvent(isHot:Bool,page:String,duration:CLongLong) {
        if self.isInitAppId() {
            print("not set `AppId`,stop report`reportPointLog`")
            return
        }
        var eventDictionary:[String:Any] = [:]
        eventDictionary["page"] = page
        eventDictionary["event"] = isHot ? "hot-start" :"cold-start"
        eventDictionary["duration"] = duration
        eventDictionary["createTime"] = LogTool.default.getTimeStamp()
        self.dataReport(events: [eventDictionary])
    }
    

    针对于App启动耗时,我们添加了一个启动时间记录类LogStartTimeMonitor,在其中定义了4个属性分别记录下冷启动和热启动的开始和结束时间。调用recordStartTime方法在APP启动的时候开始记录时间。

    /// 恢复到初始化状态
    public class func restore() {
        LogStartTimeMonitor.default.hotStartTime = 0.0
        LogStartTimeMonitor.default.hotEndTime = 0.0
        LogStartTimeMonitor.default.coldStartTime = 0.0
        LogStartTimeMonitor.default.coldEndTime = 0.0
    }
    
    /// APP启动开始记录时间
    /// - Parameters:
    ///   - isHot: 是否是热启动
    public class func recordStartTime(isHot:Bool) {
        LogStartTimeMonitor.restore()
        if isHot {
            LogStartTimeMonitor.default.hotStartTime = CFAbsoluteTimeGetCurrent()
        } else {
            LogStartTimeMonitor.default.coldStartTime = CFAbsoluteTimeGetCurrent()
        
        }
    }
    

    调用recordHotEndTime方法来记录下APP热启动结束记录时间,并在该方法中调用日志上报方法上报了热启动时间。

    /// APP热启动结束记录时间
    /// - Parameters:
    ///   - isReport: true 上报  false 不上报
    public class func recordHotEndTime(isReport:Bool) {
        if LogStartTimeMonitor.default.hotStartTime == 0.0 { return }
        
        LogStartTimeMonitor.default.hotEndTime = CFAbsoluteTimeGetCurrent()
        let duration = (LogStartTimeMonitor.default.hotEndTime - LogStartTimeMonitor.default.hotStartTime) * 1000
        print( "本次热启动" +  " App启动总共耗时为:\(duration) 毫秒")
        
        // 上报启动时间
        if isReport {
            LogReport.default.reportStartUpEvent(isHot: true, page: "", duration: CLongLong(duration))
        }
        
        // 复原
        LogStartTimeMonitor.default.hotStartTime = 0.0
        LogStartTimeMonitor.default.hotEndTime = 0.0
    }
    

    冷启动时间的统计方式也是一样的。

    /// APP冷启动结束记录时间
    /// - Parameters:
    ///   - isReport: true 上报  false 不上报
    public class func recordColdEndTime(isReport:Bool) {
        if LogStartTimeMonitor.default.coldStartTime == 0.0 { return }
    
        LogStartTimeMonitor.default.coldEndTime = CFAbsoluteTimeGetCurrent()
        let duration = (LogStartTimeMonitor.default.coldEndTime - LogStartTimeMonitor.default.coldStartTime) * 1000
        print( "本次冷启动" +  " App启动总共耗时为:\(duration) 毫秒")
        
        // 上报启动时间
        if isReport {
            if duration <= 60 * 1000 {
                LogReport.default.reportStartUpEvent(isHot: false, page: "", duration: CLongLong(duration))
            }
        }
        
        //复原
        LogStartTimeMonitor.default.coldStartTime = 0.0
        LogStartTimeMonitor.default.coldEndTime = 0.0
    }
    
    上报网络请求性能
    /// SDK 上报网络请求性能
    /// - Parameters:
    ///   - url: 地址
    ///   - requestParameter: 参数
    ///   - requestMethod: 请求方法
    ///   - responseCode: code
    ///   - responseSize: 响应体大小
    ///   - responseTid: http响应头返回的服务端traceId
    ///   - duration: 耗时 (毫秒 ms)
    open func reportHttpPerformanceEvent(_ url:String,
                                         requestParameter:Any?,
                                         requestMethod:RequestHttpMethod = RequestHttpMethod.get,
                                         responseCode:Int,
                                         responseSize:Int,
                                         responseTid:String,
                                         duration:CLongLong){
        
        if LogReport.default.openNetWorkPerformance == false { return }
        if LogReport.default.networkElapsedTimeExceed != 0 && LogReport.default.networkElapsedTimeExceed >= duration { return }
        if self.isInitAppId()() {
            print("not set `AppId`,stop report`reportHttpError`")
            return
        }
        
        var eventDictionary:[String:Any] = [:]
        eventDictionary["createTime"] = LogTool.default.getTimeStamp()
        eventDictionary["duration"] = duration
        eventDictionary["reqUrl"] = url
        eventDictionary["reqMethod"] = requestMethod.rawValue
        if let dic = requestParameter as? [String : Any] {
            eventDictionary["reqParam"] = LogTool.default.dicToJson(dic)
        }
        eventDictionary["resCode"] = responseCode
        eventDictionary["resSize"] = responseSize
        eventDictionary["resTid"] = responseTid
        eventDictionary["event"] = "http-perf"
        
        self.dataReport(events: [eventDictionary])
    }
    

    openNetWorkPerformance属性表示是否开启网络上报功能,默认是关闭此功能的。networkElapsedTimeExceed属性表示在开启网络上报的情况下,网络请求超过设置时间,才开启上报。

    fileprivate var openNetWorkPerformance = false
    fileprivate var networkElapsedTimeExceed:CLongLong = 1500
    

    我们可以通过openHttpPerformanceEvent方法来设置这两个属性值。

    /// 是否开启网络上报功能 默认是关闭此功能的
    /// - Parameters:
    ///   - open: true:开启  false:不开启
    ///   - timeExceed: 在开启网络上报的情况下,网络请求超过设置时间,才开启上报。单位:毫秒(ms)。 默认-1,表示都上报
    open func openHttpPerformanceEvent(open:Bool,timeExceed:CLongLong) {
        LogReport.default.openNetWorkPerformance = open
        LogReport.default.networkElapsedTimeExceed = timeExceed
    }
    

    针对于网络性能的上报,我们可以在request.responseJSON回调中调用此方法来进行上报。

    let duration = res.timeline.totalDuration * 1000
    if let traceId = res.request?.allHTTPHeaderFields?["X-Trace-Id"] { tid = traceId }
    
    LogReport.default.reportHttpPerformanceEvent(path, requestParameter: parameters, requestMethod: (RequestHttpMethod(rawValue: method.rawValue) ?? RequestHttpMethod.get), responseCode: (res.response?.statusCode ?? 200), responseSize: res.data?.count ?? 0, responseTid: tid, duration: CLongLong(duration))
    

    二、网络请求库 Alamofire

    1、外层调用的网络请求方法 apiGet 的实现

    当我们使用绑定医生接口进行网络请求的时候,使用方式是这样的。我们直接使用XJPHTTPClient.apiGet来进行了网络请求,现在我们就来看看apiGet方法究竟是如何实现的?

    var isBindDoctor: Bool? = nil
    func loadHomeBindDoctorList(completion: (() -> Void)? = nil) {
        XJPHTTPClient.apiGet(path: Constant.webServicePath.home_bind_doctor_list, parameters: nil) { [weak self](data) in
            print(data as Any)
            
            guard let json = data as? [String: Any] else{return}
            guard let list = json["list"] as? Array<Any> else{return}
            self?.isBindDoctor = list.count > 0
            completion?()
        } failure: { [weak self](error)  in
            self?.isBindDoctor = nil
            completion?()
        }
    }
    

    XJPHTTPClient是对我们二次封装的XJPNetWorkingClient库的重命名,在AppDelegate中我们书写了这一行代码,让其在全局方便使用。

    typealias XJPHTTPClient = XJPNetWorkingClient
    

    接下来我们在XJPHTTPClient的扩展中实现了上面我们使用到的apiGet方法。该方法用来进Get请求,需要传入请求URL、请求参数、成功和失败的回调。其底层使用的是XJPNetWorkingClient网络请求库的constructHttpDataRequest方法,返回的是一个Alamofire.DataRequest?,不过可以通过@discardableResult忽略返回值不弹出警告。

    extension XJPHTTPClient
    
    @discardableResult
    static func apiGet(path: String,
                       parameters: [String : Any]?,
                       success: HTTPSuccess?,
                       failure : HTTPFailure?,
                       cache : HTTPCache? = nil) -> Alamofire.DataRequest?{
        
        return constructHttpDataRequest(baseURL: kApiServiceHost,
                                        path: path,
                                        method: .get,
                                        parameters: parameters,
                                        success: success,
                                        failure: failure,
                                        cache: cache)
    }
    

    上面展示的是Get请求的实现,那么Post请求又当如何呢?基本类似,只是将请求方法替换成了Post,并且提供了参数编码的方式而已,默认的参数编码方式为JSONEncoding

    @discardableResult
    static func apiPost(path: String,
                        parameters: [String : Any]?,
                        encoding:ParameterEncoding = JSONEncoding.default,
                        success: HTTPSuccess?,
                        failure : HTTPFailure?,
                        cache : HTTPCache? = nil ) -> Alamofire.DataRequest? {
        return constructHttpDataRequest(baseURL: kApiServiceHost,
                                        path: path,
                                        method: .post,
                                        parameters: parameters,
                                        success: success,
                                        failure: failure,
                                        encoding: encoding,
                                        cache: cache)
    }
    

    上面所涉及到的请求成功HTTPSuccess、失败failure、还有缓冲的回调HTTPCache都是来自于我们二次封装的网络请求库XJPNetWorkingClient,而ParameterEncodingJSONEncoding则来自于最初的网络请求库Alamofire


    2、拼接请求的URL

    现在来到我们真正要进行Alamofire库二次封装的网络库XJPNetWorkingClient中。

    open class XJPNetWorkingClient 
    

    接着前面所讲的apiGet请求方法,其底层所使用的就是XJPNetWorkingClient工具库中的constructHttpDataRequest方法,现在我们来到这个最核心的方法看下它是如何实现的。

    public static func constructHttpDataRequest(baseURL: String,
                                                path: String,
                                                method: HTTPMethod,
                                                parameters: [String : Any]?,
                                                success: HTTPSuccess?,
                                                failure : HTTPFailure?,
                                                encoding: ParameterEncoding = URLEncoding.default,
                                                cache:HTTPCache? = nil)
    -> Alamofire.DataRequest? {
        ...
        return request
    }
    

    constructHttpDataRequest方法中,我们的入参中传入了baseURLpath,所以我们要做的第一件事情就是将两者拼装起来成为请求的URL。在拼装方法中我们加入了一个预防措施来保证baseURLpath之间一定会存在一个/

    public static func joinURL(baseURL: String, path: String) -> String {
        let pathSeparator = !baseURL.hasSuffix("/") && !path.hasPrefix("/") ? "/" : ""
        return baseURL + pathSeparator + path
    }
    

    3、拼接网络请求的公共参数

    获取APP的基本信息

    在进行网络请求的时候,除了我们在入参中传入的请求参数之外,还需要带上一些公共参数,这些公共参数包括APP的基本信息,登陆时候获取到的最新session信息等。首先我们来设置一下请求所需要包含的APP的基本信息,在XJPNetWorkingInfo类中我们创建了一个paramsForWebservice懒加载属性用来提供这些信息。

    lazy var paramsForWebservice: [String: String] = {
        var params = [
            "sys_p": XJPNetWorkingInfo.shared.sysp,
            "sys_v": XJPNetWorkingInfo.shared.sysv,
            "sys_vc": XJPNetWorkingInfo.shared.sysv,
            "cli_v": XJPNetWorkingInfo.shared.cliv ?? "",
            "cli_c": "xiejiapei",
            "sys_m": XJPNetWorkingInfo.shared.sysm(),
            "sys_d": XJPNetWorkingInfo.shared.sysd,
            "sys_pkg": "app"
        ]
        return params
    }()
    

    其中sysp表示iOS设备,sysv表示系统版本,cliv表示app版本。sysm表示设备型号,比如Simulator或者iPhone12 Pro MAX,获取sysm的方法我们在UIDevice的扩展中写过,这里不再赘述。

    let sysp = "i"// ios设备
    let sysv = UIDevice.current.systemVersion// 系统版本
    let cliv: String? = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String// app版本
    // 设备型号
    func sysm() -> String 
    

    至于sysd则表示的是UUID,我们可以通过Keychain来保存获取到的UUID下次直接取用。

    import KeychainAccess
    let kHttpAppKeyChainServiceName = "net.xiejiapei.GenericKeychain"
    let kHttpAppKeyChainUUID = "UUID"
    
    var sysd: String = {
        let keychain = Keychain(service: kHttpAppKeyChainServiceName)
        var uuid = keychain[kHttpAppKeyChainUUID] ?? ""
        if uuid == ""{
            let strUUID = UIDevice.current.identifierForVendor?.uuidString ?? ""
            keychain[kHttpAppKeyChainUUID] = strUUID
            uuid = strUUID
        }
        return uuid
    }()
    
    获取到的最新session信息

    以上就是我们在请求时候需要拼接的公共参数,除此之外,我们还需要将登陆时候获取到的最新session信息等也添加到公参中来,这部分信息是交由外界来提供的,所以我们可以通过委托的方式来实现,倘若外界实现了委托,那么就将最新信息添加进来。

    public protocol XJPNetWorkingClientDelegate: NSObjectProtocol {
        /// 更新session信息
        func updateSessParams() -> [String:String]?
    }
    
    weak public var delegate: XJPNetWorkingClientDelegate?
    

    这样在登陆的时候就实现该委托,传入我们的session信息。

    extension LoginUser: XJPNetWorkingClientDelegate
    
    func updateSessParams() -> [String:String]? {
        if let sessionId =  LoginUser.shared.sessionId {
            return ["sess": sessionId,
                    "X-Sess-Name":sessionId,
                    "sessName":sessionId,
                    "sessionId":sessionId,
                    "X-Sess-Xp":"orthopaedicsApp",
                    "x_platform":"orthopaedicsApp",
                    "mplatform":"orthopaedicsApp",
                    "sys_vc":AppInfo.shared.sysv]
        } else {
            return [
                "X-Sess-Xp":"orthopaedicsApp",
                "x_platform":"orthopaedicsApp",
                "mplatform":"orthopaedicsApp",
                "sys_vc":AppInfo.shared.sysv]
        }
    }
    
    实现拼接网络请求参数的方法

    获取到上面的两部分信息之后,我们就可以实现拼接请求参数的方法了。我们将APP信息、session信息、传入的参数全部拼接在了一个字典之中。

    fileprivate static func unionParams(parameters: [String: Any]?) -> [String : Any] {
        var mustParams = XJPNetWorkingInfo.shared.paramsForWebservice
        if let updateSessParams =  XJPNetWorkingClient.share.delegate?.updateSessParams() {
            for (key,value) in updateSessParams {
                mustParams[key] = value
            }
        }
        var unionParams = parameters ?? [String: Any]()
        for (key, value) in mustParams {
            unionParams[key] = value
        }
        return unionParams
    }
    
    获取URLRequest的请求头HTTPHeaders

    除了需要为请求参数添加公共参数,我们也可以在请求头HTTPHeaders中添加一些公共的请求参数,这些请求参数大体同上。

    public static func unioheadParams() -> [String: String] {
        var params = [
            "X-Analyse-Sp": XJPNetWorkingInfo.shared.sysp,
            "X-Analyse-Sv": XJPNetWorkingInfo.shared.sysv,
            "X-Analyse-Osv": XJPNetWorkingInfo.shared.sysv,
            "X-Analyse-Av": XJPNetWorkingInfo.shared.cliv ?? "",
            "X-Analyse-C": "medlinker",
            "X-Analyse-Sm": XJPNetWorkingInfo.shared.sysm(),
            "X-Analyse-Cd": XJPNetWorkingInfo.shared.sysd,
            "X-Analyse-Spk": "app",
            "authorization":"Basic bWVkbGlua2VyOm1lZDEyMzQ1Ng=="
        ]
        
        if let updateSessParams =  XJPNetWorkingClient.share.delegate?.updateSessParams() {
            for (key,value) in updateSessParams {
                params[key] = value
            }
        }
        return params
    }
    

    4、创建URLRequest请求

    经过前面两步我们就获取到了拼接公共参数后到请求参数、请求头、请求URL,现在我们需要将这些信息用起来创建URLRequest请求。Alamofire通过sessionManager来创建request请求,所以我们需要创建SessionManager

    这里设置了URLSessionConfiguration中的两个属性,timeoutIntervalForRequest是表示在下载过程中,如果某段时间之内一直都没有接收到数据,那么就认为超时。timeoutIntervalForResource是表示数据没有在指定的时间里面加载完,默认值是7天。举个例子就是,如果你要下一个10G的数据,timeoutIntervalForResource设置成7天的话,你的网速特别慢:0.1k/s,7天都没下载完,那就超时了。虽然整个过程中,你一直在源源不断地下载。如果你要下一个10G的数据,timeoutIntervalForRequest设置为20秒的话,下的过程中有超过20s的时间段并没有数据过来,那么这时候就也算超时。httpAdditionalHeaders此属性指定基于此配置添加到会话内所有任务的附加头。例如,您可以设置User Agent以便它自动包含在应用程序通过基于此配置的会话发出的每个请求中。

    static let sessionManager: SessionManager = {
        let configuration = URLSessionConfiguration.default
        configuration.httpAdditionalHeaders = AF.sessionConfiguration.httpAdditionalHeaders
        configuration.timeoutIntervalForRequest = 30
        return SessionManager(configuration: configuration)
    }()
    

    接着我们就可以通过SessionManager来创建request对象。

    let url = joinURL(baseURL:baseURL,path:path)
    let params: [String: Any] = unionParams(parameters: parameters)
    let headParams: [String: String] = unioheadParams()
    let headers = HTTPHeaders(headParams)
    let request = sessionManager.request(url,
                                         method: method,
                                         parameters: params,
                                         encoding: encoding,
                                         headers: headers)
    

    创建好request对象之后我们可以为其设置请求响应之后需要进行的处理操作。我们将相关操作封装在了handleResponse方法之中。

    request.responseJSON(completionHandler: {(response) in
         handleResponse(path: url,
                        parameters: parameters,
                        response: response,
                        success: success,
                        failure: failure,
                        cache: cache)
    })
    

    处理完了request.responseJSON方法之后,我们可以获取到dataManager之前存储的缓存,将其置于cache闭包中进行回调。

    if let cacheBlock = cache {
        let cacheParams = getCacheParams(parameters: parameters)
        dataManager.fetchLocalData(path: url, params: cacheParams) { json in
            if let jsonAny = json {
                DispatchQueue.main.async {
                    cacheBlock(jsonAny)
                }
            }
        }
    }
    

    这里有一个关于缓存需要注意的点,这里是只有当if cache != nil的时候才会将请求数据缓存到本地,而默认的cache : HTTPCache? = nil是为nil,而我们在请求的时候一般只传入了成功和失败的回调,也就是大多数情况下这里的缓存根本就不会被用上。


    5、处理请求响应的信息

    handleResponse方法比较长,涉及到的内容比较多,我们来逐步分析。

    fileprivate static func handleResponse(path: String,
                                           parameters: [String: Any]?,
                                           response: AFDataResponse<Any>
                                           success: HTTPSuccess?,
                                           failure: HTTPFailure?,
                                           cache:HTTPCache? = nil) {
        ...
    }
    
    请求成功、失败、缓存、结果闭包

    handleResponse方法中我们看到了HTTPSuccess等,这些都是下列闭包的重命名。

    public typealias HTTPSuccess = (_ response: Any?) -> ()
    public typealias HTTPFailure = (_ error: XJPNetWorkingError) -> ()
    public typealias HTTPResult = (_ rs: [String: Any]) -> ()
    public typealias HTTPCache = (_ response: Any?) -> ()
    
    网络错误信息

    我们创建了一个继承自ErrorXJPNetWorkingError类,在该类中定义了一系列与网络错误有关的情况。

    open class XJPNetWorkingError : Error
    

    在这个类中我们提供了错误消息message和错误码code给外界使用。

    private var _message: String = ""
    public var code: Int64 = 0
    public var message: String {
         if _message.contains("system") || _message.contains("error") || _message.contains("sql") || _message.contains("system error") {
             return  "服务器开小差了"
         }
         return _message
     }
    

    在一系列的初始化方法中我们对这两个属性进行了赋值。

    public init(code: Int64) {
         self.code = code
         self._message = XJPNetWorkingError.codeToString["\(code)"] ?? "服务器开小差了"
     }
    
    public init(errorCode: XJPNetWorkingErrorCode) {
         self.code = errorCode.rawValue
         self._message = XJPNetWorkingError.codeToString["\(errorCode.rawValue)"] ?? "服务器开小差了"
     }
    
    public init(code: Int64, message: String) {
         self.code = code
         self._message = message
     }
    
    public init?(error: NSError?) {
         if error == nil {
             return nil
         }
         self.code = Int64(error!.code)
         self._message = error!.localizedDescription
     }
    

    XJPNetWorkingErrorCode是一个枚举,在其中我们列举出了与后端协定的网络请求过程中项目遇到的一系列错误码,下面列举出来了几个例子。

    public enum XJPNetWorkingErrorCode: Int64 {
        case MedNumberUsed = 777777 //该号已被使用
        case SensitiveMessage = 88001 //消息敏感词汇
        case LoginNoAccount = 20005//用户未注册
        case LoginWrongPassword = 20006//密码不对
        ...
    }
    

    codeToString会根据错误码给出相匹配的错误提示文本。

    public static let codeToString: [String: String] = [
        "-1": "请检查网络连接",
        "-2": "服务器开小差了",
        "-999": "请求被取消",
        ...
    ]
    
    请求失败的处理

    根据请求结果,我们分别对请求成功和失败两种情况进行处理。

    switch response.result {
    case .success:
        ...
    case .failure(let error):
        ...
    }
    

    当进入到case .failure(let error):的时候就表示请求失败了。获取到错误状态码和错误信息来创建Error对象,将其通过failure闭包回调出去。

    case .failure(let error):
        let code = (error as NSError).code
        let message = XJPNetWorkingError.codeToString["\(code)"] ?? "服务器开小差了"
        let error = XJPNetWorkingError(code: Int64(code), message: message)
        postFailureResponseNotification(code: Int64(code))
        failure?(error)
    }
    

    这时候额外需要的处理方式为提取错误码判断是否是登录session过期、服务器是否在维护中或者是特殊的业务代码,并发送相应通知来进行处理。

    fileprivate static func postFailureResponseNotification(code: Int64) {
        if let errorCode = XJPNetWorkingErrorCode(rawValue: code) {
            switch errorCode {
            case .ServiceNoLogin,
                    .ServiceNoAuthen,
                    .ServiceNeedLogin,
                    .ServiceNeedLogin101,
                    .ServiceNeedLogin102,
                    .ServiceNeedLogin401,
                    .ServiceUpdateSession:
                NotificationCenter.default.post( name: Notification.Name(rawValue: "SessionExpired"), object: nil)
                break
            case .ServiceSystemStop:// 服务器维护中
                NotificationCenter.default.post( name: Notification.Name(rawValue: "SystemMaintaining"), object: nil)
                break
            default:// 特殊的业务代码回调
                NotificationCenter.default.post(name: Notification.Name(rawValue: "BussinessMaintaining"), object: nil, userInfo: ["code":errorCode])
                break
            }
        }
    }
    
    请求成功的处理
    case .success:
        let ret = handleSuccessResponse(response: response)
        if ret.error == nil {
            success?(ret.data)
            if cache != nil {
                let cacheParams = getCacheParams(parameters: parameters)
                dataManager.save(path: path, params: cacheParams, data: ret.data)
            }
        } else {
            let error = ret.error!
            failure?(error)
        }
    

    在请求成功的时候我们先获取请求结果的值,将其转化为字典,再从字典中获取到请求错误码。倘若我们并不能获取到结果的值,那就传入错误信息为networkConnectErrorCode,这是我们新增的,表示网络连接出错,状态码为-1。

    fileprivate static let responseErrorCodeKey = "errcode"
    
    let value = try? response.result.get()
    let ret: (data: Any?, error: XJPNetWorkingError?)!
    
    if let jsonObj = value as? [String: Any], let errCode = jsonObj[XJPNetWorkingClient.responseErrorCodeKey] as? Int64 {
        ...
    }  else {
        let error = XJPNetWorkingError(code: XJPNetWorkingError.networkConnectErrorCode)
        ret = (data: nil, error: error)
    }
    

    倘若顺利拿到了请求到的jsonObj的值,我们就用httpResultBlock闭包将该值传递出去。当状态码为0和200的时候表示请求成功了,我们就将jsonObj中的data数据传给ret

    public var httpResultBlock: HTTPResult?
    
    XJPNetWorkingClient.share.httpResultBlock?(jsonObj)
    if errCode == 0 || errCode == 200 {
        ret = (data: jsonObj["data"], error: nil)
    } else {
        ...
    }
    

    倘若并未状态码并非上述两个,那么就同前面失败的处理方式一样提取错误码判断是否是登录session过期、服务器是否在维护中或者是特殊的业务代码,并发送相应通知来进行处理。获取到错误状态码和错误信息来创建Error对象,将其赋值给ret

    if errorCode == 0 || errorCode == 200 {
        ...
    } else {
        let errorMessage = jsonObj[XJPNetWorkingClient.responseErrorMessageKey] as? String ?? "未知错误"
        let error = XJPNetWorkingError(code: errorCode, message: errorMessage)
        postFailureResponseNotification(code: errorCode)
        ret = (data: nil, error: error)
    }
    

    经过上面的一系列处理之后,ret已经有值了,我们根据元组中的error来判断是否请求到了数据,倘若error存在就调用失败回调将错误信息传递出去。倘若error为空则调用请求成功的回调将请求到的数据传递出来。

    if ret.error == nil {
        success?(ret.data)
    } else {
        let error = ret.error!
        failure?(error)
    }
    
    将请求数据缓存到本地

    为了将从接口请求到的数据缓存到本地,我们新建了一个缓存类XJPNetworkDataManager

    open class XJPNetworkDataManager: NSObject
    

    这里导入了一个缓存的库Cache。以下是使用该库存储缓存数据的写法:

    fileprivate let diskConfig = DiskConfig(name: "XJPNetWorkingClientCache")
    fileprivate let memoryConfig = MemoryConfig(expiry: .never, countLimit: 10, totalCostLimit: 10)
    fileprivate lazy var storage = try! Storage(
      diskConfig: diskConfig,
      memoryConfig: memoryConfig,
      transformer: TransformerFactory.forCodable(ofType: String.self)
    )
    
    open func save(path: String, params: [String : Any]?, data: Any?) {
        let validParams = params ?? [String : Any]()
        if let validData = data, let dataStr = try? jsonStringWithObject(object: validData), let key = try? jsonStringWithObject(object: validParams) {
            do {
                try storage.setObject(dataStr, forKey: path + "_" + key)
            } catch {
                print("存储失败")
            }
        }
    }
    

    如果想要从缓存中获取到之前存储的数据可以这样来写,其中数据存在于闭包中。

    open func fetchLocalData(path: String, params: [String : Any]?, completion: @escaping (Any?)->()) {
        let validParams = params ?? [String : Any]()
        if let key = (try? jsonStringWithObject(object: validParams)) {
            storage.async.object(forKey: path + "_" + key) { result in
                switch result {
                case .value(let str):
                    let data = self.objectFromJsonString(string: str)
                    DispatchQueue.main.async {
                        completion(data)
                    }
                case .error(let error):
                    print(error)
                }
            }
        } else {
            completion(nil)
        }
    }
    

    将数据结构转换为json string

    private func jsonStringWithObject(object: Any) throws -> String {
        let data = try JSONSerialization.data(withJSONObject: object, options: JSONSerialization.WritingOptions(rawValue: 0))
        let string = String(data: data, encoding: String.Encoding.utf8) ?? ""
        return string
    }
    

    json字符串转换为Any

    func objectFromJsonString(string: String) -> Any? {
        if let data = string.data(using: String.Encoding.utf8) {
            do {
                let object = try JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.allowFragments)
                return object
            } catch {
                return nil
            }
        }
        return nil
    }
    

    实现了我们的缓存类之后就可以来使用了,创建dataManager对象。

    public static let dataManager: XJPNetworkDataManager = XJPNetworkDataManager()
    

    然后依次将我们的参数和请求路径传入作为缓存的key,再将数据作为存储的值。存储缓存的时候还需要在登陆时候获取到用户的userId。这样便可以区分不同用户的缓存数据,如不实现或为nil,便存储为共有的缓存数据。

    fileprivate static func getCacheParams(parameters: [String : Any]?) -> [String : Any] {
        var params = parameters ?? [:]
        if let key = XJPNetWorkingClient.share.delegate?.cacheOnlyKeyByNetwork() {
            params["onlyKey"] = key
        }
        var cacheKeys = [Any]()
        var cacheValues = [Any]()
        let array = params.keys.sorted { key1, key2 in
            return key1 < key2
        }
        for item in array {
            cacheKeys.append(item)
            cacheValues.append(params[item] ?? "")
        }
        let cacheParams: [String : Any] = ["LocalCacheKey":[cacheKeys,cacheValues]]
        return cacheParams
    }
    
    let cacheParams = getCacheParams(parameters: parameters)
    dataManager.save(path: path, params: cacheParams, data: ret.data)
    

    所以我们在XJPNetWorkingClientDelegate中又声明了一个委托方法,用来获取用户的userId,在登陆模块中实现该委托,这样在拼接缓存的key的时候就可以区分不同用户的数据了。

    public protocol XJPNetWorkingClientDelegate: NSObjectProtocol {
        ...
        /// 如不实现或为nil,这存储为共有的缓存数据
        func cacheOnlyKeyByNetwork() -> String?
    }
    
    extension MLLoginUser: MLNetWorkingClientDelegate {
        func cacheOnlyKeyByNetwork() -> String? {
            if let userId = MLLoginUser.shared.userId {
                return "\(userId)"
            } else {
                return nil
            }
        }
    }
    

    通过上面的一系列操作,我们二次封装的网络请求方法基本成型了,我们只需要额外的配置两样东西就可以了。一样是在根据测试、开发、生产环境来配置不同的baseURL,这个交给业务方来进行设置,没有配置在工具类中。一样是支持模型和JSON的转换操作,这个可以交由ObjectMapper库来完成。


    Demo

    Demo在我的Github上,欢迎下载。
    SourceCodeAnalysisDemo

    相关文章

      网友评论

          本文标题:IOS框架封装:上报日志库和网络请求库

          本文链接:https://www.haomeiwen.com/subject/axabcrtx.html