接入友盟统计与 U-APM,并优化宣传页打卡点标签交互。
友盟 SDK 改为隐私同意后再初始化;宣传页标签新增滑块位移动画,超过 5 个时选中项自动滚动居中。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -24,6 +24,7 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
||||
- `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。
|
||||
- `AccountContextLoader`:统一同步用户资料、角色权限、景区和门店。
|
||||
- `AppServiceEnvironment.swift`:为无 UI 状态的服务定义 SwiftUI `EnvironmentKey`,供 View 通过 `@Environment(\.xxxAPI)` 读取。
|
||||
- `UMengSDKBootstrap`:友盟统计与 U-APM 的隐私合规初始化入口,只在用户同意隐私政策且 AppKey 非空后初始化。
|
||||
|
||||
## 依赖注入:为何 API 走 Environment 而非单例
|
||||
|
||||
@ -81,31 +82,42 @@ SwiftUI 的 `Environment` 在本项目中是**依赖注入(DI)通道**,不
|
||||
|
||||
> 完整启动接口清单、数据依赖与 Splash 优化方案见 [启动流程分析与优化方案](../../docs/startup-analysis.md)。
|
||||
|
||||
1. `suixinkanApp` 创建 `RootView`。
|
||||
1. `suixinkanApp` 处理 UI Test 冷启动状态清理并创建 `RootView`。
|
||||
2. `RootView` 初始化共享依赖,并把它们注入 Environment。
|
||||
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`),失败静默。
|
||||
6. 如果本地已记录用户同意隐私政策,则初始化高德与友盟 SDK;否则不触发第三方 SDK 初始化。
|
||||
7. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token。
|
||||
8. 无 token 时保持 `loggedOut`,展示 `LoginView`。
|
||||
9. 有 token 时进入 `restoring`,先恢复本地账号快照。
|
||||
10. 阻塞请求 `rolePermissions` 成功后立即 `markLoggedIn` 并展示 `MainTabsView`。
|
||||
11. `userInfo`、`scenicListAll`、`storeAll` 在后台补充刷新,不阻塞首屏。
|
||||
12. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
|
||||
13. 明确 token 失效时清空 token 和账号快照,回到登录页。
|
||||
14. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
|
||||
15. 冷启动 bootstrap 不使用 Lottie 全局 Loading;`.restoring` 期间展示 `SplashView`。
|
||||
16. `LoginView` 首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默。
|
||||
|
||||
## 初始化分层
|
||||
|
||||
| 阶段 | 职责 | 主要对象 |
|
||||
|------|------|----------|
|
||||
| App 入口 | UI Test 状态清理、后续可扩展 SDK 初始化 | `suixinkanApp`、`AppUITestLaunchState` |
|
||||
| App 入口 | UI Test 状态清理 | `suixinkanApp`、`AppUITestLaunchState` |
|
||||
| 冷启动恢复 | Launch Screen + Splash 覆盖 bootstrap | `SplashView`、`SessionBootstrapper`、`AccountContextLoader` |
|
||||
| 首屏出现后 | 页面级远程配置 | `LoginView`、`AppConfigAPI` |
|
||||
| 登录后 | 推送、景点列表、排队 WebSocket | `PushNotificationManager`、`ScenicSpotContext` |
|
||||
|
||||
## 第三方 SDK 隐私门禁
|
||||
|
||||
高德与友盟 SDK 均由 `RootView` 在隐私政策已同意后初始化,不再由 `suixinkanApp.init()` 直接触发。隐私同意状态保存在 `AppPreferencesStore`:
|
||||
- 首次冷启动未同意时,不初始化高德、友盟统计或 U-APM。
|
||||
- 登录页校验确认已同意后,`AuthSessionCoordinator.savePrivacyAgreementAccepted(true)` 会先持久化状态,再触发第三方 SDK 初始化。
|
||||
- 后续冷启动读取到已同意状态时,会自动初始化第三方 SDK。
|
||||
- 退出登录只清空 token 和账号快照,保留隐私同意状态。
|
||||
|
||||
友盟配置集中在 `UMengConfig`。`appKey` 为空时 `UMengSDKBootstrap` 不会调用友盟 SDK,便于在未配置真实 AppKey 前保持构建可用。U-APM 首版采用最小合规采集范围:开启崩溃、卡顿和启动分析,关闭网络分析、内存、OOM 与日志回捞。
|
||||
|
||||
## UI Test 启动约定
|
||||
|
||||
- `AppUITestLaunchState` 在收到 `-suixinkan-ui-tests` 时跳过推送注册和排队 WebSocket,避免系统弹窗干扰自动化。
|
||||
|
||||
@ -51,12 +51,14 @@ struct RootView: View {
|
||||
@State private var sessionBootstrapper: SessionBootstrapper
|
||||
@State private var isColdStartBootstrapPending = true
|
||||
@State private var appConfigAPI: AppConfigAPI
|
||||
@State private var preferencesStore: AppPreferencesStore
|
||||
|
||||
/// 初始化网络客户端和业务 API,确保它们共享同一个 token provider。
|
||||
init() {
|
||||
let apiClient = APIClient()
|
||||
let tokenStore = SessionTokenStore()
|
||||
let snapshotStore = AccountSnapshotStore()
|
||||
let preferencesStore = AppPreferencesStore()
|
||||
_snapshotStore = State(initialValue: snapshotStore)
|
||||
_apiClient = State(initialValue: apiClient)
|
||||
_authAPI = State(initialValue: AuthAPI(client: apiClient))
|
||||
@ -89,11 +91,17 @@ struct RootView: View {
|
||||
_locationReportAPI = State(initialValue: locationReportAPI)
|
||||
_homeLocationViewModel = StateObject(wrappedValue: HomeLocationViewModel(api: locationReportAPI))
|
||||
_cooperationOrderAPI = State(initialValue: CooperationOrderAPI(client: apiClient))
|
||||
_preferencesStore = State(initialValue: preferencesStore)
|
||||
_authSessionCoordinator = State(
|
||||
initialValue: AuthSessionCoordinator(
|
||||
tokenStore: tokenStore,
|
||||
snapshotStore: snapshotStore,
|
||||
preferencesStore: AppPreferencesStore()
|
||||
preferencesStore: preferencesStore,
|
||||
onPrivacyAgreementPersisted: { accepted in
|
||||
guard accepted else { return }
|
||||
AMapBootstrap.configure(apiKey: AMapConfig.apiKey)
|
||||
UMengSDKBootstrap.shared.configureIfAllowed(preferencesStore: preferencesStore)
|
||||
}
|
||||
)
|
||||
)
|
||||
_sessionBootstrapper = State(
|
||||
@ -157,6 +165,7 @@ struct RootView: View {
|
||||
.environment(\.authSessionCoordinator, authSessionCoordinator)
|
||||
.task {
|
||||
apiClient.bindAuthTokenProvider { appSession.token }
|
||||
configurePrivacyGatedThirdPartySDKsIfAllowed()
|
||||
PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter)
|
||||
await sessionBootstrapper.restore(
|
||||
appSession: appSession,
|
||||
@ -256,6 +265,14 @@ struct RootView: View {
|
||||
}
|
||||
return "\(phaseText)-\(accountContext.currentScenic?.id ?? 0)"
|
||||
}
|
||||
|
||||
/// 在用户已经同意隐私政策后初始化第三方 SDK。
|
||||
private func configurePrivacyGatedThirdPartySDKsIfAllowed() {
|
||||
guard preferencesStore.loadPrivacyAgreementAccepted() else { return }
|
||||
|
||||
AMapBootstrap.configure(apiKey: AMapConfig.apiKey)
|
||||
UMengSDKBootstrap.shared.configureIfAllowed(preferencesStore: preferencesStore)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@ -14,16 +14,19 @@ final class AuthSessionCoordinator {
|
||||
private let tokenStore: SessionTokenStore
|
||||
private let snapshotStore: AccountSnapshotStore
|
||||
private let preferencesStore: AppPreferencesStore
|
||||
private let privacyAgreementPersistedHandler: ((Bool) -> Void)?
|
||||
|
||||
/// 初始化登录会话协调器,并注入 token、账号快照和偏好存储。
|
||||
init(
|
||||
tokenStore: SessionTokenStore,
|
||||
snapshotStore: AccountSnapshotStore,
|
||||
preferencesStore: AppPreferencesStore
|
||||
preferencesStore: AppPreferencesStore,
|
||||
onPrivacyAgreementPersisted: ((Bool) -> Void)? = nil
|
||||
) {
|
||||
self.tokenStore = tokenStore
|
||||
self.snapshotStore = snapshotStore
|
||||
self.preferencesStore = preferencesStore
|
||||
privacyAgreementPersistedHandler = onPrivacyAgreementPersisted
|
||||
}
|
||||
|
||||
/// 创建使用默认存储服务的登录会话协调器。
|
||||
@ -58,7 +61,7 @@ final class AuthSessionCoordinator {
|
||||
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
try tokenStore.save(token)
|
||||
preferencesStore.saveLastLoginUsername(username)
|
||||
preferencesStore.savePrivacyAgreementAccepted(privacyAgreementAccepted)
|
||||
savePrivacyAgreementAccepted(privacyAgreementAccepted)
|
||||
|
||||
let profile = response.primaryProfile
|
||||
let scenicScopes = response.scenicScopes
|
||||
@ -154,6 +157,12 @@ final class AuthSessionCoordinator {
|
||||
appSession.logout()
|
||||
}
|
||||
|
||||
/// 保存隐私协议同意状态,并通知 App 级第三方 SDK 初始化入口。
|
||||
func savePrivacyAgreementAccepted(_ accepted: Bool) {
|
||||
preferencesStore.savePrivacyAgreementAccepted(accepted)
|
||||
privacyAgreementPersistedHandler?(accepted)
|
||||
}
|
||||
|
||||
/// 保存当前账号上下文的快照。
|
||||
private func saveSnapshot(
|
||||
profile: AccountProfile?,
|
||||
@ -211,4 +220,3 @@ struct LoginPreferences: Equatable {
|
||||
let lastUsername: String?
|
||||
let privacyAgreementAccepted: Bool
|
||||
}
|
||||
|
||||
|
||||
17
suixinkan/App/UMengConfig.swift
Normal file
17
suixinkan/App/UMengConfig.swift
Normal file
@ -0,0 +1,17 @@
|
||||
//
|
||||
// UMengConfig.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/7/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 友盟 SDK 配置,真实 AppKey 后续在此处替换。
|
||||
enum UMengConfig {
|
||||
/// 友盟 iOS AppKey。保持为空时不会初始化友盟 SDK。
|
||||
static let appKey = "6a470b81cbfa6959516c34d0"
|
||||
|
||||
/// 友盟渠道标识;nil 时友盟按 App Store 渠道处理。
|
||||
static let channel: String? = nil
|
||||
}
|
||||
28
suixinkan/App/UMengSDKAdapter.h
Normal file
28
suixinkan/App/UMengSDKAdapter.h
Normal file
@ -0,0 +1,28 @@
|
||||
//
|
||||
// UMengSDKAdapter.h
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/7/2.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 友盟 ObjC SDK 适配器,隔离 Swift 侧对 ObjC API 命名映射的依赖。
|
||||
@interface UMengSDKAdapter : NSObject
|
||||
|
||||
/// 按传入的最小化采集开关配置 U-APM,并初始化友盟组件化 SDK。
|
||||
+ (void)configureWithAppKey:(NSString *)appKey
|
||||
channel:(nullable NSString *)channel
|
||||
crashAndBlockMonitorEnable:(BOOL)crashAndBlockMonitorEnable
|
||||
launchMonitorEnable:(BOOL)launchMonitorEnable
|
||||
memMonitorEnable:(BOOL)memMonitorEnable
|
||||
oomMonitorEnable:(BOOL)oomMonitorEnable
|
||||
networkEnable:(BOOL)networkEnable
|
||||
logCollectEnable:(BOOL)logCollectEnable
|
||||
NS_SWIFT_NAME(configure(appKey:channel:crashAndBlockMonitorEnable:launchMonitorEnable:memMonitorEnable:oomMonitorEnable:networkEnable:logCollectEnable:));
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
36
suixinkan/App/UMengSDKAdapter.m
Normal file
36
suixinkan/App/UMengSDKAdapter.m
Normal file
@ -0,0 +1,36 @@
|
||||
//
|
||||
// UMengSDKAdapter.m
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/7/2.
|
||||
//
|
||||
|
||||
#import "UMengSDKAdapter.h"
|
||||
#import <UMCommon/UMCommon.h>
|
||||
#import <UMAPM/UMAPMConfig.h>
|
||||
#import <UMAPM/UMCrashConfigure.h>
|
||||
|
||||
@implementation UMengSDKAdapter
|
||||
|
||||
+ (void)configureWithAppKey:(NSString *)appKey
|
||||
channel:(NSString *)channel
|
||||
crashAndBlockMonitorEnable:(BOOL)crashAndBlockMonitorEnable
|
||||
launchMonitorEnable:(BOOL)launchMonitorEnable
|
||||
memMonitorEnable:(BOOL)memMonitorEnable
|
||||
oomMonitorEnable:(BOOL)oomMonitorEnable
|
||||
networkEnable:(BOOL)networkEnable
|
||||
logCollectEnable:(BOOL)logCollectEnable {
|
||||
UMAPMConfig *config = [UMAPMConfig defaultConfig];
|
||||
config.crashAndBlockMonitorEnable = crashAndBlockMonitorEnable;
|
||||
config.launchMonitorEnable = launchMonitorEnable;
|
||||
config.memMonitorEnable = memMonitorEnable;
|
||||
config.oomMonitorEnable = oomMonitorEnable;
|
||||
config.networkEnable = networkEnable;
|
||||
config.logCollectEnable = logCollectEnable;
|
||||
|
||||
[UMConfigure setLogEnabled:NO];
|
||||
[UMCrashConfigure setAPMConfig:config];
|
||||
[UMConfigure initWithAppkey:appKey channel:channel];
|
||||
}
|
||||
|
||||
@end
|
||||
96
suixinkan/App/UMengSDKBootstrap.swift
Normal file
96
suixinkan/App/UMengSDKBootstrap.swift
Normal file
@ -0,0 +1,96 @@
|
||||
//
|
||||
// UMengSDKBootstrap.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/7/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// U-APM 采集开关集合,默认使用首版最小合规能力。
|
||||
struct UMengAPMOptions: Equatable {
|
||||
let crashAndBlockMonitorEnable: Bool
|
||||
let launchMonitorEnable: Bool
|
||||
let memMonitorEnable: Bool
|
||||
let oomMonitorEnable: Bool
|
||||
let networkEnable: Bool
|
||||
let logCollectEnable: Bool
|
||||
|
||||
/// 首版仅开启崩溃、卡顿和启动分析,关闭更敏感的网络、内存、OOM 和日志回捞。
|
||||
static let minimumCompliance = UMengAPMOptions(
|
||||
crashAndBlockMonitorEnable: true,
|
||||
launchMonitorEnable: true,
|
||||
memMonitorEnable: false,
|
||||
oomMonitorEnable: false,
|
||||
networkEnable: false,
|
||||
logCollectEnable: false
|
||||
)
|
||||
}
|
||||
|
||||
/// 友盟 SDK 调用协议,便于单元测试验证初始化门禁与配置。
|
||||
@MainActor
|
||||
protocol UMengSDKConfiguring {
|
||||
/// 使用指定 AppKey、渠道和 APM 开关初始化友盟 SDK。
|
||||
func configure(appKey: String, channel: String?, options: UMengAPMOptions)
|
||||
}
|
||||
|
||||
/// 生产环境友盟 SDK 调用实现。
|
||||
@MainActor
|
||||
struct UMengSDKProductionConfigurator: UMengSDKConfiguring {
|
||||
/// 使用 ObjC 适配器完成友盟 SDK 初始化。
|
||||
func configure(appKey: String, channel: String?, options: UMengAPMOptions) {
|
||||
UMengSDKAdapter.configure(
|
||||
appKey: appKey,
|
||||
channel: channel,
|
||||
crashAndBlockMonitorEnable: options.crashAndBlockMonitorEnable,
|
||||
launchMonitorEnable: options.launchMonitorEnable,
|
||||
memMonitorEnable: options.memMonitorEnable,
|
||||
oomMonitorEnable: options.oomMonitorEnable,
|
||||
networkEnable: options.networkEnable,
|
||||
logCollectEnable: options.logCollectEnable
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 友盟 SDK 隐私合规初始化入口,确保用户同意隐私政策后才会初始化。
|
||||
final class UMengSDKBootstrap {
|
||||
static let shared = UMengSDKBootstrap(configurator: UMengSDKProductionConfigurator())
|
||||
|
||||
private let configurator: UMengSDKConfiguring
|
||||
private(set) var didConfigure = false
|
||||
|
||||
/// 初始化友盟启动器,并允许测试注入替身。
|
||||
init(configurator: UMengSDKConfiguring) {
|
||||
self.configurator = configurator
|
||||
}
|
||||
|
||||
/// 在隐私政策已同意且 AppKey 有效时初始化友盟 SDK。
|
||||
@discardableResult
|
||||
func configureIfAllowed(
|
||||
preferencesStore: AppPreferencesStore,
|
||||
appKey: String = UMengConfig.appKey,
|
||||
channel: String? = UMengConfig.channel,
|
||||
options: UMengAPMOptions = .minimumCompliance
|
||||
) -> Bool {
|
||||
guard preferencesStore.loadPrivacyAgreementAccepted() else { return false }
|
||||
guard !didConfigure else { return false }
|
||||
|
||||
let normalizedAppKey = appKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedAppKey.isEmpty else { return false }
|
||||
|
||||
configurator.configure(
|
||||
appKey: normalizedAppKey,
|
||||
channel: normalizedChannel(channel),
|
||||
options: options
|
||||
)
|
||||
didConfigure = true
|
||||
return true
|
||||
}
|
||||
|
||||
/// 去除渠道首尾空白,空字符串按 nil 处理。
|
||||
private func normalizedChannel(_ channel: String?) -> String? {
|
||||
let value = channel?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,6 @@ struct suixinkanApp: App {
|
||||
|
||||
init() {
|
||||
AppUITestLaunchState.resetIfNeeded()
|
||||
AMapBootstrap.configure(apiKey: AMapConfig.apiKey)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
||||
Reference in New Issue
Block a user