Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Studyplusのとある画面でYouTubeの動画再生に対応し、やらかしたお話

こんにちは、Studyplus iOSチームの明渡(ID: m_yamada1992)です。

今回は、今年3月にリリースしたStudyplusアプリにて大学の情報を表示する画面でYouTubeの再生に対応したお話、およびiOSアプリ側の実装にて盛大にやらかした話をつづっていきます。

YouTubeをStudyplusのアプリで表示する

動画を表示することになった背景

Studyplusは現状受験生のユーザーが非常に多く、「同じ志望校を目指す仲間と励まし合いたい!」というニーズに応えるため大学にまつわる情報を取り扱う機能が手厚く揃っております。志望大学を探す機能、同じ大学を志望しているユーザーの勉強時間ランキングなど。

そうすると、「Studyplusで志望大学を探すユーザーに自校をもっとアピールしたい!」という大学さんも少なからず存在するわけです。アピールする新しい手段の1つとして、動画を掲載もできるように対応することとなりました。

実現方法

YouTubeの動画サムネイルを表示して、サムネイルがタップされたらウェブビューのみの画面を表示して動画を表示するようにしました。実装イメージが以下のとおりです。

f:id:m_yamada1992:20190513212011p:plain:w300f:id:m_yamada1992:20190513212017p:plain:w300

左がサムネイル表示画面、右が遷移先の動画再生のみを目的とするWKWebView画面

なお、公式ライブラリの利用は真っ先に検討したのですが、iOSに関してはとうの昔に非推奨のUIWebViewを挟んで動作させることが前提だったので断念しました。 AndroidはきちんとメンテされているYouTube Android Player APIを採用してます。

YouTubeの動画サムネイルを取得する

YouTubeにて動画の情報を取り扱う手段として正式に提供されているYouTube Data API (v3)のうち、Videosリソースにて動画のサムネイル画像URLが取得できるので、そちらを使用しました。

ひとくちに動画情報といっても動画の投稿者やタイトル、説明文など今回不要な情報もたくさんあるので、クエリパラメータのpartfieldsで必要なレスポンスの値を絞り込みました。

let queryItems = [
    URLQueryItem(name: "id", value: id),
    URLQueryItem(name: "part", value: "snippet"),
    URLQueryItem(
        name: "fields",
        value: "items(snippet(thumbnails(standard(url),medium(url))))"
    )
]

過去に外部のAPIを使用する場面はそこそこあったのですが、「返ってくるレスポンスのうち○○と△△しか使わない」といったコメントを残さないと分かりにくいことがありました。 このように不要な値はレスポンスに含まないよう実装で制御し、使っている値が一目瞭然にできるのはすごく画期的に思えます。

YouTube独自のオーバーレイ表示へ対応

サムネイル画像をタップしたあと表示するWKWebViewの画面なのですが、Studyplusのアプリで素直な実装にて表示してみるとちょっとした問題が発生しました。

Studyplusアプリで実際にYouTubeの動画を再生した際、以下のキャプチャのような表示が可能です。

f:id:m_yamada1992:20190513162321p:plain

問題点

  • 端末の縦方向表示のみをサポートしているのに、YouTubeの動画プレイヤーを横方向で表示できてしまう
    • 端末を横にした状態で動画プレイヤーを閉じると、もとの画面でステータスバーが消えてしまう

なお、消えた状態のまま前の画面に戻ってもステータスバーは復帰せず、通常のナビゲーションバー下辺までの高さがステータスバー分縮んだままとなります。

キャプチャからも見て取れる通り、オーバーレイ表示上でステータスバーのみ標準UIを流用していることが影響している挙動のようです。

今回選んだ解決方法

記事冒頭のキャプチャでお察しかもしれませんが、YouTubeを表示するためのWKWebView画面でのみステータスバーの表示を諦めました。画面を表示時にステータスバーは非表示へ変更、画面を閉じるときに元通り表示すると他の画面は影響しないことが確認できたためです。

YouTube的にはプラットフォームの標準仕様より、ユーザーがいかに快適に動画を視聴できるかを追求した結果の挙動なのかしらとは思います。が、なぜステータスバーだけ標準OSで表示しているものを引っこ抜いていくのだろうか・・・?

こちらの手段に落ち着くまでにそこそこの試行錯誤や調査を要して戸惑ったので、そこまで独自の挙動をするのだったら全部独自のビューを表示すればいいのにと思ってしまいます。

やらかしたお話

結論から申し上げますと、YouTubeの動画サムネイル取得失敗のパターンが無限ループする実装を施してしまいました。

起こったこと

GCPのAPIなので、クォータを食い切ります。以下は弊社開発環境でのYouTube Data APIクォータ利用状況のGoogle Cloud Consoleの様子です。

GoogleCloudコンソール上で確認した、開発環境でクォータ上限突破した際のキャプチャ

キャプチャのうち、赤い点線が1日のクォータ上限です。現実的に上限を使い切ることはまずないだろうという値を設定しております。

1回目の時点で-8時間表記であることを見落として、夜中の3時に起こったアプリの操作では起こりえないという判断をしてアプリ担当としてはスルー。2回目に再発してこれはおかしいぞと調べたら無限ループを仕込んでいたことが発覚。

リリースを予定していたバージョンを数日前に審査提出・通過完了しており、翌日のリリース準備万端! というタイミングで2回目の再発でした。 同じ環境に接続されているうち1個の端末でエラーが発生するとクォータを一気に1日の上限まで使い切っちゃいます。

同じ事象を本番環境で発生させてしまうとサムネイルの取得が全ユーザーで最大24時間できなくなります。存在する動画はサムネイルが取得できるものとして実装しているため、YouTubeの動画をアプリで表示できなくなります。致命的です。

原因

対象の画面で既存バージョンから存在する、ほとんど同じ形で単純に画像の表示を行うセルにて取得した画像サイズに応じて高さを変更していました。そしてYouTubeのサムネイルを表示するセルを新しく作成するときに流用したのですが、その際に本来不要な画像サイズに応じた高さ変更処理諸共流用して放置して開発を進めたのが直接の原因です。

ディレクターチームにリリースを予定している機能を一通り動作確認してもらったあと、iOSチーム内でのソースコードレビューにて指摘をもらって上記の処理をリファクタリングしたのが事の発端でした。

実装の詳細

まず、動画を表示するセルにて表示する内容を設定するメソッドは以下のようなイメージです。

func setup(text: String, completion: @escaping (_ loadImageRatio: CGFloat?) -> Void) {
    // ...省略...
    if let thumbnailUrlString = videoThumbnailUrl {
        // YouTubeのサムネイルのURLをすでに取得済みの場合、画像を表示
        loadImage(urlString: thumbnailUrlString)
    } else {
        // 動画サムネイルURLの取得に1回失敗していた場合、このタイミングでリトライ
        guard let videoId = youtubeId else { return }
        VideoRepository.getVideoData(id: videoId, success: { thumbnailUrl in
            /* このセル用のコンテキストインスタンスへサムネイルのURLを保持 */
            // 取得したYouTubeのサムネイルのURLから画像を取得して表示
            self.loadImage(urlString: thumbnailUrl)
        }, failure: { _ in
            /* 動画を表示できない旨を表記 */
        }, finally: {
            // 成功したときは取得した画像の縦横比率を、失敗時は失敗したとき用の画像の縦横比率を渡す
            completion(imageRatio)
        })
    }
    // ...省略...
}

YouTubeから動画のサムネイルURLをすでに取得して保持済みの場合はその画像を表示、何らかの影響でサムネイルURLを取得できずに動画が表示できていなかった場合はリトライしてみるという実装です。

なお、この画面で表示する動画IDは弊社側で大学から頂戴したデータを管理するので、まず存在しないものはないはずという前提です。一瞬ネットワークの調子が悪くて取得失敗したなら、スクロールして戻ってきた際にリトライしたほうが親切だろうと。

問題が発生した動画を表示するセル生成処理

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    // ...省略...
    let cell = tableView.dequeueReusableCell(withIdentifier: "VideoCell", for: indexPath)
    cell.setup(text: text, completion: { imageRatio in
        /* このタイミングで取得完了した画像の縦横比率をこのセル用のコンテキストインスタンスへ保持し、以降画面へセルを再表示しても同じ高さで表示 */
        tableView.reloadRows(at: [indexPath], with: .none)
    })
    // ...省略...
}

上記のうち reloadRows(at:with:) メソッドを呼んでいる箇所で、リファクタリング前にperformBatchUpdates(_:completion:)メソッドを引数nilで呼ぶという一見なぜこの処理をこのタイミングで呼ぶか分かりにくい状態でした。ですので、そもそも reloadRows(at:with:) メソッドでも同じ挙動が期待できる()から、こっちのほうがシンプルでいいよね! という結論にいたり修正してこの実装に落ち着きました。

※厳密にはreloadRows(at:with:) メソッドを実行時にはtableView(_:cellForRowAt:)メソッドが呼ばれ、 引数nilのperformBatchUpdates(_:completion:)メソッド実行時は呼ばれないという違いがあります

起こったこと

  1. セルを生成する
  2. 指定した動画IDがなんらかの原因で存在しない、またはアクセス不可なので動画のサムネイルURL取得を再度試みる
  3. 共通の完了処理を呼ぶ
  4. 完了処理でreloadRows(at:)を呼ぶ
  5. 動画を表示するセルを再生成する(1に戻る)

という流れでの無限ループが完成しました。 動画を表示するためのセルが見える範囲内に存在する限り、無限に処理が走り続けます。

対応

さらにそもそもの話で、YouTubeの動画サムネイルはサイズ指定して取得できるため、画像が空の状態で一旦表示したあとに高さが変わることへの考慮は一切必要ございませんでした。 なので、サムネイル取得の試行完了後に行なっていた再読み込み処理をばっさり削除するかたちで事象は解消しました。

対応後の実装

セルのsetupメソッドからcompletion引数を削除しました。

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    // ...省略...
    let cell = tableView.dequeueReusableCell(withIdentifier: "VideoCell", for: indexPath)
    cell.setup(text: text)
    return cell
    // ...省略...
}

なんて無駄で弊害のある処理をしていたのでしょう! 驚きのシンプルさ。😭

修正版のリリース

リリース予定前日であり、その日修正版で審査を再提出してリジェクトされずに翌日リリースできるという保証はありません。ですので、社内のディレクターチームへ事象の説明や修正をいつリリースするかの対応を相談しました。

結果的な対応としては、もともとのバージョンを一旦予定通りリリースした直後に修正版を審査に提出、審査が通った次の日にリリースしました。

事象が発生したとしてもユーザー影響が限定的であることを鑑みての判断です。今回の事象が発生するのはケース入稿元のデータがおかしくないと滅多なことで発生し得ないこと、リリース日当日から動画の表示を始める大学がまだそれほど多くなかったこともあり。

さいごに

至極当たり前でたいへん初歩的ですが、以下を肝に銘じました・・・

  • こまかな仕様検討が開発と並行で走らざるおえない場合、未決定の状態で極力実装を進めない
    • 動画の取得が失敗した際、失敗したとき用画像を代わりに表示するかサムネイルの表示領域自体を非表示にするか開発フェーズ終盤まで確定させていなかった
      • 決まったあと不要と確定した処理を削れば良いかと思っていた節もある
    • 後回しにしがちだったエラーケースも早めに決めてもらえるよう、ディレクターへ積極的に声がけする
  • 既存の類似UIの処理を流用する際は、その時点で絶対に使用するとわかっている処理のみ絞り込んで利用する
    • "大は小を兼ねる"という概念はソースコードに当てはまらない、当てはめちゃいけない
  • リファクタリング先の影響範囲動作確認を徹底する
    • 全く同じように動くだろうと確認漏らすのはダメ絶対

現在担当しているプロジェクトの実装の品質は、前に担当したものよりかは安定しつつあるかなと自負してます。いや、安定していかないと不味いんですが。

ただし、新規事業開発でお忙しいところiOSチームリーダーの須藤さんにかなりの助力を頂いております。 今回の記事に記載したYouTubeのオーバーレイ表示に対する対応やサムネイルURL取得処理無限ループへの対応をはじめ、現在進行中のプロジェクトも。

まだまだ知識経験共に足りないひよっ子ですが、少しでも早くより力になれるよう、ひよっ子なりに学び取ったことは血肉として今後に活かしてゆく所存です。

そして、やらかしが発覚したあとの対応を決めるまでのスピード感や、「ブログ当番時に今回やらかした内容したためます〜」という具合にアウトプットすることへの寛容さにも驚いたり。

実は事象が発覚したリリース前日に自宅からのリモート勤務をしていて、これは1回会社に出勤して各方面へ直接相談しないと不味いかなと肝を冷やしました。 が、ディレクターチームへの対応の相談はMTGすら発生せず全部Slackのチャットで完結しました。ちょっと感動しました・・・良い環境に身を置けているなぁとしみじみ思います。