癒し系の自作アプリに Play の In-App Review を組み込んだ日のことを、今でもよく覚えています。launchReviewFlow を呼ぶと addOnCompleteListener がきちんと成功で返ってきます。ログにも「review flow finished」と出ます。なのに、画面には星のダイアログが一度も出てこないのです。
実機を変え、アカウントを変え、何十回と試しても同じでした。コードは正しく動いているように見えるのに、肝心のレビュー導線だけが沈黙している。個人開発で長くアプリを触ってきた私自身でも、このときは半日ほど「自分の実装が壊れている」と思い込んで原因を探し続けました。
結論から言えば、壊れていたのは実装ではなく、私の前提のほうでした。Play In-App Review は「呼べば出る」API ではありません。出すかどうかを最終的に決めるのは Google 側で、しかもその判断はこちらからは見えません。この記事は、その「表示保証なし」を前提に置いたときに初めて正しく組める発火設計を、実装コードとあわせてまとめたものです。
「成功コールバック」が表示保証にならないという契約
最初に頭を入れ替えるべきなのは、launchReviewFlow の完了が「ダイアログが表示された」ことを意味しない、という点です。公式の挙動として、レビューフローが実際に表示されたかどうかをアプリ側に伝える API は存在しません。完了リスナーが返すのは「フローが終わった」という事実だけで、その中身(表示された/数量制限で出さなかった/既にレビュー済みだった)は区別できません。
// ❌ よくある誤解 — 完了したから「出た」と思い込む実装
manager. launchReviewFlow (activity, reviewInfo)
. addOnCompleteListener { task ->
// task.isSuccessful == true でも、ダイアログは出ていないかもしれない
// ここで「レビュー済みフラグ」を立ててはいけない
markReviewedAndNeverAskAgain () // ← これが沈黙の温床になる
}
私が半日溶かした原因の半分はこれでした。完了コールバックで「もう聞かない」フラグを永続化していたため、一度フローを回しただけで二度と発火しない状態を、自分で作り込んでいたのです。正しくは、コールバックでは何もしない(あるいは内部の発火クールダウンだけを進める)。レビューが完了したかどうかをこちらで判定しようとしないことが出発点になります。
こちらが知りたいこと API が返すか 取るべき設計
ダイアログが表示されたか 返さない UX を表示有無に依存させない
ユーザーが星を付けたか 返さない 付与後の特典・分岐を作らない
数量制限で抑制されたか 返さない(成功扱い) クールダウンで再発火に備える
フローが終わったか 返す 次画面への遷移再開にだけ使う
数量制限という見えない壁
次の壁が数量制限(quota)です。In-App Review は、同一ユーザーに対してレビュー導線を出せる頻度に上限を設けています。短期間に何度も launchReviewFlow を呼んでも、上限を超えた分は静かに無視され、ダイアログは出ません。しかも前述のとおり、無視されたことはコールバックからは分かりません。成功として返ってきます。
ここが個人開発者にとって本当に厄介なところです。検証中はアプリを何度も起動し、何度もレビュー条件を満たします。その結果、開発中の自分のアカウントが真っ先に数量制限に当たり、「実装したのに出ない」という最初の症状にぴったり一致してしまうのです。
私の癒し系アプリでは、自前のポリシーで条件を満たして発火させたうち、実際に星のダイアログまで到達したのは多い週でも40%前後でした。残りの60%は数量制限や Google 側の判断で静かに見送られています。この数字を「異常」ではなく「正常」として受け止められるかどうかが、最初の落とし穴を回避できるかの分かれ目になります。
設計上の含意は明確です。発火回数は API の制限に頼って絞るのではなく、アプリ側で意図的に間引くこと。私は癒し系アプリで、レビュー導線を「セッションをまたいで一定間隔が空いたとき」かつ「満足のシグナルが出たとき」にだけ発火するようにしています。API の quota はあくまで最後の安全網で、こちらの発火ポリシーがその手前で十分に絞っているのが理想です。
requestReviewFlow と launchReviewFlow を分けて持つ
実装の骨格は、ReviewInfo を「先に温めておく」設計にすると安定します。requestReviewFlow でネットワークを伴う準備を済ませて ReviewInfo を取得し、満足の瞬間が来たら launchReviewFlow を即座に呼ぶ。こうすると、肝心のタイミングで待たせずに済みます。
ただし ReviewInfo には寿命があり、長時間放置すると無効になります。取得しっぱなしで何分も抱えると、いざ使うときに失敗する。だから「画面に入ったタイミングで温め、その画面の中で使い切る」くらいの近さで運用するのが現実的です。
import com.google.android.play.core.review.ReviewInfo
import com.google.android.play.core.review.ReviewManagerFactory
import com.google.android.play.core.review.ReviewManager
class InAppReviewLauncher (context: Context ) {
private val manager: ReviewManager = ReviewManagerFactory. create (context)
private var warmInfo: ReviewInfo ? = null
/** 満足の瞬間が来そうな画面に入ったら早めに呼んで温めておく */
fun warmUp () {
manager. requestReviewFlow ()
. addOnCompleteListener { task ->
warmInfo = if (task.isSuccessful) task.result else null
// 失敗してもクラッシュさせない。次の機会に再度温める
}
}
/**
* 実際に出したい瞬間に呼ぶ。
* 戻り値で「出せたか」を返さないのが重要 — 呼び出し側も結果に依存しない。
*/
fun launchIfReady (activity: Activity , onFlowFinished: () -> Unit) {
val info = warmInfo
if (info == null ) {
// 温まっていなければ何もしない。UX を止めない
onFlowFinished ()
return
}
warmInfo = null // 使い捨て。ReviewInfo は再利用しない
manager. launchReviewFlow (activity, info)
. addOnCompleteListener {
// 表示有無は問わない。次の遷移を再開するためだけのフック
onFlowFinished ()
}
}
}
ポイントは launchIfReady が成否を返さないことです。呼び出し側に「出せたか」を握らせると、必ずそこに分岐を書きたくなり、表示保証のない API の上に脆い前提を積み上げてしまいます。出せなかったときも onFlowFinished() を必ず呼んで、アプリの流れだけは止めないようにします。
いつ出すか — 満足のピークを engagement で捉える
In-App Review がうまく回るかどうかは、コードよりも「いつ呼ぶか」で決まります。Google も、ユーザーが価値を感じた直後に出すことを推奨しています。逆に、起動直後・課金直後・エラー直後のような「お願いごとが透けて見える瞬間」に出すと、低評価を引き寄せるか、無視されて quota だけを消費します。
私の壁紙・癒し系アプリでは、満足のシグナルを次のように定義しています。ある程度の枚数をお気に入りに保存した、一定時間以上アプリ内で穏やかに過ごした、複数セッションにわたって戻ってきてくれた——こうした「自分から繰り返している」状態が重なったときだけ、レビュー導線の候補にします。
/** 発火の可否だけを判定する純粋なポリシー。副作用を持たせない */
class ReviewTriggerPolicy (
private val store: ReviewPrefs ,
private val clock: () -> Long = { System. currentTimeMillis () }
) {
companion object {
private const val MIN_DAYS_BETWEEN = 60L * 24 * 60 * 60 * 1000 // 60日
private const val MIN_FAVORITES = 8
private const val MIN_CALM_SECONDS = 90
}
fun shouldOfferReview (signals: EngagementSignals ): Boolean {
val now = clock ()
val sinceLast = now - store.lastOfferedAt
if (sinceLast < MIN_DAYS_BETWEEN) return false // 自前のクールダウン
if (signals.favoritesCount < MIN_FAVORITES) return false // 満足のシグナル
if (signals.calmSessionSeconds < MIN_CALM_SECONDS) return false
if (signals.lastActionWasError) return false // 直後の不満を避ける
return true
}
/** 実際に発火した瞬間にだけ記録する(表示されたかは問わない) */
fun markOffered () {
store.lastOfferedAt = clock ()
}
}
markOffered() を呼ぶのは、launchReviewFlow を実際に試みた瞬間です。ここでも「表示されたか」は問いません。試みた事実をクールダウンの起点にすることで、quota に当たって無音だった場合でも、すぐに再発火して読者をうんざりさせる事故を防げます。60日という間隔は私のアプリでの落ち着きどころで、アプリの利用頻度によって調整する前提の値です。
広告・ペイウォールと衝突させない
満足のピークで出す、という方針は、もう一つの設計と必ずぶつかります。同じ「いい瞬間」に、AdMob のリワード広告の報酬付与やインタースティシャル、メンバーシップの案内も出したくなるからです。これらが重なって連続表示されると、ユーザーから見れば「お願いの連打」にしかなりません。
私はこの調停を、各ダイアログが勝手に出るのを禁じて、中央のコーディネーターに発火権を集約することで解いています。レビュー導線も例外ではなく、コーディネーターに「出したい」と申告し、そのフレームで他に何も出ていないときだけ許可をもらう形にしています。この考え方そのものは モーダル表示をコーディネーターで一元管理する設計 に詳しくまとめています。
// コーディネーター側に「レビューを出したい」候補として渡す
fun maybeOfferReview (activity: Activity , signals: EngagementSignals ) {
if ( ! policy. shouldOfferReview (signals)) return
modalCoordinator. request (ModalRequest. Review (priority = 20 ) {
policy. markOffered ()
reviewLauncher. launchIfReady (activity) {
modalCoordinator. onModalFinished ()
}
})
}
優先度を持たせて、課金や戻るボタンの広告ゲートと並べて調停するのがコツです。戻るボタン側のインタースティシャル制御と優先度がぶつかる場合の整理は、戻るボタンの広告ゲートをネストせず平らなガードで組む設計 と同じ考え方で揃えられます。レビュー導線は「邪魔をしてまで出すものではない」と割り切り、衝突したら譲るのが、長い目で見ると評価を守ります。
テストで「動かない」と勘違いしないために
最後の罠が、テスト環境での無音化です。In-App Review は、ローカルの debug ビルドや、まだ Play からインストールされていないアプリでは、実際のダイアログを出しません。launchReviewFlow は成功で返るのに、画面には何も出ない——私が最初に陥ったのは、まさにこの状態でした。
実際の表示を確認するには、Play の内部テスト(Internal testing)または内部アプリ共有(Internal app sharing)経由でインストールしたビルドで試す必要があります。それでも quota の影響は残るため、確認は「出る/出ないの二値」ではなく「条件を満たしたうえで、表示されることもされないこともある」という前提で受け止めるのが正しい読み方です。
ビルド/配布経路 launchReviewFlow の戻り ダイアログ表示
ローカル debug ビルド 成功で返る 出ない(仕様)
Play 未経由のサイドロード 成功で返る 出ない
内部テスト/内部アプリ共有 成功で返る 条件次第で出る
本番(quota 超過時) 成功で返る 出ない(無音)
この表をチームやドキュメントに一枚貼っておくだけで、「実装したのに出ない」という問い合わせの大半は未然に消えます。私はこの確認手順を本番運用に乗せる前に必ず一度通すことを推奨します。実際、私はこの一覧を Antigravity に投げて、自分のアプリの BuildConfig と配布フローに合わせた確認手順へ展開してもらいました。エージェントに「In-App Review は表示保証がなく、debug では無音、内部テスト経由でのみ確認できる」という制約を最初に渡しておくと、テスト手順の生成が一気に的確になります。
ポリシー違反の「事前フィルタ」と、許される設計の境界
In-App Review を扱うときに必ず確認しておきたいのが、満足度で出し分けてはいけない、という線引きです。「このアプリは気に入りましたか?」と先に聞いて、はい の人だけにレビュー導線を出し、いいえ の人は問い合わせフォームへ流す——この「ハッピーパスだけを誘導する事前フィルタ」は Google のポリシーで禁じられています。
一方で、行動シグナルでタイミングを選ぶこと自体は禁止されていません。前述のポリシーは「お気に入りを8件保存した」「穏やかに90秒以上過ごした」といった engagement を見ているだけで、ユーザーの評価そのもので分岐しているわけではありません。境界はシンプルで、満足度を問う UI を挟んで出し分けるのが NG、満足していそうな行動の瞬間を選んで全員に等しく出すのが OK です。この一線を踏み越えないことが、結果的にアカウントとアプリを守ります。
次の一手
In-App Review を初めて入れるなら、まず launchReviewFlow の完了コールバックから「レビュー済みフラグの永続化」を外すところから始めてみてください。私が半日溶かした沈黙の正体はそこにありました。表示保証のない API だと前提を置き換えるだけで、コールバック・quota・テストの3つの謎が一度に解けます。発火ポリシーと衝突調停は、そのうえで少しずつ自分のアプリのリズムに合わせていけば十分です。