Android Service 教學

Photo by Amal Abas on Unsplash
Photo by Amal Abas on Unsplash
Android Service 是 Android 的四個 application components 中的其中一個。它可以在背景處理 longer-running 工作,如播放音樂、下載檔案等。所以 Service 不提供使用者介面。本文章將介紹 Service 基本概念。

Android Service 是 Android 的四個 application components 中的其中一個。它可以在背景處理 longer-running 工作,如播放音樂、下載檔案等。所以 Service 不提供使用者介面。本文章將介紹 Service 基本概念。

完整程式碼可以在 下載。

概覽

Service 可以在背景處理 longer-running 工作,如播放音樂、下載檔案等。所以 Service 不提供使用者介面。它也可以提供一些功能給其他的 app 使用。Service 一旦啟動後,即使使用者切換到其他的 app,它還是會繼續執行。

如同 Activity,Service 也是在 main thread 中執行。也就是說,如果它要處理 CPU intensive 工作(如播放音樂)或 blocking 工作(如網路、下載檔案),那它就要建立一個 thread 來處理這些工作。

官網所述,Service 不是一個單獨的 process。Service 物件不是在它自己的 process 中執行(除非特別指定),而是在它所屬的 app 的 process 中執行。Service 也不是一個 thread。Service 物件不是在它自己的 thread 中執行,而是在它所屬的 app 的 main thread 中執行。

當 service 被啟動時,Android 系統實際上只是 instantiate 它,並在 main thread 中呼叫它的 onCreate() 和其他 callbacks(如 onStartCommand()、onBind())。接下來,就是由 service 去實作合適的行為,如建立一個 thread 來處理工作。

總而言之,Service 提供了兩個主要的功能:

  • 讓 app 告訴系統,它想要在背景做一些工作,即使使用者沒有直接地使用 app(也就是說,app 不在前景)。在這種情況,app 會呼叫 Context.startService() 去告訴系統去排程工作來執行 service,直到 service 停止自己,或是其他元件停止它。
  • App 可以公開一些功能給其他 app 使用。在這種情況,其他 app 會呼叫 Context.bindService() 來和 service 建立一個 long-standing connection,以便和 service 互動。

Service 有三種 types 以對應這兩個主要功能,詳情可參照官網

  • Background Service:經由 Context.startService() 啟動。
  • Foreground Service:經由 Context.startService() 啟動。
  • Bound Service:經由 Context.bindService() 啟動。
    • Local Bound Service:由同一個 app 的其他元件建立的 long-standing connection。
    • Remote Bound Service:由其他 app 建立的 long-standing connection。

Lifecycle

如同 Activity,Service 也有 lifecycle。其他的元件透過呼叫 Context.startService()Context.bindService() 來啟動 service。如同 startActivity(),呼叫 startService() 時也要傳入 intent。官網有詳細地解說各個 callbacks。

  • Context.startService():Android 系統會啟動 service,並且呼叫 Service.onCreate()。然後再呼叫 Service.onStartCommand(),並且傳入 intent。如果已經有一個 service 被啟動了,並且正在運行中,則 Android 系統就不會再另外啟動 service,也不會再次呼叫 Service.onCreate()。而是直接呼叫 Service.onStartCommand()。
  • Context.bindService():Android 系統會啟動 service,並且呼叫 Service.onCreate()。然後再呼叫 Service.onBind(),並且傳入 intent。如果有一個 service 已經被啟動了,並且正在運行中,則 Android 系統就不會再另外啟動 service,也不會再次呼叫 Service.onCreate() 和 Service.onBind()。

當 service 處理完工作後,呼叫 Service.stopSelf() 來停止自己,或者是其他的元件呼叫 Context.stopService() 來停止 service。所以,如果 service 處裡多個 requests(Service.onStartCommand() 被呼叫多次),那麼 service 應該要呼叫 Service.stopSelf(started)

如果是透過 Context.bindService() 啟動的話,當其他全部的元件都呼叫 Context.unbindService() 後,Android 系統會呼叫 Service.onUnbind(),並且停止 service。

Service Lifecycle
Service Lifecycle

Low Memory

Android 系統會嘗試地讓那些有在執行 service 的 process 持續地運作。而且,當系統遭遇到 low memory 時,而必須要終止一些 processes 時來釋放 memory。它會根據以下的情況來決定要終止哪些 processes,詳情請參照官網

  • 假如 service 正在執行 onCreate()、onStartCommand()、或 onDestroy(),則 hosting process 不會被終止,以保證這些 methods 可以被完整地執行。
  • 假如 service 已經啟動了,它的 hosting process 的優先順序低於前景的 processes。系統會根據這些背景的 hosting processes 的優先順序來一一終止。
  • 假如有其他 app bind service,而 service 的 hosting process 的優先順序低於這個 app 的優先順序。而且這個 app 是前景 app,則這個 service 的 hosting process 也會被考慮為前景 app,則 hosting process 不會被終止。
  • 假如 service 有呼叫 startForeground(),那它就是一個 foreground service。則 hosting process 不會被終止。

所以在大部分的時候,service 都可以正常地執行。但當系統遭遇到 low memory 時,就會終止一些 services。之後系統會在適當的時機,嘗試重啟那些被終止的 services。

所以當我們在實作 service 時,就必須要考慮到 service 可能會被系統終止,而之後又會被重啟的情況。例如,你實作 onStartCommand() 時,你可能會想要使用 START_FLAG_REDELIVERY 來要求系統在重啟 service 的時候,重新傳入上一個 intent。

在 Service 和 Thread 間的選擇

Service 的主要功能之一是在背景處理 longer-running 工作。當我們實作一個 service 來處理 longer-running 工作時,我們會建立一個背景 thread 來處理工作,因為 service 是在 main thread 中執行的。那為何我們不直接建立一個背景 thread 來處理工作,而是要透過 Service 來建立一個背景 thread 呢?例如,在 Activity 中直接建立一個 thread 來處理工作。

官網有詳細提到這個問題。簡單地來說,這要依據這個背景 thread 的存活時間來做決定。

假如,你只想要當 activity 顯示在螢幕上時,才播放音樂。當切換到其他 activities 或是其他 app 時(也就是 activity 沒有顯示在螢幕上),就停止播放音樂。那麼你應該在 activity 裡,建立一個 thread 或用 Kotlin coroutine 來播放音樂。然後,當 activity 被停止時,你要停止 thread 或 Kotlin coroutine 來停止播放音樂。也就是說,這個背景 thread 的存活時間只有當 activity 存活時(也就是,activity 顯示在螢幕上)。

假如,你想要當 app 執行時,在任何的 activities 顯示時,都播放音樂。甚至是 app 在背景(也就是,沒有顯示任何 activity)時,都要播放音樂。那麼你就應該使用 Service 來建立一個 thread 來播放音樂。因為這個背景 thread 的存活時間和任何 activity 無關,而是和 app 的存活時間有關。所以,我們才要透過 Service 建立背景 thread。這樣的話,這個背景 thread 的存活時間就會和 service 的存活時間相關。

如果,你想要當 app 執行時,都播放音樂,但是卻在某個 activity 中建立一個全域的背景 thread 來播放音樂。而且,這個 activity 在結束時,也不停止這個 thread。這樣的作法會讓這個 thread 不受到管控。因為當 Android 系統要停止 app 時,它會呼叫目前有在執行的 activities 和 services 的 lifecycle callbacks,而它們可以在這些 callbacks 裡面安全地停止這個背景 thread。如果,當這個 thread 不受管控時,那當 Android 系統要終止 app 時,這個背景 thread 就沒有辦法安全地結束,如將資料寫入檔案等。因為這個 thread 根本不知道系統要終止 app。此外,如果 app 是因為 low memory 而被終止的話,Android 系統可能之後會嘗試重啟 app 的 services。但如果是不受管控的背景 thread 的話,就不會被重啟了。

Background Service

在以下範例中,我們建立一個 DownloadService 來下載檔案,並且更新下載進度給 activity。

當 activity 呼叫 Context.startService() 時,如果 service 沒有啟動,service 就會被建立。然後,onCreate() 就會被呼叫。我們在 onCreate() 裡,建立一個 Handler 當作背景 thread 來下載檔案。

接下來,系統會呼叫 onStartCommand(),並且傳入 intent。我們在 intent 中取得 activity 傳過來的 ResultReceiver。Service 將透過 ResultReceiver 將下載進度更新給 activity。我們傳送一個 message 給 Handler,這樣 Handler 就會開始運作。最後,onStartCommand() 要回傳一個常數。

這個常數告訴系統,當 service 被終止後,要如何重啟 service。Android 提供一些常數

  • START_NOT_STICKY:在 Android 終止 service 後,不要重啟 service,除非有 pending intents 還沒有處理。
  • START_STICKY:在 Android 終止 service 後,重啟 service。但不要重新傳入上一個 intent,而是傳入 null,除非有 pending intents 還沒處理。
  • START_REDELIVER_INTENT:在 Android 終止 service 後,重啟 service,並傳入上一個 intent。如果有 pending intents 還沒處理,依序地再傳入 pending intents。

在 ServiceHandler.handleMessage() 裡,在下載結束後,我們呼叫 Service.stopSelf() 來停止 service。

我們不需要實作 onBind(),因為 DownloadService 不是 Bound Service。

最後,如同 Activity,記得在 AndroidManifest.xml 中宣告 service。

package com.waynestalk.serviceexample

import android.app.Service
import android.content.Intent
import android.os.*
import android.util.Log

class DownloadService : Service() {
    companion object {
        const val ARGUMENT_RECEIVER = "receiver"
        const val RESULT_CODE_UPDATE_PROGRESS = 6
        const val RESULT_PROGRESS = "progress"

        private const val TAG = "DownloadService"
    }

    inner class ServiceHandler(looper: Looper) : Handler(looper) {
        override fun handleMessage(msg: Message) {
            Log.d(TAG, "handleMessage: msg.arg1=${msg.arg1} => ${Thread.currentThread()}")

            for (i in 1..10) {
                try {
                    Thread.sleep(1000)

                    val resultData = Bundle().apply {
                        putInt(RESULT_PROGRESS, i * 10)
                    }
                    receiver?.send(RESULT_CODE_UPDATE_PROGRESS, resultData)
                } catch (e: InterruptedException) {
                    e.printStackTrace()
                }
            }

            stopSelf(msg.arg1)
        }
    }

    private var looper: Looper? = null
    private var serviceHandler: ServiceHandler? = null

    private var receiver: ResultReceiver? = null

    override fun onCreate() {
        Log.d(TAG, "onCreate => ${Thread.currentThread()}")

        val handlerThread = HandlerThread("DownloadService", Process.THREAD_PRIORITY_BACKGROUND)
        handlerThread.start()

        looper = handlerThread.looper
        serviceHandler = ServiceHandler(handlerThread.looper)
    }

    override fun onDestroy() {
        super.onDestroy()
        Log.d(TAG, "onDestroy => ${Thread.currentThread()}")
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        Log.d(TAG, "onStartCommand=$startId => ${Thread.currentThread()}")

        receiver = intent?.getParcelableExtra(ARGUMENT_RECEIVER)

        serviceHandler?.obtainMessage()?.let { message ->
            message.arg1 = startId
            serviceHandler?.sendMessage(message)
        }

        return START_NOT_STICKY
    }

    override fun onBind(p0: Intent?): IBinder? {
        TODO("Not yet implemented")
    }
}
package com.waynestalk.serviceexample

import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.ResultReceiver
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import com.waynestalk.serviceexample.databinding.DownloadActivityBinding

class DownloadActivity : AppCompatActivity() {
    companion object {
        private const val TAG = "DownloadActivity"
    }

    private var _binding: DownloadActivityBinding? = null
    private val binding: DownloadActivityBinding
        get() = _binding!!

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        _binding = DownloadActivityBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.downloadWithStartCommand.setOnClickListener {
            val intent = Intent(this, DownloadService::class.java)
            intent.putExtra(DownloadService.ARGUMENT_RECEIVER, UpdateReceiver(Handler(mainLooper)))
            startService(intent)
        }
    }

    inner class UpdateReceiver constructor(handler: Handler) : ResultReceiver(handler) {
        override fun onReceiveResult(resultCode: Int, resultData: Bundle) {
            super.onReceiveResult(resultCode, resultData)
            if (resultCode == DownloadService.RESULT_CODE_UPDATE_PROGRESS) {
                val progress = resultData.getInt(DownloadService.RESULT_PROGRESS)
                Log.d(TAG, "progress=" + progress + " => " + Thread.currentThread())
                binding.progress.text = "$progress %"
            }
        }
    }
}
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.ServiceExample"
        tools:targetApi="31">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

            <meta-data
                android:name="android.app.lib_name"
                android:value="" />
        </activity>

        <activity android:name=".DownloadActivity" />

        <service android:name=".DownloadService" />
    </application>

</manifest>

Foreground Service

Foreground Service 和 Background Service 的實作方式幾乎一樣。Foreground Service 必須顯示 Notification,即使 app 在背景,然後呼叫 Service.startForeground()。最後,還要在 AndroidManifest.xml 裡,宣告 android.permission.FOREGROUND_SERVICE。

Notification 會顯示在 status bar 和 notification drawer,所以使用者會注意到這個 service 正在執行。也因此,它才叫 Foreground Service。使用者不能 dismiss 這個 notification。但從 Android 13(API level 33) 開始,使用者可以 dismiss 這個 notification。如果希望使用者不能 dismiss notification 的話,可以在建立 Notification 時,呼叫 setOngoing(true)。

Notification icons appear on the status bar
Notification icons appear on the status bar
Notifications on the notification drawer
Notifications on the notification drawer
package com.waynestalk.serviceexample

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.*
import android.util.Log
import androidx.core.app.NotificationCompat

class DownloadForegroundService : Service() {
    companion object {
        const val ARGUMENT_RECEIVER = "receiver"
        const val RESULT_CODE_UPDATE_PROGRESS = 8
        const val RESULT_PROGRESS = "progress"

        private const val CHANNEL_ID = "DownloadForegroundServiceChannel"

        private const val TAG = "ForegroundService"
    }

    inner class ServiceHandler(looper: Looper) : Handler(looper) {
        override fun handleMessage(msg: Message) {
            Log.d(TAG, "handleMessage: msg.arg1=${msg.arg1} => ${Thread.currentThread()}")

            for (i in 1..10) {
                try {
                    Thread.sleep(1000)

                    val resultData = Bundle().apply {
                        putInt(RESULT_PROGRESS, i * 10)
                    }
                    receiver?.send(RESULT_CODE_UPDATE_PROGRESS, resultData)
                } catch (e: InterruptedException) {
                    e.printStackTrace()
                }
            }

            stopSelf(msg.arg1)
        }
    }

    private var looper: Looper? = null
    private var serviceHandler: ServiceHandler? = null

    private var receiver: ResultReceiver? = null

    override fun onCreate() {
        Log.d(TAG, "onCreate => ${Thread.currentThread()}")

        val handlerThread = HandlerThread("DownloadService", Process.THREAD_PRIORITY_BACKGROUND)
        handlerThread.start()

        looper = handlerThread.looper
        serviceHandler = ServiceHandler(handlerThread.looper)
    }

    override fun onDestroy() {
        super.onDestroy()
        Log.d(TAG, "onDestroy => ${Thread.currentThread()}")
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        Log.d(TAG, "onStartCommand=$startId => ${Thread.currentThread()}")

        createNotificationChannel()
        val notificationIntent = Intent(this, MainActivity::class.java)
        val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0)
        val notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("Download Foreground Service")
            .setContentText("Downloading ...")
            .setSmallIcon(android.R.drawable.stat_sys_download)
            .setContentIntent(pendingIntent)
            .setOngoing(true)
            .build()
        startForeground(startId, notification)

        receiver = intent?.getParcelableExtra(ARGUMENT_RECEIVER)
        serviceHandler?.obtainMessage()?.let { message ->
            message.arg1 = startId
            serviceHandler?.sendMessage(message)
        }

        return START_NOT_STICKY
    }

    override fun onBind(p0: Intent?): IBinder? {
        TODO("Not yet implemented")
    }

    private fun createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                CHANNEL_ID,
                "Download Foreground Service",
                NotificationManager.IMPORTANCE_DEFAULT
            )
            val notificationManager = getSystemService(NotificationManager::class.java)
            notificationManager.createNotificationChannel(channel)
        }
    }
}
package com.waynestalk.serviceexample

import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.ResultReceiver
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import com.waynestalk.serviceexample.databinding.DownloadForegroundActivityBinding

class DownloadForegroundActivity : AppCompatActivity() {
    companion object {
        private const val TAG = "ForegroundActivity"
    }

    private var _binding: DownloadForegroundActivityBinding? = null
    private val binding: DownloadForegroundActivityBinding
        get() = _binding!!

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        _binding = DownloadForegroundActivityBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.downloadWithForegroundService.setOnClickListener {
            val intent = Intent(this, DownloadForegroundService::class.java)
            intent.putExtra(
                DownloadForegroundService.ARGUMENT_RECEIVER,
                UpdateReceiver(Handler(mainLooper))
            )
            startService(intent)
        }
    }

    inner class UpdateReceiver constructor(handler: Handler) : ResultReceiver(handler) {
        override fun onReceiveResult(resultCode: Int, resultData: Bundle) {
            super.onReceiveResult(resultCode, resultData)
            if (resultCode == DownloadForegroundService.RESULT_CODE_UPDATE_PROGRESS) {
                val progress = resultData.getInt(DownloadForegroundService.RESULT_PROGRESS)
                Log.d(TAG, "progress=" + progress + " => " + Thread.currentThread())
                binding.progress.text = "$progress %"
            }
        }
    }
}
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.ServiceExample"
        tools:targetApi="31">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

            <meta-data
                android:name="android.app.lib_name"
                android:value="" />
        </activity>

        <activity android:name=".DownloadForegroundActivity" />

        <service android:name=".DownloadForegroundService" />
    </application>

</manifest>

Bound Service

結語

Service 可以在背景處理 longer-running 工作,或是提供一些功能給其他的 app 使用。Service 不提供使用者介面。當 app 在背景時,Service 會繼續執行。要注意的是,Service 在 main thread 中執行,所以要另外建立背景 thread 來執行那些耗時的工作。在實作 Service 時,要考慮到 Service 隨時會被系統終止,之後可能會被重啟。在被終止時,要適當地釋放資源,並要儲存重要的資料。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

You May Also Like
Photo by Hans-Jurgen Mager on Unsplash
Read More

Kotlin Coroutine 教學

Kotlin 的 coroutine 是用來取代 thread。它不會阻塞 thread,而且還可以被取消。Coroutine core 會幫你管理 thread 的數量,讓你不需要自行管理,這也可以避免不小心建立過多的 thread。
Read More