Refactor AppServices into grouped bundles and document DI conventions.
Centralize dependency wiring with Session/Context/Network/UI/Runtime bundles, unify leaf ViewControllers on appServices, and add a test initializer with AppServicesTests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -2,18 +2,19 @@
|
|||||||
|
|
||||||
## 模块职责
|
## 模块职责
|
||||||
|
|
||||||
App 模块负责应用入口、根视图切换、全局状态注入、主导航状态、登录态恢复和全局 Toast。
|
App 模块负责应用入口、根视图切换、全局状态注入、主导航状态、登录态恢复和全局 Toast / Loading。
|
||||||
|
|
||||||
该模块不直接处理具体页面业务,主要承担 App 级基础设施编排:
|
该模块不直接处理具体页面业务,主要承担 App 级基础设施编排:
|
||||||
- 根据登录状态展示登录页、恢复态或主 Tab。
|
- 根据登录状态展示登录页、恢复态或主 Tab。
|
||||||
- 创建并注入全局状态和共享服务。
|
- 创建并注入全局状态和共享服务。
|
||||||
- 管理每个 Tab 独立的 `NavigationStack` 路径。
|
- 管理每个 Tab 独立的导航栈与 `AppRouter` 路径。
|
||||||
- 协调登录成功、退出登录、冷启动恢复和本地缓存同步。
|
- 协调登录成功、退出登录、冷启动恢复和本地缓存同步。
|
||||||
|
|
||||||
## 核心对象
|
## 核心对象
|
||||||
|
|
||||||
- `suixinkanApp`:SwiftUI 应用入口,挂载 `RootView`。
|
- `AppDelegate` / `SceneDelegate`:UIKit 应用入口,挂载 `RootViewController`。
|
||||||
- `RootView`:创建 `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter`、`APIClient` 和业务 API,并注入 SwiftUI Environment。
|
- `AppServices`:全局依赖容器(Composition Root),等价于参考工程 SwiftUI `RootView` 中的 Environment 注入;内部按 Session / Context / Network / UI / Runtime 分组,对外仍保留扁平属性访问。
|
||||||
|
- `RootViewController`:根据 `AppSession.phase` 切换登录页、恢复占位或 `MainTabBarController`,并挂载全局 Toast / Loading。
|
||||||
- `AppSession`:保存认证阶段和正式 token,只负责登录态,不承载业务资料。
|
- `AppSession`:保存认证阶段和正式 token,只负责登录态,不承载业务资料。
|
||||||
- `AccountContext`:保存当前账号资料、景区作用域和门店作用域。
|
- `AccountContext`:保存当前账号资料、景区作用域和门店作用域。
|
||||||
- `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。
|
- `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。
|
||||||
@ -24,16 +25,51 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
|||||||
- `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。
|
- `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。
|
||||||
- `AccountContextLoader`:统一同步用户资料、角色权限、景区和门店。
|
- `AccountContextLoader`:统一同步用户资料、角色权限、景区和门店。
|
||||||
|
|
||||||
|
## AppServices 依赖约定
|
||||||
|
|
||||||
|
`AppServices.shared` 是唯一 Composition Root,集中创建 `APIClient`、各业务 API 与全局 Context,并绑定 token provider。
|
||||||
|
|
||||||
|
### 何时通过 `init(services:)` 注入
|
||||||
|
|
||||||
|
仅在 Composition 边界传参,便于 UI Test 或单元测试替换容器:
|
||||||
|
|
||||||
|
- `RootViewController`(默认 `AppServices.shared`)
|
||||||
|
- `MainTabBarController` → `TabNavigationController`
|
||||||
|
- `LoginViewController`
|
||||||
|
- `AppRouteViewControllerFactory.makeViewController(..., services:)`
|
||||||
|
|
||||||
|
根链路使用 `init(services: AppServices = .shared)`,生产代码无感,测试可传入自定义实例。
|
||||||
|
|
||||||
|
### 何时直接使用 `appServices`
|
||||||
|
|
||||||
|
普通业务 `ViewController`、模块基类(`ModuleTableViewController` / `ModuleCollectionViewController`)通过 `UIViewController.appServices` 扩展访问,**不必**再声明 `private let services` 或新增 `init(services:)`。
|
||||||
|
|
||||||
|
导航辅助(`UIKitAppNavigation`、`HomeMenuRouting`)在全局 push 场景可直接使用 `AppServices.shared`。
|
||||||
|
|
||||||
|
### ViewModel 层边界
|
||||||
|
|
||||||
|
ViewModel **不持有** `AppServices`。View 从容器取出具体依赖,在 `reload` / `load` / `submit` 等方法调用时传入所需 API 与 Context。
|
||||||
|
|
||||||
|
### 适合独立单例的类型
|
||||||
|
|
||||||
|
以下类型独立于 `AppServices`,因其生命周期或系统入口特殊:
|
||||||
|
|
||||||
|
- `PushNotificationManager.shared`:`AppDelegate` 回调桥
|
||||||
|
- `CloudTransferStore.shared`:跨页面云传任务状态
|
||||||
|
- 第三方 SDK(如 `AMapServices`)
|
||||||
|
|
||||||
|
`AppSession`、`AccountContext`、各 `*API` 等**不应**拆成多个 `.shared`,须由 `AppServices` 统一创建,并在登出时由 `RootViewController` 统一 reset。
|
||||||
|
|
||||||
## 启动流程
|
## 启动流程
|
||||||
|
|
||||||
1. `suixinkanApp` 创建 `RootView`。
|
1. `AppDelegate` 创建 `RootViewController(services: .shared)`。
|
||||||
2. `RootView` 初始化共享依赖,并把它们注入 Environment。
|
2. `AppServices` 初始化共享依赖(`NetworkBundle` 内共享同一 `APIClient`)。
|
||||||
3. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。
|
3. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。
|
||||||
4. `SessionBootstrapper.restore` 尝试从 Keychain 读取正式 token。
|
4. `SessionBootstrapper.restore` 尝试从 Keychain 读取正式 token。
|
||||||
5. 无 token 时保持 `loggedOut`,展示 `LoginView`。
|
5. 无 token 时保持 `loggedOut`,展示 `LoginViewController`。
|
||||||
6. 有 token 时进入 `restoring`,先恢复本地账号快照。
|
6. 有 token 时进入 `restoring`,先恢复本地账号快照。
|
||||||
7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。
|
7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。
|
||||||
8. 校验成功后进入 `loggedIn`,展示 `MainTabsView`。
|
8. 校验成功后进入 `loggedIn`,展示 `MainTabBarController`。
|
||||||
9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
|
9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
|
||||||
10. 明确 token 失效时清空 token 和账号快照,回到登录页。
|
10. 明确 token 失效时清空 token 和账号快照,回到登录页。
|
||||||
11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
|
11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
|
||||||
@ -66,14 +102,14 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
|||||||
|
|
||||||
## Toast
|
## Toast
|
||||||
|
|
||||||
全局 Toast 由 `RootView` 挂载在页面最上层,业务页面只调用 `toastCenter.show(...)` 发出提示命令。
|
全局 Toast 由 `RootViewController` 在 `viewDidAppear` 时挂载到 window,业务页面通过 `showToast(...)` 或 `appServices.toastCenter.show(...)` 发出提示命令。
|
||||||
|
|
||||||
Toast 展示为顶部全宽横幅,背景使用不透明主色并延伸到屏幕顶部、左边和右边;文案居中展示,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。
|
Toast 展示为顶部全宽横幅,背景使用不透明主色并延伸到屏幕顶部、左边和右边;文案居中展示,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。
|
||||||
|
|
||||||
## 导航规则
|
## 导航规则
|
||||||
|
|
||||||
主界面使用 `TabView`,每个 Tab 内部由单独的 `NavigationStack` 包裹。`AppRouter` 为每个 `AppTab` 持有独立 `RouterPath`,切换 Tab 不会丢失该 Tab 的内部导航路径。
|
主界面使用 `MainTabBarController`,每个 Tab 内部由独立的 `TabNavigationController`(`UINavigationController`)包裹。`AppRouter` 为每个 `AppTab` 持有独立 `RouterPath`,切换 Tab 不会丢失该 Tab 的内部导航路径。
|
||||||
|
|
||||||
Tab 根页面显示底部 TabBar。通过 `AppRoute` push 到子页面时,`MainTabsView` 会根据 `AppRoute.hidesTabBarWhenPushed` 统一隐藏 TabBar,避免每个业务页面重复处理。
|
Tab 根页面显示底部 TabBar。通过 `AppRoute` push 到子页面时,会根据 `AppRoute.hidesTabBarWhenPushed` 统一隐藏 TabBar。
|
||||||
|
|
||||||
当前路由枚举 `AppRoute` 仍以占位详情页为主,后续新增真实页面时应优先扩展 `AppRoute`,再由对应 Tab 的 `NavigationStack` 处理跳转。
|
跨 Tab 跳转通过 `AppRouter.select` / `navigateHome` / `navigateProfile` 处理;Tab 内 push 通过 `UIKitAppNavigation` 或当前 Nav 栈完成。新增页面时优先扩展 `AppRoute` / `HomeRoute`,在 `AppRouteViewControllerFactory` 注册对应 ViewController。
|
||||||
|
|||||||
@ -12,46 +12,55 @@ import Foundation
|
|||||||
final class AppServices {
|
final class AppServices {
|
||||||
static let shared = AppServices()
|
static let shared = AppServices()
|
||||||
|
|
||||||
let appSession = AppSession()
|
let session: SessionBundle
|
||||||
let accountContext = AccountContext()
|
let context: AppContextBundle
|
||||||
let permissionContext = PermissionContext()
|
let network: NetworkBundle
|
||||||
let scenicSpotContext = ScenicSpotContext()
|
let ui: UIFeedbackBundle
|
||||||
|
let runtime: AppRuntimeBundle
|
||||||
let appRouter = AppRouter()
|
let appRouter = AppRouter()
|
||||||
let toastCenter = ToastCenter()
|
|
||||||
let globalLoading = GlobalLoadingCenter()
|
|
||||||
let scenicQueueRuntime = ScenicQueueRuntime()
|
|
||||||
|
|
||||||
let apiClient: APIClient
|
|
||||||
let authAPI: AuthAPI
|
|
||||||
let profileAPI: ProfileAPI
|
|
||||||
let uploadAPI: UploadAPI
|
|
||||||
let ossUploadService: OSSUploadService
|
|
||||||
let accountContextAPI: AccountContextAPI
|
|
||||||
let ordersAPI: OrdersAPI
|
|
||||||
let statisticsAPI: StatisticsAPI
|
|
||||||
let paymentAPI: PaymentAPI
|
|
||||||
let walletAPI: WalletAPI
|
|
||||||
let pushAPI: PushAPI
|
|
||||||
let scenicPermissionAPI: ScenicPermissionAPI
|
|
||||||
let scenicSettlementAPI: ScenicSettlementAPI
|
|
||||||
let messageCenterAPI: MessageCenterAPI
|
|
||||||
let scenicQueueAPI: ScenicQueueAPI
|
|
||||||
let liveAPI: LiveAPI
|
|
||||||
let operatingAreaAPI: OperatingAreaAPI
|
|
||||||
let pilotCertificationAPI: PilotCertificationAPI
|
|
||||||
let taskAPI: TaskAPI
|
|
||||||
let projectAPI: ProjectAPI
|
|
||||||
let scheduleAPI: ScheduleAPI
|
|
||||||
let inviteAPI: InviteAPI
|
|
||||||
let assetsAPI: AssetsAPI
|
|
||||||
let punchPointAPI: PunchPointAPI
|
|
||||||
let locationReportAPI: LocationReportAPI
|
|
||||||
|
|
||||||
let authSessionCoordinator: AuthSessionCoordinator
|
|
||||||
let sessionBootstrapper: SessionBootstrapper
|
|
||||||
private let tokenStore: SessionTokenStore
|
private let tokenStore: SessionTokenStore
|
||||||
private let snapshotStore: AccountSnapshotStore
|
private let snapshotStore: AccountSnapshotStore
|
||||||
|
|
||||||
|
// MARK: - 扁平访问(兼容现有调用方)
|
||||||
|
|
||||||
|
var appSession: AppSession { session.appSession }
|
||||||
|
var accountContext: AccountContext { context.accountContext }
|
||||||
|
var permissionContext: PermissionContext { context.permissionContext }
|
||||||
|
var scenicSpotContext: ScenicSpotContext { context.scenicSpotContext }
|
||||||
|
var toastCenter: ToastCenter { ui.toastCenter }
|
||||||
|
var globalLoading: GlobalLoadingCenter { ui.globalLoading }
|
||||||
|
var scenicQueueRuntime: ScenicQueueRuntime { runtime.scenicQueueRuntime }
|
||||||
|
|
||||||
|
var apiClient: APIClient { network.apiClient }
|
||||||
|
var authAPI: AuthAPI { network.authAPI }
|
||||||
|
var profileAPI: ProfileAPI { network.profileAPI }
|
||||||
|
var uploadAPI: UploadAPI { network.uploadAPI }
|
||||||
|
var ossUploadService: OSSUploadService { network.ossUploadService }
|
||||||
|
var accountContextAPI: AccountContextAPI { network.accountContextAPI }
|
||||||
|
var ordersAPI: OrdersAPI { network.ordersAPI }
|
||||||
|
var statisticsAPI: StatisticsAPI { network.statisticsAPI }
|
||||||
|
var paymentAPI: PaymentAPI { network.paymentAPI }
|
||||||
|
var walletAPI: WalletAPI { network.walletAPI }
|
||||||
|
var pushAPI: PushAPI { network.pushAPI }
|
||||||
|
var scenicPermissionAPI: ScenicPermissionAPI { network.scenicPermissionAPI }
|
||||||
|
var scenicSettlementAPI: ScenicSettlementAPI { network.scenicSettlementAPI }
|
||||||
|
var messageCenterAPI: MessageCenterAPI { network.messageCenterAPI }
|
||||||
|
var scenicQueueAPI: ScenicQueueAPI { network.scenicQueueAPI }
|
||||||
|
var liveAPI: LiveAPI { network.liveAPI }
|
||||||
|
var operatingAreaAPI: OperatingAreaAPI { network.operatingAreaAPI }
|
||||||
|
var pilotCertificationAPI: PilotCertificationAPI { network.pilotCertificationAPI }
|
||||||
|
var taskAPI: TaskAPI { network.taskAPI }
|
||||||
|
var projectAPI: ProjectAPI { network.projectAPI }
|
||||||
|
var scheduleAPI: ScheduleAPI { network.scheduleAPI }
|
||||||
|
var inviteAPI: InviteAPI { network.inviteAPI }
|
||||||
|
var assetsAPI: AssetsAPI { network.assetsAPI }
|
||||||
|
var punchPointAPI: PunchPointAPI { network.punchPointAPI }
|
||||||
|
var locationReportAPI: LocationReportAPI { network.locationReportAPI }
|
||||||
|
|
||||||
|
var authSessionCoordinator: AuthSessionCoordinator { session.authSessionCoordinator }
|
||||||
|
var sessionBootstrapper: SessionBootstrapper { session.sessionBootstrapper }
|
||||||
|
|
||||||
/// 当前景区 ID。
|
/// 当前景区 ID。
|
||||||
var currentScenicId: Int? {
|
var currentScenicId: Int? {
|
||||||
accountContext.currentScenic?.id
|
accountContext.currentScenic?.id
|
||||||
@ -67,50 +76,74 @@ final class AppServices {
|
|||||||
accountContext.profile?.userId
|
accountContext.profile?.userId
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 初始化实例。
|
/// 生产环境默认初始化。
|
||||||
private init() {
|
private init() {
|
||||||
let client = APIClient()
|
|
||||||
let tokenStore = SessionTokenStore()
|
let tokenStore = SessionTokenStore()
|
||||||
let snapshotStore = AccountSnapshotStore()
|
let snapshotStore = AccountSnapshotStore()
|
||||||
|
let preferencesStore = AppPreferencesStore()
|
||||||
|
let apiClient = APIClient()
|
||||||
|
|
||||||
self.tokenStore = tokenStore
|
self.tokenStore = tokenStore
|
||||||
self.snapshotStore = snapshotStore
|
self.snapshotStore = snapshotStore
|
||||||
apiClient = client
|
context = AppContextBundle(
|
||||||
authAPI = AuthAPI(client: client)
|
accountContext: AccountContext(),
|
||||||
profileAPI = ProfileAPI(client: client)
|
permissionContext: PermissionContext(),
|
||||||
uploadAPI = UploadAPI(client: client)
|
scenicSpotContext: ScenicSpotContext()
|
||||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
|
||||||
accountContextAPI = AccountContextAPI(client: client)
|
|
||||||
ordersAPI = OrdersAPI(client: client)
|
|
||||||
statisticsAPI = StatisticsAPI(client: client)
|
|
||||||
paymentAPI = PaymentAPI(client: client)
|
|
||||||
walletAPI = WalletAPI(client: client)
|
|
||||||
pushAPI = PushAPI(client: client)
|
|
||||||
scenicPermissionAPI = ScenicPermissionAPI(client: client)
|
|
||||||
scenicSettlementAPI = ScenicSettlementAPI(client: client)
|
|
||||||
messageCenterAPI = MessageCenterAPI(client: client)
|
|
||||||
scenicQueueAPI = ScenicQueueAPI(client: client)
|
|
||||||
liveAPI = LiveAPI(client: client)
|
|
||||||
operatingAreaAPI = OperatingAreaAPI(client: client)
|
|
||||||
pilotCertificationAPI = PilotCertificationAPI(client: client)
|
|
||||||
taskAPI = TaskAPI(client: client)
|
|
||||||
projectAPI = ProjectAPI(client: client)
|
|
||||||
scheduleAPI = ScheduleAPI(client: client)
|
|
||||||
inviteAPI = InviteAPI(client: client)
|
|
||||||
assetsAPI = AssetsAPI(client: client)
|
|
||||||
punchPointAPI = PunchPointAPI(client: client)
|
|
||||||
locationReportAPI = LocationReportAPI(client: client)
|
|
||||||
authSessionCoordinator = AuthSessionCoordinator(
|
|
||||||
tokenStore: tokenStore,
|
|
||||||
snapshotStore: snapshotStore,
|
|
||||||
preferencesStore: AppPreferencesStore()
|
|
||||||
)
|
)
|
||||||
sessionBootstrapper = SessionBootstrapper(
|
network = NetworkBundle(apiClient: apiClient)
|
||||||
tokenStore: tokenStore,
|
ui = UIFeedbackBundle(
|
||||||
snapshotStore: snapshotStore
|
toastCenter: ToastCenter(),
|
||||||
|
globalLoading: GlobalLoadingCenter()
|
||||||
)
|
)
|
||||||
apiClient.bindAuthTokenProvider { [unowned self] in
|
runtime = AppRuntimeBundle(scenicQueueRuntime: ScenicQueueRuntime())
|
||||||
appSession.token
|
session = SessionBundle(
|
||||||
}
|
appSession: AppSession(),
|
||||||
|
authSessionCoordinator: AuthSessionCoordinator(
|
||||||
|
tokenStore: tokenStore,
|
||||||
|
snapshotStore: snapshotStore,
|
||||||
|
preferencesStore: preferencesStore
|
||||||
|
),
|
||||||
|
sessionBootstrapper: SessionBootstrapper(
|
||||||
|
tokenStore: tokenStore,
|
||||||
|
snapshotStore: snapshotStore
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bindAuthTokenProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试专用初始化,允许注入 mock `APIClient` 与存储替身。
|
||||||
|
init(
|
||||||
|
apiClient: APIClient,
|
||||||
|
tokenStore: SessionTokenStore = SessionTokenStore(),
|
||||||
|
snapshotStore: AccountSnapshotStore = AccountSnapshotStore(),
|
||||||
|
preferencesStore: AppPreferencesStore = AppPreferencesStore()
|
||||||
|
) {
|
||||||
|
self.tokenStore = tokenStore
|
||||||
|
self.snapshotStore = snapshotStore
|
||||||
|
context = AppContextBundle(
|
||||||
|
accountContext: AccountContext(),
|
||||||
|
permissionContext: PermissionContext(),
|
||||||
|
scenicSpotContext: ScenicSpotContext()
|
||||||
|
)
|
||||||
|
network = NetworkBundle(apiClient: apiClient)
|
||||||
|
ui = UIFeedbackBundle(
|
||||||
|
toastCenter: ToastCenter(),
|
||||||
|
globalLoading: GlobalLoadingCenter()
|
||||||
|
)
|
||||||
|
runtime = AppRuntimeBundle(scenicQueueRuntime: ScenicQueueRuntime())
|
||||||
|
session = SessionBundle(
|
||||||
|
appSession: AppSession(),
|
||||||
|
authSessionCoordinator: AuthSessionCoordinator(
|
||||||
|
tokenStore: tokenStore,
|
||||||
|
snapshotStore: snapshotStore,
|
||||||
|
preferencesStore: preferencesStore
|
||||||
|
),
|
||||||
|
sessionBootstrapper: SessionBootstrapper(
|
||||||
|
tokenStore: tokenStore,
|
||||||
|
snapshotStore: snapshotStore
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bindAuthTokenProvider()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 绑定推送与 UIKit 导航。
|
/// 绑定推送与 UIKit 导航。
|
||||||
@ -121,4 +154,11 @@ final class AppServices {
|
|||||||
router: appRouter
|
router: appRouter
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将 `APIClient` 与当前 `AppSession` token 关联。
|
||||||
|
private func bindAuthTokenProvider() {
|
||||||
|
apiClient.bindAuthTokenProvider { [unowned self] in
|
||||||
|
appSession.token
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
96
suixinkan_ios/App/AppServicesBundles.swift
Normal file
96
suixinkan_ios/App/AppServicesBundles.swift
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
//
|
||||||
|
// AppServicesBundles.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 登录与会话恢复相关依赖分组。
|
||||||
|
struct SessionBundle {
|
||||||
|
let appSession: AppSession
|
||||||
|
let authSessionCoordinator: AuthSessionCoordinator
|
||||||
|
let sessionBootstrapper: SessionBootstrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 账号、权限与景区景点等业务作用域 Context 分组。
|
||||||
|
struct AppContextBundle {
|
||||||
|
let accountContext: AccountContext
|
||||||
|
let permissionContext: PermissionContext
|
||||||
|
let scenicSpotContext: ScenicSpotContext
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 网络层依赖分组,共享同一 `APIClient` 与 token 链路。
|
||||||
|
struct NetworkBundle {
|
||||||
|
let apiClient: APIClient
|
||||||
|
let authAPI: AuthAPI
|
||||||
|
let profileAPI: ProfileAPI
|
||||||
|
let uploadAPI: UploadAPI
|
||||||
|
let ossUploadService: OSSUploadService
|
||||||
|
let accountContextAPI: AccountContextAPI
|
||||||
|
let ordersAPI: OrdersAPI
|
||||||
|
let statisticsAPI: StatisticsAPI
|
||||||
|
let paymentAPI: PaymentAPI
|
||||||
|
let walletAPI: WalletAPI
|
||||||
|
let pushAPI: PushAPI
|
||||||
|
let scenicPermissionAPI: ScenicPermissionAPI
|
||||||
|
let scenicSettlementAPI: ScenicSettlementAPI
|
||||||
|
let messageCenterAPI: MessageCenterAPI
|
||||||
|
let scenicQueueAPI: ScenicQueueAPI
|
||||||
|
let liveAPI: LiveAPI
|
||||||
|
let operatingAreaAPI: OperatingAreaAPI
|
||||||
|
let pilotCertificationAPI: PilotCertificationAPI
|
||||||
|
let taskAPI: TaskAPI
|
||||||
|
let projectAPI: ProjectAPI
|
||||||
|
let scheduleAPI: ScheduleAPI
|
||||||
|
let inviteAPI: InviteAPI
|
||||||
|
let assetsAPI: AssetsAPI
|
||||||
|
let punchPointAPI: PunchPointAPI
|
||||||
|
let locationReportAPI: LocationReportAPI
|
||||||
|
|
||||||
|
/// 基于共享 `APIClient` 创建全部业务 API。
|
||||||
|
init(apiClient: APIClient) {
|
||||||
|
self.apiClient = apiClient
|
||||||
|
authAPI = AuthAPI(client: apiClient)
|
||||||
|
profileAPI = ProfileAPI(client: apiClient)
|
||||||
|
uploadAPI = UploadAPI(client: apiClient)
|
||||||
|
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||||
|
accountContextAPI = AccountContextAPI(client: apiClient)
|
||||||
|
ordersAPI = OrdersAPI(client: apiClient)
|
||||||
|
statisticsAPI = StatisticsAPI(client: apiClient)
|
||||||
|
paymentAPI = PaymentAPI(client: apiClient)
|
||||||
|
walletAPI = WalletAPI(client: apiClient)
|
||||||
|
pushAPI = PushAPI(client: apiClient)
|
||||||
|
scenicPermissionAPI = ScenicPermissionAPI(client: apiClient)
|
||||||
|
scenicSettlementAPI = ScenicSettlementAPI(client: apiClient)
|
||||||
|
messageCenterAPI = MessageCenterAPI(client: apiClient)
|
||||||
|
scenicQueueAPI = ScenicQueueAPI(client: apiClient)
|
||||||
|
liveAPI = LiveAPI(client: apiClient)
|
||||||
|
operatingAreaAPI = OperatingAreaAPI(client: apiClient)
|
||||||
|
pilotCertificationAPI = PilotCertificationAPI(client: apiClient)
|
||||||
|
taskAPI = TaskAPI(client: apiClient)
|
||||||
|
projectAPI = ProjectAPI(client: apiClient)
|
||||||
|
scheduleAPI = ScheduleAPI(client: apiClient)
|
||||||
|
inviteAPI = InviteAPI(client: apiClient)
|
||||||
|
assetsAPI = AssetsAPI(client: apiClient)
|
||||||
|
punchPointAPI = PunchPointAPI(client: apiClient)
|
||||||
|
locationReportAPI = LocationReportAPI(client: apiClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 全局 UI 反馈(Toast、Loading)分组。
|
||||||
|
struct UIFeedbackBundle {
|
||||||
|
let toastCenter: ToastCenter
|
||||||
|
let globalLoading: GlobalLoadingCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 与登录态联动的后台运行时依赖分组。
|
||||||
|
struct AppRuntimeBundle {
|
||||||
|
let scenicQueueRuntime: ScenicQueueRuntime
|
||||||
|
}
|
||||||
@ -9,8 +9,6 @@ import UIKit
|
|||||||
/// ViewController 通用工具,封装 Toast、Loading 和 ViewModel 绑定。
|
/// ViewController 通用工具,封装 Toast、Loading 和 ViewModel 绑定。
|
||||||
@MainActor
|
@MainActor
|
||||||
enum ViewControllerHelpers {
|
enum ViewControllerHelpers {
|
||||||
static var services: AppServices { AppServices.shared }
|
|
||||||
|
|
||||||
/// 绑定 ViewModel 的 onChange 回调并在 deinit 时自动清理。
|
/// 绑定 ViewModel 的 onChange 回调并在 deinit 时自动清理。
|
||||||
static func bind(onChange: (() -> Void)?, owner: AnyObject, handler: @escaping () -> Void) {
|
static func bind(onChange: (() -> Void)?, owner: AnyObject, handler: @escaping () -> Void) {
|
||||||
onChange?()
|
onChange?()
|
||||||
|
|||||||
@ -42,7 +42,8 @@ final class TitleSubtitleTableViewCell: UITableViewCell {
|
|||||||
|
|
||||||
/// 模块列表页基类,封装 UITableView Diffable、下拉刷新和 ViewModel onChange 绑定。
|
/// 模块列表页基类,封装 UITableView Diffable、下拉刷新和 ViewModel onChange 绑定。
|
||||||
class ModuleTableViewController: UIViewController, UITableViewDelegate {
|
class ModuleTableViewController: UIViewController, UITableViewDelegate {
|
||||||
let services = AppServices.shared
|
/// 全局依赖容器快捷访问,等价于 `appServices`。
|
||||||
|
var services: AppServices { appServices }
|
||||||
let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||||
private let refreshControl = UIRefreshControl()
|
private let refreshControl = UIRefreshControl()
|
||||||
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
@ -289,7 +290,8 @@ private extension Array {
|
|||||||
|
|
||||||
/// 模块复杂列表基类,封装 UICollectionView Diffable 与下拉刷新。
|
/// 模块复杂列表基类,封装 UICollectionView Diffable 与下拉刷新。
|
||||||
class ModuleCollectionViewController: UIViewController {
|
class ModuleCollectionViewController: UIViewController {
|
||||||
let services = AppServices.shared
|
/// 全局依赖容器快捷访问,等价于 `appServices`。
|
||||||
|
var services: AppServices { appServices }
|
||||||
let collectionView: UICollectionView
|
let collectionView: UICollectionView
|
||||||
private let refreshControl = UIRefreshControl()
|
private let refreshControl = UIRefreshControl()
|
||||||
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import UIKit
|
|||||||
|
|
||||||
/// 摄影师邀请页。
|
/// 摄影师邀请页。
|
||||||
final class PhotographerInviteViewController: UIViewController {
|
final class PhotographerInviteViewController: UIViewController {
|
||||||
private let services = AppServices.shared
|
|
||||||
private let viewModel = PhotographerInviteViewModel()
|
private let viewModel = PhotographerInviteViewModel()
|
||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
private let contentStack = UIStackView()
|
private let contentStack = UIStackView()
|
||||||
@ -27,7 +26,7 @@ final class PhotographerInviteViewController: UIViewController {
|
|||||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||||
setupUI()
|
setupUI()
|
||||||
viewModel.onChange = { [weak self] in self?.render() }
|
viewModel.onChange = { [weak self] in self?.render() }
|
||||||
Task { await viewModel.reload(api: services.inviteAPI) }
|
Task { await viewModel.reload(api: appServices.inviteAPI) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 初始化UI相关 UI 或状态。
|
/// 初始化UI相关 UI 或状态。
|
||||||
|
|||||||
@ -15,7 +15,6 @@ final class OperatingAreaViewController: UIViewController {
|
|||||||
private lazy var blockReasonView = AppContentUnavailableView(title: "无法展示地图", systemImage: "mappin.slash")
|
private lazy var blockReasonView = AppContentUnavailableView(title: "无法展示地图", systemImage: "mappin.slash")
|
||||||
|
|
||||||
private var dataSource: UITableViewDiffableDataSource<Int, String>!
|
private var dataSource: UITableViewDiffableDataSource<Int, String>!
|
||||||
private var services: AppServices { AppServices.shared }
|
|
||||||
|
|
||||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
@ -112,12 +111,12 @@ final class OperatingAreaViewController: UIViewController {
|
|||||||
private func scopeText() -> String {
|
private func scopeText() -> String {
|
||||||
switch viewModel.mode {
|
switch viewModel.mode {
|
||||||
case .storeAdmin:
|
case .storeAdmin:
|
||||||
return services.accountContext.currentStore?.name ?? "当前店铺"
|
return appServices.accountContext.currentStore?.name ?? "当前店铺"
|
||||||
case .scenicAdmin:
|
case .scenicAdmin:
|
||||||
return services.accountContext.currentScenic?.name ?? "当前景区"
|
return appServices.accountContext.currentScenic?.name ?? "当前景区"
|
||||||
case nil:
|
case nil:
|
||||||
return services.accountContext.currentScenic?.name
|
return appServices.accountContext.currentScenic?.name
|
||||||
?? services.accountContext.currentStore?.name
|
?? appServices.accountContext.currentStore?.name
|
||||||
?? "未选择业务范围"
|
?? "未选择业务范围"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -130,16 +129,16 @@ final class OperatingAreaViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func reload(showLoading: Bool) async {
|
private func reload(showLoading: Bool) async {
|
||||||
if showLoading { services.globalLoading.show() }
|
if showLoading { appServices.globalLoading.show() }
|
||||||
defer { if showLoading { services.globalLoading.hide() } }
|
defer { if showLoading { appServices.globalLoading.hide() } }
|
||||||
|
|
||||||
await viewModel.reload(
|
await viewModel.reload(
|
||||||
api: services.operatingAreaAPI,
|
api: appServices.operatingAreaAPI,
|
||||||
accountContext: services.accountContext,
|
accountContext: appServices.accountContext,
|
||||||
permissionContext: services.permissionContext
|
permissionContext: appServices.permissionContext
|
||||||
)
|
)
|
||||||
if let message = viewModel.errorMessage {
|
if let message = viewModel.errorMessage {
|
||||||
services.toastCenter.show(message)
|
appServices.toastCenter.show(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import UIKit
|
|||||||
|
|
||||||
/// 收款页。
|
/// 收款页。
|
||||||
final class PaymentCollectionViewController: UIViewController {
|
final class PaymentCollectionViewController: UIViewController {
|
||||||
private let services = AppServices.shared
|
|
||||||
private let viewModel = PaymentCollectionViewModel()
|
private let viewModel = PaymentCollectionViewModel()
|
||||||
private let qrImageView = UIImageView()
|
private let qrImageView = UIImageView()
|
||||||
private let statusLabel = UILabel()
|
private let statusLabel = UILabel()
|
||||||
@ -25,7 +24,7 @@ final class PaymentCollectionViewController: UIViewController {
|
|||||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||||
setupUI()
|
setupUI()
|
||||||
viewModel.onChange = { [weak self] in self?.render() }
|
viewModel.onChange = { [weak self] in self?.render() }
|
||||||
Task { await viewModel.loadPayCode(api: services.paymentAPI, scenicId: services.currentScenicId) }
|
Task { await viewModel.loadPayCode(api: appServices.paymentAPI, scenicId: appServices.currentScenicId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 初始化UI相关 UI 或状态。
|
/// 初始化UI相关 UI 或状态。
|
||||||
@ -76,8 +75,8 @@ final class PaymentCollectionViewController: UIViewController {
|
|||||||
@objc private func togglePolling() {
|
@objc private func togglePolling() {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.pollUntilPaymentDetected(
|
await viewModel.pollUntilPaymentDetected(
|
||||||
api: services.paymentAPI,
|
api: appServices.paymentAPI,
|
||||||
scenicId: services.currentScenicId
|
scenicId: appServices.currentScenicId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -141,8 +141,6 @@ final class PunchPointEditorViewController: UIViewController {
|
|||||||
private let mapPicker = PunchPointMapPickerView()
|
private let mapPicker = PunchPointMapPickerView()
|
||||||
private let locationProvider = ForegroundLocationProvider()
|
private let locationProvider = ForegroundLocationProvider()
|
||||||
|
|
||||||
private var services: AppServices { AppServices.shared }
|
|
||||||
|
|
||||||
init(punchPointId: Int?) {
|
init(punchPointId: Int?) {
|
||||||
self.punchPointId = punchPointId
|
self.punchPointId = punchPointId
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
@ -175,7 +173,7 @@ final class PunchPointEditorViewController: UIViewController {
|
|||||||
|
|
||||||
Task {
|
Task {
|
||||||
guard let punchPointId else { return }
|
guard let punchPointId else { return }
|
||||||
if let detail = try? await services.punchPointAPI.punchPointInfo(id: punchPointId) {
|
if let detail = try? await appServices.punchPointAPI.punchPointInfo(id: punchPointId) {
|
||||||
viewModel.apply(detail)
|
viewModel.apply(detail)
|
||||||
syncFieldsFromViewModel()
|
syncFieldsFromViewModel()
|
||||||
}
|
}
|
||||||
@ -252,7 +250,7 @@ final class PunchPointEditorViewController: UIViewController {
|
|||||||
)
|
)
|
||||||
syncFieldsFromViewModel()
|
syncFieldsFromViewModel()
|
||||||
} catch {
|
} catch {
|
||||||
services.toastCenter.show("定位失败:\(error.localizedDescription)")
|
appServices.toastCenter.show("定位失败:\(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -264,15 +262,15 @@ final class PunchPointEditorViewController: UIViewController {
|
|||||||
viewModel.longitudeText = longitudeField.text ?? ""
|
viewModel.longitudeText = longitudeField.text ?? ""
|
||||||
Task {
|
Task {
|
||||||
let success = await viewModel.submit(
|
let success = await viewModel.submit(
|
||||||
scenicId: services.currentScenicId,
|
scenicId: appServices.currentScenicId,
|
||||||
api: services.punchPointAPI,
|
api: appServices.punchPointAPI,
|
||||||
uploadService: services.ossUploadService
|
uploadService: appServices.ossUploadService
|
||||||
)
|
)
|
||||||
if success {
|
if success {
|
||||||
services.toastCenter.show("保存成功")
|
appServices.toastCenter.show("保存成功")
|
||||||
navigationController?.popViewController(animated: true)
|
navigationController?.popViewController(animated: true)
|
||||||
} else if let message = viewModel.errorMessage {
|
} else if let message = viewModel.errorMessage {
|
||||||
services.toastCenter.show(message)
|
appServices.toastCenter.show(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
104
suixinkan_iosTests/AppServicesTests.swift
Normal file
104
suixinkan_iosTests/AppServicesTests.swift
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
//
|
||||||
|
// AppServicesTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan_ios
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// `AppServices` 依赖容器测试,覆盖分组结构与测试专用初始化。
|
||||||
|
final class AppServicesTests: XCTestCase {
|
||||||
|
/// 测试专用初始化应创建独立实例,且各 API 共享同一 `APIClient`。
|
||||||
|
func testTestingInitializerCreatesGroupedDependenciesWithSharedClient() async throws {
|
||||||
|
let session = AppServicesRecordingURLSession(responses: [
|
||||||
|
try TestFixture.data(named: "writeoff_list_success"),
|
||||||
|
try TestFixture.data(named: "user_info_success")
|
||||||
|
])
|
||||||
|
let apiClient = APIClient(session: session)
|
||||||
|
let services = AppServices(apiClient: apiClient)
|
||||||
|
|
||||||
|
XCTAssertNotIdentical(services.network.apiClient as AnyObject, AppServices.shared.network.apiClient as AnyObject)
|
||||||
|
_ = try await services.ordersAPI.writeOffList(scenicId: 1, storeId: nil, page: 1, pageSize: 1)
|
||||||
|
_ = try await services.profileAPI.userInfo()
|
||||||
|
XCTAssertEqual(session.requests.count, 2)
|
||||||
|
XCTAssertNotNil(services.session.appSession)
|
||||||
|
XCTAssertNotNil(services.context.accountContext)
|
||||||
|
XCTAssertNotNil(services.ui.toastCenter)
|
||||||
|
XCTAssertNotNil(services.runtime.scenicQueueRuntime)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扁平属性应转发到对应 bundle,保持现有调用方兼容。
|
||||||
|
func testFlatAccessorsForwardToBundles() {
|
||||||
|
let services = AppServices(apiClient: APIClient(session: AppServicesRecordingURLSession()))
|
||||||
|
|
||||||
|
XCTAssertIdentical(services.appSession as AnyObject, services.session.appSession as AnyObject)
|
||||||
|
XCTAssertIdentical(services.ordersAPI as AnyObject, services.network.ordersAPI as AnyObject)
|
||||||
|
XCTAssertIdentical(services.toastCenter as AnyObject, services.ui.toastCenter as AnyObject)
|
||||||
|
XCTAssertIdentical(services.scenicQueueRuntime as AnyObject, services.runtime.scenicQueueRuntime as AnyObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `APIClient` 应通过 `AppSession.token` 提供鉴权 token。
|
||||||
|
func testAuthTokenProviderUsesAppSessionToken() async throws {
|
||||||
|
let session = AppServicesRecordingURLSession(data: try TestFixture.data(named: "user_info_success"))
|
||||||
|
let apiClient = APIClient(session: session)
|
||||||
|
let services = AppServices(apiClient: apiClient)
|
||||||
|
services.appSession.markLoggedIn(token: "test-token")
|
||||||
|
|
||||||
|
_ = try await services.profileAPI.userInfo()
|
||||||
|
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.value(forHTTPHeaderField: "token"), "test-token")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 便捷属性应基于 `AccountContext` 派生当前用户与景区信息。
|
||||||
|
func testConveniencePropertiesReflectAccountContext() {
|
||||||
|
let services = AppServices(apiClient: APIClient(session: AppServicesRecordingURLSession()))
|
||||||
|
services.accountContext.applyLogin(
|
||||||
|
profile: AccountProfile(
|
||||||
|
userId: "42",
|
||||||
|
displayName: "测试用户",
|
||||||
|
phone: "18600000000",
|
||||||
|
avatarURL: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
services.accountContext.currentScenic = BusinessScope(id: 7, name: "测试景区", kind: .scenic)
|
||||||
|
|
||||||
|
XCTAssertEqual(services.userId, "42")
|
||||||
|
XCTAssertEqual(services.staffId, 42)
|
||||||
|
XCTAssertEqual(services.currentScenicId, 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录请求的 URLSession 测试替身。
|
||||||
|
private final class AppServicesRecordingURLSession: URLSessionProtocol {
|
||||||
|
private(set) var requests: [URLRequest] = []
|
||||||
|
private let responses: [Data]
|
||||||
|
private var responseIndex = 0
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
|
init(data: Data = Data()) {
|
||||||
|
responses = [data]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按顺序返回多段响应数据。
|
||||||
|
init(responses: [Data]) {
|
||||||
|
self.responses = responses
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录请求并返回固定响应。
|
||||||
|
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||||
|
requests.append(request)
|
||||||
|
let data = responses[min(responseIndex, responses.count - 1)]
|
||||||
|
responseIndex += 1
|
||||||
|
let response = HTTPURLResponse(
|
||||||
|
url: request.url ?? URL(string: "https://example.com")!,
|
||||||
|
statusCode: 200,
|
||||||
|
httpVersion: nil,
|
||||||
|
headerFields: nil
|
||||||
|
)!
|
||||||
|
return (data, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user