インプレッション計測について
いろんな呼び方があるようですが、「見た」というイベントを測定することです。タップアクションなどわかりやすいユーザ行動とは別に、例えば配信したコンテンツがどれだけユーザの興味を引いているかを可視化することができます。広告系のコンポーネントではユーザに見られたことが収益につながるのでこのインプレッションが重要な要素になるのですが、サービス独自のコンテンツでもインプレッションを測りたいという需要は多いはずです。
今回は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にアジャストできていません
結論・フルコード
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へ継承する全体のprotocolを定義してます。対象のVC内のTableViewが計測対象となります。
2️⃣ extension TableViewImpressionTrackable
1のprotocolを継承したViewControllerからインプレッション計測の操作を行うための関数群です。まあなくてもいいです。
3️⃣ class TableViewImpressionTracker
インプレッション計測の核となるクラスです。ソースコメントも入れてますがざっくり以下のようなことをしてます
- setupメソッドでUITableViewの
contentOffset
の変化をバインディング-
contentOffset
の変化を、表示中のIndexPathindexPathsForVisibleRows
に変換 -
indexPathsForVisibleRows
から表示中のCellのFrameを取得 - 対象のCellのFrameが「見た」の表示領域条件(今回はCellの2/3が画面内にあること)に合致するかを判定
- 表示領域条件を最低表示時間(今回は2秒)継続して満たしたかを判定
- 一度計測済みの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メソッドでインプレッション計測を停止(予期せぬ計測の暴発を防ぐ)
- アプリ自体がバックグラウンドに入ったら〜等の要件に対応するのも良い
デモ
最後に少しだけ会社紹介
現在私が所属している株式会社SARAHでは一皿に特化したごはん情報の投稿・配信・収集・解析するサービスを、toC、toBと多角的に展開しています。
そんなSARAHを一緒に爆進してくれるメンバーを募集中です!興味のある方はぜひ↓の採用窓口からカジュアルにお話を聞きにきてください!
皆さんと一緒に働けるのを楽しみにしています!
また、SARAHではテックブログを運営してるので、是非見てみてください
SARAH Tech Blog Hub