优化冷启动流程:Launch Screen 对齐 Splash,仅阻塞 rolePermissions。

移除 SplashCoordinator,bootstrap 期间用 SplashView 覆盖避免空白闪屏,其余账号数据改为后台补充刷新。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 09:56:49 +08:00
parent d2fe5d71e4
commit 258c438f9a
15 changed files with 545 additions and 223 deletions

View File

@ -30,27 +30,26 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
1. `suixinkanApp` 创建 `RootView`
2. `RootView` 初始化共享依赖,并把它们注入 Environment。
3. iOS 系统 Launch Screen 展示白底 + `SplashLogo`
4. `SplashView` 覆盖根视图,展示与 Android 对齐的品牌启动页
5. `SplashCoordinator.start` 并行执行 `SessionBootstrapper.restore`Splash 以 bootstrap 完成为准,无固定最短展示时长
6. `APIClient` 绑定 `AppSession.token` 作为默认 token provider
7. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token
8. token 时保持 `loggedOut`Splash 结束后展示 `LoginView`
9. 有 token 时进入 `restoring`,先恢复本地账号快照
10. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店
11. 校验成功后进入 `loggedIn`Splash 结束后展示 `MainTabsView`
12. `ScenicSpotContext` 按当前景区懒加载景点/打卡点
13. 明确 token 失效时清空 token 和账号快照,回到登录页
14. 普通网络失败时保留本地登录态,使用账号快照进入主界面
15. 冷启动 bootstrap 不使用 Lottie 全局 LoadingSplash 即启动等待层
16. `LoginView` 首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默。
3. iOS 系统 Launch Screen 展示品牌页白底、Logo、文案
4. `RootView` 挂载后在 bootstrap 完成前以 `SplashView` 覆盖根视图,避免空白或登录页闪屏
5. `APIClient` 绑定 `AppSession.token` 作为默认 token provider
6. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token
7. 无 token 时保持 `loggedOut`,展示 `LoginView`
8. token 时进入 `restoring`,先恢复本地账号快照
9. 阻塞请求 `rolePermissions` 成功后立即 `markLoggedIn` 并展示 `MainTabsView`
10. `userInfo``scenicListAll``storeAll` 在后台补充刷新,不阻塞首屏
11. `ScenicSpotContext` 按当前景区懒加载景点/打卡点
12. 明确 token 失效时清空 token 和账号快照,回到登录页
13. 普通网络失败时保留本地登录态,使用账号快照进入主界面
14. 冷启动 bootstrap 不使用 Lottie 全局 Loading`.restoring` 期间展示 `SplashView`
15. `LoginView` 首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默
## 初始化分层
| 阶段 | 职责 | 主要对象 |
|------|------|----------|
| App 入口 | UI Test 状态清理、后续可扩展 SDK 初始化 | `suixinkanApp``AppUITestLaunchState` |
| Splash | 品牌展示 + 登录态恢复 | `SplashView``SplashCoordinator``SessionBootstrapper` |
| 冷启动恢复 | Launch Screen + Splash 覆盖 bootstrap | `SplashView``SessionBootstrapper``AccountContextLoader` |
| 首屏出现后 | 页面级远程配置 | `LoginView``AppConfigAPI` |
| 登录后 | 推送、景点列表、排队 WebSocket | `PushNotificationManager``ScenicSpotContext` |

View File

@ -48,7 +48,7 @@ struct RootView: View {
@State private var cooperationOrderAPI: CooperationOrderAPI
@State private var authSessionCoordinator: AuthSessionCoordinator
@State private var sessionBootstrapper: SessionBootstrapper
@StateObject private var splashCoordinator = SplashCoordinator()
@State private var isColdStartBootstrapPending = true
@State private var appConfigAPI: AppConfigAPI
/// API token provider
@ -106,7 +106,7 @@ struct RootView: View {
ZStack {
rootContent
if !splashCoordinator.isFinished {
if isColdStartBootstrapPending {
SplashView()
.transition(.opacity)
.zIndex(1)
@ -155,15 +155,16 @@ struct RootView: View {
.task {
apiClient.bindAuthTokenProvider { appSession.token }
PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter)
await splashCoordinator.start {
await sessionBootstrapper.restore(
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext,
profileAPI: profileAPI,
accountContextAPI: accountContextAPI,
toastCenter: toastCenter
)
await sessionBootstrapper.restore(
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext,
profileAPI: profileAPI,
accountContextAPI: accountContextAPI,
toastCenter: toastCenter
)
withAnimation {
isColdStartBootstrapPending = false
}
if appSession.isLoggedIn, !AppUITestLaunchState.isRunningUITests {
PushNotificationManager.shared.requestAuthorizationAndRegister()
@ -227,7 +228,7 @@ struct RootView: View {
case .loggedOut:
LoginView()
case .restoring:
Color(.systemBackground)
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .loggedIn:
MainTabsView()

View File

@ -1,45 +0,0 @@
//
// SplashCoordinator.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Combine
import Foundation
@MainActor
/// bootstrapSplash bootstrap
final class SplashCoordinator: ObservableObject {
@Published private(set) var isFinished = false
private let minimumDisplayDuration: TimeInterval
private let skipsMinimumDuration: Bool
/// delay
init(
minimumDisplayDuration: TimeInterval = 0,
skipsMinimumDuration: Bool = AppUITestLaunchState.isRunningUITests
) {
self.minimumDisplayDuration = minimumDisplayDuration
self.skipsMinimumDuration = skipsMinimumDuration
}
/// bootstrap Splash
func start(bootstrap: @escaping () async -> Void) async {
guard !isFinished else { return }
async let minimumDelay: Void = waitForMinimumDisplayDuration()
async let bootstrapWork: Void = bootstrap()
_ = await (minimumDelay, bootstrapWork)
isFinished = true
}
/// UI Test
private func waitForMinimumDisplayDuration() async {
guard !skipsMinimumDuration else { return }
let nanoseconds = UInt64(max(minimumDisplayDuration, 0) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
}
}

View File

@ -17,7 +17,7 @@ protocol UserProfileServing {
@MainActor
///
struct AccountContextLoader {
///
///
func refresh(
accountContext: AccountContext,
permissionContext: PermissionContext,
@ -28,17 +28,66 @@ struct AccountContextLoader {
cachedCurrentScenicId: Int? = nil,
cachedCurrentStoreId: Int? = nil
) async throws {
async let userData = profileAPI.userInfo()
async let roleData = accountContextAPI.rolePermissions()
let (userInfo, rolePermissions) = try await (userData, roleData)
try await restorePermissions(
permissionContext: permissionContext,
accountContext: accountContext,
accountContextAPI: accountContextAPI,
cachedCurrentRoleCode: cachedCurrentRoleCode,
cachedLegacyRoleId: cachedLegacyRoleId,
cachedCurrentScenicId: cachedCurrentScenicId,
cachedCurrentStoreId: cachedCurrentStoreId
)
try await refreshSupplementalContext(
accountContext: accountContext,
permissionContext: permissionContext,
profileAPI: profileAPI,
accountContextAPI: accountContextAPI,
cachedCurrentScenicId: cachedCurrentScenicId,
cachedCurrentStoreId: cachedCurrentStoreId
)
}
///
func restorePermissions(
permissionContext: PermissionContext,
accountContext: AccountContext,
accountContextAPI: AccountContextServing,
cachedCurrentRoleCode: String? = nil,
cachedLegacyRoleId: Int? = nil,
cachedCurrentScenicId: Int? = nil,
cachedCurrentStoreId: Int? = nil
) async throws {
let rolePermissions = try await accountContextAPI.rolePermissions()
permissionContext.replaceRolePermissions(
rolePermissions,
currentRoleCode: cachedCurrentRoleCode,
cachedLegacyRoleId: cachedLegacyRoleId
)
let scenicResponse = await loadScenicList(accountContextAPI: accountContextAPI, rolePermissions: rolePermissions)
let roleScenicScopes = permissionContext.currentRoleScenicScopes()
guard accountContext.scenicScopes.isEmpty, !roleScenicScopes.isEmpty else { return }
accountContext.replaceScopes(
scenic: roleScenicScopes,
stores: accountContext.storeScopes,
currentScenicId: cachedCurrentScenicId,
currentStoreId: cachedCurrentStoreId
)
}
///
func refreshSupplementalContext(
accountContext: AccountContext,
permissionContext: PermissionContext,
profileAPI: UserProfileServing,
accountContextAPI: AccountContextServing,
cachedCurrentScenicId: Int? = nil,
cachedCurrentStoreId: Int? = nil
) async throws {
let userInfo = try await profileAPI.userInfo()
let scenicResponse = await loadScenicList(
accountContextAPI: accountContextAPI,
rolePermissions: permissionContext.rolePermissions
)
let storesResponse = await loadStores(accountContextAPI: accountContextAPI)
let profile = makeProfile(from: userInfo, fallback: accountContext.profile)
let scenicScopes = scopedScenics(
@ -53,8 +102,8 @@ struct AccountContextLoader {
accountContext.replaceScopes(
scenic: scenicScopes,
stores: storeScopes,
currentScenicId: cachedCurrentScenicId,
currentStoreId: cachedCurrentStoreId
currentScenicId: cachedCurrentScenicId ?? accountContext.currentScenic?.id,
currentStoreId: cachedCurrentStoreId ?? accountContext.currentStore?.id
)
}

View File

@ -32,7 +32,7 @@ final class SessionBootstrapper {
)
}
///
/// rolePermissions
func restore(
appSession: AppSession,
accountContext: AccountContext,
@ -51,32 +51,38 @@ final class SessionBootstrapper {
appSession.beginRestoring(token: token)
let cachedSnapshot = restoreCachedSnapshot(to: accountContext)
let loader = AccountContextLoader()
do {
try await AccountContextLoader().refresh(
accountContext: accountContext,
try await loader.restorePermissions(
permissionContext: permissionContext,
profileAPI: profileAPI,
accountContext: accountContext,
accountContextAPI: accountContextAPI,
cachedCurrentRoleCode: cachedSnapshot?.currentRoleCode,
cachedLegacyRoleId: cachedSnapshot?.currentRoleId,
cachedCurrentScenicId: cachedSnapshot?.currentScenicId,
cachedCurrentStoreId: cachedSnapshot?.currentStoreId
)
saveLatestSnapshot(from: accountContext, permissionContext: permissionContext)
appSession.markLoggedIn(token: token)
refreshSupplementalContextInBackground(
loader: loader,
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext,
profileAPI: profileAPI,
accountContextAPI: accountContextAPI,
toastCenter: toastCenter,
cachedSnapshot: cachedSnapshot
)
} catch {
if APIError.isAuthenticationExpired(error) {
try? tokenStore.clear()
snapshotStore.clear()
accountContext.reset()
permissionContext.reset()
appSession.logout()
toastCenter.show("登录状态已失效,请重新登录")
} else {
appSession.markLoggedIn(token: token)
toastCenter.show("网络异常,已使用本地登录状态")
}
handleBlockingRestoreFailure(
error,
token: token,
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext,
toastCenter: toastCenter
)
}
}
@ -93,6 +99,97 @@ final class SessionBootstrapper {
return snapshot
}
///
private func refreshSupplementalContextInBackground(
loader: AccountContextLoader,
appSession: AppSession,
accountContext: AccountContext,
permissionContext: PermissionContext,
profileAPI: UserProfileServing,
accountContextAPI: AccountContextServing,
toastCenter: ToastCenter,
cachedSnapshot: AccountSnapshot?
) {
Task {
do {
try await loader.refreshSupplementalContext(
accountContext: accountContext,
permissionContext: permissionContext,
profileAPI: profileAPI,
accountContextAPI: accountContextAPI,
cachedCurrentScenicId: cachedSnapshot?.currentScenicId,
cachedCurrentStoreId: cachedSnapshot?.currentStoreId
)
saveLatestSnapshot(from: accountContext, permissionContext: permissionContext)
} catch {
handleSupplementalRefreshFailure(
error,
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext,
toastCenter: toastCenter
)
}
}
}
///
private func handleBlockingRestoreFailure(
_ error: Error,
token: String,
appSession: AppSession,
accountContext: AccountContext,
permissionContext: PermissionContext,
toastCenter: ToastCenter
) {
if APIError.isAuthenticationExpired(error) {
clearPersistedSession(
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext
)
toastCenter.show("登录状态已失效,请重新登录")
return
}
appSession.markLoggedIn(token: token)
toastCenter.show("网络异常,已使用本地登录状态")
}
///
private func handleSupplementalRefreshFailure(
_ error: Error,
appSession: AppSession,
accountContext: AccountContext,
permissionContext: PermissionContext,
toastCenter: ToastCenter
) {
if APIError.isAuthenticationExpired(error) {
clearPersistedSession(
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext
)
toastCenter.show("登录状态已失效,请重新登录")
return
}
toastCenter.show("网络异常,已使用本地登录状态")
}
/// token
private func clearPersistedSession(
appSession: AppSession,
accountContext: AccountContext,
permissionContext: PermissionContext
) {
try? tokenStore.clear()
snapshotStore.clear()
accountContext.reset()
permissionContext.reset()
appSession.logout()
}
///
private func saveLatestSnapshot(from accountContext: AccountContext, permissionContext: PermissionContext) {
let existing = snapshotStore.load()
@ -109,5 +206,4 @@ final class SessionBootstrapper {
)
)
}
}