CountDownTimerがズレるので無理やり直してみた

AndroidStudio + KotlinでCountDownTimerを使ったアプリを作ったのですが、無視できないレベルで時間がずれる。。。
ここでは誤差を補正する方法について説明します。

問題点

最初、時間のカウント処理をするためにCountDownTimerクラスを使ったのですが、カウントする時間に誤差が発生します。
調べてみると2つのタイプの誤差がありました。

  1. 常に発生する誤差。1分当たり1~2秒ずれる
  2. スリープ時に発生する誤差。1分当たり数秒~数十秒ずれる

いずれも時間計測するアプリとしては致命的です。

解決方法

結論から言うと以下3つの方法を実装して解決しました。

  1. coroutineでカウント処理を非同期にする
  2. 誤差を補正するロジックを入れる
  3. スリープ時の大きな誤差に対処する

順を追って説明します。

1.Coroutineでカウント処理を非同期にする

同期処理・非同期処理の概要についてはこちらの記事を参考にさせていただきました。

同期処理、非同期処理、並列処理のざっくりとした違い

最初にカウント処理を実装した際は同期処理だったので、非同期処理にすれば誤差が改善するのでは?ということでcoroutineを組み込みます。

Gradleにcoroutineライブラリを設定する

build.gradle
dependencies {
    def coroutines_version = '1.3.4' //Kotlin coroutines用ライブラリ
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
}

ライブラリをimpotrtする

MainActivity.kt
import kotlinx.coroutines.*

カウント処理を非同期処理にする

MainActivity.kt
    class Countdown() {
        private var countDownJob: Job? = null

        fun countDownStart(activity: MainActivity) {

        //ここにカウント処理を記述

            }
            countDownJob?.start()
        }

これで非同期処理のガワができました。

2.誤差補正ロジックを追加する

上記のここにカウント処理を記述のところに、カウント処理および誤差補正ロジックを記述します。

処理概要は以下の通り

  • 指定の間隔をカウントダウンする (intervalTime)
  • 1秒ごとに画面の残り時間表示を更新 (activity.timerText.text)
MainActivity.kt
val intervalTime = 60 //指定の間隔(カウントダウン秒数)
var lateSec = intervalTime  //計算用に格納
var delayTime = 1000L   //呼び出す間隔 1秒おき
val startTime   = System.currentTimeMillis() //スタート時の時刻を保持
var count = 0 //while内の繰り返し数カウント
var diffTime:Long =0L

while(isActive) {

      //ここで指定した時間待つので、以降の処理はここで指定された間隔で実行する
      delay(delayTime)

      diffTime = System.currentTimeMillis() - startTime - count * 1000   //現在時刻から開始時刻と経過秒数を引く
      delayTime = 2000 - diffTime         //次のdelay待ち時間を補正
      lateSec -= 1                        //1秒ずつマイナス
      count += 1                          //繰り返し回数をカウント

      //画面に残り時間を表示
      activity.timerText.text = activity.getFormatTime(lateSec.toLong())

      //残り時間がゼロになったら、リセットする
      if (lateSec == 0L){
          //ここに残り時間ゼロ時に実行したい処理内容を記述

          lateSec = intervalTime //残り時間を初期化する
      }
}

例えば時間表示を1秒ごとに更新する場合は、delay(1000)とし、その後に時間表示処理を書きます。
が、実際には正確に1000ミリ秒毎カウントされるわけではなく、微妙に遅延が発生します。

そこで遅延分を補正した待ち時間を算出するために、以下計算をしています。
遅延時間 = 現時刻 - 開始時刻 - 経過秒数
コード上は以下の部分です。

diffTime = System.currentTimeMillis() - startTime - count * 1000

これで遅延補正のロジックは完成です。

3.Doze無効にする

上記2つによりアクティブ時の誤差は解消しますが、スリープ時の誤差は解消しません。
ここで言うスリープ時とはデバイスがDoze状態に移行することを指します。

Dozeについては公式ドキュメントで詳しく解説されています。

Doze とアプリ スタンバイ用に最適化する

簡単に説明するとDoze状態とはOSレベルで省エネ機能がはたらいている状態で、OS側の判断でバッテリー節約のために処理が保留されたりします。これが原因でスリープ時に大幅に遅延するわけですね。使用クラスやロジックの工夫では回避できません。

回避方法は、Dozeホワイトリストに追加して、アプリをDoze状態に移行させない処理を記述します。

パーミッションの追加

マニフェストファイルに以下一文を追加します

AndroidManifest.xml
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>

ホワイトリスト登録ダイアログの表示

アプリ初回起動時にホワイトリスト登録ダイアログを表示します。

MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    ・・・
    //Doze無効
    val powerManager = applicationContext!!.getSystemService(Context.POWER_SERVICE) as PowerManager
    if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
        val intent = Intent(ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) //バッテリ最適化を無効化するダイアログ表示
        intent.data = Uri.parse("package:$packageName")
        startActivity(intent)
    }

これでDozeの影響を受けなくなります。

最後に

以上でズレは解消しました。
もっとスマートなやり方があるかもしれませんが、まあこういう方法もあるということで。
参考になれば幸いです。

で、結果できたアプリがコチラ

インターバルタイマー

指定した間隔で繰り返しアラームを鳴らす、シンプルなAndroidアプリです。
ホントにズレないだろうな?と興味がありましたら是非使ってみて下さい。