关于印象测量
似乎有多种称呼方式,但它是衡量“看到”的事件。除了易于理解的用户操作(例如点击操作)之外,还可以直观地看到交付的内容在多大程度上吸引了用户的兴趣。印象是广告相关组件中的一个重要因素,因为用户看到的内容会带来收入,但即使是针对特定服务的内容,也必须有很多测量印象的需求。
这是一个测量每个单元格的展示次数的示例,针对 iOS 的 UITableView。我见过很多委托模式,但我还没有见过像 RxSwift 这样的响应式设计,所以它可能适合这种需求。
要求
我们的 SARAH 印象测量要求示例
- 屏幕上显示n部分内容(Cell)的区域。如果
- 则发送展示事件并保持该状态 m 秒
- 在同一个转换中,已经测量过一次的内容(Cell)不再测量
环境
- Xcode 14.0.1
- 斯威夫特 5.7
- ReactiveKit 3.19.1
- 债券 7.8.1
* ReactiveKit 用于项目方便
*仅适用于 UIKit。无法适应 SwiftUI
结论/完整代码
import UIKit
import ReactiveKit
import Bond
// MARK: - Extensions
private extension UITableView {
/// 現在のoffsetを元に、相対的なframeを取得する
var currentContentRect: CGRect {
CGRect(
x: self.contentOffset.x,
y: self.contentOffset.y,
width: self.bounds.width,
height: self.bounds.height
)
}
}
// MARK: - TableViewImpressionTrackable 1️⃣
protocol TableViewImpressionTrackable where Self: UIViewController {
var tableView: UITableView { get }
var impressionTracker: TableViewImpressionTracker { get }
}
// 2️⃣
extension TableViewImpressionTrackable {
var indexPathForTrackingImpression: Signal<IndexPath, Never> {
impressionTracker.trackingIndexPath
}
func setupImpressionTracker() {
impressionTracker.setup(with: tableView)
}
func startImpressionTracking() {
impressionTracker.startTracking()
}
func stopImpressionTracking() {
impressionTracker.stopTracking()
}
func restartImpressionTracking() {
impressionTracker.restartTracking()
}
}
// MARK: - TableViewImpressionTracker 3️⃣
final class TableViewImpressionTracker {
private struct ThresholdPoints {
let topPoint: CGPoint
let bottomPoint: CGPoint
}
private let config: Configuration
private var trackedIndexPaths: Set<IndexPath> = []
private let trackable = Observable<Bool>(false)
private let indexPathForTracking = Subject<IndexPath?, Never>()
var trackingIndexPath: Signal<IndexPath, Never> {
indexPathForTracking.ignoreNils().toSignal()
}
init(config: Configuration = .default) {
self.config = config
}
func setup(with tableView: UITableView) {
bind(tableView: tableView)
}
/// インプレッション計測開始
func startTracking() {
trackable.send(true)
}
/// インプレッション計測停止
func stopTracking() {
trackable.send(false)
}
/// インプレッション計測のリセット
/// 計測済みのCellのIndexキャッシュを削除し、再度計測を開始する
func restartTracking() {
trackedIndexPaths.removeAll()
indexPathForTracking.send(nil)
startTracking()
}
private func bind(tableView: UITableView) {
let indexPathsForVisibleRows = tableView.reactive.keyPath(.indexPathsForVisibleRows)
let indexPath = tableView.reactive.keyPath(.contentOffset) // contentOffset の変化を監視
.flatMapLatest { _ in
// offset 変化時に画面表示中の Cell の index を全て取得
indexPathsForVisibleRows
}
.ignoreNils()
.flattenElements()
.filter { [unowned self] visibleIndexPath in
// 画面表示中の Cell のうち、計測対象の閾値を満たす Cell の Index に絞る
self.containsCurrentContentRect(in: tableView, at: visibleIndexPath)
}
.removeDuplicates()
.flatMapMerge { [unowned self] trackingIndexPath in
// 指定秒間対象IndexのCellが表示され続けたことを評価する
self.filterContinuousDisplayedIndex(in: tableView, at: trackingIndexPath)
}
.filter { [unowned self] trackingIndexPath in
/// 一度表示(計測)された Cell の Index はキャッシュして、重複してイベントを流さない
if self.trackedIndexPaths.contains(trackingIndexPath) { return false }
self.trackedIndexPaths.insert(trackingIndexPath)
return true
}
// `trackable` フラグが立っている時だけ計測する
combineLatest(trackable, indexPath)
.filter { trackable, _ in trackable }
.map { _, indexPath in indexPath }
.removeDuplicates() // trackable フラグを変えた瞬間に、最後に計測したindexが流れてしまうのを防ぐ
.bind(to: indexPathForTracking)
}
/// 評価対象のCellのframeから、計測対象の閾値となる座標を割り出す
private func getThresholdPoints(from originalRect: CGRect) -> ThresholdPoints {
let topPoint = originalRect.origin
let bottomPoint = CGPoint(x: originalRect.origin.x, y: originalRect.maxY)
let thresholdRatio = config.trackingCellHeightRetio
let thresholdHeight = originalRect.size.height * CGFloat(thresholdRatio)
return ThresholdPoints(
topPoint: CGPoint(x: topPoint.x, y: topPoint.y + thresholdHeight),
bottomPoint: CGPoint(x: bottomPoint.x, y: bottomPoint.y - thresholdHeight)
)
}
/// 評価対象のCellのIndexが、「表示中」の閾値を満たしているかどうか
private func containsCurrentContentRect(in tableView: UITableView, at indexPath: IndexPath) -> Bool {
let tableViewCurrentContentRect = tableView.currentContentRect
let cellRect = tableView.rectForRow(at: indexPath)
guard cellRect != .zero else { return false }
let thresholdPoints = getThresholdPoints(from: cellRect)
return tableViewCurrentContentRect.contains(thresholdPoints.topPoint)
&& tableViewCurrentContentRect.contains(thresholdPoints.bottomPoint)
}
/// 評価対象のCellのIndexが、n秒間表示され続けたかを0.5秒間隔でチェックする
private func filterContinuousDisplayedIndex(in tableView: UITableView, at indexPath: IndexPath) -> Signal<IndexPath, Never> {
var limitSecond = config.trackingImpressionSecond
let interval = 0.5 // second
return Signal { observer in
Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] timer in
guard let self else { return }
// カウントダウン
limitSecond -= interval
if !self.containsCurrentContentRect(in: tableView, at: indexPath) {
timer.invalidate()
}
if limitSecond <= 0 {
observer.receive(indexPath)
timer.invalidate()
}
}
return NonDisposable.instance
}
}
}
// MARK: - Configuration
extension TableViewImpressionTracker {
// 4️⃣
struct Configuration {
let trackingCellHeightRetio: Double
let trackingImpressionSecond: TimeInterval
init(trackingCellHeightRetio: Double, trackingImpressionSeccond: TimeInterval) {
self.trackingCellHeightRetio = min(1.0, trackingCellHeightRetio)
self.trackingImpressionSecond = max(0.0, trackingImpressionSeccond)
}
static var `default`: Configuration {
Configuration(trackingCellHeightRetio: 2/3, trackingImpressionSeccond: 2.0)
}
}
}
// 5️⃣
class ViewController: UIViewController, TableViewImpressionTrackable {
let tableView = UITableView()
let impressionTracker = TableViewImpressionTracker()
override func viewDidLoad() {
super.viewDidLoad()
setupImpressionTracker()
indexPathForTrackingImpression
.bind(to: self) { me, indexPath in
print("testing___indexPath", indexPath)
// indexPathを元にデータソースを取得するなどして
// FirebaseAnalytics等へイベント送信
}
// レイアウトは割愛
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startImpressionTracking()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
stopImpressionTracking()
}
}
详细解释
1️⃣protocol TableViewImpressionTrackable
在大多数情况下,我认为发送测量事件的基础是 UIViewController,所以我定义了继承到 UIViewController 的整个协议。目标VC中的TableView就是测量目标。
2️⃣extension TableViewImpressionTrackable
一组用于从继承协议 1 的 ViewController 执行印象测量操作的函数。好吧,你不必。
3️⃣class TableViewImpressionTracker
这个类是印象测量的核心。我还添加了源评论,但我大致做了如下的事情
- 绑定 UITableView 的
contentOffset更改设置方法-
在显示上将
contentOffset更改为 IndexPathindexPathsForVisibleRows -
从
indexPathsForVisibleRows获取当前显示的Cell的Frame - 判断目标单元格的边框是否满足“seen”的显示区域条件(本次有2/3的单元格在屏幕内)
- 判断显示区域条件是否持续满足最短显示时间(本次为 2 秒)
- 排除已测量单元格的索引 -> 防止重复测量
- 通过所有条件的Cell Index通知监控端
-
在显示上将
- 指示使用 start 方法开始测量展示次数
- 使用 stop 方法指示停止印象测量
- 删除缓存的 Cell Index 组以防止使用重置方法重复测量,并重新启用测量
4️⃣struct Configuration
这是判断“见过”的条件值。
- 要测量的屏幕内单元格高度的百分比
- 测量单元格是否显示了多少秒
class TableViewImpressionTracker 的初始值是可设置的。 default 属性使得在应用程序内定义通用条件值成为可能,但为每个屏幕定义不同的条件值留出了空间。
5️⃣观察端
详细的机制隐藏在 TableViewImpressionTracker 中,所以从 ViewController 端,你只需要监控要测量的 IndexPath 并指示印象测量的开始和停止。
- 根据协议定义属性
- 在设置方法中初始化印象机制
- 绑定满足“seen”条件的单元格的IndexPath
- 根据IndexPath获取数据源并发送事件即可
- 屏幕显示完成后,使用 start 方法开始测量印象数
- 使用 stop 方法停止印象测量并隐藏屏幕(防止意外测量爆炸)
- 支持应用本身进入后台等需求也不错
演示
结尾有一点公司介绍
在我现在所属的 SARAH Co., Ltd.,我们正在开发一种多方面的 toC 和 toB 服务,该服务可以发布、分发、收集和分析关于一道菜的米饭信息。
我们正在寻找一起推动 SARAH 的成员!如果您有兴趣,请从下面的招聘窗口中来随便听听!
我们期待着与您的合作!
另外,SARAH 经营着一个科技博客,所以请看一下。
SARAH 技术博客中心
招聘窗口
原创声明:本文系作者授权爱码网发表,未经许可,不得转载;
原文地址:https://www.likecs.com/show-308631850.html