Foreground Service Android demo App - stopwatch

Теги: Android, Kotlin, Service

Задача

Один экран. На экране показывается секундомер, который стартует сразу после запуска приложения. Время секундомера обновляется каждые 10 мск. Когда приложение уходит в бэкгроунд (lifesycle state = STOP), запускается Foreground Service, который показывает notification c секундомером, время секундомера в нотификации обновляется каждую секунду. Когда приложение выходит в форегроунд (lifesycle state = START), Foreground Service стопается. Секундомер на экране продолжает работу.

1-Stopwatch

В демо-проекте с RecyclerView мы использовали CountDownTimer для секундомера и просто увеличивали переменную, которая хранила текущее значение таймера, на интервал за каждый такт CountDownTimer. Это неправильный подход для решение таких задач, как имплементация секундомера:

  1. Во-первых, CountDownTimer может остановится и мы не сможем это проконтролировать. Как, например, уход айтема за пределы экрана в RecyclerView в нашем прошлом примере.
  2. Во-вторых, мы делаем определенный набор операций на каждый такт (увеличение переменной, запись нового значения во View) - как итог будет накапливаться смещение и чем дальше - тем более неточное значение будет показывать секундомер.

Решение в следующем. Нам нужен независимый и достаточно точный источник значения времени. Будем использовать системное время. При старте приложения запоминаем значение системного времени и всякий раз, когда нам необходимо обновить состояние View, вычитаем из текущего значения времени сохраненное значение. Разница - количество времени прошедшее на данный момент. Cекундомер будет достаточно точен.

Тут мы используем корутину и в корутин-билдере launch обноляем каждые 10 мск (delay(INTERVAL)) View секундомера. Запускаем корутину на lifecycleScope - это значит, что корутина завершит свою работу вместе с lifecycle owner - Activity, и нам не нужно беспокоится об утечках памяти.

Работу displayTime() см. в Utils

2-ProcessLifecycleOwner

Теперь подумаем, как мы будет определять момент, когда приложение ушло в фон и когда вышло на передний план. Проще всего это делать фиксируя изменение жизненного цикла приложения, используя ProcessLifecycleOwner. START - приложение на переднем плане, STOP - приложение ушло в фон.

Делать это будем в Activity:

Добавляем два метода (имя не важно) с аннотациями @OnLifecycleEvent(Lifecycle.Event.ON_STOP) и @OnLifecycleEvent(Lifecycle.Event.ON_START). Методы будут вызываться когда соответствующие состояния жизненного цикла будут достигнуты. Помечаем Activity интерфейсом-маркером LifecycleObserver - теперь система понимает, что MainActivity обсервит lifesycle. В onCreate() добавляем обсервер ProcessLifecycleOwner.get().lifecycle.addObserver(this), передаем туда this - теперь измененения жизненного цикла будут передаваться в активити, т.е. будут вызываться методы, которые мы пометили соответствующими аннотациями.

Foreground Service

Тут сразу весь класс, и пройдемся по коду:

  1. private var isServiceStarted = false - флаг, определяет запущен ли сервис или нет, чтобы не стартовать повторно.
  2. private var notificationManager: NotificationManager? = null - мы будем обращаться к NotificationManager, когда нам нужно показать нотификацию или обновить её состояние. Это системный класс, мы можем влиять на отображение нотификаций только через него. Отсюда некоторые ограничения. Например, мы не сможем обновлять нотификацию чаще, чем 1 раз в секунду, NotificationManager просто не даст нам такой возможности. Но можете попробовать.
  3. private var job: Job? = null - тут будет хранится Job нашей корутины, в которой мы запускаем обновление секундомера в нотификации. Мы сможен вызвать job?.cancel(), чтобы остановить корутину, когда сервис будет завершать свою работу.
  4. private val builder by lazy { - Notification Builder понадобиться нам всякий раз когда мы будем обновлять нотификацию, но некоторые значения Builder остаются неизменными. Поэтому мы создаем Builder при первом обращении к нему с этими параметрами. Теперь при каждом повторном обращении к builder он вернет нам готовую реализацию.
  5. setContentIntent(getPendingIntent()) - при нажатии на нотификацию мы будем возвращаться в MainActivity.
  6. В onCreate() создаём экземпляр NotificationManager
  7. В onStartCommand() обрабатываем Intent. Этот метод вызывается когда сервис запускается. Мы будем передавать параметры для запуска и остановки сервиса через Intent.
  8. В processCommand() получаем данные из Intent и определяем что делаем дальше: стартуем или останавливаем сервис.
  9. Если получили команду на старт сервиса:
    • moveToStartedState() - вызываем startForegroundService() или startService() в зависимости от текущего API. Почему мы это делаем внутри сервиса? Т.к. метод startForeground() будет выдавать ошибку если будет вызываться на другом контексте, отличном от контекста в startForegroundService() или startService(). Почему мы вызываем разные методы в зависимости от API? В Android O (API 26) произошли существенные изменения в регулировании Services системой. Одно из главных изменений в том, что Foreground Service, который не в белом списке или который явно не сообщает пользователю о своей работе, не будет запускаться в фоновом потоке после закрытия Activity. Другими словами, вы должны создать notification, к которому вы прикрепляете Foreground Service, чтобы сервис продолжал работу. И вы должны запускать сервис с помощью нового метода  startForegroundService() (а не с помощью startService()). И, после создания сервиса, у вас есть пять секунд чтобы вызвать метод startForeground() запущенной службы и показать видимое пользователю уведомление. Иначе система останавливает сервис и показывает ANR
    • startForegroundAndShowNotification() - создаем канал, если API >= Android O. Создаем нотификацию и вызываем startForeground()
    • continueTimer(startTime) - продолжаем отсчитывать секундомер. Тут мы запускаем корутину, которую кэнсельнем, когда сервис будет стопаться. В корутине каждую секунду обновляем нотификацию. И как уже было сказано, обновлять чаще будет проблематично.
  10. commandStop() - останавливаем обновление секундомера job?.cancel(), убираем сервис из форегроунд стейта stopForeground(true), и останавливаем сервис stopSelf()

В MainActivity стартуем или останавливаем сервис, если получаем соотвествующие состояния от lifecycle. Для этого сетаем параметры в Intent и вызываем startService(intent)


Репо: https://github.com/ziginsider/Foreground-Service-Demo-Android-App

10 07 2021

Теги заметки: