Flutterアプリのスクショを極力自動で撮る(Riverpod使用・メソッドの濫用あり)


以前こんな記事を書きました。

[Flutterアプリのスクショを(極力)自動で撮る on AndroidStudio](https://qiita.com/agajo/items/2d2d57561b5880618966)

これでスクショ撮影はだいぶ簡単になった！！と思っていたのですが、

今回新たにゲーム要素のあるアプリを作成しまして、スクショ用の画面を作るためにその**ゲームを攻略する**必要が生じてしまいました。

ストア申請のために言語や端末を切り替えながら毎回ゲームを攻略するなんてとてもやってられません。

アプリコードに手を入れて数値を指定すればどうとでもなりますが、スクショを撮るためだけにあまりアプリを改造したくありません。

ということで、アプリコードを極力改造せず、必要な画面を自動的に生成しながら次々にスクショを撮る方法を確立しました。


アプリコード・スクショ撮るコードどちらも、break pointを打ってデバッグすることができます。



# 基本的な考え方

アプリの画面はアプリの状態の関数です。状態が決まれば画面が決まる。

![ui-equals-function-of-state-54b01b000694caf9da439bd3f774ef22b00e92a62d3b2ade4f2e95c8555b8ca7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/99e337a7-e97e-7010-a484-c9447186ab43.png)

画像は公式サイトのこちらから引用してます。

https://flutter.dev/docs/development/data-and-backend/state-mgmt/declarative

ということは、状態(state)さえ指定してやれば、欲しい画面は作れるということです。

今回の記事では、StateNotifierのstateと、Navigatorスタックの状態とTextEditingControllerの状態を直接指定することで、欲しい画面を生成します。

StateNotifierのstateの代わりに、ChangeNotifierのプロパティを指定することでも同様のことができると思いますが、試していません。アプリ内で使うChangeNotifierをimplementsして専用クラスを作れば良さそうな気がします。



# メソッドの濫用は自己責任で

今回の提案手法ではこちらのメソッドを、本来の使用用途を超えた使い方で使います。

https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/requestData.html

「濫用する部分があります」というレベルではなく、ほぼ話の中心です。

スクショを撮るだけの目的なら恐らく問題ないとは思いますが、何か問題が生じても筆者は責任を負いかねますので自己責任でお願いします。

# バージョン

この記事は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のバージョンアップ？？🤔


## 改悪点

 - メソッドを濫用している
 - Riverpodに依存している(なしでできるかどうかは不明)



# アプリのソースコード

こんな感じにします。

## main.dart

普通にアプリを起動する場合のエントリーポイントです。

Riverpodの`ProviderScope`で囲んだ`MyApp`を`runApp`に渡すだけです。

```dart:lib/main.dart
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`を使わないアプリならこれは不要です。

```dart:lib/my_app.dart
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`)に依存しています。

なお、記事に載せるソースコードを短くするため、**別のページへの遷移は実装していません**のでアプリとしては機能しません！(それでも全ページのスクショが撮れるのが今回の手法です)

```dart:lib/page1.dart
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`が管理しています。

```dart:lib/page2.dart
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`の初期値を外から渡せるように改造してあります。

```dart:lib/something/something_controller.dart
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`だけ。

```dart:lib/something/something_state.dart
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`関数を渡しておきます。

https://api.flutter.dev/flutter/flutter_driver_extension/enableFlutterDriverExtension.html

この`handler`は後に`FlutterDriver`の`requestData`によってアクセスでき、引数に与えられた文字列に応答して文字列を返します。

https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/requestData.html

本来これは`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`でそれを上書きします。

https://pub.dev/documentation/flutter_riverpod/latest/flutter_riverpod/UncontrolledProviderScope-class.html

### `Navigator`

`Navigator`に外部から直接アクセスするための`GlobalKey<NavigatorState>`もここで用意しています。

外部から`Navigator`の`pushAndRemoveUntil`に新しいページを渡すことで強制的にページ遷移します。

### ソースコード

```dart:take_ss/main_for_ss.dart
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`に、欲しい画面を表す文字列を渡していくことで次々に必要な画面を作り出してスクショを撮影していきます。

```dart:take_ss/take_ss.dart
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言語分撮ってみましょう。

4*3*3=36毎の画像になります。

あらかじめシミュレーターを4つ起動しておき、各シミュレーター上で

`take_ss/main_for_ss.dart`を起動 → `take_ss/take_ss.dart`でスクショを撮る

という動作をします。

`screenshots`ディレクトリの中身がこうなったら終了。ストア申請時に扱いやすいように、名前順に並び替えると言語別→デバイス別に並びます。


![スクリーンショット 2020-09-06 23.03.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/8e0e34ac-a730-c7cf-d7de-198d050c7329.png)



前回記事よりかなり早くスクショを撮り終えることが出来ました。**やったね！！**

# もっと複雑なケース

この記事では手法の肝だけを説明するため、`StateNotifier`を`extends`したクラスは作らず、クラスは同じで`State`だけを変えたインスタンスを使ってProviderのoverrideを行っていますが、実際には下記のような対処が必要になると思います。

下記のサンプルコードは、**ここまで使ったサンプルアプリとは関係ない**です。


## StateNotifierに初期化処理があり、`state`が書き換わるケース

せっかく普段と違うstateを持ったStateNotifierを作ったのに、初期化処理が走って内容を変えられてしまっては困ります。

StateNotifierである`SomethingController`を`extends`した`FakeSomethingController`を作って次のようにしましょう。

### UIから、初期化メソッドを呼んでいる時

そのメソッドをoverrideして初期化処理が走らないようにしましょう。

```dart
// NG!! こう書いてはだめ！！！！
@override
void initThisController();
```

こう書くとoverrideされていないので気をつけましょう。

正しくはこう

```dart
@override
void initThisController(){}
```


### コンストラクタ内で初期化している時

それはサブクラスからは止められないので、その後サブクラス側でまたstateを上書きすればよいでしょう。

参考
[【Dart】クイズ！extendsしたクラスのコンストラクタ実行順序！](https://qiita.com/agajo/items/23c26c71c9928f99bd2c)

## Repositoryパターンのケース

Repositoryの初期化処理が走ってデータベースへのアクセスを確保しようとし、失敗してエラーが出ることがあります。

Repositoryを`implements`した`FakeなんとかRepository`を作って、データベースへのアクセスが発生しないようにしましょう。

RepositoryをStateNotifierにコンストラクタDIしているなら、StateNotifierをoverrideする際にFakeのRepositoryを渡せばOKです。

そもそもそのStateNotifierもFakeにしている場合は、そちらには始めからFakeのRepositoryを持たせればいいですね。


```dart:例
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するとエラーが出ます。

```dart
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すると回避できます。

```dart
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ビルドが走るが、それを止めて、前と同じバイナリを使いたい
 - 端末の切り替えも自動化出来ないかしら…
 - そもそも論、訴求力の高いストア用スクショを作るためにやるべきことはもっと別かも？？

皆さん結局どうやってストア用スクショ用意してるんでしょう。

ということで、今回も終わります！