优化冷启动流程: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

@ -10,13 +10,13 @@
App 启动链路为:
**`suixinkanApp``RootView``SplashView` 覆盖层 + 并行 bootstrap → 按 `AppSession.phase` 展示 `LoginView``MainTabsView`**
**`suixinkanApp``RootView`阻塞 `rolePermissions` + 后台补充 bootstrap → 按 `AppSession.phase` 展示 `LoginView``MainTabsView`**
用户感知的「启动 Loading」并非 Lottie 全局 Loading而是
1. **系统 Launch Screen**白底 + LogoiOS 原生)
2. **应用内 `SplashView`**品牌页bootstrap 完成即结束,无固定最短展示时长
3. **有 token 时的 `.restoring` 阶段**Splash 下方空白背景,等待 4 个 API 完成
1. **系统 Launch Screen**品牌页iOS 原生)
2. **`.restoring` 短暂空白**(仅等待 `rolePermissions`,完成后立即进主界面
3. **后台补充刷新**`userInfo``scenicListAll``storeAll`,不阻塞首屏
冷启动期间 **不使用** `GlobalLoadingCenter` / Lottie 动画。

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,7 +155,6 @@ 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,
@ -164,6 +163,8 @@ struct RootView: View {
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 {
)
)
}
}

View File

@ -2,32 +2,36 @@
## 模块职责
Splash 模块负责冷启动时的品牌启动页展示,对齐 Android `SplashActivity` + `activity_splash.xml` 的视觉规范
Splash 模块负责冷启动品牌展示,与系统 Launch Screen 保持相同视觉,并在 bootstrap 完成前避免空白页或登录页闪屏
该模块不处理登录、隐私协议或远程配置,只承担启动阶段的品牌展示和时序控制
冷启动恢复登录态由 `SessionBootstrapper` 负责;`SplashView` 仅覆盖 bootstrap 阻塞阶段,不做固定时长品牌停留
## 核心对象
- `SplashView`展示白底、180pt Logo、「随心瞰 / 商家版」文案
- `SplashCoordinator`:并行执行 `SessionBootstrapper.restore`bootstrap 完成后结束 Splash无固定最短展示时长
- `SplashLogo` / `LaunchBackground`:启动页图片与背景色资源,同时用于系统 `UILaunchScreen`
- `SplashView`冷启动 bootstrap 期间展示的品牌页,与 Launch Screen 布局一致
- `SplashTypography`:启动页标题/副标题字号与 UIKit 字体,供 `SplashView``Launch Screen.storyboard` 对齐
- `SplashLogo` / `LaunchBackground`:启动页图片与背景色资源。
- `SessionBootstrapper`:冷启动恢复登录态,阻塞等待 `rolePermissions`,其余数据后台刷新。
- `AccountContextLoader.restorePermissions` / `refreshSupplementalContext`:拆分阻塞阶段与后台补充阶段。
## 展示规则
- 背景色:白色 `#FFFFFF`
- Logo`SplashLogo`180×180 pt
- 标题:`随心瞰`40ptbold + italic`AppDesign.primary`
- 副标题:`商家版`20ptbold距底 32pt
- 系统 Launch Screen白底、180×180 Logo、「随心瞰 / 商家版」文案。
- 应用内 `SplashView`bootstrap 完成前覆盖根视图,避免 `.restoring` 空白或登录页闪现。
- bootstrap 结束后淡出 Splash进入 `LoginView``MainTabsView`
- 无固定最短展示时长。
## 启动时序
1. iOS 系统 Launch Screen 展示白底 + Logo
2. `RootView` 挂载后立即显示 `SplashView`
3. `SplashCoordinator.start` 并行执行 `SessionBootstrapper.restore` 静默恢复登录态。
4. bootstrap 完成后淡出 Splash进入 `LoginView``MainTabsView`
5. 冷启动期间不显示 Lottie 全局 Loading
1. iOS 系统 Launch Screen 展示品牌页
2. `RootView` 挂载后立即显示 `SplashView` 覆盖层
3. `SessionBootstrapper.restore` 并行恢复登录态。
4. 无 tokenbootstrap 完成后进入 `LoginView`
5. 有 token恢复 `AccountSnapshot` → 阻塞请求 `rolePermissions``markLoggedIn` → 进入 `MainTabsView`
6. `userInfo``scenicListAll``storeAll` 在后台补充刷新,不阻塞首屏。
7. 冷启动期间不显示 Lottie 全局 Loading。
## 与 Android 的差异
- Android 首启隐私弹窗在 Splash 上iOS 仍在登录页勾选协议。
- Android 系统 Launch Screen 与应用内 Splash 使用同一 XMLiOS 系统 Launch Screen 仅展示 Logo文案由 SwiftUI Splash 补齐
- Android 系统 Launch Screen 与应用内 Splash 使用同一 XMLiOS 系统 Launch Screen 与应用内 `SplashView` 保持相同布局,但应用内 Splash 仅在 bootstrap 期间展示

View 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) }
}

View File

@ -7,7 +7,7 @@
import SwiftUI
/// Android `activity_splash.xml`
/// Launch Screen
struct SplashView: View {
var body: some View {
ZStack {
@ -24,15 +24,14 @@ struct SplashView: View {
.accessibilityIdentifier("splash.logo")
Text("随心瞰")
.font(.system(size: 40, weight: .bold))
.italic()
.font(SplashTypography.titleFont)
.foregroundStyle(AppDesign.primary)
.accessibilityIdentifier("splash.title")
Spacer()
Text("商家版")
.font(.system(size: 20, weight: .bold))
.font(SplashTypography.subtitleFont)
.foregroundStyle(AppDesign.primary)
.padding(.bottom, 32)
.accessibilityIdentifier("splash.subtitle")

View File

@ -15,8 +15,50 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<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"/>
<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>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
@ -24,4 +66,7 @@
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="SplashLogo" width="180" height="180"/>
</resources>
</document>

View File

@ -65,6 +65,64 @@ final class AccountContextLoaderTests: XCTestCase {
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 {
let accountContext = AccountContext()

View 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: [])
}
}

View File

@ -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)
}
}

View 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))
}
}

View File

@ -43,21 +43,26 @@ final class SuixinkanApp {
hasLaunched = true
}
dismissSystemAlertsIfNeeded()
waitForSplashToDisappearIfNeeded()
waitForInitialScreen()
if waitForLoading {
waitForGlobalLoadingToDisappear()
}
return self
}
///
func waitForSplashToDisappearIfNeeded(timeout: TimeInterval = 5) {
let splash = application.otherElements["splash.root"]
guard splash.waitForExistence(timeout: 2) else { return }
/// Tab
func waitForInitialScreen(timeout: TimeInterval = 45) {
if application.staticTexts["login.title"].waitForExistence(timeout: 2) {
return
}
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: splash)
_ = XCTWaiter.wait(for: [expectation], timeout: timeout)
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if isOnMainTabs {
return
}
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
}
/// Loading
@ -91,19 +96,4 @@ final class SuixinkanApp {
var isOnMainTabs: Bool {
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))
}
}
}