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:
2026-06-26 15:24:25 +08:00
parent d99a5b1bf8
commit 24a7339b68
10 changed files with 385 additions and 114 deletions

View File

@ -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

View File

@ -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) network = NetworkBundle(apiClient: apiClient)
ordersAPI = OrdersAPI(client: client) ui = UIFeedbackBundle(
statisticsAPI = StatisticsAPI(client: client) toastCenter: ToastCenter(),
paymentAPI = PaymentAPI(client: client) globalLoading: GlobalLoadingCenter()
walletAPI = WalletAPI(client: client) )
pushAPI = PushAPI(client: client) runtime = AppRuntimeBundle(scenicQueueRuntime: ScenicQueueRuntime())
scenicPermissionAPI = ScenicPermissionAPI(client: client) session = SessionBundle(
scenicSettlementAPI = ScenicSettlementAPI(client: client) appSession: AppSession(),
messageCenterAPI = MessageCenterAPI(client: client) authSessionCoordinator: AuthSessionCoordinator(
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, tokenStore: tokenStore,
snapshotStore: snapshotStore, snapshotStore: snapshotStore,
preferencesStore: AppPreferencesStore() preferencesStore: preferencesStore
) ),
sessionBootstrapper = SessionBootstrapper( sessionBootstrapper: SessionBootstrapper(
tokenStore: tokenStore, tokenStore: tokenStore,
snapshotStore: snapshotStore snapshotStore: snapshotStore
) )
apiClient.bindAuthTokenProvider { [unowned self] in )
appSession.token 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
}
}
} }

View 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 ToastLoading
struct UIFeedbackBundle {
let toastCenter: ToastCenter
let globalLoading: GlobalLoadingCenter
}
@MainActor
///
struct AppRuntimeBundle {
let scenicQueueRuntime: ScenicQueueRuntime
}

View File

@ -9,8 +9,6 @@ import UIKit
/// ViewController ToastLoading ViewModel /// ViewController ToastLoading 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?()

View File

@ -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)

View File

@ -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

View File

@ -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)
} }
} }
} }

View File

@ -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
) )
} }
} }

View File

@ -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)
} }
} }
} }

View 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)
}
}