Document SwiftUI-to-UIKit migration approach, third-party libraries, list patterns, unit testing requirements, and imperative UIKit style. Co-authored-by: Cursor <cursoragent@cursor.com>
7.2 KiB
7.2 KiB
suixinkan
项目概览
这是一个 Swift + UIKit 的 iOS 工程,采用 MVVM 设计模式。
本工程的主要目标是将原 SwiftUI 项目迁移为 Swift + UIKit 实现。迁移时应按 UIKit idiomatic 方式重写,而不是把 SwiftUI 的声明式/响应式写法原样搬到 UIKit 上。
平台与设备
- 最低系统版本:iOS 16
- 设备:仅支持 iPhone(
TARGETED_DEVICE_FAMILY = 1) - 屏幕方向:仅竖屏(Portrait)
开发时请遵守以上约束,不要引入 iPad 专属布局、横屏适配或低于 iOS 16 的 API。
架构(MVVM)
按 MVVM 分层组织代码,职责清晰:
| 层级 | 职责 |
|---|---|
| View | UIViewController / 自定义 UIView,负责 UI 展示与用户交互,不包含业务逻辑 |
| ViewModel | 处理业务逻辑与状态;通过方法、delegate 或明确的回调与 View 通信,不直接引用 UIKit 视图 |
| Model | 数据模型、网络/本地数据源 |
约定
- View 通过 ViewModel 获取数据并响应用户操作,避免在 ViewController 中写复杂业务逻辑
- ViewModel 不依赖
UIViewController或具体 View 类型 - 新增功能时,优先创建对应的 Model、ViewModel、View(Controller)文件,保持结构一致
- 新增功能时,同步添加单元测试(详见「单元测试」章节)
SwiftUI 迁移与开发风格
迁移过程中采用 传统命令式 开发,按 UIKit 原生模式组织代码,避免引入 SwiftUI 时代的响应式习惯。
不使用
@Observable/ObservableObject/@Published等观察型状态机制- Combine(
PassthroughSubject、CurrentValueSubject、sink等)驱动 UI 更新 - 为模拟 SwiftUI 的
onChange而堆砌大量属性监听回调
推荐做法
- 命令式更新 UI:在明确的时机(网络回调、按钮点击、页面出现等)直接调用
apply(snapshot)或更新对应子视图 - 显式通信:优先
delegate、target-action、或带语义的单一回调(如onSubmit),避免「任意字段变化 → 触发 refresh」的泛化监听 - 按场景刷新:数据变了就调对应刷新方法,而不是让 View 订阅一堆变更信号
- 迁移时重写交互模型:对照 SwiftUI 页面的行为,用 UIKit 方式实现,而不是逐行翻译声明式语法
// ✅ 推荐:用户点击后显式加载并刷新
@objc private func didTapRefresh() {
viewModel.loadItems { [weak self] items in
self?.applyItems(items)
}
}
// ❌ 避免:为迁移方便堆叠泛化 onChanged
viewModel.onItemsChanged = { [weak self] in self?.reload() }
viewModel.onLoadingChanged = { [weak self] in self?.reload() }
viewModel.onErrorChanged = { [weak self] in self?.reload() }
- ViewModel 可暴露普通属性供读取,但 UI 更新由 View 在操作完成后的回调里主动触发,而非自动绑定
技术栈
- 语言:Swift
- UI 框架:UIKit
- 布局:SnapKit(代码约束)
- 图片加载:Kingfisher
- 入口:
AppDelegate、SceneDelegate、Main.storyboard
第三方库约定
SnapKit(UI 约束)
- 使用 SnapKit 编写 Auto Layout 约束,优先代码布局,不使用 Storyboard 约束
- 约束写在
setupUI()/setupConstraints()等独立方法中,保持viewDidLoad简洁 - 使用
snp.makeConstraints设置约束,snp.updateConstraints/snp.remakeConstraints处理动态布局
imageView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(200)
}
Kingfisher(网络图片)
- 使用 Kingfisher 加载与缓存网络图片,不要手写
URLSession下载图片 - 通过
UIImageView.kf.setImage(with:)加载,按需配置 placeholder、缓存策略
imageView.kf.setImage(
with: URL(string: imageURL),
placeholder: UIImage(named: "placeholder")
)
- 列表/滚动场景中利用 Kingfisher 内置缓存,避免重复请求
- Cell 复用时无需手动取消任务,Kingfisher 会自动处理
列表展示
根据列表复杂度选择合适的容器,数据更新优先使用 Diffable Data Source,让增删改带有过渡动画。
选型
| 场景 | 推荐 | 说明 |
|---|---|---|
| 简单列表 | UITableView |
单行、结构统一的列表(设置项、纯文本列表等) |
| 复杂列表 | UICollectionView |
多列、网格、卡片、不等高、混合 Section 布局等 |
- 简单场景不必强行上
UICollectionView;复杂布局不要硬用UITableView变通 UICollectionView配合UICollectionViewCompositionalLayout实现灵活布局
Diff 更新与动画
- 使用
UITableViewDiffableDataSource/UICollectionViewDiffableDataSource管理数据源 - 数据变化时通过
NSDiffableDataSourceSnapshot描述差异,调用apply(_:animatingDifferences:)刷新 - 默认开启动画(
animatingDifferences: true),插入、删除、移动应有平滑过渡 - 避免
reloadData()全量刷新;仅在 diff 无法表达的场景(如整体替换且无需动画)才考虑全量刷新
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.apply(snapshot, animatingDifferences: true)
- Item 需遵循
Hashable,便于系统计算 diff - ViewModel 输出新数据后,由 View 层构建 snapshot 并 apply,保持职责分离
单元测试
每添加一个功能,必须编写对应的单元测试,并确保全部通过后再视为完成。
要求
- 新功能与单元测试同一次改动中提交,不单独留「后续补测」
- 测试需在本地运行通过(Xcode
Cmd + U,或xcodebuild test) - 功能有 bug 修复时,优先补充或更新相关测试用例
测试范围
| 层级 | 是否测试 | 说明 |
|---|---|---|
| ViewModel | ✅ 优先 | 业务逻辑、状态变更、数据转换 |
| Model | ✅ 优先 | 解析、校验、纯函数逻辑 |
| View | 按需 | 复杂交互或自定义 View 行为;简单 UI 可不测 |
- 测试应独立、可重复,不依赖真实网络或外部服务(使用 mock / stub)
- 测试文件放在
suixinkanTests/,命名与被测类型对应,如FooViewModelTests.swift
示例
import XCTest
@testable import suixinkan
final class FooViewModelTests: XCTestCase {
func testLoadDataSuccess() {
let viewModel = FooViewModel(service: MockFooService())
viewModel.load()
XCTAssertEqual(viewModel.items.count, 3)
}
}
开发注意
- 使用 iOS 16+ 可用 API;如需兼容更低版本,需先与项目维护者确认
- UI 按竖屏 iPhone 设计,无需考虑横屏与 iPad 分屏
- 布局统一用 SnapKit,网络图片统一用 Kingfisher
- 简单列表用
UITableView,复杂列表优先UICollectionView;数据更新用 Diffable Data Source 并带动画 - SwiftUI → UIKit 迁移:命令式开发,不用 Observable / Combine,不堆
onChanged式回调 - 新增功能必须附带单元测试,且全部通过
- 保持代码简洁,遵循现有命名与目录风格