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>
6.7 KiB
App 模块业务逻辑
模块职责
App 模块负责应用入口、根视图切换、全局状态注入、主导航状态、登录态恢复和全局 Toast / Loading。
该模块不直接处理具体页面业务,主要承担 App 级基础设施编排:
- 根据登录状态展示登录页、恢复态或主 Tab。
- 创建并注入全局状态和共享服务。
- 管理每个 Tab 独立的导航栈与
AppRouter路径。 - 协调登录成功、退出登录、冷启动恢复和本地缓存同步。
核心对象
AppDelegate/SceneDelegate:UIKit 应用入口,挂载RootViewController。AppServices:全局依赖容器(Composition Root),等价于参考工程 SwiftUIRootView中的 Environment 注入;内部按 Session / Context / Network / UI / Runtime 分组,对外仍保留扁平属性访问。RootViewController:根据AppSession.phase切换登录页、恢复占位或MainTabBarController,并挂载全局 Toast / Loading。AppSession:保存认证阶段和正式 token,只负责登录态,不承载业务资料。AccountContext:保存当前账号资料、景区作用域和门店作用域。PermissionContext:保存角色权限、当前角色和扁平化权限 URI。ScenicSpotContext:保存当前景区下的景点/打卡点列表和加载状态。AppRouter:保存当前 Tab 和每个 Tab 自己的导航路径。ToastCenter:管理当前全局 Toast 文案和自动隐藏任务。AuthSessionCoordinator:统一处理登录完成、退出登录、偏好读取和账号快照刷新。SessionBootstrapper:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。AccountContextLoader:统一同步用户资料、角色权限、景区和门店。
AppServices 依赖约定
AppServices.shared 是唯一 Composition Root,集中创建 APIClient、各业务 API 与全局 Context,并绑定 token provider。
何时通过 init(services:) 注入
仅在 Composition 边界传参,便于 UI Test 或单元测试替换容器:
RootViewController(默认AppServices.shared)MainTabBarController→TabNavigationControllerLoginViewControllerAppRouteViewControllerFactory.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。
启动流程
AppDelegate创建RootViewController(services: .shared)。AppServices初始化共享依赖(NetworkBundle内共享同一APIClient)。APIClient绑定AppSession.token作为默认 token provider。SessionBootstrapper.restore尝试从 Keychain 读取正式 token。- 无 token 时保持
loggedOut,展示LoginViewController。 - 有 token 时进入
restoring,先恢复本地账号快照。 AccountContextLoader并行请求用户资料和角色权限,再补全景区与门店。- 校验成功后进入
loggedIn,展示MainTabBarController。 ScenicSpotContext按当前景区懒加载景点/打卡点。- 明确 token 失效时清空 token 和账号快照,回到登录页。
- 普通网络失败时保留本地登录态,使用账号快照进入主界面。
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。
- 清空账号快照。
- 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。
AppSession切换为loggedOut。- 保留上次手机号、协议状态等非敏感偏好。
Toast
全局 Toast 由 RootViewController 在 viewDidAppear 时挂载到 window,业务页面通过 showToast(...) 或 appServices.toastCenter.show(...) 发出提示命令。
Toast 展示为顶部全宽横幅,背景使用不透明主色并延伸到屏幕顶部、左边和右边;文案居中展示,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。
导航规则
主界面使用 MainTabBarController,每个 Tab 内部由独立的 TabNavigationController(UINavigationController)包裹。AppRouter 为每个 AppTab 持有独立 RouterPath,切换 Tab 不会丢失该 Tab 的内部导航路径。
Tab 根页面显示底部 TabBar。通过 AppRoute push 到子页面时,会根据 AppRoute.hidesTabBarWhenPushed 统一隐藏 TabBar。
跨 Tab 跳转通过 AppRouter.select / navigateHome / navigateProfile 处理;Tab 内 push 通过 UIKitAppNavigation 或当前 Nav 栈完成。新增页面时优先扩展 AppRoute / HomeRoute,在 AppRouteViewControllerFactory 注册对应 ViewController。