移除 SplashCoordinator,bootstrap 期间用 SplashView 覆盖避免空白闪屏,其余账号数据改为后台补充刷新。 Co-authored-by: Cursor <cursoragent@cursor.com>
15 KiB
App 启动流程分析与优化方案
本文档梳理 iOS App 冷启动期间的接口调用、数据依赖,并评估去掉启动 Splash 等待层、直接进入登录页或首页的可行性。
1. 概述
App 启动链路为:
suixinkanApp → RootView → 阻塞 rolePermissions + 后台补充 bootstrap → 按 AppSession.phase 展示 LoginView 或 MainTabsView
用户感知的「启动 Loading」并非 Lottie 全局 Loading,而是:
- 系统 Launch Screen(品牌页,iOS 原生)
.restoring短暂空白(仅等待rolePermissions,完成后立即进主界面)- 后台补充刷新(
userInfo、scenicListAll、storeAll,不阻塞首屏)
冷启动期间 不使用 GlobalLoadingCenter / Lottie 动画。
2. 启动时序
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
乐观恢复方案:
- 读 token + 恢复
AccountSnapshotStore→AccountContext - 立即
markLoggedIn→ 展示MainTabsView - 后台执行
AccountContextLoader.refresh - 鉴权失败 → logout + Toast「登录状态已失效,请重新登录」
- 网络失败 → 保持登录 + 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 — 低风险(已实施)
去掉或缩短→ 默认改为 0SplashCoordinator1.5 秒最短展示- 未登录:Splash 仅覆盖本地 token 读取(几乎瞬时)→ 登录页
- 已登录:仍等 4 个 API,但去掉人为 1.5 秒延迟
改动小,启动速度提升明显(未登录场景)。
方案 2 — 中等改动(乐观恢复)
- 已登录:快照恢复 → 立即
loggedIn→ 展示主 Tab SessionBootstrapper.restore拆为:restoreFromCache()— 同步,填充AccountContextrefreshInBackground()— 异步,填充PermissionContext并校验 token
- 扩展
AccountSnapshot缓存rolePermissions(可选,解决菜单空白问题)
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.5 秒人为延迟(方案 1)已完成- 已登录乐观恢复(方案 2,需处理 PermissionContext 未缓存问题)
- 景区/门店列表移出 bootstrap(方案 3,代码已有部分兜底逻辑)