使用 Loader 优雅处理数据模型转换
了解如何使用 Loader Pattern 解决 Swift 中数据模型转换的问题。
为什么需要数模模型转换?
Apple 推荐的 SwiftUI + SwiftData 设计模式
设想一下,如果使用 SwiftUI + SwiftData 开发应用,首先需要创建一个数据模型,例如 Book:
import SwiftData
@Model
class Book {
var title: String
var author: String
var publishYear: Int
var coverImage: Data?
var isbn: String
var summary: String
var pageCount: Int
var rating: Double
// 省略初始化器
}
然后,创建一个 View
组件用于显示 Book
的详细信息。根据 Apple 最佳实践,BookDetailView 最好接受一个特定的模型对象(这里是Book):
struct BookDetailView: View {
let book: Book
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// 封面图片
if let coverData = book.coverImage,
let uiImage = UIImage(data: coverData) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 300)
}
// 书籍标题
}
.padding()
}
.navigationTitle("图书详情")
}
}
网络数据与本地模型的冲突
以上设计在展示本地数据的场景下工作良好。
但当我们需要添加搜索网络数据的功能时,问题就出现了:
我们需要根据后端API创建一个新的结构体用于存储后端返回的数据,例如BookResponse:
struct BookResponse: Decodable {
let id: String
let volumeInfo: VolumeInfo
struct VolumeInfo: Decodable {
let title: String
let authors: [String]?
let publishedDate: String
let description: String?
let pageCount: Int?
let imageLinks: ImageLinks?
let industryIdentifiers: [IndustryIdentifier]?
let averageRating: Double?
struct ImageLinks: Decodable {
let thumbnail: String?
let smallThumbnail: String?
}
struct IndustryIdentifier: Decodable {
let type: String
let identifier: String
}
}
}
当用户点击搜索结果,需要展示图书详情时,最理想的方式是直接复用BookDetailView
组件,而无需创建新组件。
但 BookDetailView
只能接受 Book
对象,不支持 BookResponse
对象。
为了解决这个问题,有两种方法:
- 第一种方法:在 BookDetailView 中添加一个新的初始化器,支持 BookResponse 模型。但这种方式会让 BookDetailView 代码变得臃肿,特别是当需要支持多个不同的模型时。不推荐。
- 第二种方法:将 BookResponse 转换成 Book 模型,再使用 BookDetailView 进行加载——推荐。
模型转换的实施方案
在 BookDetailView 内部执行模型转换(不推荐)
注意,这不同于接受多个不同的模型对象,并分别处理各个对象的展示逻辑。而是将多个模型转换成一个同意模型进行处理。

这种方式会让BookDetailView
代码变得臃肿,特别是当需要支持多个不同模型时。不符合单一责任原则。
并且,和 Vertical Slice Architecture (垂直切片架构)架构模式不契合:

在导航链接处进行转换
NavigationLink {
let convertedBookmark = movie.toBookmark()
BookmarkDetailView(bookmark: convertedBookmark, displayMode: .preview)
} label: {
DoubanContentCard(contentType: .movie(movie))
}
这种方式简单直接,不需要额外文件。
但是如果模型转换比较耗时,会导致 UI 出现延迟。没有加载状态指示,转换过程中用户体验不佳。适用于模型转换简单的场景。
使用独立的 Loader 组件执行数据转换
struct BookResponseDetailLoader: View {
let bookResponse: BookResponse
@State private var book: Book?
var body: some View {
Group {
if let book = book {
BookDetailView(book: book)
} else {
// 加载中显示进度指示器
VStack {
Spacer()
ProgressView()
Text("正在加载内容...")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.top, 8)
Spacer()
}
}
}
.task {
// 执行模型转换
book = bookResponse.toBook()
}
}
}
这种方法将模型转换与视图展示分离,提供了加载反馈,并且只在视图真正显示时执行一次转换。
使用 Loader 模式
创建模型转化方法
根据单一责任原则(SRP),转换逻辑应该是数据模型的责任,而不是视图的责任。
因此,推荐将转换方法创建在模型的扩展中。例如:
extension BookResponse {
func toBook() -> Book {
let authors = self.volumeInfo.authors?.joined(separator: ", ") ?? "未知作者"
let year = Int(self.volumeInfo.publishedDate.prefix(4)) ?? 0
return Book(
title: self.volumeInfo.title,
author: authors,
publishYear: year,
isbn: self.volumeInfo.industryIdentifiers?.first?.identifier ?? "",
summary: self.volumeInfo.description ?? "",
pageCount: self.volumeInfo.pageCount ?? 0,
rating: self.volumeInfo.averageRating ?? 0
)
}
}
创建 Loader 组件(单个数据源)
对于简单场景,可以使用直接的实现方式,不需要闭包和复杂泛型:
/// 简单直接的模型加载器
struct BookLoader: View {
// 源数据
private let bookResponse: BookResponse
@State private var book: Book?
init(bookResponse: BookResponse) {
self.bookResponse = bookResponse
}
var body: some View {
Group {
if let book = book {
BookDetailView(book: book)
} else {
VStack {
Spacer()
ProgressView()
Text("正在加载内容...")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.top, 8)
Spacer()
}
}
}
.task {
book = bookResponse.toBook()
}
}
}
// 使用示例
NavigationLink {
BookLoader(bookResponse: bookResponse)
} label: {
BookListItemView(bookResponse: bookResponse)
}
对于简单项目,可以使用直接的 Loader 组件;对于复杂项目,通用闭包 Loader 提供更好的扩展性。
创建 Loader 组件(支持多种数据源)
对于需要处理多种数据源的情况,可以在同一个Loader中提供多个初始化方法:
/// 通用内容加载器 - 支持多种数据源
struct ContentLoader: View {
// 源数据 - 使用Any存储不同类型
private let source: Any
@State private var book: Book?
// 初始化方法 - 网络API响应
init(bookResponse: BookResponse) {
self.source = bookResponse
}
// 初始化方法 - 其他API响应
init(googleBookResponse: GoogleBookResponse) {
self.source = googleBookResponse
}
// 初始化方法 - 第三方来源
init(amazonBook: AmazonBookData) {
self.source = amazonBook
}
var body: some View {
Group {
if let book = book {
BookDetailView(book: book)
} else {
VStack {
Spacer()
ProgressView()
Text("正在加载内容...")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.top, 8)
Spacer()
}
}
}
.task {
// 根据源数据类型执行不同的转换
if let bookResponse = source as? BookResponse {
book = bookResponse.toBook()
} else if let googleBook = source as? GoogleBookResponse {
book = googleBook.toBook()
} else if let amazonBook = source as? AmazonBookData {
book = amazonBook.toBook()
}
}
}
}