关于印象测量

似乎有多种称呼方式,但它是衡量“看到”的事件。除了易于理解的用户操作(例如点击操作)之外,还可以直观地看到交付的内容在多大程度上吸引了用户的兴趣。印象是广告相关组件中的一个重要因素,因为用户看到的内容会带来收入,但即使是针对特定服务的内容,也必须有很多测量印象的需求。
这是一个测量每个单元格的展示次数的示例,针对 iOS 的 UITableView。我见过很多委托模式,但我还没有见过像 RxSwift 这样的响应式设计,所以它可能适合这种需求。

要求

我们的 SARAH 印象测量要求示例

  • 屏幕上显示n部分内容(Cell)的区域。如果
  • 则发送展示事件并保持该状态 m 秒
  • 在同一个转换中,已经测量过一次的内容(Cell)不再测量

环境

  • Xcode 14.0.1
  • 斯威夫特 5.7
  • ReactiveKit 3.19.1
  • 债券 7.8.1

* ReactiveKit 用于项目方便
*仅适用于 UIKit。无法适应 SwiftUI

结论/完整代码

TableViewImpressionTracker.swift
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)
    }
  }
}
ViewController.swift
// 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 方法停止印象测量并隐藏屏幕(防止意外测量爆炸)
    • 支持应用本身进入后台等需求也不错

演示

【Swift】UITableViewのインプレッションをReactiveに計測する

结尾有一点公司介绍

在我现在所属的 SARAH Co., Ltd.,我们正在开发一种多方面的 toC 和 toB 服务,该服务可以发布、分发、收集和分析关于一道菜的米饭信息。
我们正在寻找一起推动 SARAH 的成员!如果您有兴趣,请从下面的招聘窗口中来随便听听!
我们期待着与您的合作!
另外,SARAH 经营着一个科技博客,所以请看一下。

SARAH 技术博客中心

招聘窗口


原创声明:本文系作者授权爱码网发表,未经许可,不得转载;

原文地址:https://www.likecs.com/show-308631850.html

相关文章:

  • 2022-12-23
  • 2022-12-23
  • 2022-12-23
  • 2021-07-11
  • 2022-12-23
  • 2021-04-07
  • 2022-12-23
  • 2022-12-23
猜你喜欢
  • 2022-12-23
  • 2021-11-01
  • 2021-09-11
  • 2022-12-23
  • 2022-12-23
  • 2022-12-23
  • 2022-02-03
相关资源
相似解决方案