友盟 SDK 改为隐私同意后再初始化;宣传页标签新增滑块位移动画,超过 5 个时选中项自动滚动居中。 Co-authored-by: Cursor <cursoragent@cursor.com>
10 KiB
App 模块业务逻辑
模块职责
App 模块负责应用入口、根视图切换、全局状态注入、主导航状态、登录态恢复和全局 Toast。
该模块不直接处理具体页面业务,主要承担 App 级基础设施编排:
- 根据登录状态展示登录页、恢复态或主 Tab。
- 创建并注入全局状态和共享服务。
- 管理每个 Tab 独立的
NavigationStack路径。 - 协调登录成功、退出登录、冷启动恢复和本地缓存同步。
核心对象
suixinkanApp:SwiftUI 应用入口,挂载RootView。RootView:创建AppSession、AccountContext、PermissionContext、ScenicSpotContext、AppRouter、ToastCenter、APIClient和业务 API,并注入 SwiftUI Environment。AppSession:保存认证阶段和正式 token,只负责登录态,不承载业务资料。AccountContext:保存当前账号资料、账号类型、景区作用域和门店作用域,并提供账号级缓存前缀。PermissionContext:保存角色权限、当前角色和扁平化权限 URI。ScenicSpotContext:保存当前景区下的景点/打卡点列表和加载状态。AppRouter:保存当前 Tab 和每个 Tab 自己的导航路径。ToastCenter:管理当前全局 Toast 文案和自动隐藏任务。AuthSessionCoordinator:统一处理登录完成、退出登录、偏好读取和账号快照刷新。SessionBootstrapper:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。AccountContextLoader:统一同步用户资料、角色权限、景区和门店。AppServiceEnvironment.swift:为无 UI 状态的服务定义 SwiftUIEnvironmentKey,供 View 通过@Environment(\.xxxAPI)读取。UMengSDKBootstrap:友盟统计与 U-APM 的隐私合规初始化入口,只在用户同意隐私政策且 AppKey 非空后初始化。
依赖注入:为何 API 走 Environment 而非单例
SwiftUI 的 Environment 在本项目中是依赖注入(DI)通道,不等于「UI 状态容器」。注入对象分两类:
| 类型 | 机制 | 代表 |
|---|---|---|
| 会驱动 UI 刷新的状态 | environmentObject + ObservableObject |
AppSession、ToastCenter |
| 无 UI 状态的服务 | 自定义 EnvironmentKey |
OrdersAPI、ProfileAPI |
数据流
RootView.init()创建一个APIClient,再传给所有*API(client:)。RootView.body通过.environment(\.ordersAPI, ordersAPI)等下发到整棵视图树。task中apiClient.bindAuthTokenProvider { appSession.token }绑定 token。- View 用
@Environment(\.ordersAPI)读取,调用 ViewModel 时作为参数传入,例如await viewModel.reload(api: ordersAPI, ...)。 - ViewModel 不持有 API,方法签名接收
XxxServing协议,单元测试直接传 Mock。
网络层细节见 Networking 模块。
为何不用「每个 API 一个单例」
核心原因:需要共享的是 APIClient,不是 API 类本身。
AuthAPI、OrdersAPI 等只是 APIClient 上的薄封装,本身无状态。全 App 必须只有一个 APIClient,才能保证 token provider 只绑定一次、登录后 token 对所有请求一致、tokenOverride(如 set-user)走同一客户端。若每个 API 各自 static let shared = XxxAPI(client: APIClient()),容易在不经意间创建多个 client,导致 token 不同步。
RootView 作为组合根集中创建并装配依赖,例如:
OSSUploadService(configService: uploadAPI)依赖UploadAPISessionBootstrapper、AuthSessionCoordinator在 init 时注入具体 APIPushNotificationManager.shared.configure(api: pushAPI, ...)需要与 RootView 相同的pushAPI实例
Environment 的其他收益
- Preview / 局部替换:可在子树
.environment(\.ordersAPI, mockAPI)替换,不影响全局;AppServiceEnvironment的defaultValue供 Preview 兜底。 - 测试:ViewModel 测试靠
XxxServing协议 + 方法参数注入 Mock,不依赖 Environment;Environment 主要给 View 层省掉层层传参。 - 历史选择:iOS 17 迁移到 iOS 16 时,UI 状态统一
environmentObject,服务统一自定义EnvironmentKey(见根目录iOS16兼容迁移记录.md)。
单例是否可行
可以,但应有纪律。项目里已有单例先例:PushNotificationManager.shared、PaymentVoiceSpeaker.shared、CloudTransferStore.shared——特点是跨模块、与 UI 树无关、需在 AppDelegate 或后台回调中访问。
若全面改单例,推荐收敛为一个 AppServices 容器共享同一 client,而不是 20 个 OrdersAPI.shared。运行时行为可与当前方案等价,差别在可维护性和团队习惯。
当前方案的代价
AppServiceEnvironment.swift约 25 组 Key,RootView大量@State/.environment行,样板代码多。Environment命名易误导为 UI 绑定,实际是 service locator。- Key 的
defaultValue会new独立APIClient(),仅未从RootView注入时使用;Preview 需注意是否拿到真实实例。 - View 用
@Environment,ViewModel 用方法参数,View 充当桥接层。
若将来觉得 Key 过多,可收敛为一个 AppServices 对象注入一次,属于简化写法,不是架构对错。
启动流程
完整启动接口清单、数据依赖与 Splash 优化方案见 启动流程分析与优化方案。
suixinkanApp处理 UI Test 冷启动状态清理并创建RootView。RootView初始化共享依赖,并把它们注入 Environment。- iOS 系统 Launch Screen 展示品牌页(白底、Logo、文案)。
RootView挂载后在 bootstrap 完成前以SplashView覆盖根视图,避免空白或登录页闪屏。APIClient绑定AppSession.token作为默认 token provider。- 如果本地已记录用户同意隐私政策,则初始化高德与友盟 SDK;否则不触发第三方 SDK 初始化。
SessionBootstrapper.restore尝试从 UserDefaults 读取正式 token。- 无 token 时保持
loggedOut,展示LoginView。 - 有 token 时进入
restoring,先恢复本地账号快照。 - 阻塞请求
rolePermissions成功后立即markLoggedIn并展示MainTabsView。 userInfo、scenicListAll、storeAll在后台补充刷新,不阻塞首屏。ScenicSpotContext按当前景区懒加载景点/打卡点。- 明确 token 失效时清空 token 和账号快照,回到登录页。
- 普通网络失败时保留本地登录态,使用账号快照进入主界面。
- 冷启动 bootstrap 不使用 Lottie 全局 Loading;
.restoring期间展示SplashView。 LoginView首屏出现后调用/api/app/config加载远程配置(如enable_register),失败静默。
初始化分层
| 阶段 | 职责 | 主要对象 |
|---|---|---|
| App 入口 | UI Test 状态清理 | suixinkanApp、AppUITestLaunchState |
| 冷启动恢复 | Launch Screen + Splash 覆盖 bootstrap | SplashView、SessionBootstrapper、AccountContextLoader |
| 首屏出现后 | 页面级远程配置 | LoginView、AppConfigAPI |
| 登录后 | 推送、景点列表、排队 WebSocket | PushNotificationManager、ScenicSpotContext |
第三方 SDK 隐私门禁
高德与友盟 SDK 均由 RootView 在隐私政策已同意后初始化,不再由 suixinkanApp.init() 直接触发。隐私同意状态保存在 AppPreferencesStore:
- 首次冷启动未同意时,不初始化高德、友盟统计或 U-APM。
- 登录页校验确认已同意后,
AuthSessionCoordinator.savePrivacyAgreementAccepted(true)会先持久化状态,再触发第三方 SDK 初始化。 - 后续冷启动读取到已同意状态时,会自动初始化第三方 SDK。
- 退出登录只清空 token 和账号快照,保留隐私同意状态。
友盟配置集中在 UMengConfig。appKey 为空时 UMengSDKBootstrap 不会调用友盟 SDK,便于在未配置真实 AppKey 前保持构建可用。U-APM 首版采用最小合规采集范围:开启崩溃、卡顿和启动分析,关闭网络分析、内存、OOM 与日志回捞。
UI Test 启动约定
AppUITestLaunchState在收到-suixinkan-ui-tests时跳过推送注册和排队 WebSocket,避免系统弹窗干扰自动化。-suixinkan-ui-tests-reset-state用于冷启动清理 UserDefaults 中的 token、账号快照和偏好。-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 统一处理:
- 清空本地 token。
- 清空账号快照。
- 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。
AppSession切换为loggedOut。- 保留上次手机号、协议状态等非敏感偏好。
Toast
全局 Toast 由 RootView 挂载在页面最上层,业务页面只调用 toastCenter.show(...) 发出提示命令。
Toast 展示为屏幕中央的黑色半透明圆角卡片(Color.black.opacity(0.78)),左侧带警告图标,文案 16pt 展示,以透明度淡入淡出,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。
导航规则
主界面使用 TabView,每个 Tab 内部由单独的 NavigationStack 包裹。AppRouter 为每个 AppTab 持有独立 RouterPath,切换 Tab 不会丢失该 Tab 的内部导航路径。
Tab 根页面显示底部 TabBar。通过 AppRoute push 到子页面时,MainTabsView 会根据 AppRoute.hidesTabBarWhenPushed 统一隐藏 TabBar,避免每个业务页面重复处理。
当前路由枚举 AppRoute 仍以占位详情页为主,后续新增真实页面时应优先扩展 AppRoute,再由对应 Tab 的 NavigationStack 处理跳转。