
1. 从MVC到MVVM为什么列表开发需要架构升级如果你做过iOS开发尤其是处理过稍微复杂一点的列表页面大概率经历过这样的场景一个ViewController里塞了上千行代码既要负责网络请求、数据解析又要处理UITableView的代理和数据源还得响应各种按钮点击和页面跳转。最后这个文件变成了一个难以维护、测试和复用的“上帝类”。这就是传统MVC架构在iOS开发中尤其是数据驱动型页面里最常见的痛点。MVVMModel-View-ViewModel架构的出现就是为了解决这种职责不清的问题。它不是一个凭空创造的概念而是对MVC模式中“Controller”过于臃肿的一种自然演进和职责拆分。在列表数据展示与交互这个高频场景里MVVM的价值被放得最大。它通过引入一个“ViewModel”层将原本属于Controller的数据处理、状态管理和业务逻辑剥离出来让ViewViewController UI组件只专注于UI的呈现和用户事件的传递。那么一个“完整”的列表功能实战到底意味着什么它绝不仅仅是把数据塞进UITableViewCell里显示出来那么简单。一个生产级的列表至少需要涵盖以下几个核心环节数据获取与解析可能是网络API也可能是本地数据库、数据绑定与驱动更新数据变了UI要自动变、用户交互处理点击、滑动、加载更多、状态管理加载中、空数据、错误、分页结束、以及良好的可测试性。MVVM架构正是为了优雅地实现这些环节而生的。在Swift生态中随着Combine框架和SwiftUI的成熟MVVM的实现变得更加自然和强大。但考虑到实际项目中UIKit的存量依然巨大且很多团队仍在使用它本次实战我们将聚焦于UIKit 手动数据绑定的方案。这套方案理解透彻后迁移到Combine响应式绑定或SwiftUI的声明式语法都会水到渠成。我们会构建一个模拟“新闻资讯列表”的应用从零开始一步步拆解如何用MVVM实现一个包含下拉刷新、上拉加载、错误重试、详情跳转等完整功能的列表。2. 项目结构与核心组件定义动手写代码之前合理的项目结构是成功的一半。一个清晰的目录不仅能让你自己思路清晰更能让后续维护的同事或者三个月后的你自己快速理解代码的意图。对于MVVM项目我们通常会按角色而非按功能来组织文件这有助于强化架构的边界感。我个人的习惯是在项目中建立Models、ViewModels、Views、Services、Utilities等分组。对于本次的列表案例核心的文件结构会是这样NewsListDemo/ ├── Models/ │ ├── NewsItem.swift // 数据模型 │ └── ListState.swift // 列表状态枚举 ├── ViewModels/ │ └── NewsListViewModel.swift // 列表的“大脑” ├── Views/ │ ├── ViewControllers/ │ │ └── NewsListViewController.swift │ ├── Cells/ │ │ └── NewsTableViewCell.swift │ └── CustomViews/ │ └── LoadingFooterView.swift // 加载更多视图 ├── Services/ │ └── NewsService.swift // 网络服务层 └── Utilities/ └── Bindable.swift // 简易数据绑定工具接下来我们逐一创建这些核心组件并解释其设计意图。2.1 数据模型Model的定义Model层代表纯粹的数据和业务规则。在我们的新闻列表里核心模型就是一条新闻。此外我们还需要一个枚举来清晰定义列表可能处于的各种状态这对于管理UI状态至关重要。// Models/NewsItem.swift import Foundation struct NewsItem: Codable, Equatable { let id: Int let title: String let summary: String let publishTime: String let source: String let imageUrl: String? // 实现Equatable便于后续比较更新 static func (lhs: NewsItem, rhs: NewsItem) - Bool { return lhs.id rhs.id } } // Models/ListState.swift enum ListState { case idle // 初始空闲状态 case loading // 首次加载或下拉刷新中 case loadingMore // 正在加载更多 case loaded // 加载完成有数据 case empty // 加载完成但数据为空 case error(Error) // 加载失败携带错误信息 case noMoreData // 已加载全部数据用于分页 // 便捷计算属性用于UI判断 var isLoading: Bool { switch self { case .loading, .loadingMore: return true default: return false } } var shouldShowError: Bool { if case .error self { return true } return false } }定义ListState枚举是一个非常重要的经验技巧。它将分散在各个Bool变量如isLoading、isError中的状态集中管理避免了状态组合爆炸的问题也让ViewModel的状态变更更加清晰和可预测。2.2 简易绑定工具Utilities/Bindable.swift在引入Combine或RxSwift这类重型响应式框架之前我们可以实现一个超轻量的绑定工具用于建立ViewModel和View之间的单向数据流。它的核心思想是“观察者模式”View监听ViewModel中某个属性的变化一旦变化就执行一个闭包来更新UI。// Utilities/Bindable.swift final class BindableT { typealias Listener (T) - Void private var listener: Listener? var value: T { didSet { // 值发生变化时通知所有监听者 listener?(value) } } init(_ value: T) { self.value value } // 绑定监听器并立即用当前值调用一次 func bind(listener: Listener?) { self.listener listener listener?(value) } }这个Bindable类虽然简单但威力巨大。ViewModel中将需要驱动UI更新的属性如数据数组、加载状态声明为Bindable类型。在ViewController的viewDidLoad中我们对这些属性进行绑定并在闭包中更新对应的UI。这样就实现了数据变化自动驱动UI更新无需手动调用tableView.reloadData()。注意这是一个简易实现仅用于教学和演示MVVM的数据流思想。在实际生产项目中如果逻辑复杂、数据流繁多强烈建议使用成熟的响应式框架如CombineiOS 13或RxSwift。它们提供了更强大的操作符和错误处理机制能更好地管理异步数据流和生命周期。2.3 网络服务层Services/NewsService.swiftService层负责处理一切外部数据交互如网络请求、数据库读写等。它将具体的实现细节如使用URLSession还是Alamofire封装起来向ViewModel提供干净的API。这里我们模拟一个网络请求。// Services/NewsService.swift import Foundation protocol NewsServiceProtocol { func fetchNews(page: Int, pageSize: Int, completion: escaping (Result[NewsItem], Error) - Void) } class NewsService: NewsServiceProtocol { // 模拟网络延迟 private let simulatedDelay: TimeInterval 1.5 func fetchNews(page: Int, pageSize: Int, completion: escaping (Result[NewsItem], Error) - Void) { // 在实际项目中这里会是真实的网络请求例如 // let url URL(string: https://api.example.com/news?page\(page)size\(pageSize))! // URLSession.shared.dataTask(...) // 此处使用DispatchQueue模拟异步请求 DispatchQueue.global().asyncAfter(deadline: .now() simulatedDelay) { // 模拟分页第一页返回20条第二页返回10条第三页及以后返回空数组 let totalItems page 1 ? 20 : (page 2 ? 10 : 0) guard totalItems 0 else { // 模拟成功但无更多数据 completion(.success([])) return } // 模拟随机生成新闻数据 var news: [NewsItem] [] let startId (page - 1) * pageSize 1 for i in 0..totalItems { let newsItem NewsItem( id: startId i, title: 新闻标题 \(startId i)Swift MVVM 架构深度解析, summary: 这是一条模拟的新闻摘要用于展示列表数据绑定与状态管理的完整流程。当前为第\(page)页第\(i1)条数据。, publishTime: 2023-10-\(10 i % 20) 14:30, source: [科技日报, 开发者头条, iOS Weekly].randomElement()!, imageUrl: i % 3 0 ? https://picsum.photos/seed/\(startIdi)/300/200 : nil // 部分有图 ) news.append(newsItem) } // 小概率模拟网络错误 let shouldFail Double.random(in: 0...1) 0.05 // 5%失败率 if shouldFail { let error NSError(domain: com.demo.network, code: 500, userInfo: [NSLocalizedDescriptionKey: 模拟网络请求失败]) completion(.failure(error)) } else { completion(.success(news)) } } } }使用Protocol定义服务接口是一个好习惯它便于进行单元测试可以轻松创建MockNewsService和未来替换底层实现比如从URLSession换到Alamofire。3. ViewModel业务逻辑与状态的中枢NewsListViewModel是整个MVVM架构的核心它是View的抽象模型包含了View所需的所有数据和状态。它不应该导入UIKit从而保持平台无关性便于测试和复用。3.1 ViewModel的属性设计ViewModel需要暴露哪些属性给View绑定这取决于UI需要展示什么。对于我们的列表至少需要数据列表、加载状态、当前页码、是否还有更多数据。我们将这些属性都定义为Bindable以便View监听。// ViewModels/NewsListViewModel.swift import Foundation final class NewsListViewModel { // MARK: - 对外暴露的可绑定属性 /// 新闻数据列表 private(set) var newsItems Bindable[NewsItem]([]) /// 列表当前状态 private(set) var state BindableListState(.idle) /// 是否还有更多数据可以加载用于控制footer显示 private(set) var hasMoreData BindableBool(true) // MARK: - 内部状态与依赖 private let newsService: NewsServiceProtocol private var currentPage 1 private let pageSize 10 private var isRequestInFlight false // 防止重复请求的标志位 // MARK: - 初始化 init(newsService: NewsServiceProtocol NewsService()) { self.newsService newsService } }这里有几个关键设计点private(set)将属性的setter设为私有强制外部只能通过ViewModel提供的方法如loadData来修改状态保证了状态变更的可控性。isRequestInFlight这是一个非常重要的防重复请求标志。在网络请求未返回时阻止用户再次触发加载比如快速连续下拉刷新避免数据错乱和性能浪费。依赖注入通过init参数传入NewsServiceProtocol而不是在内部直接实例化NewsService()。这使得单元测试时可以轻松注入一个模拟服务Mock是编写可测试代码的基础。3.2 核心业务方法加载数据ViewModel的核心职责是执行业务逻辑。对于列表最主要的就是加载数据并处理好成功、失败、分页等各种情况。// 在 NewsListViewModel 类中继续添加方法 extension NewsListViewModel { /// 加载数据刷新或首次加载 func loadData(isRefreshing: Bool false) { // 1. 防重检查 guard !isRequestInFlight else { print(⚠️ 请求正在进行中忽略此次调用) return } // 2. 状态更新 if isRefreshing { currentPage 1 // 刷新重置页码 hasMoreData.value true state.value .loading } else { // 首次加载也是.loading状态 if newsItems.value.isEmpty { state.value .loading } } isRequestInFlight true let targetPage currentPage // 3. 调用服务层获取数据 newsService.fetchNews(page: targetPage, pageSize: pageSize) { [weak self] result in guard let self self else { return } // 确保回到主线程更新UI绑定的属性 DispatchQueue.main.async { self.isRequestInFlight false switch result { case .success(let newItems): self.handleLoadSuccess(newItems: newItems, isRefreshing: isRefreshing, targetPage: targetPage) case .failure(let error): self.handleLoadFailure(error: error, isRefreshing: isRefreshing) } } } } /// 处理加载成功 private func handleLoadSuccess(newItems: [NewsItem], isRefreshing: Bool, targetPage: Int) { if isRefreshing { // 刷新完全替换旧数据 newsItems.value newItems } else { // 加载更多追加新数据需去重根据实际业务需求 var currentItems newsItems.value // 简单的基于id的去重逻辑 let existingIds Set(currentItems.map { $0.id }) let uniqueNewItems newItems.filter { !existingIds.contains($0.id) } currentItems.append(contentsOf: uniqueNewItems) newsItems.value currentItems } // 更新状态和页码 if newItems.isEmpty { // 服务器返回空数组意味着没有更多数据了 hasMoreData.value false if newsItems.value.isEmpty { state.value .empty } else { state.value .loaded // 如果已有数据但新返回为空可能是分页结束 if targetPage 1 { state.value .noMoreData } } } else { // 成功加载到数据 state.value .loaded // 如果返回的数据量小于请求的pageSize通常认为没有更多数据了这是一种常见策略 if newItems.count pageSize { hasMoreData.value false state.value .noMoreData } // 只有成功且非刷新时才递增页码 if !isRefreshing { currentPage 1 } } } /// 处理加载失败 private func handleLoadFailure(error: Error, isRefreshing: Bool) { // 如果是刷新失败保持原有数据如果是加载更多失败页码不递增即可 // 这里我们简单地将状态设置为错误并传递错误信息 state.value .error(error) // 错误状态下hasMoreData保持不变允许重试 } /// 加载更多数据 func loadMoreData() { // 检查是否还有更多数据且当前没有正在进行的请求 guard hasMoreData.value, !isRequestInFlight else { return } state.value .loadingMore loadData(isRefreshing: false) // 注意这里 isRefreshing 为 false } /// 重试加载通常在错误状态下调用 func retryLoading() { // 根据当前是否有数据来决定是刷新还是加载更多 if newsItems.value.isEmpty { loadData(isRefreshing: true) } else { loadMoreData() } } }这段代码包含了处理列表数据加载的几乎所有核心逻辑和边界条件。handleLoadSuccess方法中的分页逻辑和空数据判断是实战中的关键不同的后端API设计比如返回totalPage字段或者返回hasNext布尔值会影响这里的实现。我们采用的是一种保守策略当返回的数据条数小于pageSize时认为没有更多数据了。这能避免不必要的额外请求。实操心得isRequestInFlight这个标志位至关重要。在快速滑动触发加载更多或者用户连续下拉刷新时没有它会导致并发多个相同请求可能引起数据重复、顺序错乱甚至崩溃。务必在请求开始前检查在请求结束后无论成功失败重置。4. View层ViewController与Cell的实现View层主要是UIViewController及其管理的UI组件的职责变得非常清晰配置UI、绑定ViewModel的数据、响应用户操作并通知ViewModel、根据状态更新UI表现。4.1 列表ViewController的搭建我们使用经典的UITableViewController或UIViewController内嵌UITableView来作为列表视图。// Views/ViewControllers/NewsListViewController.swift import UIKit class NewsListViewController: UITableViewController { // MARK: - 属性 private let viewModel NewsListViewModel() private let cellIdentifier NewsCell private let footerIdentifier LoadingFooter // 下拉刷新控件 private lazy var refreshControl: UIRefreshControl { let control UIRefreshControl() control.addTarget(self, action: #selector(handleRefresh), for: .valueChanged) control.attributedTitle NSAttributedString(string: 下拉刷新...) return control }() // MARK: - 生命周期 override func viewDidLoad() { super.viewDidLoad() setupTableView() setupBindings() // 初始加载数据 viewModel.loadData() } private func setupTableView() { title 新闻列表 tableView.register(NewsTableViewCell.self, forCellReuseIdentifier: cellIdentifier) tableView.register(LoadingFooterView.self, forHeaderFooterViewReuseIdentifier: footerIdentifier) tableView.rowHeight UITableView.automaticDimension tableView.estimatedRowHeight 120 tableView.separatorStyle .singleLine tableView.tableFooterView UIView() // 初始时隐藏自定义footer // 添加下拉刷新 if #available(iOS 10.0, *) { tableView.refreshControl refreshControl } else { tableView.addSubview(refreshControl) } } }4.2 数据绑定连接View与ViewModel这是MVVM中“绑定”概念的具体体现。我们在viewDidLoad中调用setupBindings将ViewModel中Bindable属性的变化与UI更新关联起来。// 在 NewsListViewController 中添加方法 extension NewsListViewController { private func setupBindings() { // 绑定新闻数据列表 viewModel.newsItems.bind { [weak self] _ in // 数据变化刷新表格。注意这里可能会在非主线程调用所以需要DispatchQueue.main.async DispatchQueue.main.async { self?.tableView.reloadData() // 数据更新后检查是否需要显示/隐藏加载更多footer self?.updateTableFooterView() } } // 绑定列表状态 viewModel.state.bind { [weak self] newState in DispatchQueue.main.async { self?.handleStateChange(newState) } } // 绑定是否有更多数据 viewModel.hasMoreData.bind { [weak self] hasMore in DispatchQueue.main.async { self?.updateTableFooterView() } } } private func handleStateChange(_ state: ListState) { // 根据状态更新UI switch state { case .idle: break case .loading: // 首次加载可以显示一个全屏的加载指示器 // 这里我们简单处理下拉刷新控件会自己显示 if !refreshControl.isRefreshing { // 显示一个小的加载指示在navigationBar或者tableView上 showLoadingIndicator(true) } case .loadingMore: // 加载更多时我们的自定义footer会显示加载动画 updateTableFooterView() case .loaded: // 加载完成隐藏所有加载指示 refreshControl.endRefreshing() showLoadingIndicator(false) // 如果数据为空状态会变为.empty不会走到这里 tableView.reloadData() case .empty: refreshControl.endRefreshing() showLoadingIndicator(false) // 显示空数据视图 showEmptyView(message: 暂无新闻内容) case .error(let error): refreshControl.endRefreshing() showLoadingIndicator(false) // 显示错误视图并提供重试按钮 showErrorView(error: error) { [weak self] in self?.viewModel.retryLoading() } case .noMoreData: // 没有更多数据了更新footer状态 updateTableFooterView() } } private func showLoadingIndicator(_ show: Bool) { // 在实际项目中这里可以控制一个自定义的加载HUD或navigationBar上的activityIndicator UIApplication.shared.isNetworkActivityIndicatorVisible show // 已废弃仅作示例 } private func updateTableFooterView() { let isLoadingMore viewModel.state.value .loadingMore let hasMore viewModel.hasMoreData.value let itemsEmpty viewModel.newsItems.value.isEmpty if itemsEmpty { // 数据为空时不显示加载更多footer tableView.tableFooterView UIView() return } if hasMore { // 还有数据显示加载更多footer let footerView tableView.dequeueReusableHeaderFooterView(withIdentifier: footerIdentifier) as? LoadingFooterView footerView?.isLoading isLoadingMore tableView.tableFooterView footerView } else { // 没有更多数据了可以显示一个“没有更多了”的提示或者直接隐藏 let noMoreLabel UILabel(frame: CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 44)) noMoreLabel.text —— 已经到底了 —— noMoreLabel.textColor .gray noMoreLabel.textAlignment .center noMoreLabel.font UIFont.systemFont(ofSize: 14) tableView.tableFooterView noMoreLabel } } }4.3 处理用户交互用户交互如点击单元格、下拉刷新、滚动到底部需要由View捕获并调用ViewModel对应的方法。// 在 NewsListViewController 中继续添加 extension NewsListViewController { // 下拉刷新处理 objc private func handleRefresh() { viewModel.loadData(isRefreshing: true) } // 滚动到底部触发加载更多 override func scrollViewDidScroll(_ scrollView: UIScrollView) { let offsetY scrollView.contentOffset.y let contentHeight scrollView.contentSize.height let frameHeight scrollView.frame.size.height // 一个简单的触发加载更多的逻辑当滚动到离底部还有44pt时触发 let threshold: CGFloat 44.0 if offsetY contentHeight - frameHeight - threshold { viewModel.loadMoreData() } } // 表格数据源和代理 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) - Int { return viewModel.newsItems.value.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) - UITableViewCell { guard let cell tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as? NewsTableViewCell else { return UITableViewCell() } let newsItem viewModel.newsItems.value[indexPath.row] cell.configure(with: newsItem) return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) let newsItem viewModel.newsItems.value[indexPath.row] // 跳转到详情页这里简单用Alert示意 let alert UIAlertController(title: newsItem.title, message: newsItem.summary, preferredStyle: .alert) alert.addAction(UIAlertAction(title: 确定, style: .default)) present(alert, animated: true) // 在实际项目中这里会 push 到一个 NewsDetailViewController并将 newsItem.id 或整个对象传递过去。 // 详情页同样可以采用MVVM其ViewModel接收一个newsId并负责获取详情数据。 } }4.4 自定义Cell与加载FooterCell的职责是展示一条数据。它应该对外提供一个清晰的配置接口。// Views/Cells/NewsTableViewCell.swift import UIKit class NewsTableViewCell: UITableViewCell { private let titleLabel UILabel() private let summaryLabel UILabel() private let sourceLabel UILabel() private let timeLabel UILabel() private let newsImageView UIImageView() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setupUI() } required init?(coder: NSCoder) { fatalError(init(coder:) has not been implemented) } private func setupUI() { // ... 详细的AutoLayout或Frame布局代码这里省略 // 主要设置label的字体、颜色、行数imageView的contentMode等 titleLabel.font UIFont.boldSystemFont(ofSize: 16) titleLabel.numberOfLines 2 summaryLabel.font UIFont.systemFont(ofSize: 14) summaryLabel.textColor .darkGray summaryLabel.numberOfLines 3 sourceLabel.font UIFont.systemFont(ofSize: 12) sourceLabel.textColor .gray timeLabel.font UIFont.systemFont(ofSize: 12) timeLabel.textColor .lightGray newsImageView.contentMode .scaleAspectFill newsImageView.clipsToBounds true newsImageView.layer.cornerRadius 4 } func configure(with newsItem: NewsItem) { titleLabel.text newsItem.title summaryLabel.text newsItem.summary sourceLabel.text newsItem.source timeLabel.text newsItem.publishTime // 图片加载简化版实际项目应用SDWebImage或Kingfisher if let imageUrl newsItem.imageUrl, let url URL(string: imageUrl) { // 注意这里应使用异步加载和缓存此处仅为演示 DispatchQueue.global().async { if let data try? Data(contentsOf: url) { DispatchQueue.main.async { self.newsImageView.image UIImage(data: data) } } } } else { newsImageView.image nil } } }加载更多的FooterView用于在列表底部显示“正在加载”或“没有更多了”的状态。// Views/CustomViews/LoadingFooterView.swift import UIKit class LoadingFooterView: UITableViewHeaderFooterView { static let reuseIdentifier LoadingFooter private let activityIndicator UIActivityIndicatorView(style: .medium) private let messageLabel UILabel() var isLoading: Bool false { didSet { if isLoading { activityIndicator.startAnimating() messageLabel.text 正在加载更多... } else { activityIndicator.stopAnimating() messageLabel.text 上拉加载更多 } } } override init(reuseIdentifier: String?) { super.init(reuseIdentifier: reuseIdentifier) setupView() } required init?(coder: NSCoder) { fatalError(init(coder:) has not been implemented) } private func setupView() { contentView.backgroundColor .systemBackground activityIndicator.translatesAutoresizingMaskIntoConstraints false messageLabel.translatesAutoresizingMaskIntoConstraints false messageLabel.font UIFont.systemFont(ofSize: 14) messageLabel.textColor .gray contentView.addSubview(activityIndicator) contentView.addSubview(messageLabel) NSLayoutConstraint.activate([ activityIndicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), activityIndicator.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), messageLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), messageLabel.leadingAnchor.constraint(equalTo: activityIndicator.trailingAnchor, constant: 12) ]) } }5. 进阶优化与实战避坑指南一个能跑通的Demo只是起点要让MVVM架构在实际项目中稳健运行还需要考虑很多边界情况和优化点。下面是我在多个项目中总结出的关键经验和常见“坑”。5.1 内存管理与循环引用MVVM中由于存在大量的闭包绑定循环引用是高频问题。我们的绑定代码中已经使用了[weak self]但还需要注意以下几点ViewModel中的闭包NewsService.fetchNews的回调闭包中也必须使用[weak self]并在主线程更新前检查self是否存在。Cell的配置闭包如果Cell内部有按钮等交互元素其点击事件回调通常需要调用ViewModel的方法。这时Cell不应该强引用ViewModel。常见的做法是使用闭包属性由ViewController在cellForRowAt中弱引用捕获ViewModel进行赋值。// 在NewsTableViewCell中定义 var onButtonTapped: (() - Void)? // 在button的action中调用 onButtonTapped?() // 在ViewController中配置cell时 cell.onButtonTapped { [weak self] in self?.viewModel.handleButtonAction(for: newsItem.id) }使用unowned需谨慎只有在确定生命周期同步且不会为nil的情况下比如ViewModel和它内部的一个Service才考虑使用unowned否则一律用weak更安全。5.2 列表性能优化当列表数据量很大时性能问题会凸显。Cell复用与配置确保cellForRowAt方法高效。图片加载必须异步并使用缓存库如Kingfisher。我们的configure方法中模拟的网络加载是错误示范会阻塞主线程且无法复用。正确做法是使用专业的图片加载库。视图层级与离屏渲染避免在Cell中使用过多的cornerRadius、shadow和mask这些会导致离屏渲染。对于圆角图片优先使用UIImageView的cornerRadiusmasksToBounds或者预渲染圆角图片。高度计算使用UITableView.automaticDimension配合AutoLayout是主流做法。确保Cell的约束足够清晰没有歧义并且estimatedRowHeight设置一个合理的近似值可以大幅提升初次渲染性能。对于超复杂Cell可以考虑手动计算并缓存高度。5.3 状态管理的颗粒度我们的ListState枚举管理了全局状态。但在更复杂的列表中你可能需要更细粒度的状态。例如局部加载状态某个Cell内部有一个“点赞”按钮点击后需要发送网络请求。这个加载状态不应该影响整个列表的刷新控件。这时可以在对应的Cell ViewModel或Model中增加一个isLiking的状态属性并单独绑定更新这个Cell。多Section状态如果列表有多个Section每个Section可能有独立的数据源和加载状态。可以考虑为每个Section建立一个子ViewModel主ViewModel管理一个[SectionViewModel]的数组。5.4 单元测试策略MVVM最大的优势之一就是可测试性。ViewModel不依赖UIKit可以很方便地进行单元测试。import XCTest testable import NewsListDemo class NewsListViewModelTests: XCTestCase { var viewModel: NewsListViewModel! var mockService: MockNewsService! override func setUp() { super.setUp() mockService MockNewsService() viewModel NewsListViewModel(newsService: mockService) } func testInitialState() { XCTAssertTrue(viewModel.newsItems.value.isEmpty) XCTAssertEqual(viewModel.state.value, .idle) XCTAssertTrue(viewModel.hasMoreData.value) } func testLoadDataSuccess() { // 1. 设置Mock返回数据 let mockNews [NewsItem(id: 1, title: Test, summary: Test, publishTime: , source: , imageUrl: nil)] mockService.mockResult .success(mockNews) // 2. 定义期望 let expectation self.expectation(description: Data loaded) var observedItems: [NewsItem] [] // 绑定监听当数据变化时触发 viewModel.newsItems.bind { items in if !items.isEmpty { observedItems items expectation.fulfill() } } // 3. 执行操作 viewModel.loadData() // 4. 验证 waitForExpectations(timeout: 2) { _ in XCTAssertEqual(observedItems.count, 1) XCTAssertEqual(observedItems.first?.id, 1) XCTAssertEqual(self.viewModel.state.value, .loaded) } } func testLoadDataFailure() { // 模拟网络错误 let mockError NSError(domain: test, code: -1, userInfo: nil) mockService.mockResult .failure(mockError) let expectation self.expectation(description: State changed to error) viewModel.state.bind { state in if case .error state { expectation.fulfill() } } viewModel.loadData() waitForExpectations(timeout: 2, handler: nil) } // 测试防重逻辑 func testPreventDuplicateRequests() { mockService.mockResult .success([]) mockService.fetchDelay 1.0 // 设置一个延迟让请求挂起 viewModel.loadData() // 立即再次调用 viewModel.loadData() // 由于 isRequestInFlight 的保护第二次调用应该被忽略 // 可以通过检查 mockService 的 fetchCallCount 来验证 // 这里简化断言只要测试不崩溃且最终状态正确即可 let expectation self.expectation(description: Request completed) DispatchQueue.main.asyncAfter(deadline: .now() 1.5) { expectation.fulfill() } waitForExpectations(timeout: 2, handler: nil) // 断言 mockService 的调用次数需要在Mock中实现计数 // XCTAssertEqual(mockService.fetchCallCount, 1) } } // 对应的Mock服务 class MockNewsService: NewsServiceProtocol { var mockResult: Result[NewsItem], Error .success([]) var fetchDelay: TimeInterval 0 var fetchCallCount 0 func fetchNews(page: Int, pageSize: Int, completion: escaping (Result[NewsItem], Error) - Void) { fetchCallCount 1 DispatchQueue.global().asyncAfter(deadline: .now() fetchDelay) { completion(self.mockResult) } } }编写单元测试能极大增强代码的健壮性尤其是在修改分页逻辑、状态转换等复杂业务时测试能给你重构的信心。5.5 向响应式编程Combine/RxSwift的平滑过渡我们手写的Bindable是一个教学工具。当项目复杂度上升你会需要处理多个数据流的组合、过滤、去抖等操作。这时迁移到Combine或RxSwift是必然选择。过渡其实很平滑将BindableT替换为CurrentValueSubjectT, NeverCombine或BehaviorRelayTRxSwift。在ViewController中将bind方法替换为sinkCombine或subscribeRxSwift来监听变化。利用操作符处理复杂逻辑例如用.debounce处理搜索框输入用.combineLatest组合多个输入状态。迁移后ViewModel内部的逻辑可能变化不大但数据流的声明性和可组合性会大大增强。6. 总结与个人体会走完这个完整的MVVM列表实战案例你应该能深刻感受到架构带来的清晰度。ViewController的代码量可能并没有急剧减少但它的职责变得单一而明确它只关心“什么时候显示什么”。所有“为什么显示这个”和“数据从哪里来”的逻辑都移交给了ViewModel。我个人在大型项目中使用MVVM的体会是前期多花一点时间在架构设计上后期在功能迭代、Bug排查和人员协作上节省的时间是成倍的。当产品经理提出“在列表页增加一个筛选功能”时你只需要在ViewModel中增加一个filterKeyword的Bindable属性并在获取数据的方法里加入过滤逻辑。View层几乎不用动只需要加一个搜索框并绑定到这个属性即可。这种修改是局部的、可预测的。另一个深刻的教训是关于状态管理。早期我习惯用多个独立的Bool或enum变量来管理加载、错误、空状态结果经常出现状态矛盾比如isLoading和isError同时为true。后来统一使用一个State枚举就像本文的ListState所有UI状态都源于此彻底避免了状态不一致的问题。这在小程序、Flutter等声明式UI框架中也是核心思想。最后MVVM不是银弹。对于极其简单的页面比如一个静态的设置页直接用MVC甚至更简单。但对于像列表、表单、详情页这类数据驱动、交互复杂的场景MVVM带来的结构优势和可测试性绝对是值得投入的学习成本。从手写绑定开始理解数据流再逐步过渡到成熟的响应式框架这条学习路径会让你对iOS架构有更扎实的理解。