良いタイミングでApp StoreのFlutterアプリの評価を上げる

良いタイミングでApp StoreのFlutterアプリの評価を上げる

4.9⭐(5点満点中)の評価は、ただの見せびらかしの指標ではありません。 それは ASOのレバー であり、信頼のシグナル であり…多くの場合は タイミングの勝利 の結果でもあります。

この記事では、rate_my_app + in_app_review を使って、Flutterアプリ内でネイティブかつ配慮ある形で効率的にレビューを求める方法と、5⭐を最大化するために「いつ」行うべきかを説明します。ユーザーを苛立たせないことが最優先です。

1) なぜ評価が本当に重要なのか(スコアを超えて)

  • ASO と可視性。 App Store や Play Store のアルゴリズムはレビューの量と質に依存します。高評価はプロフィールの 発見されやすさコンバージョン率 を向上させます。
  • 社会的証明。 インストール前にユーザーは総合評価や直近のコメントを確認します。高めの平均 + プロによる返信 → 即時の 信頼
  • プロダクトループ。 レビュー(と特にプライベートなフィードバック)は どこで体験が壊れているか を示します。迅速に対応すれば、公開の1⭐を 防ぎ、ロードマップの材料になります。

よくある間違い:早すぎて要求すること。ユーザーが価値を体験する前に中断すると、逆効果になります。「いつ」「どうやって」 より難しいことが多いです。

2) 私の Flutter アプローチ:ネイティブ、管理された…そして配慮ある

製品の目的

ネイティブのレビュー(iOS/Android)ポップアップを適切なタイミングで呼び出し、控えめにリマインドし、不満なユーザーはプライベートなチャネル(フィードバック)に誘導すること。

使用する構成要素

  • in_app_review(パッケージ) :レビューの ネイティブウィンドウ を起動(iOS/macOS は StoreKit、Android は In-App Review API)。高速で UX に準拠し、ユーザーはアプリ内に留まります。
  • rate_my_app(パッケージ) :表示条件(インストール後J日、起動回数X、リマインドの間隔など)を管理します。タイミングをオーケストレーションしてスパムを避けるのに最適です。
  • flutter_riverpod(パッケージ) (オプション):ロジックを注入可能でテスト可能なサービスに 集中化 するためだけに使います。Riverpod を使わない場合は、従来のサービスクラスを作ってウィジェットから呼んでください。

このアプローチが守ること(としないこと)

  • ハラスメントはしない。 コールドな中断は行いません。エンゲージメントの高い瞬間(アクション成功、チャプター完了、小さな「ワオ」)を選びます。

  • 最終表示はプラットフォームに委ねる。

    • iOSStoreKit はユーザー/デバイスごとに365日で最大3回までに制限する可能性があり、いくら要求しても表示が制限されることがあります。タイミングは制御できますが、表示保証はできません。
    • AndroidPlay Core は要求を無視することがあります。ここでも、節度適切なタイミングが重要です。

🔎 「analytics」についての補足(任意)。 Firebase や Amplitude 等のイベントスタックがあれば、トリガーを細かく設定できます:例えば「成功アクションN回後に尋ねる」。便利ですが、最初のイテレーションには必須ではありません。

目標とする UX のメンタルモデル

  1. ポジティブな瞬間を検出 → レビューを要求(ネイティブポップアップが利用可能ならそれを表示)
  2. 拒否された場合 → 数日/数起動後に再試行(即時リマインドはしない)
  3. 微妙な反応の場合 → 公開レビューではなく内部フォーム(「バグを報告」、「詳細なフィードバックを送る」)に誘導
  4. 投稿後 → レビューに返信し、プロダクト改善(チェンジログ、迅速パッチ)に反映するループを回す

フィードバック開始 フィードバック嫌い フィードバックフォーム アプリ内で評価

3) タイミング:スパムを避けるための簡単なルール

「いつ」が「どうやって」より重要です。目的は ユーザーの流れを壊さずに5⭐を最大化すること

3.1 基本ルール(日数、起動回数、リマインド)

4つのガードレールを設定します:

const MIN_DAYS = 1;        // attendre au moins N jours après install
const MIN_LAUNCHES = 3;    // attendre N lancements de l'app
const REMIND_DAYS = 5;     // laisser N jours entre deux demandes
const REMIND_LAUNCHES = 5; // ou N lancements entre deux demandes

👉 これらの閾値は ユーザーが価値を体験する時間 を与えます。
👉 リマインドを間隔を空けることで「ハラスメント」効果を避けます。

3.2 2つの流れ:公開評価 と プライベートフィードバック

それぞれペースが異なる2つのユースケースを区別します:

const DAYS_BEFORE_ASK_IN_APP_REVIEW = 30;                 // popup native de note
const DAYS_BEFORE_ASK_IN_APP_REVIEW_FOR_FEEDBACK = 3;     // demande de feedback interne
  • 「公開評価」(ネイティブポップアップ)→ 長く間隔をあける(30日)し、表示の可否はプラットフォームに委ねる。
  • 「プライベートフィードバック」(内部フォーム)→ 早めに戻ってこれる(3日):これは聞き取りであり強制ではない。

結果として:

  • 不満ユーザーはプライベートチャネルへ 逸らされる
  • 満足ユーザーは公開レビューへ 導かれる

3.3 状態と技術的ガードレール

  • 初期化は一度だけinit() を一度だけ呼び、_isInitialized を保持する。
  • WebガードkIsWeb でストア未対応の呼び出しを防ぐ。
  • 表示条件shouldShowDialogshouldShowDialogForFeedback が表示可否の判定をカプセル化する。
  • 永続化:最後に要求した日時(ISO-8601)を保存して間隔を守る。
  • きれいなフォールバック:ネイティブポップアップが利用不可なら、ストアを開くlaunchStore())ようにする(何もしないよりベター)。

4) Flutter 実装(サービス + UI ダイアログ)

4.1 サービス RateAppService(Riverpod はオプション)

初期化、条件、イベントトラッキング、ネイティブレビュー要求、ストア遷移など、すべてのロジックをここに集約します。

⚙️ 使う前に

  • ANDROID_APP_ID / IOS_APP_IDdotenv で設定する。

  • Riverpod の注釈を使う場合はコード生成を実行する:

      dart run build_runner build -d
    
  • Storage の import をプロジェクトに合わせて調整する。

  • i18n のキー(FlutterI18n.translate)は翻訳ファイルに存在している必要がある。

RateAppService のコード

import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_i18n/flutter_i18n.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:in_app_review/in_app_review.dart';
import 'package:rate_my_app/rate_my_app.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:flutter/foundation.dart' show kIsWeb;

part 'rate_app_provider.g.dart';

const MIN_DAYS = 1;
const MIN_LAUNCHES = 3;
const REMIND_DAYS = 5;
const REMIND_LAUNCHES = 5;

const DAYS_BEFORE_ASK_IN_APP_REVIEW = 30;
const DAYS_BEFORE_ASK_IN_APP_REVIEW_FOR_FEEDBACK = 3;

/// Provider for the RateMyApp instance
@Riverpod(keepAlive: true)
RateAppService rateAppService(Ref ref) {
  return RateAppService();
}

/// Service class to handle RateMyApp functionality
class RateAppService {
  late final RateMyApp _rateMyApp;
  late final InAppReview _inAppReview;
  final Storage _storage = SharedPreferencesStorage();

  bool _isInitialized = false;

  RateAppService() {
    if (kIsWeb) return;

    _rateMyApp = RateMyApp(
      preferencesPrefix: 'rate_my_app_',
      minDays: MIN_DAYS,
      minLaunches: MIN_LAUNCHES,
      remindDays: REMIND_DAYS,
      remindLaunches: REMIND_LAUNCHES,
      googlePlayIdentifier:
          dotenv.env['ANDROID_APP_ID'],
      appStoreIdentifier: dotenv.env['IOS_APP_ID'],
    );

    _inAppReview = InAppReview.instance;
  }

  /// Initialize the RateMyApp instance
  Future<void> init() async {
    if (kIsWeb || _isInitialized) return;

    await _rateMyApp.init();
    _isInitialized = true;
  }

  /// Check if the rate dialog should be shown
  bool get shouldShowDialog =>
      !kIsWeb && _isInitialized && _rateMyApp.shouldOpenDialog;

  Future<bool> get shouldShowDialogForFeedback async {
    final lastTimeAsked =
        await _storage.read(key: "in_app_review_last_time_asked_for_feedback");

    if (lastTimeAsked != null) {
      final now = DateTime.now();
      final lastTimeAskedInDate = DateTime.parse(lastTimeAsked);
      final difference = now.difference(lastTimeAskedInDate);
      if (difference.inDays < DAYS_BEFORE_ASK_IN_APP_REVIEW_FOR_FEEDBACK) {
        return false;
      }
    }

    await _storage.write(
        key: "in_app_review_last_time_asked_for_feedback",
        value: DateTime.now().toIso8601String());

    return !kIsWeb && _isInitialized;
  }

  /// Show the rate dialog
  Future<void> showRateDialog(BuildContext context) async {
    if (kIsWeb || !_isInitialized) return;

    _rateMyApp.callEvent(RateMyAppEventType.dialogOpen);

    showDialog(
      context: context,
      builder: (context) => RateAppDialog(),
    );
  }

  /// Call a RateMyApp event
  void callEvent(RateMyAppEventType eventType) {
    if (kIsWeb || !_isInitialized) return;
    _rateMyApp.callEvent(eventType);
  }

  // InAppReview
  Future<bool> requestAppReview() async {
    if (kIsWeb || !_isInitialized) return false;

    final lastTimeAsked =
        await _storage.read(key: "in_app_review_last_time_asked");

    if (lastTimeAsked != null) {
      final now = DateTime.now();
      final lastTimeAskedInDate = DateTime.parse(lastTimeAsked);

      final difference = now.difference(lastTimeAskedInDate);
      if (difference.inDays < DAYS_BEFORE_ASK_IN_APP_REVIEW) {
        await launchStore();
        return false;
      }
    }

    if (await _inAppReview.isAvailable()) {
      _inAppReview.requestReview();
      await _storage.write(
          key: "in_app_review_last_time_asked",
          value: DateTime.now().toIso8601String());
      return true;
    }

    await launchStore();
    return false;
  }

  Future<LaunchStoreResult> launchStore() async {
    if (kIsWeb || !_isInitialized) return LaunchStoreResult.errorOccurred;

    return await _rateMyApp.launchStore();
  }
}

RateAppStep 列挙型

enum RateAppStep {
  initial,
  rateApp,
  askForFeedback,
}

RateAppDialog ウィジェット

import 'package:flutter/material.dart';
import 'package:flutter_i18n/flutter_i18n.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';

class RateAppDialog extends ConsumerStatefulWidget {
  final RateAppStep step;
  const RateAppDialog({
    super.key,
    this.step = RateAppStep.initial,
  });

  @override
  ConsumerState<RateAppDialog> createState() => _RateAppDialogState();
}

class _RateAppDialogState extends ConsumerState<RateAppDialog> {
  RateAppStep _step = RateAppStep.initial;

  @override
  void initState() {
    super.initState();

    _step = widget.step;
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    final title = switch (_step) {
      RateAppStep.initial =>
        FlutterI18n.translate(context, "rate_app_dialog.title.initial"),
      RateAppStep.rateApp => FlutterI18n.translate(
          context, "rate_app_dialog.title.rate_app"),
      RateAppStep.askForFeedback => FlutterI18n.translate(
          context, "rate_app_dialog.title.ask_for_feedback"),
    };

    final message = switch (_step) {
      RateAppStep.initial =>
        FlutterI18n.translate(context, "rate_app_dialog.message.initial"),
      RateAppStep.rateApp => FlutterI18n.translate(
          context, "rate_app_dialog.message.rate_app"),
      RateAppStep.askForFeedback => FlutterI18n.translate(
          context, "rate_app_dialog.message.ask_for_feedback"),
    };

    final image = switch (_step) {
      RateAppStep.initial => 'assets/images/dialog/rate-dialog-positive.png',
      RateAppStep.rateApp => 'assets/images/dialog/rate-dialog-positive.png',
      RateAppStep.askForFeedback =>
        'assets/images/dialog/rate-dialog-negative.png',
    };

    final forceOverflow = switch (_step) {
      RateAppStep.initial => false,
      RateAppStep.rateApp => true,
      RateAppStep.askForFeedback => true,
    };

    final buttonYes = switch (_step) {
      RateAppStep.initial => FlutterI18n.translate(
          context, "rate_app_dialog.button_yes.initial"),
      RateAppStep.rateApp => FlutterI18n.translate(
          context, "rate_app_dialog.button_yes.rate_app"),
      RateAppStep.askForFeedback => FlutterI18n.translate(
          context, "rate_app_dialog.button_yes.ask_for_feedback"),
    };

    final buttonNo = switch (_step) {
      RateAppStep.initial => FlutterI18n.translate(
          context, "rate_app_dialog.button_no.initial"),
      RateAppStep.rateApp => FlutterI18n.translate(
          context, "rate_app_dialog.button_no.rate_app"),
      RateAppStep.askForFeedback => FlutterI18n.translate(
          context, "rate_app_dialog.button_no.ask_for_feedback"),
    };

    return AlertDialog(
      contentPadding: EdgeInsets.zero,
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Padding(
            padding: const EdgeInsets.fromLTRB(32, 32, 32, 0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                Image.asset(
                  image,
                  width: 200.w,
                  height: 200.h,
                ),
                const SizedBox(height: 16),
                Text(title,
                    style: theme.textTheme.headlineSmall,
                    textAlign: TextAlign.center),
                const SizedBox(height: 8),
                Text(message,
                    style: theme.textTheme.bodyMedium?.copyWith(
                          color: theme.colorScheme.onSurface.withOpacity(0.6),
                          fontWeight: FontWeight.w300,
                        ),
                    textAlign: TextAlign.center),
              ],
            ),
          ),
          const SizedBox(height: 24),
          Divider(
            color: theme.dividerColor,
            height: 1,
          ),
        ],
      ),
      actionsAlignment: MainAxisAlignment.spaceBetween,
      actionsPadding: const EdgeInsets.symmetric(
          horizontal: 32, vertical: 24),
      actionsOverflowAlignment: OverflowBarAlignment.center,
      actionsOverflowDirection: VerticalDirection.up,
      actions: [
        TextButton(
          onPressed: () {
            switch (_step) {
              case RateAppStep.initial:
                setState(() {
                  _step = RateAppStep.askForFeedback;
                });
                ref
                    .read(rateAppServiceProvider)
                    .callEvent(RateMyAppEventType.noButtonPressed);
                break;
              case RateAppStep.rateApp:
                ref
                    .read(rateAppServiceProvider)
                    .callEvent(RateMyAppEventType.laterButtonPressed);
                Navigator.of(context).pop(false);
                break;
              case RateAppStep.askForFeedback:
                ref
                    .read(rateAppServiceProvider)
                    .callEvent(RateMyAppEventType.laterButtonPressed);
                Navigator.of(context).pop(false);
                break;
            }
          },
          child: Text(
            buttonNo,
          ),
        ),
        if (forceOverflow)
          const SizedBox(width: 48, height: 8),
        ElevatedButton(
          onPressed: () async {
            switch (_step) {
              case RateAppStep.initial:
                setState(() {
                  _step = RateAppStep.rateApp;
                });

                ref
                    .read(rateAppServiceProvider)
                    .callEvent(RateMyAppEventType.rateButtonPressed);
                await ref.read(rateAppServiceProvider).requestAppReview();
                break;
              case RateAppStep.rateApp:
                ref.read(rateAppServiceProvider);
                Navigator.of(context).pop(false);
                break;
              case RateAppStep.askForFeedback:
                Navigator.of(context).pop(false);
                context.go('/settings/contact/feedback_on_app');
                break;
            }
          },
          child: Text(
            buttonYes,
          ),
        ),
      ],
    );
  }
}


サービス側で覚えておくべき重要ポイント

  • init()shouldOpenDialog が使えるように早めに(スプラッシュ/ホーム)呼ぶ。

  • shouldShowDialograte_my_app のロジック(日数/起動回数/リマインド)に依存する。

  • requestAppReview()

    • 最近要求していないかを確認する、
    • ネイティブポップアップが利用可能なら試す、
    • そうでなければストアへフォールバック(App Store または Play Store)。
  • イベント callEvent(...):ユーザーの流れを追うため(またはアナリティクスに繋ぐため)に便利。

  • プライベートフィードバックshouldShowDialogForFeedback は要求の間隔を空け、再要求しすぎないようタイムスタンプを記録する。

5) アプリ内での使用例

目的は「ただポップアップを表示すること」ではなく、ユーザーが味方である瞬間を選ぶことです。

5.1 メイン画面で(描画後)

最初の描画直後に数秒待つことで「強引なポップイン」感を避けられます。

WidgetsBinding.instance.addPostFrameCallback((_) {
  Future.delayed(const Duration(seconds: 5), () async {
    if (!mounted) return;
    final service = ref.read(rateAppServiceProvider);
    if (service.shouldShowDialog) {
      await service.showRateDialog(context);
    }
  });
});

なぜこれが効くか:

  • コールドな中断をしない、
  • ロジック(日数/起動回数/リマインド)に従う、
  • ユーザーに一呼吸与えてから行動する。

5.2 成功したアクション後(最良のタイミング)

ユーザーが「小さな成功」を達成した直後(例:チャプター完了、エクスポート成功、設定完了)に要求をトリガーします。

Future<void> onStoryCompleted(BuildContext context, WidgetRef ref) async {
  final service = ref.read(rateAppServiceProvider);
  if (service.shouldShowDialog) {
    await service.showRateDialog(context);
  }
}

「ポジティブな瞬間」の例:

  • 目標達成(バッジ、レベル、セッション完了)、
  • 機能が成功裏に動作したとき、

5.3 不満のあるユーザーをプライベートチャネルに誘導する

RateAppDialog は「フィードバック」ブランチを扱います。ルートが存在し、シンプルで安心感のある画面になっていることを確認してください。

context.go('/settings/contact/feedback_on_app');
// dans cet écran : formulaire court, captures optionnelles, consentement, envoi asynchrone.
// Si offline, stocke en local et sync plus tard.

6) ベストプラクティスと注意点

6.1 ネイティブのクォータを尊重する

  • iOS(StoreKit)はシステムウィンドウの表示を強く制限します(ユーザー/年あたり約3回)。タイミングは制御できますが、表示は保証できません。
  • Android(In-App Review API)も要求を無視することがあります。

結果として:要求の間隔を空け、タイミングを変え、ユーザーが「いいえ」と言ったら無理に食い下がらないこと。

6.2 マイクロコピー:ユーザーを安心させる文言

  • 明確で誠実に:「1分ほどで評価できますか?アプリ改善の助けになります。」
  • 明示的な選択肢:「後で」/「評価する」(ダークパターンは避ける)。
  • 感謝の表現:拒否された場合でも必ず感謝を伝える。

6.3 アクセシビリティと UX の細部

  • フォーカスマネジメントshowDialog 後にフォーカスがタイトル/本文に行くようにする(キーボード/スクリーンリーダー)。
  • ターゲットサイズ:ボタンは快適なサイズ(≥ 44×44 pt)。
  • 色とコントラスト:WCAG に従う(通常テキストで ≥ 4.5:1)。
  • 代替テキスト/セマンティクス:ダイアログに画像を使う場合はアクセシブルな代替テキストを提供する。

6.4 重要なフローを壊さない

  • 支払い、長文入力、録音などの重要なアクション中には決して表示しない。
  • スプラッシュやログインなど最初の画面は避ける。
  • 繰り返しは避ける:ユーザーが戻ってきたときに即リトライしない。

6.5 アプリ内フィードバックチャネル(1⭐を防ぐバッファ)

  • 設定やヘルプに「バグを報告 / フィードバックを送る」リンクを目立つ場所に置く。
  • フォームは非常に短く(メッセージのみ)。
  • サポート/プロダクト側で追跡できるようにする(最初は Notion やメールでも可)。
  • ダイアログ内で「実際に返信します」と明記する。

6.6 フィーチャーフラグとロールバック

  • 要求機能はリモートフラグ(Remote Config、Firestore 等)で囲う。
  • 異常があればリリースせずに機能だけ無効化できるようにする。

6.7 アナリティクス(任意だが有用)

  • 表示率(ダイアログ表示)、クリック率(「評価する」クリック)、ストア遷移率を測る。
  • プラットフォームの制限で実際に評価が投稿されたかは分からないことがあるが、ストアページの傾向は監視できる。

6.8 テストと QA

  • iOS:ネイティブウィンドウは開発環境で表示されないことがある。TestFlight やテストアカウントで確認する。
  • Android:同様にタイミングや頻度を変えて Internal Testing で検証する。
  • デバッグ用の構成:必要なら minDays=0, minLaunches=0 のテスト設定を用意する(本番には含めない)。

デバッグ用のテスト設定例(本番に含めないでください):

RateMyApp(
  preferencesPrefix: 'rateMyApp_dev_',
  minDays: 0,
  minLaunches: 0,
  remindDays: 1,
  remindLaunches: 1,
  // ...
);

6.9 技術的なエッジケース

  • mountedshowDialog を開く前に常に確認する。
  • 再入可能性:複数トリガーが同時に走って重複表示しないよう保護する。
  • Web:未対応プラットフォームでは kIsWeb を使って何もしない。
  • ナビゲーション:ストアを開いた後の戻り状態を扱う(多くの場合不要)。

結論と次のステップ

効果的なフォーミュラ:

ポジティブな瞬間 → 配慮ある要求 → 必要ならプライベートフィードバック → 全てに返信 → 継続的改善

in_app_review でネイティブ表示、rate_my_app でテンポ管理、そして適切に配置したフィードバックダイアログにより、UX を守りながら自然に評価を向上させるシステムを作れます。

あなたのやること:

  • サービスを組み込み、ひとつだけのポジティブな瞬間を選び、小さな割合のユーザーに展開する。
  • 2週間観察して閾値(日数/起動回数)を調整し、徐々に対象を広げる。
  • 新しいレビューには 個別の返信 を書く。

💬 フィードバックを歓迎します:

  • 他に効果的だったタイミングはありますか?
  • どのダイアログ文言がコンバージョン良かったですか?
  • これを管理するために他のパッケージやパターンを使っていますか?

👉 このガイドが役に立ったら、周りのモバイル開発者に共有してください。

参考リンク

タグ

  • フラッター

  • ios

  • アンドロイド

  • AppStore Optimization

  • アプリストア最適化

  • アプリ内_レビュー

  • アプリを評価

この記事は

コメント

読み込み中...

良いタイミングでApp StoreのFlutterアプリの評価を上げる | DEMILY Clément