KotlinでバックグラウンドでWebAPIを呼び出すアプリの実装方法

に公開

本記事ではKotlinでバックグラウンドでWebAPIを呼び出すために、AndroidでKotlinを用いて下のように「2秒ごとにWebAPIにリクエストを送りレスポンスをログ出力するアプリ」の実装方法を解説します。

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

以下の流れで解説します
0. 今回使用するWebAPIの説明
1. フォアグラウンドでWebAPIを呼び出す
2. バックグラウンドでWebAPIを呼び出す
補足. POSTもしてみる

今回使用するWebAPI「JSONPlaceholder」

JSONPlaceholderは、RESTAPIの動作確認やフロントエンドの開発などで広く利用されている無料のダミーWebAPIサービスです。

このAPIは以下のような特徴があります:

  • ユーザ情報や投稿データなど典型的なJSON形式のデータを返してくれる
  • GET,POST,PUT,DELETEなどのHTTPメソッドに対応しておりAPIの使い方の学習やテストに最適
  • 登録不要で誰でもすぐに利用可能

実際に以下のURLをブラウザで開いてみるとイメージつきやすいかなと思います。
https://jsonplaceholder.typicode.com/todos/1
https://jsonplaceholder.typicode.com/todos/5

1.フォアグラウンドでWebAPIを呼び出す

まずはアプリ画面が表示されている(フォアグラウンド)状態でWebAPIを呼び出しレスポンスをログ出力してみます。

依存関係の追加

KotlinでWebAPIを呼び出すライブラリはさまざまありますが今回はRetrofitを使用します。
Retrofit関連の依存関係をbuild.gradle(app)に追記します。

build.gradle(app)
 dependencies {
+   // Retrofit 
+   implementation 'com.squareup.retrofit2:retrofit:2.9.0'
+   implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

権限の追加

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

  • INTERNET
    • アプリケーションがネットワークに接続するために必要。
  • ACCESS_NETWORK_STATE
    • アプリケーションがネットワークの状態を取得するために必要。
AndroidManifest.xml
+   <uses-permission android:name="android.permission.INTERNET" />
+   <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

Retrofitインターフェースとデータクラスの実装

networkフォルダを作成しTodoService.ktを作成します。Retrofitを使ってWebAPIを呼び出すためのインターフェースと、受信するJSONの型を定義するためのデータクラスを追加します。

 .
+├── network
+│    └── TodoService.kt
 └── MainActivity.kt
TodoService.kt
 interface TodoService{
    @GET("todos/{id}")
    fun getRawResponseForPosts(@Path("id") id: Int): Call<TodoResponse>
 }

 data class TodoResponse(
    val userId: Int,
    val id: Int,
    val title: String,
    val completed: Boolean
 )

ここらの詳しい解説はこちらの記事がわかりやすくしてくれていますのでぜひご覧ください。
https://qiita.com/naoi/items/5036adc8d33638911deb

WebAPIを定期的に呼び出す処理の実装

MainActivity.ktを以下のようにし色々importします。

MainActivity.kt
 class MainActivity : ComponentActivity() {
    // Retrofit本体
    private val retrofit = Retrofit.Builder()
        .baseUrl("https://jsonplaceholder.typicode.com/")
        .addConverterFactory(GsonConverterFactory.create())
        .build()
    // サービスクラスの実装オブジェクト取得
    private val service = retrofit.create(TodoService::class.java)

    override fun onCreate(savedInstanceState: Bundle?) {
        // 非同期処理
        CoroutineScope(Dispatchers.IO).launch {
            while (true) {
                try{
                    val get = service.getRawResponseForPosts(1)
                    val responseBody = get.execute().body()
                    if (responseBody != null) {
                        Log.d("API", "ユーザID:${responseBody.userId}, TodoID:${responseBody.id}, タイトル:${responseBody.title}")
                    }
                } catch (e: Exception) {
                    Log.e("API", "通信失敗")
                }
                delay(2000)
            }
        }

        super.onCreate(savedInstanceState)
        setContent {
            Text(
                text="Hello API!"
            )
        }
    }
}

ここまでのアプリの動作

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

2.バックグラウンドでWebAPIを呼び出す

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

WebAPIを定期的に呼び出す処理をフォアグラウンドサービスに移行

MainActivity.ktに書いていたWebAPIを定期的に呼び出す処理をサービスクラスに移すためserviceフォルダを作成しAPIService.ktを作成します。

 .
 ├── network
 │    └── TodoService.kt
+├── service
+│    └── APIService.kt
 └── MainActivity.kt

フォアグラウンドサービスを利用するにはユーザに知られずに動くゴーストアプリを防ぐために、アプリが動いているのを知らせるための通知が義務付けられています。そのため、APISerivice.ktにはWebAPIを定期的に呼び出す処理に加えて通知を出す処理も書きます。

service/APIService.kt
class APIService: Service() {
    // Retrofit本体
    private val retrofit = Retrofit.Builder()
        .baseUrl("https://jsonplaceholder.typicode.com/")
        .addConverterFactory(GsonConverterFactory.create())
        .build()
    // サービスクラスの実装オブジェクト取得
    private val service = retrofit.create(TodoService::class.java)

    private lateinit var notificationManager: NotificationManagerCompat

    companion object {
        const val CHANNEL_ID = "56789"
    }

    override fun onCreate() {
        super.onCreate()
    }

    @RequiresApi(Build.VERSION_CODES.O)
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        Log.d("Service", "サービスが開始")
        // 通知マネージャーを取得(通知チャンネル作成などに使用)
        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("API呼び出し")
            .setContentText("フォアグラウンドサービスでAPI呼び出し結果を出力し続けています")
            .setContentIntent(openIntent)
            .setOngoing(true)
            .build()
        // 通知の表示
        startForeground(5, notification)

        // 非同期処理
        CoroutineScope(Dispatchers.IO).launch {
            while (true) {
                try{
                    val get = service.getRawResponseForPosts(1)
                    val responseBody = get.execute().body()
                    if (responseBody != null) {
                        Log.d("API", "ユーザID:${responseBody.userId}, TodoID:${responseBody.id}, タイトル:${responseBody.title}")
                    }
                } catch (e: Exception) {
                    Log.e("API", "通信失敗")
                }
                delay(2000)
            }
        }

        // サービスが強制終了されたときにも自動的に再起動されるようにする
        return START_STICKY
    }

    override fun onDestroy() {
        // サービス終了時の処理
        Log.d("Service", "サービスが終了")
        super.onDestroy()
    }

    override fun onBind(intent: Intent?): IBinder? {
        return null
    }
}

必要な権限の追加とサービスの宣言

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

  • FOREGROUND_SERVICE
    • フォアグラウンドサービスに必要
  • FOREGROUND_SERVICE_DATA_SYNC
    • foregroundServiceType="dataSync"のサービスに必要
    • foregroundServiceTypeはフォアグラウンドサービスでどんな処理をするかに合わせる(一覧
  • POST_NOTIFICATIONS
    • フォアグラウンドサービス中は通知を出しておく必要がある。その通知を出すのに必要。
  • REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
    • フォアグラウンドサービス中はバッテリーの最適化をオフにしないとそのうち動作が止まってしまう。バッテリーの最適化をオフにさせるのに必要。

また先ほど作成したAPIServiceクラスを登録します。

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

    <application...>
        <activity...>
            ...
        </activity>
+       <service
+           android:name=".service.APIService" />
    </application>

権限のリクエスト画面とサービスの起動/終了ボタンを追加

MainActivity.ktに以下のようにWebAPIを呼び出す処理を消して、権限リクエストとサービス起動/終了ボタンを追加します。

MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {

        // 通知の権限のリクエスト
        val permissions = arrayOf(Manifest.permission.POST_NOTIFICATIONS)
        val requestCode = 100
        if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
            Toast.makeText(this, "通知が許可されていません", Toast.LENGTH_SHORT).show()
            // リクエストを送信する処理
            requestPermissions(permissions,requestCode)
        } else {
            Toast.makeText(this, "通知が許可されています", Toast.LENGTH_SHORT).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)
        }

        super.onCreate(savedInstanceState)
        setContent {
            Column {
                Button(
                    onClick = {
                        // Intentオブジェクト
                        val intent = Intent(application, APIService::class.java)
                        // サービスの起動
                        startService(intent)
                    }
                ) {
                    Text(
                        text="サービス起動"
                    )
                }
                Button(
                    onClick = {
                        // Intentオブジェクト
                        val intent = Intent(application, APIService::class.java)
                        // サービスの終了
                        stopService(intent)
                    }
                ) {
                    Text(
                        text="サービス終了"
                    )
                }
            }
        }
    }
}

完成したアプリの動作

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

補足:POSTもしてみる

POSTもしてみましょう。

WebAPIを呼び出すためのインターフェースにPOSTメソッドを追加し、送信するJSONの型を定義するためのデータクラスを追加します。

TodoService.kt
 interface TodoService{
    ...
+   @POST("posts")
+   fun postRawRequestForPosts(@Body body: TodoRequest):Call<TodoResponse>
 }

+data class TodoRequest(
+   val userId: Int,
+   val title: String,
+   val completed: Boolean
+)

APISerice.ktのGETしていた部分をPOSTに書き換えます。

APISerice.kt
 // 非同期処理
 CoroutineScope(Dispatchers.IO).launch {
    while (true) {
        try{
-          val get = service.getRawResponseForPosts()
-          val responseBody = get.execute().body()
-          if (responseBody != null) {
-             Log.d("API", "ユーザID:${responseBody.userId}, TodoID:${responseBody.id}, タイトル:${responseBody.title}")
-          }
+          // POST送信するDummyデータを作成
+          val postBody = TodoRequest(
+             userId = 15,
+             title = "ZennArticle",
+             completed = false
+          )
+          // POSTリクエスト送信
+          val postCall = service.postRawRequestForPosts(postBody)
+          val postResponse = postCall.execute().body()
+          Log.d("API", "$postResponse")
        } catch (e: Exception) {
            Log.e("API", "通信失敗")
        }
        delay(2000)
    }
 }

まとめ

今回はAndroidにおいてフォアグラウンドサービスを用い、バックグラウンドでWebAPIを呼び出すアプリを実装しました。フォアグラウンドサービスを使ったアプリの記事は今までにも色々書いていますので興味あればぜひご覧ください。
https://zenn.dev/togetine/articles/ec7ec86e0bbb92
https://zenn.dev/togetine/articles/602a9351f8e621
https://zenn.dev/togetine/articles/1399fce2cae85b

Discussion