# App 启动流程分析与优化方案 本文档梳理 iOS App 冷启动期间的接口调用、数据依赖,并评估去掉启动 Splash 等待层、直接进入登录页或首页的可行性。 相关模块文档:[App 模块](../suixinkan/App/App.md)、[Splash 模块](../suixinkan/Features/Splash/Splash.md) --- ## 1. 概述 App 启动链路为: **`suixinkanApp` → `RootView` → 阻塞 `rolePermissions` + 后台补充 bootstrap → 按 `AppSession.phase` 展示 `LoginView` 或 `MainTabsView`** 用户感知的「启动 Loading」并非 Lottie 全局 Loading,而是: 1. **系统 Launch Screen**(品牌页,iOS 原生) 2. **`.restoring` 短暂空白**(仅等待 `rolePermissions`,完成后立即进主界面) 3. **后台补充刷新**(`userInfo`、`scenicListAll`、`storeAll`,不阻塞首屏) 冷启动期间 **不使用** `GlobalLoadingCenter` / Lottie 动画。 --- ## 2. 启动时序 ```mermaid sequenceDiagram participant Launch as SystemLaunchScreen participant App as suixinkanApp participant Root as RootView participant Splash as SplashView participant Coord as SplashCoordinator participant Boot as SessionBootstrapper participant Loader as AccountContextLoader participant UI as LoginView_or_MainTabsView Launch->>App: 系统 Launch Screen App->>Root: 挂载 RootView Root->>Splash: ZStack 覆盖层 Root->>Coord: start(bootstrap) par BootstrapOnly Coord->>Boot: restore() alt 无 token Boot->>Boot: 读 UserDefaults,logout else 有 token Boot->>Boot: 恢复 AccountSnapshotStore Boot->>Loader: refresh 4 个接口 Loader->>Loader: userInfo + rolePermissions 并行 Loader->>Loader: scenicListAll + storeAll 串行 end end Coord->>Splash: isFinished = true,淡出 Root->>UI: 按 phase 展示登录页或主 Tab opt loggedIn Root->>Root: 推送注册、景点列表、排队等异步任务 end ``` ### 核心代码路径 | 环节 | 文件 | 说明 | |------|------|------| | 入口 | `suixinkan/App/suixinkanApp.swift` | `@main`,挂载 `RootView` | | 编排 | `suixinkan/App/RootView.swift` | `.task` 绑定 token、启动 Splash、bootstrap | | 时序 | `suixinkan/App/SplashCoordinator.swift` | bootstrap 完成后结束 Splash(默认无最短展示时长) | | 恢复 | `suixinkan/App/State/SessionBootstrapper.swift` | 读 token、恢复快照、校验登录态 | | 接口批量 | `suixinkan/App/State/AccountContextLoader.swift` | 用户资料 + 权限 + 景区 + 门店 | | 路由 | `RootView.rootContent` | `switch appSession.phase` 切换页面 | --- ## 3. 启动期间接口清单 ### 3.1 阶段 A — Splash 阻塞期 #### 未登录(无本地 token) | 操作 | 类型 | 说明 | |------|------|------| | 读 `SessionTokenStore` | 本地 | UserDefaults 读 token | | 读 `AppPreferencesStore` | 本地 | 登录页偏好(Splash 期间不读,LoginView 出现时读) | **零网络请求。** Splash 仅覆盖本地 token 读取,bootstrap 完成即进入登录页。 #### 已登录(有本地 token) | # | 方法 | HTTP | 路径 | 调用方 | 获取数据 | 失败策略 | |---|------|------|------|--------|----------|----------| | 1 | `userInfo()` | GET | `/api/yf-handset-app/userinfo` | `AccountContextLoader` | 头像、昵称、真实姓名、手机号 | 鉴权失败 → 登出;其他错误 → 用快照进入主界面 | | 2 | `rolePermissions()` | GET | `/api/yf-handset-app/role-permission` | `AccountContextLoader` | 角色列表、权限 URI 树、角色关联景区 | 同上(与 userInfo 并行) | | 3 | `scenicListAll()` | GET | `/api/yf-handset-app/photog/scenic/list-all` | `AccountContextLoader` | 全部可访问景区 | 失败时从 rolePermissions 中 dedupe 兜底 | | 4 | `storeAll()` | GET | `/api/app/store/all` | `AccountContextLoader` | 门店列表 | `try?` 失败不阻断,返回空列表 | **并行策略:** `userInfo` + `rolePermissions` 并行 → 完成后串行 `scenicListAll` → `storeAll`。 **阻塞原因:** `SplashCoordinator.start` 必须等 bootstrap 完成才设置 `isFinished = true`;有 token 时 `AppSession.phase` 停留在 `.restoring`,主界面不可见。 ### 3.2 阶段 B — Splash 结束后(不阻塞 Splash) | 方法 | HTTP | 路径 | 触发时机 | 获取数据 | 阻塞 UI? | |------|------|------|----------|----------|-----------| | `fetchAppConfig()` | GET | `/api/app/config` | `LoginView.task`(未登录) | `enable_register` 等远程配置 | 否,失败静默 | | `scenicSpotListAll(scenicId:)` | GET | `/api/yf-handset-app/photog/scenic-spot/list-all?scenic_id=` | `RootView.task(id: scenicSpotTaskID)` | 当前景区打卡点列表 | 否 | | `registerJPushId(_:)` | POST | `/api/app/user/register-jpush-id?jpush_reg_id=` | 登录后 Push 授权完成 | 注册推送 token | 否 | | `writeOffList(...)` | GET | `/api/yf-handset-app/photog/order/order-verification-list` | `MainTabsView.task` | 订单 Tab 待核销角标 | 否 | | `scenicQueueStats` / `scenicQueueHome` | GET | `/api/app/scenic-queue/*` | `ScenicQueueRuntime`(有排队配置时) | 排队统计与列表 | 否 | **Profile 页:** 切换到「我的」Tab 时 `ProfileView.task` 会再次调用 `userInfo()`,不属于冷启动首屏链路。 **Home 页:** `HomeView.task` 仅调用 `rebuildMenusFromCurrentContext()`,**不发起网络**。 ### 3.3 启动链路中不存在的接口 | 类型 | 说明 | |------|------| | Token 刷新 | 代码库中无 refresh/renew 接口 | | 版本强更 | 无独立版本检查接口;App 版本通过 `APIClient` 请求头 `X-APP-VERSION` 携带 | | AppDelegate 网络 | `AppDelegate` 仅处理 APNs 回调,启动时不调 API | | WebSocket | `GET /api/app/socket-token` 仅在排队管理页面使用,冷启动不连接 | --- ## 4. 本地缓存说明 ### 4.1 SessionTokenStore - **存储:** UserDefaults - **键:** `suixinkan.session.token.{bundleId}.session.token` - **内容:** 正式登录 token 字符串 - **用途:** 判断是否有登录态;作为 `APIClient` 默认 Authorization ### 4.2 AccountSnapshotStore - **存储:** UserDefaults - **键:** `suixinkan.account.snapshot.v1` - **内容(`AccountSnapshot`):** | 字段 | 说明 | |------|------| | `profile` | 用户资料(昵称、手机号、头像 URL 等) | | `accountType` | 账号类型 | | `businessUserId` | 业务用户 ID | | `currentRoleCode` | 当前角色 code | | `currentRoleId` | 旧版 legacy 角色 ID(兼容迁移) | | `scenicScopes` | 景区作用域列表 | | `storeScopes` | 门店作用域列表 | | `currentScenicId` | 当前选中景区 ID | | `currentStoreId` | 当前选中门店 ID | **注意:`AccountSnapshot` 不保存 `rolePermissions`(完整权限树)。** 冷启动时 `SessionBootstrapper.restoreCachedSnapshot` 只恢复 `AccountContext`(profile + scopes),**不会**填充 `PermissionContext.rolePermissions`。权限数据必须等 `AccountContextLoader.refresh` 成功后通过 `replaceRolePermissions` 写入。 ### 4.3 AppPreferencesStore - **内容:** 上次登录手机号、隐私协议勾选状态 - **用途:** 登录页表单预填,与 Splash bootstrap 无关 --- ## 5. 各页面首屏数据依赖 ### 5.1 LoginView(未登录) | 数据 | 来源 | 是否阻塞首屏 | |------|------|-------------| | 上次手机号、协议勾选 | `AppPreferencesStore` 本地 | 否 | | 是否显示注册入口 | `GET /api/app/config` | 否(Splash 后异步,失败默认隐藏) | **结论:未登录路径可在读 token 后立即展示登录页,无网络前置依赖。** ### 5.2 HomeView(已登录首页) | 数据 | 来源 | 快照是否覆盖 | |------|------|-------------| | 当前景区名称 | `AccountContext.currentScenic` | 是 | | 门店卡片(门店管理员) | `AccountContext.currentStore` | 是 | | 常用应用菜单 | `PermissionContext.rolePermissions` + 权限 URI | **否** | | 工作状态卡片 | 本地 State,无 API | — | 首页菜单构建逻辑(`HomeView.rebuildMenusFromCurrentContext`)依赖 `permissionContext.rolePermissions`。若仅恢复快照、不等 API,**菜单区域会为空**,直到后台 refresh 完成。 ### 5.3 MainTabsView | 数据 | 来源 | 是否阻塞 Tab 展示 | |------|------|------------------| | Tab 结构 | 本地静态配置 | 否 | | 订单角标 | `writeOffList` API | 否(`.task` 异步刷新) | --- ## 6. 去掉 Splash 的可行性分析 ### 6.1 未登录路径 — 可行,收益明确 **现状(方案 1 已实施):** - bootstrap 仅读 UserDefaults,耗时极短 - 已去掉固定 1.5 秒品牌展示,Splash 以 bootstrap 完成为准 **建议:** - 无 token → 直接 `loggedOut` → 展示 `LoginView` - 可保留系统 Launch Screen(白底 Logo),去掉应用内 `SplashView` 覆盖层 - `/api/app/config` 维持现状,LoginView 出现后异步加载 ### 6.2 已登录路径 — 可行,但需处理权限缓存缺口 **现状:** ``` token → restoring(空白屏)→ 等 4 个 API → loggedIn → 去 Splash → MainTabsView ``` **乐观恢复方案:** 1. 读 token + 恢复 `AccountSnapshotStore` → `AccountContext` 2. 立即 `markLoggedIn` → 展示 `MainTabsView` 3. 后台执行 `AccountContextLoader.refresh` 4. 鉴权失败 → logout + Toast「登录状态已失效,请重新登录」 5. 网络失败 → 保持登录 + Toast「网络异常,已使用本地登录状态」 **风险与对策:** | 风险 | 影响 | 对策 | |------|------|------| | Token 已失效 | 用户短暂看到首页后跳登录 | 可接受;或首页顶部加「同步中」轻提示 | | 快照无 rolePermissions | 首页菜单为空 | 扩展快照缓存权限树;或首页 skeleton;或至少等 `rolePermissions` 再进首页 | | 有 token 无快照(极端) | 景区名、菜单均不可用 | 降级为短 loading 或强制等 refresh | | UI Test 依赖 splash.root | 自动化等待逻辑失效 | 改为等待 `login.title` 或 TabBar | ### 6.3 最短展示时长(方案 1 已移除) - 原 1.5 秒为纯品牌需求,与业务数据无关 - **已实施:** `SplashCoordinator` 默认 `minimumDisplayDuration = 0`,Splash 以 bootstrap 完成为准 - 系统 Launch Screen(白底 Logo)仍保留,作为唯一品牌曝光 --- ## 7. 延后加载建议 按数据重要性与现有实现,分为三档: ### 7.1 必须同步(进入主界面前) | 数据 | 原因 | 当前状态 | |------|------|----------| | Token 有效性 | 避免进入主界面后立刻登出 | 通过 `rolePermissions` / `userInfo` 间接校验 | | `rolePermissions` | 首页菜单、权限控制依赖 | **阻塞 Splash**;快照未缓存 | 若采用乐观恢复,建议至少保留 `rolePermissions` 的后台刷新用于鉴权;若要菜单立即可用,需扩展 `AccountSnapshot` 缓存权限树。 ### 7.2 可后台刷新(不阻塞首屏) | 数据 | 原因 | 当前状态 | |------|------|----------| | `userInfo` | 头像昵称可先用快照 | 阻塞 Splash,可延后 | | `scenicListAll` | 已有 rolePermissions 兜底 | 阻塞 Splash,可延后 | | `storeAll` | 门店管理员卡片才需要 | 已 `try?` 不阻断,可延后 | ### 7.3 已延后(Splash 后异步) | 数据 | 触发点 | |------|--------| | App 远程配置 | `LoginView.task` | | 打卡点列表 | `RootView.task(id: scenicSpotTaskID)` | | 推送注册 | 登录后 `PushNotificationManager` | | 订单角标 | `MainTabsView.task` | | 排队数据 | `ScenicQueueRuntime`(有配置时) | | 个人资料刷新 | Profile Tab 进入时 | --- ## 8. 推荐优化方案 ### 方案 1 — 低风险(**已实施**) - ~~去掉或缩短 `SplashCoordinator` 1.5 秒最短展示~~ → 默认改为 0 - 未登录:Splash 仅覆盖本地 token 读取(几乎瞬时)→ 登录页 - 已登录:仍等 4 个 API,但去掉人为 1.5 秒延迟 **改动小,启动速度提升明显(未登录场景)。** ### 方案 2 — 中等改动(乐观恢复) - 已登录:快照恢复 → 立即 `loggedIn` → 展示主 Tab - `SessionBootstrapper.restore` 拆为: - `restoreFromCache()` — 同步,填充 `AccountContext` - `refreshInBackground()` — 异步,填充 `PermissionContext` 并校验 token - 扩展 `AccountSnapshot` 缓存 `rolePermissions`(可选,解决菜单空白问题) ```mermaid flowchart TD start[AppLaunch] --> readToken{本地有token?} readToken -->|否| loginPage[直接LoginView] readToken -->|是| loadCache[恢复AccountSnapshotStore] loadCache --> homePage[直接MainTabsView] homePage --> bgRefresh[后台AccountContextLoader.refresh] bgRefresh -->|鉴权失败| logout[logout + Toast] bgRefresh -->|成功| updateUI[静默更新PermissionContext和AccountContext] bgRefresh -->|网络失败| keepLocal[保持本地状态 + Toast] ``` ### 方案 3 — 进一步延后 - 将 `scenicListAll` / `storeAll` 移出 bootstrap,改为进入首页或切换景区/角色时加载 - 推送、角标、打卡点、排队保持现状(已是 Splash 后异步) **建议实施顺序:方案 1 → 方案 2 → 方案 3。** --- ## 9. 改造影响面 若后续实施代码改造,需关注以下文件: | 文件 | 改动内容 | |------|----------| | `suixinkan/App/SplashCoordinator.swift` | 去掉/缩短最短展示,或废弃 | | `suixinkan/App/RootView.swift` | 调整 `.task`:乐观恢复 vs 去掉 Splash 覆盖 | | `suixinkan/App/State/SessionBootstrapper.swift` | 拆分 cache restore 与 background refresh | | `suixinkan/Core/Storage/AccountSnapshotStore.swift` | (方案 2 可选)扩展快照字段 | | `suixinkanTests/SplashCoordinatorTests.swift` | 更新时序测试 | | `suixinkanUITests/Support/SuixinkanUIApplication.swift` | 调整 `waitForSplashToDisappearIfNeeded` | | `suixinkan/Features/Splash/Splash.md` | 同步 Splash 业务文档 | | `suixinkan/App/App.md` | 同步启动流程描述 | --- ## 10. 结论 | 问题 | 结论 | |------|------| | 启动调用了哪些接口? | 有 token 时 4 个 GET(userInfo、rolePermissions、scenicListAll、storeAll);无 token 时零网络 | | 获取了哪些数据? | 用户资料、角色权限、景区/门店作用域;Splash 后另有 config、打卡点、推送、角标等 | | 能否去掉 Splash? | **可以。** 未登录可立即进登录页;已登录可乐观恢复 + 后台刷新 | | 哪些可延后? | userInfo、scenicListAll、storeAll 可延后;rolePermissions 建议至少后台刷新以校验 token;config/角标/推送等已延后 | 当前最大可优化点: 1. ~~**去掉 1.5 秒人为延迟**(方案 1)~~ **已完成** 2. **已登录乐观恢复**(方案 2,需处理 PermissionContext 未缓存问题) 3. **景区/门店列表移出 bootstrap**(方案 3,代码已有部分兜底逻辑)