🆙

[Flutter x Firebase] 強制アップデートについて

2023/03/21に公開

はじめに

モバイルアプリ開発で、強制アップデートはマストで用意しておきたい機能だと思います。
今回はFlutter製アプリの強制アップデート機能を、Firebaseを利用して実装する際に考えたことと、実装例を紹介していきます。

利用するFirebaseのサービス

強制アップデート実装に利用するサービスは主に以下の2つになると思います。
他の実装例はあまり見たことないので、もしあれば追記します。

RemoteConfig

https://firebase.google.com/docs/remote-config?hl=ja

ユーザーにアプリのアップデートをダウンロードしてもらわなくても、アプリの動作や外観を変更できるクラウド サービス

FirestoreDatabase

https://firebase.google.com/docs/firestore?hl=ja

Firebase と Google Cloud からのモバイル、ウェブ、サーバー開発に対応した、柔軟でスケーラブルなデータベース

個人的にですが、Firestoreではユーザ毎のデータを管理しておきたいので、
今回はRemotoConfigを利用したいと思います。

強制アップデート実装時に注意したい点

ユーザ側でキャンセル可能とするか

アップデート内容によるが、キャンセルもできるようにしておきたい。

その他、留意したい点(筆者のメモ書き)
  • キャンセルボタン押下後はダイアログを表示させたくない
  • どこでアップデートを促すダイアログを表示させる処理を実装するか
  • ストア公開、対象バージョン変更などの運用をどうするか

完成イメージ

アップデート対象のバージョンなら、ホーム画面でダイアログを表示させます。

実装例

※プロジェクトとfirebaseの繋ぎこみは割愛しています。

RemoteConfigの利用

1.構成を作成

2.パラメータの入力

※前述の「強制アップデート実装時に注意したい点」を考慮して設定。

「キャンセル不可」としたいアップデートが、
「キャンセル可能」となってしまうため、注意する必要がある。

{
  "latestVersion": "3.0.0",
  "requiredVersion": "2.0.0",
  "enabledAt": "2023-01-01T00:00+09:00"
}

3.公開

コード

https://github.com/taku01200220/update_sample

0.構成

1.パッケージの追加
pubspec.yaml
# 記事作成時の最新バージョン
dependencies:
  flutter:
    sdk: flutter

  firebase_core: ^2.8.0
  firebase_remote_config: ^3.0.15
  flutter_hooks: ^0.18.6
  hooks_riverpod: ^2.3.1
  package_info_plus: ^3.0.3
  version: ^3.0.2
  freezed_annotation: ^2.2.0
  shared_preferences: ^2.0.20

dev_dependencies:
  flutter_test:
    sdk: flutter

  build_runner: ^2.3.3
  freezed: ^2.3.2
  json_serializable: ^6.6.1
2.アップデートの種類をenumで定義
update_request_type.dart
enum UpdateRequestType {
  /// アップデートなし
  not,

  /// 後回しを許容するアップデートあり
  cancelable,

  /// 強制的なアップデートあり
  forcibly,
}
3.modelの定義
update_info.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'update_info.freezed.dart';
part 'update_info.g.dart';


class UpdateInfo with _$UpdateInfo {
  const factory UpdateInfo({
    /// 要求バージョン(任意)
    required String latestVersion,

    /// 要求バージョン(強制)
    required String requiredVersion,

    /// RemoteConfigが有効になる日時
    required DateTime enabledAt,
  }) = _UpdateInfo;

  factory UpdateInfo.fromJson(Map<String, dynamic> json) =>
      _$UpdateInfoFromJson(json);
}

https://zenn.dev/taku_zenn/articles/d5134fbd1540ee

4.RemoteConfigの初期化とインスタンスを返すProviderを作成
remote_config_provider.dart
// remote configのprovider
final remoteConfigProvider = FutureProvider<FirebaseRemoteConfig>(
  (ref) async {
    final rc = FirebaseRemoteConfig.instance;

    // 例)flavorで開発環境ごとにintervalを分ける場合
    // final interval = flavor.isDev ? Duration.zero : const Duration(hours: 1);
    const interval = Duration.zero;

    // タイムアウトとフェッチのインターバル時間を設定する
    await rc.setConfigSettings(
      RemoteConfigSettings(
        fetchTimeout: const Duration(minutes: 1),
        minimumFetchInterval: interval,
      ),
    );
    return rc;
  },
);
5.UpdateRequestTypeを返すProviderを作成
update_request_provider.dart
final updateRequesterProvider = FutureProvider<UpdateRequestType>((ref) async {
  final remoteConfig = await ref.watch(remoteConfigProvider.future);
  await remoteConfig.fetchAndActivate();
  // Firebase の RemoteConfigコンソールで指定したキーを使って値を取得
  final string = remoteConfig.getString('update_info');
  if (string.isEmpty) {
    return UpdateRequestType.not;
  }
  // JSON to Map
  final map = json.decode(string) as Map<String, Object?>;
  // Map(JSON) to Entity
  final updateInfo = UpdateInfo.fromJson(map);

  // 現在のアプリケーションを取得
  final appPackageInfo = await PackageInfo.fromPlatform();
  final appVersion = Version.parse(appPackageInfo.version);

  // shared_preferencesで保存ている「キャンセル」を押下した日時を取得
  final latestVersion = Version.parse(updateInfo.latestVersion);
  final requiredVersion = Version.parse(updateInfo.requiredVersion);
  final enabledAt = updateInfo.enabledAt;

  // shared_preferencesで保存ている「キャンセル」を押下した日時を取得
  final cancelledUpdateDateTime = await ref
      .watch(sharedPreferencesRepositoryProvider)
      .fetch<String>(SharedPreferencesKey.cancelledUpdateDateTime);

  // 現在のバージョンより新しいバージョンが指定されているか
  final hasNewVersion =
      latestVersion > appVersion || requiredVersion > appVersion;

  // ダイアログ表示が有効かどうか
  late bool isEnabled;
  if (enabledAt.isBefore(DateTime.now())) {
    isEnabled = cancelledUpdateDateTime == null ||
        enabledAt.isAfter(DateTime.parse(cancelledUpdateDateTime));
  } else {
    isEnabled = false;
  }

  if (!isEnabled || !hasNewVersion) {
    return UpdateRequestType.not;
  }
  return latestVersion > appVersion && requiredVersion <= appVersion
      ? UpdateRequestType.cancelable
      : UpdateRequestType.forcibly;
});
6.shared_preferencesでキャンセルボタンを押下した日時を保存
main.dart
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  // SharedPreferencesの初期化
  late final SharedPreferences sharedPreferences;
  await Future.wait([
    Future(() async {
      sharedPreferences = await SharedPreferences.getInstance();
    }),
  ]);

  runApp(
    ProviderScope(
      overrides: [
        sharedPreferencesRepositoryProvider.overrideWithValue(
          SharedPreferencesRepository(sharedPreferences),
        ),
      ],
      child: const MyApp(),
    ),
  );
}
shared_preferences_repository.dart
enum SharedPreferencesKey {
  // 任意のアップデートをキャンセルした日時
  cancelledUpdateDateTime
}

final sharedPreferencesRepositoryProvider =
    Provider<SharedPreferencesRepository>((_) => throw UnimplementedError());

class SharedPreferencesRepository {
  SharedPreferencesRepository(this.sharedPreferences);

  final SharedPreferences sharedPreferences;

  Future<bool> save<T>(SharedPreferencesKey key, T value) async {
    if (value is int) {
      return sharedPreferences.setInt(key.name, value);
    }
    if (value is double) {
      return sharedPreferences.setDouble(key.name, value);
    }
    if (value is bool) {
      return sharedPreferences.setBool(key.name, value);
    }
    if (value is String) {
      return sharedPreferences.setString(key.name, value);
    }
    if (value is List<String>) {
      return sharedPreferences.setStringList(key.name, value);
    }
    return false;
  }

  Future<T?> fetch<T>(SharedPreferencesKey key) async {
    if (T.toString() == 'int') {
      return sharedPreferences.getInt(key.name) as T?;
    }
    if (T.toString() == 'double') {
      return sharedPreferences.getDouble(key.name) as T?;
    }
    if (T.toString() == 'bool') {
      return sharedPreferences.getBool(key.name) as T?;
    }
    if (T.toString() == 'String') {
      return sharedPreferences.getString(key.name) as T?;
    }
    if (T.toString() == 'List<String>') {
      return sharedPreferences.getStringList(key.name) as T?;
    }
    return null;
  }

  Future<bool> remove(SharedPreferencesKey key) =>
      sharedPreferences.remove(key.name);
}
7.ダイアログを表示するページの作成
top_page.dart
class TopPage extends ConsumerWidget {
  const TopPage({
    super.key,
  });

  
  Widget build(BuildContext context, WidgetRef ref) {
    // updateの確認
    final updateRequestType = ref.watch(updateRequesterProvider).whenOrNull(
          skipLoadingOnRefresh: false,
          data: (updateRequestType) => updateRequestType,
        );

    WidgetsBinding.instance.addPostFrameCallback((_) {
      // アップデートがあった場合
      if (updateRequestType == UpdateRequestType.cancelable ||
          updateRequestType == UpdateRequestType.forcibly) {
        // 新しいバージョンがある場合はダイアログを表示する
        // barrierDismissible はダイアログ表示時の背景をタップしたときにダイアログを閉じてよいかどうか
        // updateの案内を勝手に閉じて欲しくないのでbarrierDismissibleはfalse
        showDialog<void>(
          context: context,
          barrierDismissible: false,
          builder: (context) {
            return UpdatePromptDialog(
              updateRequestType: updateRequestType,
            );
          },
        );
      }
    });

    return Scaffold(
      appBar: AppBar(title: const Text('TOP PAGE')),
      body: const SafeArea(
        child: Center(
          child: Text('update_sample'),
        ),
      ),
    );
  }
}
8.ダイアログの作成
update_prompt_dialog.dart
class UpdatePromptDialog extends ConsumerWidget {
  const UpdatePromptDialog({
    super.key,
    required this.updateRequestType,
  });

  final UpdateRequestType? updateRequestType;

  
  Widget build(BuildContext context, WidgetRef ref) {
    return WillPopScope(
      // AndroidのBackボタンで閉じられないようにする
      onWillPop: () async => false,
      child: CupertinoAlertDialog(
        title: const Text('アプリが更新されました。\n\n最新バージョンのダウンロードをお願いします。'),
        actions: [
          if (updateRequestType == UpdateRequestType.cancelable)
            TextButton(
              onPressed: () async {
                Navigator.pop(context);
                await ref
                    .watch(sharedPreferencesRepositoryProvider)
                    .save<String>(
                      SharedPreferencesKey.cancelledUpdateDateTime,
                      DateTime.now().toString(),
                    );
                ref.invalidate(updateRequesterProvider);
              },
              child: const Text('キャンセル'),
            ),
          TextButton(
            onPressed: () {
              // App Store or Google Play に飛ばす処理
            },
            child: const Text('アップデートする'),
          ),
        ],
      ),
    );
  }
}

Discussion