JobScheduler – planowanie zadań w Androidzie

Gdy użytkownik na resztkach baterii, próbuje wydostać się z ciemnego lasu za pomocą mapy na telefonie, Twoja aplikacja nie powinna w tym czasie realizować żadnych energochłonnych i nieistotnych (w tym momencie dla użytkownika) zadań takich jak kompresja lub synchronizacja z bazą danych.

Synchronizacja o konkretnej godzinie (np. w nocy) też nie wchodzi w grę, po pierwsze nie wiesz co wtedy użytkownik robi a po drugie aplikacja mająca kilkaset tysięcy aktywnych użytkowników mogłaby przeciążyć serwery.

Z pomocą przychodzi JobScheduler (API level 21+), który umożliwia określenie warunków, które muszą zostać spełnione aby rozpocząć zadanie np. dostęp do sieci, rodzaju sieci, przepustowości sieci, stopnia naładowania baterii itp. Jeśli, któryś z warunków nie zostanie spełniony, wykonanie zadania zostanie zawieszone do momentu gdy warunki te będą spełnione.

JobScheduler pozwala na bezpieczne wykonanie zadania, nie narażając użytkownika na duże zużycie baterii lub obciążenie telefonu w niestosownym czasie.

Poniżej dwa krótkie filmy wprowadzające do JobScheduler:

Konfiguracja

JobScheduler jest dostępny od API 21 (niektóre właściwości od API 28), dlatego należy się upewnić czy aplikacja obsługuje takie API. Jeśli wspierasz starsze API niż 21 to możesz skorzystać z WorkManager.

Ograniczenia

Jak większość rozwiązań, JobScheduler ma pewne ograniczenia lub cechy. Najważniejsze z nich to:

  • Zadanie nie może być wykonywane częściej niż co 15 min (może to być zależne od systemu)
  • Nie ma gwarancji, że zadanie będzie się wykonywało dokładnie co 20 minut, może to być 18, 25 itp.
  • Zadanie nie może wykonywać się dłużej niż ok. 10 min (zależne od systemu), po tym czasie zostanie zabite przez system.

Czy powyższe ograniczenia są uciążliwe? W żadnym wypadku, mechanizm ten jest przeznaczony na w miarę długie zadania ale nie obciążające bardzo mocno systemu. 10 min powinno starczyć na jakieś synchronizacje itp. Jeśli synchronizacja wymaga godzinnej pracy to należałoby uprzedzić użytkownika, że zajmiemy jego telefon na dłuższy czas.

Stworzenie zadania

Zadanie to nic innego jak specjalny serwis androidowy. Do jego stworzenia posłuży klasa JobService, która jest abstrakcyjną klasą dziedziczącą Service. JobService w porównaniu do Service dostarcza 3 ważnych metod:

  • onStartJob – abstrakcyjna metoda, w której powinna się wykonać logika zaplanowanego zadania. Jeśli zadanie jest wykonywane w wątku pobocznym należy zwrócić wartość true aby powiadomić system, że po zakończeniu tej metody praca nie została jeszcze wykonana i serwis nie może jeszcze zakończyć zadania.
  • onStopJob – abstrakcyjna metoda, w której należy przerwać wszystkie wykonywane czynności, ponieważ system ubije ten serwis. Powodem może być przekroczenie czasu lub niespełnienie warunku np. użytkownik mógł odłączyć ładowarkę od telefonu, bądź wyłączyć WiFi (to kiedy system przerwie zadanie zależy od wersji oraz telefonu). Zwróć wartość true jeśli chcesz aby system ponowił wykonanie zadania gdy warunki zostaną spełnione lub false gdy zadanie ma nie być już powtórzone.
  • jobFinished – metoda finalna, należy ją wywołać w momencie gdy zakończymy działanie (jeszcze przed wywołaniem przez system metody onStopJob). System dzięki temu będzie wiedział, że dane zadanie zostało zakończone i że może zakończyć działanie serwisu.

Szablon serwisu, który będzie zawierał zadanie dla JobScheduler wygląda tak:

class LongRunningJobIntentService : JobService() {
  
    override fun onStartJob(params: JobParameters?): Boolean {
        return false
    }

    override fun onStopJob(params: JobParameters?): Boolean {
        return false
    }
}

W metodzie onStartJob zostanie uruchomione zadanie w oddzielnym wątku. W metodzie onStopJob zostanie przerwany wątek i system zostanie powiadomiony o tym, że zadanie należy zaplanować jeszcze raz (zostanie zwrócona wartość true).

class LongRunningJobService : JobService() {

    private var result: Int? = null

    private var backgroundJob: Thread? = null

    override fun onStartJob(params: JobParameters?): Boolean {
        backgroundJob = createBackgroundJob(params)
        backgroundJob?.run()
        return true
    }

    override fun onStopJob(params: JobParameters?): Boolean {
        Log.d("#JOB", "Background job stopped")
        return if (backgroundJob?.isAlive!!) {
            backgroundJob?.interrupt()
            true
        } else {
            false
        }
    }


    private fun createBackgroundJob(params: JobParameters?): Thread {
        return Thread {
            try {
                backgroundJob(params)
            } catch (e: InterruptedException) {
                Log.e("#JOB", "Background job interrupted")
            }
        }
    }

    private fun backgroundJob(params: JobParameters?) {
        Log.d("#JOB", "Background job started")
        sleep(5 * 60 * 1000) //20s
        result = 100
        Log.d("#JOB", "Background job finished")
        jobFinished(params, false)
    }
}

Elementem, o którym bardzo często zapominam podczas tworzenia serwisów jest wpis do manifestu ;). Tutaj też nie obejdzie się bez jego edycji:

        <service android:name=".LongRunningJobService"
                 android:permission="android.permission.BIND_JOB_SERVICE"
                 android:exported="true"
        />

Zaplanowanie zadania LongRunningJobService

Aby zaplanować zadanie, należy stworzyć obiekt typu JobInfo, który przechowuje warunki uruchomienia i zasady działania zadania. Do stworzenia JobInfo służy builder:

    fun scheduleJob() {
        val componentName = ComponentName(context, LongRunningJobService::class.java)
        val jobInfo = JobInfo.Builder(ID_LONG_RUNNING_JOB, componentName)
            .setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED)
            .setRequiresCharging(true)
            .setPersisted(true)
            .build()
        val jobScheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
        jobScheduler.schedule(jobInfo)
    }

W powyższym przykładzie zadanie zostanie uruchomione jeśli dwa warunki zostaną spełnione:

  • telefon musi być podłączony do sieci, w której nie ma ograniczania transmisja danych (np. WiFi). Drugi
  • telefon musi znajdować się pod ładowarką

Metoda setPersisted(true) sprawia, że zadanie zostanie uruchomione nawet gdy użytkownik uruchomi ponownie telefon (np. po rozładowaniu się baterii). Bez ustawienia tej zmiennej, uruchomienie ponowne telefonu sprawi, że zaplanowane zadanie zostanie usunięte z kolejki. Ustawienie tej zmiennej na true powoduje konieczność nadania aplikacji dodatkowego uprawnienia w manifeście:

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

Do uruchomienia zadania proponuję stworzyć nową klasę, która będzie umożliwiła zaplanowanie jak i anulowanie zadania:

class LongRunningJobScheduler(private val context: Context) {

    companion object {
        private const val ID_LONG_RUNNING_JOB = 1244
    }

    fun scheduleJob() {
        val componentName = ComponentName(context, LongRunningJobService::class.java)
        val jobInfo = JobInfo.Builder(ID_LONG_RUNNING_JOB, componentName)
            .setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED)
            .setRequiresCharging(true)
            .setPersisted(true)
            .build()
        val jobScheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
        jobScheduler.schedule(jobInfo)
    }

    fun cancelJob() {
        val jobScheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
        jobScheduler.cancel(ID_LONG_RUNNING_JOB)
    }
}

Hermetyzacja tej logiki pozwoli z dowolnego miejsca w aplikacji zaplanować lub usunąć zadanie za pomocą jednej metody:

 val longJobScheduler = LongRunningJobScheduler(context)
 longJobScheduler.scheduleJob()
 longJobScheduler.cancelJob()

Warunki rozpoczęcia zadania

Oprócz tych 3 warunków, które zastosowałem w powyższym przykładzie JobInfo.Builder pozwala na ustawienie całkiem sporej liczby innych warunków i właściwości:

addTriggerContentUri(JobInfo.TriggerContentUri uri) – zadanie zostanie wywołane gdy podany zasób zostanie zmodyfikowany. Może to być np. URI providera zdjęć lub kontaktów. Dzięki czemu np. Twoja aplikacja galerii zdjęć lub do zarządzania kontaktami będzie mogła w tle zaktualizować dane i nie będzie musiała ich synchronizować np. podczas każdego włączenia aplikacji.

setBackoffCriteria(long initialBackoffMillis, int backoffPolicy) – ustawienie czasu ponownego uruchomienia zadania w momencie gdy poprzednio nie zostało zakończone. initialBackoffMillis to czas pierwszego powtórzenia w milisekundach. Drugie powtórzenie zależne jest od backoffPolicy. W przypadku gdy backoffPolicy=JobInfo.BACKOFF_POLICY_LINEAR następne powtórzenie jest powiększane za każdym nieudanym razem o initialBackoffMillis – czyli liniowo np. dla initialBackoffMillis=5*60*1000. Pierwsze powtórzenie zostanie wykonane po 5 minutach, następne po 10 minutach a jeszcze następne po 15 minutach od poprzedniego nieudanego zadania. Gdy backoffPolicy=JobInfo.BACKOFF_POLICY_EXPONENTIAL to każde następne powtórzenie jest wykonywane 2 razy dłużej niż poprzednie. np. po 5 minutach, następne po 10 minutach a następne po 20 minutach itp.

setClipData(ClipData clip, int grantFlags) – pozwala dodać do zadania dane ze schowka.

setEstimatedNetworkBytes(long downloadBytes, long uploadBytes) – jeśli wiesz ile zadanie zużyje zasobów sieciowych to należy podać taką liczbę. Dzięki tym informacjom system będzie mógł uruchomić zadanie w odpowiednim czasie np. gdy sieć i stan naładowania będzie na tyle dobry, żeby przesłać 10MB plik.

setExtras(PersistableBundle extras) – pozwala dodać dodatkowe dane do zadania. Dane te przeżywają uruchomienie ponowne telefonu.

setImportantWhileForeground(boolean importantWhileForeground) – flaga oznaczająca, że wykonywane zadanie jest bardzo ważne jeśli aplikacja aktualnie jest używana. Zadanie zostanie uruchomione z mniejszymi restrykcjami niż inne zadania. W momencie gdy aplikacja zostanie zamknięta zadanie będzie miało normalny priorytet i będzie podlegało takim samym restrykcjom jak inne.

setMinimumLatency(long minLatencyMillis) – ustawia z jakim minimalnym opóźnieniem ma wywołać się zadanie. Może to być np. użyteczne do obsługi przycisku „przypomnij za godzinę”.

setOverrideDeadline(long maxExecutionDelayMillis) – ustawia jaki jest graniczny czas rozpoczęcia wykonywania zadania. Jeśli zostanie on przekroczony to zadanie wykona się nawet mimo niespełniania innych warunków.

setPeriodic(long intervalMillis) – ustawia odstępu czasu w których zadanie powinno zostać wywołane tylko jeden raz. Np. dla interwału równego 60*60*1000 mamy gwarancję, że zadanie w ciągu godziny wykona się tylko raz. Nie mamy gwarancji, że się wywoła w ogóle i też nie wiadomo kiedy.

setPeriodic(long intervalMillis, long flexMillis) – ustawia odstęp czasu w których zadanie powinno zostać wywołane z tym, że w oknie czasowym flexMillis na koniec okresu intervalMillis. Czyli np. dla wartości intervalMillis=60*60*1000 (60 min) i flexMillis=5*60*1000 (5 min) zadanie może być wywołane dopiero po 55 minutach od ostatniego wywołania.

setPersisted(boolean isPersisted) – jeśli wartość jest równa true to zaplanowane zadanie przeżyje restart urządzenia.

setPrefetch(boolean prefetch) – jeśli wartość jest ustawiona na true to system może zignorować wymaganie odnośnie typu sieci jeśli uzna, że miesięczny limit danych jest wystarczający na pobranie tej ilości danych. Może to być użyte np. do pobierania danych nowych promocji dla użytkownika w formie np. grafik. Jeśli użytkownikowi nie pozostało za wiele transferu to nie chcemy mu go marnować, jeśli ma go dostatecznie to możemy z niego skorzystać bez większego uszczerbku,

setRequiredNetworkType(int networkType) – ustawia typ sieci jaka jest wymagana dla zadania. Jest tylko kilka ustawień:
JobInfo.NETWORK_TYPE_NONE – brak sieci
JobInfo.NETWORK_TYPE_ANY – jakakolwiek sieć
JobInfo.NETWORK_TYPE_UNMETERED – sieć bez limitu transmisji danych np. WiFi
JobInfo.NETWORK_TYPE_NOT_ROAMING – sieć nie w roamingu. Moim zdaniem wymagana wartość dla każdej aplikacji, kiedyś włączyłem na chwilkę internet w Szwajcarii aby sprawdzić odjazdy pociągów i zadania z innych aplikacji w momencie włączenia sieci pobrały dla mnie bezużyteczne dane za 150zł.
JobInfo.NETWORK_TYPE_CELLULAR – sieć komórkowa

setRequiresBatteryNotLow(boolean batteryNotLow) – jeśli wartość ustawiona jest na true zadanie zostanie uruchomione tylko i wyłącznie gdy poziom baterii nie jest niski.

setRequiresCharging(boolean requiresCharging) – jeśli wartość ustawiona jest na true zadanie zostanie wykonane tylko i wyłącznie gdy urządzenie jest podłączone pod ładowarkę bądź jest urządzeniem stale podłączonym np. Android TV.

setRequiresDeviceIdle(boolean requiresDeviceIdle) – jeśli wartość ustawiona jest na true zadanie się wykona gdy użytkownik nie używa telefonu. Jest to bardzo przydatne gdy w tle będzie się wykonywało bardzo obciążające system zadanie.

setRequiresStorageNotLow(boolean storageNotLow) – jeśli wartość ustawiona jest na true zadanie zostanie wykonane tylko i wyłącznie gdy ilość pamięci telefonu nie jest mała. Jest to przydatne np. podczas pobierania dużych plików.

setTransientExtras(Bundle extras) – pozwala dodać dodatkowe dane do zadania. W porównaniu do metody setExtras, setTransientExtras nie współpracuje gdy setPersisted jest ustawiony na true

setTriggerContentMaxDelay(long durationMs) – maksymalne opóźnienie wywołanie zadania po modyfikacji zasobów dostarczonych za pomocą metody addTriggerContentUri.

setTriggerContentUpdateDelay(long durationMs) – opóźnienie z jakim ma zadziałać zadanie gdy zasoby dostarczone za pomocą metody addTriggerContentUri zostaną zmodyfikowane.

setRequiredNetwork(NetworkRequest networkRequest) – ustawia zaawansowane właściwości sieci. Obiekt klasy NetworkRequest tworzy się za pomocą buildera NetworkRequest.Builder, który ma tylko 2 metody do ustawiania właściwości:
addCapability(int capability) ustawia właściwości jakie musi posiadać sieć aby zadanie zostało uruchomione np. NetworkCapabilities.NET_CAPABILITY_MMS wymaga sieci, za pomocą której można wysłać wiadomość MMS
addTransportType(int transportType) ustawia wymagany typ sieci np. NetworkCapabilities.TRANSPORT_BLUETOOTH wymaga włączonego Bluetooth.
Więcej informacji na temat NetworkRequest znajdziesz tutaj: https://developer.android.com/reference/android/net/NetworkRequest.html

Dokumentację dotyczącą JobInfo.Builder znajdziesz tutaj: https://developer.android.com/reference/android/app/job/JobInfo.Builder

Wnioski

JobScheduler jest bardzo dobrym rozwiązaniem na pracochłonne zadania, które powinny być uruchamiane w tle. Dzięki niemu można zrobić to praktycznie niezauważalnie dla użytkownika, minimalizując zużycie baterii oraz sieci.

Używając JobScheduler do planowania zadań pamiętaj o korzyściach dla użytkownika nie koniecznie dla działania aplikacji.

Materiały i źródła

https://developer.android.com/reference/android/app/job/JobScheduler
https://developer.android.com/reference/android/app/job/JobInfo.Builder
https://medium.com/google-developers/scheduling-jobs-like-a-pro-with-jobscheduler-286ef8510129

Jeśli uważasz treść za wartościową to podziel się nią z innymi. Dziękuję.

Mateusz Chrustny

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *