Flutter

Flutterアプリのストアスクショ自動化で詰まったこと全部書く

App Store / Google Play に提出するストアスクリーンショットを Flutter で自動化しました。

「自動化の手順」は世の中に記事がありますが、実際に詰まった点はほとんど書かれていません。この記事では、実装中にぶつかった問題と解決策を全部まとめます。

全体構成

最終的な構成はこうなりました。

SCREENSHOT_MODE=true でビルド

integration test が画面を遷移しながらマーカーファイルを書き込む

ホスト側スクリプトがマーカーを検知して screencap / simctl screenshot

process_images.py でデバイスフレーム + マーケティング加工

SCREENSHOT_MODE でモック化する

ストアスクショの最大の問題は「カメラ映像」です。実際のカメラ映像は毎回変わるので、決まった画面を撮るにはモックに差し替える必要があります。

--dart-define=SCREENSHOT_MODE=true をビルド時に渡すと、以下を差し替えます。

// main.dart
const isScreenshotMode = bool.fromEnvironment('SCREENSHOT_MODE');

if (isScreenshotMode) {
  // Firebase, RevenueCat等の初期化をスキップ
  // モックプロバイダーを注入
}
差し替える箇所モック内容
カメラプレビューImagen APIで生成した静止画
ギャラリーのサムネイル6種類のアセット画像
クリップデータダミー6件
Pro状態常にPro=true

カメラ映像の差し替えは MockCameraPreview ウィジェットで Image.asset('mock_camera.jpg') を返すだけですが、サムネイルは rootBundle.load() で取得する必要があります。


Maestroで詰まった問題

最初はiOS/Android共通でMaestro(YAMLで書くUIテストツール)を使っていました。

問題1: Androidの初回起動が黒画面になる

Android エミュレーター上で Flutter アプリの初回起動が遅く、Maestroが画面要素を検出しようとしたときにまだ真っ黒な状態でした。

# ❌ timeout 10秒では足りない
- extendedWaitUntil:
    visible:
      id: "onboarding_next"
    timeout: 10000

# ✅ 30秒まで伸ばして解決
- extendedWaitUntil:
    visible:
      id: "onboarding_next"
    timeout: 30000

問題2: Opacity(0) のウィジェットをAndroidで検出できない

設定画面に Opacity(opacity: 0) で非表示にした「ペイウォールを表示」ボタンがありました。iOSでは問題なく検出できていたのに、Androidでは見つけられずタップできませんでした。

// ❌ Android の Maestro では検出できない
Opacity(opacity: 0, child: ElevatedButton(...))

// ✅ 0.01 にすれば検出可能(ほぼ透明だが存在を認識できる)
Opacity(opacity: 0.01, child: ElevatedButton(...))

問題3: フレーキー(不安定)

タイミング依存のため、何度か実行すると失敗します。CI環境での安定運用が難しいと判断し、AndroidだけFlutter integration testに移行しました。


Flutter integration test への移行

なぜintegration testか

tester.pumpAndSettle() で Flutterの描画完了を確実に待てるため、タイミング問題がなくなります。

ただし、いくつかの注意点があります。

詰まり1: 無限アニメーションで pumpAndSettle がタイムアウトする

録画中を示す点滅ウィジェットが AnimationController.repeat() で無限ループしており、pumpAndSettle() が永遠に待ち続けてタイムアウトしました。

// ❌ repeat() があると pumpAndSettle が終わらない
_blinkController.repeat();

// ✅ SCREENSHOT_MODE の時は repeat しない
if (!isScreenshotMode) {
  _blinkController.repeat();
}

詰まり2: integration testからスクショが撮れない

flutter test で動くintegration testは、アプリのプロセス内で実行されます。そのため adb screencapxcrun simctl screenshot などのホスト側コマンドを直接呼べません。

解決策はマーカーファイルによるハンドシェイクです。

// integration testが「撮影してください」とマーカーを書き込む
Future<void> takeScreenshot(String name) async {
  final markerDir = Platform.isAndroid
      ? '/data/data/com.example.app.dev/cache/screenshot_markers'
      : '${Directory.systemTemp.path}/screenshot_markers';

  final marker = File('$markerDir/capture_$name');
  await marker.create(recursive: true);

  // ホスト側が撮影完了マーカーを置くまで待つ
  final doneMarker = File('$markerDir/done_$name');
  while (!doneMarker.existsSync()) {
    await Future.delayed(const Duration(milliseconds: 100));
  }
}

ホスト側のシェルスクリプトがマーカーファイルを監視し、検知したら adb exec-out screencapxcrun simctl io screenshot を実行します。


iOS特有の問題

問題: ステータスバーの設定がモーダルに効かない

xcrun simctl status_bar booted override でステータスバーを9:41表示に固定できますが、モーダル画面(ペイウォール等)では反映されません

モーダルの背後にあるカメラ画面のステータスバー色(カメラ映像の背景色)が透けて見え、スクショ上で金色っぽい帯が出てしまいました。

これはアプリ側の問題ではなく、simctl status_bar override の仕様上の制限です。process_images.py の後処理でステータスバー領域を上書きすることで対応しました。

問題: xcrun simctl get_app_container でUUIDが変わる

iOSシミュレーターのアプリコンテナパスには毎回変わるUUIDが含まれます。

/Users/xxx/Library/Developer/CoreSimulator/Devices/
  {device-uuid}/data/Containers/Data/Application/{app-uuid}/tmp/

capture_ios.sh 内で毎回 xcrun simctl get_app_container を実行してパスを取得するようにしました。

APP_CONTAINER=$(xcrun simctl get_app_container booted com.example.app.dev data)
MARKER_DIR="$APP_CONTAINER/tmp/screenshot_markers"

Android demo modeのステータスバー問題

Android 16(API 36)エミュレーターで demo mode を設定したにもかかわらず、WiFiアイコンに ! マークが表示され続けました。

原因は demo mode のコマンドで datatypenone に設定していたのに、API 36 では別の挙動をしていたためです。

# ❌ API 36で !マークが消えない
adb shell am broadcast -a com.android.systemui.demo \
  -e command network -e mobile show -e datatype none

# ✅ mobile を hide にして完全に非表示
adb shell am broadcast -a com.android.systemui.demo \
  -e command network -e mobile hide -e wifi show -e level 4

process_images.py のプラットフォーム差

Pythonで画像加工するときも差異があります。

fix_status_bars(ステータスバー領域をクリーンな画像で上書き)は、iOSではモーダルに simctl status_bar が効かないために必要な処理ですが、AndroidではdemoModeが全画面に効くため不要です。

# Android に fix_status_bars を適用すると余計な帯が入る
if platform == 'ios':
    image = fix_status_bars(image, device_config)

まとめ

問題解決策
Androidで黒画面Maestroのtimeoutを30秒に延長
Opacity(0)が検出できない0.01に変更
pumpAndSettleがタイムアウトSCREENSHOT_MODEではrepeat()しない
integration testからスクショが撮れないマーカーファイルによるハンドシェイク
iOSのモーダルでステータスバーが崩れる後処理で上書き
iOSのUUIDが毎回変わる毎回get_app_containerで取得
Android API 36でdemo modeが効かないmobileをhideに変更

ストアスクショの自動化は「一度作れば終わり」ではなく、OSバージョンアップやアプリの画面変更のたびに何かしら壊れます。問題が起きたときにどこを疑えばいいかの引き出しを増やしておくことが重要です。