Android 8.0以降で充電状態の変化を監視する方法について調査してみた

18 min読了の目安(約11100字TECH技術記事

背景

Android で充電状態の変化を監視する方法として、BroadcastReceiverを使ってリアルタイムに充電状態を監視する方法があります。

デバイスを電源に接続するか未接続にすると、BatteryManager によってアクションがブロードキャストされます。アプリが実行されていないときでもこれらのイベントを受信することが重要です(特に、これらのイベントがバックグラウンド アップデートを開始するためにアプリを起動する頻度に影響する場合)。したがって、BroadcastReceiver をマニフェストで登録し、ACTION_POWER_CONNECTED と ACTION_POWER_DISCONNECTED をインテント フィルタ内で定義することで両方のイベントをリッスンする必要があります。

ですが、Android 8.0(API レベル 26)からブロードキャストの制限事項がさらに強化され、BroadcastReceiver を使ってリアルタイムに充電状態を監視できなくなりました。

アプリがブロードキャストを受信するように登録されている場合、ブロードキャストが送信されるたびにアプリのレシーバーがリソースを消費します。 そのため、システム イベントに基づくブロードキャストの受信を登録しているアプリが多すぎると、ブロードキャストをトリガーするシステム イベントによって、これらのすべてのアプリが続けざまにリソースを消費し、ユーザー エクスペリエンスに悪影響を与え、問題が発生する可能性があります。

Android 8.0 以降を対象にしているアプリは、暗黙的なブロードキャストに対するブロードキャスト レシーバーをマニフェストで登録できなくなりました。

代替手段として、JobSchedulerを使用してくださいとのことでした。

多くの場合、以前に暗黙的なブロードキャストに対して登録していたアプリは、JobScheduler ジョブを使用して同様の機能を得ることができます。

JobScheduler について調べたところ、以下の記事が見つかりました。
Android 8 で JobScheduler の定期実行を確認する - Qiita

Google I/O 2018 において発表された「JetPack」において、Android のバージョンに応じてそれらの処理を切り替える WorkManager が含まれているので、今後はそちらを利用していくべきかと思います。

なので、WorkManager を使用してリアルタイムに充電状態を監視する方法を調べてみました。

WorkManager の概要

WorkManagerの主な機能としては以下が挙げられます。

  • API 14 までの下位互換性
    • API 23 以上が搭載されたデバイスでは JobScheduler を使用
    • API 14 ~ 22 が搭載されたデバイスでは BroadcastReceiver と AlarmManager を組み合わせて使用
  • ネットワークの可用性や充電ステータスなどの処理の制約を追加する
  • 非同期の 1 回限りのタスクや定期的なタスクのスケジュールを設定する
  • スケジュール設定されたタスクの監視と管理
  • タスクを連携させる
  • アプリやデバイスが再起動してもタスクを確実に実行する
  • Doze モードなどの省電力機能に準拠する

使いどころとしては以下が挙げられます。

  • ログやアナリティクスをバックエンド サービスに送信する
  • アプリデータをサーバーと定期的に同期する

導入方法

アプリの build.gradleに以下の異存関係を追加してください。

implementation "androidx.work:work-runtime-ktx:2.4.0"

Constraints を使って検証

WorkManager にはConstraintsという、実行可能なタイミングを指定できる機能があったので、充電中または充電中でない時だけ実行できるか検証しました。
まず、以下のように充電中の時だけ実行するChargingWorkerクラスと充電してない時だけ実行するNoChargingWorkerクラスを作成します。

/**
 * 充電中の時だけ実行するWorker
 */
class ChargingWorker(appContext: Context, workerParams: WorkerParameters) :
    Worker(appContext, workerParams) {
    override fun doWork(): Result {
        Log.d(TAG, "充電中")
        return Result.success()
    }
    companion object {
        private const val TAG = "ChargingWorker"
    }
}
/**
 * 充電してない時だけ実行するWorker
 */
class NoChargingWorker(appContext: Context, workerParams: WorkerParameters) :
    Worker(appContext, workerParams) {
    override fun doWork(): Result {
        Log.d(TAG, "充電してない")
        return Result.success()
    }
    companion object {
        private const val TAG = "NoChargingWorker"
    }
}

次に、以下のように呼び出し処理を作成します。呼び出すタイミングは任意です。

// 制約を作成
val constraintsCharging = Constraints.Builder()
    .setRequiresCharging(true) // 充電中である
    .build()
val constraintsNoCharging = Constraints.Builder()
    .setRequiresCharging(false) // 充電中でない
    .build()

// 15分おきに定期実行するrequestを作成
// 充電中だったら実行
val requestCharging = PeriodicWorkRequestBuilder<ChargingWorker>(15, TimeUnit.MINUTES)
    .setConstraints(constraintsCharging)
    .build()

// 充電中じゃなかったら実行
val requestNoCharging = PeriodicWorkRequestBuilder<NoChargingWorker>(15, TimeUnit.MINUTES)
    .setConstraints(constraintsNoCharging)
    .build()

// 各種リクエストを実行
WorkManager.getInstance(this).enqueue(mutableListOf(requestCharging, requestNoCharging))

ポイントとしては以下の通りです。

  • setRequiresChargingtrueを設定することで、充電中の時だけ実行され、falseを設定することで充電中でない時だけ実行されるのではないか、と仮説を立てた
  • PeriodicWorkRequestを作成し、繰り返し処理として定義した

なお、15 分おきとしているのは以下の制約があるためです。

注: 定義可能な最小繰り返し間隔は 15 分です(JobScheduler API と同じ)。

充電中の時と充電中でない時とでそれぞれ動作させてみたところ、以下のような結果になりました。

充電中の時のログ。

2020-09-17 11:56:41.569 8448-8494/com.example.workertest D/NoChargingWorker: 充電してない
2020-09-17 11:56:41.571 8448-8495/com.example.workertest D/ChargingWorker: 充電中
2020-09-17 12:00:04.810 8448-8526/com.example.workertest D/ChargingWorker: 充電中
2020-09-17 12:10:11.527 8448-8494/com.example.workertest D/NoChargingWorker: 充電してない
2020-09-17 12:11:41.693 8448-8495/com.example.workertest D/NoChargingWorker: 充電してない
2020-09-17 12:11:41.752 8448-8526/com.example.workertest D/ChargingWorker: 充電中

充電中でない時のログ。

2020-09-17 11:25:11.225 7886-7963/com.example.workertest D/NoChargingWorker: 充電してない
2020-09-17 11:40:11.353 7886-8107/com.example.workertest D/NoChargingWorker: 充電してない

充電中の時のログを見ると、ChargingWorkerNoChargingWorkerが両方実行されてしまっていました。「falseを設定することで充電中でない時だけ実行されるのではないか」という仮説は外れてしまいました。
また、充電中でない時のログを見ると、NoChargingWorkerのみが実行されているので、「trueを設定することで、充電中の時だけ実行される」という仮説は合っていました。
とはいえこの方法ではうまくいかなかったので、別の方法を試してみました。

現在の充電状態を特定する方法と WorkManager を組み合わせて検証

BroadcastReceiver を使わなくとも、現在の充電状態を特定する方法があったので、WorkManager と組み合わせて検証しました。
まず、充電状態を確認するPowerConnectionWorkerクラスを作成します。

/**
 * 充電状態を確認するWorker
 */
class PowerConnectionWorker(appContext: Context, workerParams: WorkerParameters) :
    Worker(appContext, workerParams) {
    override fun doWork(): Result {
        val batteryStatus: Intent? = IntentFilter(Intent.ACTION_BATTERY_CHANGED).let { filter ->
            applicationContext.registerReceiver(null, filter)
        }
        val status: Int = batteryStatus?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
        val isCharging: Boolean = status == BatteryManager.BATTERY_STATUS_CHARGING
                || status == BatteryManager.BATTERY_STATUS_FULL
        Log.d(TAG, "isCharging=$isCharging")
        // How are we charging?
        val chargePlug: Int = batteryStatus?.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) ?: -1
        val usbCharge: Boolean = chargePlug == BatteryManager.BATTERY_PLUGGED_USB
        val acCharge: Boolean = chargePlug == BatteryManager.BATTERY_PLUGGED_AC
        Log.d(TAG, "usbCharge=$usbCharge")
        Log.d(TAG, "acCharge=$acCharge")
        return Result.success()
    }
    companion object {
        private const val TAG = "PowerConnectionWorker"
    }
}

次に、以下のように呼び出し処理を作成します。

// 15分おきに定期実行するrequestを作成
val requestPowerConnection =
    PeriodicWorkRequestBuilder<PowerConnectionWorker>(
        15,
        TimeUnit.MINUTES
    ).build()
// リクエストを実行
WorkManager.getInstance(this).enqueue(requestPowerConnection)

充電していない状態で実行してみたところ、以下のような結果になりました。

2020-09-18 14:30:00.364 7679-7765/com.example.workertest D/PowerConnectionWorker: isCharging=false
2020-09-18 14:30:00.364 7679-7765/com.example.workertest D/PowerConnectionWorker: usbCharge=false
2020-09-18 14:30:00.364 7679-7765/com.example.workertest D/PowerConnectionWorker: acCharge=false
2020-09-18 14:30:00.373 7679-7713/com.example.workertest I/WM-WorkerWrapper: Worker result SUCCESS for Work [ id=2136c521-df0c-4ab2-b282-7a9de07a926c, tags={ com.example.workertest.PowerConnectionWorker } ]
2020-09-18 14:30:40.096 7679-7778/com.example.workertest D/PowerConnectionWorker: isCharging=false
2020-09-18 14:30:40.096 7679-7778/com.example.workertest D/PowerConnectionWorker: usbCharge=false
2020-09-18 14:30:40.096 7679-7778/com.example.workertest D/PowerConnectionWorker: acCharge=false
2020-09-18 14:30:40.104 7679-7717/com.example.workertest I/WM-WorkerWrapper: Worker result SUCCESS for Work [ id=fb646f90-ba6d-48c1-8f49-cb791050371a, tags={ com.example.workertest.PowerConnectionWorker } ]
2020-09-18 14:41:33.290 7679-7721/com.example.workertest D/PowerConnectionWorker: isCharging=false
2020-09-18 14:41:33.290 7679-7721/com.example.workertest D/PowerConnectionWorker: usbCharge=false
2020-09-18 14:41:33.291 7679-7721/com.example.workertest D/PowerConnectionWorker: acCharge=false
2020-09-18 14:41:33.295 7679-7716/com.example.workertest I/WM-WorkerWrapper: Worker result SUCCESS for Work [ id=2aa6df1b-2d95-451a-ab9d-8507b20ed128, tags={ com.example.workertest.PowerConnectionWorker } ]
2020-09-18 14:46:39.895 7679-7765/com.example.workertest D/PowerConnectionWorker: isCharging=false
2020-09-18 14:46:39.895 7679-7765/com.example.workertest D/PowerConnectionWorker: usbCharge=false
2020-09-18 14:46:39.895 7679-7765/com.example.workertest D/PowerConnectionWorker: acCharge=false
2020-09-18 14:46:39.909 7679-7778/com.example.workertest D/PowerConnectionWorker: isCharging=false
2020-09-18 14:46:39.910 7679-7778/com.example.workertest D/PowerConnectionWorker: usbCharge=false
2020-09-18 14:46:39.910 7679-7778/com.example.workertest D/PowerConnectionWorker: acCharge=false
2020-09-18 14:46:39.911 7679-7713/com.example.workertest I/WM-WorkerWrapper: Worker result SUCCESS for Work [ id=2136c521-df0c-4ab2-b282-7a9de07a926c, tags={ com.example.workertest.PowerConnectionWorker } ]
2020-09-18 14:46:39.954 7679-7713/com.example.workertest I/WM-WorkerWrapper: Worker result SUCCESS for Work [ id=fb646f90-ba6d-48c1-8f49-cb791050371a, tags={ com.example.workertest.PowerConnectionWorker } ]

以下のように、いずれも false と表示されているので充電状態を検知できているようです。

2020-09-18 14:41:33.290 7679-7721/com.example.workertest D/PowerConnectionWorker: isCharging=false
2020-09-18 14:41:33.290 7679-7721/com.example.workertest D/PowerConnectionWorker: usbCharge=false
2020-09-18 14:41:33.291 7679-7721/com.example.workertest D/PowerConnectionWorker: acCharge=false

ですが、15 分おきに処理されておらず、何回も処理されているのが気になりました。

過去に定期実行したリクエストを全終了させる方法

15 分おきに処理されなかった原因として、過去に実行したリクエストを終了させず、そのままにしていたためでした。WorkManager の概要にも「アプリやデバイスが再起動してもタスクを確実に実行する」とあり、終了させない限りずっと処理され続けると思われます。

過去に実行したリクエストを全て終了させるには、事前に以下の処理を呼び出してください。

// 過去に実行したリクエストを全終了
WorkManager.getInstance(this).cancelAllWork()

同一のリクエストを複数実行させないようにする方法

前述した方法だと、呼び出すタイミングによっては終了させてはいけない
リクエストを終了させてしまう恐れがあります。
同一のリクエストを複数実行させないようにする方法として、ユニークなリクエストとして実行する方法があります。
WorkManager.getInstance(this).enqueue(requestPowerConnection)を以下のように書き換えてください。

// ユニークなリクエストとして実行
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
    "powerConnection", // ワーク名
    ExistingPeriodicWorkPolicy.REPLACE, // 既存のワークがあればキャンセルして置き換える
    requestPowerConnection
)

enqueueUniquePeriodicWorkに変更し、第 2 引数にExistingPeriodicWorkPolicy.REPLACEを指定することで、確実に 15 分おきに処理されます。

まとめ

充電状態の変化を監視する方法について調査した結果をまとめます。

  • Android 8.0(API レベル 26)からブロードキャストの制限事項がさらに強化され、BroadcastReceiver を使ってリアルタイムに充電状態を監視できなくなった
  • 代替手段として、WorkManager を使う方法がある
  • 現在の充電状態を特定する方法と WorkManager と組み合わせることで、15 分おきに充電状態を監視することは可能
  • ただし API の制約上リアルタイムに監視できない
  • Android 7.0 以前では未検証だが、リソース消費の観点からすると BroadcastReceiver を使用した方法は使えたとしても止めたほうが良さそう