资讯动态

YTKNetwork网络层封装:接口对象化与工程化落地

发布时间:2026/10/29 12:27:43 来源:尧图企业网站定制
每个维护过两三年的 Objective-C 项目几乎都会长出一个叫 Network 或 API 的文件夹里面塞着一堆长得差不多的类拼 URL 的、拼参数的、判空返回值的、处理 401 的、顺手写个缓存的。写得多了就会发现大家其实在重复同一件事——把一个个后端接口翻译成一个能被调用的对象。YTKNetwork 就是把这个动作抽象得比较彻底的框架它基于 AFNetworking 做了一层面向对象的封装核心思路只有两条全局配置收口到 YTKNetworkConfig单个接口变成一个 YTKRequest 子类。这个思路在今天的 Swift 生态里被 async/await 和协议式设计重新演绎了一遍但如果你手头还有 OC 老项目要维护或者想搞清楚请求层为什么要这么分层,把 YTKNetwork 吃透是性价比很高的一条路。下面这些内容适合两类人:一类是刚接手 OC 项目、看到一堆 XxxApi 类发懵的新人;另一类是想给自家项目重做网络层、又不想推翻重来的老手。1. 先想清楚请求层为什么值得单独设计1.1 从散装调用到接口即对象我见过太多项目的网络调用长这样:在 ViewController 里直接[[AFHTTPSessionManager manager] GET:url parameters:params progress:nil success:^(NSURLSessionDataTask *task, id responseObject) {...} failure:^(NSURLSessionDataTask *task, NSError *error) {...}]。写十个接口还行,写到第五十个就开始出问题。URL 前缀散落在十几个文件里,换个测试环境要全局搜替换;参数拼装在每个调用点手写,少一个字段就得翻后端文档;错误处理逻辑重复十遍,改一次要改十处;再想加个统一的 loading 或者埋点,只能一个个调用点去补。YTKNetwork 的解法是把一次网络请求当成一个对象来对待。一个接口对应一个类,这个类自己知道要请求哪个路径、用什么方法、带什么参数、要不要缓存、缓存多久、响应怎么校验。调用方只需要new一个对象、塞上业务参数、start,然后在回调里拿结果。这个转变听起来只是形式上的,实际收益在三个地方特别明显:接口定义从调用点挪到了类定义,可以集中检索和维护;请求的生命周期变成对象生命周期,能被持有、被取消、被复用;公共逻辑可以下沉到基类或者钩子里,不用每个调用点重复写。我用生活里的场景类比一下。散装调用像是每次做饭都现去菜市场买菜、现磨刀、现刷锅;而 YTKRequest 子类像是把每道菜做成一个半成品料理包,你只需要把它放进微波炉按下开关。料理包本身也统一规格,所以你可以给所有料理包加一个统一的加热计时器(全局配置)、统一的标签(URL 过滤器)、统一的质检流程(响应校验)。1.2 YTKNetwork 的三层结构Agent、Config、Request理解这个框架,不用一上来就翻源码,先把它的三层职责记住就够了。最上层是YTKRequest,你日常打交道最多的就是它。每个业务接口继承它,重写几个方法描述这个接口长什么样。它管的是单个请求的定义与状态。中间层是YTKNetworkAgent,一个单例,管所有请求的实际发起、取消、回调分发。它内部维护一个请求队列和一张字典表,把每个 YTKBaseRequest 对象映射到底层的 NSURLSessionTask 上。你调start,实际是让 Agent 去建任务;你调stop,实际是让 Agent 去取消任务。这一层还负责按优先级排队、限制并发数(默认最大并发数是 4,可以通过YTKNetworkAgent的相关配置调整)。最底层是YTKNetworkConfig,全局单例配置,管根域名、CDN 域名、URL 过滤器这些跨请求共享的东西。它的定位很清晰:凡是所有请求都长这样的部分,放这里。这三层拆开的好处是职责分明。业务代码只碰 YTKRequest 的子类,不会直接摸到 AFNetworking;想统一改域名或者加灰度分流,只动 Config;想调整并发策略,只动 Agent。真出问题的时候,排查方向也很明确:URL 不对先看 Config 的过滤器和 baseUrl,参数不对先看 Request 的 requestArgument,请求压根没发出去先看 Agent 的队列状态。1.3 这套设计在什么场景下划算,什么场景下过度不是所有项目都值得上这套封装。我个人的判断标准比较粗暴:接口数量超过 20 个、有环境切换需求、有多人协作,那就值得;如果只是个展示型的小 App,一共五个接口,老实写 AFNetworking 更省事。YTKNetwork 真正发力的场景有几类。一是接口多且形态复杂,有 GET 有 POST 有上传,还有批量请求和链式依赖(比如先登录拿 token 再拉用户信息);二是对缓存有明确要求,且不同接口缓存策略不一样,列表页缓存 10 分钟、配置接口缓存一小时、用户信息完全不缓存;三是有统一的前置逻辑,比如每个请求都要带签名、带设备信息、带灰度标识。这三类需求一旦出现,自己手写封装很容易越写越乱,而 YTKNetwork 已经把这几个方向都留好了口子。反过来说,它的代价也得说清楚:类数量会膨胀,一个接口一个类,一百个接口就是一百个文件;Objective-C 没有泛型和协议扩展那么优雅,回调里全靠强转拿具体类型;调试的时候断点会跳好几层。这些在选型时都要接受。2. YTKNetworkConfig全局配置的正确打开方式2.1 baseUrl 与 cdnUrl 到底有什么区别baseUrl好理解,就是所有请求的根路径。你在子类里requestUrl返回/v1/articles,框架最终拼出来的是baseUrl requestUrl。这里有个细节很多人第一次会踩:如果你在baseUrl末尾加了斜杠,又在requestUrl开头加了斜杠,拼出来就是双斜杠。有些服务端能容忍,有些会直接 404。我一般约定死:baseUrl不带结尾斜杠,requestUrl必须以斜杠开头,写进团队规范,后面就不用再纠结。cdnUrl这个属性存在感不高,但用对了很省事。它的语义是资源类请求的根路径,比如图片、静态 JSON 配置文件、H5 包这类不需要登录态的东西。你在 YTKRequest 子类里重写- (NSString *)baseUrl返回[YTKNetworkConfig sharedConfig].cdnUrl,这个请求就走 CDN 域名,和业务接口完全隔离。这么做的好处是:CDN 域名切换的时候(比如做多域名容灾),业务接口不受影响;埋点统计的时候,资源和接口能分开看。配置代码就这么几行,放在应用启动完成的地方,越早越好:- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { YTKNetworkConfig *config [YTKNetworkConfig sharedConfig]; config.baseUrl https://api.example.com; config.cdnUrl https://static.example.com; // 全局公共参数会以 query 形式拼到每个 URL 后面 YTKUrlArgumentsFilter *argsFilter [YTKUrlArgumentsFilter filterWithArguments:{ platform : ios, appVersion : [[NSBundle mainBundle] infoDictionary][CFBundleShortVersionString], channel : [self currentChannel] }]; [config addUrlFilter:argsFilter]; return YES; }注意YTKNetworkConfig是单例,配置一次全局生效。如果你在某个测试页面里改了baseUrl忘了改回来,整个 App 都会跟着走错环境,这个坑我在真实项目里见过两次,排查了整整一个下午。2.2 环境切换怎么做才不痛苦环境切换的本质是配置从哪来。硬编码在代码里最省事也最危险,一旦发版前忘了改,线上全崩。我的做法是分两层:编译期决定默认环境,运行期允许覆盖。编译期用宏区分 Debug 和 Release,Debug 下默认指向测试环境,Release 下强制指向正式环境。运行期再用NSUserDefaults存一个手动指定环境的开关,只有在 Debug 包或者内测包里才读取这个开关,正式包直接忽略。这样既保证了线上安全,又给测试同学留了切换空间。切换的时机也有讲究。baseUrl一旦改变,之前用旧地址建立的请求应该全部取消,否则会出现一半请求打到新环境、一半还在旧环境的情况。我一般封装一个EnvironmentManager,切换时先调[[YTKNetworkAgent sharedInstance] cancelRequest:...]把所有在飞的请求清掉(实际做法是遍历自己维护的请求记录逐个取消),再改baseUrl,再刷新当前页面。多花十行代码,能省掉一堆数据怎么对不上的诡异问题。还有一点容易被忽略:切换环境后,缓存必须清掉。YTKNetwork 的缓存是存在本地 Caches 目录下的,缓存 key 是 URL 的哈希,不会区分环境。如果你在测试环境缓存了/v1/articles的假数据,切到正式环境后十有八九会读到这份脏数据。所以切换环境的流程里,清理缓存目录是必须的一步。2.3 URL Filter多域名与灰度分流的落点YTKUrlFilterProtocol是这套框架里最容易被低估的一个扩展点。它只有一个方法:- (NSString *)filterUrl:(NSString *)originUrl withRequest:(YTKBaseRequest *)request;所有请求在拼好 URL 之后、真正发起之前,都会经过注册的过滤器,你可以在这里改写 URL。这个机制的价值在于,它让你能在不碰任何业务代码的前提下,对全站请求做统一干预。第一个典型用法是多域名容灾。假设你有三个可用的接口域名,某个域名解析挂了就切下一个。可以在过滤器里维护一个当前可用域名的索引,配合失败回调去探测和切换。当然这个方案比较粗糙,更稳的做法是在 Agent 层面做重试,但过滤器提供了一个不改业务代码的落点,这点很关键。第二个用法是灰度分流。某些接口需要根据用户的分组打到不同的后端集群,可以把分组标识放在 URL 的 query 里,或者直接替换 host。用过滤器实现的话,业务方完全无感。第三个用法是给 URL 追加公共参数,前面提到的YTKUrlArgumentsFilter就是这个思路的内置实现。这里必须提醒一句:它拼的是 query string,不是请求体。也就是说,一个 POST 请求的公共参数也会出现在 URL 上,而不是跟着 body 走。这在大多数情况下没问题,但如果你的公共参数里有敏感信息(比如设备指纹、token),那就不适合用这个过滤器,应该改到requestHeaderFieldValueDictionary里走请求头。过滤器是先进先出还是后进先出,顺序上也有讲究。我实测下来,注册顺序和最终 URL 的拼装顺序是一致的,所以如果你有多个过滤器同时改写 host,先注册的会先执行,后面的可能覆盖它。这种情况最好只保留一个负责改 host 的过滤器,避免互相打架。3. YTKRequest 全解析一个请求子类的完整骨架3.1 方法调用顺序与重写清单写 YTKRequest 子类,最忌讳的是看到什么重写什么。先把框架的调用顺序捋清楚,你就知道每个方法在什么时候被调用、能拿到什么。一次start的完整流程大致是:先判断有没有buildCustomUrlRequest返回了自定义的 NSURLRequest,有的话直接用,后面的 URL 拼装全部跳过;没有的话,取baseUrl和requestUrl拼成完整地址,再过一遍 URL 过滤器;接着按requestSerializerType组装请求体,把requestArgument塞进去;再挂上requestHeaderFieldValueDictionary里的请求头;然后交给 Agent 排队发起。响应回来后,先做statusCodeValidator校验,再做jsonValidator校验(这两步都通过才算成功),成功后如果有缓存配置就写缓存,最后回调 success;任何一步失败都走 failure 并带上 error。必重写的方法其实只有两个:requestUrl和requestMethod。前者返回接口路径,后者返回YTKRequestMethodGET、POST、PUT、DELETE、PATCH、HEAD之一。剩下的都有默认实现,按需重写即可。我习惯在子类里加几条注释分区,把必填可选钩子三块分开,后来的人看一眼就知道该改哪里。一个最简请求长这样:interface ArticleListRequest : YTKRequest property (nonatomic, assign) NSInteger page; property (nonatomic, copy) NSString *category; end implementation ArticleListRequest - (NSString *)requestUrl { return /v1/articles; } - (YTKRequestMethod)requestMethod { return YTKRequestMethodGET; } - (id)requestArgument { return { page : (self.page), size : 20, category : self.category ?: all }; } end调用侧就三行:ArticleListRequest *req [[ArticleListRequest alloc] init]; req.page 1; req.category tech; [req startWithCompletionBlockWithSuccess:^(__kindof YTKBaseRequest *request) { NSArray *list request.responseObject[data][list]; // 更新 UI } failure:^(__kindof YTKBaseRequest *request) { NSLog(err %, code %ld, request.error, (long)request.statusCode); }];这里有个细节值得说:startWithCompletionBlockWithSuccess:failure:的回调线程。框架默认把回调派发到主线程,所以你可以直接在里面刷 UI,但我个人的习惯是涉及大量数据解析的部分自己丢到后台队列,主线程只做 UI 赋值。原因很简单,一个几百条数据的列表如果都在主线程解析,滚动起来会掉帧。还有requestArgument的返回类型是id,不是NSDictionary。这个设计是为了兼容不同的序列化方式,如果你用 HTTP 序列化,返回字典就行;如果你需要传一个数组或者纯字符串作为 body,直接返回对应类型也可以。不过实际项目里 99% 都是字典。3.2 参数的三种组织方式与序列化选择参数怎么传,取决于后端接口的约定,也取决于requestSerializerType。YTKNetwork 给了三种选择:YTKRequestSerializerTypeHTTP(默认)、YTKRequestSerializerTypeJSON、YTKRequestSerializerTypePlist。默认的 HTTP 序列化,参数会被编码成keyvaluekey2value2的形式放在 body 里,Content-Type 是application/x-www-form-urlencoded。这是最传统的方式,兼容性最好。JSON 序列化则是把requestArgument整个转成 JSON 字符串放进 body,Content-Type 变成application/json。现在大部分新接口都走 JSON,尤其是嵌套结构复杂的时候,HTTP 表单嵌套数组的处理方式各语言实现不一致,很容易出问题。选择逻辑我总结成一句话:后端文档写什么就用什么,别自作主张。曾经有个项目后端说支持 JSON,结果他们那套老网关解析 JSON 的时候对中文字符处理有 bug,表单方式反而没事。折腾了两天才定位到。对应的响应序列化是responseSerializerType,默认YTKResponseSerializerTypeJSON,框架会自动把返回的 Data 解析成字典或数组,你从responseObject里直接取就行。如果某个接口返回的是纯文本或者文件流,记得单独重写:- (YTKResponseSerializerType)responseSerializerType { return YTKResponseSerializerTypeHTTP; // 不解析responseObject 是 NSData }还有一种情况需要特别处理:有些老接口返回的 JSON 里带非法字符,标准 JSON 解析会失败。这时候可以在responseSerializerType里改成 HTTP,拿原始 Data 自己用宽松模式的解析器处理。这个技巧我在对接第三方 SDK 的接口时用过好几次。参数的另一个维度是哪些参数该放进类属性。我的原则是:凡是影响请求内容的、调用方需要从外面传的,做成属性;凡是固定的、跟环境相关的,在requestArgument里写死或者从配置里取。比如分页请求里page和size,page一定是属性,size一般是常量;category这种筛选条件做成属性,而clientType这种全站统一的就在方法里固定。这样类的外部接口最干净,调用方也不会漏传必填项。3.3 公共参数、请求头与签名公共参数有两类,一类是所有接口都要带的业务参数,比如 app 版本、渠道号、设备型号;另一类是鉴权相关的,比如 token、签名。这两类的处理方式完全不同。第一类可以走前面说的 URL 过滤器,也可以在每个子类的requestArgument里拼。前者一劳永逸但会污染 URL,后者干净但要写重复代码。折中方案是做一层基类:BaseRequest : YTKRequest,在基类的requestArgument里先调 super 拿子类返回的参数,再合并公共参数。子类只需要重写自己的部分,合并逻辑基类包了。这个模式在项目里非常好用,后来做 Flutter 或者 Swift 项目我也是照搬这个思路。implementation BaseRequest - (id)requestArgument { NSMutableDictionary *dict [NSMutableDictionary dictionary]; // 子类通过这个方法返回自己的业务参数 NSDictionary *bizParams [self bizParameters]; if (bizParams) { [dict addEntriesFromDictionary:bizParams]; } dict[platform] ios; dict[deviceId] [DeviceInfo deviceId]; dict[timestamp] ([[NSDate date] timeIntervalSince1970] * 1000); return dict; } end第二类鉴权参数,我更推荐走请求头。重写requestHeaderFieldValueDictionary:- (NSDictionaryNSString *, NSString * *)requestHeaderFieldValueDictionary { NSMutableDictionary *headers [NSMutableDictionary dictionary]; headers[Authorization] [NSString stringWithFormat:Bearer %, [UserSession shared].token ?: ]; headers[X-Device-Id] [DeviceInfo deviceId]; headers[X-App-Version] [[NSBundle mainBundle] infoDictionary][CFBundleShortVersionString]; return headers; }如果接口需要 Basic 鉴权,框架还提供了requestAuthorizationHeaderFieldArray,返回[用户名, 密码],框架会自动拼成Authorization头。这个方法实际用的场景不多,但如果你对接的是那种HTTP 基础认证的老接口,它比手动拼 base64 干净。签名是个绕不开的话题。我的经验是,签名逻辑千万不要散落在各个子类里,一定要做在基类。原因很实际:签名的计算依赖大量公共字段,散着写必然出现某个接口少算一个字段,而且不好排查。基类里统一在bizParameters合并完成之后算签名,所有子类自动继承。签名的具体算法要看后端给什么,无外乎几种:参数按字母序拼接 密钥做哈希,或者只对关键字段签名。这里提醒一点,时间戳参与签名的情况下,要考虑客户端时钟漂移,服务端一般允许几分钟误差,这个误差窗口的大小要和后端确认清楚。3.4 状态码校验与数据校验默认情况下,YTKNetwork 只把 HTTP 状态码 200 到 299 视为成功,其他都算失败。这个默认规则对大部分项目够用,但真实业务里总有些例外。比较常见的是HTTP 200 但业务码表示失败。比如后端返回{code: 40001, msg: 登录已过期},HTTP 状态是 200。这种情况框架会判成功,你只能在 success 回调里再判断一次业务码。反复写很烦,所以我在基类里重写了校验逻辑:- (BOOL)statusCodeValidator { if (![super statusCodeValidator]) { return NO; } // 部分老接口业务失败也返回 200需要额外判断 id obj self.responseObject; if ([obj isKindOfClass:[NSDictionary class]] obj[code]) { NSInteger code [obj[code] integerValue]; return code 0; } return YES; }重写前一定要先调[super statusCodeValidator],把 HTTP 层的判断保留下来,别一股脑盖掉。另一个校验点是jsonValidator。默认实现会检查解析出来的对象是不是字典或数组,如果不是就判失败。这个默认行为在遇到接口直接返回一个字符串时候会误判,所以要按需放行:- (BOOL)jsonValidator { if ([self.responseString hasPrefix:\]) { return YES; // 允许返回纯字符串 } return [super jsonValidator]; }这里我要说个经验:校验逻辑不要写得太激进。我见过一个项目在jsonValidator里对返回结构做了非常严格的字段检查,结果后端加了个字段、改了嵌套层级,客户端就全挂了,而且因为是校验层拦下的,日志里只有一句校验失败,根本看不出是哪不对。校验的定位应该是挡住明显异常的数据,而不是当接口文档的守门员。字段级别的检查放到业务层,配合容错处理,更稳妥。错误处理这块,request.error在请求失败时有值,在业务校验失败时可能是 nil,这时候要看request.statusCode和request.responseObject。我习惯在基类的requestFailedFilter钩子里统一打一条结构化日志,把 URL、参数、状态码、响应体都记下来,生产环境里这些日志转成本地文件,出问题让测试同学导出来,比自己复现快得多。4. 进阶能力缓存、分页、批量与上传下载4.1 缓存三件套cacheTimeInSeconds、cacheVersion、cacheSensitiveDataYTKNetwork 的缓存开起来极其简单,重写一个方法就行:- (NSInteger)cacheTimeInSeconds { return 60 * 10; // 10 分钟 }返回 0 表示不缓存,这是默认值。返回大于 0 的时候,行为是:请求成功后把响应写进本地缓存;下次同一个请求发起时,如果缓存还在有效期内,框架直接拿出缓存回调 success,根本不会发网络请求。这一点和很多人理解的先返回缓存再请求刷新不一样,它是直接短路。所以如果你需要先展示缓存、再拉最新的体验,得自己控制:第一次带ignoreCache YES强刷,后续允许读缓存。缓存默认只按 URL 区分,这是个巨坑。一个分页列表,/v1/articles?page1和?page2在框架看来是同一个 URL(因为它取的是requestUrl,不含参数),缓存会互相覆盖。解决方法是重写cacheFileNameFilterForRequestArgument::- (id)cacheFileNameFilterForRequestArgument:(id)argument { return { page : argument[page] ?: 1, category : argument[category] ?: all }; }返回的这个对象会参与缓存 key 的计算,不同的参数组合生成不同的缓存文件。这里只要返回能区分不同响应结果的字段就够了,没必要把整个参数都塞进去,尤其是时间戳这种每次都变的东西,一塞进去缓存就永远不命中了。cacheVersion是个数字,改变它会整体失效这个请求的历史缓存。这个用在大版本升级、接口返回结构发生变化的时候很合适。我一般用CFBundleShortVersionString转成数字,每次发版天然让老缓存失效,省得手动维护。cacheSensitiveData这个名字容易被误解。它不是把敏感数据也缓存起来,恰恰相反,它的作用是:你返回的这部分参数会参与缓存 key 的计算,但不会被明文写进缓存文件。典型场景是用户维度的缓存,同一个列表对不同用户应该有不同的缓存,但你又不想把 token 明文落盘。默认实现会打一条警告日志提示你没实现这个方法,如果你没注意到这条日志,缓存串号的 bug 会非常难查。提示:开启缓存前先问自己三个问题——这份数据过期后用户能接受吗?不同用户的缓存需要隔离吗?缓存和网络返回的数据结构一致吗?三个问题有一个答不上来,就先别开。另外还有writeCacheAsynchronously,默认是 YES,写缓存异步执行。这个一般不用动,除非你在写缓存的瞬间就杀进程,可能会丢,但概率极低。4.2 分页请求怎么组织才不乱框架本身不带分页逻辑,分页是你自己在请求子类外面维护的。这件事看着简单,写乱了的地方特别多。我的做法是做一个PagedRequest中间基类,里面放page、pageSize、hasMore三个属性和一个reset方法:interface PagedRequest : BaseRequest property (nonatomic, assign) NSInteger page; property (nonatomic, assign) NSInteger pageSize; property (nonatomic, assign, readonly) BOOL hasMore; - (void)resetPaging; - (void)appendResponse:(id)responseObject; // 子类解析后告诉基类还有没有下一页 endhasMore的判断逻辑各项目不一样,有的是看数组长度是否小于 pageSize,有的是看返回的 total,有的是看后端给的has_next字段。写在基类里做成抽象方法,子类实现,这样调用方只关心hasMore一个布尔值。调用侧要处理的状态比想象中多:下拉刷新(重置 page 为 1,清空旧数据)、上拉加载(拿到当前 page 和 pageSize 构造新请求)、加载中(防止重复触发)、没有更多(停止上拉)、请求失败(保留当前 page,允许重试)。这五个状态如果不在一个统一的列表控制器里管理,每个页面都要重写一遍。有个细节特别值得提:上拉加载的时候,如果用户手速很快,连续触发了三次请求,page 会连加三,数据就乱了。解决办法是在发请求前先判断当前有没有在飞的请求,有就忽略这次触发。用 YTKRequest 的state属性可以判断,YTKRequestStateLoading就说明还在跑。或者干脆在控制器里维护一个isLoadingMore布尔值,更直白。还有一点,下拉刷新的时候要把之前的在飞请求 stop 掉,否则旧请求回来会把新数据覆盖。我踩过一次,现象是下拉刷新后列表闪一下又变回旧数据,查了半天才发现是上一次加载更多还没回来。4.3 批量请求与链式请求YTKBatchRequest解决的是多个独立请求同时发,全部成功才算成功的场景。比如首屏要同时拉轮播图、公告、推荐列表,任何一个失败都算首屏失败,允许用户重试。BannerRequest *banner [[BannerRequest alloc] init]; NoticeRequest *notice [[NoticeRequest alloc] init]; FeedRequest *feed [[FeedRequest alloc] init]; YTKBatchRequest *batch [[YTKBatchRequest alloc] initWithRequestArray:[banner, notice, feed]]; [batch startWithCompletionBlockWithSuccess:^(YTKBatchRequest *batchRequest) { BannerRequest *b (BannerRequest *)batchRequest.requestArray[0]; // 用 b.responseObject 取数据 } failure:^(YTKBatchRequest *batchRequest) { // batchRequest.failedRequest 指向第一个失败的请求 NSLog(失败的那个%, batchRequest.failedRequest); }];两个注意点。一是 batch 内部是并发发起的,不保证顺序,但requestArray的顺序和你传进去的顺序一致,取数据时按下标取没问题。二是任意一个失败就整体失败,即使另外两个成功了。如果你的场景是部分失败也能展示,那就别用 BatchRequest,自己用 dispatch_group 包一层更灵活。这种取舍在首屏场景里很常见,我一般会拆成核心请求必须成功,次要请求失败就隐藏对应模块,这时候批量请求反而不合适。YTKChainRequest解决的是后一个请求依赖前一个的结果的场景。最典型的就是登录流程:先调登录接口拿 token,拿到 token 后再拉用户信息,再根据用户信息决定跳哪个页面。YTKChainRequest *chain [[YTKChainRequest alloc] init]; [chain addRequest:loginRequest callback:^(YTKChainRequest *chainRequest, YTKBaseRequest *baseRequest) { LoginRequest *login (LoginRequest *)baseRequest; if (login.responseObject[token]) { [UserSession shared].token login.responseObject[token]; [chainRequest addRequest:profileRequest callback:nil]; // 加下一个 } else { [chainRequest stop]; // 中断链路 } }]; [chain startWithCompletionBlockWithSuccess:^(YTKChainRequest *chainRequest) { // 整条链跑完 } failure:^(YTKChainRequest *chainRequest) { // 任意一环失败 }];链式请求有个容易忽略的限制:addRequest:callback:必须在链还没跑完的时候调用,也就是说你只能在 callback 里加下一个。如果在外面提前把两个请求都 add 进去,第二个请求会在第一个还没回来的时候就开始,完全不讲武德。这个设计有点反直觉,我第一次用的时候也写错过。4.4 上传与下载的落地写法上传用constructingBodyBlock,这是 YTKRequest 给 AFNetworking multipart 留的口子:- (YTKRequestMethod)requestMethod { return YTKRequestMethodPOST; } - (NSString *)requestUrl { return /v1/user/avatar; } - (AFConstructingBlock)constructingBodyBlock { return ^(idAFMultipartFormData formData) { [formData appendPartWithFileData:self.imageData name:avatar fileName:avatar.jpg mimeType:image/jpeg]; }; }关键点在于requestSerializerType必须保持默认的 HTTP,如果你把它改成了 JSON,multipart 的组装会出问题。另外self.imageData建议是压缩后的数据,原图动辄好几兆,移动网络下体验极差。我一般的策略是:先按最长边压到 1280 像素以内,再按质量 0.7 转 JPEG,实测下来大多数场景够用,单张控制在 300KB 以内。进度回调方面,YTKNetwork 本身没有暴露上传进度,如果你需要进度条,得自己从 AFMultipartFormData 或者底层的 task 上想办法。我的做法是上传这个场景就不硬套 YTKRequest 了,单独开一个上传模块用 AFNetworking 直接做,拿到的进度回调更完整。这不是框架不好,是它的设计重心本来就在普通接口上。下载就更别为难它了。YTKRequest 没有断点续传、没有后台下载、也没有进度,硬要用只能buildCustomUrlRequest自己造一个 NSURLRequest 出来,然后用responseSerializerType设成 HTTP 拿 NSData,再自己写文件。真正需要下载能力的时候,老老实实用NSURLSessionDownloadTask或者 AFNetworking 的下载能力单开一个模块,职责也更清晰。5. 常见问题与排查技巧实录5.1 问题速查表下面这张表是我这几年攒下来的,基本都是同一类问题反复出现,列在一起方便对号入座。现象最可能的原因排查动作请求 404,但路径看着没错baseUrl 和 requestUrl 的斜杠拼出了双斜杠,或者某一边漏了斜杠在过滤器里打一条完整 URL 日志,肉眼确认参数后端收不到用 GET 方法却把参数放在 requestArgument 的嵌套字典里改成平铺结构,或改用 POSTPOST 参数在 URL 上公共参数走的是 URL 过滤器,被拼进了 query把公共参数移到 requestHeaderFieldValueDictionary缓存串号,不同用户看到同一份数据没重写 cacheSensitiveData,缓存 key 没有用户维度补上 cacheSensitiveData 返回用户标识分页数据互相覆盖没重写 cacheFileNameFilterForRequestArgument返回 page 等区分字段回调里 UI 不刷新在子线程解析后直接赋值了确认更新 UI 的代码在主线程请求重复发起触发按钮连续点击,或者上拉没有做防抖请求前判断 state 是否为 Loading回调里 statusCode 是 -1请求被取消了检查有没有调过 stop,或者控制器已释放上传一直失败,后端说没收到文件requestSerializerType 被改成了 JSON恢复默认的 HTTP 序列化切换环境后数据还是旧的本地缓存没有清理切环境时清空缓存目录和内存缓存5.2 几个把我绊倒过的坑第一个是stop之后回调的时机。我最初的认知是调了 stop 就不该有回调了,结果发现某些时序下 failure 还是会来,而且 error 信息很不明确。后来我养成了一个习惯:所有涉及页面销毁的场景,在dealloc或viewWillDisappear里把请求 stop 掉之后,同时把 completion block 清空(clearCompletionBlock),再在回调里加一个if (!self) return;的弱引用守卫。三层保险,难看但管用。第二个是请求对象的复用。有些人为了省事,把一个请求对象存在属性里反复 start。这在默认参数不变的情况下没问题,但如果中途改了参数再 start,框架用的是requestArgument方法实时计算的值,一般是对的;真正出问题的是缓存那一侧,缓存 key 是在请求发起时算的,如果你改参数和 start 之间隔了个异步操作,可能出现 key 和参数对不上的情况。我的做法是每次请求都新建对象,这点内存开销不值得省。第三个是基类里的 401 处理。token 过期后,如果十个请求同时返回 401,会弹十次登录页,或者触发十次刷新 token 的接口。这需要在基类里做一个全局刷新锁:第一个 401 进来时置位,发起刷新,token 更新后重新执行队列里挂起的请求,其余 401 直接丢弃或者等都刷完再重放。这套逻辑不复杂但很碎,建议一开始就写进基类,别等出了线上事故再补。第四个是jsonValidator和statusCodeValidator的顺序。我记得很清楚,一开始以为是先校验状态码再校验数据,后来发现两个都过不了的时候,报错信息给的是数据校验失败,容易误导排查方向。所以我在基类里都重写了一遍,并且把错误日志打全,直接标明是哪一步挂的。还有一个不太好归类的经验:日志一定要打完整 URL。YTKNetwork 内部的日志比较克制,开不出完整请求信息来。我在基类的requestWillStart钩子里打一条,在requestCompleteFilter和requestFailedFilter里各打一条,包含完整 URL、请求头(脱敏后)、耗时。这套日志在定位为什么这个接口突然慢了的时候简直救命,尤其是后端改了 Nginx 配置、多了一次 302 跳转这种情况,不看完整链路很难发现。5.3 调试与日志的落地办法线上问题排查靠的是事后日志,所以日志方案的可行性要在开发阶段就验证好,而不是出事的时候临时加。我建议至少落三类日志:一是请求发起日志,包含接口名、参数摘要、发起时间;二是响应日志,包含状态码、耗时、响应体大小;三是失败日志,包含完整的错误信息和响应体前若干字节。日志不必每天都上传,存在本地文件里,保留最近三天,用户反馈问题时引导导出即可。需要注意的是脱敏。token、手机号、身份证号这些绝不能明文进日志。我一般用一个白名单机制,只记录约定好的字段,其余字段一律打成***。这样虽然排查时信息少一点,但合规风险降到最低。还有一个小技巧:在 Debug 包里,给请求加一个可配置的延迟模拟。有时候 UI 上的 loading 状态、空数据状态用真机测很难复现,加个 3 秒延迟,所有边界状态都能从容地看一遍。这个口子在基类里留一个开关就行,不影响正式包。6. 工程化落地把 YTKRequest 用成团队资产6.1 目录结构与命名约定接口类一多,目录结构的重要性就显现出来了。我推荐按业务模块分目录,而不是按请求方法分:Network/ Base/ BaseRequest.h/.m PagedRequest.h/.m BaseUploadRequest.h/.m Config/ EnvironmentManager.h/.m RequestFilter.h/.m Modules/ Article/ ArticleListRequest.h/.m ArticleDetailRequest.h/.m ArticlePublishRequest.h/.m User/ LoginRequest.h/.m ProfileRequest.h/.m按模块分的好处是改一个功能,相关文件都在一个文件夹里。名字统一用模块 动作 Request的格式,requestUrl的路径和文件名尽量对应,这样从日志里的接口路径能直接反推出是哪个类。基类里的公共能力要克制。我见过一个项目基类里塞了二十多个属性,什么都往里加,最后没人敢动。我的原则是:基类只放三类东西——所有请求都需要的公共参数、所有请求都要走的校验、所有请求都关心的日志。业务相关的东西一律下沉到中间基类,比如PagedRequest只管分页,UploadRequest只管上传。分层清楚了,新人接手时看三个基类就懂全貌。6.2 和数据层怎么衔接YTKRequest 只是网络层,它返回的是字典或数组,直接扔给 ViewController 用,时间一长就会到处是responseObject[data][list][0][title]这种代码。正确的做法是在请求类外面再包一层 Model 转换。我的做法是在请求子类里加一个- (id)parsedModel或者- (NSArrayArticleModel * *)parsedModels,内部用 Mantle、YYModel 或者手写的initWithDictionary:把字典转成模型。调用方直接拿模型数组,UI 层完全不知道后端返回的字段名是什么。这一层加上的成本很低,但后续后端改字段名的时候,你只需要改一处转换逻辑,而不是满工程搜。再往上,如果项目是 MVVM,可以把请求对象的持有权放到 ViewModel 里,ViewModel 对外暴露的是状态(加载中、有数据、空、失败)和数据,ViewController 只管绑定。这套结构在 OC 里写起来比 Swift 啰嗦,但收益是一样的。6.3 老项目怎么渐进式迁移如果项目里现在是一堆散装的 AFNetworking 调用,不建议一次性全部改掉,风险太大。我的迁移路径是这样的:先引入 YTKNetwork,把 Config 配好,新写的接口一律用 YTKRequest 子类;老的调用点不动,但要开始接统一的日志和错误处理;等新接口占到一定比例,再按模块挑访问量大的页面,把老调用替换成请求子类,每替换一个就删掉对应的旧代码,不留双份。迁移过程中最容易出两种问题。一是两套体系同时存在,日志格式不一致,排查问题时要看两种日志,很烦。解决办法是让老的 AFHTTPSessionManager 也走同一套日志输出,至少字段名对齐。二是用户登录态的处理逻辑有两份,新老接口拿到的 token 不同步。这个必须在迁移开始前就把 session 管理抽出来,做成唯一的数据源,两边都从它读。还有一点,迁移期千万不要改动接口行为。哪怕你看到某个接口的实现有点怪,也先照原样搬过去,等迁移稳定了再单独优化。一次性改两件事,出了问题根本分不清是哪边导致的。我个人在实际操作中的体会是,这套框架的价值不在于它自己多强大,而在于它逼着你想清楚一个接口到底由哪些要素构成。等你把 URL、方法、参数、头、校验、缓存、错误这几件事在一个类里写明白,即使以后换成 Swift 的 async/await,或者换成别的框架,这个思维模型都能直接搬过去。真正沉下来的不是某个类的用法,而是这层抽象本身。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑