SplashAdViewController.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. //
  2. // SplashAdViewController.swift
  3. // VenusKitto
  4. //
  5. // Created by Neoa on 2025/1/27.
  6. //
  7. import UIKit
  8. import AnyThinkSplash
  9. import AnyThinkSDK
  10. class SplashAdViewController: UIViewController, ATSplashDelegate {
  11. // MARK: - Properties
  12. private let splashPlacementID = "b68d88cc420248" // 开屏广告placement ID
  13. private var splashAd: ATSplash?
  14. private var isAdLoaded = false
  15. private var useFootLogoView = false // 是否使用底部Logo视图
  16. // 广告展示开始时间(毫秒)
  17. private var adStartTime: Int64 = 0
  18. // 回调闭包,用于广告结束后跳转到主界面
  19. var onSplashAdFinished: (() -> Void)?
  20. // MARK: - Initialization
  21. convenience init(useFootLogoView: Bool = false) {
  22. self.init()
  23. self.useFootLogoView = useFootLogoView
  24. }
  25. // MARK: - UI Components
  26. private let backgroundImageView: UIImageView = {
  27. let imageView = UIImageView()
  28. imageView.image = UIImage(named: "catlogos")
  29. imageView.contentMode = .scaleAspectFit
  30. imageView.translatesAutoresizingMaskIntoConstraints = false
  31. return imageView
  32. }()
  33. private let appNameLabel: UILabel = {
  34. let label = UILabel()
  35. label.text = "VenusKitto"
  36. label.font = UIFont.systemFont(ofSize: 24, weight: .bold)
  37. label.textColor = UIColor(hex: "#2B2B2B")
  38. label.textAlignment = .center
  39. label.translatesAutoresizingMaskIntoConstraints = false
  40. return label
  41. }()
  42. // MARK: - Lifecycle
  43. override func viewDidLoad() {
  44. super.viewDidLoad()
  45. view.backgroundColor = UIColor(hex: "#FFFEFC")
  46. loadSplashAd()
  47. }
  48. override func viewDidAppear(_ animated: Bool) {
  49. super.viewDidAppear(animated)
  50. }
  51. // MARK: - Actions
  52. // MARK: - Splash Ad Methods
  53. private func loadSplashAd() {
  54. var loadConfigDict: [AnyHashable: Any] = [:]
  55. // 开屏超时时间
  56. loadConfigDict[kATSplashExtraTolerateTimeoutKey] = NSNumber(value: 5)
  57. // 自定义load参数
  58. loadConfigDict[kATAdLoadingExtraMediaExtraKey] = "media_val_SplashVC"
  59. // 可选接入,如果使用了腾讯优量汇(GDT),可添加以下配置
  60. // AdLoadConfigTool.splash_loadExtraConfigAppend_Tencent(&loadConfigDict)
  61. ATAdManager.shared().loadAD(withPlacementID: splashPlacementID, extra: loadConfigDict, delegate: self, containerView: nil)
  62. }
  63. private func showSplashAd() {
  64. // 场景统计功能,可选接入
  65. ATAdManager.shared().entrySplashScenario(withPlacementID: splashPlacementID, scene: "")
  66. // 检查是否有就绪
  67. guard ATAdManager.shared().splashReady(forPlacementID: splashPlacementID) else {
  68. loadSplashAd()
  69. return
  70. }
  71. // 展示配置
  72. let config = ATShowConfig(scene: "", showCustomExt: "testShowCustomExt")
  73. // 开屏相关参数配置
  74. let configDict: [AnyHashable: Any] = [:]
  75. // 展示广告,在App原window中展示
  76. ATAdManager.shared().showSplash(withPlacementID: splashPlacementID, config: config, window: view.window!, in: self, extra: configDict, delegate: self)
  77. // 记录广告展示时间
  78. adStartTime = nowMillis()
  79. incrementTodayAdCount()
  80. }
  81. private func finishSplashAd() {
  82. // 调用回调,跳转到主界面
  83. onSplashAdFinished?()
  84. }
  85. // MARK: - Helper Methods
  86. private func nowMillis() -> Int64 {
  87. Int64(Date().timeIntervalSince1970 * 1000)
  88. }
  89. private func incrementTodayAdCount() {
  90. let df = DateFormatter()
  91. df.locale = Locale(identifier: "en_US_POSIX")
  92. df.dateFormat = "yyyy-MM-dd"
  93. let key = "adCount_" + df.string(from: Date())
  94. let current = UserDefaults.standard.integer(forKey: key)
  95. UserDefaults.standard.set(current + 1, forKey: key)
  96. }
  97. }
  98. // MARK: - ATAdLoadingDelegate
  99. extension SplashAdViewController: ATAdLoadingDelegate {
  100. /// 开屏广告加载完成
  101. func didFinishLoadingSplashAD(withPlacementID placementID: String, isTimeout: Bool) {
  102. print("开屏加载成功:\(placementID)----是否超时:\(isTimeout)")
  103. isAdLoaded = true
  104. if !isTimeout {
  105. // 没有超时,展示开屏广告
  106. showSplashAd()
  107. } else {
  108. // 加载成功,但超时了
  109. finishSplashAd()
  110. }
  111. }
  112. /// 广告位加载失败
  113. func didFailToLoadAD(withPlacementID placementID: String, error: Error) {
  114. print("didFailToLoadADWithPlacementID:\(placementID) error:\(error)")
  115. // 所有广告源加载失败了,进入首页
  116. finishSplashAd()
  117. }
  118. /// 开屏广告加载超时
  119. func didTimeoutLoadingSplashAD(withPlacementID placementID: String) {
  120. print("didTimeoutLoadingSplashADWithPlacementID:\(placementID)")
  121. // 超时了,首页加载完成后进入首页
  122. finishSplashAd()
  123. }
  124. /// 获得展示收益
  125. func didRevenue(forPlacementID placementID: String, extra: [AnyHashable : Any]!) {
  126. print("didRevenueForPlacementID:\(placementID) with extra: \(extra ?? [:])")
  127. uploadAdRevenue(placementID: placementID, extra: extra ?? [:])
  128. }
  129. /// 加载成功且加载流程完毕
  130. func didFinishLoadingAD(withPlacementID placementID: String) {
  131. print("didFinishLoadingADWithPlacementID:\(placementID)")
  132. }
  133. }
  134. // MARK: - ATSplashDelegate
  135. extension SplashAdViewController {
  136. /// 开屏广告已展示
  137. func splashDidShow(forPlacementID placementID: String, extra: [AnyHashable : Any]!) {
  138. print("splashDidShowForPlacementID:\(placementID)")
  139. // 展示广告后可以隐藏,避免遮挡
  140. // 这里可以隐藏loading view
  141. }
  142. /// 开屏广告已关闭
  143. func splashDidClose(forPlacementID placementID: String, extra: [AnyHashable : Any]!) {
  144. print("splashDidCloseForPlacementID:\(placementID) extra:\(extra ?? [:])")
  145. // 进入首页
  146. finishSplashAd()
  147. }
  148. /// 开屏广告已点击
  149. func splashDidClick(forPlacementID placementID: String, extra: [AnyHashable : Any]!) {
  150. print("splashDidClickForPlacementID:\(placementID)")
  151. }
  152. /// 开屏广告展示失败
  153. func splashDidShowFailed(forPlacementID placementID: String, error: Error, extra: [AnyHashable : Any]!) {
  154. print("splashDidShowFailedForPlacementID:\(placementID) error:\(error)")
  155. // 没有展示成功,也进入首页,注意控制重复跳转
  156. finishSplashAd()
  157. }
  158. /// 开屏广告已打开或跳转深链接页面
  159. func splashDeepLinkOrJump(forPlacementID placementID: String, extra: [AnyHashable : Any]!, result success: Bool) {
  160. print("splashDeepLinkOrJumpForPlacementID:placementID:\(placementID)")
  161. }
  162. /// 开屏广告详情页已关闭
  163. func splashDetailDidClosed(forPlacementID placementID: String, extra: [AnyHashable : Any]!) {
  164. print("splashDetailDidClosedForPlacementID:\(placementID)")
  165. // 可在此获取关闭原因:dismiss_type
  166. // 热启动预加载(可选)
  167. // loadSplashAd()
  168. }
  169. /// 开屏广告关闭计时
  170. func splashCountdownTime(_ countdown: Int, forPlacementID placementID: String, extra: [AnyHashable : Any]!) {
  171. print("splashCountdownTime:\(countdown) forPlacementID:\(placementID)")
  172. }
  173. /// 开屏广告zoomout view已点击,仅Pangle 腾讯优量汇 V+支持
  174. func splashZoomOutViewDidClick(forPlacementID placementID: String, extra: [AnyHashable : Any]!) {
  175. print("splashZoomOutViewDidClickForPlacementID:\(placementID)")
  176. }
  177. /// 开屏广告zoomout view已关闭,仅Pangle 腾讯优量汇 V+支持
  178. func splashZoomOutViewDidClose(forPlacementID placementID: String, extra: [AnyHashable : Any]!) {
  179. print("splashZoomOutViewDidCloseForPlacementID:\(placementID)")
  180. }
  181. }
  182. // MARK: - Ad Revenue Upload
  183. extension SplashAdViewController {
  184. private func uploadAdRevenue(placementID: String, extra: [AnyHashable: Any]) {
  185. // 计算 begin / finish(毫秒)并格式化为 "yyyy-MM-dd HH:mm:ss"
  186. let beginMs = adStartTime
  187. let finishMs = nowMillis()
  188. let beginTime = formatMillisToString(beginMs)
  189. let finishTime = formatMillisToString(finishMs)
  190. // 将 AnyHashable-key 的字典转为 String-key,且递归转为 JSON 可序列化类型
  191. func jsonSafe(_ value: Any) -> Any {
  192. if value is String || value is NSNumber || value is NSNull { return value }
  193. if let d = value as? [String: Any] { return d.mapValues { jsonSafe($0) } }
  194. if let d = value as? [AnyHashable: Any] {
  195. var nd: [String: Any] = [:]
  196. d.forEach { nd[String(describing: $0.key)] = jsonSafe($0.value) }
  197. return nd
  198. }
  199. if let arr = value as? [Any] { return arr.map { jsonSafe($0) } }
  200. if let v = value as? NSValue {
  201. // 处理常见 CoreGraphics 结构体;其余转描述串
  202. let type = String(cString: v.objCType)
  203. #if canImport(CoreGraphics)
  204. if type == "{CGSize=dd}" {
  205. let s = v.cgSizeValue
  206. return ["width": s.width, "height": s.height]
  207. } else if type == "{CGPoint=dd}" {
  208. let p = v.cgPointValue
  209. return ["x": p.x, "y": p.y]
  210. } else if type == "{CGRect={CGPoint=dd}{CGSize=dd}}" {
  211. let r = v.cgRectValue
  212. return ["x": r.origin.x, "y": r.origin.y, "width": r.size.width, "height": r.size.height]
  213. }
  214. #endif
  215. return v.description
  216. }
  217. if let date = value as? Date { return ISO8601DateFormatter().string(from: date) }
  218. if let url = value as? URL { return url.absoluteString }
  219. return String(describing: value)
  220. }
  221. // 注意:这里用 jsonSafe 处理每个值
  222. var extraDict: [String: Any] = [:]
  223. extra.forEach { (k, v) in
  224. extraDict[String(describing: k)] = jsonSafe(v)
  225. }
  226. // 便捷取值工具
  227. func str(_ key: String) -> String {
  228. if let v = extraDict[key] { return String(describing: v) }
  229. return ""
  230. }
  231. func intVal(_ key: String) -> Int {
  232. if let v = extraDict[key] as? Int { return v }
  233. if let s = extraDict[key] as? String, let i = Int(s) { return i }
  234. if let d = extraDict[key] as? Double { return Int(d) }
  235. return 0
  236. }
  237. func dbl(_ key: String) -> Double {
  238. if let v = extraDict[key] as? Double { return v }
  239. if let s = extraDict[key] as? String, let d = Double(s) { return d }
  240. if let i = extraDict[key] as? Int { return Double(i) }
  241. return 0
  242. }
  243. // —— 字段映射(对齐安卓) ——
  244. // adSourceId
  245. let adSourceId = intVal("adsource_id")
  246. // networkFormId / networkName / networkPlacementId
  247. let networkFormId = intVal("network_firm_id")
  248. let networkName = str("network_name")
  249. let networkPlacementId = str("network_placement_id")
  250. // placementId(iOS 回传里一般是 adunit_id;若无,用回调给的 placementID)
  251. let placementIdValue = str("adunit_id").isEmpty ? placementID : str("adunit_id")
  252. // recordId(有的 key 叫 requestId,也可能是 req_id,兜底)
  253. let recordId = str("requestId").isEmpty ? str("req_id") : str("requestId")
  254. // revenue(publisher_revenue)
  255. let revenue = dbl("publisher_revenue")
  256. // userId / nickName
  257. // let userId = UserDefaults.standard.string(forKey: "roleID") ?? ""
  258. let nickName = UserDefaults.standard.string(forKey: "memberName") ?? ""
  259. // ecpm:优先取 adsource_price;若没有,用 revenue*1000 估算一个(单位同 revenue)
  260. var ecpmStr = str("adsource_price")
  261. if ecpmStr.isEmpty {
  262. let ecpm = revenue * 1000.0
  263. ecpmStr = String(format: "%.6f", ecpm)
  264. }
  265. // adSourceIndex / adSourceType(优先 adsource_bid_type: 0=非竞价, 1=竞价)
  266. let adSourceIndex = intVal("adsource_index")
  267. // adSourceType 依据 adunit_format 映射:Native->0, RewardedVideo->1, Banner->2, Interstitial->3, Splash->4
  268. let adUnitFormatRaw = str("adunit_format")
  269. let adUnitFormat = adUnitFormatRaw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  270. let adSourceType: Int
  271. switch adUnitFormat {
  272. case "native":
  273. adSourceType = 0
  274. case "rewardedvideo", "rewarded_video", "rewarded":
  275. adSourceType = 1
  276. case "banner":
  277. adSourceType = 2
  278. case "interstitial":
  279. adSourceType = 3
  280. case "splash":
  281. adSourceType = 4
  282. default:
  283. // 兜底逻辑:保持原有取值(若 SDK 提供 adsource_bid_type 则沿用)
  284. adSourceType = intVal("adsource_bid_type")
  285. }
  286. // resultJson:把 SDK 回传完整塞进去,便于排查
  287. let resultJsonData = try? JSONSerialization.data(withJSONObject: extraDict, options: [])
  288. let resultJson = resultJsonData.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
  289. // appId:iOS 若无可用 App ID,可用 Bundle Identifier 兜底
  290. let appId = kTakuAppID
  291. let adCount = todayAdCount()
  292. // 组装请求体(与安卓 map 字段完全一致)
  293. let body: [String: Any] = [
  294. "adSourceId": adSourceId,
  295. "beginTime": beginTime,
  296. "finishTime": finishTime,
  297. "networkFormId": networkFormId,
  298. "networkName": networkName,
  299. "networkPlacementId": networkPlacementId,
  300. "nickName": nickName,
  301. "placementId": placementIdValue,
  302. "recordId": recordId,
  303. "revenue": revenue,
  304. "userId": "",
  305. "ecpm": ecpmStr,
  306. "adSourceIndex": adSourceIndex,
  307. "adSourceType": adSourceType,
  308. "resultJson": resultJson,
  309. "appId": appId,
  310. "adCount": adCount,
  311. "iosId": UIDevice.current.identifierForVendor?.uuidString ?? "", //iOS 设备id
  312. ]
  313. // 发起上报
  314. let urlString = "\(baseURL)/ad/saveRecord"
  315. guard let url = URL(string: urlString) else {
  316. print("[AD-Upload] URL 无效: \(urlString)")
  317. return
  318. }
  319. var request = URLRequest(url: url)
  320. request.httpMethod = "POST"
  321. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  322. request.httpBody = try? JSONSerialization.data(withJSONObject: body, options: [])
  323. if let body = request.httpBody,
  324. let bodyString = String(data: body, encoding: .utf8) {
  325. print("[AD-Upload] Request URL: \(url)")
  326. print("[AD-Upload] Request Headers: \(request.allHTTPHeaderFields ?? [:])")
  327. print("[AD-Upload] Request Body: \(bodyString)")
  328. }
  329. URLSession.shared.dataTask(with: request) { data, response, error in
  330. if let error = error {
  331. print("[AD-Upload] 网络错误: \(error.localizedDescription)")
  332. return
  333. }
  334. if let http = response as? HTTPURLResponse {
  335. print("[AD-Upload] 响应状态码: \(http.statusCode)")
  336. }
  337. if let data = data, let s = String(data: data, encoding: .utf8) {
  338. print("[AD-Upload] 响应: \(s)")
  339. }
  340. }.resume()
  341. }
  342. // 将毫秒时间戳格式化为 "yyyy-MM-dd HH:mm:ss"
  343. private func formatMillisToString(_ ms: Int64) -> String {
  344. let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000.0)
  345. let df = DateFormatter()
  346. df.locale = Locale(identifier: "en_US_POSIX")
  347. df.timeZone = TimeZone.current
  348. df.dateFormat = "yyyy-MM-dd HH:mm:ss"
  349. return df.string(from: date)
  350. }
  351. // ===== 当天广告观看次数(持久化到 UserDefaults)=====
  352. private func todayAdCount() -> Int {
  353. let df = DateFormatter()
  354. df.locale = Locale(identifier: "en_US_POSIX")
  355. df.dateFormat = "yyyy-MM-dd"
  356. let key = "adCount_" + df.string(from: Date())
  357. return UserDefaults.standard.integer(forKey: key)
  358. }
  359. }