1. 网络层为什么要“一个接口一个类”
iOS 项目里网络这块儿,写到第三个业务模块基本就开始难受了:同一个接口在三个页面各写一遍,参数拼法不一样,超时时间不一样,缓存一个有一个没有,出错之后还得靠翻日志猜是哪个请求挂了。YTKNetwork 走的是另一条路——它不塞给你一个万能 manager,而是要求你为每个接口写一个类去继承YTKRequest,再配一个单例YTKNetworkConfig管全局配置,把 URL、参数、缓存、超时、优先级这些全都变成对象上的方法。请求不再是“一次函数调用”,而是一个有状态、可取消、可缓存、可复用的对象。这套思路第一次接触会觉得啰嗦,接口多了之后反而省心,因为每个接口的行为都被固化在一个文件里,改哪儿找哪儿。
1.1 直接怼 AFNetworking 的三个难受点
绝大多数 iOS 项目起步都是直接拿 AFNetworking(或者后来的 AFNetworking 3.x 那一套)在 ViewController 里发请求,顶多包一层NetworkManager单例加几个+ (void)getXXX类方法。这套写法在接口数量小于十个的时候很舒服,超过二十个就开始出问题,我自己踩过的坑主要集中在三块。
第一块是参数和地址的散落。接口地址写死在 ViewController 里,公共参数(设备号、渠道号、版本号、token)靠每处调用手工拼进字典。某天产品要求所有请求加一个灰度标识,你就得全局搜parameters,一个个补。补漏了一个,那个页面就静默地对不上数据。
第二块是缓存和取消没有归属。列表页下拉刷新带着缓存,详情页不要缓存,搜索框输入要做防抖加取消上一个请求。用一个 manager 的话,这些状态要么写在调用方,要么塞进 manager 的字典里按 URL 做 key,代码会越来越像一锅粥。取消尤其麻烦——你取消了一个请求,回调到底还返不返回?返回了怎么区分正常回调和取消回调?
第三块是回调里的类型转换重复劳动。JSON 转模型这一步,每个调用点都要写一遍[Model yy_modelWithJSON:...],字段类型不对还得在回调里isKindOfClass判一遍。这些活儿每写一遍就是在给未来的自己挖一个坑。
YTKNetwork 的设计基本就是冲着这三块来的:接口地址和参数变成YTKRequest子类的方法,缓存和取消挂在请求对象自己身上,JSON 校验交给基类的jsonValidator。
1.2 YTKNetwork 的三层结构:Config、Request、Agent
刚上手容易把它当成一个黑盒,其实结构很干净,就三层。
最上面是YTKNetworkConfig。全 App 只有一个实例,通过[YTKNetworkConfig sharedConfig]拿到,负责 baseUrl、cdnUrl、安全策略、是否打印日志、URL 过滤器这些全局性的东西。你在AppDelegate里初始化一次,后面所有请求都吃这套配置。
中间是YTKBaseRequest/YTKRequest。前者是抽象基类,负责请求的启动、停止、回调、状态机;后者在它基础上加了缓存、参数拼接、响应序列化这些业务层常用的能力。你的业务接口全部继承YTKRequest,重写几个方法就行。这一层是整个库的价值所在。
最下面是YTKNetworkAgent。它是个内部单例,真正持有底层网络库的会话对象和操作队列,负责把 Request 对象翻译成一次真实的 HTTP 调用,再把结果回填给 Request 的回调 block。业务代码一般不直接碰它,但排查问题的时候绕不开——比如断网重连、并发压制、请求排队,都得从它这一层理解。
这套分工带来的直接好处是:你要换底层网络库(比如从 AFNetworking 换到别的),理论上只需要动 Agent 这一层,业务侧上百个 Request 子类一行都不用改。这是“面向对象封装”在工程上的真实收益,不是概念游戏。
1.3 什么时候该用它,什么时候别硬上
这库不是银弹。项目只有五六个接口、也没缓存和取消需求,硬套一层 YTKRequest 反而增加文件数量和心智负担,直接写两个函数更痛快。
真正适合上场的场景我觉得有这几类:接口数量在三十个以上、有明确的列表缓存需求、需要批量并行拉多个接口再合并展示(首页那种)、存在链式依赖(先登录换 token 再拉用户资料)、以及需要针对单个接口做超时和优先级的调优。这几种场景下,请求对象化的收益非常明显,尤其是批量请求和链式请求,YTKNetwork 自带的YTKBatchRequest和YTKChainRequest能省掉大量手写的调度代码。
还有一类隐性的适配场景:团队里人多、代码风格不统一。强制“一个接口一个类”之后,新人加接口是照着模板抄一个文件,而不是自由发挥写一个 manager 方法,代码的可预测性会好很多。
2. YTKNetworkConfig:全局配置只有一个入口
全局配置看着简单,实际是整个库最容易出问题的地方,因为它是隐式的——请求发出去之前你不知道 baseUrl 到底拼成了什么。我见过好几次“接口 404”排查半天,最后发现是 baseUrl 结尾多了个斜杠。
2.1 baseUrl 与 cdnUrl 的拼接规则和踩坑点
请求最终 URL 的拼接逻辑是这样的:YTKRequest子类重写的- (NSString *)requestUrl返回的是相对路径,Agent 会拿它去和config.baseUrl拼。如果requestUrl本身已经是一个完整的绝对地址(以http://或https://开头),就直接用,不再拼 baseUrl——这个特性在接第三方 SDK 或者临时调一个外部接口时特别有用。
拼接的细节坑在于斜杠。我建议的统一约定是:baseUrl 结尾不带斜杠,requestUrl 开头带斜杠。
// AppDelegate 里初始化,只做一次 YTKNetworkConfig *config = [YTKNetworkConfig sharedConfig]; config.baseUrl = @"https://api.example.com"; // 结尾不要斜杠 config.cdnUrl = @"https://cdn.example.com"; // 图片等静态资源用 config.debugLogEnabled = YES; // 只在 Debug 打开对应到请求子类:
- (NSString *)requestUrl { return @"/v2/feed/list"; // 开头带斜杠 }最终得到https://api.example.com/v2/feed/list。如果你两边都带斜杠,会拼出https://api.example.com//v2/feed/list。多数服务端框架能容忍这个双斜杠,但有些严格的网关会直接返回 400 或者路由匹配失败,而且这种错误在浏览器里手敲地址是复现不出来的(因为浏览器会自动归一化),排查成本很高。
cdnUrl的用途值得单独说一句。当你重写- (BOOL)useCDN返回YES时,Agent 会优先用cdnUrl作为主机名去拼这次的请求地址。图片上传、静态资源拉取这类走 CDN 域名的接口适合这么干,能绕开主域名的连接数限制。注意它不会自动回退——cdnUrl 配错了或者为空,请求就是失败的,不会悄悄退回 baseUrl。
还有一个细节:单个请求可以重写- (NSString *)baseUrl来覆盖全局配置。灰度环境切换、某个接口要打另一台机器的时候,用这个比改全局配置安全得多,因为全局配置一改,所有在飞的请求都会受影响。
2.2 安全策略、超时、日志开关怎么配
config.securityPolicy是一个AFSecurityPolicy对象。默认走的是系统信任链校验,绝大多数情况不用动。只有在自签证书、内网测试环境这类场景才需要调整,调的时候注意别顺手把allowInvalidCertificates全局打开然后忘了关——这种配置一旦混进 Release 包,风险是长期的。
超时时间分两级。全局的可以在 config 里设,单个请求可以在YTKRequest子类里重写- (NSTimeInterval)requestTimeoutInterval覆盖。默认值是 60 秒,对于列表类接口太长了,我的习惯是全局压到 20 秒,然后按接口类型分别处理:
| 接口类型 | 建议超时 | 理由 |
|---|---|---|
| 列表、详情等内容接口 | 15 ~ 20 秒 | 用户等的耐心上限,超时宁可给重试按钮 |
| 登录、支付等强交互接口 | 25 ~ 30 秒 | 失败代价高,多给一点重试窗口 |
| 埋点、日志上报 | 5 ~ 8 秒 | 失败就丢,不能阻塞主流程 |
| 文件上传下载 | 60 秒以上或改用断点续传 | 按实际文件大小估算 |
debugLogEnabled这个开关必须严格控制。打开之后控制台会把每个请求的完整 URL、参数、响应体都打出来,联调阶段非常好用,但线上包里如果开着,一是性能损耗(大响应体打日志很吃 CPU),二是隐私风险。我的做法是包一层宏,只在 Debug 配置下置 YES,并且在 CI 加一条检查,防止有人把它写死成 YES 提交。
2.3 用 UrlFilter 统一塞公共参数
公共参数这块,很多人第一反应是在基类里重写requestArgument合并字典。这招能用,但有两个限制:一是它对 GET 请求有效,参数最终会进 query string;二是它对requestUrl本身没法做手脚。
更彻底的做法是注册一个YTKUrlFilterProtocol的实现。它在请求真正发出前拿到原始 URL 和对应的 request 对象,你可以在这里往 URL 上追加公共 query 参数,或者按 request 的类型做域名替换。
@interface CommonParamFilter : NSObject <YTKUrlFilterProtocol> @end @implementation CommonParamFilter - (NSString *)filterUrl:(NSString *)originUrl withRequest:(YTKBaseRequest *)request { NSString *sep = [originUrl containsString:@"?"] ? @"&" : @"?"; NSString *extra = [NSString stringWithFormat:@"channel=appstore&ver=%@&did=%@", [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"], [[UIDevice currentDevice] identifierForVendor].UUIDString]; return [originUrl stringByAppendingFormat:@"%@%@", sep, extra]; } @end注册就一行:
[[YTKNetworkConfig sharedConfig] addUrlFilter:[CommonParamFilter new]];注意:Filter 拿到的
originUrl已经完成 baseUrl 拼接了,所以你在里面看到的地址就是最终会发出去的地址。调试 URL 拼错的问题时,在这里打个断点比翻源码快得多。
Filter 的执行顺序是后注册的先执行。如果你有多个 Filter(比如一个加公共参数、一个做域名灰度替换),顺序会影响最终结果,别假设它是按注册顺序来的。这一点在官方文档里写得不显眼,我当初排查一个“灰度参数被覆盖”的问题耗了小半天。
3. YTKRequest:把请求写成对象
到了业务层,你在 95% 的时间里只跟YTKRequest打交道。理解它的关键是把“类方法”的思维换成“实例方法”的思维:这个类的一个实例,就是一次具体的请求,它有自己的状态、自己的缓存、自己的生命周期。
3.1 四个必写方法,以及它们的隐含语义
新建一个接口,最少要重写这几个:
| 方法 | 作用 | 默认值 | 备注 |
|---|---|---|---|
requestUrl | 相对路径 | 无 | 不写就发到 baseUrl 根路径,几乎肯定是错的 |
requestMethod | HTTP 方法 | YTKRequestMethodGET | 枚举,POST/PUT/DELETE 都有 |
requestArgument | 参数字典 | nil | GET 进 query,POST 进 body |
requestSerializerType | 请求体格式 | YTKRequestSerializerTypeHTTP | 要发 JSON 必须改成JSON |
requestSerializerType是最容易漏的一个。默认的 HTTP 序列化会把参数字典编码成key=value&key2=value2的表单格式,服务端如果按 JSON body 解析,拿到的就是空的。我见过不止一次“参数明明传了,后端说没收到”的对话,最后都是这一行没改。
- (YTKRequestSerializerType)requestSerializerType { return YTKRequestSerializerTypeJSON; }对应的响应侧还有一个responseSerializerType,默认是 JSON,会帮你把 Data 解析成字典或数组。如果某个接口返回的是纯文本或者裸二进制(比如图片流、protobuf),就得改成YTKResponseSerializerTypeHTTP,然后从responseData里自己取原始数据。
jsonValidator是这库里一个被严重低估的功能。你可以给它返回一个结构描述,请求完成时会自动校验返回的 JSON 是否符合这个结构,不符合就当成失败处理。
- (id)jsonValidator { return @{ @"code": [NSNumber class], @"data": @{ @"list": [NSArray class], @"hasMore": [NSNumber class] } }; }这么写的好处是把“接口返回了错误结构”这件事从业务回调里挪到了框架层。想象一下服务端某次发版返回了{"error": "..."}而不是预期的结构,没有校验的话你会在data[@"list"]那里拿到 nil,然后界面白屏,还得猜是哪儿的问题;有了校验,直接进 failure 回调,你在里面统一弹一个兜底提示就行。
statusCodeValidator默认校验 200~299,业务上要用 401 表示登录失效的话,可以在基类里重写它,把 401 也算作“有效响应”,然后在业务层根据响应体的 code 字段判断是否需要跳登录。反之,如果服务端习惯用 200 返回所有状态、靠 body 里的 code 区分,那就保持默认,把判断全放响应体里。
3.2 缓存策略:cacheTimeInSeconds 背后的四个参数
YTKRequest 的缓存是自动的,但它的行为由四个参数共同决定,只盯着cacheTimeInSeconds一个会踩坑。
cacheTimeInSeconds是缓存的有效期,单位秒。默认 0,表示不读也不写缓存;设成大于 0 的值,就在有效期内读缓存;设成 -1,表示永久缓存、永不过期(这个值只适合配置类接口,业务数据用 -1 迟早出事)。
cachePolicy控制的是“缓存和网络谁优先”。YTKRequestCachePolicyNetwork是无条件走网络(但仍然会写缓存),YTKRequestCachePolicyCacheThenNetwork是先用缓存回调一次、再走网络回调一次。后者是列表页首屏体验的关键——用户进来立刻看到上次的数据,几百毫秒后网络回来再刷新一次。
cacheVersion是个整数,参与缓存 key 的计算。改了接口的返回结构、或者服务端做了不兼容变更时,把它加一,旧缓存自动失效。这比手动清缓存目录优雅得多。
cacheSensitiveData是一个会参与 key 计算但不会落盘的额外标识。最典型的用法是带用户身份的接口——同一个 URL 和参数,A 用户和 B 用户拿到的数据完全不同。如果你只用 URL 和参数生成缓存 key,切换账号后就会看到上一个人的数据。把 userId 塞进cacheSensitiveData,key 就区分开了,同时这个值不会被明文写进缓存文件。
- (NSInteger)cacheTimeInSeconds { return 5 * 60; } - (long long)cacheVersion { return 3; } - (id)cacheSensitiveData { return [AccountManager currentUserId]; } - (YTKRequestCachePolicy)cachePolicy { return YTKRequestCachePolicyCacheThenNetwork; }缓存落盘位置在沙盒的 Caches 目录下,系统在存储紧张时有权限清理它。不要把缓存当持久化存储用,重要数据该进数据库就进数据库。另外,缓存文件是按 key 做哈希后存的,你在 Finder 里看到的是一堆看不懂文件名的文件,调试时想定位某条缓存,靠
debugLogEnabled打出来的日志更靠谱。
还有一个容易忽略的开关是writeCacheAsynchronously。默认是异步写,网络回调和缓存写入不互相阻塞。除非你在做“写完缓存立刻读”的特殊逻辑,否则别改成同步——同步写在主线程上会卡 UI,尤其是在返回体比较大的时候。
3.3 优先级、断点续传与手动取消
requestPriority只有三档:Low、Default、High。它的实际效果取决于底层操作队列的调度。我的经验是,High 留给“用户能直接感知的交互请求”(点了按钮马上要出结果那种),Low 留给埋点和预加载。中间层不要滥用 High,全都设成 High 等于都没设。
断点续传靠resumableDownloadPath实现。重写它返回一个本地文件路径,Agent 会把下载内容写进这个文件,中断之后再发同一个请求会自动从断点继续。
- (NSString *)resumableDownloadPath { NSString *dir = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject]; return [dir stringByAppendingPathComponent:@"video_demo.mp4"]; }这里有个必须注意的点:路径里的文件名要能唯一标识这个资源。如果两个不同的下载请求映射到同一个路径,后一个会覆盖前一个的断点数据,最终得到的文件是损坏的。稳妥做法是把资源 id 或者 URL 的哈希拼进文件名。
取消就是调- (void)stop。但取消之后有个行为必须搞清楚:stop 之后,成功和失败的回调都不会再被触发。这意味着如果你在回调里做了一些收尾工作(比如把 loading 关掉、把按钮恢复可点),取消路径上这些代码不会执行,得自己额外处理。我的做法是把“启动前状态”和“结束态恢复”抽成两个方法,在调stop的地方手动调一次恢复方法。
还有一个必须注意的坑:不要对一个正在执行的请求实例重复调start。同一个对象重复启动会导致状态机错乱,回调可能被调用多次,或者在 stop 之后仍然收到回调。正确姿势是先停止再启动,或者干脆每次都新建一个请求实例——反正请求对象很轻,新建一个的代价远比调试状态错乱小。
// 反面教材:直接重新 start [self.listRequest start]; // 推荐:先停再启,或者 new 一个 [self.listRequest stop]; self.listRequest = [[FeedListRequest alloc] init]; [self.listRequest startWithCompletionBlockWithSuccess:... failure:...];4. 从零跑通一个完整业务流程
光看方法名没感觉,走一遍完整流程更清楚。我用一个“分页信息流列表 + 详情页 + 首屏并行拉取配置和列表”的例子串一下,这套模式我在几个项目里都复用过。
4.1 集成与初始化
集成方式按团队习惯走,源码拖进来或者包管理工具都行。关键是初始化时机——必须在任何请求发出之前完成YTKNetworkConfig的配置。最稳的位置是AppDelegate的application:didFinishLaunchingWithOptions:里,靠前的位置,早于任何 UI 的创建。
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { YTKNetworkConfig *config = [YTKNetworkConfig sharedConfig]; config.baseUrl = @"https://api.example.com"; config.cdnUrl = @"https://cdn.example.com"; #ifdef DEBUG config.debugLogEnabled = YES; #else config.debugLogEnabled = NO; #endif [config addUrlFilter:[CommonParamFilter new]]; return YES; }有个隐性的时序问题:如果某个第三方 SDK 在didFinishLaunching的更早阶段就发了网络请求(有些统计、推送 SDK 会这么干),而你的 config 还没配好,那这次请求的 baseUrl 就是空的。虽然不影响你自己后续的请求,但日志里会多一条莫名其妙的失败记录。所以我一般把 config 配置放在方法体最前面,能往前挪就往前挪。
ATS 相关配置也要在这个阶段确认好。如果服务端全量上了 HTTPS,保持默认即可;如果还有部分接口走 HTTP,需要在 Info.plist 的NSAppTransportSecurity里做例外声明。这个配置涉及具体域名,写的时候注意别用通配符一把放开,按域名精确声明更稳妥。
4.2 写一个带缓存的分页列表请求
先建基类,把整个 App 通用的行为固化成默认值:
@interface AppBaseRequest : YTKRequest @end @implementation AppBaseRequest - (YTKRequestMethod)requestMethod { return YTKRequestMethodPOST; } - (YTKRequestSerializerType)requestSerializerType { return YTKRequestSerializerTypeJSON; } - (NSTimeInterval)requestTimeoutInterval { return 20; } - (id)cacheSensitiveData { return [AccountManager currentUserId]; } @end然后具体接口继承它,只写差异部分:
@interface FeedListRequest : AppBaseRequest @property (nonatomic, assign) NSInteger page; @property (nonatomic, assign) NSInteger pageSize; @end @implementation FeedListRequest - (NSString *)requestUrl { return @"/v2/feed/list"; } - (id)requestArgument { return @{ @"page": @(self.page), @"size": @(self.pageSize > 0 ? self.pageSize : 20) }; } - (NSInteger)cacheTimeInSeconds { return self.page == 1 ? 5 * 60 : 0; } - (YTKRequestCachePolicy)cachePolicy { return YTKRequestCachePolicyCacheThenNetwork; } - (id)jsonValidator { return @{ @"code": [NSNumber class], @"data": @{ @"list": [NSArray class], @"hasMore": [NSNumber class] } }; }注意cacheTimeInSeconds那个三元判断:只有第一页给缓存,后面几页不缓存。这是我在实际项目里定下来的规矩——第一页缓存能显著提升冷启动的观感,翻页数据缓存意义不大还占空间。如果全量给缓存,用户在列表里翻到第五页停住,第二天打开 App 直接看到的是第五页的旧数据,这个体验是灾难性的。
调用侧的写法:
self.listRequest = [[FeedListRequest alloc] init]; self.listRequest.page = 1; __weak typeof(self) weakSelf = self; [self.listRequest startWithCompletionBlockWithSuccess:^(__kindof YTKBaseRequest * _Nonnull request) { NSDictionary *data = request.responseObject[@"data"]; NSArray *list = data[@"list"]; [weakSelf reloadWithList:list hasMore:[data[@"hasMore"] boolValue]]; } failure:^(__kindof YTKBaseRequest * _Nonnull request) { [weakSelf showRetryViewWithError:request.error]; }];关于__weak那行,值得展开说一句。YTKRequest 会强引用你传进去的 completion block,而 block 又捕获了self,这就构成了一条self -> request -> block -> self的循环链。请求结束后 YTKRequest 内部会清空 block 引用,所以这个环是临时的,不会造成永久泄漏。但如果你在请求还没结束的时候 VC 就被 pop 了,这个环会短暂存在。用 weak 更稳,而且能避免“页面都走了回调还在跑”的尴尬。
4.3 批量请求与链式请求的编排
首页那种“配置 + 用户信息 + 列表”三合一的场景,用YTKBatchRequest一把发出去,全部成功才回调,任何一个失败整体失败。
ConfigRequest *cfg = [[ConfigRequest alloc] init]; ProfileRequest *pro = [[ProfileRequest alloc] init]; FeedListRequest *list = [[FeedListRequest alloc] init]; list.page = 1; YTKBatchRequest *batch = [[YTKBatchRequest alloc] initWithRequestArray:@[cfg, pro, list]]; [batch startWithCompletionBlockWithSuccess:^(YTKBatchRequest * _Nonnull batchRequest) { // batchRequest.requestArray 里按原顺序拿到各个请求的 responseObject ConfigModel *config = [ConfigModel modelWithJSON:batchRequest.requestArray[0].responseObject]; // ... } failure:^(YTKBatchRequest * _Nonnull batchRequest) { YTKBaseRequest *failed = batchRequest.failedRequest; NSLog(@"失败的是: %@, 原因: %@", failed.requestUrl, failed.error); }];failedRequest这个属性很实用,能直接告诉你是哪一个挂了。批量请求内部是并行发的,所以三个接口各自的重试、超时互不影响,整体耗时取决于最慢的那个。
链式请求用在有依赖的场景,比如“先拿一个临时 token,再拿 token 去请求业务数据”:
TokenRequest *tokenReq = [[TokenRequest alloc] init]; YTKChainRequest *chain = [[YTKChainRequest alloc] init]; [chain addRequest:tokenReq callback:^(YTKChainRequest *chainReq, YTKBaseRequest *baseReq) { NSString *token = baseReq.responseObject[@"data"][@"token"]; DataRequest *dataReq = [[DataRequest alloc] init]; dataReq.token = token; [chainReq addRequest:dataReq callback:^(YTKChainRequest *chainReq, YTKBaseRequest *baseReq) { // 第二步的结果 }]; }]; [chain startWithCompletionBlockWithSuccess:... failure:...];这里有个结构性提醒:YTKChainRequest的requestArray 只能在start之前添加。上面那种在 callback 里继续addRequest:的写法,链式请求内部是支持的(这是它的设计用途),但如果你试图在 start 之后从外部往 chain 里塞请求,就会出问题。所以链式编排的代码我建议集中在一个方法里写完,别分散到几个地方动态追加。
5. 排查实录:那些文档里不会写的问题
这部分是我这些年实际遇到过的、值得记下来的问题。多数不是库的 bug,而是使用方式和预期不一致。
5.1 URL 拼错、参数丢失的速查表
| 现象 | 最可能原因 | 处理方式 |
|---|---|---|
404,日志里的路径有// | baseUrl 和 requestUrl 斜杠重复 | 统一 baseUrl 末尾不带/ |
| 请求发到了 baseUrl 根路径 | requestUrl返回 nil 或空串 | 检查重写方法的返回值 |
| POST 后端收不到参数 | 没改requestSerializerType | 改成YTKRequestSerializerTypeJSON |
| 参数里有数组或嵌套字典,后端解析乱 | 表单序列化不支持嵌套 | 改用 JSON 序列化 |
| 请求发到了外部域名 | requestUrl意外返回了完整 URL | 检查是否有拼接逻辑误加前缀 |
| cdnUrl 相关请求全失败 | cdnUrl 未配置或配错 | 不用 CDN 的请求别重写useCDN |
排查顺序我一般是这样:先打开debugLogEnabled,看日志里打出来的完整 URL 和参数。如果 URL 是对的但服务端说参数没有,那就是序列化类型的问题。如果 URL 就不对,看 UrlFilter 有没有改坏,看 requestUrl 有没有返回意外值。这个顺序能覆盖八成的问题,比盲目打断点快。
有个小技巧:在
AppBaseRequest里重写- (void)requestCompleteFilter(请求成功后的钩子)和- (void)requestFailedFilter,在里面统一打印requestUrl、耗时、状态码。这比依赖库自带的日志更可控,而且可以顺手加上上报逻辑。
5.2 缓存不生效与“假重复请求”
缓存不生效是最常见的问题,按这几个方向查基本能定位:
第一,cacheTimeInSeconds是不是 0。默认值就是 0,很多人以为不写就是永久缓存,其实是不缓存。
第二,cachePolicy是不是Network。这个模式下完全不读缓存,但会写。如果你的逻辑是“先展示缓存再刷新”,必须设成CacheThenNetwork,否则回调只会来一次。
第三,cacheSensitiveData返回了每次都变的值。比如你用了时间戳或者随机数,那缓存 key 每次都不同,永远命中不了。这里必须是稳定值,比如用户 id。
第四,cacheVersion有没有被无意改动。改了版本号等同于全量清缓存,用户会感觉“缓存突然没了”。
“假重复请求”是另一类问题:你在日志里看到同一个接口连发了两次,以为是自己代码里写了两处。实际上很可能是CacheThenNetwork策略下的正常行为——它本来就会先回调一次缓存、再回调一次网络,日志打两遍是符合预期的。判断方法很简单,看两次回调之间有没有几十毫秒以上的间隔,以及第一次的响应内容是不是和上次一模一样。要确认是不是真的重复发,看debugLogEnabled打出的实际网络请求次数,而不是回调次数。
还有一类是页面生命周期导致的重复。viewWillAppear里发请求、viewDidLoad里也发了一次,或者通知监听导致多次触发。这种问题跟 YTKNetwork 无关,但表现得很像“请求重发”,排查时先把调用栈打出来看看是谁在调start。
5.3 上传下载与并发压制的经验值
上传用constructingBodyBlock,返回一个往AFMultipartFormData里塞数据的 block。这里最常踩的坑是文件名和 MIME 类型对不上。服务端做类型校验的话,image/jpeg的字段配了个xxx.png的文件名,会被直接拒。另一个坑是上传大文件时的内存占用——NSData一次性读进内存,几十兆的图集就会触发内存警告。超过 5MB 的文件建议走分片,或者降级用流式上传。
下载用resumableDownloadPath,前面提过路径要唯一。还有一个容易忽略的点:下载完成之后,responseObject并不是文件路径,而是原始 Data(如果序列化类型没改的话可能是解析失败的 nil)。真正的文件在你自己指定的那个路径上,业务侧要从路径读。
并发这块,YTKNetwork 内部的网络请求是排队的,队列的并发上限可以调。默认值对多数 App 够用,但在“首屏同时拉七八个接口”的场景下,会明显感觉到后面的请求被前面堵住。我的经验值是把并发控制在 4 到 6 之间——再往上收益很小,因为移动网络的带宽和连接建立开销摆在那儿,而且并发太高会让失败时的重试风暴更难受。
另外,allowsCellularAccess这个属性容易被忽略。它控制这个请求是否允许走蜂窝网络。做视频预加载、大图缓存这类“只在 WiFi 下干”的功能时,重写它返回NO就行,不用自己写网络状态判断。
最后分享一个实践上的小结:把每个 Request 子类都当成一个可以独立测试的单元。因为请求的所有行为都由类方法决定,你完全可以写单测去断言requestUrl返回了什么、requestArgument拼出来是什么、cacheTimeInSeconds在 page=1 和 page=2 时分别是什么。这一点是 manager 式封装做不到的,也是我后来坚持每个接口都建类的最大理由——接口的行为可以被验证,而不是靠人肉 review 和线上观察。