SwiftData|使用 DTO 处理网络请求

了解如何使用 DTO 解耦网络请求,避免直接发送 SwiftData 对象。

SwiftData|使用 DTO 处理网络请求

如果你使用 SwiftData 进行本地数据持久化,如何处理网络请求,并使用网络响应更新本地 SwiftData 对象?——这就是这篇文章将会讨论的主题——解耦网络请求和本地数据转换的最佳实践方案。

使用 DTO 数据传输对象

根据 Swift 编程的最佳实践,在处理网络数据和本地持久化时,应当创建独立的 DTO(Data Transfer Object) 模式来处理数据解码,而不是直接让 SwiftData 模型遵循 Codable 协议。

在之前的这篇文章中,我已经介绍过了什么是 DTO:

Swift 本地数据模型与后端 API 数据转换解决方案
了解在 Swift 编程中,如何解决本地数据模型与远程 API 通信时的数据转换问题。

由于 SwiftData 不支持 Sendable 协议,在跨线程传递 SwiftData 对象时可能引发数据竞争问题,因此 SwiftData 模型不适合直接用于网络传输。

通过解耦网络处理与数据持久化,网络层仅负责发送请求并返回结果(以独立的 Struct 保存),随后利用专门的 DTO 层将 Struct 转换为 SwiftData 对象,这样可以避免数据并发问题。

苹果 DataCache 示例项目做法

苹果开发者网站提供了 DataCache 示例项目,演示了如何通过网络请求获取数据,并在本地使用 SwiftData 进行数据持久化:

Maintaining a local copy of server data | Apple Developer Documentation
Create and update a persistent store to cache read-only network data.

这个示例项目就使用了 DTO 模式来处理网络数据和本地持久化。

  1. 项目首先创建了 Quake 数据模型,但并没有实现 Codable 协议
@Model
class Quake {
    /// A unique identifier associated with each earthquake event.
    @Attribute(.unique) var code: String

    /// The measured strength of the earthquake.
    var magnitude: Double

    /// When the earthquake happened.
    var time: Date

    /// Where the earthquake happened.
    var location: Location

    /// Creates a new earthquake from the specified values.
    init(
        code: String,
        magnitude: Double,
        time: Date,
        name: String,
        longitude: Double,
        latitude: Double
    ) {
        self.code = code
        self.magnitude = magnitude
        self.time = time
        self.location = Location(name: name, longitude: longitude, latitude: latitude)
    }
}
  1. 然后创建了专门的 DTO 层(GeoFeatureCollection),用于保存从服务器响应的数据。
struct GeoFeatureCollection: Decodable {
    let features: [Feature]

    struct Feature: Decodable {
        let properties: Properties
        let geometry: Geometry
        
        struct Properties: Decodable {
            let mag: Double
            let place: String
            let time: Date
            let code: String
        }

        struct Geometry: Decodable {
            let coordinates: [Double]
        }
    }
}

GeoFeatureCollection 和其内部的 Feature 结构体作为网络数据的 DTO,完全匹配 从服务器返回的 JSON 结构——这样可以方便后续转换成 SwiftData 时,可以任意调整使用哪些数据。

  1. 在 DTO(GeoFeatureCollection)中创建了扩展方法,用于从服务端获取数据,并返回 GeoFeatureCollection 结构体的格式:

但这里不符合最佳实践。一般来说,DTO 应该只负责数据结构定义和序列化。

一个更好的实现方式是,创建一个专门的网络服务层负责处理网络请求,并 -> GeoFeatureCollection 对象:

// NetworkService.swift
class QuakeNetworkService {
    func fetchQuakes() async throws -> GeoFeatureCollection {
        let url = URL(string: "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson")!
        
        let (data, response) = try await URLSession.shared.data(from: url)
        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            throw NetworkError.invalidResponse
        }
        
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .millisecondsSince1970
        return try decoder.decode(GeoFeatureCollection.self, from: data)
    }
}
  1. 最后创建了一个 Quake+GeoFeatureCollection 文件,在这个文件中,分别为 Quake 和 GeoFeatureCollection 创建了一个扩展方法,用于从服务器获取数据并创建一个 Quake 对象:

最佳实践方案

创建三个文件

创建三个文件,分别对应:

  • SwiftData Model 文件
  • DTO Struct 文件
  • SwiftData + DTO Struct 文件

在「SwiftData + DTO 结构体文件」文件中,创建一个 SwiftData 模型的扩展,并在其中创建一个便利初始化方法,完成 DTO -> Model 的转换:

文件命名规则

初始化器命名规则

在 Model 扩展文件中创建 init() 初始化器,使用简单的 from 关键词即可:

如何处理批量数据转换?

如果从服务端获取的不是单个对象,而是一个数组,此时我们需要从 DTO 中创建并返回一个 SwiftData Model 数组。

在这种情况下,可以同时使用便利初始化器和静态方法:

  1. 创建便利初始化器:创建单个模型对象
  2. 创建静态方法:在这个方法中,调用以上便利初始化器,并返回一个数组。

这个静态方法,建议采用类似以下命名方式,简单且符合功能描述: