追記(2020年9月6日)
新手法を提案する記事を書きました!こちらも是非参考にしてみてください。
Flutterアプリのスクショを極力自動で撮る(Riverpod使用・メソッドの濫用あり)
動機
アプリをストアでリリースしようと思うと、アプリの画面を撮影したスクリーンショット、略してスクショが必要になります。
これが、そこそこの枚数必要になるんですね。
iOSアプリをAppStoreでリリースする場合
- スクショとして使いたい画面が4画面あるとして
- iPadにも対応している場合、iPhoneとそれぞれ2端末ずつの計4端末ぶん必要で(2020-07-16現在)
- 3言語対応した場合
4画面 * 4端末 * 3言語 = 48枚ものスクショが必要になります。
スクショをあとで加工して訴求力の高い画像を作成するとしても、アプリ画面を撮影する必要はあるはずです。
手動でひとつひとつ撮るのはちょ〜っと面倒くさいですね。
端末と言語の切り替えだけは手動でやるしかないとしても、画面操作は自動でやってドンドン撮って欲しいです。
(本当は言語の切り替えも自動でやって欲しかったけど、いい方法が見つかりませんでした)
なお、以前書いたこちらの記事と内容的に重複するところがあります。ご了承ください。
【Flutter】VSCode上でIntegration Testを動かす
大まかな内容
こちらの記事を大いに参考にしていきます。
https://medium.com/flutter-community/hot-reload-for-flutter-integration-tests-e0478b63bd54
これからやることを大まかに言うと
- シミュレーター上でアプリを起動する
- そのアプリをFlutterDriverをで自動操作し、スクショを撮る
という感じです。
Integration testの場合と同様、FlutterDriverでアプリを操作します。
FlutterDriver class
An introduction to integration testing
アプリの要件
アプリは次を満たすものとします。
- 日英中の3言語対応
- 表示内容がサーバー上のデータに依存
- 何かしら操作できるところがある
言語はシミュレーターの設定言語を操作することで切り替えることも出来ますが、プログラムから指定する方が手間が少なく済みます。
欲しいスクショを撮るためには、言語とサーバー上のデータをプログラムから操作してやる必要があるわけですね。
ということは、それができるようにはじめからアプリを作る必要があります。
また、もちろんアプリ自体を操作して必要な画面を作ることもあるでしょう。これはFlutterDriverから行います。
アプリ
記事の本質と関係ない細かい仕様
ソースコードを読む時に混乱しないにように、サンプルアプリの細かい仕様を説明しておきます。
-
「サーバーのデータ」はCloud Firestoreから引っ張ってくることにします。
cloud_firestore
パッケージを使います。 -
状態管理手法は、RiverpodとChangeNotifierを使ったパターンでいきます。
これらの部分はこの記事の本質とは何ら関係ないので、適当に読者の皆様ご自身の例で置き換えてください。
ソースコード
ソースはこんな感じになりました。もっと小さい例を作りたかったけど、今回の条件を満たしたらこうなってしまった。
main.dart
まずはmain.dart
です。アプリを普通に起動する場合のエントリーポイントになります。
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'my_app.dart';
// ProviderScopeはRiverpodを使うために必要
void main() => runApp(const ProviderScope(child: MyApp()));
my_app.dart
続いてmy_app.dart
です。ほぼ全体に渡って、MaterialApp
の設定をしているだけです。
出力してるのはサーバーのデータを表示するText
と、操作可能な部分を作るために取って付けたようなCounterWidget
だけ。
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'counter_widget.dart';
import 'data_notifier.dart';
class MyApp extends HookWidget {
const MyApp();
@override
Widget build(BuildContext context) {
return MaterialApp(
// 下記のMaterialAppArgsの中身を読み取る。
debugShowCheckedModeBanner:
useProvider(materialAppArgsProvider).showDebugBanner,
locale: useProvider(materialAppArgsProvider).locale,
supportedLocales: const [Locale('en'), Locale('ja'), Locale('zh')],
localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
home: Scaffold(
appBar: AppBar(),
body: Column(
children: <Widget>[
// サーバーから受け取ったデータを表示するText
Text(useProvider(dataProvider.select((p) => p.string))),
// 操作できるところを用意するために、取って付けたようなCounterWidget
CounterWidget(),
],
),
),
);
}
}
// MaterialAppウィジェットに渡す引数を管理している。
// スクショを取る場合はこれらの引数を書き換えた別のクラスを使う。
class MaterialAppArgs {
final bool showDebugBanner = true;
final Locale locale = null;
}
final Provider<MaterialAppArgs> materialAppArgsProvider =
Provider((ref) => MaterialAppArgs());
data_notifier.dart
肝心の状態管理クラスであるDataNotifier
を定義します。
DataNotifier
自体は通信をせず、通信を担当するRepository
を外からDIできるようになっているのが特徴です。
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
// サーバーから受け取ったデータを受け渡す状態管理クラスです。
// サーバーと直接通信することはありません。
// コンストラクタDIにより、サーバーと直接通信をするRepositoryを外部から受け取れるようになっています。
class DataNotifier extends ChangeNotifier {
DataNotifier({@required this.dataRepository}) {
dataRepository.dataStream.listen((event) {
string = event;
notifyListeners();
});
}
final DataRepository dataRepository;
String string = 'loading';
}
// Firestoreとの直接の通信を担当するRepositoryです。
class DataRepository {
Stream<String> get dataStream => Firestore.instance
.collection('data')
.document('1')
.snapshots()
.map((e) => e.data['string'] as String);
}
// =======ここより下は、上で作った各クラスをRiverpodを使ってprovideしているだけです。この記事の本質とは関係ないです。=======
final ChangeNotifierProvider<DataNotifier> dataProvider =
ChangeNotifierProvider((ref) {
final x = ref.read(dataRepositoryProvider);
return DataNotifier(dataRepository: x);
});
final Provider<DataRepository> dataRepositoryProvider =
Provider((ref) => DataRepository());
counter_widget.dart
最後に、操作可能な部分を作るために取って付けたようなCounterWidget
です。
単なるStatefulWidget
です。ボタンを押すと数字が増えます。
ボタンのテキストが簡易的に多言語対応されています。
import 'package:flutter/material.dart';
class CounterWidget extends StatefulWidget {
@override
_CounterWidgetState createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int counter = 0;
@override
Widget build(BuildContext context) {
// アプリで使用している言語の取得
final languageCode = Localizations.localeOf(context).languageCode;
return Column(
children: <Widget>[
Text(counter.toString()),
RaisedButton(
// FlutterDriverからこのボタンを押すため、キーを持たせておきます
key: const ValueKey('increment_button'),
// めちゃくちゃ簡易的な多言語対応
child: Text(languageCode == 'ja'
? 'インクリメント'
: languageCode == 'zh' ? '增量' : 'increment'),
onPressed: () {
setState(() {
++counter;
});
},
)
],
);
}
}
スクショ用にアプリを起動する
関連ファイルはIntegration Testにならってtest_driver
というディレクトリに置いています。変えてもいいと思います。
pubspec.yaml
必要なパッケージをインストールしておきます。
スクショ撮影時に、どの端末で撮ったものかわかるファイル名にしたいので、device_info
もインストールします。
起動用test_driver/main_for_ss.dart
provideするクラスを変更した上でMyApp()
を呼び出します。
デバッグバナーを非表示にしたり、言語を指定したり、FirestoreにアクセスしないRepository
を用意したりしています。
import 'dart:io';
import 'package:device_info/device_info.dart';
import 'package:flutter/material.dart';
import 'package:flutter_driver/driver_extension.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:stepbystep/data_notifier.dart';
import 'package:stepbystep/my_app.dart';
void main() {
// FlutterDriverからのデータ要求に対する回答を用意しておきます
// これをするとシミュレーター上でキーボードが出なくなりますがビビらないようにしましょう。
enableFlutterDriverExtension(handler: (action) async {
if (action == 'get_device_info') {
final _deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
return (await _deviceInfo.androidInfo).device;
} else {
return (await _deviceInfo.iosInfo).name;
}
} else if (action == 'get_lang_code') {
return FakeMaterialAppArgs().locale.languageCode;
} else {
return '';
}
});
// ProviderScopeはRiverpodを使うために必要
// MaterialAppArgsとDataRepositoryを変更しておきます
// ここのやり方は採用している状態管理手法によって異なります
runApp(ProviderScope(overrides: [
materialAppArgsProvider
.overrideAs(Provider((ref) => FakeMaterialAppArgs())),
dataRepositoryProvider.overrideAs(Provider((ref) => FakeDataRepository())),
], child: const MyApp()));
}
class FakeMaterialAppArgs implements MaterialAppArgs {
// 言語設定を強制的に指定します。
// ここを書き換えてHot Reload / Hot Restartすることで言語を切り替えます
// この言語変更も自動で行う方法があったら教えてもらえると嬉しいです
@override
Locale get locale => const Locale('en');
// スクショ撮るときに「DEBUG」のバナーが出ていると困るので消します
@override
bool get showDebugBanner => false;
}
// 実際のFirestoreにアクセスせず、スクショ用データを流すRepositoryです
class FakeDataRepository implements DataRepository {
@override
Stream<String> get dataStream => Stream.value('fake data for screenshots!');
}
AndroidStudio側の設定
AndroidStudio右上からEdit Configurations
を選択します。
先程作ったtest_driver/main_for_ss.dart
を指定します
オプションは--host-vmservice-port 8888 --disable-service-auth-codes
のように、スペース区切りで2つ指定します。区切りのスペースが2個重なったりすると動かなくなるので気をつけてください。その時はエラーも出ません。
--host-vmservice-port 8888
により、このポート経由で、後ほどFlutter Driver
からアプリを操作します。
(--observatory-port
はdeprecatedです)
--disable-service-auth-codes
これにより、外部から起動中のアプリにアクセスできるようになります。
起動確認
AndroidStudioから、先程作ったコンフィグmain_for_ss.dart
を選択して起動しましょう。
デバッグバナーが消え、サーバーではなくフェイクのデータを表示し、ボタン表記が英語のアプリが起動するはずです。
さらに、外部からの接続も試します。
ブラウザを開いてhttp://localhost:8888
にアクセスして、次のような画面が出ればOKです。
FlutterDriverから操作するためのコード
では、このアプリにポート経由で接続し、操作したりスクショを撮るコードを書いていきます。
import 'dart:io';
import 'package:flutter_driver/flutter_driver.dart';
Future<void> main() async {
FlutterDriver driver;
driver = await FlutterDriver.connect();
// ファイル名に含めるためのデバイス名と言語コードを取得しておきます
final _deviceName = (await driver.requestData('get_device_info'))
.replaceAll(RegExp(r'\s'), '');
final _langCode = await driver.requestData('get_lang_code');
// 1枚目
await takeScreenshot(
driver, './screenshots/${_langCode}_${_deviceName}_0_home.png');
// 10回ボタンを押す
for (var i = 0; i < 10; ++i) {
await driver.tap(find.byValueKey('increment_button'));
}
// もし画面遷移がアニメーションを伴う場合はこれで待ちます
await driver.waitForCondition(const NoPendingFrame());
// 2枚目
await takeScreenshot(
driver, './screenshots/${_langCode}_${_deviceName}_1_incremented.png');
// closeは要る!ないとプログラムの実行が終わらない。
await driver.close();
}
// スクショを撮る関数
Future<void> takeScreenshot(FlutterDriver driver, String path) async {
print('will take a screenshot $path');
final pixels = await driver.screenshot();
final file = File(path);
await file.writeAsBytes(pixels);
print('took a screenshot $file');
}
AndroidStudio側の設定
先程同様に、コンフィグを追加します。
今度はFlutter
ではなくDart Command Line App
として追加しましょう。
実行ファイルは今作ったtest_driver/take_screenshots.dart
を、
Working directoryは普通に作業ディレクトリを、
Environment variables:はVM_SERVICE_URL=http://127.0.0.1:8888/
を、それぞれ指定しましょう。
これで、FlutterDriverの接続先としてこの環境変数が使われて、localhostの8888ポートにアクセスしてくれます。
実際に動かしてみる
保存先として/screenshots
というディレクトリを作成しておきます。
シミュレーター上でmain_for_ss.dart
を起動します。既にしてたらOK。
続いてコンフィグtakescreen_shots
を選んで実行します。
これで、シミュレーター上でアプリが勝手に操作されてスクショが撮られます。コンソールに出力が出てるはずなので確認しましょう。
続いてmain_for_ss.dart
のFakeMaterialAppArgs
で指定しているlocale
を書き換えます。
@override
Locale get locale => const Locale('ja');
セーブしてHot Reloadします。シミュレーター上でボタンが日本語になったはずです。
ところで、画面上の数値が10
になったままです。このままでもよければこのまま、ダメな場合はアプリを操作するかHot Restartして値を元に戻します。
操作で元に戻せるアプリの場合は、take_screenshots.dart
のmain
の最後に元に戻す操作を仕込んでもいいですね。
準備ができたら、もう一度take_screenshots
を実行します。
アプリ自体をビルドし直さなくていいのが良い所です。
さらに言語を'zh'に書き換えて、Hot Restartが必要ならして、もう一度take_screenshots
します。
これで端末一つ分のスクショが撮れました。
シミュレーターで起動しているアプリを止めて、端末を切り替え、またmain_for_ss.dart
を実行したいところですが、このまま実行すると次のようなエラーが出てしまいます。
flutter: Observatory server failed to start after 1 tries
これが数字を変えながら並んでいます。
これは、ポート8888
が専有されたままになっているからです。この専有しているプロセスを終了させる必要があります。
ターミナルから、次のコマンドを実行します。
lsof -i :8888
すると、port8888
を使用しているプロセスの一覧が出るので、PIDを指定してkillします。
kill (PIDを指定)
テスト終了時に自動でプロセスも終了したらいいんですけどね。やり方がわからなかったです。
port8888
を開放したら、次の端末でmain_for_ss.dart
を起動し、take_screenshots
を実行します。
それを4端末ぶんのスクショが撮れるまで繰り返します。
最終的にscreenshots
ディレクトリがこうなったら終了。
変更時刻から、この作業の所要時間も見て取れますね。
最後にportを解放するのも忘れずにしておきましょう。
残る課題
これで、全部手動で撮るよりはいくらかラクになったとは思いますが、色々と課題が残りました。
解決策をご存じの方は教えていただけると嬉しいです。
- 端末切り替えのたびにXcodeビルドが走るが、それを止めて、前と同じバイナリを使いたい
- 端末の切り替えのたびにプロセスを
kill
するのが面倒。勝手に閉じて欲しい。 - 言語の切り替えも含めて自動で操作できそうじゃない??
- なんなら端末の切り替えも自動化出来ないかしら…
- そもそも論、訴求力の高いストア用スクショを作るためにやるべきことはもっと別かも??
皆さん一体どうやってストア用スクショ用意してるんでしょう。
ということで、終わります!