Sytuacja kobiet w IT w 2024 roku
22.07.20199 min
Roberto Huertas

Roberto HuertasSoftware DeveloperAlpha

Budowanie usługi Android, która działa zawsze

Sprawdź, jak stworzyć usługę w tle na Androida, która będzie działała bez przerwy, mimo ograniczeń.

Budowanie usługi Android, która działa zawsze

Od kilku dni walczę, próbując znaleźć sposób na zrobienie usługi, która działa zawsze w systemie Android. Oto przewodnik dla wszystkich, którzy dążą do tego samego celu. Mam nadzieję, że to pomoże! ?

Problemem jest to, że

Ze względu na optymalizację baterii Androida, wprowadzoną w systemie Android 8.0 (poziom API 26), usługi w tle mają teraz pewne istotne ograniczenia. Zasadniczo, są one zabijane, gdy aplikacja znajduje się w tle przez jakiś określony czas, czyniąc je bezwartościowymi w osiągnięciu naszego celu prowadzenia zawsze działającej usługi.

Zgodnie z zaleceniami Androida, powinniśmy używać JobScheduler, który wydaje się działać całkiem dobrze i obsłuży dla nas wakelocki, utrzymując telefon w gotowości do pracy.

Niestety, to też nie zadziała. JobScheduler będzie uruchamiać zadania według uznania Androida, a na dodatek, gdy telefon wejdzie w tryb drzemki (doze mode), częstotliwość uruchamiania tych zadań będzie stale wzrastać. A co gorsza, jeśli kiedykolwiek będziesz chciał uzyskać dostęp do sieci - powiedzmy, że musisz wysłać dane na swój serwer - nie będziesz w stanie tego zrobić. Rzuć okiem na listę ograniczeń narzucanych przez tryb drzemki.

JobScheduler działa dobrze, jeśli nie masz nic przeciwko temu, aby nie mieć dostępu do sieci i nie zależy Ci również na tym, aby okres wykonywania był stały. W naszym przypadku chcemy, aby nasze usługi działały z określoną częstotliwością i nigdy nie były zatrzymywane, więc będziemy potrzebować czegoś innego.

Wszystko sprowadza się do usług na pierwszym planie

Jeśli szukasz przez internet rozwiązania tego problemu, jest bardzo prawdopodobne, że dotarłeś na tę stronę z dokumentacji Androida.

Tam poznamy różne rodzaje usług, które oferuje Android. Spójrz na opis Foreground Service:

Foreground Service wykonuje pewne operacje, które są zauważalne dla użytkownika. Np. aplikacja audio wykorzystuje  foreground service do odtwarzania ścieżki dźwiękowej. Foreground service muszą wyświetlać powiadomienie. Foreground service działają nawet wtedy, gdy użytkownik nie wchodzi w interakcję z aplikacją.

Wygląda na to, że to jest dokładnie to, czego szukamy... W rzeczy samej! ?

Pokaż mi kod

Stworzenie foreground service to naprawdę prosty proces, więc wyjaśnię i przejdę przez wszystkie kroki niezbędne do zbudowania foreground service, który który nigdy się nie zatrzyma.

Jak zwykle stworzyłem repozytorium z całością kodu na wypadek, gdybyś chciał się temu przyjrzeć i pominąć resztę postu.

Dodawanie niektórych zależności

W tym przykładzie wykorzystuję Kotlina, więc będziemy wykorzystywać korutyny i bibliotekę Fuellibrary na potrzeby HTTP.

Aby dodać te zależności, musimy dodać je do naszego pliku build.gradle:

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.3'
    implementation 'com.jaredrummler:android-device-names:1.1.8'

    implementation 'com.github.kittinunf.fuel:fuel:2.1.0'
    implementation 'com.github.kittinunf.fuel:fuel-android:2.1.0'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.0-M1'

    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

Pisanie naszych usług

Foreground services muszą wyświetlać powiadomienia, aby użytkownik wiedział, że aplikacja jest nadal uruchomiona. Jeśli się nad tym zastanowić, to ma to jakiś sens.

Należy pamiętać, że będziemy musieli pominąć niektóre z callbacków serwisu, które dotyczą kluczowych aspektów cyklu życia usług.

Bardzo ważne jest również używanie częściowego wakelocka, dzięki czemu nasz serwis nigdy nie zostanie dotknięty przez tryb drzemki. Należy pamiętać, że będzie to miało wpływ na żywotność baterii naszego telefonu, więc musimy ocenić, czy nasz przypadek użycia może być obsługiwany przez jakąkolwiek inną alternatywę oferowaną przez system Android w celu uruchomienia procesów w tle.

W kodzie jest kilka wywołań funkcji użytkowych (log, setServiceState) i kilka niestandardowych enumów (ServiceState.STARTED), ale nie martw się zbytnio. Jeśli chcesz zobaczyć skąd pochodzą, po prostu spójrz na przykładowe repozytorium.

class EndlessService : Service() {

    private var wakeLock: PowerManager.WakeLock? = null
    private var isServiceStarted = false

    override fun onBind(intent: Intent): IBinder? {
        log("Some component want to bind with the service")
        // We don't provide binding, so return null
        return null
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        log("onStartCommand executed with startId: $startId")
        if (intent != null) {
            val action = intent.action
            log("using an intent with action $action")
            when (action) {
                Actions.START.name -> startService()
                Actions.STOP.name -> stopService()
                else -> log("This should never happen. No action in the received intent")
            }
        } else {
            log(
                "with a null intent. It has been probably restarted by the system."
            )
        }
        // by returning this we make sure the service is restarted if the system kills the service
        return START_STICKY
    }

    override fun onCreate() {
        super.onCreate()
        log("The service has been created".toUpperCase())
        var notification = createNotification()
        startForeground(1, notification)
    }

    override fun onDestroy() {
        super.onDestroy()
        log("The service has been destroyed".toUpperCase())
        Toast.makeText(this, "Service destroyed", Toast.LENGTH_SHORT).show()
    }

    private fun startService() {
        if (isServiceStarted) return
        log("Starting the foreground service task")
        Toast.makeText(this, "Service starting its task", Toast.LENGTH_SHORT).show()
        isServiceStarted = true
        setServiceState(this, ServiceState.STARTED)

        // we need this lock so our service gets not affected by Doze Mode
        wakeLock =
            (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
                newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "EndlessService::lock").apply {
                    acquire()
                }
            }

        // we're starting a loop in a coroutine
        GlobalScope.launch(Dispatchers.IO) {
            while (isServiceStarted) {
                launch(Dispatchers.IO) {
                    pingFakeServer()
                }
                delay(1 * 60 * 1000)
            }
            log("End of the loop for the service")
        }
    }

    private fun stopService() {
        log("Stopping the foreground service")
        Toast.makeText(this, "Service stopping", Toast.LENGTH_SHORT).show()
        try {
            wakeLock?.let {
                if (it.isHeld) {
                    it.release()
                }
            }
            stopForeground(true)
            stopSelf()
        } catch (e: Exception) {
            log("Service stopped without being started: ${e.message}")
        }
        isServiceStarted = false
        setServiceState(this, ServiceState.STOPPED)
    }

    private fun pingFakeServer() {
        val df = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.mmmZ")
        val gmtTime = df.format(Date())

        val deviceId = Settings.Secure.getString(applicationContext.contentResolver, Settings.Secure.ANDROID_ID)

        val json =
            """
                {
                    "deviceId": "$deviceId",
                    "createdAt": "$gmtTime"
                }
            """
        try {
            Fuel.post("https://jsonplaceholder.typicode.com/posts")
                .jsonBody(json)
                .response { _, _, result ->
                    val (bytes, error) = result
                    if (bytes != null) {
                        log("[response bytes] ${String(bytes)}")
                    } else {
                        log("[response error] ${error?.message}")
                    }
                }
        } catch (e: Exception) {
            log("Error making the request: ${e.message}")
        }
    }

    private fun createNotification(): Notification {
        val notificationChannelId = "ENDLESS SERVICE CHANNEL"

        // depending on the Android API that we're dealing with we will have
        // to use a specific method to create the notification
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
            val channel = NotificationChannel(
                notificationChannelId,
                "Endless Service notifications channel",
                NotificationManager.IMPORTANCE_HIGH
            ).let {
                it.description = "Endless Service channel"
                it.enableLights(true)
                it.lightColor = Color.RED
                it.enableVibration(true)
                it.vibrationPattern = longArrayOf(100, 200, 300, 400, 500, 400, 300, 200, 400)
                it
            }
            notificationManager.createNotificationChannel(channel)
        }

        val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent ->
            PendingIntent.getActivity(this, 0, notificationIntent, 0)
        }

        val builder: Notification.Builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) Notification.Builder(
            this,
            notificationChannelId
        ) else Notification.Builder(this)

        return builder
            .setContentTitle("Endless Service")
            .setContentText("This is your favorite endless service working")
            .setContentIntent(pendingIntent)
            .setSmallIcon(R.mipmap.ic_launcher)
            .setTicker("Ticker text")
            .setPriority(Notification.PRIORITY_HIGH) // for under android 26 compatibility
            .build()
    }
}

Czas zająć się manifestem Androida

Będziemy potrzebowali dodatkowych uprawnień dla FOREGROUND_SERVICE, INTERNET i WAKE_LOCK. Upewnij się, że nie zapomniałeś ich uwzględnić, bo inaczej nic nie zadziała.

Kiedy już je wprowadzimy, będziemy musieli zadeklarować nasze usługi.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" 
          package="com.robertohuertas.endless">

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

    <application
            android:allowBackup="true"
            android:icon="@mipmap/ic_launcher"
            android:label="@string/app_name"
            android:roundIcon="@mipmap/ic_launcher_round"
            android:supportsRtl="true"
            android:theme="@style/AppTheme">

        <service
                android:name=".EndlessService"
                android:enabled="true"
                android:exported="false">
        </service>

        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
    </application>
</manifest>

Możesz mi powiedzieć, jak mam uruchomić usługę?

Tak, masz rację. Widzisz, w zależności od wersji Androida musimy uruchomić usługę z konkretną metodą.

Jeśli wersja dla systemu Android znajduje się poniżej API 26, musimy użyć startService. W każdym innym przypadku, startForegroundService to nasz wybór.

Tutaj możesz zobaczyć naszą główną aktywność MainActivity, zwykły ekran z dwoma przyciskami do uruchamiania i zatrzymywania usługi. To wszystko, czego potrzebujesz, aby rozpocząć naszą zawsze działającą usługę.

Pamiętaj, że możesz sprawdzić cały kod w tym repozytorium na GitHubie.

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        title = "Endless Service"

        findViewById<Button>(R.id.btnStartService).let {
            it.setOnClickListener {
                log("START THE FOREGROUND SERVICE ON DEMAND")
                actionOnService(Actions.START)
            }
        }

        findViewById<Button>(R.id.btnStopService).let {
            it.setOnClickListener {
                log("STOP THE FOREGROUND SERVICE ON DEMAND")
                actionOnService(Actions.STOP)
            }
        }
    }

    private fun actionOnService(action: Actions) {
        if (getServiceState(this) == ServiceState.STOPPED && action == Actions.STOP) return
        Intent(this, EndlessService::class.java).also {
            it.action = action.name
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                log("Starting the service in >=26 Mode")
                startForegroundService(it)
                return
            }
            log("Starting the service in < 26 Mode")
            startService(it)
        }
    }
}

Bonus: Uruchomić usługę podczas uruchomienia Androida (boot)

Ok, mamy naszą zawsze działającą usługę wykonującą żądania sieciowe co minutę, tak jak chcieliśmy, ale gdy użytkownik zrestartuje telefon... nasz serwis nie restartuje się razem z nim... ?

Nie martw się, na to też znajdziemy rozwiązanie. Stworzymy BroadCastReceiver o nazwie StartReceiver.

class StartReceiver : BroadcastReceiver() {

    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action == Intent.ACTION_BOOT_COMPLETED && getServiceState(context) == ServiceState.STARTED) {
            Intent(context, EndlessService::class.java).also {
                it.action = Actions.START.name
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                    log("Starting the service in >=26 Mode from a BroadcastReceiver")
                    context.startForegroundService(it)
                    return
                }
                log("Starting the service in < 26 Mode from a BroadcastReceiver")
                context.startService(it)
            }
        }
    }
}


Następnie ponownie zmodyfikujemy Android Manifest i dodamy nowe uprawnienia (RECEIVE_BOOT_COMPLETED) oraz nasz nowy BroadCastReceiver.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="com.robertohuertas.endless">
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE"></uses-permission>
    <uses-permission android:name="android.permission.INTERNET"></uses-permission>
    <uses-permission android:name="android.permission.WAKE_LOCK" />
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>

    <application
            android:allowBackup="true"
            android:icon="@mipmap/ic_launcher"
            android:label="@string/app_name"
            android:roundIcon="@mipmap/ic_launcher_round"
            android:supportsRtl="true"
            android:theme="@style/AppTheme">

        <service
                android:name=".EndlessService"
                android:enabled="true"
                android:exported="false">
        </service>

        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>

        <receiver android:enabled="true" android:name=".StartReceiver">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED"/>
            </intent-filter>
        </receiver>

    </application>
</manifest>


Należy wziąć pod uwagę, że usługa nie zostanie ponownie uruchomiona, chyba że była już uruchomiona. Tak to zaprogramowaliśmy, nie chodzi o to, że tak musi być.

W każdym razie, jeśli chcesz to przetestować, po prostu odpal emulator z Google Services w środku i upewnij się, że uruchamiasz adb w trybie root.

adb root
# If you get an error then you're not running the proper emulator.
# Be sure to stop the service
# and force a system restart:
adb shell stop
adb shell start
# wait for the service to be restarted!


Miłej zabawy!


Oryginał tekstu w języku angielskim przeczytasz tutaj.

<p>Loading...</p>