From e2906563222e942363c5db9233935ff7df0aa72f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 6 Jul 2026 14:59:55 +0800 Subject: [PATCH] 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 --- AGENTS.md | 184 ++++-------------- suixinkan.xcodeproj/project.pbxproj | 45 +++++ .../xcshareddata/swiftpm/Package.resolved | 24 +++ suixinkan/Base/BaseViewController.swift | 162 +++++++++++++++ suixinkan/Common/NotificationName.swift | 74 +++++++ suixinkan/ViewController.swift | 4 +- 6 files changed, 341 insertions(+), 152 deletions(-) create mode 100644 suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 suixinkan/Base/BaseViewController.swift create mode 100644 suixinkan/Common/NotificationName.swift diff --git a/AGENTS.md b/AGENTS.md index 422fe73..41fec57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 -- **设备**:仅支持 iPhone(`TARGETED_DEVICE_FAMILY = 1`) -- **屏幕方向**:仅竖屏(Portrait) - -开发时请遵守以上约束,不要引入 iPad 专属布局、横屏适配或低于 iOS 16 的 API。 +iOS 16+ · 仅 iPhone · 仅竖屏。不做 iPad / 横屏 / 低版本 API 适配。 ## 架构(MVVM) -按 MVVM 分层组织代码,职责清晰: - | 层级 | 职责 | |------|------| -| **View** | `UIViewController` / 自定义 `UIView`,负责 UI 展示与用户交互,不包含业务逻辑 | -| **ViewModel** | 处理业务逻辑与状态;通过方法、delegate 或明确的回调与 View 通信,不直接引用 UIKit 视图 | +| **View** | `UIViewController` / 自定义 `UIView`,UI 展示与交互,不含业务逻辑 | +| **ViewModel** | 业务逻辑与状态;通过方法、delegate 或明确回调与 View 通信 | | **Model** | 数据模型、网络/本地数据源 | -### 约定 +- 新增功能:对应 Model + ViewModel + ViewController,同步单元测试 +- ViewModel 不依赖 UIKit 视图类型 -- View 通过 ViewModel 获取数据并响应用户操作,避免在 ViewController 中写复杂业务逻辑 -- ViewModel 不依赖 `UIViewController` 或具体 View 类型 -- 新增功能时,优先创建对应的 Model、ViewModel、View(Controller)文件,保持结构一致 -- 新增功能时,同步添加单元测试(详见「单元测试」章节) +## 开发风格 -## 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` 等观察型状态机制 -- **Combine**(`PassthroughSubject`、`CurrentValueSubject`、`sink` 等)驱动 UI 更新 -- 为模拟 SwiftUI 的 `onChange` 而堆砌大量属性监听回调 +`class` / `struct` / `enum`、协议及公开 API 必须写 `///` 文档注释,说明职责与用途;复杂内部逻辑按需补充。不写废话注释、不写过时注释。 -### 推荐做法 +## 技术栈与库 -- **命令式更新 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 -- **布局**: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 会自动处理 +Swift · UIKit · SnapKit(代码布局,约束写在 `setupUI()` / `setupConstraints()`)· Kingfisher(`kf.setImage(with:)` 加载网络图,列表利用缓存) ## 列表展示 -根据列表复杂度选择合适的容器,数据更新优先使用 **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,保持职责分离 +- 简单列表 → `UITableView`;复杂列表(网格、卡片、不等高)→ `UICollectionView` + Compositional Layout +- 数据更新用 Diffable Data Source + `apply(_:animatingDifferences: true)`,Item 遵循 `Hashable`,避免 `reloadData()` +- ViewModel 提供数据,View 构建 snapshot 并 apply ## 单元测试 -每添加一个功能,**必须**编写对应的单元测试,并确保全部通过后再视为完成。 +每增一功能必须附带单元测试且全部通过(`Cmd + U` / `xcodebuild test`),与功能同次提交。 -### 要求 +| 层级 | 优先级 | +|------|--------| +| ViewModel / Model | ✅ 优先 | +| View | 按需 | -- 新功能与单元测试同一次改动中提交,不单独留「后续补测」 -- 测试需在本地运行通过(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) - } -} -``` +测试放 `suixinkanTests/`,独立可重复,mock 网络与外部依赖。 ## 开发注意 -- 使用 iOS 16+ 可用 API;如需兼容更低版本,需先与项目维护者确认 -- UI 按竖屏 iPhone 设计,无需考虑横屏与 iPad 分屏 -- 布局统一用 SnapKit,网络图片统一用 Kingfisher -- 简单列表用 `UITableView`,复杂列表优先 `UICollectionView`;数据更新用 Diffable Data Source 并带动画 -- **SwiftUI → UIKit 迁移**:命令式开发,不用 Observable / Combine,不堆 `onChanged` 式回调 -- **新增功能必须附带单元测试,且全部通过** -- 保持代码简洁,遵循现有命名与目录风格 +- SnapKit 布局、Kingfisher 图片、Diffable 列表带动画 +- 以 Android 工程为功能参考,命令式 UIKit,不用 Observable / Combine +- 新增功能必测、类型与公开 API 必注释、保持简洁 diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index a46c39e..6a8aa08 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -6,6 +6,11 @@ objectVersion = 77; 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 */ 93FF17C52FFB84950033C304 /* suixinkan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = suixinkan.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -36,6 +41,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 93FF17E42FFB84950033C304 /* SnapKit in Frameworks */, + 93FF17E52FFB84950033C304 /* Kingfisher in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -78,6 +85,8 @@ ); name = suixinkan; packageProductDependencies = ( + 93FF17E22FFB84950033C304 /* SnapKit */, + 93FF17E32FFB84950033C304 /* Kingfisher */, ); productName = suixinkan; productReference = 93FF17C52FFB84950033C304 /* suixinkan.app */; @@ -107,6 +116,10 @@ ); mainGroup = 93FF17BC2FFB84950033C304; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 93FF17E02FFB84950033C304 /* XCRemoteSwiftPackageReference "SnapKit" */, + 93FF17E12FFB84950033C304 /* XCRemoteSwiftPackageReference "Kingfisher" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 93FF17C62FFB84950033C304 /* Products */; projectDirPath = ""; @@ -355,6 +368,38 @@ defaultConfigurationName = Release; }; /* 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 */; } diff --git a/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..71b5a7f --- /dev/null +++ b/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 +} diff --git a/suixinkan/Base/BaseViewController.swift b/suixinkan/Base/BaseViewController.swift new file mode 100644 index 0000000..e226d35 --- /dev/null +++ b/suixinkan/Base/BaseViewController.swift @@ -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-action、delegate 等交互。 + 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() + } +} diff --git a/suixinkan/Common/NotificationName.swift b/suixinkan/Common/NotificationName.swift new file mode 100644 index 0000000..37e45b5 --- /dev/null +++ b/suixinkan/Common/NotificationName.swift @@ -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" +} diff --git a/suixinkan/ViewController.swift b/suixinkan/ViewController.swift index cc22fbb..7f6fc1e 100644 --- a/suixinkan/ViewController.swift +++ b/suixinkan/ViewController.swift @@ -7,13 +7,11 @@ import UIKit -class ViewController: UIViewController { +class ViewController: BaseViewController { override func viewDidLoad() { super.viewDidLoad() - // Do any additional setup after loading the view. } - }