🙃

通知をsqfliteに保存したがUI Stateも必要なので困った

2024/08/02に公開

🤔Think

sqfliteを使用して、FCMからローカルDBに通知の記録を保存する機能を作った。しかし、ボトムナビゲーションバーと通知のリストを表示するページ両方で、Notifierを使うと、通知のリストに2個通知のリストが作られた。えっなんで💦

通知のページでは、ローカルDBのデータを表示してるというよりもFreezedに保持した値を保存してました。これが良くなかった💦
riverpodは、グローバル State ってやつなのか、他の場所にもstateの変更が影響してしまう。Badgeのカウントだけ数えてくれれば良いとわかったので、通知のページは、ローカルDBのデータを表示することにした。

UI Stateの管理してるNotifierはこんな感じですね。List操作するだけ。SwiftUIでも同じでしたけど、Cloud Firestoreのデータ消してもUIに表示されてるのは、リストの値なので、リストも操作して、削除する必要があったりします。

モデルを作ります。これは、sqflite用のもの

/// ローカルに、push通知を保存する state class
class NotificationState {
  int? id;
  String? title;
  String? body;

  NotificationState({this.id, this.title, this.body});

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'title': title,
      'body': body,
    };
  }

  factory NotificationState.fromJson(Map<String, dynamic> map) {
    return NotificationState(
      id: map['id'],
      title: map['title'],
      body: map['body'],
    );
  }
}

これが今回問題になってたNotifierです。通知のカウントと削除でしか使わなくなった。UIの状態を変更する役割を担ってくれます。

import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:workstream_prod/domain/notification/notification_state.dart';
part 'notification_notifier.g.dart';


class NotificationNotifier extends _$NotificationNotifier {
  
  List<NotificationState> build() {
    return [];
  }

  void setNotifications(List<NotificationState> notifications) {
    state = notifications;
  }

  void addNotification(NotificationState notification) {
    state = [notification, ...state];
  }

  void removeNotification(int index) {
    state = List.from(state)..removeAt(index);
  }
}

GoRouter使ってるので、こんな感じで、ボトムナビゲーションバーは書きます。

// Flutter imports:
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

// Package imports:
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:workstream_prod/application/local/data_base_helper.dart';
import 'package:workstream_prod/application/usecase/notification/notification_notifier.dart';
import 'package:workstream_prod/core/logger/logger.dart';
import 'package:workstream_prod/domain/notification/notification_state.dart';

class ScaffoldWithNavBar extends HookConsumerWidget {
  const ScaffoldWithNavBar({
    required this.navigationShell,
    Key? key,
  }) : super(key: key ?? const ValueKey<String>('ScaffoldWithNavBar'));

  final StatefulNavigationShell navigationShell;

  
  Widget build(BuildContext context, WidgetRef ref) {
    final notifications = ref.watch(notificationNotifierProvider);
    final isLoading = useState(true);

    useEffect(() {
      // 初回のデータ取得
      ref.read(databaseHelperProvider).getNotification().then((data) {
        ref.read(notificationNotifierProvider.notifier).setNotifications(data);
        isLoading.value = false;
      });

      // FCMメッセージのリスナー設定
      final subscription =
          FirebaseMessaging.onMessage.listen((RemoteMessage message) {
        logger.d('Got a message whilst in the foreground!');
        logger.d('Message data: ${message.data}');

        if (message.notification != null) {
          logger.d('FCM message🔔: ${message.notification}');
          final entity = NotificationState(
            title: message.notification?.title,
            body: message.notification?.body,
          );
          ref.read(databaseHelperProvider).addNotification(entity).then((_) {
            ref
                .read(notificationNotifierProvider.notifier)
                .addNotification(entity);
          });
        }
      });

      return () {
        subscription.cancel(); // Clean up
      };
    }, const []);

    return Scaffold(
      body: navigationShell,
      bottomNavigationBar: BottomNavigationBar(
        items: <BottomNavigationBarItem>[
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'ホーム'),
          BottomNavigationBarItem(icon: Icon(Icons.task_alt), label: 'タスクと管理'),

          /// BadgeでWrapして、通知のカウントを表示
          BottomNavigationBarItem(
              icon: Badge.count(
                  count: notifications.length,
                  backgroundColor: Colors.red,
                  child: Icon(Icons.notifications)),
              label: '通知'),
        ],
        currentIndex: navigationShell.currentIndex,
        onTap: (index) => _onTap(context, index),
      ),
    );
  }

  void _onTap(BuildContext context, int index) {
    navigationShell.goBranch(
      index,
      initialLocation: index == navigationShell.currentIndex,
    );
  }
}

しかし、うまくいかない???

StreamBuilderだと限界あるのか???
すいません。書いてるコードは、自由な現場なので、決まりはありません笑
私は、flutter_hooks + hooks_riverpodで開発するのを推奨してます。

なんでもRiverpod使えばいいだろうって考えなので、StreamNotifierを使用して表示することにしたが、削除ボタン押したら、ref.invalidateを実行してもstateの更新がされないのか画面が切り替わらなかった???

対応策としては

私は、レイヤーを分けてますけど、これは真似しなくてよくて、DatabaseHelperなるクラスをStreamNotifierで呼び出せば問題ないです。

分けたほうがいいのは、もしObjectBoxに変えたいとか、要望が出てきたら、変更が必要なので、インターフェースを作ってあげました。抽象クラスと言ったほうが良い。

他のクラスで、newしたクラス。外で、インスタンス化したクラスをコンストラクタ引数に渡すDI(依存性の注入)なるものをやってます。

Riverpodを使うと、Refを使うから、勝手にDIしてくれてると思います。

このクラスの中にあるStreamを使います。

import 'package:path/path.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sqflite/sqflite.dart';
import 'package:workstream_prod/domain/notification/notification_state.dart';
part 'data_base_helper.g.dart';

(keepAlive: true)
DatabaseHelper databaseHelper(DatabaseHelperRef rer) {
  return DatabaseHelper();
}

// データベースの操作を行うクラス
class DatabaseHelper {
  // データベースのバージョン
  static const int _databaseVersion = 1;
  // データベースの名前
  static const String _databaseName = 'notification.db';

  // データベースのインスタンスを取得
  Future<Database> _getDB() async {
    return openDatabase(
      join(await getDatabasesPath(), _databaseName), // データベースのパスを指定
      version: _databaseVersion, // データベースのバージョンを指定
      onCreate: (db, version) async {
        // データベースにテーブルを作成。複数形の名前をつけるルールがあるようだ
        await db.execute(
          'CREATE TABLE notifications(id INTEGER PRIMARY KEY, title TEXT, body TEXT)',
        );
      },
    );
  }

  // insert notification
  Future<int> addNotification(NotificationState notificationState) async {
    final db = await _getDB(); // データベースのインスタンスを取得
    return db.insert(
      'notifications', // テーブル名
      notificationState.toJson(), // JSON形式に変換
      conflictAlgorithm: ConflictAlgorithm.replace, // データが重複した場合は置き換える
    );
  }

  // remove notification
  Future<int> deleteNotification(int id) async {
    final db = await _getDB();
    return db.delete(
      'notifications',
      where: 'id = ?',
      whereArgs: [id],
    );
  }

  Future<List<NotificationState>> getNotification() async {
    final db = await _getDB();
    final List<Map<String, dynamic>> maps = await db.query('notifications');
    return List.generate(maps.length, (i) {
      return NotificationState(
        id: maps[i]['id'],
        title: maps[i]['title'],
      );
    });
  }

  // Streamでリアルタイムにデータを取得
  Stream<List<NotificationState>> watchNotification() async* {
    final db = await _getDB();
    yield* db.query('notifications').asStream().map(
        (event) => event.map((e) => NotificationState.fromJson(e)).toList());
  }
}

ロジックは、サービスクラスに書いたりしますね。DB操作するなら、DTOなるフォルダに配置したりしますが。これを抽象クラスで参照します。このクラスを作っておくと、別のローカルDBを使った時に、Isar, ObjectBox, sembast, Driftなどに取り替えることができます。保守しやすいコードにするならこれがいいのかな。

import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:workstream_prod/application/local/data_base_helper.dart';
import 'package:workstream_prod/domain/notification/notification_state.dart';
part 'notification_repository.g.dart';

(keepAlive: true)
NotificationRepository notificationRepository(NotificationRepositoryRef ref) {
  return NotificationRepositoryImpl(ref);
}

abstract interface class NotificationRepository {
  Stream<List<NotificationState>> watchNotification();
}

class NotificationRepositoryImpl implements NotificationRepository {
  Ref ref;
  NotificationRepositoryImpl(this.ref);

  
  Stream<List<NotificationState>> watchNotification() async* {
    yield* ref.read(databaseHelperProvider).watchNotification();
  }
}

application層に作成したStreamNotifierで、sqfliteを呼び出してUIに表示します。このクラスは、複雑な状態管理をするのに向いているそうです。単純に表示するだけなら、StreamProviderで十分ですね。

import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:workstream_prod/domain/notification/notification_state.dart';
import 'package:workstream_prod/domain/repository/notification/notification_repository.dart';
part 'notification_stream_notifier.g.dart';


class NotificationStreamNotifier extends _$NotificationStreamNotifier {
  
  Stream<List<NotificationState>> build() async* {
    yield* ref.read(notificationRepositoryProvider).watchNotification();
  }

  Stream<List<NotificationState>> watchNotification() async* {
    yield* ref.read(notificationRepositoryProvider).watchNotification();
  }
}

こちらが問題の通知のアイコンをタップしたら表示されるページなんですけど、UI Stateの管理をNotifierだけでやろうとすると、他のページにも影響が出ました。このページでしか管理しなくても良い状態もあるので、flutter_hooksの useEffectを使うことで、FCMの通知を探知して、UIに状態の変更を反映させるロジックを実装できました。
最初は、 ref.listenも試してみたが、FirebaseMessaging.onMessage.listenはここで、購読するものではないようです。Widgetのコードしか書かないのかな。

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:workstream_prod/application/local/data_base_helper.dart';
import 'package:workstream_prod/application/usecase/notification/notification_notifier.dart';
import 'package:workstream_prod/application/usecase/notification/notification_stream_notifier.dart';
import 'package:workstream_prod/core/theme/app_color.dart';
import 'package:workstream_prod/presentation/component/indicator_component.dart';
import 'dart:async';

// 遅延処理のロジック
class Debounce {
  final Duration delay;
  Timer? _timer;

  Debounce({required this.delay});

  void run(void Function() action) {
    _timer?.cancel();
    _timer = Timer(delay, action);
  }

  void cancel() {
    _timer?.cancel();
  }
}

class NotificationPage extends HookConsumerWidget {
  const NotificationPage({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final notificationStream = ref.watch(notificationStreamNotifierProvider);
    final bouncer = Debounce(delay: const Duration(milliseconds: 300));

    useEffect(() {
      final subscription = FirebaseMessaging.onMessage.listen((event) {
        if (event.notification != null) {
          bouncer.run(() {
            // invalidateは頻繁に使うとパフォーマンスに影響があるので、遅延処理を入れる
            ref.invalidate(notificationStreamNotifierProvider);
          });
        }
      });
      return () {
        subscription.cancel();
        bouncer.cancel();
      };
    }, []);

    return Scaffold(
      appBar: AppBar(
        title: Text('通知'),
      ),
      body: switch (notificationStream) {
        AsyncData(:final value) => ListView.builder(
            itemCount: value.length,
            itemBuilder: (context, index) {
              final notification = value[index];
              return Padding(
                padding: const EdgeInsets.only(
                    left: 30, right: 30, top: 10, bottom: 10),
                child: Container(
                  width: double.infinity,
                  height: 100 * 1.5,
                  // 外枠に薄いグレー
                  decoration: BoxDecoration(
                    border: Border.all(
                      color: AppColor.lightGrey,
                    ),
                    borderRadius: BorderRadius.circular(10),
                  ),
                  child: ListTile(
                    trailing: IconButton(
                      icon: const Icon(Icons.delete),
                      onPressed: () async {
                        await ref
                            .read(databaseHelperProvider)
                            .deleteNotification(notification.id ?? 0);

                        /// [通知を削除したら、stateの通知記録を削除]
                        ref
                            .read(notificationNotifierProvider.notifier)
                            .removeNotification(index);

                        /// [通知を削除したら、通知一覧を再取得する]
                        ref.invalidate(notificationStreamNotifierProvider);
                      },
                    ),
                    title: Text(notification.title ?? ''),
                    subtitle: Text(notification.body ?? ''),
                  ),
                ),
              );
            },
          ),
        AsyncError(:final error) => Text('Error: $error'),
        _ => IndicatorComponent(),
      },
    );
  }
}

まとめ

Riverpodのプロバイダーだけで解決できるかなと思ったが、ページ内だけで完結したい処理もあったので、flutter_hooksなら、useState(), useEffect()を使い、やStatefulWidgetなら、initState(), setState(), も使う必要があるなと考えさせれらましたね。

まだまだ課題は残っているのですが、今回は、FlutterのライフサイクルとFCMの仕組みを理解していないと、機能実装で躓くっことが多かったですね。

Riverpodを使えばいいだろうって、空気を最近は感じます。私は、未経験でエンジニアになった頃、StatefulWidgetすらわかってない状態で、Riverpodを使うのを強制されました😨

StatefulWidgetを使うのも嫌がられる。ダメってわけではないんだぞ。「お前らよりレベル高い自社開発は実は使ってた笑」

確かに使えばいいけど、Dartのロジックやページが呼ばれたどのタイミングで、処理を実行するか考えないと、バグったりするので、初心者の人は、Dartの文法やFlutterのライフサイクルについて勉強して欲しいですね。

私は、どうやってそんな知識を得たかというと、フィットネスアプリを作るときに、すごくFlutterに詳しい人から、UIの状態の管理についての考え方を教わってからですね。

もしhooks_riverpodを使えないプロジェクトの場合は、ConsumerStatefulWidgetを使っていると思うので、このページでしか使わないであろうラジオボタンやチェックボックスのstate管理は、setState(), ローカルDBへの接続やFCMの通知の購読をする処理は、iniState()を使うとよさそうです。


class HomeView extends ConsumerStatefulWidget {
  const HomeView({super.key});

  
  HomeViewState createState() => HomeViewState();
}

class HomeViewState extends ConsumerState<HomeView> {
  
  void initState() {
    super.initState();
    //  `ref` は StatefulWidget のすべてのライフサイクルメソッド内で使用可能です。
    ref.read(counterProvider);
  }

  
  Widget build(BuildContext context) {
    //  `ref` は build メソッド内で使用することもできます。
    final counter = ref.watch(counterProvider);
    return Text('$counter');
  }
}

Discussion