Files
suixinkan_ios_uikit/suixinkan_ios/App/App.md
汉秋 0d8f97417b Refactor AppServices into assembly, feature facades, and lifecycle coordinator.
Extract dependency wiring and session lifecycle from the container and RootViewController, split NetworkBundle by domain, and add module-level feature services while keeping flat accessors for compatibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 09:32:44 +08:00

9.0 KiB
Raw Blame History

App 模块业务逻辑

模块职责

App 模块负责应用入口、根视图切换、全局状态注入、主导航状态、登录态恢复和全局 Toast / Loading。

该模块不直接处理具体页面业务,主要承担 App 级基础设施编排:

  • 根据登录状态展示登录页、恢复态或主 Tab。
  • 创建并注入全局状态和共享服务。
  • 管理每个 Tab 独立的 UIKit 导航栈,并通过 AppNavigator 统一执行全局跳转。
  • 协调登录成功、退出登录、冷启动恢复和本地缓存同步。

核心对象

  • AppDelegate / SceneDelegateUIKit 应用入口;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.ordersAPIservices.profileAPI 等扁平访问器继续保留,仅作为兼容层。

新增页面优先通过模块 Facade 获取依赖:

  • ordersFeatureServices:订单 API、景区/门店/角色、订单导航、Toast / Loading。
  • profileFeatureServices:登录、资料、账号上下文和退出登录清理。
  • homeFeatureServices:首页菜单、账号权限上下文和首页导航。
  • assetsFeatureServices:资产 API、OSS 上传、当前景区和云传记录仓库。
  • queueFeatureServices:排队 API、用户/景区/景点上下文和排队运行时。

何时通过 init(services:) 注入

仅在 Composition 边界传参,便于 UI Test 或单元测试替换容器:

  • RootViewController(默认 AppServices.shared
  • MainTabBarControllerTabNavigationController
  • 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.sharedAppDelegate 回调桥
  • CloudTransferStore.shared:跨页面云传任务状态
  • ScenicQueueRuntime.shared:跨页面排队轮询、后台任务和语音播报运行时
  • 第三方 SDKAMapServices

AppSessionAccountContext、各 *APIAppNavigator、持久化 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 <路由名> 登录后直达个人中心二级页(如 settingsrealNameAuth)。
  • 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 内部由独立的 TabNavigationControllerUINavigationController)包裹。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。