@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.
- package/index.js +8 -6
- package/native/android/AndroidTracker.java +115 -17
- package/native/harmony/HarmonyTracker.ets +434 -0
- package/native/ios/RKTrackerSDK.swift +944 -168
- package/package.json +1 -1
|
@@ -1,168 +1,944 @@
|
|
|
1
|
-
//
|
|
2
|
-
// RKTrackerSDK.swift
|
|
3
|
-
// RKTrackerSDK
|
|
4
|
-
// iOS Native Tracker SDK for
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
import UIKit
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
private
|
|
14
|
-
private
|
|
15
|
-
private var
|
|
16
|
-
private
|
|
17
|
-
|
|
18
|
-
private
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
"
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
private
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
private
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
+
}
|