移除 Splash 固定 1.5 秒等待,并补充启动流程分析文档。
未登录冷启动可立即进入登录页;已登录仍等待 bootstrap 接口完成,但不再叠加人为延迟。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
338
docs/startup-analysis.md
Normal file
338
docs/startup-analysis.md
Normal 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**(白底 + Logo,iOS 原生)
|
||||
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: 读 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,代码已有部分兜底逻辑)
|
||||
Reference in New Issue
Block a user