使用SwiftUI+MVVM+Combine构建一个简化版V2EX客户端
背景
SwiftUI 是苹果推出的一种全新框架,专为开发者打造简单、高效、直观的开发体验。相比传统的 UIKit 开发模式,SwiftUI 让界面构建变得更容易,代码更精简,尤其是在响应式编程方面表现出色。作为一名开发者,我在学习 SwiftUI 的过程中,发现市面上的教程大多只讲解零散的功能点,很难系统性地帮助我们掌握 SwiftUI 开发。
为了更深入地理解 SwiftUI,同时提升自己的开发能力,我决定从零开始开发一个完整的 App。希望通过这篇文章的讲解,能帮助你也轻松上手。
我们将基于 SwiftUI、Combine 和 MVVM 架构来构建项目。这不仅是学习 SwiftUI 的理想方式,也是构建现代 iOS 应用的好实践。更重要的是,这篇文章适合初学者,无需担心基础问题。
最终源码已托管在 GitHub 仓库,可以参考。
项目截图
项目概述
通过本文的开发实践,我们将构建一个简化版的 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 仓库。如果你感兴趣,可以下载运行并根据自己的需求扩展功能。