以前こんな記事を書きました。
Flutterアプリのスクショを(極力)自動で撮る on AndroidStudio
これでスクショ撮影はだいぶ簡単になった!!と思っていたのですが、
今回新たにゲーム要素のあるアプリを作成しまして、スクショ用の画面を作るためにそのゲームを攻略する必要が生じてしまいました。
ストア申請のために言語や端末を切り替えながら毎回ゲームを攻略するなんてとてもやってられません。
アプリコードに手を入れて数値を指定すればどうとでもなりますが、スクショを撮るためだけにあまりアプリを改造したくありません。
ということで、アプリコードを極力改造せず、必要な画面を自動的に生成しながら次々にスクショを撮る方法を確立しました。
アプリコード・スクショ撮るコードどちらも、break pointを打ってデバッグすることができます。
基本的な考え方
アプリの画面はアプリの状態の関数です。状態が決まれば画面が決まる。
画像は公式サイトのこちらから引用してます。
ということは、状態(state)さえ指定してやれば、欲しい画面は作れるということです。
今回の記事では、StateNotifierのstateと、Navigatorスタックの状態とTextEditingControllerの状態を直接指定することで、欲しい画面を生成します。
StateNotifierのstateの代わりに、ChangeNotifierのプロパティを指定することでも同様のことができると思いますが、試していません。アプリ内で使うChangeNotifierをimplementsして専用クラスを作れば良さそうな気がします。
メソッドの濫用は自己責任で
今回の提案手法ではこちらのメソッドを、本来の使用用途を超えた使い方で使います。
「濫用する部分があります」というレベルではなく、ほぼ話の中心です。
スクショを撮るだけの目的なら恐らく問題ないとは思いますが、何か問題が生じても筆者は責任を負いかねますので自己責任でお願いします。
バージョン
この記事は2020年9月6日に書いており、下記のバージョンを使用しています。
NavigatorとRiverpodは使い方が大きく変更される可能性がそこそこあるので気をつけてください。
- flutter 1.20.2 (stable)
- hooks_riverpod 0.9.1
- flutter_hooks 0.13.2
- state_notifier 0.6.0
アプリの仕様
- Riverpod + StateNotifier + freezed で状態管理
- 日英中の3言語対応
- ページが複数あり、Navigatorを使って遷移する
- テキスト入力欄がある
Riverpodの機能を積極的に使います。
StateNotifier+freezedを使わず、ChangeNotifierを使ったパターンでも同様のことはできると思います。
一方、Riverpodなしで同様のことができるかどうかはわかりません。
前回記事の手法との違い
前回記事を読んでくださった方向けに述べますと…
改善点
- 言語切替も自動でできるようになった(かなり嬉しい!)
- 画面の状態を直接指定するのでアプリを操作する必要がなくなった
- Terminalからプロセスキルする必要が何故かなくなった。
- AndroidStudioの右上の
Stop All
を押せばOK - 何故?AndroidStudioのバージョンアップ??🤔
- AndroidStudioの右上の
改悪点
- メソッドを濫用している
- Riverpodに依存している(なしでできるかどうかは不明)
アプリのソースコード
こんな感じにします。
main.dart
普通にアプリを起動する場合のエントリーポイントです。
RiverpodのProviderScope
で囲んだMyApp
をrunApp
に渡すだけです。
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'my_app.dart';
void main() {
runApp(const ProviderScope(child: MyApp()));
}
my_app.dart
MaterialApp
に必要な引数を渡してるだけです。
debugShowCheckedModeBanner
とlocale
は、スクショ撮影時にいじれるように、Providerを使って渡すように改造しています。
また、外からNavigator
を操作できるように、コンストラクタで受け取ったGlobalKey
をMaterialApp
のnavigatorKey
に渡しています。Navigator
を使わないアプリならこれは不要です。
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 'page1.dart';
// MaterialAppの引数の変更に応答して画面を変化させるため、HookWidgetにしておきます。
class MyApp extends HookWidget {
// 外からNavigatorを操作するためのGlobalKeyをコンストラクタで受け取れるようにします。
const MyApp({this.navigatorKey});
final GlobalKey<NavigatorState> navigatorKey;
@override
Widget build(BuildContext context) {
final materialAppArgs = useProvider(materialAppArgsProvider);
return MaterialApp(
navigatorKey: navigatorKey,
debugShowCheckedModeBanner: materialAppArgs.showDebugBanner,
locale: materialAppArgs.locale,
supportedLocales: const [Locale('en'), Locale('ja'), Locale('zh')],
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
home: const Page1(),
);
}
}
// MaterialAppに与える引数を書き換えられるようにprovideしておきます。
final materialAppArgsProvider = Provider((ref) => const MaterialAppArgs());
class MaterialAppArgs {
// showDebugBannerはnullだとエラー。普段はtrueにします。スクショを撮る時はfalse。
// localeはnullでもいい。その場合デバイスの設定言語が使われます。
const MaterialAppArgs({bool showDebugBanner, this.locale})
: showDebugBanner = showDebugBanner ?? true;
final bool showDebugBanner;
final Locale locale;
}
page1.dart
アプリ画面本体の1つ目です。
画面の表示内容はデバイスの設定言語と、StateNotifier
をextends
したSomethingController
クラス(のstate
)に依存しています。
なお、記事に載せるソースコードを短くするため、別のページへの遷移は実装していませんのでアプリとしては機能しません!(それでも全ページのスクショが撮れるのが今回の手法です)
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'something/something_controller.dart';
class Page1 extends HookWidget {
const Page1();
@override
Widget build(BuildContext context) {
final a = useProvider(somethingProvider.state).a;
final languageCode = Localizations.localeOf(context).languageCode;
return Scaffold(
appBar: AppBar(
title: const Text('Page 1'),
),
body: Column(
children: [
// MyControllerのstateに依存
Text('a: $a'),
// とってつけた多言語対応要素
Text(languageCode == 'zh'
? '你好谢谢'
: languageCode == 'ja' ? 'こんにちはありがとう' : 'Hello. Thank you.'),
],
),
);
}
}
final somethingProvider = StateNotifierProvider((ref) => SomethingController());
page2.dart
アプリ本体のページ2つ目です。StatelessWidget
です。
TextField
があり、そこに渡すTextEditingController
はSomethingController
が管理しています。
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:stepbystep/page1.dart';
class Page2 extends StatelessWidget {
const Page2();
@override
Widget build(BuildContext context) {
final languageCode = Localizations.localeOf(context).languageCode;
final somethingController = context.read(somethingProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Page 2'),
),
// とってつけた多言語対応要素
body: Column(
children: [
Text(languageCode == 'zh'
? '真的吗?'
: languageCode == 'ja' ? 'マジ?' : 'Really?'),
TextField(controller: somethingController.textController),
],
),
);
}
}
something_controller.dart
このアプリ唯一のStateNotifier
です。でも別に今回の手法はStateNotifier
がいくつあっても同様に適用できます。
Page1
の表示内容と、Page2
のTextField
に渡すTextEditingController
を管理しています。
スクショ撮影のため、TextEditingController
のtext
の初期値を外から渡せるように改造してあります。
import 'package:flutter/material.dart';
import 'package:state_notifier/state_notifier.dart';
import 'something_state.dart';
class SomethingController extends StateNotifier<SomethingState> {
// TextEditingControllerのtextの初期値を外から受け取れるように改造します。
SomethingController({SomethingState state, String textValue})
: textController = TextEditingController(text: textValue),
super(state ?? SomethingState());
final TextEditingController textController;
}
something_state.dart
freezed
用の元ファイルです。プロパティはa
だけ。
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';
part 'something_state.freezed.dart';
@freezed
abstract class SomethingState with _$SomethingState {
factory SomethingState({
@Default(0) int a,
}) = _SomethingState;
}
スクショ撮影用ソースコード
ここからは、スクショ撮影専用のソースコードを紹介します。
ここがこの記事のメインです。
以下の2ファイルはlib
ではなくtake_ss
というディレクトリを作ってそこに置いてます。
main_for_ss.dart
スクショ撮影用にアプリを起動する際のエントリーポイントです。
これを使って、スクショ撮影専用の設定でアプリを起動します。
enableFlutterDriverExtension
とrequestData
FlutterDriverパッケージにあるenableFlutterDriverExtension
にhandler
関数を渡しておきます。
このhandler
は後にFlutterDriver
のrequestData
によってアクセスでき、引数に与えられた文字列に応答して文字列を返します。
本来これはrequestData
という名の通り、FlutterDriver
を使ったintegration testの最中にアプリからデータを取得してテストするためのものなのですが、
今回はこの仕組みを悪用して、handler
に副作用を持たせてアプリの状態をいじりまくります。
handler
関数に渡された文字列に対し、アプリ画面の状態を定義します。
画面状態を表す文字列
今回は1-2-ja
というハイフン区切りの文字列で「Page1
の2つ目の画像で、言語は日本語」という画面を表すことにします。
ProviderContainer
とUncontrolledProviderScope
Riverpodでは、すべてのProvider
はProviderContainer
という所で管理されます。
通常はWidgetツリー全体をProviderScope
の傘下に入れることで、ProviderContainer
が自動的に用意されるので意識することはありません。
ですが今回はProvider
を直接いじりたいので、外から触れるProviderContainer
が必要です。
ProviderScope
の代わりにUncontrolledProviderScope
を使うと、外部で用意したProviderContainer
を渡すことができるのでこれを利用します。
後からoverrideする予定のProvider
は、ProviderScope
の生成時点で一度overrideしておく必要があります。後にupdateOverrides
でそれを上書きします。
Navigator
Navigator
に外部から直接アクセスするためのGlobalKey<NavigatorState>
もここで用意しています。
外部からNavigator
のpushAndRemoveUntil
に新しいページを渡すことで強制的にページ遷移します。
ソースコード
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/my_app.dart';
import 'package:stepbystep/something/something_controller.dart';
import 'package:stepbystep/something/something_state.dart';
import 'package:stepbystep/page1.dart';
import 'package:stepbystep/page2.dart';
void main() {
// 後にoverrideし直す予定のProviderははじめからoverrideしておく必要がある。
final providerContainer = ProviderContainer(overrides: [
somethingProvider
.overrideWithValue(SomethingController(state: SomethingState(a: 50))),
materialAppArgsProvider.overrideWithValue(
const MaterialAppArgs(showDebugBanner: false, locale: Locale('en'))),
]);
// Navigatorにアクセスするためのグローバルキー
final navigatorKey = GlobalKey<NavigatorState>();
enableFlutterDriverExtension(handler: (action) async {
// このget_device_infoの場合だけ、この機能本来の使い方
if (action == 'get_device_info') {
final _deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
return (await _deviceInfo.androidInfo).device;
} else {
return (await _deviceInfo.iosInfo).name;
}
// ここから、スクショ用の画面を定義していきます
// actionに渡ってきた文字列によって画面を場合分け。
} else if (action.startsWith('1-')) {
Navigator.of(navigatorKey.currentContext).pushAndRemoveUntil<void>(
MaterialPageRoute(builder: (_) => const Page1()), (route) => false);
if (action.startsWith('1-1-')) {
providerContainer.updateOverrides([
somethingProvider.overrideWithValue(
SomethingController(state: SomethingState(a: 100))),
]);
} else if (action.startsWith('1-2-')) {
providerContainer.updateOverrides([
somethingProvider.overrideWithValue(
SomethingController(state: SomethingState(a: 3141592))),
]);
}
} else if (action.startsWith('2-')) {
Navigator.of(navigatorKey.currentContext).pushAndRemoveUntil<void>(
MaterialPageRoute(builder: (_) => const Page2()), (route) => false);
// TextFieldの初期値を指定して、入力中の文字列をセットします。
providerContainer.updateOverrides([
somethingProvider
.overrideWithValue(SomethingController(textValue: 'OH! YEAH!!')),
]);
}
// 言語を切り替えます
final lang = action.substring(action.length - 2);
providerContainer.updateOverrides([
materialAppArgsProvider.overrideWithValue(
MaterialAppArgs(showDebugBanner: false, locale: Locale(lang))),
]);
return '';
});
// UncontrolledProviderScopeには、自分で用意したProviderContainerを渡すことが出来ます。
// そのProviderScopeを使って外から各種Providerにアクセスします。
runApp(UncontrolledProviderScope(
container: providerContainer,
child: MyApp(
// NavigatorにアクセスするためのGlobalKey
navigatorKey: navigatorKey,
)));
}
take_ss.dart
最後に、このDartプログラムを実行することでスクショを撮影します。
FlutterDriver
のrequestData
に、欲しい画面を表す文字列を渡していくことで次々に必要な画面を作り出してスクショを撮影していきます。
import 'dart:async';
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'), '');
for (final lang in ['ja', 'zh', 'en']) {
// アプリ状態を操作するメソッドを呼び出す
await driver.requestData('1-1-$lang');
await takeScreenshot(
driver, './screenshots/${lang}_${_deviceName}_1_1.png');
// もし画面遷移がアニメーションを伴う場合はこれで待ちます。多くの場合不要です。
await driver.waitForCondition(const NoPendingFrame());
}
for (final lang in ['ja', 'zh', 'en']) {
await driver.requestData('1-2-$lang');
await takeScreenshot(
driver, './screenshots/${lang}_${_deviceName}_1_2.png');
await driver.waitForCondition(const NoPendingFrame());
}
for (final lang in ['ja', 'zh', 'en']) {
await driver.requestData('2-1-$lang');
await takeScreenshot(
driver, './screenshots/${lang}_${_deviceName}_2_1.png');
await driver.waitForCondition(const NoPendingFrame());
}
// このcloseは必要です。ないとプログラムの実行が終わらない。
await driver.close();
}
// スクショを撮る関数
Future<void> takeScreenshot(FlutterDriver driver, String path) async {
final pixels = await driver.screenshot();
final file = File(path);
await file.writeAsBytes(pixels);
print('took a screenshot $file');
}
エディタ側の設定
上で紹介した「前回記事」とまったく同じなので、リンクを貼って割愛します。AndroidStudioの場合の説明しかありませんので、VSCodeやその他の方はうまく応用してください🙏
main_for_ss.dart
の起動設定
https://qiita.com/agajo/items/2d2d57561b5880618966#androidstudio%E5%81%B4%E3%81%AE%E8%A8%AD%E5%AE%9A
take_ss.dart
の起動設定
https://qiita.com/agajo/items/2d2d57561b5880618966#androidstudio%E5%81%B4%E3%81%AE%E8%A8%AD%E5%AE%9A-1
さぁ、撮ってみよう!
保存先としてscreenshots
というディレクトリを作成しておきます。
iPhone2つとiPad2つの計4デバイスで、3つの場面を、3言語分撮ってみましょう。
433=36毎の画像になります。
あらかじめシミュレーターを4つ起動しておき、各シミュレーター上で
take_ss/main_for_ss.dart
を起動 → take_ss/take_ss.dart
でスクショを撮る
という動作をします。
screenshots
ディレクトリの中身がこうなったら終了。ストア申請時に扱いやすいように、名前順に並び替えると言語別→デバイス別に並びます。
前回記事よりかなり早くスクショを撮り終えることが出来ました。やったね!!
もっと複雑なケース
この記事では手法の肝だけを説明するため、StateNotifier
をextends
したクラスは作らず、クラスは同じでState
だけを変えたインスタンスを使ってProviderのoverrideを行っていますが、実際には下記のような対処が必要になると思います。
下記のサンプルコードは、ここまで使ったサンプルアプリとは関係ないです。
StateNotifierに初期化処理があり、state
が書き換わるケース
せっかく普段と違うstateを持ったStateNotifierを作ったのに、初期化処理が走って内容を変えられてしまっては困ります。
StateNotifierであるSomethingController
をextends
したFakeSomethingController
を作って次のようにしましょう。
UIから、初期化メソッドを呼んでいる時
そのメソッドをoverrideして初期化処理が走らないようにしましょう。
// NG!! こう書いてはだめ!!!!
@override
void initThisController();
こう書くとoverrideされていないので気をつけましょう。
正しくはこう
@override
void initThisController(){}
コンストラクタ内で初期化している時
それはサブクラスからは止められないので、その後サブクラス側でまたstateを上書きすればよいでしょう。
参考
【Dart】クイズ!extendsしたクラスのコンストラクタ実行順序!
Repositoryパターンのケース
Repositoryの初期化処理が走ってデータベースへのアクセスを確保しようとし、失敗してエラーが出ることがあります。
Repositoryをimplements
したFakeなんとかRepository
を作って、データベースへのアクセスが発生しないようにしましょう。
RepositoryをStateNotifierにコンストラクタDIしているなら、StateNotifierをoverrideする際にFakeのRepositoryを渡せばOKです。
そもそもそのStateNotifierもFakeにしている場合は、そちらには始めからFakeのRepositoryを持たせればいいですね。
class FakeRankingController extends RankingController {
FakeRankingController({RankingState state})
: super(
state: state,
// はじめからFakeRepositoryを持たせる
rankingRepository: FakeRankingRepository());
// 初期化処理で何もしないようにする
@override
Future<void> initThisController() {}
}
class FakeRankingRepository implements RankingRepository {
// データベースへのアクセスをしないようにする
@override
Future<List<Duration>> getData() {}
}
このFakeRankingController
を使ってProviderのoverrideを行えばOKです。
別のProviderに依存するProviderがあり、その両方をoverrideしたいケース
gameControllerProvider
がtimerProvider
に依存している場合、このように両方まとめてoverrideするとエラーが出ます。
providerContainer.updateOverrides([
timerProvider.overrideWithValue(StateController(Duration.zero)),
gameControllerProvider
.overrideWithValue(FakeGameController(state: GameState())),
]);
Error in Flutter application: Uncaught extension error while executing request_data:
'package:riverpod/src/framework/container.dart': Failed assertion: line 247 pos 9:
'unusedOverrides.isEmpty': Updated the list of overrides with providers that were not overriden before
「一度もoverrideしていないproviderのoverrideをupdateしようとしています。」というエラーが出ていますが、これは正しくないです。というのも、ProviderContainer
を宣言した時点で、該当providerは全て一度overrideしています。
エラーメッセージが食い違っていますので、Riverpodのバグなのかもしれません。(2020年9月8日時点)
このように一つずつoverrideすると回避できます。
providerContainer
..updateOverrides([
timerProvider.overrideWithValue(StateController(Duration.zero)),
])
..updateOverrides([
gameControllerProvider
.overrideWithValue(FakeGameController(state: GameState())),
]);
原因の仮説
timerProvider
をoverrideすると、それに依存しているgameControllerProvider
も自動的にoverrideされます。その瞬間、「override可能なproviderのリスト」的な所からgameControllerProvider
が除去されてしまい、その後改めてgameControllerProvider
をoverrideする際にエラーになるものと思われます。
これは順番を入れ替えても解決しません。
どちらのproviderも指定した値でoverrideしたいので、これでは困ってしまいますね。上記のように2回に分ければ解決できます。
まだ残る課題
- 端末切り替えのたびにXcodeビルドが走るが、それを止めて、前と同じバイナリを使いたい
- 端末の切り替えも自動化出来ないかしら…
- そもそも論、訴求力の高いストア用スクショを作るためにやるべきことはもっと別かも??
皆さん結局どうやってストア用スクショ用意してるんでしょう。
ということで、今回も終わります!