新規ユーザーには最初の数回の起動だけ広告を出さない——壁紙アプリのひとつに、そんな猶予を入れておりました。しきい値は3回の想定です。
ところが、広告は思っていたより早く出始めていました。原因は、広告の側にはありませんでした。起動回数を数える処理が、アプリの起動時と画面の起動時の両方から呼ばれていて、1回の起動でカウンタが2進んでいたのです。
しきい値が3なら、1回目の起動後の値は2、2回目の起動後は4です。猶予にいられるのは、3回ではなく実質1回でした。
最初にお伝えしたいのは、直した場所が広告の設定ではなく、「数える場所」だったという点です。広告の設定ばかりを疑っていたら、きっと遠回りになっていたのではないかと思います。以下は、その考え方を Expo(Rork が書き出す React Native)向けに組み直し、Node 22 で実際に走らせて確かめた書き残しです。
猶予が3回のはずなのに、算数が合わなくなる
数え方を紙に書くと、こうなります。
猶予の判定は「カウンタがしきい値以下か」だけです。判定そのものは正しく動いていました。誤っていたのは、判定に渡す数字のほうです。
この種の不具合は、地味に厄介です。広告が早く出ても、画面は壊れません。クラッシュも、エラーも出ません。新規ユーザーの体験だけが、設計より少し冷たくなります。数字が合っているかは、数字を見に行かないと分からない——これを点検の最初に置くことにしました。
二か所から呼ばれる、という形
Android 版で見つかったのは、アプリ全体の起動処理と、最初の画面の起動処理の両方に、カウンタを進める呼び出しが入っていた、という形でした。どちらも「起動したから数える」という正しい動機で書かれており、片方ずつ見れば、間違いではありません。重なったことだけが、間違いでした。
Expo のアプリでも、同じ形は作りやすいと思います。次のような場所に、呼び出しが散らばりがちです。
開発中に Fast Refresh でファイルを保存したときも、効果は再び走ります。開発機の数字だけを見ていると、本番の数字との違いに気づけません。
壊れていた形を、まず手元で再現する
原因を直す前に、壊れていた形をそのまま再現しておきます。元のコードの引用ではなく、考え方の再現です。AsyncStorage の代わりに、非同期である性質だけを持たせたメモリ上のストレージを用意しました。
// kv.ts — AsyncStorage の代役。読み書きが非同期(少し待つ)という性質だけを再現する
export interface KV {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
}
export function createMemoryKV(latencyMs = 5): KV & { dump(): Record<string, string> } {
const data = new Map<string, string>();
const wait = () => new Promise<void>((r) => setTimeout(r, latencyMs));
return {
async getItem(key) {
await wait();
return data.has(key) ? (data.get(key) as string) : null;
},
async setItem(key, value) {
await wait();
data.set(key, value);
},
dump: () => Object.fromEntries(data),
};
}
// counter-buggy.ts — 壊れていた形。呼ぶたびに「読んで、足して、書く」だけ
import type { KV } from "./kv.ts";
export function createBuggyCounter(store: KV, key = "launch_count") {
return {
async countThisLaunch(): Promise<number> {
const raw = await store.getItem(key);
const next = (Number.parseInt(raw ?? "0", 10) || 0) + 1;
await store.setItem(key, String(next));
return next;
},
};
}
1回の起動で二つの呼び出し元が順番に呼ぶ場面を、3回の起動ぶん走らせました。
起動1回目 count=2 猶予=true | 起動2回目 count=4 猶予=false | 起動3回目 count=6 猶予=false
冒頭の算数どおりです。しきい値3の猶予は、2回目の起動で終わっていました。
同じコードが、タイミングしだいで +1 にも +2 にもなる
ここで、直感に反することが一つ分かりました。二つの呼び出し元を「同時に」呼ぶと、同じ壊れたコードでも、1回の起動で進む量が 1 になったのです。
二つの呼び出しが、ほぼ同時に「読む」を済ませると、どちらも同じ値(たとえば 0)を読みます。そのままどちらも 1 を書くので、足した分の一つが消えます。いわゆる更新の取りこぼしです。結果として数字だけは「合って」見えます。
二つ目の呼び出しを、一つ目より何ミリ秒遅らせて始めるかを変えて、1回の起動で進む量を測りました(読み書きの待ちは各5ミリ秒、2回実行して同じ結果でした)。
つまり、壊れた形は「いつも 2 進む」のではなく、「二つの呼び出しの間隔によって、1 にも 2 にもなる」のでした。実機のストレージは、端末の混み具合で待ち時間が変わります。開発機では合っていた数字が、特定の端末だけで倍になるという症状は、ここから生まれます。
「自分の端末では再現しない」を、問題がない証拠にしないこと。これは、この件でいちばん強く残った教訓です。
直した形 — 1回の起動で、1だけ進める
直し方は二つの部品に分かれます。一つは、同じ起動の中では結果を使い回すこと。もう一つは、書き込みを一列に並べて、読み書きが重ならないようにすることです。
// counter.ts — 直した形。「1回の起動で1だけ進める」ことを、部品の側で保証する
import type { KV } from "./kv.ts";
export function createLaunchCounter(store: KV, key = "launch_count") {
let session: Promise<number> | null = null; // この JS 実行環境(= 1回の起動)で数えた結果
let queue: Promise<unknown> = Promise.resolve(); // 書き込みを一列に並べる
function bump(): Promise<number> {
const run = queue.then(async () => {
const raw = await store.getItem(key);
const next = (Number.parseInt(raw ?? "0", 10) || 0) + 1;
await store.setItem(key, String(next));
return next;
});
queue = run.catch(() => undefined); // 一度失敗しても、次の書き込みは止めない
return run;
}
return {
// 何か所から、何度呼ばれても、この起動では一度しか進めない
countThisLaunch(): Promise<number> {
if (!session) {
session = bump().catch((e) => {
session = null; // 失敗した回だけは、次の呼び出しで数え直せるようにする
throw e;
});
}
return session;
},
async peek(): Promise<number> {
return Number.parseInt((await store.getItem(key)) ?? "0", 10) || 0;
},
};
}
// 猶予の判定は、数える部品と分けて、ただの関数にしておく
export function isInAdGrace(launchCount: number, graceLaunches: number): boolean {
return launchCount <= graceLaunches;
}
なぜこう書くのか。session に Promise そのものを入れているのが要点です。二つ目の呼び出し元は、新しく数え直さず、一つ目が進めている最中の結果を受け取ります。呼び出し元が三つに増えても、四つに増えても、進む量は変わりません。
queue は、別の保険です。この部品を複数作ってしまった場合や、将来ほかの用途(たとえば別のカウンタ)で同じ書き方を借りた場合に、読み書きが重ならないようにします。
失敗の扱いも、一行だけ入れました。保存に失敗した回は session を空に戻しているので、次の呼び出しで数え直せます。失敗した Promise を握ったまま、以降の起動すべてで同じ失敗を返す事態を避けるためです。ただし、書き込みが実は成功していた場合には、数え直しで 1 多くなりえます。完全に防ぐには、起動ごとの識別子を保存する別の設計が要ります。広告の猶予くらいの用途なら、この割り切りで足ると考えました。
3回の起動を、二つの呼び出し元で走らせた結果は次のとおりです。
[fixed] 1:1:猶予 | 2:2:猶予 | 3:3:猶予 | 4:4:広告可 | 5:5:広告可
[fixed/失敗後の数え直し] count = 1
3回目までが猶予で、4回目から広告が出ます。設計どおりです。
Android 版で採った「時間窓で飛ばす」形と、その限界
Android 版では、呼び出し元をひとつにまとめる前に、一定時間内の再呼び出しを飛ばす形で直しました。同じ考え方を、同じ環境で書き直すとこうなります。
// counter-window.ts — 「一定時間内の再呼び出しは飛ばす」形。呼び出し元を一つにできないときの保険
import type { KV } from "./kv.ts";
export function createWindowCounter(
store: KV,
now: () => number,
windowMs: number,
key = "launch_count",
stampKey = "launch_count_at",
) {
return {
async countThisLaunch(): Promise<number> {
const last = Number.parseInt((await store.getItem(stampKey)) ?? "0", 10) || 0;
const current = Number.parseInt((await store.getItem(key)) ?? "0", 10) || 0;
if (last > 0 && now() - last < windowMs) return current; // 直前に数えたばかり: 飛ばす
const next = current + 1;
await store.setItem(key, String(next));
await store.setItem(stampKey, String(now()));
return next;
},
};
}
窓を3秒にして、起動1回ぶんの二重呼び出しと、その1.5秒後の起動し直しと、さらに5秒後の起動を並べました。
起動1の二重= 1 1 / 1.5秒後の起動し直し= 1 / 5秒後= 2
二重呼び出しは確かに防げています。一方で、1.5秒後に起動し直した「本物の2回目の起動」も、同じ窓に飲まれて数えられませんでした。クラッシュ直後に開き直したユーザーが、これにあたります。
つまり、時間窓は「呼び出し元をひとつにまとめられない」場合の保険です。まとめられるなら、上の createLaunchCounter のほうが、飛ばすべき呼び出しだけを飛ばせます。私自身は、数える処理にこそ最初にテストを書いておきたいと考えるようになりました。窓の長さを短くすれば取りこぼしは減りますが、ゼロにはなりません。私は、まとめられる場所ではまとめる側を好みます。
ルート画面への接続 — 数える場所は一か所にする
部品ができたら、使う側のルールも決めます。ルールは一つです。カウンタを進める呼び出しは、ルートレイアウトの一か所だけに置きます。 ほかの画面や広告の部品は、進めずに「読む」だけにします。
// launch.ts — アプリで共有する、カウンタの唯一の入口
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createLaunchCounter, isInAdGrace } from "./counter";
export const launchCounter = createLaunchCounter(AsyncStorage);
const GRACE_LAUNCHES = 3; // 例: 最初の3回の起動は広告を出さない
export async function shouldHoldAds(): Promise<boolean> {
return isInAdGrace(await launchCounter.peek(), GRACE_LAUNCHES);
}
// app/_layout.tsx — 進める呼び出しは、ここだけ
import { useEffect } from "react";
import { Stack } from "expo-router";
import { launchCounter } from "../launch";
export default function RootLayout() {
useEffect(() => {
launchCounter.countThisLaunch().catch(() => {
// 数えられなくても、アプリは起動する。猶予が1回延びるだけなので握りつぶしてよい
});
}, []);
return <Stack />;
}
なぜ peek() と countThisLaunch() を分けるのか。広告を出すかどうかを調べる側が、調べるたびにカウンタを進めてしまう事故を、型の上で避けるためです。進める関数を呼べる場所を探せば、一覧が一目で出ます。点検のときに、検索一回で終わります。
ここで正直に書いておきます。手元で動かしたのは、Node 上の部品(kv.ts・counter.ts・counter-window.ts)とテストだけです。app/_layout.tsx と launch.ts は、実機の Expo では動かしていません。AsyncStorage の実機での待ち時間も、測っておりません。上の表の「ミリ秒」は、手元の模擬ストレージでの値で、端末の速さを表すものではありません。
先回りして防ぐ、点検の落とし穴
最後に、同じ型の不具合を出荷前に見つけるための点検項目を表にします。
猶予の長さそのものの決め方や、復帰時の広告との兼ね合いは、AdMob オープンアプリ広告の頻度設計 — 復帰時の体感ストレスを抑えながら eCPM を取りに行く運用メモ のほうに分けて書きました。この記事で直したのは、その前提になる「数字」です。
自分のアプリで確かめる、最初の3手
- プロジェクトを「カウンタを進める関数の名前」で検索し、呼び出し箇所を数えます。1か所なら、ここで終わりです。
- 2か所以上あれば、ルートの1か所を残して、あとは「読むだけ」に書き換えます。
- 二つ目の呼び出しをずらして呼ぶテストを一つ足し、増分が常に 1 だと確かめてから、出荷します。
数字が合っているかは、数字を見に行かないと分かりません。次にご自分のアプリで広告の出方が気になったときは、設定より先に、カウンタの値を一度だけ書き出してみていただければ幸いです。