From 6769be60e4fbd81f15e9d493aca4bea68e882a68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 30 Jun 2026 10:07:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=85=E6=8B=8D=E7=9B=B8?= =?UTF-8?q?=E5=86=8C=20API=20=E6=9C=AA=E6=B3=A8=E5=85=A5=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=20APIClient=20=E5=AF=BC=E8=87=B4=E6=9C=AA=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 RootView 注入 travelAlbumAPI,并将 travel_album 纳入首页常用功能;补充 App/Networking 依赖注入说明。 Co-authored-by: Cursor --- suixinkan/App/App.md | 53 +++++++++++++++++++ suixinkan/App/RootView.swift | 3 ++ suixinkan/Core/Networking/Networking.md | 2 + suixinkan/Features/Home/AllFunctions.md | 4 +- .../Home/Routing/HomeMenuRouter.swift | 2 +- .../Home/Services/HomeCommonMenuStore.swift | 1 + .../Features/Home/Views/HomeIconCatalog.swift | 2 +- .../HomeAllFunctionsBuilderTests.swift | 5 +- 8 files changed, 66 insertions(+), 6 deletions(-) diff --git a/suixinkan/App/App.md b/suixinkan/App/App.md index 7c3229e..c72caa0 100644 --- a/suixinkan/App/App.md +++ b/suixinkan/App/App.md @@ -23,6 +23,59 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航 - `AuthSessionCoordinator`:统一处理登录完成、退出登录、偏好读取和账号快照刷新。 - `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。 - `AccountContextLoader`:统一同步用户资料、角色权限、景区和门店。 +- `AppServiceEnvironment.swift`:为无 UI 状态的服务定义 SwiftUI `EnvironmentKey`,供 View 通过 `@Environment(\.xxxAPI)` 读取。 + +## 依赖注入:为何 API 走 Environment 而非单例 + +SwiftUI 的 `Environment` 在本项目中是**依赖注入(DI)通道**,不等于「UI 状态容器」。注入对象分两类: + +| 类型 | 机制 | 代表 | +|------|------|------| +| 会驱动 UI 刷新的状态 | `environmentObject` + `ObservableObject` | `AppSession`、`ToastCenter` | +| 无 UI 状态的服务 | 自定义 `EnvironmentKey` | `OrdersAPI`、`ProfileAPI` | + +### 数据流 + +1. `RootView.init()` 创建**一个** `APIClient`,再传给所有 `*API(client:)`。 +2. `RootView.body` 通过 `.environment(\.ordersAPI, ordersAPI)` 等下发到整棵视图树。 +3. `task` 中 `apiClient.bindAuthTokenProvider { appSession.token }` 绑定 token。 +4. View 用 `@Environment(\.ordersAPI)` 读取,调用 ViewModel 时作为参数传入,例如 `await viewModel.reload(api: ordersAPI, ...)`。 +5. ViewModel 不持有 API,方法签名接收 `XxxServing` 协议,单元测试直接传 Mock。 + +网络层细节见 [Networking 模块](../Core/Networking/Networking.md)。 + +### 为何不用「每个 API 一个单例」 + +**核心原因:需要共享的是 `APIClient`,不是 API 类本身。** + +`AuthAPI`、`OrdersAPI` 等只是 `APIClient` 上的薄封装,本身无状态。全 App 必须只有一个 `APIClient`,才能保证 token provider 只绑定一次、登录后 token 对所有请求一致、`tokenOverride`(如 `set-user`)走同一客户端。若每个 API 各自 `static let shared = XxxAPI(client: APIClient())`,容易在不经意间创建多个 client,导致 token 不同步。 + +`RootView` 作为**组合根**集中创建并装配依赖,例如: + +- `OSSUploadService(configService: uploadAPI)` 依赖 `UploadAPI` +- `SessionBootstrapper`、`AuthSessionCoordinator` 在 init 时注入具体 API +- `PushNotificationManager.shared.configure(api: pushAPI, ...)` 需要与 RootView 相同的 `pushAPI` 实例 + +### Environment 的其他收益 + +- **Preview / 局部替换**:可在子树 `.environment(\.ordersAPI, mockAPI)` 替换,不影响全局;`AppServiceEnvironment` 的 `defaultValue` 供 Preview 兜底。 +- **测试**:ViewModel 测试靠 `XxxServing` 协议 + 方法参数注入 Mock,不依赖 Environment;Environment 主要给 View 层省掉层层传参。 +- **历史选择**:iOS 17 迁移到 iOS 16 时,UI 状态统一 `environmentObject`,服务统一自定义 `EnvironmentKey`(见根目录 `iOS16兼容迁移记录.md`)。 + +### 单例是否可行 + +可以,但应有纪律。项目里已有单例先例:`PushNotificationManager.shared`、`PaymentVoiceSpeaker.shared`、`CloudTransferStore.shared`——特点是跨模块、与 UI 树无关、需在 AppDelegate 或后台回调中访问。 + +若全面改单例,推荐收敛为一个 `AppServices` 容器共享同一 `client`,而不是 20 个 `OrdersAPI.shared`。运行时行为可与当前方案等价,差别在可维护性和团队习惯。 + +### 当前方案的代价 + +1. `AppServiceEnvironment.swift` 约 25 组 Key,`RootView` 大量 `@State` / `.environment` 行,样板代码多。 +2. `Environment` 命名易误导为 UI 绑定,实际是 service locator。 +3. Key 的 `defaultValue` 会 `new` 独立 `APIClient()`,仅未从 `RootView` 注入时使用;Preview 需注意是否拿到真实实例。 +4. View 用 `@Environment`,ViewModel 用方法参数,View 充当桥接层。 + +若将来觉得 Key 过多,可收敛为一个 `AppServices` 对象注入一次,属于简化写法,不是架构对错。 ## 启动流程 diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index 996e19c..d3ad1bb 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -42,6 +42,7 @@ struct RootView: View { @State private var scheduleAPI: ScheduleAPI @State private var inviteAPI: InviteAPI @State private var assetsAPI: AssetsAPI + @State private var travelAlbumAPI: TravelAlbumAPI @State private var punchPointAPI: PunchPointAPI @State private var locationReportAPI: LocationReportAPI @StateObject private var homeLocationViewModel: HomeLocationViewModel @@ -82,6 +83,7 @@ struct RootView: View { _scheduleAPI = State(initialValue: ScheduleAPI(client: apiClient)) _inviteAPI = State(initialValue: InviteAPI(client: apiClient)) _assetsAPI = State(initialValue: AssetsAPI(client: apiClient)) + _travelAlbumAPI = State(initialValue: TravelAlbumAPI(client: apiClient)) _punchPointAPI = State(initialValue: PunchPointAPI(client: apiClient)) let locationReportAPI = LocationReportAPI(client: apiClient) _locationReportAPI = State(initialValue: locationReportAPI) @@ -147,6 +149,7 @@ struct RootView: View { .environment(\.scheduleAPI, scheduleAPI) .environment(\.inviteAPI, inviteAPI) .environment(\.assetsAPI, assetsAPI) + .environment(\.travelAlbumAPI, travelAlbumAPI) .environment(\.punchPointAPI, punchPointAPI) .environment(\.locationReportAPI, locationReportAPI) .environmentObject(homeLocationViewModel) diff --git a/suixinkan/Core/Networking/Networking.md b/suixinkan/Core/Networking/Networking.md index c35d4bc..1d44b1e 100644 --- a/suixinkan/Core/Networking/Networking.md +++ b/suixinkan/Core/Networking/Networking.md @@ -108,6 +108,8 @@ token: 空 token 不会写入 Header。 +全 App 应只存在一个 `APIClient` 实例,由 `RootView` 创建并传给各业务 `*API`;token provider 也只在此 client 上绑定一次。为何通过 Environment 下发而非单例,见 [App 模块依赖注入说明](../App/App.md#依赖注入为何-api-走-environment-而非单例)。 + ## 环境选择 `APIEnvironment.current` 根据编译环境选择接口地址: diff --git a/suixinkan/Features/Home/AllFunctions.md b/suixinkan/Features/Home/AllFunctions.md index 1a7aa37..7decb26 100644 --- a/suixinkan/Features/Home/AllFunctions.md +++ b/suixinkan/Features/Home/AllFunctions.md @@ -83,7 +83,7 @@ allFunctions = 顶层权限 `androidHomeMenuURIs` 与 Android `Constants.menuList` 登记 URI 一致。以下典型 URI **不会**出现在「全部功能」页,即使接口返回了顶层权限: - `basic_info`、`photographer_stats`、`photographer_orders` -- `location_info`、`scan_qr`、`payment_qr`、`travel_album` +- `location_info`、`scan_qr`、`payment_qr` - `album_list`、`material_upload` 等未登记 URI 子权限 URI 也不会出现(未 flatten)。 @@ -206,4 +206,4 @@ Debug 构建下可用 Xcode Console 过滤 `HomeAllFunctions`: - 不展开子权限 - 精确 URI 拆分 common / more - 常用区顺序跟随 `allFunctions` -- 与 Android 样例账号(27 顶层 → 20 可用 → 3 常用 + 17 更多)一致 +- 与 Android 样例账号(27 顶层 → 21 可用 → 3 常用 + 18 更多)一致 diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift index 5c40076..9fc2606 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -149,7 +149,7 @@ enum HomeMenuRouter { return .destination(.liveManagement) case "live_album": return .destination(.liveAlbum) - case "travel_album", "task_new_entry": + case "travel_album": return .destination(.travelAlbumEntry) case "operating-area": return .destination(.operatingArea) diff --git a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift index e255b30..dfdc4c7 100644 --- a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift +++ b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift @@ -27,6 +27,7 @@ struct HomeCommonMenuStore { "live_stream_management", "verification_order", "live_album", + "travel_album", "pm", "location_report", "registration_invitation", diff --git a/suixinkan/Features/Home/Views/HomeIconCatalog.swift b/suixinkan/Features/Home/Views/HomeIconCatalog.swift index ed1b944..fe35281 100644 --- a/suixinkan/Features/Home/Views/HomeIconCatalog.swift +++ b/suixinkan/Features/Home/Views/HomeIconCatalog.swift @@ -48,7 +48,7 @@ enum HomeIconCatalog { "checkmark.seal.fill" case "live_album": "play.rectangle.on.rectangle.fill" - case "travel_album", "task_new_entry": + case "travel_album": "photo.on.rectangle.angled" case "pm", "pm_manager", "project_edit": "circle.grid.2x2.fill" diff --git a/suixinkanTests/HomeAllFunctionsBuilderTests.swift b/suixinkanTests/HomeAllFunctionsBuilderTests.swift index 786e5b3..ab5b275 100644 --- a/suixinkanTests/HomeAllFunctionsBuilderTests.swift +++ b/suixinkanTests/HomeAllFunctionsBuilderTests.swift @@ -122,11 +122,12 @@ final class HomeAllFunctionsBuilderTests: XCTestCase { commonURIs: ["location_report", "registration_invitation", "store"] ) - XCTAssertEqual(snapshot.allFunctions.count, 20) + XCTAssertEqual(snapshot.allFunctions.count, 21) XCTAssertEqual(snapshot.allFunctions.first?.uri, "location_report") XCTAssertEqual(snapshot.commonFunctions.map(\.uri), ["location_report", "registration_invitation", "store"]) XCTAssertEqual(snapshot.moreFunctions.first?.uri, "fly") - XCTAssertEqual(snapshot.moreFunctions.count, 17) + XCTAssertEqual(snapshot.moreFunctions.count, 18) + XCTAssertTrue(snapshot.moreFunctions.contains { $0.uri == "travel_album" }) } private func permission(