YTKNetwork 这套东西我在几个规模不同的项目里都用过,第一次是在一个请求代码已经散落在几十个 ViewController 里的老项目上做重构,最后一次是给团队定网络层的统一规范。用下来感受挺明确:它功能不算全,甚至有点朴素,但在“把一个接口变成一个对象”这件事上做得异常干净。YTKRequest 负责描述单个接口长什么样,YTKNetworkConfig 负责全局统一入口,中间的 Agent 负责真正把请求发出去。三层职责切得清楚,源码半小时能看完,新接口接进去也差不多半小时。
下面这份内容主要写给两类人:一类是刚接手一个用 YTKNetwork 的老项目,想快速搞懂它怎么运转的;另一类是打算在新项目里用它重做网络层,想知道哪些地方值得照抄、哪些地方必须提前避开。我会把 YTKNetworkConfig 的配置规则、YTKRequest 的生命周期、缓存语义、批量与链式请求组织方式,以及几个线上一定会遇到的坑,按实际动手的顺序讲一遍。代码都是能直接跑的骨架,你把 URL 和字段换掉就能用。
1. 别急着写 AFNetworking 调用:先想清楚“一个请求”该长什么样
1.1 请求代码散落在 VC 里的三种典型症状
我见过最典型的老项目是这样的:登录接口写在 LoginViewController 里,首页三个接口写在 HomeViewController 的 viewDidLoad 里,个人中心又复制了一遍带 token 的 header 拼接逻辑。第一次改接口域名,全项目搜https://搜出四十多处;第一次加公共参数,改了两天才改完,还漏了一个页面。这种代码不是不能跑,是每次改动都在赌运气。
第二种症状更隐蔽:错误处理全靠if (error) { [SVProgressHUD showErrorWithStatus:@"网络异常"]; },但服务端返回 HTTP 200 加业务码 401 的场景完全没处理,用户 token 过期了还停留在页面上疯狂点击。第三种是重复请求没人管,用户快速切换 Tab,同一个接口被触发五六次,服务端日志里能看到同一秒有五个一模一样的请求,流量浪费不说,回调顺序还乱,最后渲染出来的数据是哪个请求返回的都不确定。
面向对象化的封装解决的正是这三件事。把“一个接口”抽成一个类,URL、方法、参数、header、超时、缓存策略、响应校验全部写成这个类的方法,调用方只需要 new 一个对象、填参数、调 start。请求地址改了只改一个文件,公共参数用全局过滤器统一注入,重复请求在业务层用一个属性判重就行。这不是什么高深设计,就是把散落的配置收拢到一处。
1.2 YTKNetwork 的三层分工:Config、Request、Agent
YTKNetwork 的分层非常直白,理解这三层各自管什么,后面所有用法都是自然推导出来的。YTKNetworkConfig是全局单例,管 baseUrl、cdnUrl、URL 过滤器、缓存目录过滤器这几项,整个 App 只有一份,通常在启动时配一次。YTKBaseRequest 及其子类 YTKRequest描述单个请求,你继承它、重写几个方法,就得到了一个自解释的接口对象。YTKNetworkAgent是单例,内部持有一个 AFHTTPSessionManager,负责把 request 对象翻译成 NSURLRequest 发出去,再把结果回填到 request 上。
请求流转的顺序大致是:[request start]触发 Agent 的 addRequest,Agent 依次读取 request 的 baseUrl、requestUrl、method、argument、header,拼出完整 URL,经过 Config 上挂的所有 URL 过滤器加工,交给 AFNetworking 发出去;响应回来后先过状态码校验、再过 JSON 结构校验,都通过就调 request 的requestCompleteFilter,然后执行 success block,失败则走 failure block。整个链路上 request 对象始终是数据载体,你在回调里拿到的就是这个对象本身,responseJSONObject、responseStatusCode、error都能直接读。
这个设计的好处是“请求即状态”。一个接口调用失败了,你可以把整个 request 对象丢进日志系统,它的 URL、参数、耗时、错误码全在里面,不需要额外传上下文。坏处是它不自带并发控制和去重,这些得你在业务层自己加,后面第 7 节会专门讲。
2. YTKNetworkConfig:全局开关只有它一个入口
2.1 baseUrl 的拼接规则与末尾斜杠的坑
baseUrl的拼接是字符串直接相加,不是 URL 解析。也就是说config.baseUrl = @"https://api.example.com"加上requestUrl返回@"/api/v2/auth/login",拼出来是https://api.example.com/api/v2/auth/login,这没问题。但如果 baseUrl 写成@"https://api.example.com/",拼出来就变成https://api.example.com//api/v2/auth/login,双斜杠。
双斜杠在大多数服务端会被自动规范化,看起来无害,但在两类场景下会出事:一是走 CDN 时部分节点把//当成路径的一部分参与缓存 key 计算,导致命中率异常;二是做接口签名时,签名串里带的是双斜杠,服务端按单斜杠重算,直接验签失败,而且这种失败只在部分环境出现,排查起来很浪费时间。
我的做法是定一条硬规矩并写进团队规范:baseUrl 不带尾斜杠,requestUrl 一律以/开头。同时在基类里做一次防御:
- (NSString *)requestUrl { NSAssert(NO, @"子类必须重写 requestUrl"); return @""; }用断言把“忘记重写”这件事在开发期就打出来,比上线后 404 强得多。另外 requestUrl 如果返回的是以http开头的完整地址,YTKNetwork 会直接用它、跳过 baseUrl 拼接,这个特性适合接第三方接口,但要小心别让完整地址混进需要走过滤器的请求里。
2.2 用 URL 过滤器统一注入公共参数和签名
公共参数(时间戳、token、渠道号、签名)是最适合用过滤器做的事。实现YTKUrlFilterProtocol协议,重写filterUrl:withRequest:,在原始 URL 后面追加参数,然后在启动时[config addUrlFilter:xxx]注册进去。多个过滤器会按注册顺序依次串联,所以要注意顺序:签名过滤器必须放在参数过滤器之后,否则签的是不完整的串。
@interface CommonParamFilter : NSObject <YTKUrlFilterProtocol> @end @implementation CommonParamFilter - (NSString *)filterUrl:(NSString *)originUrl withRequest:(YTKBaseRequest *)request { NSString *ts = [NSString stringWithFormat:@"%.0f", [[NSDate date] timeIntervalSince1970]]; NSString *token = [[AccountStore shared] currentToken] ?: @""; NSString *sign = [SignatureTool signWithUrl:originUrl ts:ts token:token]; NSRange range = [originUrl rangeOfString:@"?"]; NSString *sep = (range.location != NSNotFound) ? @"&" : @"?"; return [NSString stringWithFormat:@"%@%@ts=%@&token=%@&sign=%@", originUrl, sep, ts, token, sign]; } @end注意这里用rangeOfString:而不是containsString:,后者是 iOS 8 才有的 API,如果你的库要兼容更老的系统版本就得换写法。还有个细节:过滤器里加了 token,意味着 token 变化时缓存 key 也要跟着变,否则会读到上一个账号的缓存数据,这个问题在第 5 节会展开。
过滤器拿到的request参数可以做条件判断,比如登录接口本身不需要 token,就可以在过滤器里判断[request isKindOfClass:[LoginRequest class]]然后跳过注入。这种做法比在每个请求类里单独拼 header 干净得多。
2.3 初始化时机:为什么建议放在 App 启动最前面
配置必须在使用任何请求之前完成。我见过一个项目在首页的viewWillAppear里才设置 baseUrl,结果推送冷启动直接跳到详情页时请求先发出去了,baseUrl 还是 nil,URL 拼出来是一个相对路径,AFNetworking 直接抛异常。正确的位置是 App 启动完成的最早回调里,早于任何网络调用。
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { YTKNetworkConfig *config = [YTKNetworkConfig sharedConfig]; config.baseUrl = @"https://api.example.com"; config.cdnUrl = @"https://cdn.example.com"; [config addUrlFilter:[CommonParamFilter new]]; [config addUrlFilter:[SignatureFilter new]]; return YES; }还有一个容易被忽略的点:配置是单例,测试代码或者多环境切换(开发、预发、生产)时如果要在运行时改 baseUrl,必须保证没有请求正在飞行。切换环境前先调[[YTKNetworkAgent sharedAgent] cancelAllRequests],再改配置,否则会出现一半请求打到旧环境、一半打到新环境的诡异现象。
3. YTKRequest 的生命周期:从 start 到回调中间的每一步
3.1 必须重写的那几个方法及其默认值
继承 YTKRequest 后不是所有方法都要写,但有几个必须知道默认值是什么。requestMethod默认返回 GET,所以要发 POST 就得重写。requestUrl默认返回空字符串,不重写就等着 404。requestTimeoutInterval默认 60 秒,这个值对大多数接口都太长了,用户会盯着转圈等一分钟,我一般设 15 到 20 秒。requestArgument默认 nil,GET 请求会把参数拼到 query string,POST 会放到 body。requestSerializerType默认 HTTP(表单形式),传文件或者嵌套结构时需要显式改成 JSON。
requestHeaderFieldValueDictionary默认 nil,用于补充自定义 header。这里有个常见误解:很多人以为公共 header 要在这里一个个写,其实应该用过滤器或者重写基类的这个方法统一返回,让所有子类共享:
// BaseRequest.m - (NSDictionary<NSString *, NSString *> *)requestHeaderFieldValueDictionary { NSMutableDictionary *headers = [NSMutableDictionary dictionary]; headers[@"X-Client"] = @"ios"; headers[@"X-App-Version"] = [[NSBundle mainBundle] infoDictionary][@"CFBundleShortVersionString"] ?: @"0.0.0"; headers[@"X-Device-Id"] = [[DeviceInfo shared] deviceId] ?: @""; NSString *token = [[AccountStore shared] currentToken]; if (token.length > 0) { headers[@"Authorization"] = [NSString stringWithFormat:@"Bearer %@", token]; } return headers; }这样一来所有业务请求类只要继承这个基类,header 就自动带上了,改动时只改一处。
3.2 requestArgument 的序列化路径
参数怎么变成请求体,取决于requestSerializerType。默认的 HTTP 类型走的是 AFNetworking 的表单序列化,普通的一层字典没问题,@{@"mobile": @"138...", @"password": @"xxx"}会变成mobile=138...&password=xxx。但如果参数里有嵌套字典或数组,表单序列化会把它展开成user[name]=张三&user[tags][]=a这种带方括号的形式,很多后端框架根本不认,收到的是一堆奇怪的 key。
遇到这种情况,最直接的办法是把requestSerializerType改成YTKRequestSerializerTypeJSON,让整个 body 变成 JSON。但要注意同时检查服务端的Content-Type判断逻辑,有些老接口对application/json的解析和表单不一样,会出现参数拿到了但类型对不上的问题。我吃过一次亏:服务端把 JSON 里的数字解析成了字符串,结果分页参数page=1变成了"1",SQL 拼出来的类型转换报错,日志里看不出来,最后是抓包才定位到的。
还有一种情况是文件上传,参数里要带二进制。这时候标准的 requestArgument 路线走不通,得用constructingBodyBlock让 AFNetworking 自己构造 multipart 请求体:
- (AFConstructingBlock)constructingBodyBlock { return ^(id<AFMultipartFormData> formData) { [formData appendPartWithFileData:self.imageData name:@"avatar" fileName:@"avatar.jpg" mimeType:@"image/jpeg"]; }; }配合把requestMethod设成 POST、requestUrl指向上传接口,其余参数照常放在 requestArgument 里,AFNetworking 会把它们作为 multipart 的普通字段一起发出去。
3.3 自定义 NSURLRequest:绕过标准流程的场景
有一类请求用标准流程拼不出来,比如需要在 body 里塞一段已经序列化好的 XML,或者要复用一个提前构造好的 NSURLRequest。这时候重写buildCustomUrlRequest返回你自己的 NSURLRequest,YTKNetwork 会跳过参数序列化,直接拿你的对象去发。
- (NSURLRequest *)buildCustomUrlRequest { NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:self.fullUrlString]]; req.HTTPMethod = @"POST"; [req setValue:@"application/xml" forHTTPHeaderField:@"Content-Type"]; req.HTTPBody = [self.xmlString dataUsingEncoding:NSUTF8StringEncoding]; req.timeoutInterval = 20; return req; }这个口子很灵活,但代价是绕过了 URL 过滤器、绕过了 baseUrl 拼接,公共参数和签名都得自己加。我的建议是只在确实没有办法的时候用它,比如对接一个只认 XML 的老网关,用完之后在类注释里写清楚“本请求不走全局过滤器,签名在本地完成”,免得后来接手的人找了半天为什么这个接口没有 ts 参数。
4. 跑通第一个请求:登录接口从建模到回调
4.1 请求类的完整骨架
拿登录接口举例,把上面的规则串起来。头文件只暴露必要的构造方法和结果读取方法,参数和 URL 细节都藏在实现里,调用方不需要知道接口路径是什么。
// LoginRequest.h #import "BaseRequest.h" @interface LoginRequest : BaseRequest - (instancetype)initWithMobile:(NSString *)mobile password:(NSString *)password; - (NSString *)responseToken; - (NSInteger)responseUserId; @end// LoginRequest.m #import "LoginRequest.h" @interface LoginRequest () @property (nonatomic, copy) NSString *mobile; @property (nonatomic, copy) NSString *password; @end @implementation LoginRequest - (instancetype)initWithMobile:(NSString *)mobile password:(NSString *)password { self = [super init]; if (self) { _mobile = [mobile copy]; _password = [password copy]; } return self; } - (YTKRequestMethod)requestMethod { return YTKRequestMethodPOST; } - (NSString *)requestUrl { return @"/api/v2/auth/login"; } - (NSTimeInterval)requestTimeoutInterval { return 15; } - (id)requestArgument { return @{ @"mobile": self.mobile ?: @"", @"password": self.password ?: @"", @"platform": @"ios", @"scene": @"app_login" }; } - (id)jsonValidator { return @{@"token": [NSString class], @"user_id": [NSNumber class], @"expire_at": [NSNumber class]}; } - (NSInteger)cacheTimeInSeconds { return 0; } - (NSString *)responseToken { return self.responseJSONObject[@"token"]; } - (NSInteger)responseUserId { return [self.responseJSONObject[@"user_id"] integerValue]; } @endjsonValidator这段值得展开说。它的作用是校验响应体的 JSON 结构,只要声明的字段类型对不上,请求就直接判为失败,不会把脏数据传到业务层。登录接口最怕的是服务端返回了{"code": 0}但少了 token,业务层拿着 nil 去存 keychain,后面所有带鉴权的请求全部 401,排查方向会跑偏到 token 存储上。加上 validator 之后,这种问题在请求层就暴露了。
4.2 业务层发起与回调线程
发起请求就一行,但写法上有讲究。
- (void)performLogin { LoginRequest *request = [[LoginRequest alloc] initWithMobile:self.mobileField.text password:self.passwordField.text]; __weak typeof(self) weakSelf = self; [request startWithCompletionBlockWithSuccess:^(__kindof YTKBaseRequest * _Nonnull req) { __strong typeof(weakSelf) self = weakSelf; if (!self) { return; } [[AccountStore shared] saveToken:[request responseToken]]; [self.navigationController popViewControllerAnimated:YES]; } failure:^(__kindof YTKBaseRequest * _Nonnull req) { __strong typeof(weakSelf) self = weakSelf; if (!self) { return; } [self handleLoginError:req.error]; }]; }几个细节。第一,block 里用的是外面那个request变量而不是 block 参数req,两者其实是同一个对象,用哪个都行,但用request能直接调到子类的responseToken方法,可读性更好。第二,weakSelf 加 strong 是标准写法,防止 block 执行期间 self 被释放导致崩溃。第三,不要假设回调在哪个线程执行。不同版本、不同配置下回调线程可能不一致,凡是碰 UI 的地方,稳妥做法是dispatch_async(dispatch_get_main_queue(), ^{...})一下,成本几乎为零,能省掉一堆“偶尔闪一下”的玄学问题。
还有一个必须做的事:如果你希望这个请求对象在回调后能被释放,得在回调里调[request clearCompletionBlock]。YTKNetwork 的 completionBlock 是 request 的属性,block 里又捕获了 request 或者 self,很容易形成环。我的习惯是在每个回调的第一行就清掉:
[request clearCompletionBlock];4.3 把 HUD、错误提示、重试收拢到一处
到这一步,每个页面都写一遍[SVProgressHUD show]/dismiss/showError就又开始重复了。我的做法是在 BaseRequest 里加一个showLoading布尔属性,然后在基类里覆写 start 相关的方法……实际上 YTKRequest 没有提供“请求开始”的钩子,所以更简单的做法是包一层工具方法:
+ (void)send:(YTKBaseRequest *)request loading:(BOOL)loading success:(void (^)(YTKBaseRequest *req))success failure:(void (^)(YTKBaseRequest *req, NSError *error))failure { if (loading) { [SVProgressHUD show]; } [request startWithCompletionBlockWithSuccess:^(__kindof YTKBaseRequest * _Nonnull req) { if (loading) { [SVProgressHUD dismiss]; } !success ?: success(req); } failure:^(__kindof YTKBaseRequest * _Nonnull req) { if (loading) { [SVProgressHUD dismiss]; } NSString *msg = [ErrorTranslator messageForError:req.error]; [SVProgressHUD showErrorWithStatus:msg]; !failure ?: failure(req, req.error); }]; }ErrorTranslator里做的事情是把 NSError 翻译成人话:超时归为“网络有点慢,稍后再试”,-1009 归为“当前没有网络连接”,状态码 401 归为“登录已过期”,其他走服务端返回的 message 字段。这层翻译一定要集中,否则同一种错误在不同页面提示的文案不一样,用户会以为是两个问题。
5. 缓存不是开了就好:cacheTimeInSeconds 的三个取值含义
5.1 0、正数、-1 的语义差异
cacheTimeInSeconds有三个区间,语义完全不同,搞混了会出大事。返回0表示不使用缓存,既不读也不写,这是默认值,绝大多数接口都该是 0。返回正数表示缓存这么多秒,请求发起时如果存在未过期的缓存,会先走一次 success 回调把缓存数据给你,然后再发真实请求,真实请求回来后如果数据有变化再回调一次。返回-1表示永不过期,除非你手动清理。
这个“先回调缓存、再回调网络”的机制很容易踩坑。假设你的 success block 里做的是[self.tableView reloadData],那么列表会刷新两次,滚动位置可能会跳。更严重的是如果第一次回调时数据是空数组,界面显示空状态,第二次回调才有数据,用户会看到空状态闪一下。解决办法是在 block 里判断request.isDataFromCache(部分版本有这个方法名),或者干脆在缓存场景下不做动画。
-1这个值的适用场景非常窄,基本只适合配置类接口,比如节假日文案、tab 配置,这类数据变了也不影响功能正确性。业务数据用 -1 迟早出事,比如商品详情缓存永不过期,运营改价之后用户看到的一直是老价格,投诉电话能打爆。
缓存还受cacheVersion影响。这个值参与缓存 key 的计算,当你改了服务端接口字段、旧缓存结构不兼容时,把 cacheVersion 加一,所有旧缓存自动失效,比一个个删文件方便得多。版本号我一般直接用构建号,或者用一个手动维护的常量。
5.2 缓存 key 的组成与“串号”事故
YTKNetwork 生成缓存 key 的原料大致包含 baseUrl、requestUrl、参数、缓存版本和应用版本号,做一次 MD5 得到文件名。注意这里默认不包含用户身份。这意味着如果 A 用户登录后查看了“我的订单”,退出登录,B 用户登录后进入同一个页面,很可能直接看到 A 的订单列表——因为参数完全一样,缓存命中了。
这个问题的解法是cacheSensitiveData。重写这个方法返回一个能区分用户身份的值(比如用户 ID 或者 token 的哈希),它会参与缓存 key 的计算。
- (id)cacheSensitiveData { NSString *uid = [[AccountStore shared] currentUserId]; return uid.length > 0 ? uid : @"anonymous"; }我的习惯是在 BaseRequest 里统一实现这个方法,让所有子类自动带上用户维度,避免有人写了 cacheTimeInSeconds 忘了写 cacheSensitiveData。另外退出登录时要主动清一次缓存目录,用[[NSFileManager defaultManager] removeItemAtPath:...]把 YTKNetwork 的缓存目录整个删掉,比逐个 key 删靠谱。
还有writeCacheAsynchronously默认是 YES,缓存写入在后台线程做,正常场景没问题。但如果你在写缓存的同一瞬间杀掉进程,可能出现半截文件。概率极低,但涉及金额、权限这类数据时,我一般关掉异步写,宁可慢几毫秒。
6. 批量与链式:两个请求没法覆盖的场景
6.1 YTKBatchRequest 的失败语义
首页经常需要同时拉轮播图、推荐流、未读消息数三个接口,串行发会慢,并行发又要自己管计数和失败合并。YTKBatchRequest 就是干这个的,把多个请求塞进去一次性启动。
@interface HomeLoader () <YTKBatchRequestDelegate> @property (nonatomic, strong) YTKBatchRequest *batch; @end - (void)loadHomeData { BannerRequest *banner = [BannerRequest new]; FeedRequest *feed = [FeedRequest new initWithPage:1]; UnreadRequest *unread = [UnreadRequest new]; YTKBatchRequest *batch = [[YTKBatchRequest alloc] initWithRequestArray:@[banner, feed, unread]]; batch.delegate = self; self.batch = batch; [batch start]; } - (void)batchRequestFinished:(YTKBatchRequest *)batchRequest { BannerRequest *banner = batchRequest.requestArray[0]; FeedRequest *feed = batchRequest.requestArray[1]; UnreadRequest *unread = batchRequest.requestArray[2]; [self renderWithBanner:banner.responseJSONObject feed:feed.responseJSONObject unread:unread.responseJSONObject]; } - (void)batchRequestFailed:(YTKBatchRequest *)batchRequest { // 只要有一个失败,整体就失败 NSLog(@"失败的是: %@", batchRequest.failedRequest); }这里的失败语义需要特别说清楚:任何一个子请求失败,整个 batch 就判为失败,同时会取消其他还在飞的请求。而且此时成功的那些请求的数据其实已经拿到了,requestArray里能读到,但 batch 不会给你成功回调。所以如果你的诉求是“三个接口有一个挂了也要显示另外两个”,就得自己处理:在batchRequestFailed里遍历 requestArray,把responseJSONObject不为 nil 的挑出来渲染,缺的那块显示占位或者局部重试。
这个设计没有对错,只是不适合“弱依赖”的数据组合。首页推荐流挂了不影响轮播图展示,这种情况用 batch 就得自己补逻辑,倒不如手动并发加 dispatch_group。
6.2 YTKChainRequest 的依赖串联与中断
链式请求解决的是“B 的参数来自 A 的结果”这种串行依赖。比如先拿一个临时凭证,再用凭证去请求业务数据。手写的话是两层嵌套 block,三层以上就没法看了。
- (void)loadWithChain { TokenRequest *tokenReq = [TokenRequest new]; YTKChainRequest *chain = [[YTKChainRequest alloc] init]; [chain addRequest:tokenReq callback:^(YTKChainRequest *chainRequest, YTKBaseRequest *baseRequest) { TokenRequest *token = (TokenRequest *)baseRequest; if (token.responseToken.length == 0) { [chainRequest stop]; [SVProgressHUD showErrorWithStatus:@"凭证获取失败"]; return; } DataRequest *dataReq = [[DataRequest alloc] initWithToken:token.responseToken]; [chainRequest addRequest:dataReq callback:^(YTKChainRequest *chainRequest, YTKBaseRequest *baseRequest) { DataRequest *data = (DataRequest *)baseRequest; [self render:data.responseJSONObject]; }]; }]; chain.delegate = self; [chain start]; }有两个点必须注意。第一,在 callback 里调addRequest:才会继续下一个,如果 callback 里什么都没做,链就停在那里,不会自动往下走,也不会报错。第二,stop之后链不会再有回调,已经发出的请求会被取消,所以调 stop 之后要自己处理 UI 状态,别等着 delegate 通知你。
链式请求的层级建议控制在三层以内,再长就该重新设计接口了。我见过一个五层的链,每层都要带上一层的 token,改一个中间接口的返回字段,后面四层的构造全得跟着改,维护成本高得离谱。
7. 实战踩坑:文档里没写、但线上一定会遇到的几件事
7.1 参数里带嵌套结构时服务端收到的到底是什么
这是最高频的问题。默认的 HTTP 序列化下,@{@"filter": @{@"status": @"1"}}会被展开成filter[status]=1,很多现代后端框架收到的是 key 为filter[status]的字符串,而不是嵌套对象,直接忽略。表现就是“参数我传了但服务端说没收到”。
排查办法很简单:用一个能打印完整请求体的抓包工具看实际发出去的是什么,别猜。确认之后按情况处理——服务端能改就让它支持方括号语法,不能改就把requestSerializerType改成 JSON。改成 JSON 之后要连带检查一件事:有些网关对Content-Type: application/json的请求会走不同的鉴权通道,之前表单形式能过的签名可能对不上,这时候签名逻辑要跟着调整。
还有一种隐蔽情况是参数值为 nil。@{@"name": self.name}如果 self.name 是 nil,构造字典时就会崩溃,而且是那种报错位置离真正原因很远的崩溃。养成习惯,所有可能为 nil 的值都用?: @""兜底,或者用 NSMutableDictionary 逐个判断。
7.2 jsonValidator 与状态码校验的边界
statusCodeValidator默认接受 200 到 299。如果你的服务端在 304 时返回的是有意义的响应体,就得重写这个方法把 304 加进去,否则会被当成失败。
- (BOOL)statusCodeValidator { NSInteger code = self.responseStatusCode; return (code >= 200 && code <= 299) || code == 304; }另一个常见困惑是“服务端返回 HTTP 200 但 body 里 code 是 401”。这种情况jsonValidator也拦不住,因为它只校验结构不校验业务语义。有人试图在jsonValidator里做业务判断,这是错的方向。
正确做法是在请求类的requestCompleteFilter里处理:
- (void)requestCompleteFilter { [super requestCompleteFilter]; NSInteger code = [self.responseJSONObject[@"code"] integerValue]; self.businessCode = code; if (code == 401) { [[AccountStore shared] forceLogout]; } }然后在业务层的 success block 里判断request.businessCode。之所以不在这里伪造失败,是因为 YTKNetwork 的回调流程已经走完了,强行改状态会让调用方困惑。分开处理更清晰:网络层负责传输和结构,业务层负责语义。
7.3 版本兼容与迁移期的坑
YTKNetwork 的大版本差异主要在对 AFNetworking 的依赖上。3.x 依赖 AFNetworking 4.x,2.x 依赖 3.x。如果你用 CocoaPods 集成,pod install 之后先看一眼Podfile.lock里 AFNetworking 的版本,如果项目里还有别的库依赖 3.x,就可能出现两套 AFNetworking 共存,符号冲突或者行为不一致都有可能。这种时候要么统一升级其他库,要么把 YTKNetwork 锁在 2.x。
再就是同一个 request 对象不要重复 start。YTKNetwork 没有做防重入,start 两次会往 Agent 里注册两次,同一个请求发两遍,回调也会触发两次。业务层的正确姿势是每次请求都 new 一个新对象,用一个属性持有它用来取消:
- (void)reload { [self.currentRequest stop]; self.currentRequest = [[FeedRequest alloc] initWithPage:1]; __weak typeof(self) weakSelf = self; [self.currentRequest startWithCompletionBlockWithSuccess:^(__kindof YTKBaseRequest * _Nonnull req) { __strong typeof(weakSelf) self = weakSelf; if (!self) { return; } [req clearCompletionBlock]; self.currentRequest = nil; [self render:req.responseJSONObject]; } failure:^(__kindof YTKBaseRequest * _Nonnull req) { __strong typeof(weakSelf) self = weakSelf; if (!self) { return; } [req clearCompletionBlock]; self.currentRequest = nil; }]; }先 stop 旧的再发新的,下拉刷新连续触发也不会出现数据错乱。这个模式我在列表页、搜索页都用,实测下来比各种节流标记都简单可靠。
最后提一句 HTTPS 证书校验。YTKNetwork 没有对外暴露 AFNetworking 的 securityPolicy,如果服务端用了自签证书或者需要做证书绑定,得自己拿到内部的 sessionManager(用运行时取属性或者直接改源码),这一步没有优雅解法。项目早期就把这件事确认清楚,别等到上线前才发现在测试环境连不上。
上面这些内容基本覆盖了把一个业务接口从零接到 YTKNetwork 上的完整路径。我自己的习惯是先花半天把 Config、BaseRequest、错误翻译、请求工具这四个文件搭好,后面每接一个接口就是写一个二三十行的子类,改需求时改动范围也可控。真正花时间的从来不是接第一个接口,而是三四十个接口之后,你有没有把公共逻辑收在正确的位置。