【Flutter】flutter_background_serviceパッケージで、アプリからサーバに定期POST実行。

この記事は約20分で読めます。

はじめに

前回、こちらの記事で、Flutterアプリからサーバーにヘルスデータを送信し、Gemini APIを通じてアドバイスのテキスト(例:昨日はよく眠れたみたいですね。この調子!)を生成する部分を実装した。

前回までの実装だと、ユーザーが「連携開始する」ボタンを押すたびに、健康データをサーバー側にPOSTリクエスト送る仕様になっているので、Flutterアプリで毎日特定の時間にバックグラウンドで定期的にPOSTリクエストを実行する方法に変更したい。

flutter_background_serviceパッケージをインストール

今回は、iOSとAndroidの両方に対応した、定期的にバックグラウンドタスクを実行するflutterパッケージとしてflutter_background_service を利用する。

このパッケージは、アプリが閉じられた後もDartコードを継続的に実行する機能を提供する。これにより、定期的なPOSTリクエストをバックグラウンドで送信するなどの処理が可能になる。

flutter_background_service | Flutter package
A flutter plugin for executing dart code continously even application closed.

パッケージインストールのセクションを参考に、インストールする。

ShellScript
$ flutter pub add flutter_background_service
$ flutter pub get

パッケージのReadmeを見ると、Androidに関するwarningが記載されているが、これはbackgroundではなくforegroundを使用したい場合の追加設定に関してなので、今回はスルーする。

フォアグラウンドとバックグラウンドの違い

フォアグラウンドサービスは、アプリがバックグラウンドにあっても実行を続ける必要がある重要な作業(例えば、音楽の再生や位置情報の追跡)に使用され、ユーザーには常に通知を通じてそのサービスの存在を知らせる。

フォアグラウンドサービスは、システムによる優先度付けを受けやすく、バッテリーの消費やメモリ使用の最適化の影響を受けにくいため、長時間にわたるタスクの実行に適している。

一方で、バックグラウンドサービスは、ユーザーの直接的な介入を必要としない自動的な処理に利用される。例えば、アプリがユーザーの知らないところで自動的に天気情報を更新する場合など。

バックグラウンドサービスはユーザーに通知を送ることなくバックグラウンドで実行でき、システムのリソース管理のもとで動作し、必要に応じてシステムによってその実行が停止される可能性がある。

フォアグラウンドはユーザーに通知が必要であり、追加設定が必要になることが多い。Android 8.0(API レベル 26)以上では、通知チャネルを作成して、フォアグラウンドサービスの通知をユーザーに表示する必要がある。

バックグラウンドサービスの初期化

アプリの起動時にバックグラウンドサービスを初期化する必要がある。

lib/services フォルダ内に background_service_initializer.dart ファイルを作成し、ファイル内でバックグラウンドサービスの設定を行い、main.dartからサービスを開始する。

Dart
// lib/services/background_service_initializer.dart

import 'dart:async';
import 'dart:io';

import 'package:clocky_app/api/api_client.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:health/health.dart';

Future initializeBackgroundService() async {
  final service = FlutterBackgroundService();
  await service.configure(
    androidConfiguration: AndroidConfiguration(
      onStart: onStart,
      autoStart: true,
      isForegroundMode: false,
    ),
    iosConfiguration: IosConfiguration(
      autoStart: true,
      onForeground: onStart,
      onBackground: onIosBackground,
    ),
  );
}

@pragma('vm:entry-point')
void onStart(ServiceInstance service) async {
  // バックグラウンドで実行するコード
}

@pragma('vm:entry-point')
Future<bool> onIosBackground(ServiceInstance service) async {
  // iOSのバックグラウンド処理
  return true;
}

main.dart ファイル内で、initializeService 関数を呼び出してバックグラウンドサービスを初期化する。

Dart

import 'package:flutter/material.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:your_project_path/services/background_service_initializer.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  await initializeBackgroundService();// バックグラウンドサービスの初期化
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      // アプリの設定
    );
  }
}

サービスの実行ロジックの定義

onStart関数内で、定期的に実行したいタスクを定義する。ここでは、毎日朝6時に実行されるようにスケジューリングする。

Dart
// lib/services/background_service_initializer.dart

@pragma('vm:entry-point')
void onStart(ServiceInstance service) async {
  try {
    await dotenv.load();
    await Firebase.initializeApp();
  } catch (error) {
    print('Error loading .env in background service: $error');
  }
// 毎日6時に実行
  DateTime now = DateTime.now();
  if (now.hour == 6) {
    await fetchDataAndSend();
  }
  return true;
}

onStartメソッド内ではFlutterのウィジェットやコンテキストにアクセスできないため、ProviderBuildContextに依存するコードは直接使用できない。

そのため、データ取得と送信のロジックを独立した関数としてbackground_service.dart内に定義し、それをonStartから呼び出す形にする。

Dart

// lib/services/background_service_initializer.dart

Future<void> fetchDataAndSend() async {
  print("fetchAndProcessHealthData called.");
  HealthFactory health = HealthFactory(useHealthConnectIfAvailable: true);
  List<HealthDataType> types = getHealthDataTypes();

  try {
    List<HealthDataPoint> healthData = await health.getHealthDataFromTypes(
      DateTime.now().subtract(Duration(days: 1)),
      DateTime.now(),
      types,
    );

    // ダミーデータの生成(シミュレーターの場合、データないためデバッグ用として)
    // List<HealthDataPoint> healthData = [
    //   HealthDataPoint(
    //     NumericHealthValue(1000), // value: ステップ数 1000
    //     HealthDataType.STEPS, // type: データタイプは STEPS
    //     HealthDataUnit.COUNT, // unit: 単位は COUNT
    //     DateTime.now().subtract(Duration(days: 1)), // dateFrom: 昨日から
    //     DateTime.now(), // dateTo: 今日まで
    //     PlatformType.ANDROID, // platform: プラットフォームは ANDROID
    //     'dummy_device_id', // deviceId: ダミーのデバイスID
    //     'dummy_source_id', // sourceId: ダミーのソースID
    //     'dummy_source_name', // sourceName: ダミーのソース名
    //   )
    // ];

    if (healthData.isNotEmpty) {
      processHealthData(healthData); // ヘルスデータの処理
    } else {
      print(
          "No health data available or permissions not granted for both iOS and Android.");
    }
  } catch (e) {
    print("Error fetching health data: $e");
  }
}

void processHealthData(List<HealthDataPoint> healthDataPoints) {
  print(
      "Processing health data, number of data points: ${healthDataPoints.length}");

  int totalSteps = 0;
  double totalExerciseMinutes = 0;
  double totalAsleepMinutes = 0;
  double totalAwakeMinutes = 0;
  double totalDeepSleepMinutes = 0;
  double totalRemSleepMinutes = 0;
  double totalMindfulnessMinutes = 0;
  double totalLightSleepMinutes = 0;
  double totalSleepInBedMinutes = 0;
  double? firstWeight;

  for (var dataPoint in healthDataPoints) {
    print(
        "${dataPoint.typeString}: ${dataPoint.value} ${dataPoint.unitString}");

    switch (dataPoint.type) {
      case HealthDataType.STEPS:
        totalSteps +=
            (dataPoint.value as NumericHealthValue).numericValue.round();
        break;
      case HealthDataType.EXERCISE_TIME:
        totalExerciseMinutes +=
            (dataPoint.value as NumericHealthValue).numericValue;
        break;
      case HealthDataType.SLEEP_ASLEEP:
        totalAsleepMinutes +=
            (dataPoint.value as NumericHealthValue).numericValue;
        break;
      case HealthDataType.SLEEP_AWAKE:
        totalAwakeMinutes +=
            (dataPoint.value as NumericHealthValue).numericValue;
        break;
      case HealthDataType.SLEEP_DEEP:
        totalDeepSleepMinutes +=
            (dataPoint.value as NumericHealthValue).numericValue;
        break;
      case HealthDataType.SLEEP_REM:
        totalRemSleepMinutes +=
            (dataPoint.value as NumericHealthValue).numericValue;
        break;
      case HealthDataType.SLEEP_LIGHT:
        totalLightSleepMinutes +=
            (dataPoint.value as NumericHealthValue).numericValue;
        break;
      case HealthDataType.SLEEP_IN_BED:
        totalSleepInBedMinutes +=
            (dataPoint.value as NumericHealthValue).numericValue;
        break;
      case HealthDataType.WEIGHT:
        firstWeight ??=
            (dataPoint.value as NumericHealthValue).numericValue.toDouble();
        break;
      case HealthDataType.MINDFULNESS:
        totalMindfulnessMinutes +=
            (dataPoint.value as NumericHealthValue).numericValue;
        break;
      default:
        break;
    }
  }

  print("Total steps: $totalSteps");
  print("Total exercise minutes: $totalExerciseMinutes");
  print("Total asleep minutes: $totalAsleepMinutes");
  print("Total awake minutes: $totalAwakeMinutes");
  print("Total deep sleep minutes: $totalDeepSleepMinutes");
  print("Total rem minutes: $totalRemSleepMinutes");
  print("Total mindfulness minutes: $totalMindfulnessMinutes");
  print("Total light sleep minutes: $totalLightSleepMinutes");
  print("Total sleep in bed minutes: $totalSleepInBedMinutes");
  if (firstWeight != null) {
    print("First recorded weight of the previous day: $firstWeight kg");
  }

  // APIのURLを取得
  final String apiUrl = dotenv.get('API_URL');
  // ApiClientを使用してデータをサーバーに送信
  ApiClient apiClient = ApiClient(apiUrl);
  apiClient.sendHealthData({
    'steps': totalSteps,
    'exerciseMinutes': totalExerciseMinutes,
    'asleepMinutes': totalAsleepMinutes,
    'awakeMinutes': totalAwakeMinutes,
    'deepSleepMinutes': totalDeepSleepMinutes,
    'remSleepMinutes': totalRemSleepMinutes,
    'mindfulnessMinutes': totalMindfulnessMinutes,
    'lightSleepMinutes': totalLightSleepMinutes,
    'sleepInBedMinutes': totalSleepInBedMinutes,
    'firstWeight': firstWeight
  });
}

List<HealthDataType> getHealthDataTypes() {
  List<HealthDataType> types = [
    HealthDataType.STEPS,
    HealthDataType.WEIGHT,
    HealthDataType.SLEEP_ASLEEP,
    HealthDataType.SLEEP_AWAKE,
    HealthDataType.SLEEP_DEEP,
    HealthDataType.SLEEP_REM,
  ];

  if (Platform.isIOS) {
    types.addAll([
      HealthDataType.WORKOUT,
      HealthDataType.SLEEP_IN_BED,
      HealthDataType.MINDFULNESS,
      HealthDataType.EXERCISE_TIME,
    ]);
  } else if (Platform.isAndroid) {
    types.addAll([
      HealthDataType.SLEEP_LIGHT,
    ]);
  }

  return types;
}

検証

デバッグ用として、アプリが起動してから1分後にデータを前日の健康データをサーバー側にpostするように変更する。

Dart
@pragma('vm:entry-point')
void onStart(ServiceInstance service) async {
  // バックグラウンドで定期的に実行されるタスク
  // Timer.periodic(Duration(hours: 24), (timer) async {
  //   var now = DateTime.now();
  //   if (now.hour == 6) {
  //     // 毎日6時に実行
  //     await fetchDataAndSend();
  //   }
  // });

  // デバッグのためにトリガーを現在時刻から数分後に設定
  print("onStart called.");
  try {
    await dotenv.load();
    await Firebase.initializeApp();
  } catch (error) {
    print('Error loading .env in background service: $error');
  }
  Timer(Duration(minutes: 1), () async {
    await fetchDataAndSend();
  });
}

iOS実機で確認

今回は、データをサーバー側にポストする契機となるボタンを用意していない。

アプリ起動してから1分後に、アプリのコンソールで、前日の健康データの結果が出力された。

ShellScript

flutter: fetchAndProcessHealthData called.
flutter: Processing health data, number of data points: 109
flutter: Total steps: 3052
flutter: Total exercise minutes: 13.0
flutter: Total asleep minutes: 360.0
flutter: Total awake minutes: 32.5
flutter: Total deep sleep minutes: 33.0
flutter: Total rem minutes: 73.5
flutter: Total mindfulness minutes: 0.0
flutter: Total light sleep minutes: 0.0
flutter: Total sleep in bed minutes: 1508.9833333333333

と同時に、サーバー側にデータがpostされ、データ結果を元に、アドバイス文章が生成されたことを確認できた。

ShellScript
clocky_api_local  | {"time":"2024-03-06T22:47:17.724128745Z","id":"","remote_ip":"192.168.XX.XXX","host":"192.168.XX.XXX:8080","method":"PATCH","uri":"/app/me","user_agent":"Dart/3.3 (dart:io)","status":500,"error":"code=500, message=failed to update user","latency":118553890,"latency_human":"118.55389ms","bytes_in":38,"bytes_out":36}
clocky_api_local  | Received health data: {Steps:3052 ExerciseMinutes:13 AsleepMinutes:360 AwakeMinutes:32.5 DeepSleepMinutes:33 RemSleepMinutes:73.5 MindfulnessMinutes:0 LightSleepMinutes:0 SleepInBedMinutes:1508.9833333333333 FirstWeight:<nil>}
clocky_api_local  | 2024/03/06 22:47:40 GenerateText function called
clocky_api_local  | Generated health advice: 運動時間はちょっと少なめかな。15分くらい運動してみると、気分がよくなるよ!

コメント

タイトルとURLをコピーしました