cookpad storeLive のクライアントアプリ開発の裏側

こんにちは。メディアプロダクト開発部の柴原(@nshiba310)です。 趣味は Destiny2 というゲームです。

普段は cookpad storeLive(以下、storeLive)のクライアントサイド(AndroidTV)の開発を行っています。 本記事では storeLive のクライアントサイドの開発についてご紹介したいと思います。

storeLive とは

スーパーマーケットの店頭に設置した縦型55インチの大型サイネージで、著名人や料理研究家による料理デモンストレーション映像をLiveや収録動画で提供するアプリです。
プレスリリースはこちら

スーパーの担当者はまず storeLive が置かれている売り場に適した再生したい動画を選択します。 storeLive は担当者が動画を止める操作をするまで選択された動画をループで再生し続けます。
また storeLive では土・日曜日など、比較的人が集まりやすいタイミングでライブ配信を行うことがあり、ライブ配信が始まるとアプリは自動的にライブ閲覧画面を起動し、終了すると自動的に前に再生していた動画再生画面に戻るようになっています。

データの自動更新

storeLive は通常のアプリと違い基本的に人間の操作が動画の選択時しかありません。
そのため、ライブ閲覧画面の起動や動画情報の更新などは自動で行う必要があり、 storeLive ではポーリング機能を実装しライブ配信や動画の情報を自動的に更新しています。

仕様としては、

  • サーバーリクエスト時にレスポンスに次回実行時間が含まれていた場合にはその時間で次の実行を登録
    • ライブ配信が近づいてきたら高頻度で情報の更新を行うため
  • デフォルトは10分間隔で実行
    • ライブ配信を本番より15分ほど先に開始しておきクライアントから10分間隔でアクセスすることにより、必ず全ての端末でライブ閲覧画面を起動させる
    • 高頻度リクエスト時は1分間隔で実行されることを想定

の2つがあり、これを実現するために storeLive では AlarmManager を採用しました。

AlarmManager を採用する理由

近年の Android アプリ開発において、定期実行処理を実装する場合には JetPack Components の WorkManager を使うことが候補としてあがってくると思います。 しかし、 WorkManager には以下の制限があり今回要求されている仕様を満たすことができないため使用することができませんでした。

  • 最低の繰り返し実行間隔は15分
  • 厳密な実行時間は保証されない
    • 実行間隔は WorkManager に設定した時間間隔の中で端末の状態が最適かどうか(Doze Mode や WiFi 接続している等)を判断し最適なときに実行されるため常に何秒/何分後に実行される、という保証ができない

一方で AlarmManager には setAlarmClock() というメソッドがあり、これを用いると1秒単位で実行時間を設定でき、きちんと設定した時間に発火してくれます。

WorkManager にはリトライ機構もあり可能なら使いたかったですが、今回は仕様に合わず AlarmManager を採用しました。

以下の例では 100 秒後に発火するアラームを設定しています。

val triggerTimeSec = 100

val calendar = Calendar.getInstance().apply {
    timeInMillis = System.currentTimeMillis()
    add(Calendar.SECOND, triggerTimeSec)
}
val intent = Intent(context, TestBroadcastReceiver::class.java)
val pendingIntent =
    PendingIntent.getBroadcast(
        context,
        0,
        intent,
        PendingIntent.FLAG_UPDATE_CURRENT
    )
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.setAlarmClock(
    AlarmManager.AlarmClockInfo(calendar.timeInMillis, null),
    pendingIntent
)

このようなバックグラウンド処理に関しては Android Developers にガイドがあるため一度読んでおくことをおすすめします。
https://developer.android.com/guide/background

オフライン機能

スーパーはいろいろな人が出入りする場所のためいろいろな電波が飛び交う可能性があり、ネットワーク状況が不規則に不安定になる可能性があります。
また storeLive が稼働する環境はスーパーの固定された位置を想定しているため、ネットワーク状況が悪くなってもネットワーク状況がいい場所に移動できないということもあり、ネットワークが不安定でアプリがうまく動かないという問題が稼働当初からありました。
他にも、 storeLive は基本的に動画を再生し続けるアプリであり人間の操作はほとんど存在しません。それに加えて、アプリを操作して動画を再生する人と動画を閲覧する人は別の人間です。そのため、通常のアプリでネットワークに接続出来ない場合によく取る手段としてリトライボタンを表示したり、 ネットワークの接続を確認してください といったような表示で凌ぐことはできません。

これらの問題がありますが、ネットワーク接続が切れるたびに担当者に復旧作業を行うようお願いしていては運用コストが跳ね上がってしまいかなりの負担になってしまうため、 storeLive ではできるだけアプリの安定性を高めるためにオフライン機能を実装しました。

オフライン機能を実装するにあたって取得したデータをローカルに保存する必要があり、今回はDBに保存することにしました。
Android でDBを扱うライブラリはいくつかあると思いますが、今回は Room を採用しました。
Room を採用した理由については、 storeLive プロジェクトでは Kotlin coroutines を採用していることや、 ViewModel や LiveData といった JetPack Components を採用しているので、これらと連携する手段が公式で用意されているためです。
また、チームとして Google が推奨しているアーキテクチャや JetPack Components をなるべく採用していこう、という動きがあるのも理由の一因です。

Room と LiveData で実現するオフライン機能

Room と LiveData を組み合わせて使うと簡単にそしてシンプルにオフライン機能を実装することが可能です。

Room 以下のようなデータを定義します。

@Entity(tableName = "movies")
data class Movie(
    @ColumnInfo(name = "id") val id: Int,
    @ColumnInfo(name = "movie_url") val movieUrl: String
)

次に Room で DAO を定義します。
以下の例では select 文を発行する関数を実装していますが、このとき戻り値を LiveData をラップすることでデータを LiveData 経由で非同期に取得することが可能です。
ちなみに LiveData を使う場合は裏側で勝手に別スレッドでDBに問い合わせするため、 selectAvailableMovies() 関数を呼び出すときに別スレッドで呼び出す必要はありません。

@Dao
abstract class MovieDao {

    @Query("SELECT * FROM movies")
    abstract fun selectAvailableMovies(): LiveData<List<Movie>>
}

使用するときは普通に observe してあげるだけです。
LiveData にデータが渡されるタイミングとしては、メソッドを呼び出した時の他に、 SELECT * FROM movies のクエリ結果に変更があった場合(movies table にデータが insert された場合等)に新しいデータが渡されます。

class MainActivity: DaggerAppCompatActivity() {

    @Inject
    lateinit var movieDao: MovieDao

    onCreate(savedInstanceState: Bundle?) {
    
        movieDao.selectAvailableMovies.observe(this, Observer { movies ->
            // 渡されたデータを処理する  
        })  
    }
}

こうすることで Activity や Fragment などデータが欲しい場所で API を叩くのではなく、 LiveData を observe しておき、新しいデータが欲しい場合は API を叩き結果を DB に insert すると、 UI の更新が可能です。
一見 DB が間に入っているため面倒ですが、一度でも UI が表示できれば DB にデータが入っているため、ネットワークがないとき、つまりオフラインでもアプリが動作するのでこれでオフライン機能の完成です。

また今回のオフライン機能は DB のデータを真としているため表示してる Activity/Fragment 以外からのデータの更新が可能です。
前述したとおり、 storeLive ではポーリング機能が存在してます。
もともとはポーリングでデータの更新を行ったあと BroadcastReceiver を用いて現在表示している Activity にデータの更新通知を送っていたのですが、今回のオフライン機能を実装したことでポーリングでデータの更新を行ったあと DB にデータを insert するだけで良くなったのは非常に嬉しかったです。

リストの表示には Paging と組み合わせて使う

JetPack Components の中にはリスト表示を行うためのライブラリとして Paging があります。 詳細な説明は省きますが、通常 Paging は DataSource クラスを使ってデータを読み込みます。
DataSource クラスには Factory クラスが用意されており、 Room は DataSource.Fractory クラスを戻り値にすることができます。

@Dao
abstract class MovieDao {
    
    @Query("SELECT * FROM movie_list_item")
    abstract fun selectMovieList(): DataSource.Factory<Int, MovieListItem>
}

また、 ktx の version 2.1.0-alpha01 から DataSource.Factory クラスに toLiveData() という拡張関数が用意されています。
この関数は内部で LivePagedListBuilder を用いて LiveData<PagedList> に変換してくれるため、そのまま RecyclerView でリストの表示が可能となります。

val movieList = movieDao.selectMovieList().toLiveData(
        config = Config(
            pageSize = 10,
            prefetchDistance = 10,
            enablePlaceholders = false
        )         
    )

movieList.observe(this, Observer {
    // RecyclerView で表示
    adapter.submitList(it)
})

オフライン機能の難しかった点

管理画面で操作した内容はいつ端末に反映されるのか

オフライン機能ではローカルにデータを保存するため、一度データを取得したあとはネットワークに繋がらなくてもアプリの表示が可能です。
そのため、何もしなければアプリ上からは これはいつのデータか というのはわからず、管理画面からデータの変更を行っても、それはいつ端末に反映されたのか、すでに反映された後なのか、といった判断が難しいです。
そのため、どういった操作をすれば必ずサーバーと通信しデータを更新できるのか、というのも決めておく必要があり、 storeLive では、画面遷移をするときには その画面に必要な情報自動更新に関するデータ(ライブ配信情報など) を更新する API を必ず叩くようにしました。

また、ネットワークに接続出来ていない場合には全画面上右上に ネットワークに接続していません という表示を出し、ひと目でネットワークに接続出来ているかどうかを判断できるようにしています。
ネットワーク接続判断は以下のように、接続状況が変わったら通知がくる LiveData を作り各画面で observe しています。

class NetworkCallbackLiveData(private val connectivityManager: ConnectivityManager) : LiveData<Boolean>() {

    private val networkCallback = object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) {
            super.onAvailable(network)
            postValue(true)
        }

        override fun onUnavailable() {
            super.onUnavailable()
            postValue(false)
        }

        override fun onLost(network: Network) {
            super.onLost(network)
            postValue(false)
        }
    }

    override fun onActive() {
        super.onActive()
        val builder = NetworkRequest.Builder()
        connectivityManager.registerNetworkCallback(builder.build(), networkCallback)

        val network = connectivityManager.activeNetwork
        val networkCapabilities = connectivityManager.getNetworkCapabilities(network)
        value = network != null && networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
    }

    override fun onInactive() {
        super.onInactive()
        connectivityManager.unregisterNetworkCallback(networkCallback)
    }
}

余談ですが、アプリ上ではネットワークに繋がっている判定で ネットワークに接続していません の表示がでないのですが、ルーターから先のネットワークに接続できないらしく Unable to resolve host "example.com": No address associated with hostname というエラーが出てしまってデータの更新が出来なくなる、という問題が発生していて困っています。

表示するコンテンツに掲出期間があるか

こちらはアプリやサービスの性質によりますがコンテンツには表示期間が存在することがあり、実際に storeLive では各動画に表示期間を設けています。 何度も言う通り、オフライン機能ではローカルにデータを保存するため、一度データを取得したあとはネットワークに繋がらなくてもコンテンツの表示が可能です。
逆に言うと、ネットワークに繋がらなければデータの更新は一生行われません。
そのため、表示期間が定められていると期間を過ぎてアプリ上に表示されてしまうとまずい状況になってしまう可能性があり、いつまで表示していいのかという情報も一緒に保存しておく必要があります。

Room ではデータを取得する時に where 句を書くことができるので、テーブルに表示期間のカラムを入れておくことでデータ取得時にフィルタリングできるので便利です。

@Entity(tableName = "movies")
data class Movie(
    @ColumnInfo(name = "id") val id: Int,
    @ColumnInfo(name = "movie_url") val movieUrl: String,
    @ColumnInfo(name = "starts_at") val startsAt: String,
    @ColumnInfo(name = "ends_at") val endsAt: String
)
@Dao
abstract class MovieDao {
    
    @Query("SELECT * FROM movies WHERE starts_at <= :date AND :date <= ends_at")
    abstract fun selectMovieList(date: String): DataSource.Factory<Int, MovieListItem>
}

1つ注意したいのが、この時に何も考えずに端末時刻を比較に使用してしまうと Android では端末時刻は容易に変更できるため表示期間のチェックとしては不十分な場合があります。
storeLive の場合では、アプリや端末自体の操作はユーザーには行われない想定なので、端末時刻は変わらないという前提の元比較に使用しています。もし、この方法を参考に表示期間のチェックをする場合には注意してください。

まとめ

storeLive の主にポーリング機能やオフライン機能の開発についてご紹介しました。
storeLive はまだまだサービスのあり方を模索している段階で機能追加や仕様変更などがたくさんあります。また大きな機能の実装がありましたご紹介したいともいます。

興味がある方いらっしゃいましたら、気軽にお声がけください。一緒に色々チャレンジしていきましょう。

info.cookpad.com

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/