Add base infrastructure and SPM dependencies for iOS development.

Integrate SnapKit and Kingfisher, add BaseViewController and NotificationName, and streamline AGENTS.md with Android-aligned conventions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 14:59:55 +08:00
parent 6cc2336e7b
commit e290656322
6 changed files with 341 additions and 152 deletions

184
AGENTS.md
View File

@ -2,181 +2,67 @@
## 项目概览 ## 项目概览
这是一个 **Swift + UIKit** 的 iOS 工程,采用 **MVVM** 设计模式 **Swift + UIKit** 工程,**MVVM** 架构。iOS 版 **随心瞰商家版**,与 Android **ZhiFlyFollow** 功能对齐——以 Android 工程为参考同步实现业务能力UI 按 UIKit 习惯编写,不逐行翻译 Compose
本工程的主要目标是将原 **SwiftUI** 项目迁移为 **Swift + UIKit** 实现。迁移时应按 UIKit idiomatic 方式重写,而不是把 SwiftUI 的声明式/响应式写法原样搬到 UIKit 上。 **Android 参考:** `/Users/hanqiu/Desktop/android_project/zhiflyfollow``com.zhifly.follow`
**文档:** `CLAUDE.md``docs/home/``docs/排队叫号业务流程梳理.md`
**后端:** 生产 `api.zhifly.cn`,测试 `api-test.zhifly.cn`
**主要功能域:** 首页/订单/数据/我的 Tab登录注册与实名认证订单/任务/核销;相册与云存储;直播与钱包;景区排队叫号、位置上报;消息、设置、扫码、语音通话等。
**同步约定:** 接口与业务流程以 Android `NetworkApi` / DTO / ViewModel 为准UI 用 SnapKit 独立设计OTG 相机、DJI 飞控、厂商推送等 Android 专属能力按需评估。
## 平台与设备 ## 平台与设备
- **最低系统版本**iOS 16 iOS 16+ · 仅 iPhone · 仅竖屏。不做 iPad / 横屏 / 低版本 API 适配。
- **设备**:仅支持 iPhone`TARGETED_DEVICE_FAMILY = 1`
- **屏幕方向**仅竖屏Portrait
开发时请遵守以上约束,不要引入 iPad 专属布局、横屏适配或低于 iOS 16 的 API。
## 架构MVVM ## 架构MVVM
按 MVVM 分层组织代码,职责清晰:
| 层级 | 职责 | | 层级 | 职责 |
|------|------| |------|------|
| **View** | `UIViewController` / 自定义 `UIView`负责 UI 展示与用户交互,不含业务逻辑 | | **View** | `UIViewController` / 自定义 `UIView`UI 展示与交互,不含业务逻辑 |
| **ViewModel** | 处理业务逻辑与状态通过方法、delegate 或明确回调与 View 通信,不直接引用 UIKit 视图 | | **ViewModel** | 业务逻辑与状态通过方法、delegate 或明确回调与 View 通信 |
| **Model** | 数据模型、网络/本地数据源 | | **Model** | 数据模型、网络/本地数据源 |
### 约定 - 新增功能:对应 Model + ViewModel + ViewController同步单元测试
- ViewModel 不依赖 UIKit 视图类型
- View 通过 ViewModel 获取数据并响应用户操作,避免在 ViewController 中写复杂业务逻辑 ## 开发风格
- ViewModel 不依赖 `UIViewController` 或具体 View 类型
- 新增功能时,优先创建对应的 Model、ViewModel、ViewController文件保持结构一致
- 新增功能时,同步添加单元测试(详见「单元测试」章节)
## SwiftUI 迁移与开发风格 **命令式 UIKit**,不用 `@Observable` / `ObservableObject` / `@Published`、不用 Combine 驱动 UI、不堆泛化 `onChanged` 回调。
迁移过程中采用 **传统命令式** 开发,按 UIKit 原生模式组织代码,避免引入 SwiftUI 时代的响应式习惯。 - 在明确时机(点击、网络回调、页面出现)主动刷新 UI`apply(snapshot)` 或更新子视图)
- 通信用 delegate、target-action 或语义明确的单一回调
- 对照 Android **行为** 用 UIKit 重写,不翻译 Compose 语法
- ViewModel 属性供读取UI 更新由 View 在回调中主动触发
### 不使用 ## 代码注释
- `@Observable` / `ObservableObject` / `@Published` 等观察型状态机制 `class` / `struct` / `enum`、协议及公开 API 必须写 `///` 文档注释,说明职责与用途;复杂内部逻辑按需补充。不写废话注释、不写过时注释。
- **Combine**`PassthroughSubject``CurrentValueSubject``sink` 等)驱动 UI 更新
- 为模拟 SwiftUI 的 `onChange` 而堆砌大量属性监听回调
### 推荐做法 ## 技术栈与库
- **命令式更新 UI**:在明确的时机(网络回调、按钮点击、页面出现等)直接调用 `apply(snapshot)` 或更新对应子视图 Swift · UIKit · SnapKit代码布局约束写在 `setupUI()` / `setupConstraints()`)· Kingfisher`kf.setImage(with:)` 加载网络图,列表利用缓存)
- **显式通信**:优先 `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
- **布局**SnapKit代码约束
- **图片加载**[Kingfisher](https://github.com/onevcat/Kingfisher)
- **入口**`AppDelegate``SceneDelegate``Main.storyboard`
## 第三方库约定
### SnapKitUI 约束)
- 使用 **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` + Compositional Layout
- 数据更新用 Diffable Data Source + `apply(_:animatingDifferences: true)`Item 遵循 `Hashable`,避免 `reloadData()`
### 选型 - ViewModel 提供数据View 构建 snapshot 并 apply
| 场景 | 推荐 | 说明 |
|------|------|------|
| 简单列表 | `UITableView` | 单行、结构统一的列表(设置项、纯文本列表等) |
| 复杂列表 | `UICollectionView` | 多列、网格、卡片、不等高、混合 Section 布局等 |
- 简单场景不必强行上 `UICollectionView`;复杂布局不要硬用 `UITableView` 变通
- `UICollectionView` 配合 `UICollectionViewCompositionalLayout` 实现灵活布局
### Diff 更新与动画
- 使用 `UITableViewDiffableDataSource` / `UICollectionViewDiffableDataSource` 管理数据源
- 数据变化时通过 `NSDiffableDataSourceSnapshot` 描述差异,调用 `apply(_:animatingDifferences:)` 刷新
- **默认开启动画**`animatingDifferences: true`),插入、删除、移动应有平滑过渡
- 避免 `reloadData()` 全量刷新;仅在 diff 无法表达的场景(如整体替换且无需动画)才考虑全量刷新
```swift
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.apply(snapshot, animatingDifferences: true)
```
- Item 需遵循 `Hashable`,便于系统计算 diff
- ViewModel 输出新数据后,由 View 层构建 snapshot 并 apply保持职责分离
## 单元测试 ## 单元测试
添加一个功能,**必须**编写对应的单元测试,并确保全部通过后再视为完成 增一功能必须附带单元测试且全部通过(`Cmd + U` / `xcodebuild test`),与功能同次提交
### 要求 | 层级 | 优先级 |
|------|--------|
| ViewModel / Model | ✅ 优先 |
| View | 按需 |
- 新功能与单元测试同一次改动中提交,不单独留「后续补测」 测试放 `suixinkanTests/`独立可重复mock 网络与外部依赖。
- 测试需在本地运行通过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如需兼容更低版本需先与项目维护者确认 - SnapKit 布局、Kingfisher 图片、Diffable 列表带动画
- UI 按竖屏 iPhone 设计,无需考虑横屏与 iPad 分屏 - 以 Android 工程为功能参考,命令式 UIKit不用 Observable / Combine
- 布局统一用 SnapKit网络图片统一用 Kingfisher - 新增功能必测、类型与公开 API 必注释、保持简洁
- 简单列表用 `UITableView`,复杂列表优先 `UICollectionView`;数据更新用 Diffable Data Source 并带动画
- **SwiftUI → UIKit 迁移**:命令式开发,不用 Observable / Combine不堆 `onChanged` 式回调
- **新增功能必须附带单元测试,且全部通过**
- 保持代码简洁,遵循现有命名与目录风格

View File

@ -6,6 +6,11 @@
objectVersion = 77; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */
93FF17E42FFB84950033C304 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 93FF17E22FFB84950033C304 /* SnapKit */; };
93FF17E52FFB84950033C304 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 93FF17E32FFB84950033C304 /* Kingfisher */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
93FF17C52FFB84950033C304 /* suixinkan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = suixinkan.app; sourceTree = BUILT_PRODUCTS_DIR; }; 93FF17C52FFB84950033C304 /* suixinkan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = suixinkan.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -36,6 +41,8 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
93FF17E42FFB84950033C304 /* SnapKit in Frameworks */,
93FF17E52FFB84950033C304 /* Kingfisher in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -78,6 +85,8 @@
); );
name = suixinkan; name = suixinkan;
packageProductDependencies = ( packageProductDependencies = (
93FF17E22FFB84950033C304 /* SnapKit */,
93FF17E32FFB84950033C304 /* Kingfisher */,
); );
productName = suixinkan; productName = suixinkan;
productReference = 93FF17C52FFB84950033C304 /* suixinkan.app */; productReference = 93FF17C52FFB84950033C304 /* suixinkan.app */;
@ -107,6 +116,10 @@
); );
mainGroup = 93FF17BC2FFB84950033C304; mainGroup = 93FF17BC2FFB84950033C304;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = (
93FF17E02FFB84950033C304 /* XCRemoteSwiftPackageReference "SnapKit" */,
93FF17E12FFB84950033C304 /* XCRemoteSwiftPackageReference "Kingfisher" */,
);
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 93FF17C62FFB84950033C304 /* Products */; productRefGroup = 93FF17C62FFB84950033C304 /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -355,6 +368,38 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
93FF17E02FFB84950033C304 /* XCRemoteSwiftPackageReference "SnapKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SnapKit/SnapKit.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 5.7.0;
};
};
93FF17E12FFB84950033C304 /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 8.0.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
93FF17E22FFB84950033C304 /* SnapKit */ = {
isa = XCSwiftPackageProductDependency;
package = 93FF17E02FFB84950033C304 /* XCRemoteSwiftPackageReference "SnapKit" */;
productName = SnapKit;
};
93FF17E32FFB84950033C304 /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
package = 93FF17E12FFB84950033C304 /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
/* End XCSwiftPackageProductDependency section */
}; };
rootObject = 93FF17BD2FFB84950033C304 /* Project object */; rootObject = 93FF17BD2FFB84950033C304 /* Project object */;
} }

View File

@ -0,0 +1,24 @@
{
"originHash" : "6b6ce0f12bbd7c3a70ad4db9892c7afaef3ba38abf1df2e0ab744794af7d3b86",
"pins" : [
{
"identity" : "kingfisher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher.git",
"state" : {
"revision" : "ac632bd26a1c00f139ff62fd01806f21cf67325e",
"version" : "8.10.0"
}
},
{
"identity" : "snapkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SnapKit/SnapKit.git",
"state" : {
"revision" : "2842e6e84e82eb9a8dac0100ca90d9444b0307f4",
"version" : "5.7.1"
}
}
],
"version" : 3
}

View File

@ -0,0 +1,162 @@
//
// BaseViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// ViewController UI UI
class BaseViewController: UIViewController {
private var loadingOverlay: UIView?
private var loadingIndicator: UIActivityIndicatorView?
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
setupNavigationBar()
setupUI()
setupConstraints()
bindActions()
registerBaseNotifications()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - Template Methods
///
func setupNavigationBar() {}
///
func setupUI() {}
/// 使 SnapKit
func setupConstraints() {}
/// target-actiondelegate
func bindActions() {}
/// Token
func onSessionDidExpire() {}
// MARK: - Loading
func showLoading() {
if loadingOverlay == nil {
let overlay = UIView()
overlay.backgroundColor = UIColor.black.withAlphaComponent(0.1)
let indicator = UIActivityIndicatorView(style: .large)
indicator.hidesWhenStopped = true
view.addSubview(overlay)
overlay.addSubview(indicator)
overlay.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
indicator.snp.makeConstraints { make in
make.center.equalToSuperview()
}
loadingOverlay = overlay
loadingIndicator = indicator
}
loadingOverlay?.isHidden = false
loadingIndicator?.startAnimating()
view.bringSubviewToFront(loadingOverlay!)
}
func hideLoading() {
loadingIndicator?.stopAnimating()
loadingOverlay?.isHidden = true
}
// MARK: - Toast / Alert
func showToast(_ message: String, duration: TimeInterval = 2.0) {
guard !message.isEmpty else { return }
let container = UIView()
container.backgroundColor = UIColor.black.withAlphaComponent(0.8)
container.layer.cornerRadius = 8
container.clipsToBounds = true
container.alpha = 0
let label = UILabel()
label.text = message
label.textColor = .white
label.font = .systemFont(ofSize: 14)
label.textAlignment = .center
label.numberOfLines = 0
container.addSubview(label)
view.addSubview(container)
label.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 10, left: 16, bottom: 10, right: 16))
}
container.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-32)
make.leading.greaterThanOrEqualToSuperview().offset(32)
make.trailing.lessThanOrEqualToSuperview().offset(-32)
}
UIView.animate(withDuration: 0.25) {
container.alpha = 1
} completion: { _ in
UIView.animate(withDuration: 0.25, delay: duration) {
container.alpha = 0
} completion: { _ in
container.removeFromSuperview()
}
}
}
func showError(_ message: String) {
showToast(message)
}
func showAlert(
title: String?,
message: String?,
confirmTitle: String = "确定",
confirmHandler: (() -> Void)? = nil
) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: confirmTitle, style: .default) { _ in
confirmHandler?()
})
present(alert, animated: true)
}
// MARK: - Navigation
func popViewController() {
navigationController?.popViewController(animated: true)
}
// MARK: - Notifications
private func registerBaseNotifications() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleSessionDidExpireNotification),
name: NotificationName.sessionDidExpire,
object: nil
)
}
@objc private func handleSessionDidExpireNotification() {
hideLoading()
onSessionDidExpire()
}
}

View File

@ -0,0 +1,74 @@
//
// NotificationName.swift
// suixinkan
//
import Foundation
/// `NotificationCenter`
///
enum NotificationName {
private static let prefix = "com.yuanzhixiang.suixinkan."
private static func name(_ suffix: String) -> Notification.Name {
Notification.Name(prefix + suffix)
}
// MARK: - Session
///
static let userDidLogin = name("userDidLogin")
/// 退
static let userDidLogout = name("userDidLogout")
/// Token
static let sessionDidExpire = name("sessionDidExpire")
// MARK: - Scenic
///
static let scenicDidChange = name("scenicDidChange")
// MARK: - User Profile
///
static let userProfileDidUpdate = name("userProfileDidUpdate")
// MARK: - Message
///
static let unreadMessageCountDidChange = name("unreadMessageCountDidChange")
// MARK: - Order
///
static let orderDidUpdate = name("orderDidUpdate")
// MARK: - Task
///
static let taskDidUpdate = name("taskDidUpdate")
// MARK: - Scenic Queue
///
static let scenicQueueDidUpdate = name("scenicQueueDidUpdate")
// MARK: - Voice Call
///
static let voiceCallStateDidChange = name("voiceCallStateDidChange")
}
/// `Notification.userInfo`
enum NotificationUserInfoKey {
static let scenicId = "scenicId"
static let scenicName = "scenicName"
static let orderId = "orderId"
static let taskId = "taskId"
static let unreadCount = "unreadCount"
static let voiceCallState = "voiceCallState"
}

View File

@ -7,13 +7,11 @@
import UIKit import UIKit
class ViewController: UIViewController { class ViewController: BaseViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
// Do any additional setup after loading the view.
} }
} }