「広告を消す購入をしたのに、まだ表示される」
2026年4月、Beautiful HD Wallpapers(Android版・累計5,000万DL超)のユーザーレビューにこの報告が届きました。購入フロー自体は正常で、Google Play Billing の取引履歴にも記録があります。それでも一部の端末で広告が消えない現象が再現していたのです。
コードを追っていくと、問題の根本が見えてきました。課金状態を判定する条件式が、アプリの5か所に散在していました。それぞれが独立したタイミングで更新され、再起動直後やセッション切り替え時に一時的な矛盾が生じていた。これが原因でした。
2013年にアプリ開発を始めて以来、少しずつ積み上げてきた技術的負債の、ひとつの結晶でした。
この問題を Antigravity と一緒に解決していく中で、「Source of Truth(唯一の真実源)」という設計原則と、それを実現するための3つのクラス——AdFreeManager、BillingManager、ModalGate——の組み合わせにたどり着きました。設計の思考過程と実装の詳細を、以下で共有します。
なぜ「課金状態」は散らばるのか
個人でアプリを長く運営していると、課金状態の判定があちこちに生えていきます。最初はシンプルです。SharedPreferences に is_ad_free フラグを置いて Activity から読む。ところが機能を追加するたびに状況が複雑になっていきます。
リワード広告を導入したとき、「広告を見た人は一定時間だけ ad-free にする」という仕様が加わります。このリワード状態はメモリ上にしか存在しません。次に、購入復元機能を追加したとき、別の判定パスが生まれます。Fragment が増えるたびに、それぞれが微妙に異なる判定ロジックを持つようになります。
こうして気づかないうちに、課金状態は「分散した真実」の集合体になっていきます。それぞれが「正しいはず」なのに、タイミングの違いで矛盾が生まれる。
Antigravity にこの状況を共有したとき、最初の返答はコードの生成ではなく、設計の問いかけでした。「課金状態について、アプリ全体で信頼できる唯一の場所を決めましょう。それが Source of Truth です。どの情報が一番正確だと思いますか?」
この問いかけが、設計の出発点になりました。
Source of Truth という考え方
ソフトウェア設計で「Source of Truth(唯一の真実源)」とは、あるデータについて「これが正しい値だ」と信頼できる唯一の場所を設けるという設計原則です。複数の場所が同じデータを保持し、それぞれが独立して更新される構造は、必然的に矛盾を生みます。
課金状態の場合、Source of Truth の候補は以下の3つが考えられます。
まず、Google Play Billing API の返り値。Google のサーバーに問い合わせた結果であり、最も信頼性が高い情報源です。ただし非同期で時間がかかります。次に、SharedPreferences。ローカルにキャッシュした購入フラグで、高速に読めますが古くなる可能性があります。最後に、メモリ上の変数。リワード広告など一時的な状態を保持しますが、再起動後に消えます。
Antigravity が提案したのは「Google Play Billing を Source of Truth とし、それを包む AdFreeManager クラスに全判定を集約する」というアーキテクチャでした。なぜなら、購入状態は最終的に Google のサーバーが持つ情報が正であり、それ以外はすべてキャッシュや派生値に過ぎないからです。
この考え方を採用することで、「どこに聞けばいいか」が一か所に定まります。判定ロジックが変わるとき、修正は1か所だけです。バグを追うとき、調査は1か所から始まります。
AdFreeManager:状態の集約点を作る
AdFreeManager は、課金状態の唯一の出どころになるクラスです。アプリ内のどこからでも、このクラスを通じて状態を取得します。
実装のポイントは、isAdFree という単一のプロパティが「購入済みかどうか」と「リワード視聴後かどうか」の両方を考慮した上で最終的な状態を返すことです。呼び出し側は、どちらの理由で ad-free になっているかを意識する必要がありません。
class AdFreeManager private constructor (
private val billingManager: BillingManager ,
private val rewardManager: RewardedAdManager
) {
val isAdFree: Boolean
get () = billingManager. isPurchased () || rewardManager.isAdFreeSession
val isPermanentlyAdFree: Boolean
get () = billingManager. isPurchased ()
val isSessionAdFree: Boolean
get () = rewardManager.isAdFreeSession && ! billingManager. isPurchased ()
companion object {
@Volatile private var instance: AdFreeManager ? = null
fun getInstance (
billingManager: BillingManager ,
rewardManager: RewardedAdManager
): AdFreeManager = instance ?: synchronized ( this ) {
instance ?: AdFreeManager (billingManager, rewardManager). also {
instance = it
}
}
}
}
シングルトンパターンを採用しているのは、アプリ全体で状態が一致することを保証するためです。@Volatile と synchronized の組み合わせは、マルチスレッド環境でのインスタンス生成の競合を防ぐ慣用的な書き方です。
isAdFree、isPermanentlyAdFree、isSessionAdFree の3つのプロパティに分けているのも設計上の工夫です。ほとんどの場所では isAdFree を使えばよいのですが、「購入済みの人だけに表示する永続的な機能」と「リワード視聴後の一時的な特典」を区別したい場面があります。その場合は isPermanentlyAdFree や isSessionAdFree を使うことで、コードの意図が明確になります。
これ以降、アプリ内の全ての判定は adFreeManager.isAdFree に集約されます。
BillingManager:Google Play Billing との橋渡し
AdFreeManager が依存する BillingManager は、Google Play Billing Library のラッパーです。設計の核心は、購入状態を StateFlow で管理することです。
StateFlow は Kotlin Coroutines のデータホルダーで、値が変わると購読者に自動的に通知されます。購入状態に StateFlow を使う理由は明確です。購入完了や購入復元は非同期イベントとして発生するため、イベント駆動の設計が自然に合います。また、画面回転などの構成変更が起きても、StateFlow が最後の値を自動的に再配信するため、UI の状態が失われません。
class BillingManager ( private val context: Context ) {
private var billingClient: BillingClient ? = null
private val _isPurchasedFlow = MutableStateFlow ( false )
val isPurchasedFlow: StateFlow < Boolean > = _isPurchasedFlow. asStateFlow ()
init {
setupBillingClient ()
}
private fun setupBillingClient () {
billingClient = BillingClient. newBuilder (context)
. setListener { billingResult, purchases ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
handlePurchases (purchases)
}
}
. enablePendingPurchases (
PendingPurchasesParams. newBuilder ()
. enableOneTimeProducts ()
. build ()
)
. build ()
connectAndVerify ()
}
private fun connectAndVerify () {
billingClient?. startConnection ( object : BillingClientStateListener {
override fun onBillingSetupFinished (billingResult: BillingResult ) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
queryExistingPurchases ()
}
}
override fun onBillingServiceDisconnected () { }
})
}
private fun queryExistingPurchases () {
val params = QueryPurchasesParams. newBuilder ()
. setProductType (BillingClient.ProductType.INAPP)
. build ()
billingClient?. queryPurchasesAsync (params) { billingResult, purchases ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
handlePurchases (purchases)
}
}
}
private fun handlePurchases (purchases: List < Purchase >?) {
val hasPurchased = purchases?. any { purchase ->
purchase.products. contains (PRODUCT_ID_AD_FREE) &&
purchase.purchaseState == Purchase.PurchaseState.PURCHASED
} ?: false
_isPurchasedFlow. value = hasPurchased
}
fun isPurchased (): Boolean = _isPurchasedFlow. value
companion object {
const val PRODUCT_ID_AD_FREE = "remove_ads"
}
}
重要なのは、handlePurchases が呼ばれるタイミングが2か所あることです。ひとつは startConnection の成功後(queryExistingPurchases 経由)で、アプリ起動時に既存の購入を確認します。もうひとつは setListener のコールバックで、購入フローが完了した直後に呼ばれます。この2つのトリガーにより、購入直後と起動時の両方で状態が正確に更新されます。
ModalGate:ダイアログの競合を防ぐ
Source of Truth の考え方は、ダイアログ表示の管理にも応用できます。
Beautiful HD Wallpapers には、課金関連のダイアログが複数存在します。PaywallDialog(広告削除の課金案内)、ReviewInductionDialog(アプリレビュー誘導)、RewardedInterstitialDialog(リワード広告の説明)の3つです。
これらが同じタイミングで表示されようとすると、ユーザー体験が壊れます。ペイウォールを閉じようとしたらレビュー画面が出てくる、リワード広告を視聴しようとしたら別のダイアログが割り込む。こういう状況が実際に起きていました。
根本的な問題は、各ダイアログが「今他のダイアログが表示されているかどうか」を知る手段を持っていないことです。そこで ModalGate という概念を導入しました。アプリ全体でダイアログの表示状態を管理するシングルトンであり、全てのダイアログがここを通じて表示を試みます。
object ModalGate {
private val activeModal = AtomicReference < String ?>( null )
fun tryShow (tag: String ): Boolean {
return activeModal. compareAndSet ( null , tag)
}
fun dismiss (tag: String ) {
activeModal. compareAndSet (tag, null )
}
fun isShowing (): Boolean = activeModal. get () != null
fun currentTag (): String ? = activeModal. get ()
}
AtomicReference.compareAndSet(expected, update) は、現在の値が expected と一致する場合にのみ update に書き換え、成功すれば true を返します。これにより「表示を試みる → 成功なら表示 → 失敗なら諦める」というスレッドセーフなプロトコルが実現されます。ロックを使わないため、デッドロックのリスクがありません。
全ての DialogFragment はこのプロトコルに従います。
class PaywallDialog : DialogFragment () {
override fun onStart () {
super . onStart ()
if ( ! ModalGate. tryShow (TAG)) {
dismissAllowingStateLoss ()
}
}
override fun onDismiss (dialog: DialogInterface ) {
super . onDismiss (dialog)
ModalGate. dismiss (TAG)
}
override fun onDestroyView () {
super . onDestroyView ()
ModalGate. dismiss (TAG)
}
companion object {
const val TAG = "PaywallDialog"
}
}
onDestroyView でも dismiss を呼んでいるのは、アプリがバックグラウンドに移動してプロセスが強制終了したとき、onDismiss が呼ばれない場合への保険です。両方でクリアすることで、ModalGate にタグが残り続ける問題を防いでいます。
実装で直面した3つの罠
AdFreeManager と ModalGate の実装を通じて、私自身がはまった問題を共有しておきます。
罠1:BillingClientの接続タイミング問題
BillingClient は非同期で接続されます。アプリ起動直後に isPurchased() を呼ぶと、まだ接続が確立していないため false が返ることがあります。購入済みのユーザーが起動直後に広告を一瞬見てしまう、という事象でした。
対策として、StateFlow を使ってUIが接続完了後に状態を受け取るよう設計しました。SplashScreen の表示中に BillingClient の初期接続と購入確認を完了させ、ホーム画面に移行するタイミングで StateFlow の初期値が確定するようにしています。SplashScreen の待機時間を少し延ばすコストで、購入済みユーザーへの誤った広告表示を防げます。
罠2:ModalGateのバックスタック復元後の残存状態
アプリがバックグラウンドに移動してプロセスが強制終了したとき、フォアグラウンドに戻ると ModalGate.activeModal に古いタグが残ることがありました。ダイアログが onDismiss を呼ばずに消えた場合に発生します。
対策として、DialogFragment の onDestroyView にも ModalGate.dismiss(TAG) を追加しました。onDismiss と両方でクリアすることで、残存を防いでいます。処理の重複はありますが、compareAndSet の特性上、余分な呼び出しは安全に無視されます。
罠3:テスト時のシングルトン汚染
object ModalGate はプロセスライフタイムのシングルトンです。ユニットテストで複数のテストケースが ModalGate の状態を共有してしまい、テスト間で干渉が起きました。テストを一個実行すると次のテストの tryShow が失敗する、という一見わかりにくいバグでした。
対策として、テスト専用の ModalGate.reset() メソッドを @VisibleForTesting アノテーション付きで追加し、各テストケースの @Before でリセットするようにしました。本番コードからは呼ばれないように、アノテーションでドキュメント化しています。
Antigravityを「設計の壁打ち相手」として使う
ここまでのコードは、Antigravity と会話しながら設計しました。Antigravity の使い方として、コードを生成させることに集中する方が多いと思います。しかし私が最も価値を感じているのは、設計の問いかけに答えてもらうという使い方です。
たとえば、こういう問いかけをします。「BillingManager と SharedPreferences の両方に課金状態があります。どちらを Source of Truth にすべきか、その根拠は何か」「複数のダイアログが競合する問題を解決したい。AtomicReference 以外のアプローチも見せてほしい」「このコードの潜在的な問題点を挙げてほしい。特にマルチスレッドとライフサイクルの観点で」といった形です。
Antigravity は、コードを生成する前に「なぜそうするのか」を説明してくれます。この説明を理解した上でコードを受け取ることが、単なるコピーアンドペーストではなく、設計の力として自分に蓄積されていきます。
私の両祖父は宮大工でした。「手を動かすことが一つの信心」という言葉が家にありました。AIが生成したコードでも、自分の手で一行ずつ確認してから組み込む——この習慣が、12年以上のアプリ運営を通じて品質を支えていると感じています。
UIとの統合:StateFlowで広告表示を自動更新する
設計が整ったところで、実際にどう使うかを示します。Application クラスで全てのマネージャーを初期化し、Fragment から参照するパターンです。
class MyApplication : Application () {
lateinit var billingManager: BillingManager
private set
lateinit var rewardedAdManager: RewardedAdManager
private set
lateinit var adFreeManager: AdFreeManager
private set
override fun onCreate () {
super . onCreate ()
billingManager = BillingManager ( this )
rewardedAdManager = RewardedAdManager ()
adFreeManager = AdFreeManager. getInstance (billingManager, rewardedAdManager)
}
}
class HomeFragment : Fragment () {
private val app by lazy { requireActivity ().application as MyApplication }
override fun onViewCreated (view: View , savedInstanceState: Bundle ?) {
super . onViewCreated (view, savedInstanceState)
viewLifecycleOwner.lifecycleScope. launch {
app.billingManager.isPurchasedFlow. collect { _ ->
updateAdVisibility ()
}
}
}
private fun updateAdVisibility () {
val isAdFree = app.adFreeManager.isAdFree
binding.bannerAdView.visibility = if (isAdFree) View.GONE else View.VISIBLE
}
}
このパターンにより、ユーザーが購入を完了した瞬間に isPurchasedFlow が更新され、それを購読している全てのFragmentで updateAdVisibility() が自動実行されます。手動でのUI更新は不要です。購入直後に「もう一度起動してください」とユーザーに伝える必要がなくなります。
この設計が生み出す長期的なメリット
Source of Truth を設計することで、短期的なバグ修正にとどまらない恩恵があります。
新しい課金プランを追加する場合、変更は AdFreeManager の isAdFree プロパティの判定ロジックだけです。Fragment やService に散らばった判定を探して回る必要がありません。新しい広告フォーマット(リワード動画、インタースティシャルなど)を追加する場合も、RewardedAdManager の更新と AdFreeManager への影響を確認するだけです。
ModalGate も同様です。新しいダイアログを追加するとき、ModalGate に乗るだけで競合が防がれます。既存のダイアログがどのタイミングで表示されるかを調査する必要がありません。
累計5,000万DLを超えるアプリを10年以上運営してきた経験から言えるのは、個人開発で最も消耗するのは「どこを直せばいいかわからないバグ」です。Source of Truth の設計は、その消耗を根本から減らします。
アーキテクチャ設計をAntigravityと学ぶ価値
Antigravity を使って設計を学ぶとき、私が意識しているのは「Antigravity が提案した設計を、なぜその設計にしたかを理解してから採用する」という姿勢です。
今回の AdFreeManager + BillingManager + ModalGate という組み合わせも、最初の提案のままではなく、いくつかの問い返しを経て今の形になっています。「なぜシングルトンなのか」「Dependency Injection を使った場合との比較は」「テスタビリティへの影響は」といった問いを返すたびに、設計の理解が深まりました。
Antigravity との設計の会話は、コードを手に入れるための手段ではなく、設計力を身につけるための学習プロセスとして機能します。生成されたコードを採用するかどうかは最終的に自分が判断するという姿勢が、長期的な保守性につながります。
全体を振り返って:今日から始める最初の一歩
自分のアプリに課金機能がある方は、まずこれを確認してみてください。
grep -rn "is_ad_free\|isPurchased\|isAdFree\|isAdRemoved" \
app/src/main/java/ --include= "*.kt" --include= "*.java" | wc -l
10件以上ヒットするなら、Source of Truth の設計を検討する価値があります。まず Antigravity に既存コードを見せて「課金状態の Source of Truth を設計したい」と伝えてみてください。コードより先に「どこが問題で、何を目指すのか」を整理してもらうことで、リファクタリングの方向性が明確になります。
Source of Truth は、地味ですが確実に効く設計投資です。機能を追加するたびに課金状態の判定を書き直す必要がなくなり、バグの追跡が1か所に収束します。個人で長くアプリを育て続けるための、静かな土台になります。
最後までお読みいただきありがとうございました。同じ課題に取り組んでいる方の参考になれば幸いです。
個人開発者がアーキテクチャ設計を学ぶ意味
個人開発でアーキテクチャ設計を学ぶことには、企業の開発とは異なる意味合いがあります。企業では、設計の悪さはチーム全体の生産性の問題です。しかし個人では、設計の悪さは直接、運営の持続可能性に直結します。
Beautiful HD Wallpapers を2013年にリリースして以来、コードベースは少しずつ変化し続けています。最初のバージョンは Objective-C で書かれていました。Java に移行し、やがて Kotlin になりました。UI も何度か刷新されています。この間、機能が増えるたびに当時のベストプラクティスでコードを追加してきました。
問題は、それぞれの時代のベストプラクティスが必ずしも互いに一貫していないことです。2015年頃に書かれた SharedPreferences 周りのコードと、2022年に追加されたリワード広告の状態管理は、異なる前提の上に書かれています。この「時代の差」が、技術的負債の本質的な発生源です。
Source of Truth のような設計原則を採用することの価値は、未来の自分への配慮です。3年後の自分が機能を追加するとき、今書いたコードがどれだけ「追加しやすい構造」になっているかを考えること。Antigravity はその「未来への配慮」を設計の言葉で具体化する手助けをしてくれます。
設計判断の記録という習慣
もうひとつ、Antigravity との設計の会話を通じて学んだことがあります。設計判断を記録しておくことの価値です。
なぜ ShradPreferences を Source of Truth にしなかったのか。なぜ StateFlow を選んだのか。ModalGate に AtomicReference を使った理由は何か。これらは、数年後に同じコードを読んだとき(多くの場合、それは未来の自分です)、「なぜこうなっているのか」を理解するための文脈です。
Antigravity との会話ログを DEVELOPMENT_NOTES.md として残しておく、という習慣を始めました。コードのコメントとは異なり、設計判断の経緯と却下した代替案を記録しておくことで、リファクタリング時の意思決定が速くなります。「ここはこういう理由で AtomicReference にした。Mutex に変えると〇〇の問題がある」という記録が、将来の自分の時間を節約します。
累計5,000万DLを超えるアプリを個人で運営するということは、事業の設計とコードの設計が一致していなければ持続できない、ということでもあります。良い設計は、単なる技術的な美しさではなく、長く運営し続けるための実用的な基盤です。Antigravity はその基盤を作る思考の相手として、実際に役立っています。
Google Play Billing Library のバージョンアップへの備え
Source of Truth 設計のもうひとつの利点は、ライブラリのバージョンアップへの耐性です。
Google Play Billing Library は定期的にAPIが変わります。2026年時点では v7 系が標準ですが、いずれ v8 や v9 が出てくるでしょう。このとき、課金状態の判定が5か所に散らばっていれば、5か所全てを修正する必要があります。ひとつ見落とせば、そこだけ古いAPIを使い続けることになります。
BillingManager にラッパーを設けることで、API変更の影響範囲が BillingManager 内部に閉じます。handlePurchases の実装が変わっても、isPurchased() と isPurchasedFlow のインターフェースが保たれていれば、呼び出し側のコードは一切変更不要です。
AdFreeManager も同様です。将来「定期購読型の ad-free プランを追加する」という要件が出てきたとき、変更は AdFreeManager の isAdFree の判定ロジックに対して billingManager.isSubscribed() を追加するだけです。Fragment やActivity のコードは変更しません。
これは個人開発において特に重要です。企業なら複数人でコードレビューしながらライブラリ更新できますが、個人では自分ひとりで全ての変更点を追う必要があります。影響範囲を最小化する設計は、そのままメンテナンスコストの削減です。
副次効果:レビューへの返答品質が上がる
Source of Truth 設計の話からは少し外れますが、バグが減ることで生まれる副次効果についても触れておきたいと思います。
「広告が消えない」というレビューへの返答は、バグが再現しているときと修正済みのときとで、質が大きく変わります。再現中は「ご不便をおかけして申し訳ございません。現在調査中です」という曖昧な返答しかできません。しかし修正済みであれば「v2.1.0 でこの問題を修正しました。アップデートをお試しください」と具体的に案内できます。
Beautiful HD Wallpapers は日本語・英語・中国語・スペイン語など30以上の言語のユーザーがいます。多言語でのレビュー返答は Antigravity を使って処理していますが、返答の質は「修正済みかどうか」という事実が土台になります。設計が良くなることは、ユーザーコミュニケーションの質にも直結します。
個人で長くアプリを育てていくとき、技術的な品質とユーザーとの信頼関係は切り離せません。Source of Truth のような設計原則は、コードの話でありながら、ユーザーとの関係を守る話でもあります。設計に投資することは、ユーザーへの投資でもあると感じています。