同步 Android 启动页到 iOS,并在冷启动期间静默恢复登录态。

新增 SplashView、SplashCoordinator 与 Launch Screen 资源,移除冷启动 Lottie;LoginView 加载 App 配置。同时将 token 存储迁移至 UserDefaults,并更新相关测试与文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 14:59:44 +08:00
parent 08492df6ce
commit 5bdf4a7dbf
29 changed files with 501 additions and 91 deletions

View File

@ -64,11 +64,15 @@
}; };
A00000022FE9000000000002 /* suixinkanTests */ = { A00000022FE9000000000002 /* suixinkanTests */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = suixinkanTests; path = suixinkanTests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
B00000022FEF000000000002 /* suixinkanUITests */ = { B00000022FEF000000000002 /* suixinkanUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = suixinkanUITests; path = suixinkanUITests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -324,14 +328,10 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";

View File

@ -28,20 +28,34 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
1. `suixinkanApp` 创建 `RootView` 1. `suixinkanApp` 创建 `RootView`
2. `RootView` 初始化共享依赖,并把它们注入 Environment。 2. `RootView` 初始化共享依赖,并把它们注入 Environment。
3. `APIClient` 绑定 `AppSession.token` 作为默认 token provider 3. iOS 系统 Launch Screen 展示白底 + `SplashLogo`
4. `SessionBootstrapper.restore` 尝试从 Keychain 读取正式 token 4. `SplashView` 覆盖根视图,展示与 Android 对齐的品牌启动页
5. 无 token 时保持 `loggedOut`展示 `LoginView` 5. `SplashCoordinator.start` 并行执行最短 1.5 秒展示 `SessionBootstrapper.restore`UI Test 模式跳过 1.5 秒延迟
6. 有 token 时进入 `restoring`,先恢复本地账号快照 6. `APIClient` 绑定 `AppSession.token` 作为默认 token provider
7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店 7. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token
8. 校验成功后进入 `loggedIn`,展示 `MainTabsView` 8. 无 token 时保持 `loggedOut`Splash 结束后展示 `LoginView`
9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点 9. 有 token 时进入 `restoring`,先恢复本地账号快照
10. 明确 token 失效时清空 token 和账号快照,回到登录页 10. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店
11. 普通网络失败时保留本地登录态,使用账号快照进入主界面 11. 校验成功后进入 `loggedIn`Splash 结束后展示 `MainTabsView`
12. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
13. 明确 token 失效时清空 token 和账号快照,回到登录页。
14. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
15. 冷启动 bootstrap 不使用 Lottie 全局 LoadingSplash 即启动等待层。
16. `LoginView` 首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默。
## 初始化分层
| 阶段 | 职责 | 主要对象 |
|------|------|----------|
| App 入口 | UI Test 状态清理、后续可扩展 SDK 初始化 | `suixinkanApp``AppUITestLaunchState` |
| Splash | 品牌展示 + 登录态恢复 | `SplashView``SplashCoordinator``SessionBootstrapper` |
| 首屏出现后 | 页面级远程配置 | `LoginView``AppConfigAPI` |
| 登录后 | 推送、景点列表、排队 WebSocket | `PushNotificationManager``ScenicSpotContext` |
## UI Test 启动约定 ## UI Test 启动约定
- `AppUITestLaunchState` 在收到 `-suixinkan-ui-tests` 时跳过推送注册和排队 WebSocket避免系统弹窗干扰自动化。 - `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-menu <菜单标题>` 登录后直达首页调试目录中的目标页。
- `-suixinkan-ui-tests-open-profile <路由名>` 登录后直达个人中心二级页(如 `settings``realNameAuth`)。 - `-suixinkan-ui-tests-open-profile <路由名>` 登录后直达个人中心二级页(如 `settings``realNameAuth`)。
- `AppUITestRouteDriver` 仅在 DEBUG 构建下解析上述直达参数,供 XCUITest 逐页验证。 - `AppUITestRouteDriver` 仅在 DEBUG 构建下解析上述直达参数,供 XCUITest 逐页验证。
@ -58,7 +72,7 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
- `AppSession` 切换为 `loggedIn` - `AppSession` 切换为 `loggedIn`
退出登录由 `AuthSessionCoordinator.logout` 统一处理: 退出登录由 `AuthSessionCoordinator.logout` 统一处理:
- 清空 Keychain token。 - 清空本地 token。
- 清空账号快照。 - 清空账号快照。
- 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。 - 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。
- `AppSession` 切换为 `loggedOut` - `AppSession` 切换为 `loggedOut`

View File

@ -45,6 +45,10 @@ private struct AuthAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: AuthAPI { AuthAPI(client: EnvironmentServiceDefaults.apiClient()) } static var defaultValue: AuthAPI { AuthAPI(client: EnvironmentServiceDefaults.apiClient()) }
} }
private struct AppConfigAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: AppConfigAPI { AppConfigAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct InviteAPIEnvironmentKey: EnvironmentKey { private struct InviteAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: InviteAPI { InviteAPI(client: EnvironmentServiceDefaults.apiClient()) } static var defaultValue: InviteAPI { InviteAPI(client: EnvironmentServiceDefaults.apiClient()) }
} }
@ -168,6 +172,11 @@ extension EnvironmentValues {
set { self[AuthAPIEnvironmentKey.self] = newValue } set { self[AuthAPIEnvironmentKey.self] = newValue }
} }
var appConfigAPI: AppConfigAPI {
get { self[AppConfigAPIEnvironmentKey.self] }
set { self[AppConfigAPIEnvironmentKey.self] = newValue }
}
var inviteAPI: InviteAPI { var inviteAPI: InviteAPI {
get { self[InviteAPIEnvironmentKey.self] } get { self[InviteAPIEnvironmentKey.self] }
set { self[InviteAPIEnvironmentKey.self] = newValue } set { self[InviteAPIEnvironmentKey.self] = newValue }

View File

@ -47,6 +47,8 @@ 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 appConfigAPI: AppConfigAPI
/// API token provider /// API token provider
init() { init() {
@ -56,6 +58,7 @@ struct RootView: View {
_snapshotStore = State(initialValue: snapshotStore) _snapshotStore = State(initialValue: snapshotStore)
_apiClient = State(initialValue: apiClient) _apiClient = State(initialValue: apiClient)
_authAPI = State(initialValue: AuthAPI(client: apiClient)) _authAPI = State(initialValue: AuthAPI(client: apiClient))
_appConfigAPI = State(initialValue: AppConfigAPI(client: apiClient))
_profileAPI = State(initialValue: ProfileAPI(client: apiClient)) _profileAPI = State(initialValue: ProfileAPI(client: apiClient))
let uploadAPI = UploadAPI(client: apiClient) let uploadAPI = UploadAPI(client: apiClient)
_uploadAPI = State(initialValue: uploadAPI) _uploadAPI = State(initialValue: uploadAPI)
@ -97,7 +100,15 @@ struct RootView: View {
} }
var body: some View { var body: some View {
rootContent ZStack {
rootContent
if !splashCoordinator.isFinished {
SplashView()
.transition(.opacity)
.zIndex(1)
}
}
.globalToastOverlay() .globalToastOverlay()
.globalLoadingOverlay(loadingCenter: globalLoading) .globalLoadingOverlay(loadingCenter: globalLoading)
.environmentObject(appSession) .environmentObject(appSession)
@ -110,6 +121,7 @@ struct RootView: View {
.environment(\.accountSnapshotStore, snapshotStore) .environment(\.accountSnapshotStore, snapshotStore)
.environment(\.apiClient, apiClient) .environment(\.apiClient, apiClient)
.environment(\.authAPI, authAPI) .environment(\.authAPI, authAPI)
.environment(\.appConfigAPI, appConfigAPI)
.environment(\.profileAPI, profileAPI) .environment(\.profileAPI, profileAPI)
.environment(\.uploadAPI, uploadAPI) .environment(\.uploadAPI, uploadAPI)
.environment(\.ossUploadService, ossUploadService) .environment(\.ossUploadService, ossUploadService)
@ -139,7 +151,7 @@ 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 globalLoading.withLoading { await splashCoordinator.start {
await sessionBootstrapper.restore( await sessionBootstrapper.restore(
appSession: appSession, appSession: appSession,
accountContext: accountContext, accountContext: accountContext,

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

View File

@ -43,7 +43,7 @@ final class AuthSessionCoordinator {
) )
} }
/// Keychain token /// token
func completeLogin( func completeLogin(
with response: V9AuthResponse, with response: V9AuthResponse,
username: String, username: String,

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "icon_splash.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -42,12 +42,12 @@ Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存
## Storage ## Storage
本地缓存按安全级别拆分: 本地缓存按安全级别拆分:
- `SessionTokenStore`:使用 Keychain 保存正式 token。 - `SessionTokenStore`:使用 UserDefaults 保存正式 token。
- `AccountSnapshotStore`:使用 UserDefaults 保存非敏感账号快照。 - `AccountSnapshotStore`:使用 UserDefaults 保存非敏感账号快照。
- `AppPreferencesStore`:使用 UserDefaults 保存上次手机号、协议同意状态等偏好。 - `AppPreferencesStore`:使用 UserDefaults 保存上次手机号、协议同意状态等偏好。
缓存边界: 缓存边界:
- 正式 token 只放 Keychain - 正式 token `SessionTokenStore` 写入 UserDefaults
- 临时 token 只放内存。 - 临时 token 只放内存。
- 密码、验证码、OSS STS token、一次性扫码结果和错误提示不落盘。 - 密码、验证码、OSS STS token、一次性扫码结果和错误提示不落盘。
- 头像、证件照等图片缓存交给 KingfisherCore 不保存图片 Data。 - 头像、证件照等图片缓存交给 KingfisherCore 不保存图片 Data。
@ -86,7 +86,7 @@ Toast UI 是顶部全宽横幅,背景使用不透明主色并延伸到顶部
`UploadAPI` 通过 `/api/app/config/get-sts-token` 获取阿里云 OSS 临时上传配置。`OSSUploadService` 负责校验文件、生成 objectKey、调用 `AlibabaCloudOSS` SDK 并返回最终文件 URL。 `UploadAPI` 通过 `/api/app/config/get-sts-token` 获取阿里云 OSS 临时上传配置。`OSSUploadService` 负责校验文件、生成 objectKey、调用 `AlibabaCloudOSS` SDK 并返回最终文件 URL。
上传模块只保存内存状态: 上传模块只保存内存状态:
- STS token 不写入 Keychain 或 UserDefaults。 - STS token 不写入 UserDefaults。
- 用户选择的本地图片 Data 不落盘。 - 用户选择的本地图片 Data 不落盘。
- 上传进度只用于当前页面展示。 - 上传进度只用于当前页面展示。

View File

@ -6,33 +6,21 @@
// //
import Foundation import Foundation
import Combine
import Security
/// token Keychain /// token UserDefaults
enum SessionTokenStoreError: LocalizedError {
case unexpectedStatus(OSStatus)
var errorDescription: String? {
switch self {
case let .unexpectedStatus(status):
"登录凭证存储失败(\(status)"
}
}
}
/// token Keychain API
final class SessionTokenStore { final class SessionTokenStore {
private let service: String private let defaults: UserDefaults
private let account: String private let key: String
/// token App Bundle Keychain /// token App Bundle UserDefaults
init( init(
defaults: UserDefaults = .standard,
key: String? = nil,
service: String = Bundle.main.bundleIdentifier ?? "com.yuanzhixiang.suixinkan", service: String = Bundle.main.bundleIdentifier ?? "com.yuanzhixiang.suixinkan",
account: String = "session.token" account: String = "session.token"
) { ) {
self.service = service self.defaults = defaults
self.account = account self.key = key ?? "suixinkan.session.token.\(service).\(account)"
} }
/// token token /// token token
@ -43,38 +31,12 @@ final class SessionTokenStore {
return return
} }
let data = Data(trimmedToken.utf8) defaults.set(trimmedToken, forKey: key)
let query = baseQuery()
let updateAttributes: [String: Any] = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary)
if updateStatus == errSecSuccess {
return
}
guard updateStatus == errSecItemNotFound else {
throw SessionTokenStoreError.unexpectedStatus(updateStatus)
}
var addQuery = query
updateAttributes.forEach { addQuery[$0.key] = $0.value }
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw SessionTokenStoreError.unexpectedStatus(addStatus)
}
} }
/// token nil /// token nil
func load() -> String? { func load() -> String? {
var query = baseQuery() guard let token = defaults.string(forKey: key)?
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let token = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines), .trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty else { !token.isEmpty else {
return nil return nil
@ -84,18 +46,6 @@ final class SessionTokenStore {
/// token /// token
func clear() throws { func clear() throws {
let status = SecItemDelete(baseQuery() as CFDictionary) defaults.removeObject(forKey: key)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw SessionTokenStoreError.unexpectedStatus(status)
}
}
/// App 使 Keychain
private func baseQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
} }
} }

View File

@ -49,4 +49,4 @@ Upload 模块负责 App 内通用文件上传能力,当前主要服务个人
- OSS STS token 不落盘,只在一次上传流程中临时使用。 - OSS STS token 不落盘,只在一次上传流程中临时使用。
- 原始图片 Data、压缩后图片 Data、上传进度不落盘。 - 原始图片 Data、压缩后图片 Data、上传进度不落盘。
- 图片展示缓存交给 Kingfisher业务代码不自行保存网络图片文件。 - 图片展示缓存交给 Kingfisher业务代码不自行保存网络图片文件。
- 正式登录 token 仍由 `SessionTokenStore` 使用 Keychain 保存,上传模块不直接读取或保存登录态。 - 正式登录 token 仍由 `SessionTokenStore` 使用 UserDefaults 保存,上传模块不直接读取或保存登录态。

View File

@ -0,0 +1,26 @@
//
// AppConfigAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
@MainActor
/// App API `/api/app/config`
final class AppConfigAPI {
private let client: APIClient
/// App API
init(client: APIClient) {
self.client = client
}
/// App
func fetchAppConfig() async throws -> AppConfigResponse {
try await client.send(
APIRequest(method: .get, path: "/api/app/config")
)
}
}

View File

@ -56,7 +56,7 @@ Auth 模块负责登录页、手机号密码登录、多账号选择和账号选
## 缓存规则 ## 缓存规则
Auth 模块不直接写缓存。登录成功后的缓存由 `AuthSessionCoordinator` 负责: Auth 模块不直接写缓存。登录成功后的缓存由 `AuthSessionCoordinator` 负责:
- 正式 token 写 Keychain - 正式 token 写 UserDefaults
- 上次手机号和协议状态写 UserDefaults。 - 上次手机号和协议状态写 UserDefaults。
- 账号资料、当前角色和业务作用域写入账号快照。 - 账号资料、当前角色和业务作用域写入账号快照。

View File

@ -0,0 +1,22 @@
//
// AppConfigModels.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
/// App Android `AppConfigResponse`
struct AppConfigResponse: Decodable, Equatable {
let enableRegister: Bool
enum CodingKeys: String, CodingKey {
case enableRegister = "enable_register"
}
/// App
init(enableRegister: Bool = false) {
self.enableRegister = enableRegister
}
}

View File

@ -78,6 +78,7 @@ final class LoginViewModel: ObservableObject {
@Published var showsPassword = false @Published var showsPassword = false
@Published var showsAgreementSheet = false @Published var showsAgreementSheet = false
@Published var pendingAccountSelection: AccountSelectionPayload? @Published var pendingAccountSelection: AccountSelectionPayload?
@Published private(set) var enableRegister = false
var canSubmit: Bool { var canSubmit: Bool {
isValidPhone && !trimmedPassword.isEmpty isValidPhone && !trimmedPassword.isEmpty
@ -124,6 +125,16 @@ final class LoginViewModel: ObservableObject {
showsAgreementSheet = false showsAgreementSheet = false
} }
/// App Android
func loadAppConfig(appConfigAPI: AppConfigAPI) async {
do {
let config = try await appConfigAPI.fetchAppConfig()
enableRegister = config.enableRegister
} catch {
// false
}
}
/// +86 11 /// +86 11
func normalizeUsernameCountryCodeIfNeeded() { func normalizeUsernameCountryCodeIfNeeded() {
let digits = username.filter(\.isNumber) let digits = username.filter(\.isNumber)

View File

@ -14,6 +14,7 @@ struct LoginView: View {
@EnvironmentObject private var permissionContext: PermissionContext @EnvironmentObject private var permissionContext: PermissionContext
@EnvironmentObject private var toastCenter: ToastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.authAPI) private var authAPI @Environment(\.authAPI) private var authAPI
@Environment(\.appConfigAPI) private var appConfigAPI
@Environment(\.profileAPI) private var profileAPI @Environment(\.profileAPI) private var profileAPI
@Environment(\.accountContextAPI) private var accountContextAPI @Environment(\.accountContextAPI) private var accountContextAPI
@Environment(\.authSessionCoordinator) private var authSessionCoordinator @Environment(\.authSessionCoordinator) private var authSessionCoordinator
@ -85,6 +86,7 @@ struct LoginView: View {
} }
.task { .task {
viewModel.applyPreferences(authSessionCoordinator.loginPreferences()) viewModel.applyPreferences(authSessionCoordinator.loginPreferences())
await viewModel.loadAppConfig(appConfigAPI: appConfigAPI)
} }
} }

View File

@ -0,0 +1,35 @@
# Splash 模块业务逻辑
## 模块职责
Splash 模块负责冷启动时的品牌启动页展示,对齐 Android `SplashActivity` + `activity_splash.xml` 的视觉规范。
该模块不处理登录、隐私协议或远程配置,只承担启动阶段的品牌展示和时序控制。
## 核心对象
- `SplashView`展示白底、180pt Logo、「随心瞰 / 商家版」文案。
- `SplashCoordinator`:并行执行最短 1.5 秒展示与 `SessionBootstrapper.restore`,两者都完成后结束 Splash。
- `SplashLogo` / `LaunchBackground`:启动页图片与背景色资源,同时用于系统 `UILaunchScreen`
## 展示规则
- 背景色:白色 `#FFFFFF`
- Logo`SplashLogo`180×180 pt
- 标题:`随心瞰`40ptbold + italic`AppDesign.primary`
- 副标题:`商家版`20ptbold距底 32pt
## 启动时序
1. iOS 系统 Launch Screen 展示白底 + Logo。
2. `RootView` 挂载后立即显示 `SplashView`
3. `SplashCoordinator.start` 并行执行:
- 最短展示 1.5 秒UI Test 模式跳过)
- `SessionBootstrapper.restore` 静默恢复登录态
4. 两者都完成后淡出 Splash进入 `LoginView``MainTabsView`
5. 冷启动期间不显示 Lottie 全局 Loading。
## 与 Android 的差异
- Android 首启隐私弹窗在 Splash 上iOS 仍在登录页勾选协议。
- Android 系统 Launch Screen 与应用内 Splash 使用同一 XMLiOS 系统 Launch Screen 仅展示 Logo文案由 SwiftUI Splash 补齐。

View File

@ -0,0 +1,47 @@
//
// SplashView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
/// Android `activity_splash.xml`
struct SplashView: View {
var body: some View {
ZStack {
Color.white
.ignoresSafeArea()
VStack(spacing: 0) {
Spacer()
Image("SplashLogo")
.resizable()
.scaledToFit()
.frame(width: 180, height: 180)
.accessibilityIdentifier("splash.logo")
Text("随心瞰")
.font(.system(size: 40, weight: .bold))
.italic()
.foregroundStyle(AppDesign.primary)
.accessibilityIdentifier("splash.title")
Spacer()
Text("商家版")
.font(.system(size: 20, weight: .bold))
.foregroundStyle(AppDesign.primary)
.padding(.bottom, 32)
.accessibilityIdentifier("splash.subtitle")
}
}
.accessibilityIdentifier("splash.root")
}
}
#Preview {
SplashView()
}

View File

@ -42,7 +42,14 @@
<string>remote-notification</string> <string>remote-notification</string>
</array> </array>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
<dict/> <dict>
<key>UIColorName</key>
<string>LaunchBackground</string>
<key>UIImageName</key>
<string>SplashLogo</string>
<key>UIImageRespectsSafeAreaInsets</key>
<true/>
</dict>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>

View File

@ -0,0 +1,87 @@
//
// AppConfigTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/29.
//
import XCTest
@testable import suixinkan
@MainActor
/// App API
final class AppConfigTests: XCTestCase {
/// App 使 enable_register
func testAppConfigAPIRequestsConfigEndpoint() async throws {
let response = """
{
"code": 100000,
"msg": "success",
"data": {
"enable_register": true
}
}
""".data(using: .utf8)!
let session = AppConfigURLSession(responses: [response])
let api = AppConfigAPI(client: APIClient(session: session))
let config = try await api.fetchAppConfig()
XCTAssertTrue(config.enableRegister)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/app/config")
}
/// App enableRegister
func testLoginViewModelLoadAppConfigUpdatesEnableRegister() async throws {
let response = """
{
"code": 100000,
"msg": "success",
"data": {
"enable_register": true
}
}
""".data(using: .utf8)!
let session = AppConfigURLSession(responses: [response])
let api = AppConfigAPI(client: APIClient(session: session))
let viewModel = LoginViewModel()
await viewModel.loadAppConfig(appConfigAPI: api)
XCTAssertTrue(viewModel.enableRegister)
}
/// App
func testLoginViewModelLoadAppConfigKeepsDefaultOnFailure() async {
let session = AppConfigURLSession(responses: [Data()])
let api = AppConfigAPI(client: APIClient(session: session))
let viewModel = LoginViewModel()
await viewModel.loadAppConfig(appConfigAPI: api)
XCTAssertFalse(viewModel.enableRegister)
}
}
/// App URLSession
private final class AppConfigURLSession: URLSessionProtocol {
private var responses: [Data]
private(set) var requests: [URLRequest] = []
///
init(responses: [Data]) {
self.responses = responses
}
///
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
requests.append(request)
let data = responses.isEmpty ? Data() : responses.removeFirst()
return (
data,
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
)
}
}

View File

@ -191,7 +191,7 @@ final class ProfileSecondaryPagesTests: XCTestCase {
/// ///
func testApplyCachedProfileIgnoredAfterUserInfoLoaded() { func testApplyCachedProfileIgnoredAfterUserInfoLoaded() {
let viewModel = ProfileViewModel() let viewModel = ProfileViewModel()
viewModel.userInfo = UserInfoResponse(nickname: "接口昵称", phone: "13900000000") viewModel.userInfo = UserInfoResponse(phone: "13900000000", nickname: "接口昵称")
viewModel.applyCachedProfile( viewModel.applyCachedProfile(
AccountProfile(userId: "101", displayName: "缓存昵称", phone: "13800000000") AccountProfile(userId: "101", displayName: "缓存昵称", phone: "13800000000")
) )

View File

@ -0,0 +1,68 @@
//
// SplashCoordinatorTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/29.
//
import XCTest
@testable import suixinkan
@MainActor
/// bootstrap UI Test
final class SplashCoordinatorTests: XCTestCase {
/// 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

@ -10,6 +10,29 @@ import XCTest
/// Store UserDefaults /// Store UserDefaults
final class StorageTests: XCTestCase { final class StorageTests: XCTestCase {
/// SessionTokenStore 使 UserDefaults token
func testSessionTokenStorePersistsTokenInUserDefaults() throws {
let defaults = makeIsolatedDefaults()
let store = SessionTokenStore(defaults: defaults, key: "session.token.test")
try store.save(" token-123 ")
XCTAssertEqual(store.load(), "token-123")
try store.clear()
XCTAssertNil(store.load())
}
/// token token
func testSessionTokenStoreClearsWhenSavingBlankToken() throws {
let defaults = makeIsolatedDefaults()
let store = SessionTokenStore(defaults: defaults, key: "session.token.blank.test")
try store.save("token-123")
try store.save(" \n")
XCTAssertNil(store.load())
}
/// AppPreferencesStore /// AppPreferencesStore
func testAppPreferencesStorePersistsLoginPreferences() { func testAppPreferencesStorePersistsLoginPreferences() {
let defaults = makeIsolatedDefaults() let defaults = makeIsolatedDefaults()

View File

@ -2,7 +2,7 @@
// ZLoginSmokeUITests.swift // ZLoginSmokeUITests.swift
// suixinkanUITests // suixinkanUITests
// //
// Z Keychain // Z UserDefaults
// //
import XCTest import XCTest

View File

@ -57,7 +57,7 @@ xcodebuild test \
UI Test 进程会自动传入: UI Test 进程会自动传入:
- `-suixinkan-ui-tests`主开关App 侧跳过推送注册与排队 WebSocket - `-suixinkan-ui-tests`主开关App 侧跳过推送注册与排队 WebSocket
- `-suixinkan-ui-tests-reset-state`:冷启动清理 Keychain / UserDefaults - `-suixinkan-ui-tests-reset-state`:冷启动清理 UserDefaults 中的 token、账号快照和偏好
- `-suixinkan-ui-tests-open-menu <菜单标题>`:登录后直达首页调试目录中的页面 - `-suixinkan-ui-tests-open-menu <菜单标题>`:登录后直达首页调试目录中的页面
- `-suixinkan-ui-tests-open-profile <路由名>`:登录后直达个人中心二级页(如 `settings` - `-suixinkan-ui-tests-open-profile <路由名>`:登录后直达个人中心二级页(如 `settings`

View File

@ -43,12 +43,23 @@ final class SuixinkanApp {
hasLaunched = true hasLaunched = true
} }
dismissSystemAlertsIfNeeded() dismissSystemAlertsIfNeeded()
waitForSplashToDisappearIfNeeded()
if waitForLoading { if waitForLoading {
waitForGlobalLoadingToDisappear() waitForGlobalLoadingToDisappear()
} }
return self return self
} }
///
func waitForSplashToDisappearIfNeeded(timeout: TimeInterval = 5) {
let splash = application.otherElements["splash.root"]
guard splash.waitForExistence(timeout: 2) else { return }
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: splash)
_ = XCTWaiter.wait(for: [expectation], timeout: timeout)
}
/// Loading /// Loading
func waitForGlobalLoadingToDisappear(timeout: TimeInterval = 45) { func waitForGlobalLoadingToDisappear(timeout: TimeInterval = 45) {
let loading = application.staticTexts["加载中"] let loading = application.staticTexts["加载中"]

View File

@ -25,7 +25,7 @@ enum UITestSession {
return launched return launched
} }
/// App Keychain /// App UserDefaults
@discardableResult @discardableResult
static func relaunch( static func relaunch(
openMenu menuTitle: String, openMenu menuTitle: String,
@ -44,7 +44,7 @@ enum UITestSession {
return relaunched return relaunched
} }
/// App Keychain /// App UserDefaults
@discardableResult @discardableResult
static func relaunch( static func relaunch(
openProfile route: String, openProfile route: String,

View File

@ -16,7 +16,7 @@
| 完成 | App 根结构 | `RootView` 按登录态切换登录页/主 Tab主界面使用 `TabView`,每个 Tab 独立 `NavigationStack`。 | 持续随业务路由扩展。 | | 完成 | App 根结构 | `RootView` 按登录态切换登录页/主 Tab主界面使用 `TabView`,每个 Tab 独立 `NavigationStack`。 | 持续随业务路由扩展。 |
| 完成 | Tab 导航 | 首页、订单、数据、我的四个 Tabpush 子页面时隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 | | 完成 | Tab 导航 | 首页、订单、数据、我的四个 Tabpush 子页面时隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 |
| 完成 | 全局状态拆分 | `AppSession``AccountContext``PermissionContext``ScenicSpotContext``AppRouter``ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 | | 完成 | 全局状态拆分 | `AppSession``AccountContext``PermissionContext``ScenicSpotContext``AppRouter``ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 |
| 完成 | 登录态缓存 | token 使用 Keychain账号快照、上次手机号、偏好使用 UserDefaults冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 | | 完成 | 登录态缓存 | token账号快照、上次手机号、偏好使用 UserDefaults冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 |
| 完成 | 网络框架 | `APIClient``APIRequest``APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 | | 完成 | 网络框架 | `APIClient``APIRequest``APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 |
| 完成 | OSS 上传 | `UploadAPI``OSSUploadService`、上传策略、头像、实名图片、任务附件、云盘文件、相册文件、素材/样片文件、项目图片上传处理。 | 后续新上传场景继续复用。 | | 完成 | OSS 上传 | `UploadAPI``OSSUploadService`、上传策略、头像、实名图片、任务附件、云盘文件、相册文件、素材/样片文件、项目图片上传处理。 | 后续新上传场景继续复用。 |
| 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 | | 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 |