
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:channelappstorever%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 根路径几乎肯定是错的requestMethodHTTP 方法YTKRequestMethodGET枚举POST/PUT/DELETE 都有requestArgument参数字典nilGET 进 queryPOST 进 bodyrequestSerializerType请求体格式YTKRequestSerializerTypeHTTP要发 JSON 必须改成JSONrequestSerializerType是最容易漏的一个。默认的 HTTP 序列化会把参数字典编码成keyvaluekey2value2的表单格式服务端如果按 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 塞进cacheSensitiveDatakey 就区分开了同时这个值不会被明文写进缓存文件。- (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在 page1 和 page2 时分别是什么。这一点是 manager 式封装做不到的也是我后来坚持每个接口都建类的最大理由——接口的行为可以被验证而不是靠人肉 review 和线上观察。