移除 Splash 固定 1.5 秒等待,并补充启动流程分析文档。

未登录冷启动可立即进入登录页;已登录仍等待 bootstrap 接口完成,但不再叠加人为延迟。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 15:27:18 +08:00
parent 3dcfb99254
commit d9f897038d
5 changed files with 362 additions and 11 deletions

338
docs/startup-analysis.md Normal file
View File

@ -0,0 +1,338 @@
# App 启动流程分析与优化方案
本文档梳理 iOS App 冷启动期间的接口调用、数据依赖,并评估去掉启动 Splash 等待层、直接进入登录页或首页的可行性。
相关模块文档:[App 模块](../suixinkan/App/App.md)、[Splash 模块](../suixinkan/Features/Splash/Splash.md)
---
## 1. 概述
App 启动链路为:
**`suixinkanApp``RootView``SplashView` 覆盖层 + 并行 bootstrap → 按 `AppSession.phase` 展示 `LoginView``MainTabsView`**
用户感知的「启动 Loading」并非 Lottie 全局 Loading而是
1. **系统 Launch Screen**(白底 + LogoiOS 原生)
2. **应用内 `SplashView`**品牌页bootstrap 完成即结束,无固定最短展示时长)
3. **有 token 时的 `.restoring` 阶段**Splash 下方空白背景,等待 4 个 API 完成)
冷启动期间 **不使用** `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: 读 UserDefaultslogout
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 个 GETuserInfo、rolePermissions、scenicListAll、storeAll无 token 时零网络 |
| 获取了哪些数据? | 用户资料、角色权限、景区/门店作用域Splash 后另有 config、打卡点、推送、角标等 |
| 能否去掉 Splash | **可以。** 未登录可立即进登录页;已登录可乐观恢复 + 后台刷新 |
| 哪些可延后? | userInfo、scenicListAll、storeAll 可延后rolePermissions 建议至少后台刷新以校验 tokenconfig/角标/推送等已延后 |
当前最大可优化点:
1. ~~**去掉 1.5 秒人为延迟**(方案 1~~ **已完成**
2. **已登录乐观恢复**(方案 2需处理 PermissionContext 未缓存问题)
3. **景区/门店列表移出 bootstrap**(方案 3代码已有部分兜底逻辑

View File

@ -26,11 +26,13 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
## 启动流程 ## 启动流程
> 完整启动接口清单、数据依赖与 Splash 优化方案见 [启动流程分析与优化方案](../../docs/startup-analysis.md)。
1. `suixinkanApp` 创建 `RootView` 1. `suixinkanApp` 创建 `RootView`
2. `RootView` 初始化共享依赖,并把它们注入 Environment。 2. `RootView` 初始化共享依赖,并把它们注入 Environment。
3. iOS 系统 Launch Screen 展示白底 + `SplashLogo` 3. iOS 系统 Launch Screen 展示白底 + `SplashLogo`
4. `SplashView` 覆盖根视图,展示与 Android 对齐的品牌启动页。 4. `SplashView` 覆盖根视图,展示与 Android 对齐的品牌启动页。
5. `SplashCoordinator.start` 并行执行最短 1.5 秒展示和 `SessionBootstrapper.restore`UI Test 模式跳过 1.5 秒延迟 5. `SplashCoordinator.start` 并行执行 `SessionBootstrapper.restore`Splash 以 bootstrap 完成为准,无固定最短展示时长
6. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。 6. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。
7. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token。 7. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token。
8. 无 token 时保持 `loggedOut`Splash 结束后展示 `LoginView` 8. 无 token 时保持 `loggedOut`Splash 结束后展示 `LoginView`

View File

@ -9,23 +9,23 @@ import Combine
import Foundation import Foundation
@MainActor @MainActor
/// bootstrap /// bootstrapSplash bootstrap
final class SplashCoordinator: ObservableObject { final class SplashCoordinator: ObservableObject {
@Published private(set) var isFinished = false @Published private(set) var isFinished = false
private let minimumDisplayDuration: TimeInterval private let minimumDisplayDuration: TimeInterval
private let skipsMinimumDuration: Bool private let skipsMinimumDuration: Bool
/// UI Test /// delay
init( init(
minimumDisplayDuration: TimeInterval = 1.5, minimumDisplayDuration: TimeInterval = 0,
skipsMinimumDuration: Bool = AppUITestLaunchState.isRunningUITests skipsMinimumDuration: Bool = AppUITestLaunchState.isRunningUITests
) { ) {
self.minimumDisplayDuration = minimumDisplayDuration self.minimumDisplayDuration = minimumDisplayDuration
self.skipsMinimumDuration = skipsMinimumDuration self.skipsMinimumDuration = skipsMinimumDuration
} }
/// bootstrap Splash /// bootstrap Splash
func start(bootstrap: @escaping () async -> Void) async { func start(bootstrap: @escaping () async -> Void) async {
guard !isFinished else { return } guard !isFinished else { return }

View File

@ -9,7 +9,7 @@ Splash 模块负责冷启动时的品牌启动页展示,对齐 Android `Splash
## 核心对象 ## 核心对象
- `SplashView`展示白底、180pt Logo、「随心瞰 / 商家版」文案。 - `SplashView`展示白底、180pt Logo、「随心瞰 / 商家版」文案。
- `SplashCoordinator`:并行执行最短 1.5 秒展示与 `SessionBootstrapper.restore`两者都完成后结束 Splash。 - `SplashCoordinator`:并行执行 `SessionBootstrapper.restore`bootstrap 完成后结束 Splash(无固定最短展示时长)
- `SplashLogo` / `LaunchBackground`:启动页图片与背景色资源,同时用于系统 `UILaunchScreen` - `SplashLogo` / `LaunchBackground`:启动页图片与背景色资源,同时用于系统 `UILaunchScreen`
## 展示规则 ## 展示规则
@ -23,10 +23,8 @@ Splash 模块负责冷启动时的品牌启动页展示,对齐 Android `Splash
1. iOS 系统 Launch Screen 展示白底 + Logo。 1. iOS 系统 Launch Screen 展示白底 + Logo。
2. `RootView` 挂载后立即显示 `SplashView` 2. `RootView` 挂载后立即显示 `SplashView`
3. `SplashCoordinator.start` 并行执行 3. `SplashCoordinator.start` 并行执行 `SessionBootstrapper.restore` 静默恢复登录态。
- 最短展示 1.5 秒UI Test 模式跳过) 4. bootstrap 完成后淡出 Splash进入 `LoginView``MainTabsView`
- `SessionBootstrapper.restore` 静默恢复登录态
4. 两者都完成后淡出 Splash进入 `LoginView``MainTabsView`
5. 冷启动期间不显示 Lottie 全局 Loading。 5. 冷启动期间不显示 Lottie 全局 Loading。
## 与 Android 的差异 ## 与 Android 的差异

View File

@ -9,8 +9,21 @@ import XCTest
@testable import suixinkan @testable import suixinkan
@MainActor @MainActor
/// bootstrap UI Test /// bootstrap UI Test
final class SplashCoordinatorTests: XCTestCase { final class SplashCoordinatorTests: XCTestCase {
/// bootstrap Splash
func testStartFinishesImmediatelyWithDefaultMinimumDuration() async {
let coordinator = SplashCoordinator(skipsMinimumDuration: true)
let startedAt = Date()
await coordinator.start {
try? await Task.sleep(nanoseconds: 10_000_000)
}
XCTAssertTrue(coordinator.isFinished)
XCTAssertLessThan(Date().timeIntervalSince(startedAt), 0.15)
}
/// bootstrap /// bootstrap
func testStartWaitsForMinimumDisplayDuration() async { func testStartWaitsForMinimumDisplayDuration() async {
let coordinator = SplashCoordinator(minimumDisplayDuration: 0.2, skipsMinimumDuration: false) let coordinator = SplashCoordinator(minimumDisplayDuration: 0.2, skipsMinimumDuration: false)