001project_wildgrowth/ios/WildGrowth/WildGrowth/AnalyticsManager.swift

277 lines
9.2 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import UIKit
// MARK: - V1.0
final class AnalyticsManager {
static let shared = AnalyticsManager()
// MARK: -
private let flushInterval: TimeInterval = 30 // 30 flush
private let flushThreshold: Int = 20 // 20 flush
private let maxRetryEvents: Int = 500 // 线 500
// MARK: -
private var eventQueue: [[String: Any]] = [] //
private let queue = DispatchQueue(label: "com.wildgrowth.analytics", qos: .utility)
private var flushTimer: Timer?
private var sessionId: String = UUID().uuidString
private var sessionStartTime: Date = Date()
// MARK: -
private lazy var deviceId: String = {
// 使 deviceId
if let stored = UserDefaults.standard.string(forKey: "analytics_device_id") {
return stored
}
let id = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
UserDefaults.standard.set(id, forKey: "analytics_device_id")
return id
}()
private lazy var appVersion: String = {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
}()
private lazy var osVersion: String = {
UIDevice.current.systemVersion
}()
private lazy var deviceModel: String = {
var systemInfo = utsname()
uname(&systemInfo)
let machineMirror = Mirror(reflecting: systemInfo.machine)
let identifier = machineMirror.children.reduce("") { id, element in
guard let value = element.value as? Int8, value != 0 else { return id }
return id + String(UnicodeScalar(UInt8(value)))
}
return identifier
}()
// MARK: - Init
private init() {
// 线
loadCachedEvents()
// flush
startFlushTimer()
// App
setupLifecycleObservers()
}
// MARK: - API
///
/// - Parameters:
/// - event: "app_launch", "discovery_view"
/// - properties:
func track(_ event: String, properties: [String: Any]? = nil) {
queue.async { [weak self] in
guard let self = self else { return }
let eventData: [String: Any] = [
"eventName": event,
"timestamp": ISO8601DateFormatter().string(from: Date()),
"sessionId": self.sessionId,
"properties": properties ?? [:]
]
self.eventQueue.append(eventData)
#if DEBUG
print("📊 [Analytics] track: \(event) | queue=\(self.eventQueue.count)")
#endif
// flush
if self.eventQueue.count >= self.flushThreshold {
self.flushInternal()
}
}
}
///
func flush() {
queue.async { [weak self] in
self?.flushInternal()
}
}
/// App
func startNewSession() {
queue.async { [weak self] in
guard let self = self else { return }
self.sessionId = UUID().uuidString
self.sessionStartTime = Date()
#if DEBUG
print("📊 [Analytics] 新会话: \(self.sessionId)")
#endif
}
}
// MARK: -
private func flushInternal() {
// self.queue dispatch
guard !eventQueue.isEmpty else { return }
let eventsToSend = eventQueue
eventQueue.removeAll()
#if DEBUG
print("📊 [Analytics] flush: 发送 \(eventsToSend.count) 条事件")
#endif
//
let context: [String: Any] = [
"deviceId": deviceId,
"userId": UserManager.shared.currentUser?.id as Any,
"sessionId": sessionId,
"appVersion": appVersion,
"osVersion": osVersion,
"deviceModel": deviceModel,
"networkType": getNetworkType()
]
let body: [String: Any] = [
"events": eventsToSend,
"context": context
]
// APIClient
sendEvents(body: body, events: eventsToSend)
}
private func sendEvents(body: [String: Any], events: [[String: Any]]) {
let baseURL = APIClient.shared.baseURL
guard let url = URL(string: "\(baseURL)/api/analytics/events") else {
// URL
cacheEvents(events)
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 10 // 10
guard let httpBody = try? JSONSerialization.data(withJSONObject: body) else {
cacheEvents(events)
return
}
request.httpBody = httpBody
// 使 URLSession APIClient session
URLSession.shared.dataTask(with: request) { [weak self] _, response, error in
if let error = error {
#if DEBUG
print("📊 [Analytics] ❌ 上报失败: \(error.localizedDescription)")
#endif
//
self?.cacheEvents(events)
return
}
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
#if DEBUG
print("📊 [Analytics] ✅ 上报成功: \(events.count)")
#endif
} else {
self?.cacheEvents(events)
}
}.resume()
}
// MARK: - 线
private var cacheFileURL: URL {
let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
return dir.appendingPathComponent("analytics_cache.json")
}
private func cacheEvents(_ events: [[String: Any]]) {
queue.async { [weak self] in
guard let self = self else { return }
//
var cached = self.loadCachedEventsFromDisk()
cached.append(contentsOf: events)
//
if cached.count > self.maxRetryEvents {
cached = Array(cached.suffix(self.maxRetryEvents))
}
//
if let data = try? JSONSerialization.data(withJSONObject: cached) {
try? data.write(to: self.cacheFileURL)
}
#if DEBUG
print("📊 [Analytics] 缓存 \(events.count) 条事件(总缓存: \(cached.count)")
#endif
}
}
private func loadCachedEvents() {
let cached = loadCachedEventsFromDisk()
if !cached.isEmpty {
eventQueue.append(contentsOf: cached)
//
try? FileManager.default.removeItem(at: cacheFileURL)
#if DEBUG
print("📊 [Analytics] 加载 \(cached.count) 条离线缓存事件")
#endif
}
}
private func loadCachedEventsFromDisk() -> [[String: Any]] {
guard let data = try? Data(contentsOf: cacheFileURL),
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
return []
}
return array
}
// MARK: -
private func startFlushTimer() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.flushTimer = Timer.scheduledTimer(withTimeInterval: self.flushInterval, repeats: true) { [weak self] _ in
self?.flush()
}
}
}
// MARK: -
private func setupLifecycleObservers() {
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillResignActive),
name: UIApplication.willResignActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
}
@objc private func appWillResignActive() {
track("app_enter_background")
flush()
}
@objc private func appDidBecomeActive() {
startNewSession()
track("app_enter_foreground")
}
// MARK: -
private func getNetworkType() -> String {
// NWPathMonitor unknown
// WiFi/Cellular
return "unknown"
}
}