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

# インプレッション計測について
いろんな呼び方があるようですが、「見た」というイベントを測定することです。タップアクションなどわかりやすいユーザ行動とは別に、例えば配信したコンテンツがどれだけユーザの興味を引いているかを可視化することができます。広告系のコンポーネントではユーザに見られたことが収益につながるのでこのインプレッションが重要な要素になるのですが、サービス独自のコンテンツでもインプレッションを測りたいという需要は多いはずです。
今回はiOSのUITableViewをターゲットに、Cell毎のインプレッションを計測する例です。Delegateパターンはよく目にしましたが、RxSwift等のリアクティブな設計は目にしなかったので、そんな需要に合うかもしれません。

# 要件
弊社SARAHのインプレッション計測要件の一例です
- コンテンツ（Cell）のn割の領域が画面上に表示され
- 且つその状態がm秒間維持されたらインプレッションイベントを送信する
- 同一遷移内では、一度インプレッション計測したコンテンツ（Cell）は再度計測しない

# 環境
- Xcode 14.0.1
- Swift 5.7
- ReactiveKit 3.19.1
- Bond 7.8.1

※プロジェクト都合でReactiveKitを採用してます
※あくまでUIKit向けです。SwiftUIにアジャストできていません

# 結論・フルコード
```swift: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)
    }
  }
}
```

```swift: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へ継承する全体のprotocolを定義してます。対象のVC内のTableViewが計測対象となります。

#### 2️⃣ `extension TableViewImpressionTrackable`
１のprotocolを継承したViewControllerからインプレッション計測の操作を行うための関数群です。まあなくてもいいです。

#### 3️⃣ `class TableViewImpressionTracker`
インプレッション計測の核となるクラスです。ソースコメントも入れてますがざっくり以下のようなことをしてます
- setupメソッドでUITableViewの`contentOffset`の変化をバインディング
  - `contentOffset`の変化を、表示中のIndexPath`indexPathsForVisibleRows`に変換
  - `indexPathsForVisibleRows`から表示中のCellのFrameを取得
  - 対象のCellのFrameが「見た」の表示領域条件（今回はCellの2/3が画面内にあること）に合致するかを判定
  - 表示領域条件を最低表示時間（今回は２秒）継続して満たしたかを判定
  - 一度計測済みのCellのIndexは除外 -> 重複して計測されることを防ぐ
  - 条件全て通過したCellのIndexを監視側へ通知
- startメソッドでインプレッション計測の開始を指示
- stopメソッドでインプレッション計測の停止を指示
- resetメソッドで重複計測防止のためにキャッシュしていたCellのIndex群を削除、再度計測可能にする

#### 4️⃣ `struct Configuration`
「見た」と判断するための条件値です。
- Cellの高さの何割が画面内に収まっていたら計測するか
- Cellが何秒間表示されていたら計測する

`class TableViewImpressionTracker`の初期値で設定可能にしてます。`default`プロパティで、アプリケーション内で共通の条件値を定められるようにしてますが、各画面毎に異なる条件値を定義できる余地を残しています。

#### 5️⃣ 観測側
詳細な仕組みはTableViewImpressionTrackerに隠蔽しているので、ViewController側からは計測対象のIndexPathの監視とインプレッション計測の開始と停止を指示するだけです。

- protocolに応じてプロパティを定義
- setupメソッドでインプレッション機構を初期化
- 「見た」の条件を満たしたCellのIndexPathをバインディング
  - そのIndexPathに応じたデータソースを取得するなどしてイベント送信すればOK
- 画面表示完了と共にstartメソッドでインプレッション計測開始
- 画面非表示と共にstopメソッドでインプレッション計測を停止（予期せぬ計測の暴発を防ぐ）
  - アプリ自体がバックグラウンドに入ったら〜等の要件に対応するのも良い


# デモ
![ezgif-5-e4561cf0d9.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2368372/c883a836-812b-ef25-86cd-708dc17b9a37.gif)

# 最後に少しだけ会社紹介
現在私が所属している株式会社SARAHでは一皿に特化したごはん情報の投稿・配信・収集・解析するサービスを、toC、toBと多角的に展開しています。
そんなSARAHを一緒に爆進してくれるメンバーを募集中です！興味のある方はぜひ↓の採用窓口からカジュアルにお話を聞きにきてください！
皆さんと一緒に働けるのを楽しみにしています！
また、SARAHではテックブログを運営してるので、是非見てみてください

### SARAH Tech Blog Hub
https://tech.sarah30.com/

### 採用窓口
- SARAH採用情報
	https://www.notion.so/sarah30/SARAH-97caa9e30b1941cfbacc35558d1af86a](https://www.notion.so/SARAH-97caa9e30b1941cfbacc35558d1af86a

- talentio
	https://open.talentio.com/r/1/c/sarah30/homes/2362

- Wantedly
	https://www.wantedly.com/companies/sarah