先日、Rorkで作ったアプリを久しぶりに自分のiPhoneで触っていたとき、画像を1枚保存した直後に、レビュー依頼のダイアログが出て、それを閉じた瞬間に課金案内のシートがせり上がってきました。さらにその裏で広告の読み込みが終わっていたようで、課金シートを閉じたら全画面広告が表示されました。3秒のあいだに3枚です。自分で作ったアプリなのに、正直うんざりしました。
このとき気づいたのは、それぞれのダイアログを書いたときの自分は、どれも「良かれと思って」その場所に置いていたということです。保存できたから満足度が高いはずだとレビューを出し、せっかく機能を使ったのだから課金を勧め、区切りがついたから広告を見せる。一つひとつの判断は間違っていません。けれど、それぞれが相手の存在を知らないまま独立して発火すると、ユーザーから見れば「とにかく邪魔なものが連続で出てくるアプリ」になります。今日は、この重なりをどう交通整理したか、累計5,000万ダウンロードのアプリを12年運営してきた感覚で書き残しておこうと思います。
なぜモーダルは「良かれと思って」重なるのか
問題の根っこは、各モーダルが別々のイベントを起点にしている点にあります。
レビュー依頼は「保存」や「お気に入り追加」のような満足のサインで出したくなります。課金案内は「3回目の利用」や「無料枠の上限」で出したくなります。広告は「画面遷移」や「セッションの区切り」で出したくなります。これらのイベントは、実際のアプリではほぼ同じ瞬間に折り重なって起きます。ユーザーが何かを達成した瞬間というのは、満足のサインであり、利用回数の節目であり、画面の区切りでもあるからです。
つまり、トリガーを別々に置いている限り、重なりは「たまに起きる事故」ではなく「構造的に必ず起きること」なのです。私はこれを、Beautiful HD Wallpapersの運用で何度も痛い目を見てから理解しました。コードのどこにもバグはないのに、ユーザー体験だけが壊れている。原因は、3つの正しい判断が同じ場所で衝突していたことでした。
表示の判断を1か所に集約する
私が行き着いた解決策は、シンプルです。「どのモーダルを出すか」という判断を、各機能から取り上げて、1つの窓口にすべて預けることです。ネイティブアプリの開発では、私はこれを ModalGate と呼ぶ中央管理の仕組みとして実装しています。各機能は「出したい」と申請するだけで、実際に出すかどうかは窓口が一括で決めます。
考え方をコードにすると、こうなります。優先順位を1か所で定義し、いま画面に何も出ていないときだけ、最も優先度の高い待機中のモーダルを1枚だけ出します。
// modalGate.ts — モーダル表示を1か所で交通整理する
type ModalKind = "rewardAd" | "paywall" | "reviewPrompt";
// 数字が小さいほど優先。広告は収益、課金は事業、レビューは資産
// という順序で「最後発を勝たせない」ことが肝心です
const PRIORITY: Record<ModalKind, number> = {
rewardAd: 1,
paywall: 2,
reviewPrompt: 3,
};
let activeModal: ModalKind | null = null;
const pending = new Set<ModalKind>();
// 各機能はここに「出したい」と申請するだけ
export function requestModal(kind: ModalKind) {
pending.add(kind);
flush();
}
function flush() {
// すでに何か出ているなら、絶対に重ねない
if (activeModal !== null) return;
if (pending.size === 0) return;
// 待機中で最も優先度の高い1枚だけを選ぶ
const next = [...pending].sort((a, b) => PRIORITY[a] - PRIORITY[b])[0];
pending.delete(next);
activeModal = next;
showModal(next); // 実際の表示処理
}
// モーダルが閉じられたら次の1枚を検討する
export function onModalDismissed() {
activeModal = null;
// すぐ次を出すと連続表示になるので、少し間を置く
setTimeout(flush, 800);
}ここで一番大事なのは onModalDismissed の中の setTimeout です。1枚閉じた直後に次を出すと、結局「連続して出てくるアプリ」になります。閉じてから最低でも0.8秒ほど空白を作り、できればその間に別の画面に遷移していれば次は出さない、というルールにしています。重ならないことと、連続しないことは別の問題で、両方を窓口で面倒見る必要があります。
Rorkで作るアプリにこの考え方を持ち込む
Rorkで生成したアプリは中身がReact Nativeなので、この交通整理はそのまま持ち込めます。私が試して実用的だと感じたのは、申請窓口をReactのコンテキストにして、各画面からは「出したい」とだけ伝える形です。
// useModalGate.tsx — Rork製アプリ(React Native)向けの最小実装
import { createContext, useContext, useRef, useState, useCallback } from "react";
type ModalKind = "rewardAd" | "paywall" | "reviewPrompt";
const PRIORITY: Record<ModalKind, number> = { rewardAd: 1, paywall: 2, reviewPrompt: 3 };
const Ctx = createContext<(k: ModalKind) => void>(() => {});
export function ModalGateProvider({ children }: { children: React.ReactNode }) {
const [active, setActive] = useState<ModalKind | null>(null);
const pending = useRef<Set<ModalKind>>(new Set());
const flush = useCallback(() => {
if (active !== null || pending.current.size === 0) return;
const next = [...pending.current].sort((a, b) => PRIORITY[a] - PRIORITY[b])[0];
pending.current.delete(next);
setActive(next);
}, [active]);
const request = useCallback((kind: ModalKind) => {
pending.current.add(kind);
flush();
}, [flush]);
const dismiss = useCallback(() => {
setActive(null);
setTimeout(flush, 800); // 連続表示を防ぐ間
}, [flush]);
return (
<Ctx.Provider value={request}>
{children}
{/* active の値に応じて該当モーダルを1枚だけ描画する */}
{active === "paywall" && <Paywall onClose={dismiss} />}
{active === "reviewPrompt" && <ReviewPrompt onClose={dismiss} />}
{active === "rewardAd" && <RewardAd onClose={dismiss} />}
</Ctx.Provider>
);
}
// 各画面からはこれを呼ぶだけ。表示するかどうかは窓口が決める
export const useRequestModal = () => useContext(Ctx);Rorkに「保存ボタンを押したらレビュー依頼を出して」と指示すると、ボタンのonPressに直接ダイアログ表示を書いてくれることが多いです。これは単体では正しく動くのですが、課金や広告のロジックを別のプロンプトで追加していくと、それぞれが独立した発火点を持ってしまいます。だから私は、Rorkで一通り作ったあとに、表示系のロジックだけをこの窓口経由に書き換える、という手順を踏んでいます。生成の速さと、運用に耐える設計は、工程を分けたほうがうまくいくと感じています。
いつ出すか — 優先順位の決め方そのもの
窓口を作ったうえで、最後に残るのは「3枚が同時に来たとき、どれを勝たせるか」です。私の基準は、取り返しのつきやすさで決めています。
広告は、その瞬間に出さなければ収益の機会が消えます。あとから「さっきの広告」を出すことはできません。課金案内は、いま出さなくても次の節目でまた出せます。レビュー依頼に至っては、OSが年間の表示回数を制限しているので、軽々しく消費したくない一番の資産です。だから優先度は広告・課金・レビューの順にし、レビュー依頼は「他に何も出ていない、かつ明確に満足しているであろう瞬間」までとっておきます。
この順序は事業の形によって変わります。広告収益が薄く課金が主軸のアプリなら、課金を先に置くべきでしょう。大事なのは順序そのものより、順序を1か所で意識的に決めている状態をつくることです。Law of Attraction Everydayのような毎日開いてもらうアプリでは、私はレビュー依頼を「7日連続で開いた日」のような、こちらが心からおすすめできる瞬間まで遅らせています。早く出して星3つをもらうより、遅く出して星5つをもらうほうが、長い目で見て事業を助けてくれます。
宮大工だった祖父が、釘を打つ前に必ず材の向きをそろえていたのを思い出します。一本ずつは正しく打てても、向きがそろっていなければ全体は歪む。アプリのモーダルも同じで、一つひとつの判断が正しいことと、全体として気持ちよく使えることは、別々に設計してあげる必要があるのだと考えています。
次の一歩
もしいま運用中のアプリがあるなら、まずはレビュー依頼・課金・広告の表示が、コードのどこから呼ばれているかを書き出してみてください。3つが別々のファイルから呼ばれていたら、それが重なりの原因です。最初の一歩としては、この記事のrequestModalのような小さな窓口を1つ作り、まずは2つのモーダルだけそこを通すようにするだけでも、体験はだいぶ変わります。お読みいただきありがとうございました。