Studyplus Engineering Blog

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

Kotlin Flow+Roomで作るTimer&Stopwatch

こんにちは、モバイルクライアントグループの若宮(id:D_R_1009)です。 2月以降リモートワークで開発を続けております。会社近くのラーメン屋さんが恋しくなってきました。

今回は5月中旬にリリースした、AndroidアプリのTimer&Stopwatch改修について書きます。 Kotlin FlowとRoomを組み合わせ、複数のUIで同時にある値を表示する実装となったので、参考になれば幸いです。

Timer&Stopwatchの改修経緯

Studyplusスアプリには、学習している時間を計測するためのTimerとStopwatch機能があります。 Timerは予め時間を指定して経過時間を記録する機能、Stopwatchは計測開始から終了までの経過時間を記録する機能です。

今年の4月以降、このStopwatch機能について不具合報告が多くなっていました。 不具合を精査したところ、一部「ユーザーの利用環境変化」が理由と考えられるものが発生していることがわかりました。

利用状況の変化の詳細については、弊社の発表や過去のブログ記事をご参照ください。

info.studyplus.co.jp

tech.studyplus.co.jp

今回影響がありそうな点は、スタディプラスのユーザーがより自宅で学習する状況となったことです。 自宅で「動画教材を視聴しその時間を計測する」ユーザーが増加し、「教材の動画を視聴していると計測がストップする」という不具合が起こりやすくなったと推測できます。

開発を検討していた4月半ばごろは緊急事態宣言の話が取り沙汰されていました。 そのためこの問題が起こりやすい状況が続くことも予想され、優先度を高めて対応することとしました。

既存実装について

まず、不具合が報告された既存実装から簡単に説明します。

これまでの実装では、ForegroundService 上で時間計測用のTimerやCountDownTimerを行い、その計測結果をRxBusやIntentを用いてUIに送信する実装となっていました。 これはサービスとスレッドのどちらを選択するかを参考に、バックグラウンドでAndroid OSの許す限り計測を行うという実装方針のもとに選択したものです。 既存実装の設計時には「(物理媒体の)教材を学習中、スマートフォンで時間を計測する」ようなケースを想定していたため、この実装でも問題ないだろうと考えていました。

ただこの実装ではユーザーが例えば動画のストリーミングアプリを立ち上げた時、端末のスペックによってはStudyplusアプリがメモリから破棄されてしまいます。 この破棄時に状態を保持するためにはには SavedInstance への保存処理、そして SavedInstance からの復元処理を記述する必要があります。 しかしながら、既存実装ではこの対応が行えていませんでした。 またServiceの SavedInstance 対応は調査から始める必要があり、導入コストが高いと見積もられ、すぐに着手できる状況ではありませんでした。

不具合の原因まとめ

簡単にまとめると、下記のようになります。

  1. 既存実装は ForegroundService 上で計測処理を実施していた
  2. Activity/Fragmentは Service から送られてくる計測状態を表示していた
  3. メモリ破棄時の SavedInstance が未実装であった
  4. 外出自粛や各種休校に伴い動画視聴と並行して利用されるケースが増加した(と考えられる)

Kotlin Flow+Roomによる計測機能

新たにTimer&Stopwatchの設計をするにあたり2つの方針を立てました。

  1. 不揮発領域に計測状態を保存し、アプリの生存期間に関わりなく時間の計測ができるようにする
    1. アプリ破棄時への対応
    2. アプリ再起動時への対応
  2. Activity/FragmentとServiceを同列に扱い、どちらも"UI"としての役割とする
    1. 計測をUI層ではなくModel層で実施する
    2. DIが可能にすることでテスタビリティを向上する

なお、簡単な実装を下記のリポジトリで公開しています。 動作を見つつコードを確認したい場合は、ご活用ください。

github.com

Roomの設計

以前のエントリで紹介しているように、スタディプラスのAndroidアプリではRoomを採用しています。

tech.studyplus.co.jp

このため、今回の機能開発においてもRoomを利用することとしました。 都合の良いことにRoom 2.2よりKotlin Flowをサポートしているため、今回の用件にマッチした次第です。

medium.com

Roomに保存するテーブルは、下記のようにします。 なお説明を簡単にするため、Stopwatch機能のみの実装としています。 実際にはTimerや計測中の教材に関連する情報などのデータも含む実装です。

@Entity(tableName = "measurement")
data class MeasurementEntity(
    @PrimaryKey
    @ColumnInfo(name = "entity_id")
    val entityId: Int = 0,
    val state: MeasurementState,
    @ColumnInfo(name = "start_date_time")
    val startDateTime: String, // 2020-04-20'T'08:20:00+09:00
    @ColumnInfo(name = "elapsed_sec")
    val elapsedSec: Long = 0L
) {
    companion object {
        val INIT_OBJECT = MeasurementEntity(
            state = MeasurementState.INIT,
            startDateTime = ""
        )
    }
}

MeasurementState は次の通りです。 Stopwatchはユーザーがボタンを操作して状態が変化する仕組みとなるため、シンプルにRUNとSTOPの2つの状態だけを想定します。

enum class MeasurementState {
    INIT,
    STOPWATCH_RUN,
    STOPWATCH_STOP
}

これでStopwatchの状態、計測開始時刻、前回までの計測で加算された時間を保存することができるようになります。 続いて、経過した時間について開発を進めていきます。

Kotlin Flowによる計測

ここからはDaoの定義を行い、ブログタイトルの通りKotlin Flowでデータを取得します。 Flowを使うことで「rowを更新する」処理と「rowが更新された」処理を分離し、特に「rowが更新された」処理をリアクティブな実装とすることができます。

RoomはSELECTメソッドの返り値を Flow<MeasurementEntity?> とすることで、rowが存在しない時に null となるFlowを作ることができます。 後述のRepositoryでnon-nullに変換していますが、 Flow<List<MeasurementEntity>> とすることでnon-nullにすることもできるので、お好みで選択してください。

@Dao
interface MeasurementDao {

    @Query("SELECT * FROM measurement WHERE entity_id=0")
    fun findFlow(): Flow<MeasurementEntity?>

    @Query("SELECT * FROM measurement WHERE entity_id=0")
    suspend fun find(): MeasurementEntity?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(entity: MeasurementEntity)
}

最後にFlowで受け取った MeasurementEntity に合わせて、描画更新用のイベント clock を作成します。 サンプルでは1つのActivityでしか描画を受け取っていませんが、同期イベントをRepositoryから作成することで、複数のUIで同時に描画を更新することができます。

transformLatest を利用することで MeasurementEntity が変更された時に、rowの更新があったとき clock が更新されるようになります。 このため clock 内で while(true) のループを回す実装を採用しています。

@Singleton
class StopwatchRepository @Inject constructor(private val dao: MeasurementDao) {

    val entity = dao.findFlow().map {
        it ?: MeasurementEntity.INIT_OBJECT
    }

    private val clock = entity.transformLatest {
        emit(OffsetDateTime.now())
        when (it.state) {
            MeasurementState.STOPWATCH_RUN -> {
                while (true) {
                    delay(INTERVAL_MILLI)
                    emit(OffsetDateTime.now())
                }
            }
            else -> {
                // nop
            }
        }
    }

    companion object {
        private const val INTERVAL_MILLI = 500L
    }
}

計測時間の表示

ここまで計測のベースを作成したので、リアルタイムで更新される描画を考えていきます。 経過時間は現在時刻と計測開始時刻の差と前回までの計測で加算された時間の合計と考えることができます。

@Singleton
class StopwatchRepository @Inject constructor(private val dao: MeasurementDao) {

    val elapsedTime = combine(entity, clock) { entity, event ->
        when (entity.state) {
            MeasurementState.INIT -> {
                0L
            }
            MeasurementState.STOPWATCH_RUN -> {
                entity.secUntil(event) + entity.elapsedSec
            }
            MeasurementState.STOPWATCH_STOP -> {
                entity.elapsedSec
            }
        }
    }
}

ViewModelではこの値を表示させるため、LongからStringへの変換を行います。 どのようなStringへ変換するかはUI依存のため、今回はViewModelで変換するのが良さそうと判断しています。

class MainViewModel @Inject constructor(private val repository: StopwatchRepository) : ViewModel() {

    val elapsedTime = repository.elapsedTime.map { formatMeasureTime(it) }.asLiveData()
}

fun formatMeasureTime(duration: Long): String {
    val hour = duration / 3600L
    val minute = duration % 3600L / 60L
    val second = duration % 60L

    return String.format(Locale.US, "%02d:%02d:%02d", hour, minute, second)
}

あとは各ボタンの有効状態を repository.entityMeasurementState を基に調整することで、Stopwatch機能が完成です。 現実装では StopwatchRepository に作成した操作用メソッドをViewModelから操作することで、Roomの状態を更新しています。

不具合の改修状況

新たな実装ではRoomに保存されている状態を見ればいつでも計算を行うことができるようになりました。 このため、既存の Service のライフサイクルに依存することにより発生した問題を解消しています。

また、「Stopwatchを止めずに端末の電源をOFFにした」ケースにも対応することができました。 サンプルのコードを動かしている場合は、ぜひ1度お試しください。*1

時間計測は考慮することが多く大変なのですが、基本的なユースケースにシンプルなコードで対応できたように思います。 なお端末の時間を変更した場合などのエッジケースが存在するのですが、そちらの考慮は割愛させていただきます。

終わりに

5月半ばよりリリースした、RoomとKotlin Flowを組み合わせて時間計測機能を追加する実装を紹介しました。 現時点まで大きな不具合報告もなく、市場で動作しているようで安心しています。

Roomから簡単にKotlin Flowを取得することができるので、Roomを中心とした設計にするとFlowが扱いやすいなと感じました。 ちょうどRxJavaをアプリから削除し終わったので、今後もFlowを活用していきます。

最後までお読みいただきありがとうございました!

*1:もしもビルドがめんどくさければ、是非Studyplusをダウンロードしてみてください!