如何在iOS的SwiftUI中实现需停留3秒的Impression Tracking
SwiftUI 信息流曝光追踪(停留3秒统计浏览次数)
你的需求核心是只有当卡片在屏幕内停留满3秒且可见比例达标时,才统计浏览次数,原实现的主要问题是缺少定时器取消机制,快速滚动时旧任务仍会触发,导致错误统计。以下是正确的实现方案:
核心改进点
- 为每个卡片独立管理定时器,进入可见区域启动,离开时立即取消
- 精确计算卡片的可见比例(建议设置阈值,比如50%),避免边缘露出就触发
- 使用
ObservableObject管理卡片的可见状态和浏览次数,确保视图实时更新
完整实现代码
1. 数据模型与Presenter
import SwiftUI import Combine // 信息流数据模型 class Feed: ObservableObject, Identifiable { let id = UUID() @Published var viewedCount = 0 @Published var isVisible = false // 标记是否处于可见状态 } // Presenter 协议与实现 protocol CardViewToPresenterProtocol: AnyObject { func trackImpression(for feed: Feed) } class Presenter: ObservableObject, CardViewToPresenterProtocol { @Published var feeds: [Feed] = [] init() { // 模拟测试数据 (0..<20).forEach { _ in feeds.append(Feed()) } } func trackImpression(for feed: Feed) { guard !feed.isVisible else { return } feed.viewedCount += 1 feed.isVisible = true // 标记为已曝光,避免重复统计 } }
2. 卡片视图(带曝光追踪逻辑)
struct CardView: View { weak var delegate: CardViewToPresenterProtocol? @ObservedObject var feed: Feed // 使用ObservedObject监听状态变化 let index: Int // 定时器管理 @State private var timer: DispatchSourceTimer? // 可见比例阈值(可根据需求调整,比如0.5代表50%可见) private let visibilityThreshold: CGFloat = 0.5 var body: some View { ZStack { RoundedRectangle(cornerRadius: 8) .fill(Color.green) VStack { Text("\(feed.viewedCount) 次浏览") .font(.system(size: 12)) Text("卡片索引:\(index)") } } .frame(height: 200) .onTapGesture { print("卡片被点击") } .background( GeometryReader { geo in Color.clear.onAppear { checkVisibilityAndStartTimer(geo: geo) } .onChange(of: geo.frame(in: .global)) { newFrame in checkVisibilityAndUpdateTimer(newFrame: newFrame) } .onDisappear { cancelTimer() feed.isVisible = false // 离开屏幕重置状态 } } ) } // 检查可见性并启动/更新定时器 private func checkVisibilityAndStartTimer(geo: GeometryProxy) { let frame = geo.frame(in: .global) guard isVisibleEnough(frame: frame) else { return } startTimer() } private func checkVisibilityAndUpdateTimer(newFrame: CGRect) { if isVisibleEnough(frame: newFrame) { if timer == nil { startTimer() } } else { cancelTimer() feed.isVisible = false } } // 判断卡片是否达到可见比例阈值 private func isVisibleEnough(frame: CGRect) -> Bool { let screenBounds = UIScreen.main.bounds // 计算卡片与屏幕的交集区域 let intersection = frame.intersection(screenBounds) // 可见比例 = 交集面积 / 卡片面积 let visibleRatio = intersection.width * intersection.height / (frame.width * frame.height) return visibleRatio >= visibilityThreshold } // 启动3秒定时器 private func startTimer() { guard timer == nil else { return } timer = DispatchSource.makeTimerSource(queue: .main) timer?.schedule(deadline: .now() + 3, repeating: .never) timer?.setEventHandler { [weak self] in guard let self = self else { return } self.delegate?.trackImpression(for: self.feed) self.cancelTimer() // 统计后取消定时器 } timer?.resume() } // 取消定时器 private func cancelTimer() { timer?.cancel() timer = nil } }
3. 列表视图
struct TestUIList: View { @ObservedObject var presenter: Presenter var body: some View { List { LazyVStack { ForEach(presenter.feeds.enumerated().map { $0 }, id: \.element.id) { index, feed in CardView(delegate: presenter, feed: feed, index: index) } } } } }
关键说明
- 定时器管理:使用
DispatchSourceTimer而非asyncAfter,可以在滚动时随时取消任务,避免延迟执行错误的统计。 - 可见性判断:通过计算卡片与屏幕的交集面积比例,确保只有卡片足够可见时才触发计时,避免边缘露出的误统计。
- 状态同步:
Feed使用ObservableObject,确保曝光状态和浏览次数的变化能实时更新UI。 - 重复统计防护:曝光一次后标记
isVisible为true,避免同一卡片在屏幕内停留过久重复统计(若需要重复统计,可移除该逻辑)。
内容的提问来源于stack exchange,提问作者Raju
相关产品推荐
相关产品推荐

