Swift网络请求实战:URLRequest GET请求的编码、缓存与并发
1. 从“一墙之隔不同的时空”说起Swift开发里的那些“同与不同”我是在地铁上刷到“肘子的 Swift 周报 #129”这个标题的。标题叫“一墙之隔不同的时空”当时我就觉得这八个字简直是给Swift开发者量身定做的。Swift生态圈里到处是这种“一墙之隔”的选择同样请求一个接口有人写URLRequest有人用URLComponents同样做异步有人还在回调里嵌套回调有人已经async/await一把梭。乍一看这些方案能力几乎相同但真正写起来、跑起来、维护起来体验完全是两个世界。这篇文章我想从实际开发的角度把“一墙之隔”背后那些看似微小、实则影响巨大的细节拆开来讲。重点会落在Swift日常开发中使用频率最高的场景之一——URLRequest的GET请求上同时穿插几个典型的值类型、引用类型、并发编程的对比案例。适合刚接触Swift没多久的新手也适合写了大半年项目但没系统整理过网络层细节的开发者看完之后你应该能少踩几个我当年踩过的坑。1.1 URLRequest与URLComponents构建请求的两种思路很多人在Swift里发网络请求第一步都是直接写URL(string: https://...)然后往里拼query参数。最简单的方式长这样let url URL(string: https://api.example.com/search?qSwiftpage1)! var request URLRequest(url: url)这段代码在参数固定、没有特殊字符的时候没有任何问题。但只要参数变成动态的比如用户输入了中文、带有空格、包含或符号问题就来了。直接字符串拼接构造URL本质上是把编码责任全部揽到自己身上而百分号编码percent-encoding里的规则比大多数人想象的复杂得多。与之相对的是URLComponents。它把URL拆分成scheme、host、path、queryItems等结构化组件参数通过数组添加系统会自动处理编码var components URLComponents(string: https://api.example.com/search) components?.queryItems [ URLQueryItem(name: q, value: Swift 周报), URLQueryItem(name: page, value: 1) ] let url components?.url同一个请求两种写法结果在大多数情况下看起来一样但在特殊字符面前就分道扬镳了。这就像做饭有人习惯凭感觉放盐有人用电子秤精确称量。日常一两道菜看不出差别一旦需要复现、传给别人、应对复杂食材精确做法的优势立刻显现。1.2 struct与class值语义和引用语义的平行宇宙你可能觉得Swift里struct和class的差别不就是“值类型”和“引用类型”几个字的事吗但实际写起来它们带来的代码行为差异是根本性的。简单来说struct赋值是拷贝一份独立的数据class赋值是多个变量共享同一块内存。同样是写一行var b a在struct世界里后来修改b不会影响a在class世界里修改ba也变了。跨线程传递数据时struct天然安全class则需要小心翼翼加锁或用队列同步。我见过不少从OC转Swift的团队习惯性把所有模型都定义为class结果在Swift Concurrency落地之后疯狂踩数据竞争的坑。不是class不能用而是当你正在处理并行任务时每一份共享的可变状态都是潜在的地雷。一墙之隔一个世界是线程安全的避风港另一个世界是数据竞争的雷区。1.3 回调与协程代码范式的墙“同样是异步为什么写成async/await就感觉清爽那么多”这是我在团队内部经常听到的问题。URLSession.shared.dataTask(with:)的回调写法和try await URLSession.shared.data(from:)的写法功能完全相同但代码的阅读顺序、错误处理方式、生命周期管理方式截然不同。回调版本用闭包捕获上下文容易形成嵌套一旦需要按顺序请求多个接口很快会变成“回调地狱”。async/await版本则把异步逻辑写成了近乎同步的顺序代码中途出错直接throw调用方一目了然。更关键的是async/await还带来了结构化并发的特性——任务取消可以自动传递不需要手动判断cancelled状态再小心翼翼地在回调里return。这一节我们先建立整体感知后面第3部分我会用完整的GET请求代码具体展示这两种“时空”的写作差异。2. URLRequest GET请求实操全解从一个普通请求到能上线的请求上次做项目时我需要拉取一个公开API的列表数据。需求很简单传一个城市名和分页页码GET请求返回JSON。我原以为这活儿十分钟搞定结果在参数编码和缓存策略上折腾了小半天。本节把完整的踩坑过程拆开给你看。2.1 一个最朴素的GET请求怎么写先说最容易上手的写法。假设我们要请求的接口是天气服务的城市搜索func searchCity(name: String, page: Int, completion: escaping (Result[City], Error) - Void) { guard var components URLComponents(string: https://api.example.com/v1/city/search) else { completion(.failure(URLError(.badURL))) return } components.queryItems [ URLQueryItem(name: city, value: name), URLQueryItem(name: page, value: String(page)) ] guard let url components.url else { completion(.failure(URLError(.badURL))) return } var request URLRequest(url: url) request.httpMethod GET request.timeoutInterval 15 let task URLSession.shared.dataTask(with: request) { data, response, error in if let error error { completion(.failure(error)) return } guard let httpResponse response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { completion(.failure(URLError(.badServerResponse))) return } guard let data data else { completion(.failure(URLError(.cannotParseResponse))) return } do { let result try JSONDecoder().decode(CitySearchResult.self, from: data) completion(.success(result.cities)) } catch { completion(.failure(error)) } } task.resume() }这里有两个点值得解释。第一我用URLComponents而没有直接拼字符串原因就是上一节说的编码问题后面会详细展开。第二回调拿到response之后先检查HTTPURLResponse的状态码再做JSON解码这两个检查的顺序不要反过来。实际开发中服务器返回200但Body是错误JSON的情况很少但5xx或404时Body里的内容往往是HTML或者错误提示不是我们要解码的目标类型直接decode大概率会抛出一个让新手摸不着头脑的dataCorrupted错误。2.2 query参数编码最容易翻车的地方这是GET请求里水最深的地方。很多人会用addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)来编码参数以为这样就算处理过了。实测下来这个做法对中文、空格有效但对、、这些特殊字符是无效的。原因在于.urlQueryAllowed这个字符集的设定是“允许出现在整个query部分的字符”它并没有把、排除在外。也就是说如果你的参数值是AB用.urlQueryAllowed编码后依然是AB服务端会把它解析成两个参数A和B。这种错误非常隐蔽因为本地测试时参数常常是简单英文一旦用户输入特殊字符接口行为就变得诡异而且很难排查。正确的做法有两种我推荐优先使用URLComponents的queryItems。它内部会对每个键值对做严格编码会被变成%26变成%3D空格变成%20服务端收到后能正确还原。如果因为某些历史原因必须自己拼字符串那可以用一个自定义字符集extension String { var queryValueEncoded: String { var allowed CharacterSet.urlQueryAllowed allowed.remove(charactersIn: ?#;:/) return self.addingPercentEncoding(withAllowedCharacters: allowed) ?? self } }把query中具有特殊语义的字符从允许集中剔除只对参数值做编码不碰整个URL。这里有一点要特别提醒不要对已经编码过的URL再整体调用addingPercentEncoding否则%会被二次编码成%25服务端拿到的参数值就彻底错了。2.3 缓存策略、超时设置与会话配置GET请求默认是可以用缓存的但很多人发完请求从没在意过缓存策略直到某天发现用户那边总显示旧数据才想起来URLRequest里有个cachePolicy属性。iOS默认的缓存策略是.useProtocolCachePolicy意思是完全由HTTP缓存协议决定服务端返回的Cache-Control、Expires头说了算。大多数情况下这没问题但如果你的接口数据实时性要求高、又没设置正确的缓存头客户端就会表现得很“死”——明明数据变了界面纹丝不动。处理方式有三层。第一层接口确实不该缓存显式指定请求策略request.cachePolicy .reloadIgnoringLocalCacheData第二层某些场景希望“有缓存就用缓存没缓存再请求”比如加载配置类数据可以用request.cachePolicy .returnCacheDataElseLoad第三层调试阶段想强迫走网络可以临时清空缓存URLCache.shared.removeAllCachedResponses()超时设置同样容易被忽略。默认的timeoutInterval是60秒对纯网络请求来说这个时间太长了——用户可能在弱网环境下等一分钟才看到失败提示体验很差。实际项目中我通常设15秒左右考虑到还要等服务器处理业务逻辑这个值比较平衡。跨地域、依赖多跳转的接口可以放宽到20秒但超过30秒基本就需要考虑是不是接口本身有问题了。如果项目里请求量大建议不要直接用URLSession.shared而是自定义一个会话let config URLSessionConfiguration.default config.timeoutIntervalForRequest 15 config.timeoutIntervalForResource 30 config.waitsForConnectivity true config.urlCache URLCache(memoryCapacity: 10 * 1024 * 1024, diskCapacity: 50 * 1024 * 1024) let session URLSession(configuration: config)waitsForConnectivity这个属性值得多说一句。默认情况下设备断网时请求会立刻失败。开启它之后URLSession会等网络恢复后自动重发请求适合对及时性要求不高的后台任务。但注意它只适用于URLSessionConfiguration.default或ephemeral不适用于background配置。2.4 一个可直接复用的完整GET请求封装把上面所有细节整合起来我给出一个日常项目可以直接使用的封装。它兼顾了参数编码、缓存控制、超时设置和错误分类struct APIClient { let session: URLSession let baseURL: URL init(baseURL: URL, timeout: TimeInterval 15, urlCache: URLCache? nil) { let config URLSessionConfiguration.default config.timeoutIntervalForRequest timeout config.timeoutIntervalForResource timeout * 2 config.waitsForConnectivity true if let urlCache { config.urlCache urlCache } self.session URLSession(configuration: config) self.baseURL baseURL } func getT: Decodable(path: String, query: [String: String]?, cachePolicy: NSURLRequest.CachePolicy .reloadIgnoringLocalCacheData, as type: T.Type) async throws - T { var components URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false) if let query { components?.queryItems query.map { URLQueryItem(name: $0.key, value: $0.value) } } guard let url components?.url else { throw URLError(.badURL) } var request URLRequest(url: url) request.httpMethod GET request.cachePolicy cachePolicy let (data, response) try await session.data(from: url) guard let httpResponse response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { throw URLError(.badServerResponse) } return try JSONDecoder().decode(T.self, from: data) } }使用时一行就够了let client APIClient(baseURL: URL(string: https://api.example.com)!) let cities: [City] try await client.get(path: /v1/city/search, query: [city: 北京, page: 1], as: CitySearchResult.self).cities这套封装我用了快两年项目从几十个接口扩展到几百个基本没改过骨架。遇到特殊需求比如上传文件、自定义Header就基于request.allHTTPHeaderFields扩展或者单独写一个方法不影响主体逻辑。3. 同一请求两种时空从GCD回调到Swift Concurrency如果你打开一个老项目大概率能看到大量dataTask(with:completionHandler:)的代码而近两年的新项目可能已经开始全面转向async/await。相同的目的地完全不同的旅途体验这正是“一墙之隔不同的时空”最直观的体现。3.1 传统回调方式怎么写在async/await出现之前Swift里做网络请求的标准姿势是func fetchUser(id: String, completion: escaping (ResultUser, Error) - Void) { guard let url URL(string: https://api.example.com/user/\(id)) else { completion(.failure(URLError(.badURL))) return } URLSession.shared.dataTask(with: url) { data, response, error in if let error { completion(.failure(error)) return } guard let data else { completion(.failure(URLError(.cannotParseResponse))) return } do { let user try JSONDecoder().decode(User.self, from: data) completion(.success(user)) } catch { completion(.failure(error)) } }.resume() }单看一个请求回调写法并不算糟糕。问题出在“多个请求组合”的时候。比如先查用户信息再用用户ID查订单列表最后把两者合并展示fetchUser(id: userID) { result in switch result { case .success(let user): fetchOrders(userID: user.id) { result in switch result { case .success(let orders): // 终于拿到数据了 DispatchQueue.main.async { self.updateUI(user: user, orders: orders) } case .failure(let error): handleError(error) } } case .failure(let error): handleError(error) } }这还只是两层嵌套。真实项目里如果再加一个依赖请求、一个缓存检查、一个用户取消判断代码就没法看了。错误处理散落在每层闭包里任何一个分支忘记调用completion调用方就会永远傻等下去。更麻烦的是闭包对self的捕获容易造成循环引用你得记得写[weak self]忘了写又是一通排查。3.2 async/await的现代写法同一个业务逻辑用Swift Concurrency写出来是这样的func loadUserAndOrders(userID: String) async throws - (User, [Order]) { async let user fetchUser(id: userID) async let orders fetchOrders(userID: userID) return try await (user, orders) } func fetchUser(id: String) async throws - User { let url URL(string: https://api.example.com/user/\(id))! let (data, _) try await URLSession.shared.data(from: url) return try JSONDecoder().decode(User.self, from: data) }async let是Swift Concurrency里特别好用的特性两个不互相依赖的请求可以并行发出两个await一起等待结果。传统写法要实现同样的并行要么用DispatchGroup要么自己维护计数器写起来冗长且容易漏掉enter/leave配对。用async let代码量和心智负担都大幅降低。错误处理更是质变。回调版本的错误被塞在Result或Error?里必须显式switchasync/await版本直接throw上游用try await接收不想要这个错误就继续往上抛逻辑和同步代码完全一致。3.3 迁移时要注意的差异从回调迁移到async/await不是改个关键字就完事有几个坑需要提前知道。第一URLSession.data(from:)返回的Data和URLResponse是直接在后台线程使用的不要假设await之后还在主线程。更新UI之前确保回到MainActorlet (data, _) try await session.data(from: url) let result try JSONDecoder().decode(T.self, from: data) await MainActor.run { self.updateUI(result) }不过好消息是如果你的视图模型或控制器本身标注了MainActor那直接在方法里更新属性没有问题。Swift Concurrency会把主线程的隔离自动处理好。第二异步上下文里的Task是有取消机制的。当用户退出页面或任务超时系统会发出取消信号。在传统回调时代我们全靠手动flag判断“要不要继续”现在可以监听Task.checkCancellation()也可以把取消绑定到URLSession任务上。网络请求被取消时URLSession会主动抛一个URLError.cancelled这反而帮我们省了检查逻辑。第三不要在Task里调用与escaping混用的旧API时偷懒。虽然可以把回调封装成withCheckedThrowingContinuation但需要仔细处理“回调被调用多次”和“任务已取消”的情况否则会造成并发原语挂死问题极难排查。4. 踩坑实录GET请求里那些看不见的“墙”4.1 中文参数变成乱码有次我给一个搜索接口加城市参数用户输入“西安”我直接拼接URL模拟器里一切正常真机上同一个请求却偶尔出现乱码。排查了很久最后发现是字体输入法在某些场景下会输入带变体选择符variation selector的Unicode字符肉眼看不出来编码后却多出几个字节。最终的解决方案就是统一走URLComponents.queryItems不做任何手动编码。苹果的实现在这个场景下处理得比绝大多数手写方案可靠它能正确应对Unicode组合字符、Emoji、以及各种语言的特殊标点。4.2 缓存导致读旧数据测试阶段最容易遇到的坑是“改了接口返回客户端显示的却是旧数据”。一开始我怀疑代码下载了缓存于是各种检查请求头。后来仔细看才发现是URLSession默认的URLCache把响应缓存到了磁盘。开发阶段强烈建议每次启动App时清理一次缓存URLCache.shared.removeAllCachedResponses()或者Debug模式下把缓存容量直接设为0避免干扰let config URLSessionConfiguration.default if isDebug { config.urlCache URLCache(memoryCapacity: 0, diskCapacity: 0) }生产环境则要反过来想图片、配置表、热门列表这类数据主动利用缓存反而能提升加载速度和用户体验。选择哪种策略取决于业务容忍的数据新鲜度而不是一刀切禁用所有缓存。4.3 主线程更新UI导致的崩溃与卡顿早期的回调版本里completionHandler默认在后台队列执行直接在里面改UIKit属性轻则界面更新不及时重则触发Main Thread Checker报错甚至崩溃。我见过不少新手在这上面翻车解决方式五花八门有人每次手动DispatchQueue.main.async有人整个方法包在DispatchQueue.main.async里前者繁琐后者会让网络请求本身也跑到主线程白瞎了异步的优势。正确做法是把网络请求和UI更新分离请求在主线程发起这没问题异步任务不会阻塞回调或await结果回来后再切主线程更新UI。Swift Concurrency下用MainActor修饰的类天然解决这个问题这也是我推荐新代码优先使用async/await的原因之一。4.4 排查速查表我把日常遇到的GET请求问题整理成了一个速查表排查故障时按表格逐项检查能省下不少时间故障现象常见原因解决方向返回乱码参数编码不完整改用URLComponents.queryItems响应是旧数据缓存策略不当设置cachePolicy或清除URLCache超时频繁timeout太短或弱网调整timeoutInterval开启waitsForConnectivity接口在真机连不上ATS限制HTTP配置App Transport Security的例外域并发数据错乱共享可变模型改用struct或好的数据隔离方案回调没执行闭包分支漏调用检查所有出错分支是否都调用了completion再说一个实用小技巧Debug模式下用Charles或Proxyman抓包时看到GET /xxx请求没带query参数第一反应通常是代码没拼上但别忘了检查URLComponents的percentEncodedQuery属性。某些情况下queryItems设置了但URL拼接失败因为path里含有非法字符。遇到这种问题断点打印components.url基本一眼就能定位。5. 写在最后的一点个人体会回头看这个标题“一墙之隔不同的时空”在Swift开发里几乎无处不在。URLRequest和URLComponents那堵墙是编码细节回调与async/await那堵墙是编程范式struct和class那堵墙是并发安全。很多时候解决问题不是靠某个花哨的新框架而是把最基础的工具用对、用透。我特别想对刚入行Swift的朋友说一句不要急着学各种冷门API先把你每天都在用的URLSession、JSONDecoder、DateFormatter研究明白再研究Swift Concurrency的特性。因为高级特性都是构建在基础能力之上的地基没打牢墙上盖得越高塌得越快。下次再写GET请求的时候试着不用字符串拼接URL不用手动做addingPercentEncoding而是老老实实走一遍URLComponents。你会发现其实代码没有变复杂只是把“碰运气”的部分交给了系统处理。等你在真实项目里遇到一次因为特殊字符导致接口报错的故障就会明白这个习惯有多值钱。