优化冷启动流程:Launch Screen 对齐 Splash,仅阻塞 rolePermissions。
移除 SplashCoordinator,bootstrap 期间用 SplashView 覆盖避免空白闪屏,其余账号数据改为后台补充刷新。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -10,13 +10,13 @@
|
|||||||
|
|
||||||
App 启动链路为:
|
App 启动链路为:
|
||||||
|
|
||||||
**`suixinkanApp` → `RootView` → `SplashView` 覆盖层 + 并行 bootstrap → 按 `AppSession.phase` 展示 `LoginView` 或 `MainTabsView`**
|
**`suixinkanApp` → `RootView` → 阻塞 `rolePermissions` + 后台补充 bootstrap → 按 `AppSession.phase` 展示 `LoginView` 或 `MainTabsView`**
|
||||||
|
|
||||||
用户感知的「启动 Loading」并非 Lottie 全局 Loading,而是:
|
用户感知的「启动 Loading」并非 Lottie 全局 Loading,而是:
|
||||||
|
|
||||||
1. **系统 Launch Screen**(白底 + Logo,iOS 原生)
|
1. **系统 Launch Screen**(品牌页,iOS 原生)
|
||||||
2. **应用内 `SplashView`**(品牌页,bootstrap 完成即结束,无固定最短展示时长)
|
2. **`.restoring` 短暂空白**(仅等待 `rolePermissions`,完成后立即进主界面)
|
||||||
3. **有 token 时的 `.restoring` 阶段**(Splash 下方空白背景,等待 4 个 API 完成)
|
3. **后台补充刷新**(`userInfo`、`scenicListAll`、`storeAll`,不阻塞首屏)
|
||||||
|
|
||||||
冷启动期间 **不使用** `GlobalLoadingCenter` / Lottie 动画。
|
冷启动期间 **不使用** `GlobalLoadingCenter` / Lottie 动画。
|
||||||
|
|
||||||
|
|||||||
@ -30,27 +30,26 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
|||||||
|
|
||||||
1. `suixinkanApp` 创建 `RootView`。
|
1. `suixinkanApp` 创建 `RootView`。
|
||||||
2. `RootView` 初始化共享依赖,并把它们注入 Environment。
|
2. `RootView` 初始化共享依赖,并把它们注入 Environment。
|
||||||
3. iOS 系统 Launch Screen 展示白底 + `SplashLogo`。
|
3. iOS 系统 Launch Screen 展示品牌页(白底、Logo、文案)。
|
||||||
4. `SplashView` 覆盖根视图,展示与 Android 对齐的品牌启动页。
|
4. `RootView` 挂载后在 bootstrap 完成前以 `SplashView` 覆盖根视图,避免空白或登录页闪屏。
|
||||||
5. `SplashCoordinator.start` 并行执行 `SessionBootstrapper.restore`;Splash 以 bootstrap 完成为准,无固定最短展示时长。
|
5. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。
|
||||||
6. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。
|
6. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token。
|
||||||
7. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token。
|
7. 无 token 时保持 `loggedOut`,展示 `LoginView`。
|
||||||
8. 无 token 时保持 `loggedOut`,Splash 结束后展示 `LoginView`。
|
8. 有 token 时进入 `restoring`,先恢复本地账号快照。
|
||||||
9. 有 token 时进入 `restoring`,先恢复本地账号快照。
|
9. 阻塞请求 `rolePermissions` 成功后立即 `markLoggedIn` 并展示 `MainTabsView`。
|
||||||
10. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。
|
10. `userInfo`、`scenicListAll`、`storeAll` 在后台补充刷新,不阻塞首屏。
|
||||||
11. 校验成功后进入 `loggedIn`,Splash 结束后展示 `MainTabsView`。
|
11. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
|
||||||
12. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
|
12. 明确 token 失效时清空 token 和账号快照,回到登录页。
|
||||||
13. 明确 token 失效时清空 token 和账号快照,回到登录页。
|
13. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
|
||||||
14. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
|
14. 冷启动 bootstrap 不使用 Lottie 全局 Loading;`.restoring` 期间展示 `SplashView`。
|
||||||
15. 冷启动 bootstrap 不使用 Lottie 全局 Loading;Splash 即启动等待层。
|
15. `LoginView` 首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默。
|
||||||
16. `LoginView` 首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默。
|
|
||||||
|
|
||||||
## 初始化分层
|
## 初始化分层
|
||||||
|
|
||||||
| 阶段 | 职责 | 主要对象 |
|
| 阶段 | 职责 | 主要对象 |
|
||||||
|------|------|----------|
|
|------|------|----------|
|
||||||
| App 入口 | UI Test 状态清理、后续可扩展 SDK 初始化 | `suixinkanApp`、`AppUITestLaunchState` |
|
| App 入口 | UI Test 状态清理、后续可扩展 SDK 初始化 | `suixinkanApp`、`AppUITestLaunchState` |
|
||||||
| Splash | 品牌展示 + 登录态恢复 | `SplashView`、`SplashCoordinator`、`SessionBootstrapper` |
|
| 冷启动恢复 | Launch Screen + Splash 覆盖 bootstrap | `SplashView`、`SessionBootstrapper`、`AccountContextLoader` |
|
||||||
| 首屏出现后 | 页面级远程配置 | `LoginView`、`AppConfigAPI` |
|
| 首屏出现后 | 页面级远程配置 | `LoginView`、`AppConfigAPI` |
|
||||||
| 登录后 | 推送、景点列表、排队 WebSocket | `PushNotificationManager`、`ScenicSpotContext` |
|
| 登录后 | 推送、景点列表、排队 WebSocket | `PushNotificationManager`、`ScenicSpotContext` |
|
||||||
|
|
||||||
|
|||||||
@ -48,7 +48,7 @@ struct RootView: View {
|
|||||||
@State private var cooperationOrderAPI: CooperationOrderAPI
|
@State private var cooperationOrderAPI: CooperationOrderAPI
|
||||||
@State private var authSessionCoordinator: AuthSessionCoordinator
|
@State private var authSessionCoordinator: AuthSessionCoordinator
|
||||||
@State private var sessionBootstrapper: SessionBootstrapper
|
@State private var sessionBootstrapper: SessionBootstrapper
|
||||||
@StateObject private var splashCoordinator = SplashCoordinator()
|
@State private var isColdStartBootstrapPending = true
|
||||||
@State private var appConfigAPI: AppConfigAPI
|
@State private var appConfigAPI: AppConfigAPI
|
||||||
|
|
||||||
/// 初始化网络客户端和业务 API,确保它们共享同一个 token provider。
|
/// 初始化网络客户端和业务 API,确保它们共享同一个 token provider。
|
||||||
@ -106,7 +106,7 @@ struct RootView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
rootContent
|
rootContent
|
||||||
|
|
||||||
if !splashCoordinator.isFinished {
|
if isColdStartBootstrapPending {
|
||||||
SplashView()
|
SplashView()
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
.zIndex(1)
|
.zIndex(1)
|
||||||
@ -155,15 +155,16 @@ struct RootView: View {
|
|||||||
.task {
|
.task {
|
||||||
apiClient.bindAuthTokenProvider { appSession.token }
|
apiClient.bindAuthTokenProvider { appSession.token }
|
||||||
PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter)
|
PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter)
|
||||||
await splashCoordinator.start {
|
await sessionBootstrapper.restore(
|
||||||
await sessionBootstrapper.restore(
|
appSession: appSession,
|
||||||
appSession: appSession,
|
accountContext: accountContext,
|
||||||
accountContext: accountContext,
|
permissionContext: permissionContext,
|
||||||
permissionContext: permissionContext,
|
profileAPI: profileAPI,
|
||||||
profileAPI: profileAPI,
|
accountContextAPI: accountContextAPI,
|
||||||
accountContextAPI: accountContextAPI,
|
toastCenter: toastCenter
|
||||||
toastCenter: toastCenter
|
)
|
||||||
)
|
withAnimation {
|
||||||
|
isColdStartBootstrapPending = false
|
||||||
}
|
}
|
||||||
if appSession.isLoggedIn, !AppUITestLaunchState.isRunningUITests {
|
if appSession.isLoggedIn, !AppUITestLaunchState.isRunningUITests {
|
||||||
PushNotificationManager.shared.requestAuthorizationAndRegister()
|
PushNotificationManager.shared.requestAuthorizationAndRegister()
|
||||||
@ -227,7 +228,7 @@ struct RootView: View {
|
|||||||
case .loggedOut:
|
case .loggedOut:
|
||||||
LoginView()
|
LoginView()
|
||||||
case .restoring:
|
case .restoring:
|
||||||
Color(.systemBackground)
|
Color.clear
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
case .loggedIn:
|
case .loggedIn:
|
||||||
MainTabsView()
|
MainTabsView()
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
//
|
|
||||||
// SplashCoordinator.swift
|
|
||||||
// suixinkan
|
|
||||||
//
|
|
||||||
// Created by Codex on 2026/6/29.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
/// 启动页时序协调器,并行执行冷启动 bootstrap,Splash 结束时机以 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -17,7 +17,7 @@ protocol UserProfileServing {
|
|||||||
@MainActor
|
@MainActor
|
||||||
/// 账号上下文加载器,统一装配用户资料、角色权限、景区和门店上下文。
|
/// 账号上下文加载器,统一装配用户资料、角色权限、景区和门店上下文。
|
||||||
struct AccountContextLoader {
|
struct AccountContextLoader {
|
||||||
/// 刷新账号上下文,权限失败会向外抛错,景区和门店按旧工程规则兜底。
|
/// 刷新完整账号上下文;登录成功等场景仍需要同步拉齐全部数据。
|
||||||
func refresh(
|
func refresh(
|
||||||
accountContext: AccountContext,
|
accountContext: AccountContext,
|
||||||
permissionContext: PermissionContext,
|
permissionContext: PermissionContext,
|
||||||
@ -28,17 +28,66 @@ struct AccountContextLoader {
|
|||||||
cachedCurrentScenicId: Int? = nil,
|
cachedCurrentScenicId: Int? = nil,
|
||||||
cachedCurrentStoreId: Int? = nil
|
cachedCurrentStoreId: Int? = nil
|
||||||
) async throws {
|
) async throws {
|
||||||
async let userData = profileAPI.userInfo()
|
try await restorePermissions(
|
||||||
async let roleData = accountContextAPI.rolePermissions()
|
permissionContext: permissionContext,
|
||||||
let (userInfo, rolePermissions) = try await (userData, roleData)
|
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(
|
permissionContext.replaceRolePermissions(
|
||||||
rolePermissions,
|
rolePermissions,
|
||||||
currentRoleCode: cachedCurrentRoleCode,
|
currentRoleCode: cachedCurrentRoleCode,
|
||||||
cachedLegacyRoleId: cachedLegacyRoleId
|
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 storesResponse = await loadStores(accountContextAPI: accountContextAPI)
|
||||||
let profile = makeProfile(from: userInfo, fallback: accountContext.profile)
|
let profile = makeProfile(from: userInfo, fallback: accountContext.profile)
|
||||||
let scenicScopes = scopedScenics(
|
let scenicScopes = scopedScenics(
|
||||||
@ -53,8 +102,8 @@ struct AccountContextLoader {
|
|||||||
accountContext.replaceScopes(
|
accountContext.replaceScopes(
|
||||||
scenic: scenicScopes,
|
scenic: scenicScopes,
|
||||||
stores: storeScopes,
|
stores: storeScopes,
|
||||||
currentScenicId: cachedCurrentScenicId,
|
currentScenicId: cachedCurrentScenicId ?? accountContext.currentScenic?.id,
|
||||||
currentStoreId: cachedCurrentStoreId
|
currentStoreId: cachedCurrentStoreId ?? accountContext.currentStore?.id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -32,7 +32,7 @@ final class SessionBootstrapper {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从本地缓存恢复登录态并请求服务端校验。
|
/// 从本地缓存恢复登录态;阻塞等待 rolePermissions,其余数据后台刷新。
|
||||||
func restore(
|
func restore(
|
||||||
appSession: AppSession,
|
appSession: AppSession,
|
||||||
accountContext: AccountContext,
|
accountContext: AccountContext,
|
||||||
@ -51,32 +51,38 @@ final class SessionBootstrapper {
|
|||||||
|
|
||||||
appSession.beginRestoring(token: token)
|
appSession.beginRestoring(token: token)
|
||||||
let cachedSnapshot = restoreCachedSnapshot(to: accountContext)
|
let cachedSnapshot = restoreCachedSnapshot(to: accountContext)
|
||||||
|
let loader = AccountContextLoader()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await AccountContextLoader().refresh(
|
try await loader.restorePermissions(
|
||||||
accountContext: accountContext,
|
|
||||||
permissionContext: permissionContext,
|
permissionContext: permissionContext,
|
||||||
profileAPI: profileAPI,
|
accountContext: accountContext,
|
||||||
accountContextAPI: accountContextAPI,
|
accountContextAPI: accountContextAPI,
|
||||||
cachedCurrentRoleCode: cachedSnapshot?.currentRoleCode,
|
cachedCurrentRoleCode: cachedSnapshot?.currentRoleCode,
|
||||||
cachedLegacyRoleId: cachedSnapshot?.currentRoleId,
|
cachedLegacyRoleId: cachedSnapshot?.currentRoleId,
|
||||||
cachedCurrentScenicId: cachedSnapshot?.currentScenicId,
|
cachedCurrentScenicId: cachedSnapshot?.currentScenicId,
|
||||||
cachedCurrentStoreId: cachedSnapshot?.currentStoreId
|
cachedCurrentStoreId: cachedSnapshot?.currentStoreId
|
||||||
)
|
)
|
||||||
saveLatestSnapshot(from: accountContext, permissionContext: permissionContext)
|
|
||||||
appSession.markLoggedIn(token: token)
|
appSession.markLoggedIn(token: token)
|
||||||
|
refreshSupplementalContextInBackground(
|
||||||
|
loader: loader,
|
||||||
|
appSession: appSession,
|
||||||
|
accountContext: accountContext,
|
||||||
|
permissionContext: permissionContext,
|
||||||
|
profileAPI: profileAPI,
|
||||||
|
accountContextAPI: accountContextAPI,
|
||||||
|
toastCenter: toastCenter,
|
||||||
|
cachedSnapshot: cachedSnapshot
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
if APIError.isAuthenticationExpired(error) {
|
handleBlockingRestoreFailure(
|
||||||
try? tokenStore.clear()
|
error,
|
||||||
snapshotStore.clear()
|
token: token,
|
||||||
accountContext.reset()
|
appSession: appSession,
|
||||||
permissionContext.reset()
|
accountContext: accountContext,
|
||||||
appSession.logout()
|
permissionContext: permissionContext,
|
||||||
toastCenter.show("登录状态已失效,请重新登录")
|
toastCenter: toastCenter
|
||||||
} else {
|
)
|
||||||
appSession.markLoggedIn(token: token)
|
|
||||||
toastCenter.show("网络异常,已使用本地登录状态")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,6 +99,97 @@ final class SessionBootstrapper {
|
|||||||
return snapshot
|
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) {
|
private func saveLatestSnapshot(from accountContext: AccountContext, permissionContext: PermissionContext) {
|
||||||
let existing = snapshotStore.load()
|
let existing = snapshotStore.load()
|
||||||
@ -109,5 +206,4 @@ final class SessionBootstrapper {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,32 +2,36 @@
|
|||||||
|
|
||||||
## 模块职责
|
## 模块职责
|
||||||
|
|
||||||
Splash 模块负责冷启动时的品牌启动页展示,对齐 Android `SplashActivity` + `activity_splash.xml` 的视觉规范。
|
Splash 模块负责冷启动品牌展示,与系统 Launch Screen 保持相同视觉,并在 bootstrap 完成前避免空白页或登录页闪屏。
|
||||||
|
|
||||||
该模块不处理登录、隐私协议或远程配置,只承担启动阶段的品牌展示和时序控制。
|
冷启动恢复登录态由 `SessionBootstrapper` 负责;`SplashView` 仅覆盖 bootstrap 阻塞阶段,不做固定时长品牌停留。
|
||||||
|
|
||||||
## 核心对象
|
## 核心对象
|
||||||
|
|
||||||
- `SplashView`:展示白底、180pt Logo、「随心瞰 / 商家版」文案。
|
- `SplashView`:冷启动 bootstrap 期间展示的品牌页,与 Launch Screen 布局一致。
|
||||||
- `SplashCoordinator`:并行执行 `SessionBootstrapper.restore`,bootstrap 完成后结束 Splash(无固定最短展示时长)。
|
- `SplashTypography`:启动页标题/副标题字号与 UIKit 字体,供 `SplashView` 与 `Launch Screen.storyboard` 对齐。
|
||||||
- `SplashLogo` / `LaunchBackground`:启动页图片与背景色资源,同时用于系统 `UILaunchScreen`。
|
- `SplashLogo` / `LaunchBackground`:启动页图片与背景色资源。
|
||||||
|
- `SessionBootstrapper`:冷启动恢复登录态,阻塞等待 `rolePermissions`,其余数据后台刷新。
|
||||||
|
- `AccountContextLoader.restorePermissions` / `refreshSupplementalContext`:拆分阻塞阶段与后台补充阶段。
|
||||||
|
|
||||||
## 展示规则
|
## 展示规则
|
||||||
|
|
||||||
- 背景色:白色 `#FFFFFF`
|
- 系统 Launch Screen:白底、180×180 Logo、「随心瞰 / 商家版」文案。
|
||||||
- Logo:`SplashLogo`,180×180 pt
|
- 应用内 `SplashView`:bootstrap 完成前覆盖根视图,避免 `.restoring` 空白或登录页闪现。
|
||||||
- 标题:`随心瞰`,40pt,bold + italic,`AppDesign.primary`
|
- bootstrap 结束后淡出 Splash,进入 `LoginView` 或 `MainTabsView`。
|
||||||
- 副标题:`商家版`,20pt,bold,距底 32pt
|
- 无固定最短展示时长。
|
||||||
|
|
||||||
## 启动时序
|
## 启动时序
|
||||||
|
|
||||||
1. iOS 系统 Launch Screen 展示白底 + Logo。
|
1. iOS 系统 Launch Screen 展示品牌页。
|
||||||
2. `RootView` 挂载后立即显示 `SplashView`。
|
2. `RootView` 挂载后立即显示 `SplashView` 覆盖层。
|
||||||
3. `SplashCoordinator.start` 并行执行 `SessionBootstrapper.restore` 静默恢复登录态。
|
3. `SessionBootstrapper.restore` 并行恢复登录态。
|
||||||
4. bootstrap 完成后淡出 Splash,进入 `LoginView` 或 `MainTabsView`。
|
4. 无 token:bootstrap 完成后进入 `LoginView`。
|
||||||
5. 冷启动期间不显示 Lottie 全局 Loading。
|
5. 有 token:恢复 `AccountSnapshot` → 阻塞请求 `rolePermissions` → `markLoggedIn` → 进入 `MainTabsView`。
|
||||||
|
6. `userInfo`、`scenicListAll`、`storeAll` 在后台补充刷新,不阻塞首屏。
|
||||||
|
7. 冷启动期间不显示 Lottie 全局 Loading。
|
||||||
|
|
||||||
## 与 Android 的差异
|
## 与 Android 的差异
|
||||||
|
|
||||||
- Android 首启隐私弹窗在 Splash 上;iOS 仍在登录页勾选协议。
|
- Android 首启隐私弹窗在 Splash 上;iOS 仍在登录页勾选协议。
|
||||||
- Android 系统 Launch Screen 与应用内 Splash 使用同一 XML;iOS 系统 Launch Screen 仅展示 Logo,文案由 SwiftUI Splash 补齐。
|
- Android 系统 Launch Screen 与应用内 Splash 使用同一 XML;iOS 系统 Launch Screen 与应用内 `SplashView` 保持相同布局,但应用内 Splash 仅在 bootstrap 期间展示。
|
||||||
|
|||||||
37
suixinkan/Features/Splash/SplashTypography.swift
Normal file
37
suixinkan/Features/Splash/SplashTypography.swift
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// SplashTypography.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 启动页字体规范,供 `SplashView` 与 `Launch Screen.storyboard` 对齐。
|
||||||
|
enum SplashTypography {
|
||||||
|
/// 标题字号(「随心瞰」)
|
||||||
|
static let titlePointSize: CGFloat = 40
|
||||||
|
/// 副标题字号(「商家版」)
|
||||||
|
static let subtitlePointSize: CGFloat = 20
|
||||||
|
|
||||||
|
/// 启动页标题 UIKit 字体:40pt system bold + italic。
|
||||||
|
static var titleUIFont: UIFont {
|
||||||
|
let base = UIFont.systemFont(ofSize: titlePointSize, weight: .bold)
|
||||||
|
guard let descriptor = base.fontDescriptor.withSymbolicTraits([.traitBold, .traitItalic]) else {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return UIFont(descriptor: descriptor, size: titlePointSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动页副标题 UIKit 字体:20pt system bold。
|
||||||
|
static var subtitleUIFont: UIFont {
|
||||||
|
UIFont.systemFont(ofSize: subtitlePointSize, weight: .bold)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动页标题 SwiftUI 字体。
|
||||||
|
static var titleFont: Font { Font(titleUIFont) }
|
||||||
|
|
||||||
|
/// 启动页副标题 SwiftUI 字体。
|
||||||
|
static var subtitleFont: Font { Font(subtitleUIFont) }
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// 启动页视图,对齐 Android `activity_splash.xml` 的品牌展示布局。
|
/// 启动页视图,在冷启动恢复登录态期间展示,与 Launch Screen 保持相同品牌布局。
|
||||||
struct SplashView: View {
|
struct SplashView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@ -24,15 +24,14 @@ struct SplashView: View {
|
|||||||
.accessibilityIdentifier("splash.logo")
|
.accessibilityIdentifier("splash.logo")
|
||||||
|
|
||||||
Text("随心瞰")
|
Text("随心瞰")
|
||||||
.font(.system(size: 40, weight: .bold))
|
.font(SplashTypography.titleFont)
|
||||||
.italic()
|
|
||||||
.foregroundStyle(AppDesign.primary)
|
.foregroundStyle(AppDesign.primary)
|
||||||
.accessibilityIdentifier("splash.title")
|
.accessibilityIdentifier("splash.title")
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Text("商家版")
|
Text("商家版")
|
||||||
.font(.system(size: 20, weight: .bold))
|
.font(SplashTypography.subtitleFont)
|
||||||
.foregroundStyle(AppDesign.primary)
|
.foregroundStyle(AppDesign.primary)
|
||||||
.padding(.bottom, 32)
|
.padding(.bottom, 32)
|
||||||
.accessibilityIdentifier("splash.subtitle")
|
.accessibilityIdentifier("splash.subtitle")
|
||||||
|
|||||||
@ -15,8 +15,50 @@
|
|||||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<subviews>
|
||||||
|
<stackView opaque="NO" contentMode="scaleToFill" layoutMarginsRelativeArrangement="YES" axis="vertical" alignment="center" spacing="0" translatesAutoresizingMaskIntoConstraints="NO" id="Stk-Mn-001">
|
||||||
|
<rect key="frame" x="0.0" y="59" width="393" height="759"/>
|
||||||
|
<subviews>
|
||||||
|
<view contentMode="scaleToFill" verticalHuggingPriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="Spc-Tp-001">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="393" height="200.5"/>
|
||||||
|
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
</view>
|
||||||
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="SplashLogo" translatesAutoresizingMaskIntoConstraints="NO" id="Img-Lg-001">
|
||||||
|
<rect key="frame" x="106.5" y="200.5" width="180" height="180"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstAttribute="width" constant="180" id="Img-Wd-001"/>
|
||||||
|
<constraint firstAttribute="height" constant="180" id="Img-Ht-001"/>
|
||||||
|
</constraints>
|
||||||
|
</imageView>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="随心瞰" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Lbl-Tt-001">
|
||||||
|
<rect key="frame" x="136.66666666666666" y="380.5" width="119.66666666666666" height="48"/>
|
||||||
|
<fontDescription key="fontDescription" type="system" weight="bold" pointSize="40"/>
|
||||||
|
<color key="textColor" red="0.0" green="0.45098039215686275" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
<view contentMode="scaleToFill" verticalHuggingPriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="Spc-Bt-001">
|
||||||
|
<rect key="frame" x="0.0" y="428.5" width="393" height="200.5"/>
|
||||||
|
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
</view>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="商家版" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Lbl-St-001">
|
||||||
|
<rect key="frame" x="161.66666666666666" y="629" width="69.666666666666686" height="24"/>
|
||||||
|
<fontDescription key="fontDescription" type="system" weight="bold" pointSize="20"/>
|
||||||
|
<color key="textColor" red="0.0" green="0.45098039215686275" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
</subviews>
|
||||||
|
<edgeInsets key="layoutMargins" top="0.0" left="0.0" bottom="32" right="0.0"/>
|
||||||
|
</stackView>
|
||||||
|
</subviews>
|
||||||
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
|
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
|
||||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="Stk-Mn-001" firstAttribute="top" secondItem="Bcu-3y-fUS" secondAttribute="top" id="Stk-Tp-001"/>
|
||||||
|
<constraint firstItem="Stk-Mn-001" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" id="Stk-Ld-001"/>
|
||||||
|
<constraint firstItem="Bcu-3y-fUS" firstAttribute="trailing" secondItem="Stk-Mn-001" secondAttribute="trailing" id="Stk-Tr-001"/>
|
||||||
|
<constraint firstItem="Bcu-3y-fUS" firstAttribute="bottom" secondItem="Stk-Mn-001" secondAttribute="bottom" id="Stk-Bt-001"/>
|
||||||
|
<constraint firstItem="Spc-Tp-001" firstAttribute="height" secondItem="Spc-Bt-001" secondAttribute="height" id="Spc-Eq-001"/>
|
||||||
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
</viewController>
|
</viewController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
@ -24,4 +66,7 @@
|
|||||||
<point key="canvasLocation" x="53" y="375"/>
|
<point key="canvasLocation" x="53" y="375"/>
|
||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<image name="SplashLogo" width="180" height="180"/>
|
||||||
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
|
|||||||
@ -65,6 +65,64 @@ final class AccountContextLoaderTests: XCTestCase {
|
|||||||
XCTAssertEqual(accountContext.scenicScopes.map(\.id), [88])
|
XCTAssertEqual(accountContext.scenicScopes.map(\.id), [88])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 测试仅恢复角色权限时会写入 PermissionContext,并在无快照景区时补齐作用域。
|
||||||
|
func testRestorePermissionsLoadsRolePermissionsOnly() async throws {
|
||||||
|
let accountContext = AccountContext()
|
||||||
|
let permissionContext = PermissionContext()
|
||||||
|
let api = MockAccountContextAPI()
|
||||||
|
api.roles = [
|
||||||
|
RolePermissionResponse(
|
||||||
|
role: RoleInfo(id: 7, name: "运营", permission: [PermissionItem(id: 1, name: "首页", uri: "/home")]),
|
||||||
|
scenic: [ScenicInfo(id: 88, name: "东湖景区")]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
try await AccountContextLoader().restorePermissions(
|
||||||
|
permissionContext: permissionContext,
|
||||||
|
accountContext: accountContext,
|
||||||
|
accountContextAPI: api
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(permissionContext.currentRole?.id, 7)
|
||||||
|
XCTAssertTrue(permissionContext.canAccess("/home"))
|
||||||
|
XCTAssertEqual(accountContext.scenicScopes.map(\.id), [88])
|
||||||
|
XCTAssertNil(accountContext.profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试补充刷新会更新用户资料、景区和门店。
|
||||||
|
func testRefreshSupplementalContextUpdatesProfileAndScopes() async throws {
|
||||||
|
let accountContext = AccountContext()
|
||||||
|
let permissionContext = PermissionContext()
|
||||||
|
let api = MockAccountContextAPI()
|
||||||
|
api.roles = [
|
||||||
|
RolePermissionResponse(
|
||||||
|
role: RoleInfo(id: 7, name: "运营"),
|
||||||
|
scenic: [ScenicInfo(id: 88, name: "东湖景区")]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
api.scenicResponse = ScenicListAllResponse(total: 1, list: [ScenicListItem(id: 88, name: "东湖景区")])
|
||||||
|
api.storeResponse = ListPayload(total: 1, list: [StoreItem(id: 66, scenicId: 88, name: "东湖门店")])
|
||||||
|
|
||||||
|
try await AccountContextLoader().restorePermissions(
|
||||||
|
permissionContext: permissionContext,
|
||||||
|
accountContext: accountContext,
|
||||||
|
accountContextAPI: api
|
||||||
|
)
|
||||||
|
|
||||||
|
try await AccountContextLoader().refreshSupplementalContext(
|
||||||
|
accountContext: accountContext,
|
||||||
|
permissionContext: permissionContext,
|
||||||
|
profileAPI: MockUserProfileAPI(),
|
||||||
|
accountContextAPI: api,
|
||||||
|
cachedCurrentScenicId: 88,
|
||||||
|
cachedCurrentStoreId: 66
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(accountContext.profile?.displayName, "测试用户")
|
||||||
|
XCTAssertEqual(accountContext.currentScenic?.id, 88)
|
||||||
|
XCTAssertEqual(accountContext.currentStore?.id, 66)
|
||||||
|
}
|
||||||
|
|
||||||
/// 测试门店接口失败时不阻断上下文加载。
|
/// 测试门店接口失败时不阻断上下文加载。
|
||||||
func testRefreshAllowsStoreFailure() async throws {
|
func testRefreshAllowsStoreFailure() async throws {
|
||||||
let accountContext = AccountContext()
|
let accountContext = AccountContext()
|
||||||
|
|||||||
144
suixinkanTests/SessionBootstrapperTests.swift
Normal file
144
suixinkanTests/SessionBootstrapperTests.swift
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
//
|
||||||
|
// SessionBootstrapperTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 冷启动恢复测试,覆盖阻塞权限恢复与后台补充刷新。
|
||||||
|
final class SessionBootstrapperTests: XCTestCase {
|
||||||
|
/// 测试无 token 时直接进入未登录状态。
|
||||||
|
func testRestoreWithoutTokenLogsOut() async {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
let tokenStore = SessionTokenStore(defaults: defaults, key: "test.token")
|
||||||
|
let snapshotStore = AccountSnapshotStore(defaults: defaults, key: "test.snapshot")
|
||||||
|
let bootstrapper = SessionBootstrapper(tokenStore: tokenStore, snapshotStore: snapshotStore)
|
||||||
|
let appSession = AppSession()
|
||||||
|
let accountContext = AccountContext()
|
||||||
|
let permissionContext = PermissionContext()
|
||||||
|
let toastCenter = ToastCenter()
|
||||||
|
|
||||||
|
await bootstrapper.restore(
|
||||||
|
appSession: appSession,
|
||||||
|
accountContext: accountContext,
|
||||||
|
permissionContext: permissionContext,
|
||||||
|
profileAPI: MockUserProfileAPI(),
|
||||||
|
accountContextAPI: MockAccountContextAPI(),
|
||||||
|
toastCenter: toastCenter
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(appSession.phase, .loggedOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试有 token 时阻塞等待 rolePermissions 后立即进入 loggedIn。
|
||||||
|
func testRestoreMarksLoggedInAfterRolePermissions() async {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
let tokenStore = SessionTokenStore(defaults: defaults, key: "test.token")
|
||||||
|
try? tokenStore.save("token-1")
|
||||||
|
let snapshotStore = AccountSnapshotStore(defaults: defaults, key: "test.snapshot")
|
||||||
|
let bootstrapper = SessionBootstrapper(tokenStore: tokenStore, snapshotStore: snapshotStore)
|
||||||
|
let appSession = AppSession()
|
||||||
|
let accountContext = AccountContext()
|
||||||
|
let permissionContext = PermissionContext()
|
||||||
|
let toastCenter = ToastCenter()
|
||||||
|
let api = MockAccountContextAPI()
|
||||||
|
api.roles = [
|
||||||
|
RolePermissionResponse(
|
||||||
|
role: RoleInfo(id: 7, name: "运营", permission: [PermissionItem(id: 1, name: "首页", uri: "/home")]),
|
||||||
|
scenic: [ScenicInfo(id: 88, name: "东湖景区")]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
await bootstrapper.restore(
|
||||||
|
appSession: appSession,
|
||||||
|
accountContext: accountContext,
|
||||||
|
permissionContext: permissionContext,
|
||||||
|
profileAPI: MockUserProfileAPI(),
|
||||||
|
accountContextAPI: api,
|
||||||
|
toastCenter: toastCenter
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(appSession.phase, .loggedIn)
|
||||||
|
XCTAssertEqual(permissionContext.currentRole?.id, 7)
|
||||||
|
XCTAssertTrue(permissionContext.canAccess("/home"))
|
||||||
|
|
||||||
|
let profileExpectation = expectation(description: "profile loaded in background")
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
|
if accountContext.profile?.displayName == "测试用户" {
|
||||||
|
profileExpectation.fulfill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fulfillment(of: [profileExpectation], timeout: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 rolePermissions 鉴权失败时会清空本地登录态。
|
||||||
|
func testRestoreLogsOutWhenRolePermissionsExpired() async {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
let tokenStore = SessionTokenStore(defaults: defaults, key: "test.token")
|
||||||
|
try? tokenStore.save("token-1")
|
||||||
|
let snapshotStore = AccountSnapshotStore(defaults: defaults, key: "test.snapshot")
|
||||||
|
snapshotStore.save(AccountSnapshot(profile: AccountProfile(userId: "1", displayName: "本地用户")))
|
||||||
|
let bootstrapper = SessionBootstrapper(tokenStore: tokenStore, snapshotStore: snapshotStore)
|
||||||
|
let appSession = AppSession()
|
||||||
|
let accountContext = AccountContext()
|
||||||
|
let permissionContext = PermissionContext()
|
||||||
|
let toastCenter = ToastCenter()
|
||||||
|
let api = MockAccountContextAPI()
|
||||||
|
api.roleError = APIError.httpStatus(401, "登录状态已失效")
|
||||||
|
|
||||||
|
await bootstrapper.restore(
|
||||||
|
appSession: appSession,
|
||||||
|
accountContext: accountContext,
|
||||||
|
permissionContext: permissionContext,
|
||||||
|
profileAPI: MockUserProfileAPI(),
|
||||||
|
accountContextAPI: api,
|
||||||
|
toastCenter: toastCenter
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(appSession.phase, .loggedOut)
|
||||||
|
XCTAssertNil(tokenStore.load())
|
||||||
|
XCTAssertNil(snapshotStore.load())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeDefaults() -> UserDefaults {
|
||||||
|
let suiteName = "SessionBootstrapperTests.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class MockUserProfileAPI: UserProfileServing {
|
||||||
|
func userInfo() async throws -> UserInfoResponse {
|
||||||
|
try await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
return UserInfoResponse(nickname: "测试用户", roleName: "运营")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class MockAccountContextAPI: AccountContextServing {
|
||||||
|
var roles: [RolePermissionResponse] = []
|
||||||
|
var roleError: Error?
|
||||||
|
|
||||||
|
func rolePermissions() async throws -> [RolePermissionResponse] {
|
||||||
|
if let roleError { throw roleError }
|
||||||
|
return roles
|
||||||
|
}
|
||||||
|
|
||||||
|
func scenicListAll() async throws -> ScenicListAllResponse {
|
||||||
|
ScenicListAllResponse(total: 0, list: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeAll() async throws -> ListPayload<StoreItem> {
|
||||||
|
ListPayload(total: 0, list: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem> {
|
||||||
|
ListPayload(total: 0, list: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,81 +0,0 @@
|
|||||||
//
|
|
||||||
// SplashCoordinatorTests.swift
|
|
||||||
// suixinkanTests
|
|
||||||
//
|
|
||||||
// Created by Codex on 2026/6/29.
|
|
||||||
//
|
|
||||||
|
|
||||||
import XCTest
|
|
||||||
@testable import suixinkan
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
/// 启动页协调器测试,覆盖可选最短展示时长、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)
|
|
||||||
let startedAt = Date()
|
|
||||||
|
|
||||||
await coordinator.start {
|
|
||||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssertTrue(coordinator.isFinished)
|
|
||||||
XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(startedAt), 0.19)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试 bootstrap 耗时超过最短展示时长时,以 bootstrap 完成时间为准。
|
|
||||||
func testStartWaitsForSlowBootstrap() async {
|
|
||||||
let coordinator = SplashCoordinator(minimumDisplayDuration: 0.05, skipsMinimumDuration: false)
|
|
||||||
let startedAt = Date()
|
|
||||||
|
|
||||||
await coordinator.start {
|
|
||||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssertTrue(coordinator.isFinished)
|
|
||||||
XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(startedAt), 0.14)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试 UI Test 模式会跳过最短展示时长。
|
|
||||||
func testStartSkipsMinimumDurationWhenRequested() async {
|
|
||||||
let coordinator = SplashCoordinator(minimumDisplayDuration: 1.0, skipsMinimumDuration: true)
|
|
||||||
let startedAt = Date()
|
|
||||||
|
|
||||||
await coordinator.start {
|
|
||||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssertTrue(coordinator.isFinished)
|
|
||||||
XCTAssertLessThan(Date().timeIntervalSince(startedAt), 0.2)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试重复调用 start 不会重置已完成状态。
|
|
||||||
func testStartIsIdempotentAfterFinished() async {
|
|
||||||
let coordinator = SplashCoordinator(minimumDisplayDuration: 0, skipsMinimumDuration: true)
|
|
||||||
var bootstrapCount = 0
|
|
||||||
|
|
||||||
await coordinator.start {
|
|
||||||
bootstrapCount += 1
|
|
||||||
}
|
|
||||||
await coordinator.start {
|
|
||||||
bootstrapCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssertTrue(coordinator.isFinished)
|
|
||||||
XCTAssertEqual(bootstrapCount, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
26
suixinkanTests/SplashTypographyTests.swift
Normal file
26
suixinkanTests/SplashTypographyTests.swift
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
//
|
||||||
|
// SplashTypographyTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// 启动页字体规范测试,确保 Storyboard 与 SwiftUI 使用同一套字号与字重。
|
||||||
|
final class SplashTypographyTests: XCTestCase {
|
||||||
|
/// 标题应使用 40pt system bold。
|
||||||
|
func testTitleFontUsesExpectedPointSizeAndWeight() {
|
||||||
|
let font = SplashTypography.titleUIFont
|
||||||
|
XCTAssertEqual(font.pointSize, SplashTypography.titlePointSize, accuracy: 0.01)
|
||||||
|
XCTAssertTrue(font.fontDescriptor.symbolicTraits.contains(.traitBold))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 副标题应使用 20pt system bold。
|
||||||
|
func testSubtitleFontUsesExpectedPointSizeAndWeight() {
|
||||||
|
let font = SplashTypography.subtitleUIFont
|
||||||
|
XCTAssertEqual(font.pointSize, SplashTypography.subtitlePointSize, accuracy: 0.01)
|
||||||
|
XCTAssertTrue(font.fontDescriptor.symbolicTraits.contains(.traitBold))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -43,21 +43,26 @@ final class SuixinkanApp {
|
|||||||
hasLaunched = true
|
hasLaunched = true
|
||||||
}
|
}
|
||||||
dismissSystemAlertsIfNeeded()
|
dismissSystemAlertsIfNeeded()
|
||||||
waitForSplashToDisappearIfNeeded()
|
waitForInitialScreen()
|
||||||
if waitForLoading {
|
if waitForLoading {
|
||||||
waitForGlobalLoadingToDisappear()
|
waitForGlobalLoadingToDisappear()
|
||||||
}
|
}
|
||||||
return self
|
return self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 等待启动页消失后再继续后续断言。
|
/// 等待冷启动后进入登录页或主 Tab。
|
||||||
func waitForSplashToDisappearIfNeeded(timeout: TimeInterval = 5) {
|
func waitForInitialScreen(timeout: TimeInterval = 45) {
|
||||||
let splash = application.otherElements["splash.root"]
|
if application.staticTexts["login.title"].waitForExistence(timeout: 2) {
|
||||||
guard splash.waitForExistence(timeout: 2) else { return }
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let predicate = NSPredicate(format: "exists == false")
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: splash)
|
while Date() < deadline {
|
||||||
_ = XCTWaiter.wait(for: [expectation], timeout: timeout)
|
if isOnMainTabs {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 等待全局 Loading 层消失。
|
/// 等待全局 Loading 层消失。
|
||||||
@ -91,19 +96,4 @@ final class SuixinkanApp {
|
|||||||
var isOnMainTabs: Bool {
|
var isOnMainTabs: Bool {
|
||||||
TabBarNavigator.isVisible(in: self)
|
TabBarNavigator.isVisible(in: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 等待冷启动后进入登录页或主 Tab。
|
|
||||||
func waitForInitialScreen(timeout: TimeInterval = 45) {
|
|
||||||
if application.staticTexts["login.title"].waitForExistence(timeout: 2) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
|
||||||
while Date() < deadline {
|
|
||||||
if isOnMainTabs {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user