普通视图

发现新文章,点击刷新页面。
今天 — 2025年1月18日首页

使用SwiftUI+MVVM+Combine构建一个简化版V2EX客户端

作者 Aaron0927
2025年1月17日 15:37

背景

SwiftUI 是苹果推出的一种全新框架,专为开发者打造简单、高效、直观的开发体验。相比传统的 UIKit 开发模式,SwiftUI 让界面构建变得更容易,代码更精简,尤其是在响应式编程方面表现出色。作为一名开发者,我在学习 SwiftUI 的过程中,发现市面上的教程大多只讲解零散的功能点,很难系统性地帮助我们掌握 SwiftUI 开发。

为了更深入地理解 SwiftUI,同时提升自己的开发能力,我决定从零开始开发一个完整的 App。希望通过这篇文章的讲解,能帮助你也轻松上手。

我们将基于 SwiftUI、Combine 和 MVVM 架构来构建项目。这不仅是学习 SwiftUI 的理想方式,也是构建现代 iOS 应用的好实践。更重要的是,这篇文章适合初学者,无需担心基础问题。

最终源码已托管在 GitHub 仓库,可以参考。

项目截图

light_screenshot.jpeg

dark_screenshot.jpeg

项目概述

通过本文的开发实践,我们将构建一个简化版的 V2EX 社区客户端。

接口地址

UI地址

功能特点

  • 使用 SwiftUI 构建完全原生的声明式 UI。
  • 基于 Combine 处理响应式编程和异步数据流。
  • MVVM 架构,清晰的分层设计和可测试代码。
  • 自定义缓存机制,支持本地存储。
  • 多语言支持,包括英语和简体中文。
  • 深色模式和浅色模式的无缝集成。

项目结构

应用按照以下目录组织:

V2EXClient
├── Services      # 网络请求等服务
├── Utilties      # 工具类
├── Extension     # 扩展类
├── Core
  ├── Home            # 首页
    ├── Models        # 数据模型
    ├── Views         # SwiftUI 界面
    ├── ViewModels    # 视图模型,负责业务逻辑
  ├── Detail          # 详情页
    ├── Models        # 数据模型
    ├── Views         # SwiftUI 界面
    ├── ViewModels    # 视图模型,负责业务逻辑

开发流程

这里以开发首页最热列表页为例,讲解如何在项目中使用MVVM+Combine方式开发

1.数据模型

根据接口地址返回的数据对象,创建对应的数据模型:

/**
 URL:
 https://www.v2ex.com/api/topics/hot.json
 Response:
 {
...
 }
 */

// MARK: - TopicModel - 帖子
struct TopicModel: Identifiable, Codable {
    let node: NodeModel
    let member: MemberModel
    let lastReplyBy: String
    let lastTouched: Double
    let title: String
    let url: String
    let created, lastModified: Double
    let deleted, replies: Int
    let id: Int
    let content: String
    let contentRendered: String
    
    enum CodingKeys: String, CodingKey {
        case node, member
        case lastReplyBy = "last_reply_by"
        case lastTouched = "last_touched"
        case title, url, created, deleted, content
        case contentRendered = "content_rendered"
        case lastModified = "last_modified"
        case replies, id
    }
}

2.数据服务类

拿到接口地址后,我们需要发起请求,拿到数据并发送出去,这里使用Publisher

1.封装网络请求类

class NetworkingManager {
    enum NetworkingError: LocalizedError {
        case badURLResponse(url: URL)
        case unknown

        var errorDescription: String? {
            switch self {
            case .badURLResponse(let url):
                return "[🔥] Bad response from URL: \(url)"
            case .unknown:
                return "[⚠️] Unknown error occured"
            }
        }
    }

    static func download(url: URL, token: String? = nil) -> AnyPublisher<Data, Error> {
        var request = URLRequest(url: url)
        if let token = token {
            request.httpMethod = "GET"
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }

        return URLSession.shared.dataTaskPublisher(for: request)
            .subscribe(on: DispatchQueue.global(qos: .default))
            .tryMap({ try self.handleURLResponse(output: $0, url: url) })
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }

    static func handleURLResponse(output: URLSession.DataTaskPublisher.Output, url: URL) throws -> Data {
        guard let response = output.response as? HTTPURLResponse,
              response.statusCode >= 200 && response.statusCode < 300 else {
            throw NetworkingError.badURLResponse(url: url)
        }
        return output.data
    }

    static func handleCompletion(completion: Subscribers.Completion<Error>) {
        switch completion {
        case .finished:
            break
        case .failure(let error):
            print(error.localizedDescription)
        }
    }
}

2.创建DataService

class TopicDataService: ObservableObject {

    @Published var topics: [TopicModel] = []
    private var topicSubscribtion: AnyCancellable?

    init() {
   getTopics()
    }

    func getTopics() {
        guard let url = URL(string: "https://www.v2ex.com/api/topics/hot.json") else {
            return
        }

        topicSubscribtion = NetworkingManager.download(url: url)
            .decode(type: [TopicModel].self, decoder: JSONDecoder())
            .sink(receiveCompletion: NetworkingManager.handleCompletion, receiveValue: { [weak self] returnedTopics in
                self?.topics = returnedTopics
                self?.topicSubscribtion?.cancel()
            })
    }
}

3.ViewModel

class HomeViewModel: ObservableObject {

    @Published var topics: [TopicModel] = []

    private let dataService = TopicDataService()
    private var cancelables = Set<AnyCancellable>()

    init() {
        addSubscribers()
    }

    private func addSubscribers() {
        dataService.$topics
            .sink { [weak self] returnedTopics in
                self?.topics = returnedTopics
            }
            .store(in: &cancelables)
    }
}

4.更新UI页面

struct HomeView: View {

    @StateObject private var vm: HomeViewModel = HomeViewModel()

    var body: some View {
   List {
  ForEach(vm.topics) { topic in
TopicRowView(topic: topic)
  }
   }
        .navigationTitle(
            Text("Topic")
        )
        .navigationBarTitleDisplayMode(.inline)
    }
}

源码

完整代码已托管在 GitHub 仓库。如果你感兴趣,可以下载运行并根据自己的需求扩展功能。

❌
❌