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 screencap や xcrun 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 screencap や xcrun 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 のコマンドで datatype を none に設定していたのに、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バージョンアップやアプリの画面変更のたびに何かしら壊れます。問題が起きたときにどこを疑えばいいかの引き出しを増やしておくことが重要です。