# App 模块业务逻辑 ## 模块职责 App 模块负责应用入口、根视图切换、全局状态注入、主导航状态、登录态恢复和全局 Toast / Loading。 该模块不直接处理具体页面业务,主要承担 App 级基础设施编排: - 根据登录状态展示登录页、恢复态或主 Tab。 - 创建并注入全局状态和共享服务。 - 管理每个 Tab 独立的 UIKit 导航栈,并通过 `AppNavigator` 统一执行全局跳转。 - 协调登录成功、退出登录、冷启动恢复和本地缓存同步。 ## 核心对象 - `AppDelegate` / `SceneDelegate`:UIKit 应用入口;`SceneDelegate` 挂载 `RootViewController` 并持有其强引用。 - `AppServices`:全局依赖容器(Composition Root),等价于参考工程 SwiftUI `RootView` 中的 Environment 注入;自身只持有 Session / Context / Network / UI / Runtime 分组和导航器。 - `AppServicesAssembly`:负责创建生产或测试依赖图,避免装配细节堆在容器本体中。 - `AppFeatureServices`:按业务域提供模块级 Facade,避免页面散取全局 API、Context、Toast、Loading 和运行时依赖。 - `AppSessionLifecycleCoordinator`:处理冷启动恢复、登录后景点刷新、登出清理和推送授权;`RootViewController` 只负责根控制器切换。 - `RootViewController`:监听 `AppSession.phase`,未登录时在自身上展示登录/恢复页,登录后将 `MainTabBarController` 设为 window 根控制器,并挂载全局 Toast / Loading。 - `AppSession`:保存认证阶段和正式 token,只负责登录态,不承载业务资料。 - `AccountContext`:保存当前账号资料、景区作用域和门店作用域。 - `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。 - `ScenicSpotContext`:保存当前景区下的景点/打卡点列表和加载状态。 - `AppNavigator`:应用级导航器,持有主 Tab 宿主弱引用,并以 UIKit 导航栈作为唯一导航事实源。 - `ToastCenter`:管理当前全局 Toast 文案和自动隐藏任务。 - `AuthSessionCoordinator`:统一处理登录完成、退出登录、偏好读取和账号快照刷新。 - `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。 - `AccountContextLoader`:统一同步用户资料、角色权限、景区和门店。 ## AppServices 依赖约定 `AppServices.shared` 是唯一 Composition Root,通过 `AppServicesAssembly` 集中创建 `APIClient`、各业务 API 与全局 Context,并绑定 token provider。 `AppServices` 本体保持轻量:只保存已装配的依赖分组、绑定跨分组关系和暴露 App 生命周期入口。兼容旧调用的扁平属性统一放在 `AppServices+Accessors.swift`,新增复杂业务不得继续塞进容器本体。 `NetworkBundle` 内部按业务域拆分为 Account / Commerce / Operation / Content 四组,所有 API 仍共享同一 `APIClient`。旧的 `services.ordersAPI`、`services.profileAPI` 等扁平访问器继续保留,仅作为兼容层。 新增页面优先通过模块 Facade 获取依赖: - `ordersFeatureServices`:订单 API、景区/门店/角色、订单导航、Toast / Loading。 - `profileFeatureServices`:登录、资料、账号上下文和退出登录清理。 - `homeFeatureServices`:首页菜单、账号权限上下文和首页导航。 - `assetsFeatureServices`:资产 API、OSS 上传、当前景区和云传记录仓库。 - `queueFeatureServices`:排队 API、用户/景区/景点上下文和排队运行时。 ### 何时通过 `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:)`。 导航辅助(如 `HomeMenuRouting`)在全局 push 场景通过 `AppServices.shared.appNavigator` 执行跳转。 ### ViewModel 层边界 ViewModel **不持有** `AppServices`。View 从容器取出具体依赖,在 `reload` / `load` / `submit` 等方法调用时传入所需 API 与 Context。 ### 适合独立单例的类型 以下类型独立于 `AppServices`,因其生命周期或系统入口特殊: - `PushNotificationManager.shared`:`AppDelegate` 回调桥 - `CloudTransferStore.shared`:跨页面云传任务状态 - `ScenicQueueRuntime.shared`:跨页面排队轮询、后台任务和语音播报运行时 - 第三方 SDK(如 `AMapServices`) `AppSession`、`AccountContext`、各 `*API`、`AppNavigator`、持久化 Store 等**不应**拆成多个 `.shared`,须由 `AppServices` 统一创建,并在登出时由 `AppSessionLifecycleCoordinator` 统一 reset。 ## 启动流程 1. `AppDelegate` 创建 `RootViewController(services: .shared)`。 2. `AppServices` 初始化共享依赖(`NetworkBundle` 子分组内共享同一 `APIClient`)。 3. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。 4. `AppSessionLifecycleCoordinator` 调用 `SessionBootstrapper.restore` 尝试从 Keychain 读取正式 token。 5. 无 token 时保持 `loggedOut`,展示 `LoginViewController`。 6. 有 token 时进入 `restoring`,先恢复本地账号快照。 7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。 8. 校验成功后进入 `loggedIn`,将 `MainTabBarController` 设为 window 根控制器。 9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。 10. 明确 token 失效时清空 token 和账号快照,回到登录页。 11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。 ## UI Test 启动约定 - `AppUITestLaunchState` 在收到 `-suixinkan-ui-tests` 时跳过推送注册和排队 WebSocket,避免系统弹窗干扰自动化。 - `-suixinkan-ui-tests-reset-state` 用于冷启动清理 Keychain 与 UserDefaults。 - `-suixinkan-ui-tests-open-menu <菜单标题>` 登录后直达首页调试目录中的目标页。 - `-suixinkan-ui-tests-open-profile <路由名>` 登录后直达个人中心二级页(如 `settings`、`realNameAuth`)。 - `AppUITestRouteDriver` 仅在 DEBUG 构建下解析上述直达参数,供 XCUITest 逐页验证。 - 详细运行方式见 `suixinkanUITests/README.md`。 ## 登录和退出 登录完成由 `AuthSessionCoordinator.completeLogin` 统一处理: - 正式 token 写入 `SessionTokenStore`。 - 上次手机号和协议状态写入 `AppPreferencesStore`。 - 账号资料、景区列表、门店列表写入 `AccountContext`。 - 角色权限和当前角色写入 `PermissionContext`。 - 非敏感账号快照写入 `AccountSnapshotStore`。 - `AppSession` 切换为 `loggedIn`。 退出登录由 `AuthSessionCoordinator.logout` 统一处理: - 清空 Keychain token。 - 清空账号快照。 - 重置账号上下文、权限上下文、景点上下文、全部 UIKit 导航栈和 Toast。 - `AppSession` 切换为 `loggedOut`。 - 保留上次手机号、协议状态等非敏感偏好。 ## Toast 全局 Toast 由 `RootViewController` 在切换 window 根控制器后挂载到 window,业务页面通过 `showToast(...)` 或 `appServices.toastCenter.show(...)` 发出提示命令。 Toast 展示为顶部全宽横幅,背景使用不透明主色并延伸到屏幕顶部、左边和右边;文案居中展示,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。 ## 导航规则 主界面使用 `MainTabBarController` 作为 window 根控制器(登录后),每个 Tab 内部由独立的 `TabNavigationController`(`UINavigationController`)包裹。`UINavigationController.viewControllers` 是页面路径的唯一事实源,普通 Tab 切换保留该 Tab 的原有栈。 Tab 根页面显示底部 TabBar。通过 `AppRoute` push 到子页面时,会根据 `AppRoute.hidesTabBarWhenPushed` 统一隐藏 TabBar。 首页根页面使用自定义顶部栏,因此 `TabNavigationController` 会在展示 `HomeViewController` 时隐藏系统导航栏;push 到其他业务子页面或从子页面返回时,由导航栈统一按栈顶页面恢复系统导航栏显隐,避免首页隐藏状态影响子页面标题栏和返回按钮。 跨 Tab、推送通知、UI Test 直达和全局扫码都通过 `AppNavigator` 处理。`AppNavigationPolicy.currentStack` 沿当前栈 push,`.tabRoot` 切到目标 Tab 并先回到 root,`.preserveTab` 只切换 Tab 并保留原栈。新增页面时优先扩展 `AppRoute` / `HomeRoute` / `ProfileRoute` / `OrdersRoute`,并在 `AppRouteViewControllerFactory` 注册对应 ViewController。