接入友盟统计与 U-APM,并优化宣传页打卡点标签交互。

友盟 SDK 改为隐私同意后再初始化;宣传页标签新增滑块位移动画,超过 5 个时选中项自动滚动居中。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-03 09:23:15 +08:00
parent c7f557f361
commit 00c3cd0a93
134 changed files with 6555 additions and 590 deletions

View File

@ -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避免系统弹窗干扰自动化。

View File

@ -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 {

View File

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

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

View 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

View 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

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

View File

@ -14,7 +14,6 @@ struct suixinkanApp: App {
init() {
AppUITestLaunchState.resetIfNeeded()
AMapBootstrap.configure(apiKey: AMapConfig.apiKey)
}
var body: some Scene {

View File

@ -51,6 +51,7 @@ Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存
- 临时 token 只放内存。
- 密码、验证码、OSS STS token、一次性扫码结果和错误提示不落盘。
- 头像、证件照等图片缓存交给 KingfisherCore 不保存图片 Data。
- 隐私协议同意状态属于非敏感偏好,用于控制高德、友盟统计和 U-APM 等第三方 SDK 的初始化时机;退出登录时保留,清空登录页偏好时移除。
`AccountSnapshot` 保存可重建的账号展示和业务上下文:
- `AccountProfile`

View File

@ -154,6 +154,8 @@ struct LoginView: View {
return
}
authSessionCoordinator.savePrivacyAgreementAccepted(viewModel.privacyChecked)
Task {
do {
try await globalLoading.withLoading(message: "登录中...") {

View File

@ -7,12 +7,28 @@
import SwiftUI
/// 5 5
/// 5 5
struct ScenicPromotionSpotTabsView: View {
let spots: [ScenicPromotionSpot]
let selectedSpotID: String?
let onSelectSpot: (ScenicPromotionSpot) -> Void
/// 线
private static let selectionAnimation = Animation.spring(response: 0.35, dampingFraction: 0.82)
/// 线
private static let scrollAnimation = Animation.easeInOut(duration: 0.25)
/// 退 0
private var selectedIndex: Int {
guard let selectedSpotID else { return 0 }
return spots.firstIndex(where: { $0.id == selectedSpotID }) ?? 0
}
/// 5
private var shouldEnableScroll: Bool {
spots.count > Int(ScenicPromotionLayout.visibleSpotTabCount)
}
var body: some View {
if spots.isEmpty {
EmptyView()
@ -22,22 +38,38 @@ struct ScenicPromotionSpotTabsView: View {
.frame(height: 42)
.overlay {
GeometryReader { proxy in
let visibleCount = min(CGFloat(max(spots.count, 1)), ScenicPromotionLayout.visibleSpotTabCount)
let visibleCount = min(
CGFloat(max(spots.count, 1)),
ScenicPromotionLayout.visibleSpotTabCount
)
let tabWidth = proxy.size.width / visibleCount
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(spots) { spot in
ScenicPromotionSpotTabButton(
spot: spot,
isSelected: selectedSpotID == spot.id,
onTap: { onSelectSpot(spot) }
)
.frame(width: tabWidth)
ScrollViewReader { scrollProxy in
ScrollView(.horizontal, showsIndicators: false) {
ZStack(alignment: .leading) {
selectionBackground(width: tabWidth)
HStack(spacing: 0) {
ForEach(spots) { spot in
ScenicPromotionSpotTabButton(
spot: spot,
isSelected: selectedSpotID == spot.id,
onTap: { onSelectSpot(spot) }
)
.frame(width: tabWidth)
.id(spot.id)
}
}
}
}
.scrollDisabled(!shouldEnableScroll)
.onAppear {
scrollSelectedTabToCenter(using: scrollProxy, animated: false)
}
.onChange(of: selectedSpotID) { _ in
scrollSelectedTabToCenter(using: scrollProxy, animated: true)
}
}
.scrollDisabled(spots.count <= Int(ScenicPromotionLayout.visibleSpotTabCount))
}
}
.background(.white.opacity(0.84), in: RoundedRectangle(cornerRadius: 14))
@ -49,6 +81,35 @@ struct ScenicPromotionSpotTabsView: View {
.shadow(color: Color(hex: 0x0D47A1, alpha: 0.12), radius: 12, y: 5)
}
}
///
@ViewBuilder
private func selectionBackground(width: CGFloat) -> some View {
RoundedRectangle(cornerRadius: 14)
.fill(
LinearGradient(
colors: [Color(hex: 0x1286FF), Color(hex: 0x006BFF)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: width, height: 42)
.offset(x: CGFloat(selectedIndex) * width)
.animation(Self.selectionAnimation, value: selectedIndex)
}
///
private func scrollSelectedTabToCenter(using scrollProxy: ScrollViewProxy, animated: Bool) {
guard shouldEnableScroll, let selectedSpotID else { return }
if animated {
withAnimation(Self.scrollAnimation) {
scrollProxy.scrollTo(selectedSpotID, anchor: .center)
}
} else {
scrollProxy.scrollTo(selectedSpotID, anchor: .center)
}
}
}
///
@ -68,22 +129,9 @@ struct ScenicPromotionSpotTabButton: View {
.minimumScaleFactor(0.66)
}
.foregroundStyle(isSelected ? .white : Color(hex: 0x14213D))
.frame(maxWidth: .infinity)
.frame(height: 42)
.padding(.horizontal, 4)
.background(
Group {
if isSelected {
LinearGradient(
colors: [Color(hex: 0x1286FF), Color(hex: 0x006BFF)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
} else {
Color.clear
}
}
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}

View File

@ -0,0 +1,13 @@
//
// suixinkan-Bridging-Header.h
// suixinkan
//
// Created by Codex on 2026/7/2.
//
#import <UMCommon/UMCommon.h>
#import <UMCommon/MobClick.h>
#import <UMAPM/UMLaunch.h>
#import <UMAPM/UMAPMConfig.h>
#import <UMAPM/UMCrashConfigure.h>
#import "App/UMengSDKAdapter.h"