@rk911/tracker-sdk 1.7.16 → 1.7.18

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,1057 @@
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 < 20 {
292
+ if ver.patchVersion > 0 {
293
+ return "\(ver.majorVersion).\(ver.minorVersion).\(ver.patchVersion)"
294
+ }
295
+ return "\(ver.majorVersion).\(ver.minorVersion)"
296
+ }
297
+ // 当运行在 Mac Catalyst、iPad 模拟器或开发机 Darwin 内核基线 (如 26.5) 时,规整至真实商业版本
298
+ if ver.majorVersion >= 20 {
299
+ let mappedMajor = max(16, min(18, ver.majorVersion - 8))
300
+ return "\(mappedMajor).\(ver.minorVersion)"
301
+ }
302
+ let sys = UIDevice.current.systemVersion
303
+ if !sys.isEmpty && !sys.hasPrefix("2") {
304
+ return sys
305
+ }
306
+ return "18.2"
307
+ }
308
+
309
+ @objc public func getExactDeviceModel() -> String {
310
+ var systemInfo = utsname()
311
+ uname(&systemInfo)
312
+ let machineMirror = Mirror(reflecting: systemInfo.machine)
313
+ let identifier = machineMirror.children.reduce("") { identifier, element in
314
+ guard let value = element.value as? Int8, value != 0 else { return identifier }
315
+ return identifier + String(UnicodeScalar(UInt8(value)))
316
+ }
317
+ if identifier.isEmpty {
318
+ return UIDevice.current.model
319
+ }
320
+ let modelMap: [String: String] = [
321
+ "iPhone10,1": "iPhone 8", "iPhone10,4": "iPhone 8",
322
+ "iPhone10,2": "iPhone 8 Plus", "iPhone10,5": "iPhone 8 Plus",
323
+ "iPhone10,3": "iPhone X", "iPhone10,6": "iPhone X",
324
+ "iPhone11,2": "iPhone XS",
325
+ "iPhone11,4": "iPhone XS Max", "iPhone11,6": "iPhone XS Max",
326
+ "iPhone11,8": "iPhone XR",
327
+ "iPhone12,1": "iPhone 11",
328
+ "iPhone12,3": "iPhone 11 Pro",
329
+ "iPhone12,5": "iPhone 11 Pro Max",
330
+ "iPhone12,8": "iPhone SE (2nd gen)",
331
+ "iPhone13,1": "iPhone 12 mini",
332
+ "iPhone13,2": "iPhone 12",
333
+ "iPhone13,3": "iPhone 12 Pro",
334
+ "iPhone13,4": "iPhone 12 Pro Max",
335
+ "iPhone14,4": "iPhone 13 mini",
336
+ "iPhone14,5": "iPhone 13",
337
+ "iPhone14,2": "iPhone 13 Pro",
338
+ "iPhone14,3": "iPhone 13 Pro Max",
339
+ "iPhone14,6": "iPhone SE (3rd gen)",
340
+ "iPhone14,7": "iPhone 14",
341
+ "iPhone14,8": "iPhone 14 Plus",
342
+ "iPhone15,2": "iPhone 14 Pro",
343
+ "iPhone15,3": "iPhone 14 Pro Max",
344
+ "iPhone15,4": "iPhone 15",
345
+ "iPhone15,5": "iPhone 15 Plus",
346
+ "iPhone16,1": "iPhone 15 Pro",
347
+ "iPhone16,2": "iPhone 15 Pro Max",
348
+ "iPhone17,3": "iPhone 16",
349
+ "iPhone17,4": "iPhone 16 Plus",
350
+ "iPhone17,1": "iPhone 16 Pro",
351
+ "iPhone17,2": "iPhone 16 Pro Max",
352
+ "i386": "iPhone Simulator",
353
+ "x86_64": "iPhone Simulator",
354
+ "arm64": "iPhone Simulator"
355
+ ]
356
+ return modelMap[identifier] ?? identifier
357
+ }
358
+
359
+ @objc public func getFriendlyTitle(for route: String) -> String {
360
+ if let name = RKTrackerSDK.friendlyTitles[route] {
361
+ return name
362
+ }
363
+ for (k, v) in RKTrackerSDK.friendlyTitles {
364
+ if route == k || route.hasPrefix(k) { return v }
365
+ }
366
+ // 语义化降级兜底
367
+ if route.contains("ShortVideo") || route.contains("Recommend") { return "小视频推荐" }
368
+ if route.contains("PlayletHistory") { return "短剧历史" }
369
+ if route.contains("PlayletPlay") { return "短剧播放页" }
370
+ if route.contains("Playlet") { return "短剧" }
371
+ if route.contains("Video") { return "视频页" }
372
+ if route.contains("Live") || route.contains("PLV") { return "直播" }
373
+ if route.contains("Login") { return "登录" }
374
+ if route.contains("Mine") { return "个人中心" }
375
+ if route.contains("Scan") { return "扫码页" }
376
+ if route.contains("Web") { return "Web视图" }
377
+ return route
378
+ }
379
+
380
+ @objc public func getNativeContextJson() -> String {
381
+ let dict: [String: Any] = [
382
+ "appId": appId,
383
+ "deviceId": getDeviceId(),
384
+ "userId": userId,
385
+ "phone": phone,
386
+ "sessionId": sessionId,
387
+ "appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0",
388
+ "platform": "ios",
389
+ "os": "iOS",
390
+ "brand": "Apple",
391
+ "deviceBrand": "Apple",
392
+ "model": "iPhone",
393
+ "deviceModel": getExactDeviceModel(),
394
+ "osVersion": getNormalizedSystemVersion(),
395
+ "networkType": getNetworkType(),
396
+ "currentSequence": sequence,
397
+ "pflag": pflag,
398
+ "pFlagName": platformName
399
+ ]
400
+ if let data = try? JSONSerialization.data(withJSONObject: dict), let str = String(data: data, encoding: .utf8) {
401
+ return str
402
+ }
403
+ return "{}"
404
+ }
405
+
406
+ @objc public func track(eventName: String, properties: [String: Any]?) {
407
+ trackCustom(eventName: eventName, elementText: nil, properties: properties)
408
+ }
409
+
410
+ @objc public func trackCustom(eventName: String, elementText: String?, properties: [String: Any]?) {
411
+ bgQueue.async { [weak self] in
412
+ guard let self = self else { return }
413
+ var event = self.createBaseEvent(eventType: "custom", eventName: eventName)
414
+ if let txt = elementText, !txt.isEmpty {
415
+ event["elementText"] = txt
416
+ }
417
+ if let props = properties {
418
+ var extra: [String: Any] = self.buildBaseExtra()
419
+ for (k, v) in props { extra[k] = v }
420
+ if let data = try? JSONSerialization.data(withJSONObject: extra), let str = String(data: data, encoding: .utf8) {
421
+ event["extraData"] = str
422
+ }
423
+ }
424
+ self.enqueueEvent(event)
425
+ self.flushAsync()
426
+ }
427
+ }
428
+
429
+ @objc public func trackPageView(pageRoute: String, pageTitle: String) {
430
+ bgQueue.async { [weak self] in
431
+ guard let self = self else { return }
432
+ let now = Date().timeIntervalSince1970
433
+ // 500ms 内相同路由防抖,避免生命周期和手动调用重复触发
434
+ if pageRoute == self.currentPageRoute && (now - self.lastPageStartTime < 0.5) {
435
+ return
436
+ }
437
+ self.referrerPageRoute = self.currentPageRoute
438
+ self.currentPageRoute = pageRoute
439
+ let title = pageTitle.isEmpty ? self.getFriendlyTitle(for: pageRoute) : pageTitle
440
+ self.currentPageTitle = title
441
+ self.lastPageStartTime = now
442
+
443
+ var event = self.createBaseEvent(eventType: "page_view", eventName: "page_view")
444
+ event["pageRoute"] = pageRoute
445
+ event["pageTitle"] = title
446
+ self.enqueueEvent(event)
447
+ self.flushAsync()
448
+ }
449
+ }
450
+
451
+ @objc public func trackPageStay(pageRoute: String, pageTitle: String, durationMs: Int64) {
452
+ guard durationMs >= 500 else { return }
453
+ bgQueue.async { [weak self] in
454
+ guard let self = self else { return }
455
+ var event = self.createBaseEvent(eventType: "page_stay", eventName: "page_stay")
456
+ event["pageRoute"] = pageRoute
457
+ let title = pageTitle.isEmpty ? self.getFriendlyTitle(for: pageRoute) : pageTitle
458
+ event["pageTitle"] = title
459
+ event["duration"] = durationMs
460
+ self.enqueueEvent(event)
461
+ self.flushAsync()
462
+ }
463
+ }
464
+
465
+ @objc public func trackVideo(eventName: String, videoId: Int64, videoTitle: String?, customProperties: [String: Any]?) {
466
+ bgQueue.async { [weak self] in
467
+ guard let self = self else { return }
468
+ if eventName == "video_play_end", let props = customProperties {
469
+ let dur = (props["playDurationMs"] as? NSNumber)?.int64Value ?? (props["playDurationMs"] as? Int64 ?? 0)
470
+ let reason = (props["endReason"] as? String) ?? ""
471
+ if dur < 200 && reason == "refresh_list" {
472
+ return
473
+ }
474
+ }
475
+ var event = self.createBaseEvent(eventType: "video", eventName: eventName)
476
+ event["elementId"] = "video_\(videoId)"
477
+ if let title = videoTitle, !title.isEmpty {
478
+ event["elementText"] = title
479
+ } else {
480
+ event["elementText"] = "video_\(videoId)"
481
+ }
482
+ if let props = customProperties {
483
+ if let pr = props["pageRoute"] as? String, !pr.isEmpty {
484
+ event["pageRoute"] = pr
485
+ }
486
+ if let pt = props["pageTitle"] as? String, !pt.isEmpty {
487
+ event["pageTitle"] = pt
488
+ }
489
+ }
490
+ var extra: [String: Any] = self.buildBaseExtra()
491
+ extra["videoId"] = videoId
492
+ if let title = videoTitle { extra["videoTitle"] = title }
493
+ if let props = customProperties {
494
+ for (k, v) in props { extra[k] = v }
495
+ if let dur = (props["playDurationMs"] as? NSNumber)?.int64Value {
496
+ event["duration"] = dur
497
+ } else if let dur = props["playDurationMs"] as? Int64 {
498
+ event["duration"] = dur
499
+ }
500
+ }
501
+ if let data = try? JSONSerialization.data(withJSONObject: extra), let str = String(data: data, encoding: .utf8) {
502
+ event["extraData"] = str
503
+ }
504
+ self.enqueueEvent(event)
505
+ self.flushAsync()
506
+ }
507
+ }
508
+
509
+ @objc public func trackVideoComment(videoId: Int64, videoTitle: String?, commentText: String?, customProperties: [String: Any]?) {
510
+ var props = customProperties ?? [:]
511
+ props["content"] = commentText ?? ""
512
+ trackVideo(eventName: "video_comment", videoId: videoId, videoTitle: videoTitle, customProperties: props)
513
+ }
514
+
515
+ @objc public func trackApi(url: String, method: String, statusCode: Int, duration: Int64, reqParam: String?, respData: String?, error: String?) {
516
+ guard !url.isEmpty, !url.contains("/api/point/task/trackBatch") else { return }
517
+ bgQueue.async { [weak self] in
518
+ guard let self = self else { return }
519
+ let isError = statusCode >= 400 || (error != nil && !error!.isEmpty)
520
+ let eventType = isError ? "api_error" : "api_request"
521
+ let apiPath = self.extractApiPath(url)
522
+ var event = self.createBaseEvent(eventType: eventType, eventName: apiPath)
523
+
524
+ var extra: [String: Any] = self.buildBaseExtra()
525
+ extra["url"] = url
526
+ extra["apiPath"] = apiPath
527
+ extra["method"] = method.uppercased()
528
+ extra["statusCode"] = statusCode
529
+ extra["duration"] = duration
530
+ if let req = reqParam { extra["requestParams"] = String(req.prefix(2048)) }
531
+ if let resp = respData { extra["responseData"] = String(resp.prefix(2048)) }
532
+ if let err = error { extra["error"] = String(err.prefix(512)) }
533
+ let reqId = self.extractRequestIdSafe(reqParam, respData)
534
+ if !reqId.isEmpty {
535
+ extra["requestId"] = reqId
536
+ }
537
+
538
+ if let data = try? JSONSerialization.data(withJSONObject: extra), let str = String(data: data, encoding: .utf8) {
539
+ event["extraData"] = str
540
+ }
541
+ self.enqueueEvent(event)
542
+ if isError {
543
+ self.flushAsync()
544
+ }
545
+ }
546
+ }
547
+
548
+ private static let jsonRequestIdRegex: NSRegularExpression? = {
549
+ return try? NSRegularExpression(pattern: "\"requestId\"\\s*:\\s*\"([^\"\\r\\n]+)\"", options: [])
550
+ }()
551
+
552
+ private static let queryRequestIdRegex: NSRegularExpression? = {
553
+ return try? NSRegularExpression(pattern: "(?:^|[?&])requestId=([^&#\\s]+)", options: [])
554
+ }()
555
+
556
+ private func extractRequestIdSafe(_ reqParam: String?, _ respData: String?) -> String {
557
+ let reqId = extractRequestIdFromText(reqParam)
558
+ if !reqId.isEmpty {
559
+ return reqId
560
+ }
561
+ return extractRequestIdFromText(respData)
562
+ }
563
+
564
+ private func extractRequestIdFromText(_ text: String?) -> String {
565
+ guard let text = text, text.contains("requestId") else { return "" }
566
+ let nsString = text as NSString
567
+ let fullRange = NSRange(location: 0, length: nsString.length)
568
+
569
+ // 1. 优先匹配 JSON 格式: "requestId": "xxx"
570
+ if let regex = RKTrackerSDK.jsonRequestIdRegex,
571
+ let match = regex.firstMatch(in: text, options: [], range: fullRange),
572
+ match.numberOfRanges > 1 {
573
+ let r = match.range(at: 1)
574
+ if r.location != NSNotFound {
575
+ let candidate = nsString.substring(with: r).trimmingCharacters(in: .whitespacesAndNewlines)
576
+ if isValidRequestId(candidate) {
577
+ return candidate
578
+ }
579
+ }
580
+ }
581
+
582
+ // 2. 匹配 Query String 格式: requestId=xxx
583
+ if let regex = RKTrackerSDK.queryRequestIdRegex,
584
+ let match = regex.firstMatch(in: text, options: [], range: fullRange),
585
+ match.numberOfRanges > 1 {
586
+ let r = match.range(at: 1)
587
+ if r.location != NSNotFound {
588
+ let candidate = nsString.substring(with: r).trimmingCharacters(in: .whitespacesAndNewlines)
589
+ if isValidRequestId(candidate) {
590
+ return candidate
591
+ }
592
+ }
593
+ }
594
+
595
+ return ""
596
+ }
597
+
598
+ private func isValidRequestId(_ val: String) -> Bool {
599
+ let trimmed = val.trimmingCharacters(in: .whitespacesAndNewlines)
600
+ if trimmed.isEmpty { return false }
601
+ let lower = trimmed.lowercased()
602
+ return lower != "null" && lower != "undefined"
603
+ }
604
+
605
+ @objc public func trackFrontUrl(frontUrl: String, elementId: String?, postParam: String?) {
606
+ bgQueue.async { [weak self] in
607
+ guard let self = self else { return }
608
+ let eventType = "custom"
609
+ let targetId = !frontUrl.isEmpty ? frontUrl : (elementId ?? "")
610
+ var event = self.createBaseEvent(eventType: eventType, eventName: targetId)
611
+ event["frontUrl"] = targetId
612
+ event["elementId"] = targetId
613
+ if let post = postParam, !post.isEmpty {
614
+ event["postParam"] = post
615
+ event["extraKey"] = "param"
616
+ event["extraValue"] = post
617
+ var extra = self.buildBaseExtra()
618
+ extra["param"] = post
619
+ extra["getParam"] = post
620
+ extra["postParam"] = post
621
+ if let data = try? JSONSerialization.data(withJSONObject: extra), let str = String(data: data, encoding: .utf8) {
622
+ event["extraData"] = str
623
+ }
624
+ }
625
+ self.enqueueEvent(event)
626
+ self.flushAsync()
627
+ }
628
+ }
629
+
630
+ @objc public func handleNativeControlClick(control: UIControl, action: Selector, event: UIEvent?) {
631
+ // 过滤非点击结束状态(避免 touchDown、touchDragInside 等多次误触发)
632
+ if let touch = event?.allTouches?.first {
633
+ if touch.phase != .ended && touch.phase != .cancelled {
634
+ return
635
+ }
636
+ }
637
+
638
+ // 忽略纯文本输入框聚焦等事件
639
+ if control is UITextField || control is UITextView {
640
+ return
641
+ }
642
+
643
+ // 提取 elementId
644
+ var elementId = control.accessibilityIdentifier ?? ""
645
+ if elementId.isEmpty {
646
+ let actName = NSStringFromSelector(action).replacingOccurrences(of: ":", with: "")
647
+ if !actName.isEmpty {
648
+ elementId = actName
649
+ } else if control.tag > 0 {
650
+ elementId = "tag_\(control.tag)"
651
+ } else {
652
+ elementId = String(describing: type(of: control))
653
+ }
654
+ }
655
+
656
+ // 提取 elementText
657
+ var elementText = ""
658
+ if let btn = control as? UIButton {
659
+ elementText = btn.currentTitle ?? btn.titleLabel?.text ?? ""
660
+ }
661
+ if elementText.isEmpty {
662
+ elementText = findTextInView(control)
663
+ }
664
+ elementText = elementText.trimmingCharacters(in: .whitespacesAndNewlines)
665
+ if elementText.count > 30 {
666
+ elementText = String(elementText.prefix(30))
667
+ }
668
+
669
+ let key = "\(elementId)|\(elementText)"
670
+ let now = Date().timeIntervalSince1970
671
+ if key == lastClickKey && (now - lastClickTime < 0.8) {
672
+ return
673
+ }
674
+ lastClickKey = key
675
+ lastClickTime = now
676
+
677
+ let touchLocation = event?.allTouches?.first?.location(in: nil) ?? control.center
678
+ let pageX = touchLocation.x
679
+ let pageY = touchLocation.y
680
+ let viewType = String(describing: type(of: control))
681
+
682
+ bgQueue.async { [weak self] in
683
+ guard let self = self else { return }
684
+ var clickEvent = self.createBaseEvent(eventType: "click", eventName: "native_click")
685
+ clickEvent["elementId"] = elementId
686
+ if !elementText.isEmpty {
687
+ clickEvent["elementText"] = elementText
688
+ }
689
+ var extra = self.buildBaseExtra()
690
+ extra["viewType"] = viewType
691
+ extra["pageX"] = pageX
692
+ extra["pageY"] = pageY
693
+ extra["isNative"] = true
694
+ if let data = try? JSONSerialization.data(withJSONObject: extra), let str = String(data: data, encoding: .utf8) {
695
+ clickEvent["extraData"] = str
696
+ }
697
+ self.enqueueEvent(clickEvent)
698
+ self.flushAsync()
699
+ }
700
+ }
701
+
702
+ @objc public func handleNativeViewClick(view: UIView, actionName: String?) {
703
+ guard isInitialized else { return }
704
+ if view is UITextField || view is UITextView {
705
+ return
706
+ }
707
+
708
+ var elementId = view.accessibilityIdentifier ?? ""
709
+ if elementId.isEmpty {
710
+ if let act = actionName, !act.isEmpty {
711
+ elementId = act.replacingOccurrences(of: ":", with: "")
712
+ } else if view.tag > 0 {
713
+ elementId = "tag_\(view.tag)"
714
+ } else {
715
+ elementId = String(describing: type(of: view))
716
+ }
717
+ }
718
+
719
+ var elementText = ""
720
+ if let btn = view as? UIButton {
721
+ elementText = btn.currentTitle ?? btn.titleLabel?.text ?? ""
722
+ }
723
+ if elementText.isEmpty {
724
+ elementText = findTextInView(view)
725
+ }
726
+ elementText = elementText.trimmingCharacters(in: .whitespacesAndNewlines)
727
+ if elementText.count > 30 {
728
+ elementText = String(elementText.prefix(30))
729
+ }
730
+
731
+ let key = "\(elementId)|\(elementText)"
732
+ let now = Date().timeIntervalSince1970
733
+ if key == lastClickKey && (now - lastClickTime < 0.8) {
734
+ return
735
+ }
736
+ lastClickKey = key
737
+ lastClickTime = now
738
+
739
+ let centerInWindow = view.superview?.convert(view.center, to: nil) ?? view.center
740
+ let pageX = centerInWindow.x
741
+ let pageY = centerInWindow.y
742
+ let viewType = String(describing: type(of: view))
743
+
744
+ bgQueue.async { [weak self] in
745
+ guard let self = self else { return }
746
+ var clickEvent = self.createBaseEvent(eventType: "click", eventName: "native_click")
747
+ clickEvent["elementId"] = elementId
748
+ if !elementText.isEmpty {
749
+ clickEvent["elementText"] = elementText
750
+ }
751
+ var extra = self.buildBaseExtra()
752
+ extra["viewType"] = viewType
753
+ extra["pageX"] = pageX
754
+ extra["pageY"] = pageY
755
+ extra["isNative"] = true
756
+ if let data = try? JSONSerialization.data(withJSONObject: extra), let str = String(data: data, encoding: .utf8) {
757
+ clickEvent["extraData"] = str
758
+ }
759
+ self.enqueueEvent(clickEvent)
760
+ self.flushAsync()
761
+ }
762
+ }
763
+
764
+ private func findTextInView(_ view: UIView) -> String {
765
+ for sub in view.subviews {
766
+ if let label = sub as? UILabel, let t = label.text, !t.isEmpty {
767
+ return t
768
+ }
769
+ let child = findTextInView(sub)
770
+ if !child.isEmpty {
771
+ return child
772
+ }
773
+ }
774
+ return ""
775
+ }
776
+
777
+ private func buildBaseExtra() -> [String: Any] {
778
+ var extra: [String: Any] = ["platform": "ios"]
779
+ if !phone.isEmpty { extra["phone"] = phone }
780
+ if !storeName.isEmpty { extra["storeName"] = storeName }
781
+ if !userName.isEmpty { extra["userName"] = userName }
782
+ if !userId.isEmpty { extra["userId"] = userId }
783
+ if pflag != -1 { extra["pflag"] = pflag }
784
+ if !platformName.isEmpty { extra["platformName"] = platformName }
785
+ return extra
786
+ }
787
+
788
+ private func createBaseEvent(eventType: String, eventName: String) -> [String: Any] {
789
+ sequence += 1
790
+ var extraStr = ""
791
+ let extra = buildBaseExtra()
792
+ if let data = try? JSONSerialization.data(withJSONObject: extra), let str = String(data: data, encoding: .utf8) {
793
+ extraStr = str
794
+ }
795
+
796
+ var event: [String: Any] = [
797
+ "eventId": "e_\(Int(Date().timeIntervalSince1970 * 1000))_\(Int.random(in: 1000...9999))",
798
+ "eventType": eventType,
799
+ "eventName": eventName,
800
+ "timestamp": Int(Date().timeIntervalSince1970 * 1000),
801
+ "sequence": sequence,
802
+ "sessionId": sessionId,
803
+ "os": "iOS",
804
+ "deviceBrand": "Apple",
805
+ "deviceModel": getExactDeviceModel(),
806
+ "osVersion": getNormalizedSystemVersion(),
807
+ "networkType": getNetworkType(),
808
+ "extraData": extraStr
809
+ ]
810
+ if !userId.isEmpty { event["userId"] = userId }
811
+ if !currentPageRoute.isEmpty {
812
+ event["pageRoute"] = currentPageRoute
813
+ let title = !currentPageTitle.isEmpty ? currentPageTitle : getFriendlyTitle(for: currentPageRoute)
814
+ if !title.isEmpty {
815
+ event["pageTitle"] = title
816
+ }
817
+ }
818
+ if !referrerPageRoute.isEmpty {
819
+ event["referrer"] = referrerPageRoute
820
+ }
821
+ return event
822
+ }
823
+
824
+ private func extractApiPath(_ url: String) -> String {
825
+ guard let u = URL(string: url) else { return url }
826
+ return u.path
827
+ }
828
+
829
+ // --- Persistence & Queue ---
830
+ private func enqueueEvent(_ event: [String: Any]) {
831
+ eventQueue.append(event)
832
+ saveDiskEvents()
833
+ if eventQueue.count >= 20 {
834
+ flushAsync()
835
+ }
836
+ }
837
+
838
+ private func getCacheFilePath() -> URL? {
839
+ FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent(cacheFileName)
840
+ }
841
+
842
+ private func saveDiskEvents() {
843
+ guard let path = getCacheFilePath() else { return }
844
+ let toSave = Array(eventQueue.suffix(maxDiskRecords))
845
+ if let data = try? JSONSerialization.data(withJSONObject: toSave) {
846
+ try? data.write(to: path)
847
+ }
848
+ }
849
+
850
+ private func loadDiskEvents() {
851
+ guard let path = getCacheFilePath(), let data = try? Data(contentsOf: path),
852
+ let list = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return }
853
+ eventQueue = list
854
+ }
855
+
856
+ private func startTimer() {
857
+ DispatchQueue.main.async {
858
+ self.flushTimer?.invalidate()
859
+ let timer = Timer(timeInterval: 3.0, repeats: true) { [weak self] _ in
860
+ self?.flushAsync()
861
+ }
862
+ RunLoop.main.add(timer, forMode: .common)
863
+ self.flushTimer = timer
864
+ }
865
+ }
866
+
867
+ @objc public func flushAsync() {
868
+ bgQueue.async { [weak self] in
869
+ self?.flushInternal()
870
+ }
871
+ }
872
+
873
+ @objc public func flushSync() {
874
+ let semaphore = DispatchSemaphore(value: 0)
875
+ bgQueue.async { [weak self] in
876
+ self?.flushInternal()
877
+ semaphore.signal()
878
+ }
879
+ _ = semaphore.wait(timeout: .now() + 1.5)
880
+ }
881
+
882
+ private func flushInternal() {
883
+ guard !eventQueue.isEmpty, !serverUrl.isEmpty else { return }
884
+ var batchCount = 0
885
+ while !eventQueue.isEmpty && batchCount < 5 {
886
+ batchCount += 1
887
+ let count = min(eventQueue.count, 20)
888
+ let batch = Array(eventQueue.prefix(count))
889
+ let success = sendBatchSync(events: batch)
890
+ if success {
891
+ eventQueue.removeFirst(count)
892
+ saveDiskEvents()
893
+ } else {
894
+ break
895
+ }
896
+ }
897
+ }
898
+
899
+ private func sendBatchSync(events: [[String: Any]]) -> Bool {
900
+ guard let url = URL(string: serverUrl) else { return false }
901
+ var request = URLRequest(url: url)
902
+ request.httpMethod = "POST"
903
+ request.setValue("application/json; charset=UTF-8", forHTTPHeaderField: "Content-Type")
904
+ request.setValue(appId, forHTTPHeaderField: "appId")
905
+ request.setValue(getDeviceId(), forHTTPHeaderField: "deviceId")
906
+ request.timeoutInterval = 5.0
907
+
908
+ var batchSessionId = sessionId
909
+ var batchUserId = userId
910
+ if let first = events.first {
911
+ if let s = first["sessionId"] as? String, !s.isEmpty { batchSessionId = s }
912
+ if let u = first["userId"] as? String, !u.isEmpty { batchUserId = u }
913
+ }
914
+
915
+ let body: [String: Any] = [
916
+ "appId": Int(appId) ?? 13,
917
+ "sessionId": batchSessionId,
918
+ "deviceId": getDeviceId(),
919
+ "userId": batchUserId,
920
+ "phone": phone,
921
+ "storeName": storeName,
922
+ "userName": userName,
923
+ "platform": "ios",
924
+ "networkType": getNetworkType(),
925
+ "events": events
926
+ ]
927
+ if pflag != -1 {
928
+ var mBody = body
929
+ mBody["pflag"] = pflag
930
+ if !platformName.isEmpty { mBody["platformName"] = platformName }
931
+ guard let jsonData = try? JSONSerialization.data(withJSONObject: mBody) else { return false }
932
+ request.httpBody = jsonData
933
+ } else {
934
+ guard let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return false }
935
+ request.httpBody = jsonData
936
+ }
937
+
938
+ let semaphore = DispatchSemaphore(value: 0)
939
+ var success = false
940
+
941
+ let task = URLSession.shared.dataTask(with: request) { _, response, error in
942
+ if error == nil, let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) {
943
+ success = true
944
+ }
945
+ semaphore.signal()
946
+ }
947
+ task.resume()
948
+ _ = semaphore.wait(timeout: .now() + 5.0)
949
+ return success
950
+ }
951
+
952
+ // --- Swizzling for UIViewController Auto-Tracking ---
953
+ private func swizzleUIViewController() {
954
+ let appearSelector = #selector(UIViewController.viewDidAppear(_:))
955
+ let swizzledAppear = #selector(UIViewController.rk_viewDidAppear(_:))
956
+ if let orig = class_getInstanceMethod(UIViewController.self, appearSelector),
957
+ let swiz = class_getInstanceMethod(UIViewController.self, swizzledAppear) {
958
+ method_exchangeImplementations(orig, swiz)
959
+ }
960
+
961
+ let disappearSelector = #selector(UIViewController.viewDidDisappear(_:))
962
+ let swizzledDisappear = #selector(UIViewController.rk_viewDidDisappear(_:))
963
+ if let orig = class_getInstanceMethod(UIViewController.self, disappearSelector),
964
+ let swiz = class_getInstanceMethod(UIViewController.self, swizzledDisappear) {
965
+ method_exchangeImplementations(orig, swiz)
966
+ }
967
+ }
968
+
969
+ // --- Swizzling for UIControl / UIButton Auto-Click Tracking (对齐 Android native_click) ---
970
+ private func swizzleUIApplication() {
971
+ let sendActionSelector = #selector(UIApplication.sendAction(_:to:from:for:))
972
+ let swizzledSendAction = #selector(UIApplication.rk_sendAction(_:to:from:for:))
973
+ if let orig = class_getInstanceMethod(UIApplication.self, sendActionSelector),
974
+ let swiz = class_getInstanceMethod(UIApplication.self, swizzledSendAction) {
975
+ method_exchangeImplementations(orig, swiz)
976
+ }
977
+ }
978
+ }
979
+
980
+ private var rkPageStartTimeKey: UInt8 = 0
981
+
982
+ extension UIViewController {
983
+ var rkPageStartTime: TimeInterval {
984
+ get { (objc_getAssociatedObject(self, &rkPageStartTimeKey) as? TimeInterval) ?? 0 }
985
+ set { objc_setAssociatedObject(self, &rkPageStartTimeKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
986
+ }
987
+
988
+ private func rkShouldTrackPage() -> Bool {
989
+ if self is UINavigationController || self is UITabBarController {
990
+ return false
991
+ }
992
+ let clsName = String(describing: type(of: self))
993
+ if clsName.hasPrefix("UI") || clsName.hasPrefix("_") {
994
+ return false
995
+ }
996
+ // 彻底过滤 iOS 15+ 预热机制(Prewarming)及系统快照/预览伪页面
997
+ if clsName.contains("Prewarming") || clsName.contains("Preview") || clsName.contains("Snapshot") {
998
+ return false
999
+ }
1000
+ // 过滤系统辅助/键盘相关临时控制器及三方技术容器
1001
+ if clsName.contains("InputWindow") || clsName.contains("Compatibility") || clsName.contains("Alert") || clsName.contains("GKPageList") {
1002
+ return false
1003
+ }
1004
+ let ignoredContainers = [
1005
+ "CYLMainRootViewController",
1006
+ "MainTabBarController",
1007
+ "RRBaseNavigationController",
1008
+ "RRHomeRecommendPageController",
1009
+ "GKPageListContainerViewController",
1010
+ "GKPageListViewController"
1011
+ ]
1012
+ if ignoredContainers.contains(clsName) {
1013
+ return false
1014
+ }
1015
+ return true
1016
+ }
1017
+
1018
+ @objc func rk_viewDidAppear(_ animated: Bool) {
1019
+ self.rk_viewDidAppear(animated)
1020
+ guard self.rkShouldTrackPage() else { return }
1021
+ var pageRoute = String(describing: type(of: self))
1022
+ if let baseWeb = self as? NSObject, baseWeb.responds(to: Selector(("rrTag"))),
1023
+ let tag = baseWeb.value(forKey: "rrTag") as? String, !tag.isEmpty {
1024
+ pageRoute = "\(pageRoute)/\(tag)"
1025
+ }
1026
+ self.rkPageStartTime = Date().timeIntervalSince1970
1027
+ let friendly = RKTrackerSDK.shared.getFriendlyTitle(for: pageRoute)
1028
+ RKTrackerSDK.shared.trackPageView(pageRoute: pageRoute, pageTitle: friendly)
1029
+ }
1030
+
1031
+ @objc func rk_viewDidDisappear(_ animated: Bool) {
1032
+ self.rk_viewDidDisappear(animated)
1033
+ guard self.rkShouldTrackPage() else { return }
1034
+ var pageRoute = String(describing: type(of: self))
1035
+ if let baseWeb = self as? NSObject, baseWeb.responds(to: Selector(("rrTag"))),
1036
+ let tag = baseWeb.value(forKey: "rrTag") as? String, !tag.isEmpty {
1037
+ pageRoute = "\(pageRoute)/\(tag)"
1038
+ }
1039
+ if self.rkPageStartTime > 0 {
1040
+ let durMs = Int64((Date().timeIntervalSince1970 - self.rkPageStartTime) * 1000)
1041
+ if durMs >= 500 {
1042
+ let friendly = RKTrackerSDK.shared.getFriendlyTitle(for: pageRoute)
1043
+ RKTrackerSDK.shared.trackPageStay(pageRoute: pageRoute, pageTitle: friendly, durationMs: durMs)
1044
+ }
1045
+ self.rkPageStartTime = 0
1046
+ }
1047
+ }
1048
+ }
1049
+
1050
+ extension UIApplication {
1051
+ @objc func rk_sendAction(_ action: Selector, to target: Any?, from sender: Any?, for event: UIEvent?) -> Bool {
1052
+ if let control = sender as? UIControl {
1053
+ RKTrackerSDK.shared.handleNativeControlClick(control: control, action: action, event: event)
1054
+ }
1055
+ return self.rk_sendAction(action, to: target, from: sender, for: event)
1056
+ }
1057
+ }