Flutterアプリをクローズドアルファ版に登録してから、本番公開するまでに絶対に入れておいた方が良いだろうという機能を思い出したので、慌てて作りました(汗)
いわゆる「強制アップデート機能」です。
「新しいバージョンがあるからアップデートしてね。アップデートしないと使わせないよ」ってやつです。
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)
Firebase RemoteConfigの設定
以下の2つを定義しました。
整数値比較の方が楽なので、AndroidでいうVersionCode
を入れていくようにします。
- 強制アップデート対象のバージョンコード
- debug版アプリの最新版のバージョンコード
これより低い値のアプリを起動すると、「アップデートして下さい」という表示を出すことにします。
iOSとAndroidで別々に管理する方法もありますが、Flutterの場合恐らくどちらも同時にアップデートしていくことになると思うので、共有で使うことにします。
設定し終えたら、右上の「変更を公開」ボタンをクリックしましょう。
Flutterアプリの設定
1.依存パッケージ
以下のパッケージを使います。
-
package_info
- パッケージ情報からVersionCode(BuildNumber)を簡単に取得するために使用
-
url_launcher
- Playストア、Appストアを開くために使用
-
firebase_remote_config
- Firebase RemoteConfigの為に使用
-
get_it
- シングルトンなサービスをDIで利用できるようにするため使用
(1)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]をクリックしておくか、コマンドラインで以下を実行しておきます。
$ flutter pub get
2.RemoteConfigからバージョンを取得して比較
(1)コンフィグ名の決定
デバッグビルド版とリリース版はコンフィグ名を分けていました。なのでデバッグビルドかどうかを判定してコンフィグ名を決めなければなりません。
bool.fromEnvironment('dart.vm.product')
がリリースビルドでtrue
を返すのを利用します。
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
から取得します。
// versionCode(buildNumber)取得
final PackageInfo info = await PackageInfo.fromPlatform();
int currentVersion = int.parse(info.buildNumber);
(3)RemoteConfigから取得
// 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します。
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登録で使います。
GetIt locator = GetIt.instance;
void setupLocator() {
...
// 追加
locator.registerLazySingleton<VersionCheckService>(() => VersionCheckService());
}
void main() async {
setupLocator();
...
// 描画の開始
runApp(...);
}
3.更新ダイアログ表示
(1)アップデートダイアログを出す専用のWidgetクラスを作る
これはあくまでもアプローチの1例です。
他のアプリを作ったときに使い回しやすいように、専用のウィジェットクラスにしました。
ダイアログを出すのが仕事なので、返すウィジェットはダミーのものになります。
また、このウィジェットはStatefulWidget
の方が都合が良いのでそうしています。
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
は以下のような関数です。
/// 指定の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
ウィジェットを、メインウィジェットに組み込みます。
@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版
[今すぐ更新]をタップすると、Playストアアプリが起動します。今はIDが不正なのでちゃんとページは開きませんが。
- iOS版
さあ実行しよう!と思ったら・・・
クラッシュしましたT_T
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
関連は動かなくても当然ですね。
/// アプリのルートウィジェット
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, // 追加
],
....
これでもう一度実行。
出ました!
Android版の端末の戻るボタンでダイアログが閉じてしまう
このままだと、Androidではダイアログが閉じられてしまいます。端末の戻るボタンで自動的にPopされちゃうからですね。
端末の戻るボタンの処理を上書きするのには、WillPopScope
というのを使います。
これでAlertDialogを出しているところを括ってしまいます。
/// 更新版案内ダイアログを表示
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
下に追加します。
dev_dependencies:
flutter_test:
sdk: flutter
# matcher
matcher: ^0.12.6
# mock
mockito: ^3.0.0
2.モック化する
VersionCheckService
クラスをモック化します。
/// VersionCheck
class MockVersionCheckService extends Mock implements VersionCheckService {}
Flutterで使うDart言語には、暗黙的インターフェースという機能があり、implements
とすることで、すべてのメソッドが宣言されただけで未定義の状態になります。
それを、extends Mock
とすることで、中身が無い空のメソッドにしてくれていると理解しておけば良いかと思います。
3.DIでモック化クラスを注入する
testSetup
などの関数でまとめて初期化できるようにしておくと良いでしょう。
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
が呼ばれてしまうんです。
コードで書くと、
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を返す
更新ダイアログを出して欲しくないテストでは、固定の値を返して表示されないようにします。
group('画面遷移のテスト', () {
final mockVersionCheck = MockVersionCheckService();
when(mockVersionCheck.versionCheck())
.thenAnswer((_) => Future.value(false));
setUpAll(() async {
testSetupLocator(versionCheckService: mockVersionCheck);
});
tearDownAll(() {
cleanupLocator();
});
test('test', () {
// 更新ダイアログ表示に左右されないでテスト
});
});
モック化したMockVersionCheckService
クラスのversionCheck
は、このテストの間は常にfalse
を返すように設定しています。
(2)更新ダイアログ表示のテスト
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