一个 33,623 字节的 ViewController——拆开它我用了四步

老项目的首页 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 拼出来的。一大堆 addSubviewsnp.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 才动手。

相关推荐
2501_916008891 小时前
移动安全之 APP 加固,保障移动应用安全的重要手段
安全·macos·ios·小程序·uni-app·iphone·xcode
阿祖zu20 小时前
芝士就是力量!开源私有化部署与 GitHub 双向同步的个人知识笔记 App
前端·后端·ios
GitLqr1 天前
iOS 27 强制要求 UISceneDelegate:UIKit 和 Flutter 开发者该如何应对?
flutter·ios·全栈
屑曦晨1 天前
PKToolPicker工具栏图标错误模糊问题排查报告
ios
2501_915921431 天前
详细解析,iOS 应用上架 App Store 的完整流程与指南
android·ios·小程序·https·uni-app·iphone·webview
2501_915106321 天前
SwiftUI项目创建详解:使用Xcode从零开始创建第一个App项目
ide·vscode·ios·swiftui·个人开发·xcode·敏捷流程
2501_915918411 天前
Xcode调试iOS应用内存管理与优化,识别泄漏与性能监控指南
android·ios·小程序·uni-app·cocoa·iphone·xcode
9765033351 天前
iOS 上架/审核 4.3a Cocos 2026最新方案解读
flutter·ios·swift·cocos2d·ios开发
2501_916007471 天前
IDE 是什么?集成开发环境详解与 iOS 开发选型指南
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程