iOSでリストに種類の違うデータを挿入する実装方法

f:id:vasilyjp:20180824001457j:plain

こんにちは、新事業創造部の遠藤です。現在WEARの開発を行っています。
最近はWEARのコーディネート一覧やユーザー一覧など、リスト画面にバナー型の広告を実装をしました。

リストにデータを挿入する実装は簡単なように思えますが、種類の違うデータを扱う場合には、考慮するべきポイントがいくつかあります。
本記事ではリストに広告を表示することを例に、種類の違うデータをリストに挿入する際のデータの持ち方・実装ついて紹介したいと思います。

仕様

リスト画面にバナー型の広告を表示するにあたっての仕様は以下のとおりです。

  • 広告を取得できない場合はリストをつめる
  • スクロールして戻っても同じ広告が表示されている
  • 広告のインプレッションは表示時のみとなるように制御する
  • スクロールに合わせて遅延なく広告の表示をする

上記のような元々表示していたデータと異なる仕様を扱うため、実装が複雑になります。

バナー型広告について

リストにデータを挿入する実装を紹介する前に、表示しているバナー型広告について軽く触れたいと思います。
今回はGoogle Mobile Ads SDKが提供しているバナー型広告(DFP)をリストに表示しています。 このバナー型広告は広告取得リクエストをするとdelegateメソッドを通じて表示するバナーのオブジェクトが取得できます。

import GoogleMobileAds

class ViewController: UICollectionViewController, GADBannerViewDelegate {

    let banner: DFPBannerView!

    override func viewDidLoad() {
        super.viewDidLoad()

        // バナー広告のインスタンスを生成してリクエスト
        banner = DFPBannerView()
        banner.adUnitID = "xxxxxxxxxxxxxxxxx"
        banner.delegate = self
        banner.request(DFPRequest())
    }

    func adViewDidReceiveAd(_ bannerView: GADBannerView) {
        // 広告取得成功のdelegate
    }

    func adView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: GADRequestError) {    
        // 広告取得失敗のdelegate
    }
}

また、広告が表示されなくても広告取得が成功したタイミングでインプレッションの計測がされるようになっています。 インプレッションを手動でカウントする仕組みはあるのですが、第三者ネットワーク広告には使用できないです。

実装について

データの持ち方について

リストに種類の違うデータを挿入する際に肝となるのはデータの持ち方だと思います。
データ保持の方法はいろいろあると思いますが、今回は以下の考慮するポイントをもとにデータの持ち方について検討しました。

考慮するポイント 
1. 広告が取得できない場合の対応
2. cellの再利用
3. インプレッション計測を正しく行う

1. 広告が取得できない場合の対応
広告を取得できなかった場合にリストをつめる簡単な方法は、cellの高さを0にすることだと思います。
しかし、UICollectionViewでminimumLineSpacingを使用して実装している場合は気をつけないといけません。
高さを0にすることでリストがつまったように見えますが、cell自体は残っています。 高さを0にしたcellのminimumLineSpacingと前のcellのminimumLineSpacingが合わさりコンテンツ間のスペースが広く見えてしまいます。 なので、広告が取得できなかった場合には高さを0にする方法ではなくデータでの制御が必要になります。

f:id:vasilyjp:20180822210746p:plain

2. cellの再利用
UITableViewやUICollectionViewはcellが再利用されます。 cellを表示するタイミングで広告のリクエストを行うと、スクロールするたびに違う広告が表示されてしまいます。 この問題を解決するために、バナーのViewを保持する必要があります。

3. インプレッション計測を正しく行う
スクロールに合わせて遅延なく広告を表示するために、広告の先読み処理を行うと思います。 しかし今回導入したバナー型広告は、広告を取得したタイミングでインプレッション計測が行われます。 たくさんの広告を先読みしてしまうと、広告を表示していないのに、インプレッション数が増えてしまいます。 なので、表示する直前に1件ずつ広告取得のリクエストを行う必要があります。

上記の考慮するポイントをから、以下の3種類のリストデータを持つことにしました。

  • リスト画面で表示するコンテンツのみのリストデータ
  • コンテンツ、AD、ADを入れる位置を確保するためのスタブのリストデータ
    • ADの位置を確保するためのデータです。
  • コンテンツと取得できた広告だけのリストデータ
    • 表示に使用するためのデータです。
    • UICollectionViewのdataSource、delegateで使用します。

f:id:vasilyjp:20180822201453p:plain

ADを入れるための位置を確保したデータと表示に使用するデータを分けることにしました。 これにより実際に取得できた広告のみデータとして扱われることになるので、広告の取得が失敗したときにcellの高さを0にするという対応をしなくてよくなります。 また、広告の表示位置をスタブを使用して確保するようにしました。 そうすることで広告を挿入するたびに、挿入する位置の計算をしなくてよくなります。

リストのデータについてですが、スクロールして戻っても同じ広告を表示する仕様を実現するために、表示するバナーのViewをデータとして保持することにしました。

実装

データの持ち方が決まったので、リストに種類の違うデータを挿入する実装について大まかに説明していきます。 今回はわかりやすいように、リストに3件ずつ広告を入れる仕様で説明したいと思います。

1. コンテンツのリストデータに広告のスタブを挿入する

リスト画面に表示するコンテンツが取得できたら、広告のスタブを挿入していきます。 複数の型を扱うためにenumの配列で実装しています。

class ViewController: UICollectionViewController {

    enum CellType {
        case contents(Contents)
        case ad(DFPBannerView)
        case adStub
    }

    private let adInterval: Int = 3 // 3件ずつ広告を表示する
    private var contentsList: [Contents] = []  // コンテンツのみのリスト
    private var dataList: [CellType] = [] // ADスタブが入ったリスト

    // APIリクエスト完了後に行う処理
    // contentsListにはAPIから取得できたデータが追加されている
    private func insertAdStub() {
        // データを3件ずつに分割する
        let chunks = stride(from: 0, to: contentsList.count, by: adInterval).map {
          contentsList[$0 ..< Swift.min($0 + adInterval, contentsList.count)].map { CellType.contents($0) }
        }

        // 分割したデータにAdStubを挿入していく
        dataList = chunks.map {
          ($0.count == splitSize) ? $0 + [CellType.adStub] : $0
          }.flatMap { $0 }
    }
}

2. 広告の挿入

広告の取得リクエストを行い、実際に表示するバナーのViewが取得できたら、AdStubと置き換えていきます。

import GoogleMobileAds

class ViewController: UICollectionViewController, GADBannerViewDelegate  {

    private var ads: [DFPBannerView] = []
    private var dataList: [CellType] = []
    private var ad: DFPBannerView?
    private var latestAdIndex: Int = 0

    override func viewDidLoad() {
        super.viewDidLoad()

        requestAd()
    }

    fileprivate func requestAd() {
        let bannerView =  DFPBannerView()
        bannerView.adUnitID = ""
        bannerView.rootViewController = self
        bannerView.delegate = self
        bannerView.load(DFPRequest())
        ad = bannerView
    }

    private func insertAd(bannerView: DFPBannerView) {
        for index in latestAdIndex..<dataList.count {
            if case .adStub = dataSorce[index] {
                dataList[index] = .ad(adView)
                latestAdIndex = index
                return
            }
        }
    }


    // MARK: - GADBannerViewDelegate

    func adViewDidReceiveAd(_ bannerView: GADBannerView) {
        guard let bannerView = bannerView as? DFPBannerView else { return }

        ads.removeFirst()
        insertAd(adView: adView)
    }
}

3. 表示

実際に表示するデータはAdStubを抜いたデータを使用します。

import GoogleMobileAds

class ViewController: UICollectionViewController, GADBannerViewDelegate  {

    private func displayData() -> [CellType] {
        return dataList.filter {
            if case .adStub = $0 {
                return false
            } else {
                return true
            }
        }
    }


    // MARK: - UICollectionViewDataSource

    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
            return displayData().count
    }

    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        if let contents = displayData()[indexPath: indexPath] {
            ・・・
        } else if case .ad = displayData()[indexPath.item] {
            ・・・
        } else {
            fatalError("Unexpected Display Data")
        }
    }
}

4. 広告は1件ずつリクエストを行う

インプレッション計測を正しく行うため、広告を表示する直前に広告取得のリクエストをするようにしました。 しかし表示する直前にリクエストを行うと、広告を表示したいタイミングに広告を取得できないことがあります。 広告が取得できていないと、スクロールして広告を表示する位置にきても広告を表示できません。 なので遅延なく広告を表示するために、直前に広告取得を行うのではなく、少し早いタイミングで広告の取得を行うようにしました。 今回は以下のタイミングで広告の取得リクエストを行っています。

  • 初回表示(viewDidLoad)
  • 広告が表示
  • 広告の取得失敗
import GoogleMobileAds

class ViewController: UICollectionViewController, GADBannerViewDelegate  {

    override func viewDidLoad() {
        super.viewDidLoad()

        requestAd()
    }

    fileprivate func requestAd() {
        let bannerView =  DFPBannerView()
        bannerView.adUnitID = ""
        bannerView.rootViewController = self
        bannerView.delegate = self
        bannerView.load(DFPRequest())
        ad = bannerView
    }


    // MARK: - UICollectionViewDataSource

    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        if let contents = displayData()[indexPath: indexPath] {
            ・・・
        } else if case .ad = displayData()[indexPath.item] {
            requestAd()
        } else {
            fatalError("Unexpected Display Data")
        }
    }


    // MARK: - GADBannerViewDelegate

    func adView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: GADRequestError) {    
        requestAd()
    }
}

以上が今回バナー型広告をリストに挿入する実装についての説明です。

まとめ

リスト画面に種類の違うデータを挿入する実装についての紹介でした。 広告など種類の違うデータをリスト画面に挿入して表示する際の参考になれば幸いです。 スタートトゥデイテクノロジーズではiOSエンジニアを募集しています。少しでも興味がある方は、ぜひ一度オフィスにお越しください。 下記からのエントリーもお待ちしています。 www.wantedly.com