同步 Android 启动页到 iOS,并在冷启动期间静默恢复登录态。
新增 SplashView、SplashCoordinator 与 Launch Screen 资源,移除冷启动 Lottie;LoginView 加载 App 配置。同时将 token 存储迁移至 UserDefaults,并更新相关测试与文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -28,20 +28,34 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
||||
|
||||
1. `suixinkanApp` 创建 `RootView`。
|
||||
2. `RootView` 初始化共享依赖,并把它们注入 Environment。
|
||||
3. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。
|
||||
4. `SessionBootstrapper.restore` 尝试从 Keychain 读取正式 token。
|
||||
5. 无 token 时保持 `loggedOut`,展示 `LoginView`。
|
||||
6. 有 token 时进入 `restoring`,先恢复本地账号快照。
|
||||
7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。
|
||||
8. 校验成功后进入 `loggedIn`,展示 `MainTabsView`。
|
||||
9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
|
||||
10. 明确 token 失效时清空 token 和账号快照,回到登录页。
|
||||
11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
|
||||
3. iOS 系统 Launch Screen 展示白底 + `SplashLogo`。
|
||||
4. `SplashView` 覆盖根视图,展示与 Android 对齐的品牌启动页。
|
||||
5. `SplashCoordinator.start` 并行执行最短 1.5 秒展示和 `SessionBootstrapper.restore`;UI Test 模式跳过 1.5 秒延迟。
|
||||
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 全局 Loading;Splash 即启动等待层。
|
||||
16. `LoginView` 首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默。
|
||||
|
||||
## 初始化分层
|
||||
|
||||
| 阶段 | 职责 | 主要对象 |
|
||||
|------|------|----------|
|
||||
| App 入口 | UI Test 状态清理、后续可扩展 SDK 初始化 | `suixinkanApp`、`AppUITestLaunchState` |
|
||||
| Splash | 品牌展示 + 登录态恢复 | `SplashView`、`SplashCoordinator`、`SessionBootstrapper` |
|
||||
| 首屏出现后 | 页面级远程配置 | `LoginView`、`AppConfigAPI` |
|
||||
| 登录后 | 推送、景点列表、排队 WebSocket | `PushNotificationManager`、`ScenicSpotContext` |
|
||||
|
||||
## UI Test 启动约定
|
||||
|
||||
- `AppUITestLaunchState` 在收到 `-suixinkan-ui-tests` 时跳过推送注册和排队 WebSocket,避免系统弹窗干扰自动化。
|
||||
- `-suixinkan-ui-tests-reset-state` 用于冷启动清理 Keychain 与 UserDefaults。
|
||||
- `-suixinkan-ui-tests-reset-state` 用于冷启动清理 UserDefaults 中的 token、账号快照和偏好。
|
||||
- `-suixinkan-ui-tests-open-menu <菜单标题>` 登录后直达首页调试目录中的目标页。
|
||||
- `-suixinkan-ui-tests-open-profile <路由名>` 登录后直达个人中心二级页(如 `settings`、`realNameAuth`)。
|
||||
- `AppUITestRouteDriver` 仅在 DEBUG 构建下解析上述直达参数,供 XCUITest 逐页验证。
|
||||
@ -58,7 +72,7 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
||||
- `AppSession` 切换为 `loggedIn`。
|
||||
|
||||
退出登录由 `AuthSessionCoordinator.logout` 统一处理:
|
||||
- 清空 Keychain token。
|
||||
- 清空本地 token。
|
||||
- 清空账号快照。
|
||||
- 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。
|
||||
- `AppSession` 切换为 `loggedOut`。
|
||||
|
||||
@ -45,6 +45,10 @@ private struct AuthAPIEnvironmentKey: EnvironmentKey {
|
||||
static var defaultValue: AuthAPI { AuthAPI(client: EnvironmentServiceDefaults.apiClient()) }
|
||||
}
|
||||
|
||||
private struct AppConfigAPIEnvironmentKey: EnvironmentKey {
|
||||
static var defaultValue: AppConfigAPI { AppConfigAPI(client: EnvironmentServiceDefaults.apiClient()) }
|
||||
}
|
||||
|
||||
private struct InviteAPIEnvironmentKey: EnvironmentKey {
|
||||
static var defaultValue: InviteAPI { InviteAPI(client: EnvironmentServiceDefaults.apiClient()) }
|
||||
}
|
||||
@ -168,6 +172,11 @@ extension EnvironmentValues {
|
||||
set { self[AuthAPIEnvironmentKey.self] = newValue }
|
||||
}
|
||||
|
||||
var appConfigAPI: AppConfigAPI {
|
||||
get { self[AppConfigAPIEnvironmentKey.self] }
|
||||
set { self[AppConfigAPIEnvironmentKey.self] = newValue }
|
||||
}
|
||||
|
||||
var inviteAPI: InviteAPI {
|
||||
get { self[InviteAPIEnvironmentKey.self] }
|
||||
set { self[InviteAPIEnvironmentKey.self] = newValue }
|
||||
|
||||
@ -47,6 +47,8 @@ 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 appConfigAPI: AppConfigAPI
|
||||
|
||||
/// 初始化网络客户端和业务 API,确保它们共享同一个 token provider。
|
||||
init() {
|
||||
@ -56,6 +58,7 @@ struct RootView: View {
|
||||
_snapshotStore = State(initialValue: snapshotStore)
|
||||
_apiClient = State(initialValue: apiClient)
|
||||
_authAPI = State(initialValue: AuthAPI(client: apiClient))
|
||||
_appConfigAPI = State(initialValue: AppConfigAPI(client: apiClient))
|
||||
_profileAPI = State(initialValue: ProfileAPI(client: apiClient))
|
||||
let uploadAPI = UploadAPI(client: apiClient)
|
||||
_uploadAPI = State(initialValue: uploadAPI)
|
||||
@ -97,7 +100,15 @@ struct RootView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
rootContent
|
||||
ZStack {
|
||||
rootContent
|
||||
|
||||
if !splashCoordinator.isFinished {
|
||||
SplashView()
|
||||
.transition(.opacity)
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
.globalToastOverlay()
|
||||
.globalLoadingOverlay(loadingCenter: globalLoading)
|
||||
.environmentObject(appSession)
|
||||
@ -110,6 +121,7 @@ struct RootView: View {
|
||||
.environment(\.accountSnapshotStore, snapshotStore)
|
||||
.environment(\.apiClient, apiClient)
|
||||
.environment(\.authAPI, authAPI)
|
||||
.environment(\.appConfigAPI, appConfigAPI)
|
||||
.environment(\.profileAPI, profileAPI)
|
||||
.environment(\.uploadAPI, uploadAPI)
|
||||
.environment(\.ossUploadService, ossUploadService)
|
||||
@ -139,7 +151,7 @@ struct RootView: View {
|
||||
.task {
|
||||
apiClient.bindAuthTokenProvider { appSession.token }
|
||||
PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter)
|
||||
await globalLoading.withLoading {
|
||||
await splashCoordinator.start {
|
||||
await sessionBootstrapper.restore(
|
||||
appSession: appSession,
|
||||
accountContext: accountContext,
|
||||
|
||||
45
suixinkan/App/SplashCoordinator.swift
Normal file
45
suixinkan/App/SplashCoordinator.swift
Normal file
@ -0,0 +1,45 @@
|
||||
//
|
||||
// SplashCoordinator.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 启动页时序协调器,保证品牌页最短展示时长与冷启动 bootstrap 并行完成。
|
||||
final class SplashCoordinator: ObservableObject {
|
||||
@Published private(set) var isFinished = false
|
||||
|
||||
private let minimumDisplayDuration: TimeInterval
|
||||
private let skipsMinimumDuration: Bool
|
||||
|
||||
/// 创建启动页协调器,并配置最短展示时长与 UI Test 跳过策略。
|
||||
init(
|
||||
minimumDisplayDuration: TimeInterval = 1.5,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -43,7 +43,7 @@ final class AuthSessionCoordinator {
|
||||
)
|
||||
}
|
||||
|
||||
/// 完成正式登录,写入内存状态、Keychain token 和账号快照。
|
||||
/// 完成正式登录,写入内存状态、本地 token 和账号快照。
|
||||
func completeLogin(
|
||||
with response: V9AuthResponse,
|
||||
username: String,
|
||||
|
||||
Reference in New Issue
Block a user