@rk911/tracker-sdk 1.7.15 → 1.7.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,168 +1,944 @@
1
- //
2
- // RKTrackerSDK.swift
3
- // RKTrackerSDK
4
- // iOS Native Tracker SDK for tracker-sdk repo
5
- //
6
-
7
- import UIKit
8
-
9
- @objc public class RKTrackerSDK: NSObject {
10
- @objc public static let shared = RKTrackerSDK()
11
-
12
- private var serverUrl: String = ""
13
- private var appId: String = "13" // 13: iOS App
14
- private var sessionId: String = ""
15
- private var deviceId: String = ""
16
- private var userId: String = ""
17
- private var phone: String = ""
18
- private var storeName: String = ""
19
- private var sequence: Int = 0
20
-
21
- private let bgQueue = DispatchQueue(label: "cn.renruikeji.tracker.bgQueue", qos: .utility)
22
- private var eventQueue: [[String: Any]] = []
23
- private var flushTimer: Timer?
24
-
25
- private override init() {
26
- super.init()
27
- self.sessionId = "ios_\(Int(Date().timeIntervalSince1970))_\(Int.random(in: 1000...9999))"
28
- }
29
-
30
- @objc public func initSDK(serverUrl: String, deviceId: String, userId: String, phone: String, storeName: String) {
31
- if !serverUrl.contains("/api/point/task/trackBatch") {
32
- if serverUrl.hasSuffix("/") {
33
- self.serverUrl = serverUrl + "api/point/task/trackBatch"
34
- } else {
35
- self.serverUrl = serverUrl + "/api/point/task/trackBatch"
36
- }
37
- } else {
38
- self.serverUrl = serverUrl
39
- }
40
- self.deviceId = deviceId.isEmpty ? (UIDevice.current.identifierForVendor?.uuidString ?? "") : deviceId
41
- self.userId = userId
42
- self.phone = phone
43
- self.storeName = storeName
44
-
45
- swizzleUIViewController()
46
- startTimer()
47
- }
48
-
49
- @objc public func getSessionId() -> String {
50
- return sessionId
51
- }
52
-
53
- @objc public func track(eventName: String, properties: [String: Any]?) {
54
- bgQueue.async { [weak self] in
55
- guard let self = self else { return }
56
- var event = self.createBaseEvent(eventType: "custom", eventName: eventName)
57
- if let props = properties, let jsonData = try? JSONSerialization.data(withJSONObject: props), let str = String(data: jsonData, encoding: .utf8) {
58
- event["extraData"] = str
59
- }
60
- self.enqueueEvent(event)
61
- }
62
- }
63
-
64
- @objc public func trackFrontUrl(frontUrl: String, elementId: String?, postParam: String?) {
65
- bgQueue.async { [weak self] in
66
- guard let self = self else { return }
67
- var event = self.createBaseEvent(eventType: "click", eventName: "legacy_dadian")
68
- event["frontUrl"] = frontUrl
69
- if let elem = elementId { event["elementId"] = elem }
70
- if let post = postParam { event["postParam"] = post }
71
- self.enqueueEvent(event)
72
- }
73
- }
74
-
75
- private func createBaseEvent(eventType: String, eventName: String) -> [String: Any] {
76
- sequence += 1
77
- var extra: [String: Any] = ["platform": "ios"]
78
- if !phone.isEmpty { extra["phone"] = phone }
79
- if !storeName.isEmpty { extra["storeName"] = storeName }
80
- if !userId.isEmpty { extra["userId"] = userId }
81
- var extraStr = ""
82
- if let data = try? JSONSerialization.data(withJSONObject: extra), let str = String(data: data, encoding: .utf8) {
83
- extraStr = str
84
- }
85
-
86
- var event: [String: Any] = [
87
- "eventId": "e_\(Int(Date().timeIntervalSince1970 * 1000))_\(Int.random(in: 1000...9999))",
88
- "eventType": eventType,
89
- "eventName": eventName,
90
- "timestamp": Int(Date().timeIntervalSince1970 * 1000),
91
- "sequence": sequence,
92
- "os": "iOS",
93
- "extraData": extraStr
94
- ]
95
- return event
96
- }
97
-
98
- private func enqueueEvent(_ event: [String: Any]) {
99
- eventQueue.append(event)
100
- if eventQueue.count >= 20 {
101
- flushAsync()
102
- }
103
- }
104
-
105
- private func startTimer() {
106
- DispatchQueue.main.async {
107
- self.flushTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
108
- self?.flushAsync()
109
- }
110
- }
111
- }
112
-
113
- @objc public func flushAsync() {
114
- bgQueue.async { [weak self] in
115
- guard let self = self, !self.eventQueue.isEmpty, !self.serverUrl.isEmpty else { return }
116
- let batchEvents = self.eventQueue
117
- self.eventQueue.removeAll()
118
- self.sendBatch(events: batchEvents)
119
- }
120
- }
121
-
122
- private func sendBatch(events: [[String: Any]]) {
123
- guard let url = URL(string: serverUrl) else { return }
124
- var request = URLRequest(url: url)
125
- request.httpMethod = "POST"
126
- request.setValue("application/json; charset=UTF-8", forHTTPHeaderField: "Content-Type")
127
- request.setValue(appId, forHTTPHeaderField: "appId")
128
- request.setValue(deviceId, forHTTPHeaderField: "deviceId")
129
-
130
- let body: [String: Any] = [
131
- "appId": Int(appId) ?? 13,
132
- "sessionId": sessionId,
133
- "deviceId": deviceId,
134
- "userId": userId,
135
- "phone": phone,
136
- "storeName": storeName,
137
- "platform": "ios",
138
- "events": events
139
- ]
140
-
141
- guard let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return }
142
- request.httpBody = jsonData
143
-
144
- let task = URLSession.shared.dataTask(with: request) { _, _, _ in }
145
- task.resume()
146
- }
147
-
148
- // --- Swizzling for UIViewController Auto-Tracking ---
149
- private func swizzleUIViewController() {
150
- let originalSelector = #selector(UIViewController.viewDidAppear(_:))
151
- let swizzledSelector = #selector(UIViewController.rk_viewDidAppear(_:))
152
-
153
- guard let originalMethod = class_getInstanceMethod(UIViewController.self, originalSelector),
154
- let swizzledMethod = class_getInstanceMethod(UIViewController.self, swizzledSelector) else { return }
155
-
156
- method_exchangeImplementations(originalMethod, swizzledMethod)
157
- }
158
- }
159
-
160
- extension UIViewController {
161
- @objc func rk_viewDidAppear(_ animated: Bool) {
162
- self.rk_viewDidAppear(animated)
163
- let pageRoute = String(describing: type(of: self))
164
- if !pageRoute.hasPrefix("UI") {
165
- RKTrackerSDK.shared.track(eventName: "page_view", properties: ["pageRoute": pageRoute])
166
- }
167
- }
168
- }
1
+ //
2
+ // RKTrackerSDK.swift
3
+ // RKTrackerSDK
4
+ // iOS Native Tracker SDK for renkangios project
5
+ //
6
+
7
+ import UIKit
8
+ import Network
9
+ import CoreTelephony
10
+
11
+ public class RKNetworkMonitor: NSObject {
12
+ public static let shared = RKNetworkMonitor()
13
+ private let monitor = NWPathMonitor()
14
+ private let queue = DispatchQueue(label: "cn.renruikeji.tracker.networkMonitor", qos: .utility)
15
+ private var cachedType: String = "unknown"
16
+ private let lock = NSLock()
17
+
18
+ private override init() {
19
+ super.init()
20
+ monitor.pathUpdateHandler = { [weak self] path in
21
+ self?.updateNetworkType(path: path)
22
+ }
23
+ monitor.start(queue: queue)
24
+ }
25
+
26
+ private func updateNetworkType(path: NWPath) {
27
+ lock.lock()
28
+ defer { lock.unlock() }
29
+ if path.status != .satisfied {
30
+ cachedType = "none"
31
+ return
32
+ }
33
+ if path.usesInterfaceType(.wifi) {
34
+ cachedType = "wifi"
35
+ return
36
+ }
37
+ if path.usesInterfaceType(.wiredEthernet) {
38
+ cachedType = "ethernet"
39
+ return
40
+ }
41
+ if path.usesInterfaceType(.cellular) {
42
+ cachedType = getCellularType()
43
+ return
44
+ }
45
+ cachedType = "other"
46
+ }
47
+
48
+ private func getCellularType() -> String {
49
+ let info = CTTelephonyNetworkInfo()
50
+ var radioTech: String?
51
+ if #available(iOS 12.0, *) {
52
+ radioTech = info.serviceCurrentRadioAccessTechnology?.values.first
53
+ } else {
54
+ radioTech = info.currentRadioAccessTechnology
55
+ }
56
+ guard let tech = radioTech else { return "4g" }
57
+ if #available(iOS 14.1, *) {
58
+ if tech == CTRadioAccessTechnologyNR || tech == CTRadioAccessTechnologyNRNSA {
59
+ return "5g"
60
+ }
61
+ }
62
+ switch tech {
63
+ case CTRadioAccessTechnologyLTE:
64
+ return "4g"
65
+ case CTRadioAccessTechnologyWCDMA,
66
+ CTRadioAccessTechnologyHSDPA,
67
+ CTRadioAccessTechnologyHSUPA,
68
+ CTRadioAccessTechnologyCDMA1x,
69
+ CTRadioAccessTechnologyCDMAEVDORev0,
70
+ CTRadioAccessTechnologyCDMAEVDORevA,
71
+ CTRadioAccessTechnologyCDMAEVDORevB,
72
+ CTRadioAccessTechnologyeHRPD:
73
+ return "3g"
74
+ case CTRadioAccessTechnologyGPRS,
75
+ CTRadioAccessTechnologyEdge:
76
+ return "2g"
77
+ default:
78
+ return "4g"
79
+ }
80
+ }
81
+
82
+ public func currentNetworkType() -> String {
83
+ lock.lock()
84
+ let val = cachedType
85
+ lock.unlock()
86
+ if val != "unknown" {
87
+ return val
88
+ }
89
+ if monitor.currentPath.usesInterfaceType(.wifi) {
90
+ return "wifi"
91
+ }
92
+ if monitor.currentPath.usesInterfaceType(.cellular) {
93
+ return "4g"
94
+ }
95
+ if monitor.currentPath.status == .satisfied {
96
+ return "other"
97
+ }
98
+ return "none"
99
+ }
100
+ }
101
+
102
+ @objc public class RKTrackerSDK: NSObject {
103
+ @objc public static let shared = RKTrackerSDK()
104
+
105
+ private var serverUrl: String = "https://state.renruikeji.cn/api/point/task/trackBatch"
106
+ private var appId: String = "13" // 13: iOS App
107
+ private var sessionId: String = ""
108
+ private var deviceId: String = ""
109
+ private var userId: String = ""
110
+ private var phone: String = ""
111
+ private var storeName: String = ""
112
+ private var userName: String = ""
113
+ private var pflag: Int = -1
114
+ private var platformName: String = ""
115
+ private var sequence: Int = 0
116
+
117
+ private var currentPageRoute: String = ""
118
+ private var currentPageTitle: String = ""
119
+ private var referrerPageRoute: String = ""
120
+ private var lastPageStartTime: TimeInterval = 0
121
+
122
+ private var lastClickKey: String = ""
123
+ private var lastClickTime: TimeInterval = 0
124
+
125
+ private let bgQueue = DispatchQueue(label: "cn.renruikeji.tracker.bgQueue", qos: .utility)
126
+ private var eventQueue: [[String: Any]] = []
127
+ private var flushTimer: Timer?
128
+ private let maxDiskRecords = 1000
129
+ private let cacheFileName = "rk_tracker_events_cache.json"
130
+
131
+ private static let friendlyTitles: [String: String] = [
132
+ "RRHomeVC": "APP主页/首页Tab",
133
+ "RRHomeRecommendVC": "首页推荐精选",
134
+ "RRBaseWebViewController": "通用Web容器",
135
+ "RRBaseWebViewController/circle": "发光者",
136
+ "RRBaseWebViewController/shop": "商城",
137
+ "RRVideoContainerVC": "小视频推荐",
138
+ "RRMineVC": "个人中心/我的",
139
+ "RRLoginVC": "登录页",
140
+ "RRLoginViewController": "登录页",
141
+ "RRLoginSelectViewController": "登录选择页",
142
+ "RRLoginPhoneVC": "手机号验证码登录",
143
+ "RRLoginPhoneVericodeViewController": "手机号验证码登录",
144
+ "RRLoginBindPhoneViewController": "绑定手机号",
145
+ "RRLoginBindPhoneMultipleViewController": "多账号绑定手机号",
146
+ "RRLoginChangePhoneViewController": "更换手机号",
147
+ "RRWebviewVC": "Web视图",
148
+ "RRSettingVC": "设置页",
149
+ "QRCodeScanVC": "扫码页",
150
+ "RRShortVideoHomeVideoViewController": "小视频首页",
151
+ "RRShortVideoRecommendVC": "小视频推荐",
152
+ "RRShortVideoCollectionVC": "小视频合集",
153
+ "RRShortVideoCollectionVideoViewController": "小视频合集播放",
154
+ "RRShortVideoFocusVC": "小视频关注",
155
+ "RRShortVideoNewbieTaskVideoViewController": "新人任务视频",
156
+ "RRRecreationVideoContainerViewController": "娱乐视频聚合页",
157
+ "RRRecreationVideoPageViewController": "娱乐视频分页",
158
+ "RRRecreationVideoViewController": "娱乐视频列表",
159
+ "RRPlayletHistoryContainerViewController": "短剧历史记录",
160
+ "RRPlayletHistoryViewController": "短剧历史列表",
161
+ "RRPlayletHistoryPageViewController": "短剧历史分页",
162
+ "RRPlayletPlayViewController": "短剧播放页",
163
+ "RRPlayletSiftViewController": "短剧精选",
164
+ "RRPlayletSiftPageViewController": "短剧精选分页",
165
+ "RRRlayletSiftContainerViewController": "短剧精选容器",
166
+ "PLVECPlayerViewController": "直播间播放页",
167
+ "PLVECWatchRoomViewController": "直播观看室",
168
+ "PLVCommodityDetailViewController": "直播商品详情",
169
+ "PLVECPlaybackListViewController": "直播回放列表",
170
+ "RRVistorHomeViewController": "游客首页",
171
+ "RRVistorGoodsViewController": "游客商品页",
172
+ "RRVistorMineViewController": "游客个人中心",
173
+ "RRVistorTabBarViewController": "游客主导航"
174
+ ]
175
+
176
+ private override init() {
177
+ super.init()
178
+ _ = RKNetworkMonitor.shared // 预热网络监听
179
+ let savedSession = UserDefaults.standard.string(forKey: "rr_app_session_id")
180
+ if let s = savedSession, !s.isEmpty {
181
+ self.sessionId = s
182
+ } else {
183
+ self.sessionId = "ios_\(Int(Date().timeIntervalSince1970))_\(Int.random(in: 1000...9999))"
184
+ UserDefaults.standard.set(self.sessionId, forKey: "rr_app_session_id")
185
+ }
186
+ loadDiskEvents()
187
+ }
188
+
189
+ @objc public func initSDK(serverUrl: String, deviceId: String, userId: String, phone: String, storeName: String, userName: String = "") {
190
+ if !serverUrl.contains("/api/point/task/trackBatch") {
191
+ if serverUrl.hasSuffix("/") {
192
+ self.serverUrl = serverUrl + "api/point/task/trackBatch"
193
+ } else {
194
+ self.serverUrl = serverUrl + "/api/point/task/trackBatch"
195
+ }
196
+ } else {
197
+ self.serverUrl = serverUrl
198
+ }
199
+
200
+ self.deviceId = deviceId.isEmpty ? (UIDevice.current.identifierForVendor?.uuidString ?? "") : deviceId
201
+ self.userId = userId
202
+ self.phone = phone
203
+ self.storeName = storeName
204
+ self.userName = userName
205
+
206
+ swizzleUIViewController()
207
+ swizzleUIApplication()
208
+ startTimer()
209
+
210
+ // 记录 App 启动/冷启动事件(对齐 Android Splash 生命周期)
211
+ trackCustom(eventName: "app_launch", elementText: "冷启动", properties: ["isColdStart": true])
212
+
213
+ flushAsync()
214
+ }
215
+
216
+ @objc public func updateUser(userId: String?, phone: String?, storeName: String?, userName: String?) {
217
+ if let u = userId { self.userId = u }
218
+ if let p = phone { self.phone = p }
219
+ if let s = storeName { self.storeName = s }
220
+ if let n = userName { self.userName = n }
221
+ }
222
+
223
+ @objc public func setPlatform(pflag: Int, platformName: String?) {
224
+ self.pflag = pflag
225
+ let raw = platformName ?? ""
226
+ self.platformName = raw.removingPercentEncoding ?? raw
227
+ }
228
+
229
+ @objc public func resetSession() {
230
+ self.sequence = 0
231
+ self.sessionId = "ios_\(Int(Date().timeIntervalSince1970))_\(Int.random(in: 1000...9999))"
232
+ UserDefaults.standard.set(self.sessionId, forKey: "rr_app_session_id")
233
+ }
234
+
235
+ @objc public func getSessionId() -> String {
236
+ return sessionId
237
+ }
238
+
239
+ @objc public func getDeviceId() -> String {
240
+ return deviceId.isEmpty ? (UIDevice.current.identifierForVendor?.uuidString ?? "") : deviceId
241
+ }
242
+
243
+ @objc public func getUserId() -> String {
244
+ return userId
245
+ }
246
+
247
+ @objc public func getPhone() -> String {
248
+ return phone
249
+ }
250
+
251
+ @objc public func getStoreName() -> String {
252
+ return storeName
253
+ }
254
+
255
+ @objc public func getUserName() -> String {
256
+ return userName
257
+ }
258
+
259
+ @objc public func getPflag() -> Int {
260
+ return pflag
261
+ }
262
+
263
+ @objc public func getPlatformName() -> String {
264
+ return platformName
265
+ }
266
+
267
+ @objc public func getCurrentPageRoute() -> String {
268
+ return currentPageRoute
269
+ }
270
+
271
+ @objc public func updateH5PageState(h5Route: String, h5Title: String) {
272
+ bgQueue.async { [weak self] in
273
+ guard let self = self, !h5Route.isEmpty else { return }
274
+ self.referrerPageRoute = self.currentPageRoute
275
+ self.currentPageRoute = h5Route
276
+ self.currentPageTitle = h5Title
277
+ self.lastPageStartTime = Date().timeIntervalSince1970
278
+ }
279
+ }
280
+
281
+ @objc public func getCurrentSequence() -> Int {
282
+ return sequence
283
+ }
284
+
285
+ @objc public func getNetworkType() -> String {
286
+ return RKNetworkMonitor.shared.currentNetworkType()
287
+ }
288
+
289
+ @objc public func getNormalizedSystemVersion() -> String {
290
+ let ver = ProcessInfo.processInfo.operatingSystemVersion
291
+ if ver.majorVersion >= 10 && ver.majorVersion < 25 {
292
+ if ver.patchVersion > 0 {
293
+ return "\(ver.majorVersion).\(ver.minorVersion).\(ver.patchVersion)"
294
+ }
295
+ return "\(ver.majorVersion).\(ver.minorVersion)"
296
+ }
297
+ let sys = UIDevice.current.systemVersion
298
+ return sys.isEmpty ? "16.0" : sys
299
+ }
300
+
301
+ @objc public func getExactDeviceModel() -> String {
302
+ var systemInfo = utsname()
303
+ uname(&systemInfo)
304
+ let machineMirror = Mirror(reflecting: systemInfo.machine)
305
+ let identifier = machineMirror.children.reduce("") { identifier, element in
306
+ guard let value = element.value as? Int8, value != 0 else { return identifier }
307
+ return identifier + String(UnicodeScalar(UInt8(value)))
308
+ }
309
+ if identifier.isEmpty {
310
+ return UIDevice.current.model
311
+ }
312
+ let modelMap: [String: String] = [
313
+ "iPhone10,1": "iPhone 8", "iPhone10,4": "iPhone 8",
314
+ "iPhone10,2": "iPhone 8 Plus", "iPhone10,5": "iPhone 8 Plus",
315
+ "iPhone10,3": "iPhone X", "iPhone10,6": "iPhone X",
316
+ "iPhone11,2": "iPhone XS",
317
+ "iPhone11,4": "iPhone XS Max", "iPhone11,6": "iPhone XS Max",
318
+ "iPhone11,8": "iPhone XR",
319
+ "iPhone12,1": "iPhone 11",
320
+ "iPhone12,3": "iPhone 11 Pro",
321
+ "iPhone12,5": "iPhone 11 Pro Max",
322
+ "iPhone12,8": "iPhone SE (2nd gen)",
323
+ "iPhone13,1": "iPhone 12 mini",
324
+ "iPhone13,2": "iPhone 12",
325
+ "iPhone13,3": "iPhone 12 Pro",
326
+ "iPhone13,4": "iPhone 12 Pro Max",
327
+ "iPhone14,4": "iPhone 13 mini",
328
+ "iPhone14,5": "iPhone 13",
329
+ "iPhone14,2": "iPhone 13 Pro",
330
+ "iPhone14,3": "iPhone 13 Pro Max",
331
+ "iPhone14,6": "iPhone SE (3rd gen)",
332
+ "iPhone14,7": "iPhone 14",
333
+ "iPhone14,8": "iPhone 14 Plus",
334
+ "iPhone15,2": "iPhone 14 Pro",
335
+ "iPhone15,3": "iPhone 14 Pro Max",
336
+ "iPhone15,4": "iPhone 15",
337
+ "iPhone15,5": "iPhone 15 Plus",
338
+ "iPhone16,1": "iPhone 15 Pro",
339
+ "iPhone16,2": "iPhone 15 Pro Max",
340
+ "iPhone17,3": "iPhone 16",
341
+ "iPhone17,4": "iPhone 16 Plus",
342
+ "iPhone17,1": "iPhone 16 Pro",
343
+ "iPhone17,2": "iPhone 16 Pro Max",
344
+ "i386": "iPhone Simulator",
345
+ "x86_64": "iPhone Simulator",
346
+ "arm64": "iPhone Simulator"
347
+ ]
348
+ return modelMap[identifier] ?? identifier
349
+ }
350
+
351
+ @objc public func getFriendlyTitle(for route: String) -> String {
352
+ if let name = RKTrackerSDK.friendlyTitles[route] {
353
+ return name
354
+ }
355
+ for (k, v) in RKTrackerSDK.friendlyTitles {
356
+ if route == k || route.hasPrefix(k) { return v }
357
+ }
358
+ // 语义化降级兜底
359
+ if route.contains("ShortVideo") || route.contains("Recommend") { return "小视频推荐" }
360
+ if route.contains("PlayletHistory") { return "短剧历史" }
361
+ if route.contains("PlayletPlay") { return "短剧播放页" }
362
+ if route.contains("Playlet") { return "短剧" }
363
+ if route.contains("Video") { return "视频页" }
364
+ if route.contains("Live") || route.contains("PLV") { return "直播" }
365
+ if route.contains("Login") { return "登录" }
366
+ if route.contains("Mine") { return "个人中心" }
367
+ if route.contains("Scan") { return "扫码页" }
368
+ if route.contains("Web") { return "Web视图" }
369
+ return route
370
+ }
371
+
372
+ @objc public func getNativeContextJson() -> String {
373
+ let dict: [String: Any] = [
374
+ "appId": appId,
375
+ "deviceId": getDeviceId(),
376
+ "userId": userId,
377
+ "phone": phone,
378
+ "sessionId": sessionId,
379
+ "appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0",
380
+ "platform": "ios",
381
+ "os": "iOS",
382
+ "brand": "Apple",
383
+ "deviceBrand": "Apple",
384
+ "model": "iPhone",
385
+ "deviceModel": getExactDeviceModel(),
386
+ "osVersion": getNormalizedSystemVersion(),
387
+ "networkType": getNetworkType(),
388
+ "currentSequence": sequence,
389
+ "pflag": pflag,
390
+ "pFlagName": platformName
391
+ ]
392
+ if let data = try? JSONSerialization.data(withJSONObject: dict), let str = String(data: data, encoding: .utf8) {
393
+ return str
394
+ }
395
+ return "{}"
396
+ }
397
+
398
+ @objc public func track(eventName: String, properties: [String: Any]?) {
399
+ trackCustom(eventName: eventName, elementText: nil, properties: properties)
400
+ }
401
+
402
+ @objc public func trackCustom(eventName: String, elementText: String?, properties: [String: Any]?) {
403
+ bgQueue.async { [weak self] in
404
+ guard let self = self else { return }
405
+ var event = self.createBaseEvent(eventType: "custom", eventName: eventName)
406
+ if let txt = elementText, !txt.isEmpty {
407
+ event["elementText"] = txt
408
+ }
409
+ if let props = properties {
410
+ var extra: [String: Any] = self.buildBaseExtra()
411
+ for (k, v) in props { extra[k] = v }
412
+ if let data = try? JSONSerialization.data(withJSONObject: extra), let str = String(data: data, encoding: .utf8) {
413
+ event["extraData"] = str
414
+ }
415
+ }
416
+ self.enqueueEvent(event)
417
+ self.flushAsync()
418
+ }
419
+ }
420
+
421
+ @objc public func trackPageView(pageRoute: String, pageTitle: String) {
422
+ bgQueue.async { [weak self] in
423
+ guard let self = self else { return }
424
+ let now = Date().timeIntervalSince1970
425
+ // 500ms 内相同路由防抖,避免生命周期和手动调用重复触发
426
+ if pageRoute == self.currentPageRoute && (now - self.lastPageStartTime < 0.5) {
427
+ return
428
+ }
429
+ self.referrerPageRoute = self.currentPageRoute
430
+ self.currentPageRoute = pageRoute
431
+ let title = pageTitle.isEmpty ? self.getFriendlyTitle(for: pageRoute) : pageTitle
432
+ self.currentPageTitle = title
433
+ self.lastPageStartTime = now
434
+
435
+ var event = self.createBaseEvent(eventType: "page_view", eventName: "page_view")
436
+ event["pageRoute"] = pageRoute
437
+ event["pageTitle"] = title
438
+ self.enqueueEvent(event)
439
+ self.flushAsync()
440
+ }
441
+ }
442
+
443
+ @objc public func trackPageStay(pageRoute: String, pageTitle: String, durationMs: Int64) {
444
+ guard durationMs >= 500 else { return }
445
+ bgQueue.async { [weak self] in
446
+ guard let self = self else { return }
447
+ var event = self.createBaseEvent(eventType: "page_stay", eventName: "page_stay")
448
+ event["pageRoute"] = pageRoute
449
+ let title = pageTitle.isEmpty ? self.getFriendlyTitle(for: pageRoute) : pageTitle
450
+ event["pageTitle"] = title
451
+ event["duration"] = durationMs
452
+ self.enqueueEvent(event)
453
+ self.flushAsync()
454
+ }
455
+ }
456
+
457
+ @objc public func trackVideo(eventName: String, videoId: Int64, videoTitle: String?, customProperties: [String: Any]?) {
458
+ bgQueue.async { [weak self] in
459
+ guard let self = self else { return }
460
+ if eventName == "video_play_end", let props = customProperties {
461
+ let dur = (props["playDurationMs"] as? NSNumber)?.int64Value ?? (props["playDurationMs"] as? Int64 ?? 0)
462
+ let reason = (props["endReason"] as? String) ?? ""
463
+ if dur < 200 && reason == "refresh_list" {
464
+ return
465
+ }
466
+ }
467
+ var event = self.createBaseEvent(eventType: "video", eventName: eventName)
468
+ event["elementId"] = "video_\(videoId)"
469
+ if let title = videoTitle, !title.isEmpty {
470
+ event["elementText"] = title
471
+ } else {
472
+ event["elementText"] = "video_\(videoId)"
473
+ }
474
+ if let props = customProperties {
475
+ if let pr = props["pageRoute"] as? String, !pr.isEmpty {
476
+ event["pageRoute"] = pr
477
+ }
478
+ if let pt = props["pageTitle"] as? String, !pt.isEmpty {
479
+ event["pageTitle"] = pt
480
+ }
481
+ }
482
+ var extra: [String: Any] = self.buildBaseExtra()
483
+ extra["videoId"] = videoId
484
+ if let title = videoTitle { extra["videoTitle"] = title }
485
+ if let props = customProperties {
486
+ for (k, v) in props { extra[k] = v }
487
+ if let dur = (props["playDurationMs"] as? NSNumber)?.int64Value {
488
+ event["duration"] = dur
489
+ } else if let dur = props["playDurationMs"] as? Int64 {
490
+ event["duration"] = dur
491
+ }
492
+ }
493
+ if let data = try? JSONSerialization.data(withJSONObject: extra), let str = String(data: data, encoding: .utf8) {
494
+ event["extraData"] = str
495
+ }
496
+ self.enqueueEvent(event)
497
+ self.flushAsync()
498
+ }
499
+ }
500
+
501
+ @objc public func trackVideoComment(videoId: Int64, videoTitle: String?, commentText: String?, customProperties: [String: Any]?) {
502
+ var props = customProperties ?? [:]
503
+ props["content"] = commentText ?? ""
504
+ trackVideo(eventName: "video_comment", videoId: videoId, videoTitle: videoTitle, customProperties: props)
505
+ }
506
+
507
+ @objc public func trackApi(url: String, method: String, statusCode: Int, duration: Int64, reqParam: String?, respData: String?, error: String?) {
508
+ guard !url.isEmpty, !url.contains("/api/point/task/trackBatch") else { return }
509
+ bgQueue.async { [weak self] in
510
+ guard let self = self else { return }
511
+ let isError = statusCode >= 400 || (error != nil && !error!.isEmpty)
512
+ let eventType = isError ? "api_error" : "api_request"
513
+ let apiPath = self.extractApiPath(url)
514
+ var event = self.createBaseEvent(eventType: eventType, eventName: apiPath)
515
+
516
+ var extra: [String: Any] = self.buildBaseExtra()
517
+ extra["url"] = url
518
+ extra["apiPath"] = apiPath
519
+ extra["method"] = method.uppercased()
520
+ extra["statusCode"] = statusCode
521
+ extra["duration"] = duration
522
+ if let req = reqParam { extra["requestParams"] = String(req.prefix(2048)) }
523
+ if let resp = respData { extra["responseData"] = String(resp.prefix(2048)) }
524
+ if let err = error { extra["error"] = String(err.prefix(512)) }
525
+ let reqId = self.extractRequestIdSafe(reqParam, respData)
526
+ if !reqId.isEmpty {
527
+ extra["requestId"] = reqId
528
+ }
529
+
530
+ if let data = try? JSONSerialization.data(withJSONObject: extra), let str = String(data: data, encoding: .utf8) {
531
+ event["extraData"] = str
532
+ }
533
+ self.enqueueEvent(event)
534
+ if isError {
535
+ self.flushAsync()
536
+ }
537
+ }
538
+ }
539
+
540
+ private func extractRequestIdSafe(_ reqParam: String?, _ respData: String?) -> String {
541
+ for str in [reqParam, respData] {
542
+ guard let text = str, text.contains("requestId") else { continue }
543
+ if let regex = try? NSRegularExpression(pattern: "\"requestId\"\\s*:\\s*\"([^\"]+)\"", options: []) {
544
+ let nsString = text as NSString
545
+ if let match = regex.firstMatch(in: text, options: [], range: NSRange(location: 0, length: nsString.length)), match.numberOfRanges > 1 {
546
+ let r = match.range(at: 1)
547
+ if r.location != NSNotFound {
548
+ return nsString.substring(with: r)
549
+ }
550
+ }
551
+ }
552
+ }
553
+ return ""
554
+ }
555
+
556
+ @objc public func trackFrontUrl(frontUrl: String, elementId: String?, postParam: String?) {
557
+ bgQueue.async { [weak self] in
558
+ guard let self = self else { return }
559
+ let eventType = "custom"
560
+ let targetId = !frontUrl.isEmpty ? frontUrl : (elementId ?? "")
561
+ var event = self.createBaseEvent(eventType: eventType, eventName: targetId)
562
+ event["frontUrl"] = targetId
563
+ event["elementId"] = targetId
564
+ if let post = postParam, !post.isEmpty {
565
+ event["postParam"] = post
566
+ event["extraKey"] = "param"
567
+ event["extraValue"] = post
568
+ var extra = self.buildBaseExtra()
569
+ extra["param"] = post
570
+ extra["getParam"] = post
571
+ extra["postParam"] = post
572
+ if let data = try? JSONSerialization.data(withJSONObject: extra), let str = String(data: data, encoding: .utf8) {
573
+ event["extraData"] = str
574
+ }
575
+ }
576
+ self.enqueueEvent(event)
577
+ self.flushAsync()
578
+ }
579
+ }
580
+
581
+ @objc public func handleNativeControlClick(control: UIControl, action: Selector, event: UIEvent?) {
582
+ // 过滤非点击结束状态(避免 touchDown、touchDragInside 等多次误触发)
583
+ if let touch = event?.allTouches?.first {
584
+ if touch.phase != .ended && touch.phase != .cancelled {
585
+ return
586
+ }
587
+ }
588
+
589
+ // 忽略纯文本输入框聚焦等事件
590
+ if control is UITextField || control is UITextView {
591
+ return
592
+ }
593
+
594
+ // 提取 elementId
595
+ var elementId = control.accessibilityIdentifier ?? ""
596
+ if elementId.isEmpty {
597
+ let actName = NSStringFromSelector(action).replacingOccurrences(of: ":", with: "")
598
+ if !actName.isEmpty {
599
+ elementId = actName
600
+ } else if control.tag > 0 {
601
+ elementId = "tag_\(control.tag)"
602
+ } else {
603
+ elementId = String(describing: type(of: control))
604
+ }
605
+ }
606
+
607
+ // 提取 elementText
608
+ var elementText = ""
609
+ if let btn = control as? UIButton {
610
+ elementText = btn.currentTitle ?? btn.titleLabel?.text ?? ""
611
+ }
612
+ if elementText.isEmpty {
613
+ elementText = findTextInView(control)
614
+ }
615
+ elementText = elementText.trimmingCharacters(in: .whitespacesAndNewlines)
616
+ if elementText.count > 30 {
617
+ elementText = String(elementText.prefix(30))
618
+ }
619
+
620
+ let key = "\(elementId)|\(elementText)"
621
+ let now = Date().timeIntervalSince1970
622
+ if key == lastClickKey && (now - lastClickTime < 0.8) {
623
+ return
624
+ }
625
+ lastClickKey = key
626
+ lastClickTime = now
627
+
628
+ let touchLocation = event?.allTouches?.first?.location(in: nil) ?? control.center
629
+ let pageX = touchLocation.x
630
+ let pageY = touchLocation.y
631
+ let viewType = String(describing: type(of: control))
632
+
633
+ bgQueue.async { [weak self] in
634
+ guard let self = self else { return }
635
+ var clickEvent = self.createBaseEvent(eventType: "click", eventName: "native_click")
636
+ clickEvent["elementId"] = elementId
637
+ if !elementText.isEmpty {
638
+ clickEvent["elementText"] = elementText
639
+ }
640
+ var extra = self.buildBaseExtra()
641
+ extra["viewType"] = viewType
642
+ extra["pageX"] = pageX
643
+ extra["pageY"] = pageY
644
+ extra["isNative"] = true
645
+ if let data = try? JSONSerialization.data(withJSONObject: extra), let str = String(data: data, encoding: .utf8) {
646
+ clickEvent["extraData"] = str
647
+ }
648
+ self.enqueueEvent(clickEvent)
649
+ self.flushAsync()
650
+ }
651
+ }
652
+
653
+ private func findTextInView(_ view: UIView) -> String {
654
+ for sub in view.subviews {
655
+ if let label = sub as? UILabel, let t = label.text, !t.isEmpty {
656
+ return t
657
+ }
658
+ let child = findTextInView(sub)
659
+ if !child.isEmpty {
660
+ return child
661
+ }
662
+ }
663
+ return ""
664
+ }
665
+
666
+ private func buildBaseExtra() -> [String: Any] {
667
+ var extra: [String: Any] = ["platform": "ios"]
668
+ if !phone.isEmpty { extra["phone"] = phone }
669
+ if !storeName.isEmpty { extra["storeName"] = storeName }
670
+ if !userName.isEmpty { extra["userName"] = userName }
671
+ if !userId.isEmpty { extra["userId"] = userId }
672
+ if pflag != -1 { extra["pflag"] = pflag }
673
+ if !platformName.isEmpty { extra["platformName"] = platformName }
674
+ return extra
675
+ }
676
+
677
+ private func createBaseEvent(eventType: String, eventName: String) -> [String: Any] {
678
+ sequence += 1
679
+ var extraStr = ""
680
+ let extra = buildBaseExtra()
681
+ if let data = try? JSONSerialization.data(withJSONObject: extra), let str = String(data: data, encoding: .utf8) {
682
+ extraStr = str
683
+ }
684
+
685
+ var event: [String: Any] = [
686
+ "eventId": "e_\(Int(Date().timeIntervalSince1970 * 1000))_\(Int.random(in: 1000...9999))",
687
+ "eventType": eventType,
688
+ "eventName": eventName,
689
+ "timestamp": Int(Date().timeIntervalSince1970 * 1000),
690
+ "sequence": sequence,
691
+ "sessionId": sessionId,
692
+ "os": "iOS",
693
+ "deviceBrand": "Apple",
694
+ "deviceModel": getExactDeviceModel(),
695
+ "osVersion": getNormalizedSystemVersion(),
696
+ "networkType": getNetworkType(),
697
+ "extraData": extraStr
698
+ ]
699
+ if !userId.isEmpty { event["userId"] = userId }
700
+ if !currentPageRoute.isEmpty {
701
+ event["pageRoute"] = currentPageRoute
702
+ let title = !currentPageTitle.isEmpty ? currentPageTitle : getFriendlyTitle(for: currentPageRoute)
703
+ if !title.isEmpty {
704
+ event["pageTitle"] = title
705
+ }
706
+ }
707
+ if !referrerPageRoute.isEmpty {
708
+ event["referrer"] = referrerPageRoute
709
+ }
710
+ return event
711
+ }
712
+
713
+ private func extractApiPath(_ url: String) -> String {
714
+ guard let u = URL(string: url) else { return url }
715
+ return u.path
716
+ }
717
+
718
+ // --- Persistence & Queue ---
719
+ private func enqueueEvent(_ event: [String: Any]) {
720
+ eventQueue.append(event)
721
+ saveDiskEvents()
722
+ if eventQueue.count >= 20 {
723
+ flushAsync()
724
+ }
725
+ }
726
+
727
+ private func getCacheFilePath() -> URL? {
728
+ FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent(cacheFileName)
729
+ }
730
+
731
+ private func saveDiskEvents() {
732
+ guard let path = getCacheFilePath() else { return }
733
+ let toSave = Array(eventQueue.suffix(maxDiskRecords))
734
+ if let data = try? JSONSerialization.data(withJSONObject: toSave) {
735
+ try? data.write(to: path)
736
+ }
737
+ }
738
+
739
+ private func loadDiskEvents() {
740
+ guard let path = getCacheFilePath(), let data = try? Data(contentsOf: path),
741
+ let list = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return }
742
+ eventQueue = list
743
+ }
744
+
745
+ private func startTimer() {
746
+ DispatchQueue.main.async {
747
+ self.flushTimer?.invalidate()
748
+ let timer = Timer(timeInterval: 3.0, repeats: true) { [weak self] _ in
749
+ self?.flushAsync()
750
+ }
751
+ RunLoop.main.add(timer, forMode: .common)
752
+ self.flushTimer = timer
753
+ }
754
+ }
755
+
756
+ @objc public func flushAsync() {
757
+ bgQueue.async { [weak self] in
758
+ self?.flushInternal()
759
+ }
760
+ }
761
+
762
+ @objc public func flushSync() {
763
+ let semaphore = DispatchSemaphore(value: 0)
764
+ bgQueue.async { [weak self] in
765
+ self?.flushInternal()
766
+ semaphore.signal()
767
+ }
768
+ _ = semaphore.wait(timeout: .now() + 1.5)
769
+ }
770
+
771
+ private func flushInternal() {
772
+ guard !eventQueue.isEmpty, !serverUrl.isEmpty else { return }
773
+ var batchCount = 0
774
+ while !eventQueue.isEmpty && batchCount < 5 {
775
+ batchCount += 1
776
+ let count = min(eventQueue.count, 20)
777
+ let batch = Array(eventQueue.prefix(count))
778
+ let success = sendBatchSync(events: batch)
779
+ if success {
780
+ eventQueue.removeFirst(count)
781
+ saveDiskEvents()
782
+ } else {
783
+ break
784
+ }
785
+ }
786
+ }
787
+
788
+ private func sendBatchSync(events: [[String: Any]]) -> Bool {
789
+ guard let url = URL(string: serverUrl) else { return false }
790
+ var request = URLRequest(url: url)
791
+ request.httpMethod = "POST"
792
+ request.setValue("application/json; charset=UTF-8", forHTTPHeaderField: "Content-Type")
793
+ request.setValue(appId, forHTTPHeaderField: "appId")
794
+ request.setValue(getDeviceId(), forHTTPHeaderField: "deviceId")
795
+ request.timeoutInterval = 5.0
796
+
797
+ var batchSessionId = sessionId
798
+ var batchUserId = userId
799
+ if let first = events.first {
800
+ if let s = first["sessionId"] as? String, !s.isEmpty { batchSessionId = s }
801
+ if let u = first["userId"] as? String, !u.isEmpty { batchUserId = u }
802
+ }
803
+
804
+ let body: [String: Any] = [
805
+ "appId": Int(appId) ?? 13,
806
+ "sessionId": batchSessionId,
807
+ "deviceId": getDeviceId(),
808
+ "userId": batchUserId,
809
+ "phone": phone,
810
+ "storeName": storeName,
811
+ "userName": userName,
812
+ "platform": "ios",
813
+ "networkType": getNetworkType(),
814
+ "events": events
815
+ ]
816
+ if pflag != -1 {
817
+ var mBody = body
818
+ mBody["pflag"] = pflag
819
+ if !platformName.isEmpty { mBody["platformName"] = platformName }
820
+ guard let jsonData = try? JSONSerialization.data(withJSONObject: mBody) else { return false }
821
+ request.httpBody = jsonData
822
+ } else {
823
+ guard let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return false }
824
+ request.httpBody = jsonData
825
+ }
826
+
827
+ let semaphore = DispatchSemaphore(value: 0)
828
+ var success = false
829
+
830
+ let task = URLSession.shared.dataTask(with: request) { _, response, error in
831
+ if error == nil, let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) {
832
+ success = true
833
+ }
834
+ semaphore.signal()
835
+ }
836
+ task.resume()
837
+ _ = semaphore.wait(timeout: .now() + 5.0)
838
+ return success
839
+ }
840
+
841
+ // --- Swizzling for UIViewController Auto-Tracking ---
842
+ private func swizzleUIViewController() {
843
+ let appearSelector = #selector(UIViewController.viewDidAppear(_:))
844
+ let swizzledAppear = #selector(UIViewController.rk_viewDidAppear(_:))
845
+ if let orig = class_getInstanceMethod(UIViewController.self, appearSelector),
846
+ let swiz = class_getInstanceMethod(UIViewController.self, swizzledAppear) {
847
+ method_exchangeImplementations(orig, swiz)
848
+ }
849
+
850
+ let disappearSelector = #selector(UIViewController.viewDidDisappear(_:))
851
+ let swizzledDisappear = #selector(UIViewController.rk_viewDidDisappear(_:))
852
+ if let orig = class_getInstanceMethod(UIViewController.self, disappearSelector),
853
+ let swiz = class_getInstanceMethod(UIViewController.self, swizzledDisappear) {
854
+ method_exchangeImplementations(orig, swiz)
855
+ }
856
+ }
857
+
858
+ // --- Swizzling for UIControl / UIButton Auto-Click Tracking (对齐 Android native_click) ---
859
+ private func swizzleUIApplication() {
860
+ let sendActionSelector = #selector(UIApplication.sendAction(_:to:from:for:))
861
+ let swizzledSendAction = #selector(UIApplication.rk_sendAction(_:to:from:for:))
862
+ if let orig = class_getInstanceMethod(UIApplication.self, sendActionSelector),
863
+ let swiz = class_getInstanceMethod(UIApplication.self, swizzledSendAction) {
864
+ method_exchangeImplementations(orig, swiz)
865
+ }
866
+ }
867
+ }
868
+
869
+ private var rkPageStartTimeKey: UInt8 = 0
870
+
871
+ extension UIViewController {
872
+ var rkPageStartTime: TimeInterval {
873
+ get { (objc_getAssociatedObject(self, &rkPageStartTimeKey) as? TimeInterval) ?? 0 }
874
+ set { objc_setAssociatedObject(self, &rkPageStartTimeKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
875
+ }
876
+
877
+ private func rkShouldTrackPage() -> Bool {
878
+ if self is UINavigationController || self is UITabBarController {
879
+ return false
880
+ }
881
+ let clsName = String(describing: type(of: self))
882
+ if clsName.hasPrefix("UI") || clsName.hasPrefix("_") {
883
+ return false
884
+ }
885
+ // 彻底过滤 iOS 15+ 预热机制(Prewarming)及系统快照/预览伪页面
886
+ if clsName.contains("Prewarming") || clsName.contains("Preview") || clsName.contains("Snapshot") {
887
+ return false
888
+ }
889
+ // 过滤系统辅助/键盘相关临时控制器
890
+ if clsName.contains("InputWindow") || clsName.contains("Compatibility") || clsName.contains("Alert") {
891
+ return false
892
+ }
893
+ let ignoredContainers = [
894
+ "CYLMainRootViewController",
895
+ "MainTabBarController",
896
+ "RRBaseNavigationController",
897
+ "RRHomeRecommendPageController"
898
+ ]
899
+ if ignoredContainers.contains(clsName) {
900
+ return false
901
+ }
902
+ return true
903
+ }
904
+
905
+ @objc func rk_viewDidAppear(_ animated: Bool) {
906
+ self.rk_viewDidAppear(animated)
907
+ guard self.rkShouldTrackPage() else { return }
908
+ var pageRoute = String(describing: type(of: self))
909
+ if let baseWeb = self as? NSObject, baseWeb.responds(to: Selector(("rrTag"))),
910
+ let tag = baseWeb.value(forKey: "rrTag") as? String, !tag.isEmpty {
911
+ pageRoute = "\(pageRoute)/\(tag)"
912
+ }
913
+ self.rkPageStartTime = Date().timeIntervalSince1970
914
+ let friendly = RKTrackerSDK.shared.getFriendlyTitle(for: pageRoute)
915
+ RKTrackerSDK.shared.trackPageView(pageRoute: pageRoute, pageTitle: friendly)
916
+ }
917
+
918
+ @objc func rk_viewDidDisappear(_ animated: Bool) {
919
+ self.rk_viewDidDisappear(animated)
920
+ guard self.rkShouldTrackPage() else { return }
921
+ var pageRoute = String(describing: type(of: self))
922
+ if let baseWeb = self as? NSObject, baseWeb.responds(to: Selector(("rrTag"))),
923
+ let tag = baseWeb.value(forKey: "rrTag") as? String, !tag.isEmpty {
924
+ pageRoute = "\(pageRoute)/\(tag)"
925
+ }
926
+ if self.rkPageStartTime > 0 {
927
+ let durMs = Int64((Date().timeIntervalSince1970 - self.rkPageStartTime) * 1000)
928
+ if durMs >= 500 {
929
+ let friendly = RKTrackerSDK.shared.getFriendlyTitle(for: pageRoute)
930
+ RKTrackerSDK.shared.trackPageStay(pageRoute: pageRoute, pageTitle: friendly, durationMs: durMs)
931
+ }
932
+ self.rkPageStartTime = 0
933
+ }
934
+ }
935
+ }
936
+
937
+ extension UIApplication {
938
+ @objc func rk_sendAction(_ action: Selector, to target: Any?, from sender: Any?, for event: UIEvent?) -> Bool {
939
+ if let control = sender as? UIControl {
940
+ RKTrackerSDK.shared.handleNativeControlClick(control: control, action: action, event: event)
941
+ }
942
+ return self.rk_sendAction(action, to: target, from: sender, for: event)
943
+ }
944
+ }