老项目的首页 ViewController,文件大小:33,623 字节。
不是压缩前的图片资源,不是第三方库,就是一个纯 Swift 文件。一个 ViewController。
打开它之后你会发现,这 33KB 不只是 UI 代码。照片授权状态在这里判断,全局单例在这里读写,导航跳转在这里拼装,数据刷新在这里触发,订阅按钮的显隐在这里计算。还有一个 UICollectionView,同时承载三组不同布局的卡片列表。所有的 delegate 和 data source 方法也全部塞在同一个类里。
这就是典型的"一把手 ViewController"------什么都能干,什么都自己干。
csharp
┌─────────────────────────────────────────────────────────────────┐
│ 拆之前 拆之后 │
│ │
│ CleanAI_Home_ViewController.swift Home/ │
│ (33,623 字节) ├── Controllers/ │
│ ┌────────────────────────┐ │ └── DashboardVC │
│ │ • UICollectionView │ ├── State/ │
│ │ delegate + dataSource│ │ ├── HomeFeature │
│ │ • 照片权限判断 │ │ └── ScanPresenter │
│ │ • 全局单例读写(5个) │ ├── Routing/ │
│ │ • 导航跳转(push) │ │ └── Coordinator │
│ │ • 数据刷新 + 缓存 │ └── Views/ │
│ │ • 订阅状态判断 │ ├── FeatureCard │
│ │ • 权限按钮显隐 │ ├── SummaryHeader │
│ │ • 埋点上报 │ ├── LimitedAccess │
│ │ • 通知监听 │ └── TipViews × 2 │
│ └────────────────────────┘ │
│ │
│ 一个文件,管了 8 种职责。 10 个文件,各管各的。 │
└─────────────────────────────────────────────────────────────────┘
老 VC 到底管了多少事
先看一下老文件的头部,这几行 import 和类声明已经暴露了问题:
swift
// 老项目:Home_ViewController.swift(33,623 字节)
import UIKit
import Contacts
import Photos
import Combine
class HomeViewController: BaseViewController,
UICollectionViewDelegateFlowLayout,
UICollectionViewDataSource {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(true, animated: false)
updateNavTopUI()
PhotoShare.shared.allModels.forEach { $0.selected = false }
refreshHomeAfterPhotoDataChanged()
}
private func refreshHomeAfterPhotoDataChanged() {
publishHomeStates() // 扫描状态
updateVisibleTopCell() // UI 刷新
}
func updateNavTopUI() {
subscriptionButton.isHidden = SubscriptionShare.shared.isPro
let photoStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
let contactStatus = CNContactStore.authorizationStatus(for: .contacts)
if contactStatus == .authorized {
ContactShare.shared.loadAllContacts { _ in }
}
let notificationEnabled = NotificationShare.shared.status.value
if photoStatus == .authorized, notificationEnabled {
permissionButton.isHidden = true
} else {
permissionButton.isHidden = false
}
if SubscriptionShare.shared.isPro {
permissionButton.snp.remakeConstraints {
$0.trailing.equalTo(-16)
$0.centerY.equalTo(subscriptionButton)
$0.width.height.equalTo(32)
}
} else {
permissionButton.snp.remakeConstraints {
$0.trailing.equalTo(subscriptionButton.snp.leading).offset(-8)
$0.centerY.equalTo(subscriptionButton)
$0.width.height.equalTo(32)
}
}
}
}
短短几十行,已经能看到这五个互不相关的职责被揉在一起:
- 数据层 :直接操作
PhotoShare.shared.allModels,调用ContactShare.shared.loadAllContacts - 权限层:同时判断照片权限和联系人权限
- UI 布局层:根据订阅状态动态调整按钮约束
- 订阅层 :读
SubscriptionShare.shared.isPro,控制按钮显隐和布局 - 导航层:控制 navigationBar 的显隐
还有没截出来的:UICollectionView 的 delegate 和 dataSource 方法在同一个文件里管着三组不同布局的卡片------大幅卡片、方形卡片、还有顶部滚动区域。每组都有不同的 cell 类型、不同的大小计算逻辑、不同的点击响应。
同一个文件还管着数据刷新:照片库变化了要重新扫描、扫描完了要更新所有可见 cell、用户从设置页回来要重新检查权限。这些刷新逻辑和 UI 代码混在一起,没有一个"这件事应该由谁负责"的边界。
拆解第一步:先抽 State
最应该先从 VC 里剥离的,是"这个页面有哪些数据状态"的定义。
老代码里,首页的八种清理功能------相似照片、重复照片、相似视频、大视频、截图、模糊照片、录屏、Live Photo------不是通过一个明确的模型来表达的。它们散落在 dataSource 的 section 和 row 索引里、在 cell 的类型判断里、在埋点字符串的拼接里。
第一步,把这些功能定义抽成一个 enum:
swift
// 新项目:HomeFeature.swift
enum HomeFeature: CaseIterable, Hashable {
case similarPhotos
case duplicatePhotos
case similarVideos
case largeVideos
case screenshots
case blurPhotos
case screenRecordings
case livePhotos
var title: String {
switch self {
case .similarPhotos: return Localized.text("Similar Photos")
case .duplicatePhotos: return Localized.text("Duplicate Photos")
// ...
}
}
var analyticsIdentifier: String {
switch self {
case .similarPhotos: return "photo_similar"
// ...
}
}
}
这件事看起来很小------不就是把几个 case 列出来吗?但这一步解决了一个真实问题:首页到底有几个功能入口,不再需要翻 UICollectionView 的 section/row 映射才能知道。 后续不管是做路由跳转、做埋点、还是做扫描状态绑定,都从同一个 enum 出发,不再是每个地方各自写一套判断。
接着,把"首页正在扫描什么、扫了多少、结果是多少"这些运行时状态,从 VC 的私有属性里抽出来,变成一个独立的 Presenter。它只负责接收扫描进度、计算每个功能入口的最新数量、通知订阅者状态变了。VC 不再直接操作 PhotoShare.shared.allModels,而是通过 Presenter 拿它需要的那一层结果。
swift
// 新项目:HomeScanStatePresenter.swift(骨架)
@MainActor
final class HomeScanStatePresenter {
struct State {
let featureCounts: [HomeFeature: Int]
let isScanning: Bool
let totalPhotos: Int
let totalVideos: Int
}
private var current = State(featureCounts: [:], isScanning: false, ...)
func refresh(with library: PhotoLibraryIndex) {
// 从 Library 拿最新数据,算出每个 feature 的数量,更新 current
}
func count(for feature: HomeFeature) -> Int {
current.featureCounts[feature] ?? 0
}
}
拆解第二步:再抽 Routing
老 VC 里,用户点击某个功能卡片之后,跳转到哪个页面、传什么参数、页面回来后怎么刷新------这些逻辑直接写在 collectionView(_:didSelectItemAt:) 里。几十行跳转代码和 UI 渲染逻辑挤在一起。
第二步,把这件事交给一个专门的 Coordinator:
swift
// 新项目:HomeRouteCoordinator.swift(骨架)
@MainActor
final class HomeRouteCoordinator {
private weak var navigationController: UINavigationController?
private let entitlementStore: SubscriptionEntitlementStore
func navigate(to feature: HomeFeature, counts: [HomeFeature: Int]) {
guard let nav = navigationController else { return }
switch feature {
case .similarPhotos, .duplicatePhotos, .similarVideos,
.screenshots, .blurPhotos, .livePhotos, .screenRecordings:
pushPhotoResults(for: feature, navigation: nav)
case .largeVideos:
pushMediaCleanup(for: feature, navigation: nav)
}
}
}
Coordinator 拿到的输入很明确------一个 HomeFeature 和当前的统计数据。它负责决定"这个功能应该跳到哪个页面、用什么参数"。Controller 只负责在 didSelectItemAt 里调一句 coordinator.navigate(to: feature, counts: counts)。跳转逻辑本身跟 Controller 解耦了。
这一步还有一个好处:后续如果要改某个页面的跳转方式------比如从 push 改成 present、或者跳之前多一步权限判断------改 Coordinator 就行,不用动 Controller。
拆解第三步:拆分 Views
老 VC 的 view 层级是直接在 Controller 里用 SnapKit 拼出来的。一大堆 addSubview 和 snp.makeConstraints 混在业务逻辑中间。
第三步,把每个可独立的子视图抽出来,各自管各自的布局和样式:
arduino
Home/Views/
├── HomeFeatureCardView.swift // 单个功能卡片
├── HomeSummaryHeaderView.swift // 顶部统计区域
├── HomeLimitedAccessView.swift // 权限受限提示条
├── HomeSimilarPhotosTipView.swift // 相似照片说明页
└── HomeSimilarLivingPhotosTipView.swift // 相似动态照片说明页
每个 View 只有一个职责:渲染自己的内容。事件通过闭包回传给 Controller,而不是 View 自己去调全局单例或推页面。
swift
// 新项目:HomeFeatureCardView.swift(骨架)
final class HomeFeatureCardView: UIView {
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let countLabel = UILabel()
private let backgroundImageView = UIImageView()
var onTapped: (() -> Void)?
func configure(feature: HomeFeature, count: Int, style: CardStyle) {
titleLabel.text = feature.title
countLabel.text = "\(count)"
backgroundImageView.image = style.backgroundImage
}
}
View 只管展示。它不知道"被点击之后要去哪个页面"------那是 Coordinator 的事。它不知道"count 这个数字怎么算出来的"------那是 ScanStatePresenter 的事。它只是一个把数据画到屏幕上的东西。
拆解第四步:Controller 变薄
前三步做完之后,Controller 自己就变薄了。它不再持有数据的来源、不再自己拼跳转参数、不再管每个子视图的布局细节。它做的只剩三件事:装配、绑定、转发。
swift
// 新项目:HomeDashboardViewController.swift(骨架)
@MainActor
final class HomeDashboardViewController: UIViewController {
private let scanStatePresenter = HomeScanStatePresenter()
private var routeCoordinator: HomeRouteCoordinator?
private let collectionView: UICollectionView
private let permissionButton = UIButton(type: .custom)
private let subscriptionButton = UIButton(type: .custom)
// 装配:init 里创建 layout 和 collectionView
override func viewDidLoad() {
super.viewDidLoad()
assembleViews() // 拼视图层级
bindStatePresenter() // 绑定状态更新
}
private func bindStatePresenter() {
scanStatePresenter.onStateChanged = { [weak self] state in
self?.collectionView.reloadData()
self?.subscriptionButton.isHidden = entitlementStore.isActive
}
}
func collectionView(_ collectionView: UICollectionView,
didSelectItemAt indexPath: IndexPath) {
guard let feature = feature(at: indexPath) else { return }
routeCoordinator?.navigate(to: feature, counts: scanStatePresenter.counts)
}
}
拆分之后,这个 Controller 的核心逻辑用一张图就能说完:Presenter 给数据、Coordinator 管跳转、Views 管渲染、Controller 把它们连起来。
拆分决策:不是所有 VC 都要拆成四层
拆完之后回头看,真正有价值的不是"拆成了四个文件夹"这个形式,而是每次拆分都解决了一个明确的问题:
- 拆 State,是因为"这个页面有哪些状态"散落在各处,没人能一句话说清楚
- 拆 Routing,是因为跳转逻辑和 UI 代码混在一起,改一个要翻半个文件
- 拆 Views,是因为子视图的布局代码占了几百行,和业务逻辑互相打断
- Controller 变薄,是前三步做完后的自然结果
但反过来,如果一个 VC 本身只有 200 行,里面是一个 UITableView 加两三个简单的跳转,拆成四层就是过度设计。
yaml
┌─────────────────────────────────────────────────────────────────┐
│ 你这个 VC 要不要拆? │
│ │
│ 问自己三个问题: │
│ │
│ Q1: 文件超过 500 行了吗? │
│ ├── 没有 → 先别拆,保持现状 │
│ └── 有 → 继续看 Q2 │
│ │
│ Q2: 有三类以上互不相关的职责吗?(比如同时管数据/跳转/布局/权限) │
│ ├── 没有 → 可能只需要抽几个私有方法,不用分层 │
│ └── 有 → 继续看 Q3 │
│ │
│ Q3: 跳转逻辑在多处重复,或者页面状态散落在各处判断吗? │
│ ├── 没有 → 先抽 State + Views,Routing 不急 │
│ └── 有 → 四步全做:State → Routing → Views → Controller │
│ │
│ 核心原则:拆,是因为不拆会越来越难改。不是因为有四个文件夹空着。 │
└─────────────────────────────────────────────────────────────────┘
拆分前后的对比
arduino
┌──────────────────────────┬──────────────────────────────────────┐
│ 拆分前 │ 拆分后 │
├──────────────────────────┼──────────────────────────────────────┤
│ 1 个文件 │ 10 个文件 │
│ 33,623 字节 │ 最大文件不到 500 行 │
│ 8 种职责混在一起 │ 每个文件只有一种职责 │
│ 改导航要翻半个文件 │ 改导航只改 Coordinator │
│ 不知道有多少入口 │ HomeFeature enum 一目了然 │
│ 状态变更不可追踪 │ Presenter 是唯一状态变更出口 │
│ 子视图逻辑散落各处 │ 每个 View 独立,事件闭包回传 │
│ 无法单独测试任何一块 │ Presenter / Coordinator 可独立验证 │
└──────────────────────────┴──────────────────────────────────────┘
最直观的变化不是文件多了,而是------当需求说"首页加一个新的清理类型"的时候,你需要改的文件从"一个巨大的 VC,改了之后哪里都可能有副作用",变成了"在 HomeFeature enum 加一行、在 Presenter 的数据映射里加一行、在卡片列表组装里加一行。三处改动,边界清楚。"
把一把手的 VC 拆开,第一步是最难的。不是因为技术上有门槛,而是因为你要在"它跑得好好的"和"它迟早会改不动"之间做出判断。我的经验是:一旦你开始在这个文件里反复上下翻找某个方法,就说明它已经大到该拆了。 不用等到它变成 33KB 才动手。