diff --git a/AGENTS.md b/AGENTS.md index 8d862ff..422fe73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ 这是一个 **Swift + UIKit** 的 iOS 工程,采用 **MVVM** 设计模式。 +本工程的主要目标是将原 **SwiftUI** 项目迁移为 **Swift + UIKit** 实现。迁移时应按 UIKit idiomatic 方式重写,而不是把 SwiftUI 的声明式/响应式写法原样搬到 UIKit 上。 + ## 平台与设备 - **最低系统版本**:iOS 16 @@ -19,7 +21,7 @@ | 层级 | 职责 | |------|------| | **View** | `UIViewController` / 自定义 `UIView`,负责 UI 展示与用户交互,不包含业务逻辑 | -| **ViewModel** | 处理业务逻辑与状态,向 View 暴露可绑定的数据;不直接引用 UIKit 视图 | +| **ViewModel** | 处理业务逻辑与状态;通过方法、delegate 或明确的回调与 View 通信,不直接引用 UIKit 视图 | | **Model** | 数据模型、网络/本地数据源 | ### 约定 @@ -27,15 +29,154 @@ - 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 方式实现,而不是逐行翻译声明式语法 + +```swift +// ✅ 推荐:用户点击后显式加载并刷新 +@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(Storyboard / 代码布局均可,与现有工程保持一致) +- **UI 框架**:UIKit +- **布局**:SnapKit(代码约束) +- **图片加载**:[Kingfisher](https://github.com/onevcat/Kingfisher) - **入口**:`AppDelegate`、`SceneDelegate`、`Main.storyboard` +## 第三方库约定 + +### SnapKit(UI 约束) + +- 使用 **SnapKit** 编写 Auto Layout 约束,优先代码布局,不使用 Storyboard 约束 +- 约束写在 `setupUI()` / `setupConstraints()` 等独立方法中,保持 `viewDidLoad` 简洁 +- 使用 `snp.makeConstraints` 设置约束,`snp.updateConstraints` / `snp.remakeConstraints` 处理动态布局 + +```swift +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、缓存策略 + +```swift +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 无法表达的场景(如整体替换且无需动画)才考虑全量刷新 + +```swift +var snapshot = NSDiffableDataSourceSnapshot() +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` + +### 示例 + +```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` 式回调 +- **新增功能必须附带单元测试,且全部通过** - 保持代码简洁,遵循现有命名与目录风格