判定の流れを一行ずつ紙に書き出していた夜のことです。個人開発で運用している壁紙アプリには、購入すると広告が消える仕組みがあります。その「広告が消えている状態」が、購読の期限が切れたあとに元へ戻るのかを、私自身はそれまで一度も紙の上で辿ったことがありませんでした。
辿ってみて、手が止まりました。広告を戻す側の分岐が、どこにも書かれていなかったのです。
起きていたのは、購読が失効したのに広告が戻らない、という状況でした。購入した人に広告が出る事故は、気づかれやすく、すぐ問い合わせにもなります。反対向きの事故——払っていない状態で広告が出ないまま——は、誰も困らないので、誰も報告してくれません。静かに続きます。Android 版で見つかり、iOS 版へ同じ判定を移す前の点検項目の最初に書き留めたものです。
最初にお伝えしたいのは、直したのが課金 SDK の使い方ではなく、判定を組み立てる「向き」と「待ち方」だった、という点です。以下は、その考え方を手元で動く形に組み直し、実際に走らせて確かめた書き残しです。
向きが逆だった、という一文で言える誤り
言葉にすると、壊れていた判定は「いまの状態が ad-free でなければ false にする」という一文でした。本来は「購入が確認できなければ false にする」と書くべきところです。主語が、確認した事実ではなく、フラグ自身の現在の値になっていました。
元のコードをそのまま載せることは控えます。ここでは考え方だけを、TypeScript で組み直しました。壊れていた向きの再現です。
// adfree.ts — 広告非表示(ad-free)の判定を、二系統の問い合わせから組み立てる部品
export type Source = "subscription" | "oneTime"; // 購読 / 買い切り
export type QueryResult =
| { ok: true; active: boolean }
| { ok: false; reason: string };
export type Results = Record<Source, QueryResult>;
// 壊れていた向きの再現(考え方の再現であり、実際のコードの引用ではありません)
export function applyBuggy(prev: boolean, r: Results): boolean {
const purchased =
(r.subscription.ok && r.subscription.active) ||
(r.oneTime.ok && r.oneTime.active);
if (purchased) return true;
if (!prev) return false; // 「いま ad-free でなければ false」: 向きが逆
return prev; // 期限が切れても、前回の true が居座る
}
このコードは、購入が確認できたときに true にするところまでは正しく動きます。だから、購入した直後の確認では何も気づけません。壊れるのは、購読が切れたあとの一回だけです。購入の確認が「なし」を返しても、prev が true のままなら true を返し続けます。
この種の不具合は、動いている側のテストでは見つかりません。「購入したら広告が消える」を何度確かめても、緑のままだからです。消える方向の確認と、戻る方向の確認は、別々に書きます。 この線引きを、私は点検表の一行目に置くことにしました。
二系統を待ってから、確認できた事実だけで決める
購入の問い合わせは、一つではありませんでした。購読と買い切りのように、別々に聞く必要のある系統が二つあります。一方が「なし」と返した時点で false にすると、もう一方がこれから「あり」と返す場合に、広告が一瞬だけ出てしまいます。
そこで、判定を次の三つの規則に直しました。
これをそのまま関数にします。
// 直した形: 確認できた事実だけで決め、決められないときは前回を保つ
export function composeAdFree(prev: boolean, r: Results): boolean {
const list = [r.subscription, r.oneTime];
// 一つでも「有効」と確認できたら、他の系統が失敗していても true
if (list.some((x) => x.ok && x.active)) return true;
// 全系統が「無効」と確認できたときだけ false
if (list.every((x) => x.ok && !x.active)) return false;
// どれかが失敗し、有効の証拠もない: 決められないので前回を保つ
return prev;
}
なぜこう書くのか。ポイントは、QueryResult に「失敗」という第三の状態を持たせたところです。active: false は「聞いたら無効だった」、ok: false は「聞けなかった」。この二つを同じ false にまとめてしまうと、通信が途切れただけで広告が戻る、あるいは戻らないままになります。
失敗した回は、何も変えない
関数を作ったら、組み合わせを全部並べて、壊れていた形と直した形を同じ入力で比べました。Node 22 で走らせた結果です。
壊れていた形と直した形が分かれるのは、ケース A だけです。つまり、不具合の本体はこの一行にしかありませんでした。ほかの五行は、直しても変わらないことを確かめるための行です。直したつもりで別の場所を壊していないか、この表で一目に分かります。
ケース B と E には、もう一つ意味があります。通信が不安定な日に、有料の人から広告を出さない、という約束を守る行です。決められない回は、前回を信じます。ただし、いつまで信じるかは別の設計になります。期限をどう持つかは、機内モードで開くと有料会員にペイウォールが出る — RevenueCat の権限をオフラインでも保つキャッシュ設計 に別の角度から書いております。
古い応答に上書きされない更新係
関数だけでは、まだ足りませんでした。実際のアプリでは、前面に戻るたびに、そして購入の直後にも、確認が走ります。確認が重なると、先に始めた古い確認が遅れて返り、新しい結果を上書きすることがあります。
まず、一系統ずつ結果が届くたびに反映してしまう形を、再現しておきます。
// naive.ts — 一系統ずつ、届いた順に反映してしまう形(再現用)
import type { QueryResult } from "./adfree.ts";
export function makeNaive(initial: boolean, log: (s: string) => void) {
let state = initial;
return (r: QueryResult) => {
const next = r.ok ? r.active : state; // 届いた一系統だけで決めてしまう
if (next !== state) { state = next; log(`naive -> ${next}`); }
};
}
購読の「無効」が先に届き、買い切りの「有効」が後から届く、という順番を与えると、結果は naive -> false / naive -> true でした。広告が一度戻って、すぐまた消えてしまいます。有料の人にとっては、一瞬のちらつきです。
直した形は、更新係に世代番号を持たせます。
// 世代番号つきの更新係: 二系統を待ち、古い応答に上書きされない
export type Query = () => Promise<QueryResult>;
export class AdFreeController {
private generation = 0;
private current: boolean;
private queries: Record<Source, Query>;
private onChange: (adFree: boolean) => void;
constructor(
queries: Record<Source, Query>,
initial: boolean,
onChange: (adFree: boolean) => void = () => {},
) {
this.queries = queries;
this.current = initial;
this.onChange = onChange;
}
get adFree(): boolean {
return this.current;
}
async refresh(): Promise<boolean> {
const mine = ++this.generation;
const wrap = (q: Query): Promise<QueryResult> =>
q().catch((e: unknown): QueryResult => ({ ok: false, reason: String(e) }));
const [subscription, oneTime] = await Promise.all([
wrap(this.queries.subscription),
wrap(this.queries.oneTime),
]);
if (mine !== this.generation) return this.current; // 新しい更新が走っていたら捨てる
const next = composeAdFree(this.current, { subscription, oneTime });
if (next !== this.current) {
this.current = next;
this.onChange(next);
}
return this.current;
}
}
refresh() の中で、三つのことをしています。二系統を Promise.all で両方待つこと。問い合わせが例外を投げても、それを「失敗」という値に変えて落ちないようにすること。そして、自分より新しい更新が始まっていたら、結果を捨てること。
確かめた結果は次のとおりです。購読と買い切りの応答を、遅らせたり順序を入れ替えたりして与えました。
二行目は、特に気に入っております。古い応答が「有効」で、新しい応答が「無効」。どちらが正しいかは、応答の速さでは決まりません。新しく聞いた答えは、古く聞いた答えに負けません。 世代番号は、その約束を数字一つで守る方法です。
Expo アプリへの繋ぎ込みの骨子
ここから先は、型検査と実機での確認を済ませていない骨子です。Rork で生成したアプリにも、同じ形でつなげるはずだと考えて書いています。
// useAdFree.tsx — 前面に戻るたびに二系統を聞き直す(骨子・実機未確認)
import { useEffect, useRef, useState } from "react";
import { AppState } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { AdFreeController, type QueryResult } from "./adfree";
const KEY = "adfree:last";
// ここに、使っている課金 SDK の呼び出しを包みます(失敗は ok:false で返す)
async function querySubscription(): Promise<QueryResult> {
return { ok: false, reason: "not implemented" };
}
async function queryOneTime(): Promise<QueryResult> {
return { ok: false, reason: "not implemented" };
}
export function useAdFree(): boolean {
const [adFree, setAdFree] = useState(false);
const ctl = useRef<AdFreeController | null>(null);
useEffect(() => {
let alive = true;
(async () => {
const saved = (await AsyncStorage.getItem(KEY)) === "1";
if (!alive) return;
setAdFree(saved);
ctl.current = new AdFreeController(
{ subscription: querySubscription, oneTime: queryOneTime },
saved,
(v) => {
setAdFree(v);
AsyncStorage.setItem(KEY, v ? "1" : "0").catch(() => {});
},
);
await ctl.current.refresh();
})();
const sub = AppState.addEventListener("change", (s) => {
if (s === "active") ctl.current?.refresh();
});
return () => {
alive = false;
sub.remove();
};
}, []);
return adFree;
}
何を解決しているか。前回の値を端末に保存しておき、起動した瞬間はそれを初期値にします。そのうえで、二系統の確認が揃ったら置き換えます。確認が揃うまでの間、前回が true の人には広告を出さず、前回が false の人には広告を出します。確認を待つあいだの顔つきを、前回に任せる形です。
広告の表示を決める側が、この値をいつ読むかは別の話になります。起動の順番と広告の初期化の前後は、課金復元より先に広告を初期化したら、有料ユーザに一瞬広告が出た — Rork(Expo)アプリの起動順序設計 の側で扱っています。
つまずきやすい 4 か所
どれも、本番に出てから気づきやすい落とし穴です。私自身が踏みかけたものを、順に書き残します。
1. 「なし」と「聞けなかった」を同じ値にしない
false と undefined と例外を、一つの false に畳んでしまうと、通信の不調がそのまま課金状態の変更になります。結果の型に、失敗を独立して持たせる形で対処できます。本番で通信が不安定な日に、はじめて効いてくる箇所です。
2. 戻る方向のテストを、最初に書く
「購入したら広告が消える」だけでなく、「失効したら広告が戻る」を、同じ重さで書いておきます。壊れていた形はケース A でしか壊れませんでした。
3. 猶予期間や保留の扱いを、この部品に混ぜない
支払いの猶予や保留の状態をどう扱うかは、判定とは別の設計です。解約と誤認して権限を切ってしまう話は、解約だと思って権限を切ったユーザーは、まだ払う気だった — 支払い猶予期間と権限維持の実装 をご覧ください。この部品は、聞いた結果を合成するところまでを受け持ちます。
4. 広告の時限的な非表示は、別の線で OR する
リワード広告で一定時間だけ広告を止める場合は、購入の判定とは別の状態として持ち、最後に OR で合わせます。購入の判定の中に混ぜると、期限が切れたときに、どちらが理由で true だったのか分からなくなります。
明日、最初にやること
自分のアプリで、購入が確認できなかった回に、広告非表示の値がどこで false へ戻るかを探してみてください。見つからなければ、私と同じ状態かもしれません。
見つかったら、その分岐が参照しているのが、確認した事実なのか、フラグ自身の現在の値なのかを、一度だけ見分けます。そのうえで、今回の六行の表を、自分のアプリの二系統に置き換えて埋めていただければ幸いです。
確認できなかった回は、答えを作りません。 この線引きだけは、通信の悪い日でも、広告を戻したい日でも、守るようにしています。
なお、ここで走らせたのは、考え方を組み直した TypeScript の部品と、その自動テストまでです。元の Android アプリでの実機の挙動や、iOS 側の StoreKit での確認は、この記事には含めておりません。