3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-07-17

追記(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です。アプリを普通に起動する場合のエントリーポイントになります。

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

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

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

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

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もインストールします。

スクリーンショット 2020-07-17 15.24.46.png

起動用test_driver/main_for_ss.dart

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

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

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を選択します。

スクリーンショット 2020-07-17 14.25.33.png

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

スクリーンショット 2020-07-17 15.14.18.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

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

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

スクリーンショット 2020-07-17 15.20.17.png

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

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

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として追加しましょう。

スクリーンショット 2020-07-17 15.53.56.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.dartFakeMaterialAppArgsで指定しているlocaleを書き換えます。

test_driver/main_for_ss.dart
  @override
  Locale get locale => const Locale('ja');

セーブしてHot Reloadします。シミュレーター上でボタンが日本語になったはずです。

ところで、画面上の数値が10になったままです。このままでもよければこのまま、ダメな場合はアプリを操作するかHot Restartして値を元に戻します。

操作で元に戻せるアプリの場合は、take_screenshots.dartmainの最後に元に戻す操作を仕込んでもいいですね。

準備ができたら、もう一度take_screenshotsを実行します。

アプリ自体をビルドし直さなくていいのが良い所です。

さらに言語を'zh'に書き換えて、Hot Restartが必要ならして、もう一度take_screenshotsします。

これで端末一つ分のスクショが撮れました。

シミュレーターで起動しているアプリを止めて、端末を切り替え、またmain_for_ss.dartを実行したいところですが、このまま実行すると次のようなエラーが出てしまいます。

スクリーンショット 2020-07-17 16.25.55.png
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ディレクトリがこうなったら終了。

スクリーンショット 2020-07-17 16.51.18.png

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

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

残る課題

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

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

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

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

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

3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?