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()
        mavenCentral() //before 1.0.2 version jcenter()
        maven {
            // [required] background_fetch
            url "${project(':background_fetch').projectDir}/libs"
        }
    }
}

UPD С версии 1.0.2 плагин «переехал» с jcenter на mavenCentral. Обратите внимание в файлах build.gradle. И что-то не получается нормально перезапустить проект, после этого обновления 🙁 Не запускает фоновую задачу, ругается на вызов метода через methodChanell, как будто где-то у разработчика что-то сломалось или запускается не та функция (от старого пакета).

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

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

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

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

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

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

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

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

Если при вводе команды не получается ввести заглавные буквы — это глюк powershell, срабатывающий при переключении в русскую раскладку. Нужно переключиться в английскую и перезапустить VS Code.

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

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. Расширенный пример

Для этих экспериментов создал новый репозиторий, на основании того, что упоминается выше репозиторий. (Для работы с форматами даты подключаем пакет intl.)

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

Ограничение по времени работы

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

Для этого я создал функцию cupertinoGetTaskIdDialog, которая выводит окно, где необходимо ввести желаемый период повтора задачи и ограничение по времени. Пришлось обернуть TextFormField`ы в виджет Card, иначе купертиновский виджет сопротивлялся. А он мне нравится своей аккуратностью «их коробки».

Отдельный класс MyTaskConfig. Параметры я решил передавать через taskId, причем все. Можно реализовать через SharedPreferenses, но это значит, что придется делать через await и, в общем, мне так захотелось, а вы можете сделать по своему. В этом классе реализовал конструктор, который из строки создает объект и функцию, которая из параметров формирует нужный строковый taskId.

Несколько вспомогательных функций, чтобы не писать одинаковый код…

И изменения в функциях, которые работаю периодически (в фоне или в псевдо фоне). По сути, это замена страта новой задачи на функцию checkIdAndStart(taskId), в которой taskId транcформируется в объект класса MyTaskConfig. Проходят проверки, корректный ли у нас период и не закончилось ли у нас время. Если все нормально, то задача снова запускается с тем же taskId и с тем же периодом.

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

Разный функционал

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

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

В обе выполняемые функции добавляем код, который из переданного id определяет префикс, который указывает, с какой страницы была запущена задача. В данном случае, мы вместо одинаковой на все случаи записи, пишем в лог немного разные…

  if (_myTaskConfig.pagePrefix == TaskIds.firstPageKey) {
    LogManager.writeEventInLog("$taskId@$timestamp [ФОНОВАЯ] Страница 1");
  } else if (_myTaskConfig.pagePrefix == TaskIds.secondPageKey) {
    LogManager.writeEventInLog("$taskId@$timestamp [ФОНОВАЯ] 222");
  }

В файл, отвечающий за задачи, добавил пару функций для записи и чтения taskId из хранилища:

void writeIdInStorage(String keyStoradge, String taskId) async {
  var prefs = await SharedPreferences.getInstance();
  prefs.setString('mytask_$keyStoradge', taskId);
}

Future<String> readIdFromStoradge(String keyStoradge) async {
  var prefs = await SharedPreferences.getInstance();
  return prefs.getString('mytask_$keyStoradge') ?? '';
}

На странице добавил функцию stopStartBackgroundTask(), которая перезапускает задачу и «запоминает» нам айдишник. Эта функция вызывается в initState. Теперь кнопки «Запустить задачу» и «Остановить задачу» показываются, в зависимости от того, работает ли у нас сейчас задача. Немного изменил _stopTasks, теперь по ней у нас останавливается только задача, запущенная на этой странице. Ну и _taskIdInWork меняю при запуске и при остановке задачи.

При открытии страницы можно и не останавливать задачу, а только читать, что она запущена, но есть пара моментов. Та задача, которая работает в фоне уже никак с нашим приложением явно не связана (не может менять наши виджеты), а при перезапуске мы снова запускаем функцию, которая не фоновая. И еще, мы можем проверять, а не закончился ли у нас уже период… Для этого в функцию checkIdAndStart добавлены «обнуления» writeIdInStorage(_myTaskConfig.pagePrefix, »), в случае, если мы не запускаем период.

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

Далее…

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

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

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

Stop и Finish

Некоторые нюансы. stop() — останавливает задачи (все или по taskId). Вызывать ее нужно в приложении, чтобы прекратить выполнение (следующий запуск) задачи (задач). То есть, они снимаются «с расписания», по сути.

finish() — не совсем понятно. В примерах к пакету background_fetch этот метод вызывается внутри фоновых (и не совсем фоновых) функций. Судя по комментариям, «для того, чтобы сообщить о завершении работы задачи, иначе система посчитает, что задача слишком долго находится в памяти и выгрузит ее. На реальном проекте, я несколько раз ловил сообщение, что задача превысила таймаут и ей не сопоставлена функция обработки завершения по таймауту. Хотя почти ничего функция, на тот момент, не делала. Откуда появился таймаут, не ясно. Но в итоге, пришлось в «конфигураторе» подставлять и такую функцию, в которой сообщать о завершении (вызывать finish). А так же, я в самих функциях вызываю stop, а не финиш…

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

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