提示信息
# 同伴 App — Swift 网络层 & APIClient 完整实现
> 对应 Flutter 的 Dio + 三拦截器体系。Swift 版采用 `URLSession` + `async/await` + Actor 并发队列,完整还原所有拦截逻辑。
---
## 一、签名算法(X-Sign)
与 Flutter 端完全一致,**MD5**(非 HMAC):
```
原文 = appKey + timestamp + nonce + sortedParams + appSecret
sortedParams:
- 将 query params + body params 合并
- 按 key 字母升序排列
- 拼成 key1=value1&key2=value2
- Map/Array 类型的 value 用 JSON 序列化
- 空参数不参与签名
X-Sign = MD5(原文).lowercased()
```
---
## 二、目录结构
```
Core/Network/
├── APIClient.swift # 核心请求入口(actor)
├── RequestSigner.swift # 签名 + Header 注入
├── TokenRefresher.swift # Token 刷新队列(actor)
├── ResponseHandler.swift # 响应解包 + 错误码路由
├── UploadClient.swift # 文件上传 + 批量签名 URL
├── AppAPIError.swift # 统一错误类型
└── Interceptor/
└── NetworkMonitor.swift # 网络状态监听
```
---
## 三、AppAPIError
```swift
// AppAPIError.swift
struct AppAPIError: Error, Sendable {
let code: Int
let message: String
var data: (any Sendable)? // extraData,部分错误码携带
var isTokenExpired: Bool { code == 1002 }
var isNetworkError: Bool { code == -1 }
var isAccountInvalid: Bool { code == 1001 }
// 用于 UI 层直接 toast
var userMessage: String { message }
}
```
---
## 四、RequestSigner(签名 + Header 注入)
```swift
// RequestSigner.swift
import CryptoKit
import CommonCrypto
struct RequestSigner {
static func sign(request: inout URLRequest, body: Data?) {
let timestamp = String(Int(Date().timeIntervalSince1970))
let nonce = UUID().uuidString.replacingOccurrences(of: "-", with: "")
let appKey = AppConfig.appKey
// 注入公共 Header
request.setValue(appKey, forHTTPHeaderField: "X-App-Key")
request.setValue(timestamp, forHTTPHeaderField: "X-Timestamp")
request.setValue(nonce, forHTTPHeaderField: "X-Nonce")
request.setValue(deviceId, forHTTPHeaderField: "X-Device-Id")
request.setValue("ios", forHTTPHeaderField: "X-OS")
request.setValue(UIDevice.current.systemVersion, forHTTPHeaderField: "X-OS-Version")
request.setValue(appVersion, forHTTPHeaderField: "X-App-Version")
request.setValue("zh", forHTTPHeaderField: "X-Language")
// 注入 Token(已登录时)
if let token = AppStorage.getString(StorageKeys.accessToken), !token.isEmpty {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
// 风控标志
let riskFlag = AppStorage.getString(StorageKeys.deviceRiskFlag) ?? "[\"unverified\"]"
request.setValue(riskFlag, forHTTPHeaderField: "X-Device-Risk-Flag")
// 计算签名
let sortedParams = extractSortedParams(request: request, body: body)
let raw = "\(appKey)\(timestamp)\(nonce)\(sortedParams)\(AppConfig.appSecret)"
request.setValue(md5(raw), forHTTPHeaderField: "X-Sign")
}
// ── 私有 ─────────────────────────────────────────────────────────────
private static var deviceId: String {
KeychainHelper.read(StorageKeys.deviceId) ?? "unknown"
}
private static var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
}
/// 从 URL query + body JSON 中提取参数,按 key 升序拼接
private static func extractSortedParams(request: URLRequest, body: Data?) -> String {
var params: [String: Any] = [:]
// Query params
if let url = request.url, let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) {
comps.queryItems?.forEach { params[$0.name] = $0.value ?? "" }
}
// Body JSON(仅 application/json)
if let data = body,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
json.forEach { params[$0.key] = $0.value }
}
if params.isEmpty { return "" }
return params.keys.sorted().compactMap { key -> String? in
guard let value = params[key] else { return nil }
let strValue: String
if let nested = value as? [String: Any] ?? (value as? [Any]).map({ $0 as Any }),
let jsonData = try? JSONSerialization.data(withJSONObject: value),
let jsonStr = String(data: jsonData, encoding: .utf8) {
strValue = jsonStr
} else {
strValue = "\(value)"
}
return "\(key)=\(strValue)"
}.joined(separator: "&")
}
private static func md5(_ string: String) -> String {
let data = Data(string.utf8)
var digest = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))
data.withUnsafeBytes { CC_MD5($0.baseAddress, CC_LONG(data.count), &digest) }
return digest.map { String(format: "%02x", $0) }.joined()
}
}
```
---
## 五、TokenRefresher(并发刷新队列)
```swift
// TokenRefresher.swift
// actor 保证同一时刻只有一个刷新任务在执行,其余并发请求排队等待
actor TokenRefresher {
static let shared = TokenRefresher()
private var isRefreshing = false
private var waiters: [CheckedContinuation<Bool, Never>] = []
/// 返回 true = 刷新成功(调用方可重试原请求),false = 刷新失败(需强制登出)
func refreshIfNeeded() async -> Bool {
if isRefreshing {
// 已在刷新,挂起等待
return await withCheckedContinuation { continuation in
waiters.append(continuation)
}
}
isRefreshing = true
let result = await doRefresh()
isRefreshing = false
// 通知所有等待者
let localWaiters = waiters
waiters.removeAll()
for waiter in localWaiters {
waiter.resume(returning: result)
}
return result
}
private func doRefresh() async -> Bool {
guard let refreshToken = AppStorage.getString(StorageKeys.refreshToken),
!refreshToken.isEmpty else {
return false
}
do {
// 使用独立 URLSession(不走主 APIClient,避免死循环)
var request = URLRequest(url: URL(string: "\(AppConfig.apiBaseURL)/auth/token/refresh")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = try JSONSerialization.data(withJSONObject: ["refresh_token": refreshToken])
request.httpBody = body
RequestSigner.sign(request: &request, body: body) // ⚠️ 刷新请求也需要签名
let (data, _) = try await URLSession.shared.data(for: request)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
guard json?["code"] as? Int == 0,
let payload = json?["data"] as? [String: Any],
let newAccess = payload["access_token"] as? String,
let newRefresh = payload["refresh_token"] as? String else {
return false
}
AppStorage.setString(StorageKeys.accessToken, newAccess)
AppStorage.setString(StorageKeys.refreshToken, newRefresh)
return true
} catch {
return false
}
}
}
```
---
## 六、ResponseHandler(响应解包 + 全局错误码路由)
```swift
// ResponseHandler.swift
@MainActor
final class ResponseHandler {
static func handle(data: Data, response: URLResponse) throws -> Any? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw AppAPIError(code: -1, message: "响应格式异常")
}
let code = json["code"] as? Int ?? -1
let msg = json["msg"] as? String ?? "未知错误"
let payload = json["data"]
if code == 0 { return payload }
// 路由到全局处理器
switch code {
case 1001: handleAccountInvalid(msg)
case 1002: break // 由 APIClient 捕获后交给 TokenRefresher
case 1010: AppCoordinator.shared.goBanned(banInfo: payload)
case 1011: AppCoordinator.shared.goBannedFraud(banInfo: payload)
case 1012: AppCoordinator.shared.goBannedDevice()
case 1032: handleUpdate(payload: payload as? [String: Any])
case 1033: break // 由 APIClient 捕获后弹隐私协议弹窗重试
case 1038: AppCoordinator.shared.goMaintenance()
case 1039: AppCoordinator.shared.goServerError()
case 2006: showRealNameSheet()
case 3008: showDailyLimitDialog()
default: break
}
throw AppAPIError(code: code, message: msg, data: payload as? (any Sendable))
}
// ── 具体处理逻辑 ──────────────────────────────────────────────────
private static func handleAccountInvalid(_ msg: String) {
[StorageKeys.accessToken, StorageKeys.refreshToken,
StorageKeys.userId, StorageKeys.imToken, StorageKeys.userProfile]
.forEach { AppStorage.remove($0) }
Task { @MainActor in
// 用系统 Alert 显示(无 context 依赖)
let alert = UIAlertController(title: "登录已失效", message: msg, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "重新登录", style: .default) { _ in
AppCoordinator.shared.goLogin()
})
UIApplication.topViewController?.present(alert, animated: true)
}
}
private static func handleUpdate(payload: [String: Any]?) {
guard let payload else { return }
let isForced = payload["force_update"] as? Bool ?? false
let version = payload["version"] as? String ?? "新版本"
let desc = payload["description"] as? String ?? "发现新版本,请更新。"
let storeURL = payload["store_url"] as? String ?? ""
Task { @MainActor in
let alert = UIAlertController(
title: "发现新版本 \(version)",
message: desc,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "立即更新", style: .default) { _ in
if let url = URL(string: storeURL) { UIApplication.shared.open(url) }
})
if !isForced {
alert.addAction(UIAlertAction(title: "稍后再说", style: .cancel))
}
UIApplication.topViewController?.present(alert, animated: true)
}
}
private static func showRealNameSheet() {
Task { @MainActor in
AppCoordinator.shared.presentVerification()
}
}
private static func showDailyLimitDialog() {
Task { @MainActor in
let alert = UIAlertController(
title: "今日收取已达上限",
message: "你今天已经翻了好多信号啦\n休息一下,明日 0:00 限制将自动重置",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "去聊天", style: .default) { _ in
AppCoordinator.shared.goMessages()
})
alert.addAction(UIAlertAction(title: "稍后再说", style: .cancel))
UIApplication.topViewController?.present(alert, animated: true)
}
}
}
```
---
## 七、APIClient(核心请求 Actor)
```swift
// APIClient.swift
actor APIClient {
static let shared = APIClient()
private let session: URLSession
private let decoder: JSONDecoder
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 15
session = URLSession(configuration: config)
decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .secondsSince1970
}
// ── 公开接口 ──────────────────────────────────────────────────────
func get<T: Decodable>(_ path: String,
params: [String: Any]? = nil,
as type: T.Type) async throws -> T {
let req = try buildRequest(method: "GET", path: path, params: params, body: nil)
return try await execute(req, as: type)
}
func post<T: Decodable>(_ path: String,
body: (any Encodable)? = nil,
as type: T.Type) async throws -> T {
let bodyData = try body.map { try JSONEncoder().encode($0) }
let req = try buildRequest(method: "POST", path: path, params: nil, body: bodyData)
return try await execute(req, as: type)
}
@discardableResult
func post(_ path: String, body: (any Encodable)? = nil) async throws -> Any? {
let bodyData = try body.map { try JSONEncoder().encode($0) }
let req = try buildRequest(method: "POST", path: path, params: nil, body: bodyData)
return try await executeRaw(req)
}
// ── 执行层(含 Token 刷新重试) ────────────────────────────────────
private func execute<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
let raw = try await executeRaw(request)
// 将 Any? 重新序列化后解码为目标类型
let data = try JSONSerialization.data(withJSONObject: raw ?? NSNull())
return try decoder.decode(T.self, from: data)
}
private func executeRaw(_ request: URLRequest) async throws -> Any? {
do {
let (data, response) = try await session.data(for: request)
return try await ResponseHandler.handle(data: data, response: response)
} catch let err as AppAPIError where err.isTokenExpired {
// Token 过期 → 刷新后重试一次
let refreshed = await TokenRefresher.shared.refreshIfNeeded()
guard refreshed else {
await AuthService.shared.forceLogout()
throw err
}
// 用新 token 重新签名后重试
var retryReq = request
RequestSigner.sign(request: &retryReq, body: request.httpBody)
let (data, response) = try await session.data(for: retryReq)
return try await ResponseHandler.handle(data: data, response: response)
} catch let urlErr as URLError {
// 网络层错误统一封装
throw AppAPIError(code: -1, message: urlErrorMessage(urlErr))
}
}
// ── 构建请求 ───────────────────────────────────────────────────────
private func buildRequest(method: String,
path: String,
params: [String: Any]?,
body: Data?) throws -> URLRequest {
var urlStr = "\(AppConfig.apiBaseURL)/\(path.trimmingCharacters(in: CharacterSet(charactersIn: "/")))"
if method == "GET", let params, !params.isEmpty {
var comps = URLComponents(string: urlStr)!
comps.queryItems = params.map { URLQueryItem(name: $0.key, value: "\($0.value)") }
urlStr = comps.url!.absoluteString
}
var request = URLRequest(url: URL(string: urlStr)!)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = body
RequestSigner.sign(request: &request, body: body)
return request
}
private func urlErrorMessage(_ err: URLError) -> String {
switch err.code {
case .timedOut: return "网络请求超时"
case .notConnectedToInternet,
.networkConnectionLost: return "网络连接失败,请检查网络设置"
default: return "未知网络错误"
}
}
}
```
---
## 八、UploadClient(文件上传 + 批量签名 URL)
```swift
// UploadClient.swift
struct UploadResult: Decodable, Sendable {
let objectKey: String // OSS key,私有文件需批量签名后才能展示
let url: String? // 公开文件直接可用的 URL(头像等)
}
actor UploadClient {
static let shared = UploadClient()
private let session = URLSession.shared
// ── POST /upload/file ──────────────────────────────────────────────
/// type: "avatar" | "signal_image" | "signal_audio" | "chat_image" | "chat_audio" | "chat_multi"
func uploadFile(
at fileURL: URL,
type: String,
onProgress: ((Double) -> Void)? = nil
) async throws -> UploadResult {
let boundary = "TB-\(UUID().uuidString)"
var request = URLRequest(url: URL(string: "\(AppConfig.apiBaseURL)/upload/file")!)
request.httpMethod = "POST"
request.setValue("multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type")
// 构建 multipart body
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"type\"\r\n\r\n".data(using: .utf8)!)
body.append("\(type)\r\n".data(using: .utf8)!)
let fileData = try Data(contentsOf: fileURL)
let mime = mimeType(for: fileURL)
let filename = fileURL.lastPathComponent
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: \(mime)\r\n\r\n".data(using: .utf8)!)
body.append(fileData)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
RequestSigner.sign(request: &request, body: nil) // multipart 不参与签名 body
let (data, _) = try await session.data(for: request)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
guard json?["code"] as? Int == 0,
let payload = json?["data"] as? [String: Any],
let objectKey = payload["object_key"] as? String else {
let msg = json?["msg"] as? String ?? "上传失败"
throw AppAPIError(code: json?["code"] as? Int ?? -1, message: msg)
}
return UploadResult(objectKey: objectKey, url: payload["url"] as? String)
}
// ── POST /upload/oss/sign ──────────────────────────────────────────
/// 批量获取私有文件签名 URL,返回 [objectKey: signedURL]
func batchSignURLs(_ objectKeys: [String]) async throws -> [String: String] {
guard !objectKeys.isEmpty else { return [:] }
let boundary = "TB-\(UUID().uuidString)"
var request = URLRequest(url: URL(string: "\(AppConfig.apiBaseURL)/upload/oss/sign")!)
request.httpMethod = "POST"
request.setValue("multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type")
var body = Data()
for key in objectKeys {
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"objects[]\"\r\n\r\n".data(using: .utf8)!)
body.append("\(key)\r\n".data(using: .utf8)!)
}
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
RequestSigner.sign(request: &request, body: nil)
let (data, _) = try await session.data(for: request)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
guard json?["code"] as? Int == 0,
let payload = json?["data"] as? [String: Any],
let urlsDict = payload["urls"] as? [String: String?] else {
return [:]
}
// 过滤空值(已删除文件)
return urlsDict.compactMapValues { $0.flatMap { $0.isEmpty ? nil : $0 } }
}
private func mimeType(for url: URL) -> String {
switch url.pathExtension.lowercased() {
case "jpg", "jpeg": return "image/jpeg"
case "png": return "image/png"
case "webp": return "image/webp"
case "m4a": return "audio/mp4"
case "mp3": return "audio/mpeg"
default: return "application/octet-stream"
}
}
}
```
---
## 九、媒体管线(PHPicker → Crop → Compress → Upload)
```swift
// MediaPipeline.swift
// 图片完整链路:选图 → 裁剪 → 压缩 → 上传 → 返回 objectKey
struct MediaPipeline {
// ── 头像上传(1:1 强制裁剪)──────────────────────────────────────
@MainActor
static func pickAndUploadAvatar(
from viewController: UIViewController
) async throws -> UploadResult {
let image = try await pickImage(from: viewController, ratio: 1.0 / 1.0)
let cropped = try await cropImage(image, ratio: 1.0 / 1.0, from: viewController)
let fileURL = try compress(cropped, maxSize: 1024 * 1024, quality: 0.85) // 1MB
return try await UploadClient.shared.uploadFile(at: fileURL, type: "avatar")
}
// ── 信号图片(不强制裁剪)───────────────────────────────────────
@MainActor
static func pickAndUploadSignalImage(
from viewController: UIViewController
) async throws -> UploadResult {
let image = try await pickImage(from: viewController, ratio: nil)
let fileURL = try compress(image, maxSize: 2 * 1024 * 1024, quality: 0.8)
return try await UploadClient.shared.uploadFile(at: fileURL, type: "signal_image")
}
// ── 语音上传(录制完成后直接传文件路径)─────────────────────────
static func uploadAudio(at fileURL: URL, type: String = "chat_audio") async throws -> UploadResult {
return try await UploadClient.shared.uploadFile(at: fileURL, type: type)
}
// ── 内部工具 ─────────────────────────────────────────────────────
@MainActor
private static func pickImage(
from vc: UIViewController,
ratio: CGFloat?
) async throws -> UIImage {
return try await withCheckedThrowingContinuation { continuation in
var config = PHPickerConfiguration(photoLibrary: .shared())
config.selectionLimit = 1
config.filter = .images
let picker = PHPickerViewController(configuration: config)
// 通过 Delegate 桥接回调
let delegate = PHPickerDelegate { result in
Task {
if let result {
continuation.resume(returning: result)
} else {
continuation.resume(throwing: AppAPIError(code: -2, message: "未选择图片"))
}
}
}
picker.delegate = delegate
vc.present(picker, animated: true)
}
}
@MainActor
private static func cropImage(
_ image: UIImage,
ratio: CGFloat,
from vc: UIViewController
) async throws -> UIImage {
return try await withCheckedThrowingContinuation { continuation in
let cropVC = TOCropViewController(croppingStyle: .default, image: image)
cropVC.aspectRatioPreset = .presetSquare
cropVC.aspectRatioLockEnabled = true
cropVC.didCropToRect = { croppedImage, _, _ in
continuation.resume(returning: croppedImage)
}
cropVC.didFinishCancelled = { _ in
continuation.resume(throwing: AppAPIError(code: -2, message: "取消裁剪"))
}
vc.present(cropVC, animated: true)
}
}
private static func compress(
_ image: UIImage,
maxSize: Int,
quality: CGFloat
) throws -> URL {
var data = image.jpegData(compressionQuality: quality) ?? Data()
var q = quality
while data.count > maxSize && q > 0.1 {
q -= 0.1
data = image.jpegData(compressionQuality: q) ?? data
}
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("\(UUID().uuidString).jpg")
try data.write(to: url)
return url
}
}
```
---
## 十、使用示例
```swift
// ViewModel 中的典型用法
@MainActor
@Observable
final class SignalPublishViewModel {
var uploadProgress: Double = 0
var isUploading = false
func publishSignal(category: Int, content: String, image: URL?) async throws {
isUploading = true
defer { isUploading = false }
var attachment: SignalAttachment? = nil
if let imageURL = image {
let result = try await UploadClient.shared.uploadFile(
at: imageURL, type: "signal_image"
)
attachment = SignalAttachment(objectKey: result.objectKey, mime: "image/jpeg")
}
let req = SignalPublishRequest(category: category, content: content, attachment: attachment)
try await APIClient.shared.post("/signal/publish", body: req)
}
}
```
---
## 十一、UIApplication 辅助扩展
```swift
// 用于 ResponseHandler 弹窗
extension UIApplication {
static var topViewController: UIViewController? {
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
return scenes.first?.windows.first { $0.isKeyWindow }?.rootViewController?.topmost
}
}
extension UIViewController {
var topmost: UIViewController {
if let presented = presentedViewController { return presented.topmost }
if let nav = self as? UINavigationController { return nav.visibleViewController?.topmost ?? self }
if let tab = self as? UITabBarController { return tab.selectedViewController?.topmost ?? self }
return self
}
}
```