From d9f897038db4775ea87def6e101479b0ef91a852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 29 Jun 2026 15:27:18 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E9=99=A4=20Splash=20=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=201.5=20=E7=A7=92=E7=AD=89=E5=BE=85=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=90=AF=E5=8A=A8=E6=B5=81=E7=A8=8B=E5=88=86?= =?UTF-8?q?=E6=9E=90=E6=96=87=E6=A1=A3=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未登录冷启动可立即进入登录页;已登录仍等待 bootstrap 接口完成,但不再叠加人为延迟。 Co-authored-by: Cursor --- docs/startup-analysis.md | 338 ++++++++++++++++++++ suixinkan/App/App.md | 4 +- suixinkan/App/SplashCoordinator.swift | 8 +- suixinkan/Features/Splash/Splash.md | 8 +- suixinkanTests/SplashCoordinatorTests.swift | 15 +- 5 files changed, 362 insertions(+), 11 deletions(-) create mode 100644 docs/startup-analysis.md diff --git a/docs/startup-analysis.md b/docs/startup-analysis.md new file mode 100644 index 0000000..19a7e6d --- /dev/null +++ b/docs/startup-analysis.md @@ -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,代码已有部分兜底逻辑) diff --git a/suixinkan/App/App.md b/suixinkan/App/App.md index c85a7df..e4e0bdf 100644 --- a/suixinkan/App/App.md +++ b/suixinkan/App/App.md @@ -26,11 +26,13 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航 ## 启动流程 +> 完整启动接口清单、数据依赖与 Splash 优化方案见 [启动流程分析与优化方案](../../docs/startup-analysis.md)。 + 1. `suixinkanApp` 创建 `RootView`。 2. `RootView` 初始化共享依赖,并把它们注入 Environment。 3. iOS 系统 Launch Screen 展示白底 + `SplashLogo`。 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。 7. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token。 8. 无 token 时保持 `loggedOut`,Splash 结束后展示 `LoginView`。 diff --git a/suixinkan/App/SplashCoordinator.swift b/suixinkan/App/SplashCoordinator.swift index 0382c93..8e03658 100644 --- a/suixinkan/App/SplashCoordinator.swift +++ b/suixinkan/App/SplashCoordinator.swift @@ -9,23 +9,23 @@ import Combine import Foundation @MainActor -/// 启动页时序协调器,保证品牌页最短展示时长与冷启动 bootstrap 并行完成。 +/// 启动页时序协调器,并行执行冷启动 bootstrap,Splash 结束时机以 bootstrap 完成为准。 final class SplashCoordinator: ObservableObject { @Published private(set) var isFinished = false private let minimumDisplayDuration: TimeInterval private let skipsMinimumDuration: Bool - /// 创建启动页协调器,并配置最短展示时长与 UI Test 跳过策略。 + /// 创建启动页协调器;默认无最短展示时长,测试可注入自定义 delay。 init( - minimumDisplayDuration: TimeInterval = 1.5, + minimumDisplayDuration: TimeInterval = 0, skipsMinimumDuration: Bool = AppUITestLaunchState.isRunningUITests ) { self.minimumDisplayDuration = minimumDisplayDuration self.skipsMinimumDuration = skipsMinimumDuration } - /// 并行执行 bootstrap 与最短展示时长,两者都完成后结束 Splash。 + /// 并行执行 bootstrap 与可选最短展示时长,两者都完成后结束 Splash。 func start(bootstrap: @escaping () async -> Void) async { guard !isFinished else { return } diff --git a/suixinkan/Features/Splash/Splash.md b/suixinkan/Features/Splash/Splash.md index 0162188..f681943 100644 --- a/suixinkan/Features/Splash/Splash.md +++ b/suixinkan/Features/Splash/Splash.md @@ -9,7 +9,7 @@ Splash 模块负责冷启动时的品牌启动页展示,对齐 Android `Splash ## 核心对象 - `SplashView`:展示白底、180pt Logo、「随心瞰 / 商家版」文案。 -- `SplashCoordinator`:并行执行最短 1.5 秒展示与 `SessionBootstrapper.restore`,两者都完成后结束 Splash。 +- `SplashCoordinator`:并行执行 `SessionBootstrapper.restore`,bootstrap 完成后结束 Splash(无固定最短展示时长)。 - `SplashLogo` / `LaunchBackground`:启动页图片与背景色资源,同时用于系统 `UILaunchScreen`。 ## 展示规则 @@ -23,10 +23,8 @@ Splash 模块负责冷启动时的品牌启动页展示,对齐 Android `Splash 1. iOS 系统 Launch Screen 展示白底 + Logo。 2. `RootView` 挂载后立即显示 `SplashView`。 -3. `SplashCoordinator.start` 并行执行: - - 最短展示 1.5 秒(UI Test 模式跳过) - - `SessionBootstrapper.restore` 静默恢复登录态 -4. 两者都完成后淡出 Splash,进入 `LoginView` 或 `MainTabsView`。 +3. `SplashCoordinator.start` 并行执行 `SessionBootstrapper.restore` 静默恢复登录态。 +4. bootstrap 完成后淡出 Splash,进入 `LoginView` 或 `MainTabsView`。 5. 冷启动期间不显示 Lottie 全局 Loading。 ## 与 Android 的差异 diff --git a/suixinkanTests/SplashCoordinatorTests.swift b/suixinkanTests/SplashCoordinatorTests.swift index a136add..8acaf43 100644 --- a/suixinkanTests/SplashCoordinatorTests.swift +++ b/suixinkanTests/SplashCoordinatorTests.swift @@ -9,8 +9,21 @@ import XCTest @testable import suixinkan @MainActor -/// 启动页协调器测试,覆盖最短展示时长、bootstrap 并行和 UI Test 跳过策略。 +/// 启动页协调器测试,覆盖可选最短展示时长、bootstrap 并行和 UI Test 跳过策略。 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 完成后仍需等待最短展示时长。 func testStartWaitsForMinimumDisplayDuration() async { let coordinator = SplashCoordinator(minimumDisplayDuration: 0.2, skipsMinimumDuration: false)