Flutter background fetch

Обзоры > Flutter > Flutter background fetch

Понадобилось мне в одном своем проекте на Flutter выполнять некоторые задачи в фоновом режиме с некоторой периодичностью. То есть, когда экран заблокирован или вообще приложение выгружено из памяти.

Попробовал плагин android alarm manager на flutter.dev. Он у меня не «взлетел» на реальном устройстве. Не отрабатывал, как нужно, в фоновом режиме. Возможно, я просто в нем не разобрался до конца. А вот в background_fetch разобрался глубже. Чем здесь и поделюсь. И самому потом, чтобы было легче, и может кто-то другой обойдет те «грабли», на которые я наступал.

На момент написания статьи, кстати, у alarm_manager 363 лайка, а у background fetch 521. Так что со мной согласны многие 🙂

Для начала разберем совсем простой вариант приложения. (Репозиторий там) Минимум кода и реактивщины, чтобы для начала вникнуть в тему, провести эксперименты и наметить задачи на доработку. Потом разберем уже доработанный пример…

Все ниже описанное запускал на эмуляторах и реальных устройствах под Андроид. С iOS не работаю, пока…

Лог работы и остановки фоновых задач в терминале (не консоль отладки)
к содержанию ↑

Установка

Как обычно, пакет нужно прописать в pubspec.yaml, На момент написания статьи background_fetch: ^1.0.1. Дополнительно берем shared_preferences, чтобы отслеживать выполнение функций.

Вероятно, при сборке понадобится подключить NDK конкретной версии — поймете по описанию ошибки…

В папку android\app помещаем файл proguard-rules.pro (возьмите из репозитория) и прописываем его в build.gradle в этой же папке, добавив текст:

        // Enables code shrinking, obfuscation, and optimization for only
        // your project's release build type.
        minifyEnabled false

        // Enables resource shrinking, which is performed by the
        // Android Gradle plugin.
        shrinkResources false

        // Includes the default ProGuard rules files that are packaged with
        // the Android Gradle plugin. To learn more, go to the section about
        // R8 configuration files.
        proguardFiles getDefaultProguardFile(
                'proguard-android-optimize.txt'),
                'proguard-rules.pro'

после

buildTypes {
        release {
            // TODO: Add your own signing config for the release build.
            // Signing with the debug keys for now, so `flutter run --release` works.
            signingConfig signingConfigs.debug

Это нужно, на сколько я понял, чтобы градл включал в сборку не используемый, по его мнению, код. Тот, который в фоне выполняется…

В build.gradle, который лежит непосредственно в папке android в allprojects\repositories обязательно добавьте

        maven {            url «${project(‘:background_fetch’).projectDir}/libs»        }

В итоге должно быть как-то так:

allprojects {
    repositories {
        google()
        jcenter()
        maven {
            // [required] background_fetch
            url "${project(':background_fetch').projectDir}/libs"
        }
    }
}
к содержанию ↑

Часть 1. Простой пример

Разбираем код

В первую очередь озаботимся функцией, которая будет выполняться в фоновом режиме. В нашем случае, это backgroundFetchFunction. В реальном приложении ее лучше помещать в отдельный файл, как и прочее, относящееся к фоновому выполнению. В этом же примере просто оставим в файле main. Главное, чтобы функция была объявлена в глобальной зоне видимости, а не приватно, в каком то виджете.

На «вход» функции подается «задача», у которой, к сожалению, всего два параметра. Строковый taskId — айдишник задачи и булевый флаг timeout, который отличает периодические задачи от разовых. Здесь есть один очень важный момент. Периодическая задача в типовом варианте может запускаться с периодом не менее 15 минут (!!!) и то, этот период будет примерным. Мы же воспользуемся тем, что разовые задачи запускаются строго через нужное время (хоть 5 секунд), а периодичность будем «эмулировать» тем, что после выполнения задачи, будем запускать ее еще раз! С теми же параметрами, которые, правда, в реальном приложении, нужно будет передавать, к примеру, через тот же shared_preferences.

В моем примере данная функция делает запись в «лог» (строка в хранилище) с пометкой «ФОНОВАЯ» и запускает новую задачку.

Регистрируем эту функцию в main. Можно это сделать и позже, но, что называется «один раз». Перерегистрировать не получается (проверим это на второй странице), так что, если у вас несколько разных задач в фоне, все их придется обрабатывать в одной функции и анализировать по taskId.

await BackgroundFetch.registerHeadlessTask(backgroundFetchFunction);

Выяснился один нюанс. Если регистрировать функцию до runApp, то функция не находится 🙁 Поэтому, либо в main после runApp, либо (в продакшене так и нужно) где-то на странице splash, где загружаем и инициализируем все нужное…

На первой странице в initState читаем записанный лог и он выводится под кнопками. Здесь же «конфигурируем» запускаемые задачи, устанавливаем функцию, которая будет выполнять до перехода в фоновый режим. Эту функцию мы можем заявить не глобальной, поэтому можем оперировать доступным контекстом. К примеру, запускать setState, но тогда нужно проверять с помощью mounted, а отображается ли у нас виджет. Функция, в нашем случае, закидывает в список на экране запись с строкой «НА ЭКРАНЕ 1 SET», но в лог пишет строку «НА ЭКРАНЕ 1».

Заметьте один нюанс. Если мы запустим такую локальную функцию, потом перейдем на другую страницу (при этом первая из стэка удалится), то функция выполняться будет. Только на экран ничего выводить не будет. И даже если мы потом «вернемся» на эту страницу (снова отобразим), то у нас создастся новый экран (виджет StatefulWidget) и та функция уже не будет отображать в массиве строку «НА ЭКРАНЕ 1 SET». То есть, в реальном приложении, если нам нужно, чтобы на экране «в живую» отображались события из такой функции, то либо мы при входе на страницу останавливаем работающую задачу и запускаем в привязкой к новому экрану (чтобы mounted и setState отрабатывали), либо реализуем «общение» с функцией через какой-нибудь стрим, который на странице слушаем.

В эту не фоновую функцию, в отличии от глобальной фоновой, приходит только строковая taskId. Поэтому сделать все через одну функцию, не получится…

Первая кнопка запускает НЕ периодическую задачку, которая в функции запустится повторно и это будет выглядеть, как периодическое выполнение.

Вторая кнопка останавливает все задачи через BackgroundFetch.stop(), чтобы они не выполнялись у нас «вечно» :). В продакшене, конечно, нужно делать ограничение на время выполнения. Я передаю время окончания через taskId (наименование каждый раз разное). Еще вариант, через sharedPreferences. Можно пытаться останавливать отдельные задачи через BackgroundFetch.finish(taskId), но это, опять же, расширенный вариант, когда мы айдишник отслеживаем. Пока же, в упрощенном виде, останавливаем все и отслеживаем все сами.

Кнопка «На вторую страницу» отправляет нас, соответственно, на вторую страницу. А «Очистить лог», просто удаляет «лог» в хранилище и на странице.

к содержанию ↑

Эксперименты

Проводим следующие «эксперименты».

1 Жмем «Запустить» задачу. Дожидаемся, когда в списке «Лога» появится запись (записи) со стройкой «НА ЭКРАНЕ 1 SET».

2 Блокируем экран или переходим просто на «рабочий стол». Через полминуты открываем приложение в списке выполняемых. Видим, что список задач пополнился.

Выгружаем приложение из памяти (перед этим можно очистить лог). Запускаем его через минуту. и видим, что появились записи со строкой «ФОНОВЫЕ».

Примечание. Команда для отлавливания print`ов в фоновой задаче:

adb logcat *:S flutter:V, TSBackgroundFetch:V

Основная задача: выполнение кода в фоне, — выполнена!

3 Останавливаем задачи. Очищаем лог. Запускаем задачу, дожидаемся нескольких записей в лог и переходим на вторую страницу.

4 Ждем минутку. Возвращаемся на первую страницу. Должны наблюдаться записи со строкой «НА ЭКРАНЕ 1» (это вместо тех, которых мы дождались) и новые со строкой «СТРАНИЦА 1». То есть, вторая функция даже после закрытия первой страницы отрабатывала, но setstate не делала. Она и дальше будет отрабатывать (писать в хранилище), но обновлять экран не будет.

Следующий эксперимент. Запускаем на первой странице задачу, дожидаемся несколько записей в лог. Переходим на вторую страницу и запускаем вторую задачу. Ждем минутку, возвращаемся на первую страницу и видим…

Лог результатов запуска двух задач

Задачи с id 111 и 2222 нормально отрабатывают, но выполняется при этом функция, которая была заявлена при конфигурировании первой. Второй функцией заменить не получилось…

Делаем выводы:

Нельзя заменить инициализированную фоновую функцию и нельзя заменить заявленную первой не фоновую функцию. То есть, на все приложение у нас будут только две функции, которые должны отрабатывать все нужные нам задачи!

к содержанию ↑

Параметры конфигурирования

У класса BackgroundFetchConfig имеются следующие свойства:

  • required minimumFetchInterval. Обязательный параметр. Минимальный интервал для периодической задачи, запускаемой сразу после «конфигурирования». В минутах. Минимальное время — 15.
  • bool? stopOnTerminate. Флаг, останавливать выполнение задачи, когда приложение прекращает работу. По умолчанию false. Конечно же, нам не нужно прекращать, а как раз и интересует работа после выгрузки приложения! 🙂
  • bool? startOnBoot. Флаг, стартовать после перезагрузки. По умолчанию, true. Аккуратнее с ним, можно повесить вечную задачу на смартфоне 🙂
  • bool? enableHeadless Флаг, «включить фоновую функцию». Не понятно (эксперимент описан дальше, стало немного понятнее), как сочетается с stopOnTerminate. Нужны эксперименты. Предположительно, если этот флаг false, то без приложения будет выполняться та же функция, которая объявляется при конфигурировании? По умолчанию true.
  • bool? forceAlarmManager (Только для Андроида). Если этот флаг false, то используется АПИ JobSheduler, иначе используется более старый АПИ AndroidAlarm. Если коротко, то работа первого зависит от уровня батареи и не такая точная, как у AlarmManager. По умолчанию false. Ставьте в true, если нужна более точное срабатывание. При конфигурировании не так важно.
  • NetworkType? requiredNetworkType — требуемый тип сети. Под типом, здесь подразумеваются возможные значения: NONE — работать при наличии или при отсутствии подключения к сети; ANY — работать при любом подключении к сети; UNMETERED — только, когда сеть без ограничений (вероятно, имеется в виду, лимит трафика); NOT_ROUMING — работать, когда есть подключение не в роуминге; CELLULAR — когда интернет через сотовую связь (любопытно, в каком кейсе это понадобится?). Ставим либо NONE, либо ANY, в зависимости от необходимости интернета, чтобы зря не тратить заряд аккумулятора.
  • bool? requiresBatteryNotLow — для работы функции, необходимо, чтобы заряд батареи был не низким. Имеется виду, скорее всего, тот предел в 15% (обычно), когда смартфон сообщает, что телефон разряжен и рекомендует перейти в энергосберегающий режим.
  • bool? requiresStorageNotLow — необходимо много места на «диске».
  • bool? requiresCharging — необходимо, чтобы смартфон был подключен к зарядке.
  • bool? requiresDeviceIdle — необходимо, чтобы телефон находился в состоянии простоя.

Провел небольшой эксперимент. Установил везде stopOnTerminate в true. Ожидаемо, после того, как принудительно выгрузил приложение из памяти, фоновая задача не работала.

Вернул OnTerminate в false, но установил enableHeadless в false. В консоли отладки, в момент перехода на рабочий стол смартфона получил сообщение об ошибке конфигурирования. Что-то типа, чтобы использовать stopOnTerminate: false, необходимо установить значение enableHeadless. Но какой смысл в OnTerminate true… если на такое не ругается, я пока не уловил.

к содержанию ↑

Параметры задачи

Параметры конкретной задачи во многом пересекаются с параметрами конфигурации:

  • String taskId — обязательный параметр. Уникальный идентификатор задачи. В зависимости от него, функция  может выполнять разные действия.
  • int delay — через какое время (в миллисекундах) запустить задачу
  • bool periodic = (по умолчанию false). Периодическая ли функция. Если будет установлена в true, то минимальное время между запусками равно 15 минутам.     
  • bool? stopOnTerminate — см. выше в общем конфигурировании 
  • bool? startOnBoot — см. выше в общем конфигурировании
  • bool? enableHeadless — см. выше в общем конфигурировании
  • bool? forceAlarmManager — см. выше в общем конфигурировании. В примерах для не периодической функции с малым delay устанавливается в true для задачи.
  • NetworkType? requiredNetworkType — см. выше в общем конфигурировании
  • bool? requiresBatteryNotLow — см. выше в общем конфигурировании
  • bool? requiresStorageNotLow — см. выше в общем конфигурировании
  • bool? requiresCharging — см. выше в общем конфигурировании
  • bool? requiresDeviceIdle — см. выше в общем конфигурировании
  • bool requiresNetworkConnectivity = false. Нужно ли наличие (активного) соединения с сетью (выход в интернет).
к содержанию ↑

Часть 2. Расширенный пример

В разработке… Точнее, оформляется…

Необходимо добавить возможность определять общее время работы задачи (предел), чтобы не сажать батарейку и не засорять память.

Желательно через какой-нибудь bloc стримить записи в лог, чтобы отображать на экране даже после переходов. Просто для демонстрации.

Необходимо реализовать вариант, остановки задач при переходе на страницу, запущенных на ней, и перезапуск их. При этом, задачи, запущенные на другой странице, не трогать.

Интерактивное поведение. Одна кнопка на запуск и остановку задач (когда задача уже запущена).

Ну и продемонстрировать показ уведомлений из фоновой задачи…

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *