🗾

Kotlinで位置情報をバックグラウンドで取得し続けるアプリの実装方法

に公開

本記事ではKotlinでバックグラウンドで位置情報を扱うために、AndroidでKotlinを用いて下のように「数秒間隔で現在位置の緯度経度を出力するアプリ」の実装方法を解説します。

https://www.youtube.com/watch?v=ZcDxmqkfd_w

以下の流れで解説します。
1. フォアグラウンドで位置情報を取得して緯度経度を出力する
2. バックグラウンドで位置情報を取得して緯度経度を出力する
補足. バッテリー消費について

1.フォアグラウンドで位置情報を取得して緯度経度を出力する

まずはアプリ画面が表示されている(ファオグラウンド)状態で位置情報を取得し緯度を経度を出力してみます。

位置情報にアクセスする権限の追加

AndroidManifest.xmlにはアプリが位置情報にアクセスするために必要な2つの権限「ACCESS_FINE_LOCATION」と「ACCESS_COARSE_LOCATION」を追加します。

AndroidManifest.xml
 <manifest...>
+   <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
+   <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>

MainActivity.ktに権限の許可を促す処理を追加します。これにはrequestPermissions()を使用してアプリ内で権限をリクエストする方法が多く用いられますが、今後扱う予定の権限の関係上この方法ではコードが複雑になってしまいます。今回の記事では権限リクエスト自体は主要なポイントではないため、アプリの権限設定画面に遷移させる方法を採用します。

MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
+       // 権限の許可をリクエスト
+       if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
+           // リクエストを送信する処理
+           AlertDialog.Builder(this)
+               .setTitle("位置情報と通知の権限が必要です")
+               .setMessage("設定から位置情報の権限を許可してください。")
+               .setPositiveButton("設定へ") { _, _ ->
+                   val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+                       data = Uri.fromParts("package", packageName, null)
+                   }
+                   startActivity(intent)
+               }
+               .setNegativeButton("キャンセル", null)
+               .show()
+       }

依存関係を追加

FusedLocationを使うためにplay-services-locationを追加し「Sync Now」します。

build.gradle(app)
dependencies {
    implementation 'androidx.core:core-ktx:1.8.0'
    ...
+   implementation 'com.google.android.gms:play-services-location:21.3.0'

FusedLocationProviderClientで位置情報を取得

MainActivity.ktに位置情報を取得し緯度経度を出力する処理を以下のように書きます。

MainActivity.kt
class MainActivity : ComponentActivity() {
+  // 位置情報を取得するためのクラス
+  private lateinit var fusedLocationClient: FusedLocationProviderClient
+  private lateinit var locationCallback: LocationCallback

   override fun onCreate(savedInstanceState: Bundle?) {
        // 権限の許可をリクエスト
       if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
           // リクエストを送信する処理
           AlertDialog.Builder(this)
               .setTitle("位置情報と通知の権限が必要です")
               .setMessage("設定から位置情報の権限を許可してください。")
               .setPositiveButton("設定へ") { _, _ ->
                   val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
                       data = Uri.fromParts("package", packageName, null)
                   }
                   startActivity(intent)
               }
               .setNegativeButton("キャンセル", null)
               .show()
       }

+       fusedLocationClient = LocationServices.getFusedLocationProviderClient(application)
+       val locationRequest: LocationRequest.Builder = LocationRequest.Builder(2000)  // 更新間隔
+               .setPriority(Priority.PRIORITY_HIGH_ACCURACY)  // PRIORITY_HIGH_ACCURACY:最も高精度
+       locationCallback = object : LocationCallback() {
+           override fun onLocationResult(locationResult: LocationResult) {
+               val location = locationResult.lastLocation
+               if (location != null) {
+                   Log.d("LocationCallback", "緯度: ${location.latitude}, 経度: ${location.longitude}")
+               }
+           }
+       }
+       // 位置情報を更新
+       fusedLocationClient.requestLocationUpdates(locationRequest.build(), locationCallback, Looper.getMainLooper())

ここまでのアプリの動作

ここまで実装ができたら以下のようにアプリがフォアグラウンド状態のときに位置情報を取得し緯度経度を出力できているはずです。ただ今回のコードでは、権限を許可する前に位置情報を取得する処理が走るため、権限を許可した後にもう一度アプリを開く必要があります。
https://www.youtube.com/watch?v=dtB1xoEHm2Y

2.バックグラウンドで位置情報を取得して緯度経度を出力する

バックグラウンドで位置情報を取得するためにフォアグラウンドサービスを使います。
https://developer.android.com/develop/background-work/services?hl=ja

必要な権限の追加

AndroidManifest.xmlに以下の権限を追加します

  • ACCESS_BACKGROUND_LOCATION
    • アプリがバックグラウンドで位置情報にアクセスするのに必要
  • FOREGROUND_SERVICE
    • フォアグラウンドサービスに必要
  • POST_NOTIFICATIONS
    • フォアグラウンドサービス中は通知を出しておく必要がある。その通知を出すのに必要。
  • REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
    • フォアグラウンドサービス中はバッテリーの最適化をオフにしないとそのうち動作が止まってしまう。バッテリーの最適化をオフにさせるのに必要。
  • FOREGROUND_SERVICE_LOCATION
    • foregroundServiceType="location"のサービスに必要
    • foregroundServiceTypeはフォアグラウンドサービスでどんな処理をするかに合わせる(一覧
AndroidManifest.xml
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
+   <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
+   <!-- フォアグラウンドサービス -->
+   <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+   <!-- 通知 -->
+   <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+   <!-- バッテリーの最適化のオフ -->
+   <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
+   <!-- foregroundServiceType="location"のサービスに必要 -->
+   <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

MainActivity.ktには権限の許可をリクエストする内容の修正と、バッテリーの最適化を外させる処理を追加します。

MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        Log.d("MainActivity", "スタート")

        // 権限の許可をリクエスト
+       if (checkSelfPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION) != PackageManager.PERMISSION_GRANTED && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
            // リクエストを送信する処理
            AlertDialog.Builder(this)
+               .setTitle("位置情報と通知の権限が必要です")
+               .setMessage("設定から位置情報の権限を「常に許可」し、通知の権限を許可してください。")
                .setPositiveButton("設定へ") { _, _ ->
                    val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
                        data = Uri.fromParts("package", packageName, null)
                    }
                    startActivity(intent)
                }
                .setNegativeButton("キャンセル", null)
                .show()
        }

+       // バッテリーの最適化を外させる
+       val tmpIntent = Intent()
+       val packageName = packageName
+       val pm = getSystemService(POWER_SERVICE) as PowerManager
+       if (!pm.isIgnoringBatteryOptimizations(packageName)) {
+           tmpIntent.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
+           tmpIntent.data = Uri.parse("package:$packageName")
+           startActivity(tmpIntent)
+       }

フォアグラウンドサービスで位置情報を取得

MainActivity.ktに書いていた位置情報を取得しログを出力する処理をフォアグラウンドサービスに移すためserviceフォルダを作成しLocationService.ktを作成します。

.
├── service
│    └── LocationService.kt
└── MainActivity.kt
LocationService.kt
+class LocationService: Service() {
+    companion object {
+        const val CHANNEL_ID = "23456"
+    }
+    private lateinit var notificationManager: NotificationManagerCompat
+    private lateinit var fusedLocationClient: FusedLocationProviderClient
+    private lateinit var locationCallback: LocationCallback
+
+    override fun onCreate() {
+        super.onCreate()
+    }
+
+    @RequiresApi(Build.VERSION_CODES.O)
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        // バックグラウンドでの処理
+        Log.d("LocationService", "サービスが開始")
+
+        // 通知マネージャーを取得(通知チャンネル作成などに使用)
+        notificationManager = NotificationManagerCompat.from(this)
+
+        // 通知チャンネルの作成
+        val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
+            .setName("位置情報取得")
+            .build()
+        // チャンネルを通知マネージャーに登録
+        notificationManager.createNotificationChannel(channel)
+
+        // 通知をタップしたときにMainActivityを開くIntentを作成
+        val openIntent = Intent(this, MainActivity::class.java).let {
+            PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_IMMUTABLE)
+        }
+
+        // 通知を作成
+        val notification = NotificationCompat.Builder(this, channel.id)
+            .setSmallIcon(R.drawable.ic_launcher_foreground)
+            .setContentTitle("位置情報取得中")
+            .setContentText("フォアグラウンドサービスで位置情報を取得し続けています")
+            .setContentIntent(openIntent)
+            .setOngoing(true)
+            .build()
+        // 通知の表示
+        startForeground(1313, notification)
+
+        // 位置情報関連
+        fusedLocationClient =  LocationServices.getFusedLocationProviderClient(application)
+        val locationRequest: LocationRequest.Builder = LocationRequest.Builder(2000)
+                .setPriority(Priority.PRIORITY_HIGH_ACCURACY)
+        locationCallback = object : LocationCallback() {
+            override fun onLocationResult(locationResult: LocationResult) {
+                Log.d("LocationService", "LocationCallbackです")
+                val location = locationResult.lastLocation
+                if (location != null) {
+                    Log.d("LocationService", "緯度: ${location.latitude}, 経度: ${location.longitude}")
+                }
+            }
+        }
+
+        // 権限が足りているかをチェック
+        if (ActivityCompat.checkSelfPermission(
+                this,
+                Manifest.permission.ACCESS_FINE_LOCATION
+            ) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(
+                this,
+                Manifest.permission.ACCESS_COARSE_LOCATION
+            ) != PackageManager.PERMISSION_GRANTED
+        ) {
+           // 権限がない場合
+           return START_NOT_STICKY // START_NOT_STICKY: サービスを勝手に再起動しない
+       }
+        // 位置情報を更新
+        fusedLocationClient.requestLocationUpdates(locationRequest.build(), locationCallback, Looper.getMainLooper())
+        
+        return START_STICKY // START_STICKY: サービスが停止した際に自動で再起動する
+    }
+
+    override fun onDestroy() {
+        // サービス終了時の処理
+        Log.d("Service", "サービスが終了")
+        super.onDestroy()
+    }
+
+    override fun onBind(intent: Intent?): IBinder? {
+        return null
+    }
+}

サービスをAndroidManifest.xmlで定義します。

AndroidManifest.xml
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
    <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
    <!-- フォアグラウンドサービス -->
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <!-- 通知 -->
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
    <!-- バッテリーの最適化のオフ -->
    <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
    <!-- Broadcast -->
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
    <!-- foregroundServiceType="location"のサービスに必要 -->
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

    <application...>
        <activity...>
            ...
        </activity>
+       <service
+           android:name=".service.LocationService"
+           android:foregroundServiceType="location"
+           android:enabled="true"
+           android:exported="true"
+           android:directBootAware="true"/>
    </application>

以下のようにMainActivity.ktから位置情報を取得しログを出力する処理を削除し(赤い部分)、サービスを起動するボタンと終了するボタンを追加します。

MainActivity.kt

-       fusedLocationClient = LocationServices.getFusedLocationProviderClient(application)
-       val locationRequest: LocationRequest.Builder = LocationRequest.Builder(2000)  // 更新間隔
-           .setPriority(Priority.PRIORITY_HIGH_ACCURACY)  // PRIORITY_HIGH_ACCURACY:最も高精度
-       locationCallback = object : LocationCallback() {
-           override fun onLocationResult(locationResult: LocationResult) {
-               val location = locationResult.lastLocation
-               if (location != null) {
-                   Log.d("LocationCallback", "緯度: ${location.latitude}, 経度: ${location.longitude}")
-               }
-           }
-       }
-       // 位置情報を更新
-       fusedLocationClient.requestLocationUpdates(locationRequest.build(), locationCallback, Looper.getMainLooper())

        super.onCreate(savedInstanceState)
        setContent {
            ForegroundServiceLocationTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
+                   Column() {
+                       Button(
+                           onClick = {
+                               // Intentオブジェクト
+                               val intent = Intent(application, LocationService::class.java)
+                               Log.d("Button", "ボタン押されたよ")
+                               // サービスの起動
+                               startService(intent)
+                           }
+                       ) {
+                           Text(
+                               text="サービス起動"
+                           )
+                       }
+                       Button(
+                           onClick = {
+                               // Intentオブジェクト
+                               val intent = Intent(application, LocationService::class.java)
+                               Log.d("Button", "ボタン押されたよ")
+                               // サービスの停止
+                               stopService(intent)
+                           }
+                       ) {
+                           Text(
+                               text = "サービス終了"
+                           )
+                       }
+                   }
                }
            }
        }

完成したアプリの動作

https://www.youtube.com/watch?v=ZcDxmqkfd_w

補足.バッテリー消費について

今回のアプリを私の端末で20時間程度動かしたところ下の画像のようにバッテリー残量を50%近く消費してしまいました。このように位置情報取得は消費電力がまあまあ大きいためバッテリー消費を考慮しないといけません。

これには精度と頻度を調整する必要があります。

精度について今回のアプリではsetPriority(Priority.PRIORITY_HIGH_ACCURACY)のように最も高精度「PRIORITY_HIGH_ACCURACY」でやりました。ドキュメントにもあるように他にもいろいろあるので用途にあった精度を選択する必要があります。実際にいろいろ試してみてどの程度の精度でとれるか確かめてみるといいと思います。

PRIORITY_HIGH_ACCURACY では、可能な限り最も高い精度の位置情報が提供されます。必要な数の入力を使用して位置情報が計算され(GPS、Wi-Fi、セルラーが有効化され、さまざまなセンサーが使用されます)、電池が著しく消耗する可能性があります。
PRIORITY_BALANCED_POWER_ACCURACY では、正確な位置情報が提供される一方で、電力消費が最適化されます。GPS はほとんど使用されません。通常は、Wi-Fi 情報とセルラー情報の組み合わせがデバイスの位置情報の計算に使用されます。
PRIORITY_LOW_POWER では、主として基地局の情報に依存し、GPS と Wi-Fi からの入力の使用を避けて、電池の消耗を最小限に抑えつつ、低い精度(都市レベル)の位置情報を提供します。
PRIORITY_NO_POWER では、位置情報がすでに計算されている他のアプリからパッシブに位置情報を受け取ります。

https://developer.android.com/develop/sensors-and-location/location/battery?hl=ja

頻度について今回はLocationRequest.Builder(2000)のように2秒ごとに取得するようにしました。ここはできるだけ大きい値にするべきですね。

まとめ

今回は、Androidでフォアグラウンドサービスを使いバックグラウンドで位置情報を取得し続けるアプリを実装しました。
次回以降はフォアグラウンドサービスを使って「バックグラウンドでAPIを定期的に叩くアプリ」や「バックグラウンドで加速度センサの値を取得し続けるアプリ」などの実装方法を解説しようと思っています。またどこかで見かけましたらよろしくお願いします。

参考記事

https://akira-watson.com/android/kotlin/fusedlocationproviderclient.html
https://zenn.dev/log_suzaki/articles/e63c1446c61464

Discussion