SDK 与源码理解
Moya

SDK - Moya

Moya 库的使用与原理理解

1)什么是 Moya?为什么用它而不是直接用 URLSession/Alamofire?

答案:

Moya 是对网络层的“声明式抽象”,把“要请求什么”与“怎么发请求”解耦。你用 TargetType 声明接口(baseURL、path、method、task、headers…),再交给 MoyaProvider 发送。

  • 优势:接口集中管理、类型安全、便于单测(内置 stub)、统一日志/鉴权/重试(插件机制)。底层默认基于 URLSession(历史版本可选 Alamofire),但开发者主要面向 Moya 的抽象而不是传输细节。

2)Moya 的核心概念与角色有哪些?

答案:

  • TargetType:描述“一个接口”的所有元信息(baseURL、path、method、task、headers、sampleData)。
  • Task:请求体与参数的“载体”,如 .requestPlain、.requestParameters、.requestJSONEncodable、.uploadMultipart、.downloadDestination 等。
  • MoyaProvider:发送请求的门面;可注入 endpointClosure、requestClosure、stubClosure、plugins。
  • Endpoint:TargetType → Endpoint 的中间映射,最终再转成 URLRequest。
  • PluginType:拦截/观察请求生命周期(will/ did Request、will/ did Receive)。
  • Response:响应载体,含 statusCode、data、request、response。
  • MoyaError:统一错误类型(映射失败、状态码不合法、底层错误等)。

3)手写一个最小可用的 Target 与 Provider?

答案:

enum GitHubAPI {
  case user(name: String)
}
 
extension GitHubAPI: TargetType {
  var baseURL: URL { URL(string: "https://api.github.com")! }
  var path: String {
    switch self { case .user(let name): return "/users/\(name)" }
  }
  var method: Moya.Method { .get }
  var task: Task { .requestPlain }
  var headers: [String: String]? { ["Accept": "application/json"] }
  var sampleData: Data { #"{"login":"octocat"}"#.data(using: .utf8)! }
}
 
let provider = MoyaProvider<GitHubAPI>()
provider.request(.user(name: "octocat")) { result in
  switch result {
  case .success(let response):
    print(response.statusCode, response.data)
  case .failure(let error):
    print(error)
  }
}

要点:sampleData 用于 Stub 与单测;path 不加 baseURL;task 指定编码方式。

4)Task 怎么选?各自会用什么编码?

答案:

  • .requestPlain:无 body,无 query。
  • .requestParameters(parameters:encoding:):
    • URLEncoding.default:GET → query,POST → body(application/x-www-form-urlencoded)。
    • URLEncoding.queryString:强制 query。
    • JSONEncoding.default:JSON body。
  • .requestJSONEncodable(Encodable):自动编码 JSON body。
  • .uploadMultipart([MultipartFormData]) / .uploadFile(URL):上传。
  • .downloadDestination((URL, HTTPURLResponse) -> (URL)):下载到指定目录。

选择规则:GET/query 用 URLEncoding.queryString;提交 JSON 用 requestJSONEncodable 或 requestParameters + JSONEncoding;表单/文件用 upload*。

5)Moya 的请求流程(原理级)?

答案:

TargetType → Endpoint → URLRequest → URLSession:

  1. Provider 通过 endpointClosure 把 Target 映射成 Endpoint;
  2. requestClosure 把 Endpoint 转为 URLRequest(可在这里改超时、缓存策略、HTTPHeader);
  3. 走底层传输(URLSession),期间 PluginType 的 will/did 钩子被调用;
  4. 返回 Response 或抛出 MoyaError;
  5. 可在 validationType 控制“合法状态码”(例如 .successCodes)。

6)如何做日志、鉴权、埋点、重试?

答案:

Plugin

  • 内置 NetworkLoggerPlugin 打印请求/响应。
  • 自定义插件实现 prepare(:target:)、willSend(:target:)、didReceive(:target:)、process(:target:):
    • 鉴权:在 prepare 里统一加 Authorization;
    • 埋点:在 will/did 钩子记录耗时、状态;
    • 重试:在 didReceive 检测 401/5xx,再触发刷新 Token 或指数退避重试(注意避免死循环,通常在外层封装)。

7)如何与 async/await、Combine、RxSwift 搭配?

答案:

  • async/await:Moya 提供 request 的异步封装(或自己用 withCheckedThrowingContinuation 包一层)。
extension MoyaProvider {
  func request(_ target: Target) async throws -> Response {
    try await withCheckedThrowingContinuation { cont in
      self.request(target) { result in
        switch result {
        case .success(let resp): cont.resume(returning: resp)
        case .failure(let err):  cont.resume(throwing: err)
        }
      }
    }
  }
}
  • Combine:provider.requestPublisher(target) → AnyPublisher<Response, MoyaError>。
  • RxSwift:provider.rx.request(target) → Single

任选其一贯穿工程,避免三套并存。

8)怎么做解码与错误处理( Response → Model )?

答案:

  • 合法性:try response.filterSuccessfulStatusCodes();
  • 解码:try response.map(Model.self, using: JSONDecoder());
  • 业务错误:有“统一返回壳”(code/msg/data)时,先解 Envelope,再根据 code 抛业务错误;
  • 兜底:网络错误(underlying)、状态码错误(statusCode)、映射错误(objectMapping)分层处理,并记录上下文(URL、body 片段、traceId)。

9)如何使用 Stub 做单元测试与离线开发?

答案:

  • Provider 初始化时注入 stubClosure:
let stubs = MoyaProvider<GitHubAPI>(stubClosure: MoyaProvider.immediatelyStub)
  • sampleData 返回该接口的“黄金样本”(建议由录制/快照工具生成,避免手写漂移)。
  • 可用 delayedStub(…) 模拟网络延时;
  • 在测试中验证解码逻辑和状态码分支,不依赖真实网络。

10)如何设置超时、缓存与并发控制?

答案:

  • 超时:在 requestClosure 中修改 URLRequest.timeoutInterval;或自定义 URLSessionConfiguration.timeoutIntervalForRequest。
  • 缓存:配 URLSessionConfiguration.urlCache 与 request.cachePolicy;对纯 GET 可配合 ETag/If-None-Match。Moya 本身不做缓存策略。
  • 并发:使用自建 OperationQueue 或 URLSessionConfiguration 的 httpMaximumConnectionsPerHost;业务节流常放在上层(例如请求合并/去抖)。

11)上传与下载在 Moya 里的实现要点?

答案:

  • 上传表单:
let form = [MultipartFormData(provider: .data(imgData),
                              name: "file", fileName: "a.jpg", mimeType: "image/jpeg")]
var task: Task { .uploadMultipart(form) }
  • 上传进度:provider.request(target, progress: { p in ... })。
  • 下载到文件:Task.downloadDestination 提供保存路径闭包,记得处理同名覆盖/清理临时文件。

12)如何统一加公共参数、头、签名?

答案:

  • 轻量:在 headers 返回公共头;或 task 里合并公共参数。
  • 规范:写 Endpoint 包装器Plugin 在 prepare(_:target:) 注入(避免每个 Target 重复)。
  • 安全:签名(如 HMAC)在 prepare 阶段基于最终 URLRequest 计算最稳妥。

13)validationType 有什么用?

答案:

控制“哪些状态码视为成功”:

  • .none:不校验;
  • .successCodes:200…299;
  • .successAndRedirectCodes:200…399;
  • .customCodes([Int]):自定义。

随后可直接 try response.filter(statusCodes: …) 快速分支。

14)如何优雅地组织大型项目中的 Targets?

答案:

  • 按“业务域”切分枚举:AuthAPI、UserAPI、FeedAPI…;
  • 抽一个 BaseTarget 协议提供默认实现(baseURL、headers、通用 sampleData)再扩展;
  • 公共 Provider 工厂(dev/prod 注入不同 plugins、baseURL、stub 策略);
  • 配置化域名/路径前缀(避免硬编码)。

15)如何做 Token 过期自动刷新(避免并发风暴)?

答案:

  • 写 AuthPlugin 或在 Provider 外层包一层“网关”;
  • 把 401 的请求暂存到队列,串行执行一次 refreshToken,刷新成功后重放;失败则统一登出;
  • 用 actor/锁保证只刷新一次;对重放加最大次数与黑名单路径保护。

16)常见坑与排查思路

答案:

  • path 前多/少 “/” 导致 URL 拼错 → 用 URL(string:relativeTo:) 或单元测试覆盖。
  • URLEncoding.default 行为随 method 变化 → GET 强制用 .queryString。
  • sampleData 与真实响应结构漂移 → 用录制工具或 CI 校验样本 schema。
  • 重试循环 → 在插件维护“已重试次数”,并对 4xx 禁止重试。
  • 解码失败定位难 → 日志打印 statusCode、requestId/traceId、snippet(前 N 字节),避免全量个人数据落盘。

17)给一份“可落地”的 Provider 工厂与插件示例

答案:

struct ProviderFactory {
  static func make<T: TargetType>(
    plugins: [PluginType] = []
  ) -> MoyaProvider<T> {
 
    let endpointClosure = { (target: T) -> Endpoint in
      Endpoint(url: target.baseURL.appendingPathComponent(target.path).absoluteString,
               sampleResponseClosure: { .networkResponse(200, target.sampleData) },
               method: target.method,
               task: target.task,
               httpHeaderFields: target.headers)
    }
 
    let requestClosure = { (endpoint: Endpoint, done: RequestResultClosure) in
      do {
        var request = try endpoint.urlRequest()
        request.timeoutInterval = 15
        done(.success(request))
      } catch {
        done(.failure(MoyaError.underlying(error, nil)))
      }
    }
 
    return MoyaProvider<T>(
      endpointClosure: endpointClosure,
      requestClosure: requestClosure,
      stubClosure: MoyaProvider.neverStub,
      plugins: plugins
    )
  }
}
 
final class AuthPlugin: PluginType {
  func prepare(_ request: URLRequest, target: TargetType) -> URLRequest {
    var r = request
    if let token = TokenStore.shared.accessToken {
      r.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    }
    return r
  }
}

18)如何在 MVVM/Clean 架构中放置 Moya?

答案:

  • Data 层:API(Moya)→ DTO → Repository;
  • Domain 层:UseCase 依赖 Repository(协议);
  • Presentation:ViewModel 调 UseCase,不感知 Moya;
  • 测试替换:用 Stub Repository 或 MoyaProvider.immediatelyStub。

19)如何优雅地做环境切换(Dev/Stage/Prod)?

答案:

  • AppConfig.current.baseURL 注入到 BaseTarget;
  • Provider 工厂按环境注入不同 plugins(如更详细的 Logger、Mock 插件);
  • 用编译配置或远端开关切换;尽量避免在 Target 枚举里硬编码多个域名。

20)一句话速记(面试 30 秒版)

答案:

Moya 通过 TargetType 声明接口,把 Target → Endpoint → URLRequest 的过程管起来,用 MoyaProvider 发请求,PluginType 统一做日志、鉴权和重试;测试用 sampleData + Stub,业务用 Task 精准描述参数与上传下载;上层用 async/await/Combine/Rx 解耦并行与解码,Domain 只见 Repository,不见网络。

代码速粘贴清单(常用片段)

// 1) async/await 调用
let resp = try await provider.request(.user(name: "octocat"))
let model = try resp.filterSuccessfulStatusCodes()
                    .map(User.self, using: JSONDecoder())
 
// 2) Combine
provider.requestPublisher(.user(name: "octocat"))
  .tryMap { try $0.filterSuccessfulStatusCodes() }
  .decode(type: User.self, decoder: JSONDecoder())
 
// 3) RxSwift
provider.rx.request(.user(name: "octocat"))
  .filterSuccessfulStatusCodes()
  .map(User.self)
 
// 4) 上传进度
provider.request(.uploadAvatar(data)) { _ in } progress: { p in
  print(p.progress) // 0.0 ... 1.0
}

面试延伸点(扣题加分)

  • 解释 Plugin 链式拦截重试幂等性(GET 可重试,非幂等 POST 谨慎)。
  • 讲清 解码层(DTO/Model 分离)与 错误分层(网络/协议/业务)。
  • 说出 Stub 策略录制响应样本 的 CI 校验做法。
  • 描述 Token 刷新单飞(只刷新一次,队列重放)与并发安全设计。