Flutterでアプリに強制アップデートの仕組みを入れる

Flutterアプリをクローズドアルファ版に登録してから、本番公開するまでに絶対に入れておいた方が良いだろうという機能を思い出したので、慌てて作りました(汗)

いわゆる「強制アップデート機能」です。

「新しいバージョンがあるからアップデートしてね。アップデートしないと使わせないよ」ってやつです。

[upgrader](https://pub.dev/packages/upgrader)というパッケージもあるようですが、別のサービスへの登録が必要そうだったので、FirebaseのRemoteConfigを使って地道にやる方法をとります。

# 環境など

|ツールなど|バージョンなど|
|:--|:--|
| MacBook Air Early2015|macOS Mojave 10.14.5|
|Android Studio |3.6.1|
|Java |1.8.0_131|
|Flutter|1.12.13+hotfix.5|
|Dart |2.7.0|
|Xcode |10.2|

なお、Firebaseへのアプリ登録は、済んでいるものとします。
まだの方は、以下の記事などを参考にして下さい。
[Flutterに初めてのFirebase導入(Firebase Analytics)](https://qiita.com/kasa_le/items/31a7b858b1969e75016c)

# Firebase RemoteConfigの設定

以下の2つを定義しました。
整数値比較の方が楽なので、Androidでいう`VersionCode`を入れていくようにします。

- 強制アップデート対象のバージョンコード
- debug版アプリの最新版のバージョンコード

![remote_config.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/627bb1b6-bda7-d418-254b-038fe8edbe95.png)

これより低い値のアプリを起動すると、「アップデートして下さい」という表示を出すことにします。
iOSとAndroidで別々に管理する方法もありますが、Flutterの場合恐らくどちらも同時にアップデートしていくことになると思うので、共有で使うことにします。

設定し終えたら、右上の「変更を公開」ボタンをクリックしましょう。

# Flutterアプリの設定

## 1.依存パッケージ

以下のパッケージを使います。

- [package_info](https://pub.dev/packages/package_info)
  - パッケージ情報からVersionCode(BuildNumber)を簡単に取得するために使用
- [url_launcher](https://pub.dev/packages/url_launcher)
  - Playストア、Appストアを開くために使用
- [firebase_remote_config](https://pub.dev/packages/firebase_remote_config)
  - Firebase RemoteConfigの為に使用
- [get_it](https://pub.dev/packages/get_it)
  - シングルトンなサービスをDIで利用できるようにするため使用

### (1)pubspec.yamlに設定

```pubspec.yaml
dependencies:
  ...

  get_it: ^4.0.1

  # Firebase
  firebase_core: ^0.4.4+3
  firebase_analytics: ^5.0.11
  firebase_remote_config: ^0.3.0+3

  package_info: '>=0.4.0+17 <2.0.0'
  url_launcher: ^5.4.5
```

AndroidStudioから[Packages get]をクリックしておくか、コマンドラインで以下を実行しておきます。

```console
$ flutter pub get
```

## 2.RemoteConfigからバージョンを取得して比較

### (1)コンフィグ名の決定

デバッグビルド版とリリース版はコンフィグ名を分けていました。なのでデバッグビルドかどうかを判定してコンフィグ名を決めなければなりません。

`bool.fromEnvironment('dart.vm.product')`がリリースビルドで`true`を返すのを利用します。

```dart
  static const String DEV_VERSION_CONFIG = "dev_app_version";
  static const String CONFIG_VERSION = "force_update_app_version";

    // releaseビルドかどうかで取得するconfig名を変更
    final configName = bool.fromEnvironment('dart.vm.product')
        ? CONFIG_VERSION
        : DEV_VERSION_CONFIG;

```

### (2)実行しているアプリのバージョンコードの取得

`PackageInfo`から取得します。

```dart
    // versionCode(buildNumber)取得
    final PackageInfo info = await PackageInfo.fromPlatform();
    int currentVersion = int.parse(info.buildNumber);
```

### (3)RemoteConfigから取得

```dart
    // remote config
    final RemoteConfig remoteConfig = await RemoteConfig.instance;

    try {
      // 常にサーバーから取得するようにするため期限を最小限にセット
      await remoteConfig.fetch(expiration: const Duration(seconds: 0));
      await remoteConfig.activateFetched();
      int newVersion = remoteConfig.getInt(configName);
      if (newVersion > currentVersion) {
        // TODO showDialog
      }
    } on FetchThrottledException catch (exception) {
      // Fetch throttled.
      print(exception);
    } catch (exception) {
      print('Unable to fetch remote config. Cached or default values will be '
          'used');
    }
  }
```

### (4)全部まとめたコード

上記コードをまとめ、`VersionCheckService`としました。そう、あとで`get_it`パッケージでDIします。

```VersionCheckService.dart
import 'package:firebase_remote_config/firebase_remote_config.dart';
import 'package:package_info/package_info.dart';

class VersionCheckService {
  static const String DEV_VERSION_CONFIG = "dev_app_version";
  static const String CONFIG_VERSION = "force_update_app_version";

  /// バージョンチェック関数
  Future<bool> versionCheck() async {
    // versionCode(buildNumber)取得
    final PackageInfo info = await PackageInfo.fromPlatform();
    int currentVersion = int.parse(info.buildNumber);
    // releaseビルドかどうかで取得するconfig名を変更
    final configName = bool.fromEnvironment('dart.vm.product')
        ? CONFIG_VERSION
        : DEV_VERSION_CONFIG;

    // remote config
    final RemoteConfig remoteConfig = await RemoteConfig.instance;

    try {
      // 常にサーバーから取得するようにするため期限を最小限にセット
      await remoteConfig.fetch(expiration: const Duration(seconds: 0));
      await remoteConfig.activateFetched();
      int newVersion = remoteConfig.getInt(configName);
      if (newVersion > currentVersion) {
        return true;
      }
    } on FetchThrottledException catch (exception) {
      // Fetch throttled.
      print(exception);
    } catch (exception) {
      print('Unable to fetch remote config. Cached or default values will be '
          'used');
    }
    return false;
  }
}
```

### (5)get_itパッケージでDI

テストの時などにDIしやすいよう、**get_it**パッケージでDI登録で使います。

```main.dart
GetIt locator = GetIt.instance;

void setupLocator() {
  ...
  // 追加
  locator.registerLazySingleton<VersionCheckService>(() => VersionCheckService());
}

void main() async {
  setupLocator();

  ...

  // 描画の開始
  runApp(...);
}
```

## 3.更新ダイアログ表示

### (1)アップデートダイアログを出す専用のWidgetクラスを作る

これはあくまでもアプローチの1例です。
他のアプリを作ったときに使い回しやすいように、専用のウィジェットクラスにしました。
ダイアログを出すのが仕事なので、返すウィジェットはダミーのものになります。
また、このウィジェットは`StatefulWidget`の方が都合が良いのでそうしています。

```dart
import 'dart:io';

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';

import '../main.dart';
import '../services/VersionCheckService.dart';

/// 強制アップデートダイアログを出す為のダミーに近いStatefulWidget
class Updater extends StatefulWidget {
  Updater({Key key}) : super(key: key);

  @override
  State<Updater> createState() => _UpdaterState();
}

class _UpdaterState extends State<Updater> {
  @override
  void initState() {
    final checker = locator<VersionCheckService>();
    checker.versionCheck().then((needUpdate) => _showUpdateDialog(needUpdate));

    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 1,
    );
  }

  // FIXME ストアにアプリを登録したらurlが入れられる
  static const APP_STORE_URL =
      'https://apps.apple.com/jp/app/id[アプリのApple ID]?mt=8';

  // FIXME ストアにアプリを登録したらurlが入れられる
  static const PLAY_STORE_URL =
      'https://play.google.com/store/apps/details?id=[アプリのパッケージ名]';

  /// 更新版案内ダイアログを表示
  void _showUpdateDialog(bool needUpdate) {
    if (!needUpdate) return;

    showDialog(
      context: context,
      barrierDismissible: false,
      builder: (BuildContext context) {
        final title = "バージョン更新のお知らせ";
        final message = "新しいバージョンのアプリが利用可能です。ストアより更新版を入手して、ご利用下さい。";
        final btnLabel = "今すぐ更新";
        return Platform.isIOS
            ? new CupertinoAlertDialog(
                title: Text(title),
                content: Text(message),
                actions: <Widget>[
                  FlatButton(
                    child: Text(
                      btnLabel,
                      style: TextStyle(color: Colors.red),
                    ),
                    onPressed: () => _launchURL(APP_STORE_URL),
                  ),
                ],
              )
            : new AlertDialog(
                title: Text(title),
                content: Text(message),
                actions: <Widget>[
                  FlatButton(
                    child: Text(
                      btnLabel,
                      style: TextStyle(color: Colors.red),
                    ),
                    onPressed: () => _launchURL(PLAY_STORE_URL),
                  ),
                ],
              );
      },
    );
  }
}
```

`CupertinoAlertDialog`は、iOS標準のデザインのダイアログになります。何となく出し分けてみました(気分ですw)

`_launchURL`は以下のような関数です。

```dart
  /// 指定のURLを起動する. App Store or Play Storeのリンク
  void _launchURL(String url) async {
    if (await canLaunch(url)) {
      await launch(url);
    } else {
      throw 'Could not launch $url';
    }
  }
```

### (2)メインのウィジェットから利用する

上記で作った`Updater`ウィジェットを、メインウィジェットに組み込みます。

```dart
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text(TITLE_TOP),
        ),
        body: Provider<TopPageBloc>(
          create: (context) => TopPageBloc(),
          dispose: (context, bloc) => bloc.dispose(),
          child: Stack(
            children: <Widget>[
              Pager(),
              Updater(),
            ],
          ),
        ));
  }
```

今まで`Provider`の`child`に直接`Pager()`という自作のWidgetクラスを指定していましたが、そこを`Stack`にして、`Updater`を最後に追加しています。

これで実行してみます。`pubsepc.yaml`のバージョンの`+`以降の数字を、FirebaseのRemoteConfigに設定した値より小さくしておき、起動します。

- Android版

![android_dialog.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/5f7d73ce-56f0-5057-6d99-b7ff9641a037.png)

[今すぐ更新]をタップすると、Playストアアプリが起動します。今はIDが不正なのでちゃんとページは開きませんが。

- iOS版

さあ実行しよう！と思ったら・・・

クラッシュしましたT_T

```console
flutter: The getter 'alertDialogLabel' was called on null.
flutter: Receiver: null
flutter: Tried calling: alertDialogLabelflutter:
flutter: When the exception was thrown, this was the stack:
flutter: #0      Object.noSuchMethod (dart:core/runtime/libobject_patch.dart:50:5)
flutter: #1      CupertinoAlertDialog.build.<anonymous closure> (package:flutter/src/cupertino/dialog.dart:244:40)
```

こんなエラーです。
ググったら解決策が見つかりました。
https://github.com/flutter/flutter/issues/23047

よく考えたら、マテリアルデザイン用の`MaterialApp`で初期化してるんだから、iOS用デザインである`cupertino`関連は動かなくても当然ですね。

```main.dart
/// アプリのルートウィジェット
class MyApp extends StatelessWidget {
  MyApp({Key key, this.date}) : super();

  final String date;

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      // 日本語フォント設定
      localizationsDelegates: [
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate, // 追加
      ],
    ....
```


これでもう一度実行。

![ios_dialog.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/2ce3fa68-2112-a991-265b-faa48d6ba6f9.png)

出ました！

# Android版の端末の戻るボタンでダイアログが閉じてしまう
このままだと、Androidではダイアログが閉じられてしまいます。端末の戻るボタンで自動的にPopされちゃうからですね。
端末の戻るボタンの処理を上書きするのには、`WillPopScope`というのを使います。
これでAlertDialogを出しているところを括ってしまいます。

```Updater.dart
  /// 更新版案内ダイアログを表示
  void _showUpdateDialog(bool needUpdate) {
    if (!needUpdate) return;

    showDialog<String>(
      context: context,
      barrierDismissible: false,
      builder: (BuildContext context) {
        return new WillPopScope(
            child: _createPlatformAlertDialog(), 
            onWillPop: () async => false,
         );
      },
    );
  }
```

Platform別のダイアログを作るのを、`_createPlatformAlertDialog`関数に出しました。

`onWillPop: () async => false)`で、戻るボタンを押されたときの処理を何もしない、に上書きしているので、結果、ダイアログが閉じられなくなります。

# Remote Configを更新してみる

debug版のバージョンを下げてみます(実行中のアプリと同じになるようにする)。
パラメータを更新したら、右上の「変更を公開」をクリックするのを忘れずに。

更新ダイアログが出なければ、成功です。

# UnitTest

UnitTestやWidgetテストの時には、更新有無は任意で返したいし、RemoteConfigに繋ぎに行って欲しくないですよね。
ってことで、**get_it**パッケージを使ったDIとMockitoでモック化して対応します。

## 1.Mockito

`dev_dependencies`下に追加します。

```pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter

  # matcher
  matcher: ^0.12.6
  # mock
  mockito: ^3.0.0
```

## 2.モック化する

`VersionCheckService`クラスをモック化します。

```dart
/// VersionCheck
class MockVersionCheckService extends Mock implements VersionCheckService {}
```

Flutterで使うDart言語には、**暗黙的インターフェース**という機能があり、`implements`とすることで、すべてのメソッドが宣言されただけで未定義の状態になります。

それを、`extends Mock`とすることで、中身が無い空のメソッドにしてくれていると理解しておけば良いかと思います。

## 3.DIでモック化クラスを注入する

`testSetup`などの関数でまとめて初期化できるようにしておくと良いでしょう。

```dart
void testSetupLocator(
    {@required VersionCheckService versionCheckService}) {
  locator.registerSingleton(versionCheckService);
}

void cleanupLocator() {
  locator.unregister<VersionCheckService>();
}
```

これを`setUpAll`などでテストの前に1度だけ実行されるようにします。
また、`group`でテストを分けているときなどで、`testSetupLocator`が複数回呼ばれる可能性があると、既に登録済だというエラーでクラッシュするので、テストの最後にいったんクリアする必要がありそうなのでそのための関数も作りました。

※ある`group`のテストの実行だけを指示しても、実は別の`group`直下に書かれたコードは実行されてしまいます。なので、A、Bのグループのテストで`testSetupLocator`を呼んでいて、Bのグループだけ実行しようとしても、Aのグループの`testSetupLocator`が呼ばれてしまうんです。

コードで書くと、

```dart
void main() {
 group('A', () {
    testSetupLocator(); //---(a)

    testWidgets('A subTest', (WidgetTester tester) async {
      
    });
 });

 group('B', () {
    testSetupLocator(); //---(b)

    testWidgets('B subTest', (WidgetTester tester) async {
      
    });
 });
}
```

この書き方だと、`'B subTest'`だけ実行しようとしても、(a)のコードも実行された上で、(b)のコードも実行されます。
なのでダメなのです。

実際にテストが走ることが分かったタイミングで実行される、`setUpAll`等で呼ぶことが大事です。

## 4.テストで使う

実際のWidgetテストなどで使うサンプルです。

### (1)常にfalseを返す

更新ダイアログを出して欲しくないテストでは、固定の値を返して表示されないようにします。

```dart
  group('画面遷移のテスト', () {
    final mockVersionCheck = MockVersionCheckService();
    when(mockVersionCheck.versionCheck())
        .thenAnswer((_) => Future.value(false));

    setUpAll(() async {
      testSetupLocator(versionCheckService: mockVersionCheck);
    });

    tearDownAll(() {
      cleanupLocator();
    });

    test('test', () {
       // 更新ダイアログ表示に左右されないでテスト
    });
  });
```

モック化した`MockVersionCheckService`クラスの`versionCheck`は、このテストの間は常に`false`を返すように設定しています。

### (2)更新ダイアログ表示のテスト

```dart
  group('更新ダイアログの表示制御テスト', () {
    final mockVersionCheck = MockVersionCheckService();

    setUpAll(() async {
      testSetupLocator(versionCheckService: mockVersionCheck);
    });

    tearDownAll(() {
      cleanupLocator();
    });

    testWidgets('更新ダイアログ表示', (WidgetTester tester) async {
      when(mockVersionCheck.versionCheck())
          .thenAnswer((_) => Future.value(true));

      await binding.setSurfaceSize(testViewPortSize);

      await tester.pumpWidget(testMyApp('2020-02-02'));

      await tester.pumpAndSettle(); // ローディング終了を待つ

      // Alertダイアログが出ているかのチェック
      expect(find.byType(AlertDialog), findsOneWidget);
      expect(find.text('バージョン更新のお知らせ'), findsOneWidget);
      expect(find.text('新しいバージョンのアプリが利用可能です。ストアより更新版を入手して、ご利用下さい。'),
          findsOneWidget);

      expect(
          find.byWidgetPredicate((Widget widget) =>
              widget is Text &&
              widget.data == '今すぐ更新' &&
              widget.style == TextStyle(color: Colors.red)),
          findsOneWidget);
    });

    testWidgets('更新ダイアログ非表示', (WidgetTester tester) async {
      when(mockVersionCheck.versionCheck())
          .thenAnswer((_) => Future.value(false));

      await binding.setSurfaceSize(testViewPortSize);

      await tester.pumpWidget(testMyApp('2020-02-02'));

      await tester.pumpAndSettle(); // ローディング終了を待つ

      // Alertダイアログが出ていないことのチェック
      expect(find.byType(AlertDialog), findsNothing);
    });
  });
```

ダイアログが表示されるべきテストと、されないべきテストを行っています。
それには、`VerscionCheckService#checkVersion`が返す値を、`true`か`false`にするかで切り替えています。

# 感想
RemoteConfigを使ってお手軽に(?)強制アップデート対応を入れられました。
Firebaseってホントに便利。

ただ、ウィジェットテストですがiOSの場合には`CupertinoAlertDialog`デザインで出ているかが分かりませんね。
・・・とうとう、デバイステストの出番かも知れません。
もしデバイステストを書いたら別記事にアップします。

# 参考サイト

Firebase Remote Configを使ってアプリバージョンアップを実装する
https://note.com/shogoyamada/n/n08bd4d88111b

Prompt Update App Dialog In Flutter Application
https://medium.com/@naumanahmed19/prompt-update-app-dialog-in-flutter-application-4fe7a18f47f2