Flutterアプリのスクショを(極力)自動で撮る on AndroidStudio

# 追記(2020年9月6日)

新手法を提案する記事を書きました！こちらも是非参考にしてみてください。

[Flutterアプリのスクショを極力自動で撮る(Riverpod使用・メソッドの濫用あり)](https://qiita.com/agajo/items/6d7d03ac22d12106da84)

# 動機

アプリをストアでリリースしようと思うと、アプリの画面を撮影したスクリーンショット、略してスクショが必要になります。

これが、そこそこの枚数必要になるんですね。

iOSアプリをAppStoreでリリースする場合

 - スクショとして使いたい画面が4画面あるとして
 - iPadにも対応している場合、iPhoneとそれぞれ2端末ずつの計4端末ぶん必要で(2020-07-16現在)
 - 3言語対応した場合

4画面 * 4端末 * 3言語 = 48枚ものスクショが必要になります。

スクショをあとで加工して訴求力の高い画像を作成するとしても、アプリ画面を撮影する必要はあるはずです。

手動でひとつひとつ撮るのはちょ〜っと面倒くさいですね。



端末と言語の切り替えだけは手動でやるしかないとしても、画面操作は自動でやってドンドン撮って欲しいです。

(本当は言語の切り替えも自動でやって欲しかったけど、いい方法が見つかりませんでした)

なお、以前書いたこちらの記事と内容的に重複するところがあります。ご了承ください。

[【Flutter】VSCode上でIntegration Testを動かす
](https://qiita.com/agajo/items/b3a7afa07040c0f7132c)

# 大まかな内容

こちらの記事を大いに参考にしていきます。
https://medium.com/flutter-community/hot-reload-for-flutter-integration-tests-e0478b63bd54

これからやることを大まかに言うと

 - シミュレーター上でアプリを起動する
 - そのアプリをFlutterDriverをで自動操作し、スクショを撮る

という感じです。

Integration testの場合と同様、FlutterDriverでアプリを操作します。
[FlutterDriver class](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver-class.html)
[An introduction to integration testing](https://flutter.dev/docs/cookbook/testing/integration/introduction)


## アプリの要件

アプリは次を満たすものとします。

 - 日英中の3言語対応
 - 表示内容がサーバー上のデータに依存
 - 何かしら操作できるところがある

言語はシミュレーターの設定言語を操作することで切り替えることも出来ますが、プログラムから指定する方が手間が少なく済みます。

欲しいスクショを撮るためには、言語とサーバー上のデータをプログラムから操作してやる必要があるわけですね。

ということは、それができるようにはじめからアプリを作る必要があります。


また、もちろんアプリ自体を操作して必要な画面を作ることもあるでしょう。これはFlutterDriverから行います。



# アプリ

## 記事の本質と関係ない細かい仕様

ソースコードを読む時に混乱しないにように、サンプルアプリの細かい仕様を説明しておきます。


 - 「サーバーのデータ」はCloud Firestoreから引っ張ってくることにします。`cloud_firestore`パッケージを使います。

 - 状態管理手法は、[Riverpod](https://riverpod.dev/)とChangeNotifierを使ったパターンでいきます。

これらの部分はこの記事の本質とは何ら関係ないので、適当に読者の皆様ご自身の例で置き換えてください。


## ソースコード

ソースはこんな感じになりました。もっと小さい例を作りたかったけど、今回の条件を満たしたらこうなってしまった。

### `main.dart`

まずは`main.dart`です。アプリを**普通に起動する場合の**エントリーポイントになります。

```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`だけ。

```dart: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 '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できるようになっているのが特徴です。

```dart:data_notifier.dart
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`です。ボタンを押すと数字が増えます。

ボタンのテキストが簡易的に**多言語対応**されています。

```dart:counter_widget.dart
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`もインストールします。

<img width="216" alt="スクリーンショット 2020-07-17 15.24.46.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/9072d672-7a11-e2af-26ea-4b08a2be80fa.png">



## 起動用`test_driver/main_for_ss.dart`

provideするクラスを変更した上で`MyApp()`を呼び出します。

デバッグバナーを非表示にしたり、言語を指定したり、Firestoreにアクセスしない`Repository`を用意したりしています。

```dart:test_driver/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/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`を選択します。

<img width="591" alt="スクリーンショット 2020-07-17 14.25.33.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/c8d285f3-ed08-5676-6792-e9f93c16d735.png">

先程作った`test_driver/main_for_ss.dart`を指定します

<img width="864" alt="スクリーンショット 2020-07-17 15.14.18.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/638f71ab-488d-ad6d-e78a-0a847b231d43.png">



オプションは`--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`を選択して起動しましょう。

デバッグバナーが消え、サーバーではなくフェイクのデータを表示し、ボタン表記が英語のアプリが起動するはずです。

![スクリーンショット 2020-07-17 15.18.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/bd799ee3-c94a-0a4d-587c-31522dc82f1e.png)


さらに、外部からの接続も試します。

ブラウザを開いて`http://localhost:8888`にアクセスして、次のような画面が出ればOKです。

<img width="1336" alt="スクリーンショット 2020-07-17 15.20.17.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/eaac5850-e943-d0e5-526a-f1a2b79bf02f.png">




# FlutterDriverから操作するためのコード

では、このアプリにポート経由で接続し、操作したりスクショを撮るコードを書いていきます。

```dart:take_screenshots.dart
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`として追加しましょう。

<img width="732" alt="スクリーンショット 2020-07-17 15.53.56.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/26c5d474-0e33-f354-360a-d1cc76f514f4.png">

実行ファイルは今作った`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`を書き換えます。

```dart:test_driver/main_for_ss.dart
  @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`を実行したいところですが、このまま実行すると次のようなエラーが出てしまいます。

<img width="556" alt="スクリーンショット 2020-07-17 16.25.55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/c436807f-4222-528f-3905-1fd4f57f152f.png">


```
flutter: Observatory server failed to start after 1 tries
```

これが数字を変えながら並んでいます。

これは、ポート`8888`が専有されたままになっているからです。この専有しているプロセスを終了させる必要があります。


ターミナルから、次のコマンドを実行します。

```
lsof -i :8888
```

すると、port`8888`を使用しているプロセスの一覧が出るので、PIDを指定してkillします。

```
kill (PIDを指定)
```

テスト終了時に自動でプロセスも終了したらいいんですけどね。やり方がわからなかったです。


port`8888`を開放したら、次の端末で`main_for_ss.dart`を起動し、`take_screenshots`を実行します。

それを4端末ぶんのスクショが撮れるまで繰り返します。

最終的に`screenshots`ディレクトリがこうなったら終了。

<img width="571" alt="スクリーンショット 2020-07-17 16.51.18.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/5a895de3-d08d-f181-a154-6eb59d3c6011.png">


変更時刻から、この作業の所要時間も見て取れますね。

最後にportを解放するのも忘れずにしておきましょう。



# 残る課題

これで、全部手動で撮るよりはいくらかラクになったとは思いますが、色々と課題が残りました。

解決策をご存じの方は教えていただけると嬉しいです。

 - 端末切り替えのたびにXcodeビルドが走るが、それを止めて、前と同じバイナリを使いたい
 - 端末の切り替えのたびにプロセスを`kill`するのが面倒。勝手に閉じて欲しい。
 - 言語の切り替えも含めて自動で操作できそうじゃない？？
 - なんなら端末の切り替えも自動化出来ないかしら…
 - そもそも論、訴求力の高いストア用スクショを作るためにやるべきことはもっと別かも？？


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


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