使用 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 (垂直切片架构)架构模式不契合:

使用 Vertical Slice Architecture 组织代码
了解如何使用垂直切片架构(Vertical Slice Architecture)来组织 SwiftUI 应用代码,提高代码复用性并更好地适应 iOS 17 新框架。

在导航链接处进行转换

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()
            }
        }
    }
}