[译]Flutter后台定时任务插件background_fetch

4,920 阅读5分钟

「这是我参与11月更文挑战的第19天,活动详情查看:2021最后一次更文挑战」。

Flutter 应用中需要后台定时任务的话,可以使用 background_fetch 插件。

以下内容肉翻自 background_fetch 的 pub ,图片也来自相关的网站。


background_fetch | Flutter Package (pub.dev)

Flutter background_fetch

Flutter Background Geolocation 的创建者 Transistor Software 开发。

Background Fetch (后台获取)是一个大约每15分钟在后台唤醒 APP 、提供一个短暂的后台运行时间的简单插件。该插件每当 background-fetch 事件触发时都会运行你提供的 callbackFn (回调函数)。

🆕 Background Fetch 现在提供一个 scheduleTask (定时任务)方法来调度任何单次触发的任务或周期性的任务。

iOS

  • 这里无法增加 fetch-event (获取事件)触发的频率 。该插件已尽可能将频率调至最频繁,但你永远不会接收到快于15分钟的事件。操作系统会根据用户的使用模式自动压制 background-fetch 事件触发的频率。 如:如果用户长时间没有使用手机,fetch-event 的触发就会不那么频繁。
  • scheduleTask 像是只在接通电源时触发。scheduleTask 是设计用于低优先度的任务,不会如期望中频繁运行。
  • 默认的 fetch 任务会很频繁地运行。
  • ⚠️ 当 APP 终止运行时,iOS 不会再触发事件 - 在 iOS 上没有 stopOnTerminate:false 这样的设置。
  • iOS 会在内置Apple 的机器学习算法适应之前运行几天任务然后开始有规律地触发事件。不用盯着你的 log 来等待事件触发,如果你已经模拟了事件运行,你需要知道的全部就是所有事情都已经正确配置。
  • 如果用户很长时间没有打开 iOS 的应用,iOS 会停止触发事件。

Android

Android版的插件提供了一个 [Headless] pub.dartlang.org/documentati… (无头)的实现,允许即使 APP 终止之后也能继续处理事件。

内容

🔷 安装

📂 pubspec.yaml:

dependencies:
  background_fetch: '^0.7.0'

或从 git 安装最新版:

dependencies:
  background_fetch:
    git:
      url: https://github.com/transistorsoft/flutter_background_fetch

🔷 配置向导

iOS

配置 Background Capabilities

  • 选中工程的根目录,选择 Capabilities 标签。Background Modes 及跟着后面的 Mode 设为可用。

    •  Background fetch
    •  Background processing (Only if you intend to use BackgroundFetch.scheduleTask)

image.png

配置 Info.plist

  1. 打开 Info.plist,添加 Permitted background task scheduler identifiers 的 key。

image.png

  1. 添加需要的标识 com.transistorsoft.fetch

image.png

  1. 如果想通过 BackgroundFetch.scheduleTask 执行自定义Task,也需要添加这些自定义标识。
    例如:如果想执行一个 taskId: 'com.transistorsoft.customtask' 的自定义任务,也需要把 com.transistorsoft.customtask 标识添加到工程的 Permitted background task scheduler identifiers 中。

⚠️ 一个任务标识可以使用任何你想用的字符串,但是现在给这些标识符加上 com.transistorsoft. 前缀是一个好主意。 - 将来,com.transistorsoft 前缀可能会变成必需的

BackgroundFetch.scheduleTask(TaskConfig(
  taskId: 'com.transistorsoft.customtask',
  delay: 60 * 60 * 1000  //  毫秒 
));

Android

AndroidManifest

Flutter 在把第三方库合并进 Android 的 AndroidManifest.xml 里时好像有问题,特别是 android:label 属性。

📂 android/app/src/main/AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.helloworld">

    <application
         tools:replace="android:label"
         android:name="io.flutter.app.FlutterApplication"
         android:label="flutter_background_geolocation_example"
         android:icon="@mipmap/ic_launcher">
</manifest>

上面的配置中,下面两行是需要添加的内容:

    xmlns:tools="http://schemas.android.com/tools"
         tools:replace="android:label"

⚠️ 上面的步骤操作失败的话会引发一个构建错误

Execution failed for task ':app:processDebugManifest'.
> Manifest merger failed : Attribute application@label value=(hello_world) from AndroidManifest.xml:17:9-36
    is also present at [tslocationmanager-2.13.3.aar] AndroidManifest.xml:24:18-50 value=(@string/app_name).
    Suggestion: add 'tools:replace="android:label"' to <application> element at AndroidManifest.xml:15:5-38:19 to override.

android/gradle.properties

确认应用已经迁移到使用 AndroidX。

📂 android/gradle.properties:

org.gradle.jvmargs=-Xmx1536M
android.enableJetifier=true
android.useAndroidX=true

上面的配置中,下面两行是需要添加的内容:

android.enableJetifier=true
android.useAndroidX=true

android/build.gradle

APP 会变得越来越复杂,会引入各种第三方模块,所以提供 "Global Gradle Configuration Properties" 会帮助所有模块来匹配它们请求的依赖版本。 background_fetch 会意识到这些变量,然后匹配它能检测到的依赖版本。

📂 android/build.gradle:

buildscript {
+   ext.kotlin_version = '1.3.72'               // or latest
+   ext {
+       compileSdkVersion   = 30                // or latest
+       targetSdkVersion    = 30                // or latest
+       appCompatVersion    = "1.1.0"           // or latest
+   }

    repositories {
        google()
        jcenter()
    }

    dependencies {
+        classpath 'com.android.tools.build:gradle:3.3.1' // Must use 3.3.1 or higher.  4.x is fine.
    }
}

allprojects {
    repositories {
        google()
        jcenter()
+       maven {
+           // [required] background_fetch
+           url "${project(':background_fetch').projectDir}/libs"
+       }
    }
}

开头带 + (加号)的行是要添加的内容。

android/app/build.gradle

另外,需要自己来利用 Global Configuration Properties ,在 android/app/build.gradle 中用这些引用来替换硬编码的值。

📂 android/app/build.gradle:

android {
   compileSdkVersion rootProject.ext.compileSdkVersion
    .
    .
    .
    defaultConfig {
        .
        .
        .
       targetSdkVersion rootProject.ext.targetSdkVersion
    }
}

上面的配置中,下面两行是需要添加的内容:

   compileSdkVersion rootProject.ext.compileSdkVersion
       targetSdkVersion rootProject.ext.targetSdkVersion

🔷 示例

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'package:background_fetch/background_fetch.dart';

// 仅 Android 如果 APP 以 enableHeadless: true 终止,Headless Task 会运行。
void backgroundFetchHeadlessTask(HeadlessTask task) async {
  String taskId = task.taskId;
  bool isTimeout = task.timeout;
  if (isTimeout) {
    // 如果任务已经超过了允许的运行时间,必须停止当前的处理并立即终止任务(.finish(taskId))
    print("[BackgroundFetch] Headless task timed-out: $taskId");
    BackgroundFetch.finish(taskId);
    return;
  }  
  print('[BackgroundFetch] Headless event received.');
  // 在这里加你的处理
  BackgroundFetch.finish(taskId);
}

void main() {
  // 使用 Flutter 驱动扩展,可以集成测试。
  // 更多信息参考:https://flutter.io/testing/
  runApp(new MyApp());
  // 注册任务,在 APP 终止后接收 BackgroundFetch 事件。
  // 必需:{stopOnTerminate: false, enableHeadless: true}
  BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask);
}

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => new _MyAppState();
}

class _MyAppState extends State<MyApp> {
  bool _enabled = true;
  int _status = 0;
  List<DateTime> _events = [];

  @override
  void initState() {
    super.initState();
    initPlatformState();
  }

  // 平台消息是异步的,所以在异步方法里初始化
  Future<void> initPlatformState() async {
    // 配置 BackgroundFetch.
    int status = await BackgroundFetch.configure(BackgroundFetchConfig(
        minimumFetchInterval: 15,
        stopOnTerminate: false,
        enableHeadless: true,
        requiresBatteryNotLow: false,
        requiresCharging: false,
        requiresStorageNotLow: false,
        requiresDeviceIdle: false,
        requiredNetworkType: NetworkType.NONE
    ), (String taskId) async {  // <-- 事件 Handler
      // fetch 事件回调
      print("[BackgroundFetch] Event received $taskId");
      setState(() {
        _events.insert(0, new DateTime.now());
      });
      // 重要: 你必须发出任务完成的信号或者 OS 会严控你的 APP 在后台长时间运行。
      BackgroundFetch.finish(taskId);
    }, (String taskId) async {  // <-- 任务超时 Handler
      // 任务超过了允许的运行时间。必须停止当前的处理并立即终止任务(.finish(taskId))
      print("[BackgroundFetch] TASK TIMEOUT taskId: $taskId");
      BackgroundFetch.finish(taskId);
    });
    print('[BackgroundFetch] configure success: $status');
    setState(() {
      _status = status;
    });        

    // 如果异步平台消息还在处理中时组件从组件树中被移除,我们会放弃响应,而不是调用 setState 来更新已不存在的内容。
    if (!mounted) return;
  }

  void _onClickEnable(enabled) {
    setState(() {
      _enabled = enabled;
    });
    if (enabled) {
      BackgroundFetch.start().then((int status) {
        print('[BackgroundFetch] start success: $status');
      }).catchError((e) {
        print('[BackgroundFetch] start FAILURE: $e');
      });
    } else {
      BackgroundFetch.stop().then((int status) {
        print('[BackgroundFetch] stop success: $status');
      });
    }
  }

  void _onClickStatus() async {
    int status = await BackgroundFetch.status;
    print('[BackgroundFetch] status: $status');
    setState(() {
      _status = status;
    });
  }
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      home: new Scaffold(
        appBar: new AppBar(
          title: const Text('BackgroundFetch Example', style: TextStyle(color: Colors.black)),
          backgroundColor: Colors.amberAccent,
          brightness: Brightness.light,
          actions: <Widget>[
            Switch(value: _enabled, onChanged: _onClickEnable),
          ]
        ),
        body: Container(
          color: Colors.black,
          child: new ListView.builder(
              itemCount: _events.length,
              itemBuilder: (BuildContext context, int index) {
                DateTime timestamp = _events[index];
                return InputDecorator(
                    decoration: InputDecoration(
                        contentPadding: EdgeInsets.only(left: 10.0, top: 10.0, bottom: 0.0),
                        labelStyle: TextStyle(color: Colors.amberAccent, fontSize: 20.0),
                        labelText: "[background fetch event]"
                    ),
                    child: new Text(timestamp.toString(), style: TextStyle(color: Colors.white, fontSize: 16.0))
                );
              }
          ),
        ),
        bottomNavigationBar: BottomAppBar(
          child: Row(
            children: <Widget>[
              RaisedButton(onPressed: _onClickStatus, child: Text('Status')),
              Container(child: Text("$_status"), margin: EdgeInsets.only(left: 20.0))
            ]
          )
        ),
      ),
    );
  }
}

执行自定义任务

除了 BackgrondFetch.configure 中定义的默认 background-fetch 任务之外,还可以执行任何属于你的单次运行任务或周期性的任务( iOS 需要额外配置)。无论如何,所有的事件都会触发 **BackgroundFetch#configure** 中提供的回调:

⚠️ iOS:

  • scheduleTask 在 iOS 上只在设备接通电源时运行。
  • scheduleTask 在 iOS 上设计用于低优先度的任务,如清理缓存文件 - 对于关键任务来说是不可靠的scheduleTask 永远不会如期望中运行频繁。
  • 默认的 fetch 事件更可靠,触发也更频繁。
  • 在 iOS 上,用户终止 APP 时,scheduleTask 也会终止。在 iOS 上没有 stopOnTerminate: false 的设置。
// 第一步:(和通常情况一样)配置BackgroundFetch
int status = await BackgroundFetch.configure(BackgroundFetchConfig(
  minimumFetchInterval: 15
), (String taskId) async {  // <-- 事件回调
  // 这是一个 fetch 事件回调
  print("[BackgroundFetch] taskId: $taskId");

  // 使用 switch 语句为任务调度提供路由。
  switch (taskId) {
    case 'com.transistorsoft.customtask':
      print("Received custom task");
      break;
    default:
      print("Default fetch task");
  }
  // 完成,提供接收的任务 ID
  BackgroundFetch.finish(taskId);
}, (String taskId) async {  // <-- 事件超时回调
  // 任务已经超过允许的运行时间。需要停止当前的处理并立即终止任务(.finish(taskId))。
  print("[BackgroundFetch] TIMEOUT taskId: $taskId");
  BackgroundFetch.finish(taskId);
});


// 第二步:制定一个从现在执行5000毫秒 "com.transistorsoft.customtask" 的自定义单次任务。
BackgroundFetch.scheduleTask(TaskConfig(
  taskId: "com.transistorsoft.customtask",
  delay: 5000  // <-- 毫秒
));

🔷 调试

iOS

🆕 在 iOS 13+ 中为BGTaskScheduler 模拟事件

  • 在本文(pub 文档)编写时,新的任务模拟器还不能在模拟器中运行,只能真机中运行。

  • 在 XCode 中运行 APP 之后,点击 [||] 按钮来初始化断点

  • 在控制台( lldb )粘贴下面的命令(注: 在光标处用上/下键可调出前面运行过的命令):

    e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.transistorsoft.fetch"]
    
  • 点击 [>] 按钮继续。任务会执行,然后提供给 **`BackgroundFetch.configure`** 的回调函数会接收事件。

IMG_1604.PNG

IMG_1605.PNG 红字内容:通过改变标识来模拟任何已注册的任务。

IMG_1606.PNG

模拟任务超时事件

  • 只有新的 BGTaskScheduler API支持模拟 任务超时事件。要模拟一个任务超时,fetchCallback (fetch 回调)必须不调用 BackgroundFetch.finish(taskId):

    BackgroundFetch.configure(BackgroundFetchConfig(
      minimumFetchInterval: 15
    ), (String taskId) async {  // <-- 事件回调
      // 这是 fetch 事件回调
      print("[BackgroundFetch] taskId: $taskId"); 
      //BackgroundFetch.finish(taskId); // <-- 模拟 iOS 任务超时时禁用 .finish(任务Id)
    }, (String taskId) async {  // <-- 事件超时回调
      // 任务已经超过允许的运行时间。需要停止当前的处理并立即终止任务(.finish(taskId))。
      print("[BackgroundFetch] TIMEOUT taskId: $taskId");
      BackgroundFetch.finish(taskId);
    });
    
  • 现在如下模拟一个 iOS 任务超时,和上面同样地模拟一个事件

    e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.transistorsoft.fetch"]
    

BackgroundFetch API

  • 在 XCode 中使用 Debug->Simulate Background Fetch 模拟后台 fetch 事件。
  • iOS 可能会花几个小时甚至几天的时间来开启一个持续的后台 fetch 事件调度。因为 iOS 基于用户的使用模式来调度 fetch 事件。如果 Simulate Background Fetch 开始运行,就可以确认所有事情都运行正常,只是需要等待。

Android

  • $abd logcat 查看下插件日志。

    $ adb logcat *:S flutter:V, TSBackgroundFetch:V
    
  • 在设备上模拟一个 background-fetch 事件(insert <your.application.id>)只在 sdk21+ 的版本中可运行。

    $ adb shell cmd jobscheduler run -f <your.application.id> 999
    
  • 低于 sdk21 的版本,模拟一个 “Headless” 事件(使用 insert <your.application.id>

    $ adb shell am broadcast -a <your.application.id>.event.BACKGROUND_FETCH
    

Demo 应用

插件仓库里有一个 /example 目录,Clone 仓库并在 Android Studio 中打开 /example 目录。

🔷 实现

iOS

实现了 performFetchWithCompletionHandler, 触发在 cordova 插件中订阅的自定义事件。

Android

Android 根据不同的 Android SDK 版本使用两个不同的机制来实现 background fetch 。 LOLLIPOP及更高版本使用新的 JobScheduler ,否则使用旧的 AlarmManager 。

不像 iOS,Android 上的实现能够在应用终止( stopOnTerminate: false)或者设备重启( startOnBoot: true)后继续操作

🔷 许可证

MIT 许可证

详细参照 该插件的 pub。