iOSのViewable Impression対応の話

社内で内製化のログシステムを推進している中で、ZOZOTOWNのiOSアプリはLoggerの内製、またはクリック、Viewable Impression（以降、view-impと表記）などイベントの対応してきました。
この記事では、先日に公開されたTechBlog[【ZOZOTOWNホーム画面におけるログ設計と改善サイクルの紹介】](https://techblog.zozo.com/entry/zozotown-home-data-driven)のview-impについてのiOSの実現方法を深堀りして紹介します。
画面の構成や実装により、適切な対応がそれぞれになりますが、今回の対応で気づいたポイトンがきっとどこかで参考になると思います。

# おさらい

## 背景
ホーム画面は主に**バナー**と「タイトル、一覧ページへのリンク、商品コンテンツ」という3つのパーツを持つ**モジュール**で構成されています。
これまで定性的な意思決定に基づいたモジュールの運用を、定量的に運用できるように、次ページ遷移率（以降、CTRと表記）をKPIとしました。

```math
CTR = コンテンツクリック数 ÷ モジュールのview-imp数
```
よって、CTR測定のためのコンテンツのクリック数とモジュールのview-imp数を集計する必要がありました。
コンテンツクリック数が明確的にユーザーのアクションで起こすイベントで相対的に簡単なので、本記事では割愛します。

## Viewable Impressionの定義
TechBlogの記載通り、view-impが、**【ホーム画面の各パーツに対して、ユーザーが実際に目に触れて認識した閲覧ログ】** としました。
「ふむ。。。人間の目で見たかどうかはどうやって確認するの？」と、おそらくエンジンニアの皆さんは最初に同じ質問が頭に浮かぶでしょう。

ここではGoogle広告の[視認性](https://support.google.com/admanager/answer/4524488?hl=ja) を参考しました。

> ・広告の場合、広告のピクセルの 50% がブラウザ ウィンドウで連続 1 秒間見える状態にあることが条件になります。
> ・大きな広告（242,000 ピクセルを超える広告）の場合、広告のピクセルの 30% がブラウザ ウィンドウで見える状態にあることが条件になります。
> ・インストリーム動画広告の場合は、広告のピクセルの 50% がブラウザ ウィンドウで連続 2 秒間見える状態にあることが条件になります。

モジュールのサイズを考慮した上で、それに適した **【ピクセルの 50% がブラウザ ウィンドウで連続 1 秒間見える状態】** を採用しました。
これに基づいてホーム画面のview-impはこちらで細かく定義できました。
- 発火範囲の条件はモジュールの高さが 50％ 以上画面に表示されたとき
- 1 秒以上モジュールが画面上に表示されていること
    - 100ms 間隔でモジュールの高さの中心が画面に表示されているか確認し、10回連続で観測できればview-impのログとして記録
    - 記録間隔を 100ms としている理由は、[人間の反応時間](https://bsd.neuroinf.jp/wiki/%E5%8F%8D%E5%BF%9C%E6%99%82%E9%96%93)（人間が事象を認知するまでの時間）は最小で 100ms であり、 100ms より短い間隔でデータを収集する必要はないため 
- スクロールアウトして再度スクロールインの場合は再度view-impとする
    - ログ発火後にモジュールの高さが 50％ 未満となり、再度 50％ 以上になった時に観測回数をリセット
- 画面から離脱して画面に戻った時は再度view-impとする
    - ホーム画面内でのモールタブ・性別タブの切り替え時
    - バックグラウンドからの復帰時
    - 別画面への遷移からのページバック時

# アプリでの実現
view-impの定義ができたところで、ホーム画面の構造に合わせて実装していきます。
ホーム画面はユーザーの目に触れる最初の画面であり、アプリが起動している間にずっと存在する画面のため、**メモリの使用など負荷**が高そうなview-imp機能を追加するには慎重に進めことが大事です。また、リリース済みの画面に補助的な計測機能の追加になるので、なるべく**既存の実装を破壊しない**ことも重要になります。

## ホーム画面の解析
ホーム画面ではCompositionalLayoutが採用されています。それについてチームメンバーがTechBlogを出していますので、詳細は[【ZOZOTOWNアプリHome画面再設計の軌跡~10年以上歴史を持つアプリはどのようにして生まれ変わったのか~】](https://techblog.zozo.com/entry/zozotown-ios-home-rearchitecture)ご参考ください。
モジュールだけを注目した場合、構成はこちらのイメージになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45744/ba6b20a4-ae68-9ae9-e69c-bdd3f9ec3a99.png)

先述にもあったモジュールが「タイトル、一覧ページへのリンク、商品コンテンツ」3つのパーツで構成されています。タイトルと一覧ページへのリンクは `UICollectionReusableView` になり、コンテンツは２段構成の `UICollectionViewCell` になります。

## Trackerの実装
Trackerのコード下記のイメージになります。
```swift
class ViewImpressionTracker {
    /// 監視対象のホームのcollectionView
    private let collectionView: UICollectionView

    /// 定期的にcollectionViewの表示をチェックする用のタイマー
    private var timer: Timer? {
        didSet {
            oldValue?.invalidate()
        }
    }

    /// １秒間観測できたモジュールの観測回数を管理するDictionary
    /// key: モジュールのID
    private var moduleSectionImpressionCounts: [Int: (section: ModuleSection, count: Int)]

    init(collectionView: UICollectionView) {
        self.collectionView = collectionView
    }

    // 監視開始
    // - parameter moduleSections:
    func startTracking(moduleSections: [ModuleSection?]) {
        // すべてのモジュールの観測回数をクリアする
        moduleSectionImpressionCounts = [:]
        moduleSections.forEach { moduleSection in
            moduleSectionImpressionCounts[moduleSection.moduleId] = (section: moduleSection, count: 0)
        }

        // 100ms間隔でモジュールの位置を確認し、観測回数をセットする
        let timer = Timer(timeInterval: 0.1, repeats: true, block: { [weak self] _ in
            DispatchQueue.main.async {
                guard let self = self else { return }

                // モジュールの位置計算用のRectのDictionary
                // key: モジュールのID
                var moduleSectionCellRects: [Int: ModuleSectionCellRect] = [:]

                // 見えているcollectionViewのcellのindexPathsを抽出したcellのrectを、該当モジュールのに入れる
                self.collectionView.indexPathsForVisibleItems.forEach { indexPath in
                    guard let moduleSection = moduleSections[safe: indexPath.section], let cell = self.collectionView.cellForItem(at: indexPath) else { return }

                    let rect = cell.convert(cell.bounds, to: nil)
                    // ２段になっているかどうかでモジュールの高さを算出
                    let sectionHeight = moduleSection.isTwoLines ? rect.height * 2 : rect.height
                    // cellが属しているsectionのDictionaryにcellのrectを追加
                    moduleSectionCellRects[moduleSection.moduleId, default: ModuleSectionCellRect(sectionHeight: sectionHeight, cellRects: [:])].cellRects[indexPath.item] = rect
                }

                // モジュールの観測回数を更新
                self.trackImpressionViews(moduleSectionCellRects: moduleSectionCellRects)

                // 観測回数が11回のモジュールのみを抽出する
                if let impressedSections = self.moduleSectionImpressionCounts.filter({ $0.value.count == 11 }).map { $0.value.section }, !impressedSections.isEmpty {
                    // 対象のモジュールを記録
                    self.logModuleSections(impressedSections)
                }
            }
        })

        self.timer = timer

        // collectionViewがスクロール中でタイマーが止まらないようにRunLoopのcommonモードで設定する
        RunLoop.current.add(timer, forMode: .common)
    }

    func stopTracking() {
        timer = nil
    }

    private func trackImpressionViews(moduleSectionCellRects: [Int: ModuleSectionCellRect]) {
        let moduleIds = moduleSectionImpressionCounts.keys

        // 見えていないモジュールの観測回数をリセット
        moduleIds.forEach { moduleId in
            guard let moduleSectionCellRect = moduleSectionCellRects[moduleId] else {
                moduleSectionImpressionCounts[moduleId]?.count = 0
            }
        }

        // モジュール全体のview-imp判定
        if isVisible(rects: moduleSectionCellRect.cellRects.map { $0.value }, sectionHeight: moduleSectionCellRect.sectionHeight) {
            let previousCount = moduleSectionImpressionCounts[moduleId]?.count ?? 0
            // 1s内に100ms間隔でカウントすると、11回目でview-imp成立とする
            // それ以上観測回数を増やしても無意味のため、最大12回とする
            moduleSectionImpressionCounts[moduleId]?.count = min(previousCount + 1, 12)
        } else {
            moduleSectionImpressionCounts[moduleId]?.count = 0
        }
    }

    private func isVisible(rects: [CGRect], sectionHeight: CGFloat) -> Bool {
        guard let firstRect = rects.first, let window = collectionView.window else { return false }

        // 見えているすべてのコンテンツのrectをunionして見えているモジュールの面積とする
        let unionRect = rects.dropFirst().reduce(firstRect) { unionRect, rect in
            unionRect.union(rect)
        }

        // window上に表示しているrect
        let innerRect = unionRect.intersection(window.bounds)

        // 見えているコンテンツの高さとモジュールの高さを比較
        return innerRect.height > sectionHeight * 0.5
    }
}
```

ここからポイントになっている箇所を紹介します。

### 描画用のデーターとの分離
**【既存の実装を破壊しない]** とのことで、ホームのViewControllerの描画用のデーターとTrackerのログデーターが分離できた形にしています。
APIの通信後に、UICollectionViewのDatasourceに入れる描画用のデーターをそのまま利用するではなく、同じタイミングでログ用のデーターを別途で作りました。
例えば、ログは下記の形で、 `position` など描画に必要ないデーターがあった場合、あらかじめ全モジュールのログデーターを作っておいて、view-impが判定できた時にそのままログデーターを記録すれば、描画用のデーターの修正なしで済みます。

```swift
    /// view-imp集計対象のセクション
    struct ModuleSection {
        /// モジュールセクションのモジュールID
        let moduleId: Int
        /// セクションのコンテンツが1行か2行か示すフラグ。view-imp判定用のセクションの高さを決める時に使用する
        let isTwoLines: Bool
        /// モジュールの位置情報
        let position: Int
    }
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45744/7c6ffdec-3133-33b1-d100-2ab5fd59ab54.png)

### 【50％ 以上画面に表示された】の判定
モジュール本来の面積と見えている範囲の面積が必要です。
しかし`indexPathsForVisibleItems` は表示中のcellしか取得できないため、ここではタイトルと一覧ページへのリンクを持っているUICollectionReusableViewを含めたモジュールの面積計算に苦労しました。
検知されたcellを持っているSectionから該当のUICollectionReusableViewにたどり着くことができなく、`NSCollectionLayoutDecorationItem.background(elementKind: background)` でセッション全体の背景viewを用意してもそのviewを抽出。
例え「cell→Section→ほしいview」の順で対象のviewを手に入れたとしても、パフォーマンスも懸念されます。ユーザーの目を引く良いモジュールのタイトルはCTRに影響がありそうですが、結局モジュールの中で最も重要なのはコンテンツです。そういった議論をした上で、モジュールの全体の面積を**すべてのコンテンツの面積**の合計としました。また、実際にモジュールの横の幅は不変なので、面積の比率は高さだけに依存します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45744/e8b8ff22-119c-d52d-4844-bef8cb2a97bd.png)

整理しますと、 `見えているモジュールの面積の比率`の計算は下記になります。
```math
見えているモジュールの面積の比率 = 見えているコンテンツの高さの合計 ÷ モジュールの高さ
```

- `indexPathsForVisibleItems`で取得したcellのRectをモジュールの高さとセットで保持するために、モジュールのビューの情報を下記の型にしました。
```swift
    struct ModuleSectionCellRect {
        /// モジュールセクション全体の高さ。ModuleSection.isTwoLinesにより１個のcellの高さ、または１個のcellの高さ * 2 になる
        let sectionHeight: CGFloat
        /// モジュールセクションで表示されたすべてのcellのrect
        /// key: collectionViewのindexPath.item
        var cellRects: [Int: CGRect]
    }
```

- モジュールの高さがあらかじめもらったローグデーター内の `isTwoLines` で２段になっているかどうかだけで算出可能です。
```swift
        let rect = cell.convert(cell.bounds, to: nil)
        // ２段になっているかどうかでモジュールの高さを算出
        let sectionHeight = moduleSection.isTwoLines ? rect.height * 2 : rect.height
```

- 見えているモジュールの面積は見えている[cell 1、cell 2...cell N]の合計になります。ここでは `union` で簡単に合計できます。

```swift
        // 見えているすべてのコンテンツのrectをunionして見えているモジュールの面積とする
        let unionRect = rects.dropFirst().reduce(firstRect) { unionRect, rect in
            unionRect.union(rect)
        }

        // window上に表示しているrect
        let innerRect = unionRect.intersection(window.bounds)
```

- 最後は高さの比較だけです。
```swift
        // 見えているコンテンツの高さとモジュールの高さを比較
        return innerRect.height > sectionHeight * 0.5
```

### 【連続1秒間】の判定
モジュール1、2、3、4があるとして、1個簡単の時間軸のサンプルを整理してみましょう。
|  \時間  |  0s  |  0.1s  |  0.2s  |  0.3s  |  ...  |  1s  |  1.1s  |  1.2s  |  ...  |
| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
|  見えているモジュール  |  1,2  |  1,2,3  |  2,3  |  2,3  |  2,3が連続  |  2,3,4  |  2,3,4  |  3,4  |    |
|  モジュールの観測回数  |  モジュール1: 1<br>モジュール2: 1<br>モジュール3: 0<br>モジュール4: 0   |  モジュール1: 0<br>モジュール2: 2<br>モジュール3: 1<br>モジュール4: 0  |  モジュール1: 0<br>モジュール2: 3<br>モジュール3: 2<br>モジュール4: 0  |  モジュール1: 0<br>モジュール2: 4<br>モジュール3: 5<br>モジュール4: 0  |  モジュール2,3が+1  |  モジュール1: 0<br>モジュール2: 11<br>モジュール3: 10<br>モジュール4: 0  |  モジュール1: 0<br>モジュール2: 12<br>モジュール3: 11<br>モジュール4: 2  |  モジュール1: 0<br>モジュール2: 0<br>モジュール3: 12<br>モジュール4: 3  |    |
|  view-impが成立したモジュール   |  -  |  -  |  -  |  -  |  -  |  2  |  3  |  -  |    |

- 0s〜1sの間、 モジュール2が連続で観測されているため、view-impが成立します。観測回数は11回になります
- 0.1s〜1.1sの間、 モジュール3が連続で観測されているため、view-impが成立します。観測回数は11回になります

実際の観測パターンがもっと複雑になりますが、どのパターンでも1秒の間にモジュールの観測回数が**11回**になるとview-impが成立としていいです。

```swift
        // モジュール全体のview-imp判定
        if isVisible(rects: moduleSectionCellRect.cellRects.map { $0.value }, sectionHeight: moduleSectionCellRect.sectionHeight) {
            let previousCount = moduleSectionImpressionCounts[moduleId]?.count ?? 0
            // 1s内に100ms間隔でカウントすると、11回目でview-imp成立とする
            // それ以上観測回数を増やしても無意味のため、最大12回とする
            moduleSectionImpressionCounts[moduleId]?.count = min(previousCount + 1, 12)
        } else {
            moduleSectionImpressionCounts[moduleId]?.count = 0
        }
```

また、`Timer`の実行threadが自動的に`RunLoop`の`.default`モードで登録されて、操作系であるUICollectionViewのスクロールと同様のイベントとして処理されるため、スクロール中`Timer`が止まってしまいます。`.common`モードで登録してあげることでこの現象が回避できました。

```swift
        // collectionViewがスクロール中でタイマーが止まらないようにRunLoopのcommonモードで設定する
        RunLoop.current.add(timer, forMode: .common)
```

## 負荷確認
ホーム画面はアプリが生存している限り存在する画面なので、Trackerも解放することがないので、[Memory Report](https://developer.apple.com/documentation/xcode/gathering-information-about-memory-use)でメモリとCPUが大きく変化がないことだけ確認できました。
もちろん、 閉じることがある画面で対応する場合、Time ProfilerなどでTrackerがちゃんと解放されていることを確認する必要があると思います。

# 最後に
iOSのview-imp対応の紹介は以上になります。
冒頭にもあったview-impはその画面の構成によってケースバイケースで対応する必要がありますが、この記事でちょっとでもアイディアになれたら嬉しいです。