// // SplashAdViewController.swift // VenusKitto // // Created by Neoa on 2025/1/27. // import UIKit import AnyThinkSplash import AnyThinkSDK class SplashAdViewController: UIViewController, ATSplashDelegate { // MARK: - Properties private let splashPlacementID = "b68d88cc420248" // 开屏广告placement ID private var splashAd: ATSplash? private var isAdLoaded = false private var useFootLogoView = false // 是否使用底部Logo视图 // 广告展示开始时间(毫秒) private var adStartTime: Int64 = 0 // 回调闭包,用于广告结束后跳转到主界面 var onSplashAdFinished: (() -> Void)? // MARK: - Initialization convenience init(useFootLogoView: Bool = false) { self.init() self.useFootLogoView = useFootLogoView } // MARK: - UI Components private let backgroundImageView: UIImageView = { let imageView = UIImageView() imageView.image = UIImage(named: "catlogos") imageView.contentMode = .scaleAspectFit imageView.translatesAutoresizingMaskIntoConstraints = false return imageView }() private let appNameLabel: UILabel = { let label = UILabel() label.text = "VenusKitto" label.font = UIFont.systemFont(ofSize: 24, weight: .bold) label.textColor = UIColor(hex: "#2B2B2B") label.textAlignment = .center label.translatesAutoresizingMaskIntoConstraints = false return label }() // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(hex: "#FFFEFC") loadSplashAd() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) } // MARK: - Actions // MARK: - Splash Ad Methods private func loadSplashAd() { var loadConfigDict: [AnyHashable: Any] = [:] // 开屏超时时间 loadConfigDict[kATSplashExtraTolerateTimeoutKey] = NSNumber(value: 5) // 自定义load参数 loadConfigDict[kATAdLoadingExtraMediaExtraKey] = "media_val_SplashVC" // 可选接入,如果使用了腾讯优量汇(GDT),可添加以下配置 // AdLoadConfigTool.splash_loadExtraConfigAppend_Tencent(&loadConfigDict) ATAdManager.shared().loadAD(withPlacementID: splashPlacementID, extra: loadConfigDict, delegate: self, containerView: nil) } private func showSplashAd() { // 场景统计功能,可选接入 ATAdManager.shared().entrySplashScenario(withPlacementID: splashPlacementID, scene: "") // 检查是否有就绪 guard ATAdManager.shared().splashReady(forPlacementID: splashPlacementID) else { loadSplashAd() return } // 展示配置 let config = ATShowConfig(scene: "", showCustomExt: "testShowCustomExt") // 开屏相关参数配置 let configDict: [AnyHashable: Any] = [:] // 展示广告,在App原window中展示 ATAdManager.shared().showSplash(withPlacementID: splashPlacementID, config: config, window: view.window!, in: self, extra: configDict, delegate: self) // 记录广告展示时间 adStartTime = nowMillis() incrementTodayAdCount() } private func finishSplashAd() { // 调用回调,跳转到主界面 onSplashAdFinished?() } // MARK: - Helper Methods private func nowMillis() -> Int64 { Int64(Date().timeIntervalSince1970 * 1000) } private func incrementTodayAdCount() { let df = DateFormatter() df.locale = Locale(identifier: "en_US_POSIX") df.dateFormat = "yyyy-MM-dd" let key = "adCount_" + df.string(from: Date()) let current = UserDefaults.standard.integer(forKey: key) UserDefaults.standard.set(current + 1, forKey: key) } } // MARK: - ATAdLoadingDelegate extension SplashAdViewController: ATAdLoadingDelegate { /// 开屏广告加载完成 func didFinishLoadingSplashAD(withPlacementID placementID: String, isTimeout: Bool) { print("开屏加载成功:\(placementID)----是否超时:\(isTimeout)") isAdLoaded = true if !isTimeout { // 没有超时,展示开屏广告 showSplashAd() } else { // 加载成功,但超时了 finishSplashAd() } } /// 广告位加载失败 func didFailToLoadAD(withPlacementID placementID: String, error: Error) { print("didFailToLoadADWithPlacementID:\(placementID) error:\(error)") // 所有广告源加载失败了,进入首页 finishSplashAd() } /// 开屏广告加载超时 func didTimeoutLoadingSplashAD(withPlacementID placementID: String) { print("didTimeoutLoadingSplashADWithPlacementID:\(placementID)") // 超时了,首页加载完成后进入首页 finishSplashAd() } /// 获得展示收益 func didRevenue(forPlacementID placementID: String, extra: [AnyHashable : Any]!) { print("didRevenueForPlacementID:\(placementID) with extra: \(extra ?? [:])") uploadAdRevenue(placementID: placementID, extra: extra ?? [:]) } /// 加载成功且加载流程完毕 func didFinishLoadingAD(withPlacementID placementID: String) { print("didFinishLoadingADWithPlacementID:\(placementID)") } } // MARK: - ATSplashDelegate extension SplashAdViewController { /// 开屏广告已展示 func splashDidShow(forPlacementID placementID: String, extra: [AnyHashable : Any]!) { print("splashDidShowForPlacementID:\(placementID)") // 展示广告后可以隐藏,避免遮挡 // 这里可以隐藏loading view } /// 开屏广告已关闭 func splashDidClose(forPlacementID placementID: String, extra: [AnyHashable : Any]!) { print("splashDidCloseForPlacementID:\(placementID) extra:\(extra ?? [:])") // 进入首页 finishSplashAd() } /// 开屏广告已点击 func splashDidClick(forPlacementID placementID: String, extra: [AnyHashable : Any]!) { print("splashDidClickForPlacementID:\(placementID)") } /// 开屏广告展示失败 func splashDidShowFailed(forPlacementID placementID: String, error: Error, extra: [AnyHashable : Any]!) { print("splashDidShowFailedForPlacementID:\(placementID) error:\(error)") // 没有展示成功,也进入首页,注意控制重复跳转 finishSplashAd() } /// 开屏广告已打开或跳转深链接页面 func splashDeepLinkOrJump(forPlacementID placementID: String, extra: [AnyHashable : Any]!, result success: Bool) { print("splashDeepLinkOrJumpForPlacementID:placementID:\(placementID)") } /// 开屏广告详情页已关闭 func splashDetailDidClosed(forPlacementID placementID: String, extra: [AnyHashable : Any]!) { print("splashDetailDidClosedForPlacementID:\(placementID)") // 可在此获取关闭原因:dismiss_type // 热启动预加载(可选) // loadSplashAd() } /// 开屏广告关闭计时 func splashCountdownTime(_ countdown: Int, forPlacementID placementID: String, extra: [AnyHashable : Any]!) { print("splashCountdownTime:\(countdown) forPlacementID:\(placementID)") } /// 开屏广告zoomout view已点击,仅Pangle 腾讯优量汇 V+支持 func splashZoomOutViewDidClick(forPlacementID placementID: String, extra: [AnyHashable : Any]!) { print("splashZoomOutViewDidClickForPlacementID:\(placementID)") } /// 开屏广告zoomout view已关闭,仅Pangle 腾讯优量汇 V+支持 func splashZoomOutViewDidClose(forPlacementID placementID: String, extra: [AnyHashable : Any]!) { print("splashZoomOutViewDidCloseForPlacementID:\(placementID)") } } // MARK: - Ad Revenue Upload extension SplashAdViewController { private func uploadAdRevenue(placementID: String, extra: [AnyHashable: Any]) { // 计算 begin / finish(毫秒)并格式化为 "yyyy-MM-dd HH:mm:ss" let beginMs = adStartTime let finishMs = nowMillis() let beginTime = formatMillisToString(beginMs) let finishTime = formatMillisToString(finishMs) // 将 AnyHashable-key 的字典转为 String-key,且递归转为 JSON 可序列化类型 func jsonSafe(_ value: Any) -> Any { if value is String || value is NSNumber || value is NSNull { return value } if let d = value as? [String: Any] { return d.mapValues { jsonSafe($0) } } if let d = value as? [AnyHashable: Any] { var nd: [String: Any] = [:] d.forEach { nd[String(describing: $0.key)] = jsonSafe($0.value) } return nd } if let arr = value as? [Any] { return arr.map { jsonSafe($0) } } if let v = value as? NSValue { // 处理常见 CoreGraphics 结构体;其余转描述串 let type = String(cString: v.objCType) #if canImport(CoreGraphics) if type == "{CGSize=dd}" { let s = v.cgSizeValue return ["width": s.width, "height": s.height] } else if type == "{CGPoint=dd}" { let p = v.cgPointValue return ["x": p.x, "y": p.y] } else if type == "{CGRect={CGPoint=dd}{CGSize=dd}}" { let r = v.cgRectValue return ["x": r.origin.x, "y": r.origin.y, "width": r.size.width, "height": r.size.height] } #endif return v.description } if let date = value as? Date { return ISO8601DateFormatter().string(from: date) } if let url = value as? URL { return url.absoluteString } return String(describing: value) } // 注意:这里用 jsonSafe 处理每个值 var extraDict: [String: Any] = [:] extra.forEach { (k, v) in extraDict[String(describing: k)] = jsonSafe(v) } // 便捷取值工具 func str(_ key: String) -> String { if let v = extraDict[key] { return String(describing: v) } return "" } func intVal(_ key: String) -> Int { if let v = extraDict[key] as? Int { return v } if let s = extraDict[key] as? String, let i = Int(s) { return i } if let d = extraDict[key] as? Double { return Int(d) } return 0 } func dbl(_ key: String) -> Double { if let v = extraDict[key] as? Double { return v } if let s = extraDict[key] as? String, let d = Double(s) { return d } if let i = extraDict[key] as? Int { return Double(i) } return 0 } // —— 字段映射(对齐安卓) —— // adSourceId let adSourceId = intVal("adsource_id") // networkFormId / networkName / networkPlacementId let networkFormId = intVal("network_firm_id") let networkName = str("network_name") let networkPlacementId = str("network_placement_id") // placementId(iOS 回传里一般是 adunit_id;若无,用回调给的 placementID) let placementIdValue = str("adunit_id").isEmpty ? placementID : str("adunit_id") // recordId(有的 key 叫 requestId,也可能是 req_id,兜底) let recordId = str("requestId").isEmpty ? str("req_id") : str("requestId") // revenue(publisher_revenue) let revenue = dbl("publisher_revenue") // userId / nickName // let userId = UserDefaults.standard.string(forKey: "roleID") ?? "" let nickName = UserDefaults.standard.string(forKey: "memberName") ?? "" // ecpm:优先取 adsource_price;若没有,用 revenue*1000 估算一个(单位同 revenue) var ecpmStr = str("adsource_price") if ecpmStr.isEmpty { let ecpm = revenue * 1000.0 ecpmStr = String(format: "%.6f", ecpm) } // adSourceIndex / adSourceType(优先 adsource_bid_type: 0=非竞价, 1=竞价) let adSourceIndex = intVal("adsource_index") // adSourceType 依据 adunit_format 映射:Native->0, RewardedVideo->1, Banner->2, Interstitial->3, Splash->4 let adUnitFormatRaw = str("adunit_format") let adUnitFormat = adUnitFormatRaw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let adSourceType: Int switch adUnitFormat { case "native": adSourceType = 0 case "rewardedvideo", "rewarded_video", "rewarded": adSourceType = 1 case "banner": adSourceType = 2 case "interstitial": adSourceType = 3 case "splash": adSourceType = 4 default: // 兜底逻辑:保持原有取值(若 SDK 提供 adsource_bid_type 则沿用) adSourceType = intVal("adsource_bid_type") } // resultJson:把 SDK 回传完整塞进去,便于排查 let resultJsonData = try? JSONSerialization.data(withJSONObject: extraDict, options: []) let resultJson = resultJsonData.flatMap { String(data: $0, encoding: .utf8) } ?? "{}" // appId:iOS 若无可用 App ID,可用 Bundle Identifier 兜底 let appId = kTakuAppID let adCount = todayAdCount() // 组装请求体(与安卓 map 字段完全一致) let body: [String: Any] = [ "adSourceId": adSourceId, "beginTime": beginTime, "finishTime": finishTime, "networkFormId": networkFormId, "networkName": networkName, "networkPlacementId": networkPlacementId, "nickName": nickName, "placementId": placementIdValue, "recordId": recordId, "revenue": revenue, "userId": "", "ecpm": ecpmStr, "adSourceIndex": adSourceIndex, "adSourceType": adSourceType, "resultJson": resultJson, "appId": appId, "adCount": adCount, "iosId": UIDevice.current.identifierForVendor?.uuidString ?? "", //iOS 设备id "begintimestamp": beginMs, "finishtimestamp": finishMs ] // 发起上报 let urlString = "\(apiBaseURL)/ad/saveRecord" guard let url = URL(string: urlString) else { print("[AD-Upload] URL 无效: \(urlString)") return } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONSerialization.data(withJSONObject: body, options: []) if let body = request.httpBody, let bodyString = String(data: body, encoding: .utf8) { print("[AD-Upload] Request URL: \(url)") print("[AD-Upload] Request Headers: \(request.allHTTPHeaderFields ?? [:])") print("[AD-Upload] Request Body: \(bodyString)") } URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { print("[AD-Upload] 网络错误: \(error.localizedDescription)") return } if let http = response as? HTTPURLResponse { print("[AD-Upload] 响应状态码: \(http.statusCode)") } if let data = data, let s = String(data: data, encoding: .utf8) { print("[AD-Upload] 响应: \(s)") } }.resume() } // 将毫秒时间戳格式化为 "yyyy-MM-dd HH:mm:ss" private func formatMillisToString(_ ms: Int64) -> String { let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000.0) let df = DateFormatter() df.locale = Locale(identifier: "en_US_POSIX") df.timeZone = TimeZone.current df.dateFormat = "yyyy-MM-dd HH:mm:ss" return df.string(from: date) } // ===== 当天广告观看次数(持久化到 UserDefaults)===== private func todayAdCount() -> Int { let df = DateFormatter() df.locale = Locale(identifier: "en_US_POSIX") df.dateFormat = "yyyy-MM-dd" let key = "adCount_" + df.string(from: Date()) return UserDefaults.standard.integer(forKey: key) } }