「課金したのに広告が消えません」。個人開発で運営している壁紙アプリの問い合わせ欄に、この一文が月に数件、静かに積み上がっていました。決済ログを見ると購入は成立しています。App Store のサンドボックスで再現を試みても、私の手元では毎回きれいに広告が消えます。再現しない不具合ほど、胸の奥に小さな石が残ります。
原因は、購入処理そのものではありませんでした。購入が成立した「その瞬間」ではなく、アプリを次に起動した瞬間に届くトランザクションを、アプリ側が受け取り損ねていたのです。StoreKit 2 の Transaction.updates を、画面(View)の寿命に紐づけて監視していたことが競合の温床でした。
この記事は、その競合をどう突き止め、リスナーをアプリの寿命に固定し直し、最後に Antigravity へ回帰テストの作成を任せて再発を防いだかの記録です。StoreKit 2 で購読や単体購入を扱う個人開発者に、同じ石を胸に残してほしくないという気持ちで書いています。
「決済は成立、UI は据え置き」がなぜ起きるのか
StoreKit 2 では、直接の購入フロー(product.purchase() の戻り値)以外の経路でトランザクションが届きます。自動更新、家族の承認待ち(Ask to Buy)の後日承認、別デバイスでの購入、返金や失効。これらは購入ボタンの戻り値には現れず、Transaction.updates という非同期シーケンスから流れてきます。
問題は配信のタイミングです。Ask to Buy で保護者が承認したトランザクションは、子どもの端末でアプリが閉じられている間に確定します。次にアプリを起動したとき、StoreKit はそのトランザクションを Transaction.updates に流そうとします。ここでリスナーがまだ動いていなければ、権利の付与が後手に回ります。
私の実装は、購読状態を管理する SwiftUI ビューの .task 修飾子の中でリスナーを起動していました。一見きれいに見えます。けれど .task はビューの表示・非表示に連動します。ビューが消えるとリスナーは中断され、別画面から戻ってくるまで Transaction.updates を誰も見ていない空白が生まれます。決済は Apple 側で成立しているのに、アプリ内の権利フラグと広告の出し分けだけが古いまま。これが「課金済みなのに広告が消えない」の正体でした。
StoreKit は未完了トランザクションを保持し、次回起動で再配信します。つまりデータが永久に失われるわけではありません。失われるのは読者の信頼です。「お金を払ったのに変わらない」という体験は、一度きりで課金をやめる十分な理由になります。
リスナーは View ではなくアプリの寿命に固定する
再設計の核心は一つです。トランザクションの監視を、どの画面が表示されているかから完全に切り離し、アプリが生きている限り止まらないタスクにすること。SwiftUI であれば App の初期化時点で、キャンセルされない Task として起動します。
import StoreKit
import SwiftUI
@MainActor
final class EntitlementStore : ObservableObject {
@Published private ( set ) var hasPremium = false
// アプリの寿命に紐づく監視タスク。View ではなく Store が保持する
private var updatesTask: Task< Void , Never > ?
func startListening () {
// 二重起動を防ぐ。既に走っていれば何もしない
guard updatesTask == nil else { return }
updatesTask = Task. detached ( priority : .background) { [ weak self ] in
// このループはアプリが終了するまで回り続ける
for await update in Transaction.updates {
await self ? . handle (update)
}
}
}
private func handle ( _ result: VerificationResult<Transaction>) async {
guard case . verified ( let transaction) = result else {
// 署名検証に失敗したトランザクションは権利付与に使わない
return
}
await refreshEntitlements ()
// 権利へ反映したら必ず finish する。これを怠ると再配信が止まらない
await transaction. finish ()
}
}
要点は Task.detached を App 起動直後に一度だけ呼ぶことです。私は App の init() で EntitlementStore を生成し、startListening() をその場で呼ぶ形にしました。ビューがどれだけ入れ替わっても、監視ループは一本のまま生き続けます。
for await update in Transaction.updates を回すループは、意図的にキャンセルしません。StoreKit の公式ガイダンスも、この監視はアプリ起動のできるだけ早い段階で始め、アプリの寿命の間ずっと維持するよう求めています。.task 修飾子に頼ると、この「ずっと」が「表示されている間だけ」に縮んでしまいます。
起動時に currentEntitlements で状態を照合する
リスナーを固定しただけでは、まだ穴があります。Transaction.updates は「これから起きる」更新を流すシーケンスです。アプリが閉じている間に確定し、既に配信済みとしてキューに残っていない過去の権利までは、必ずしも起動直後に再送されるとは限りません。現在有効な権利の正本は Transaction.currentEntitlements です。
そこで起動シーケンスを二段構えにしました。まず currentEntitlements で現在の権利を総ざらいして UI を確定させ、その後に Transaction.updates の監視へ引き継ぎます。
extension EntitlementStore {
// 起動時に一度だけ呼ぶ。現在有効な権利をすべて照合する
func refreshEntitlements () async {
var owned = false
for await result in Transaction.currentEntitlements {
guard case . verified ( let transaction) = result else { continue }
if transaction.revocationDate == nil && transaction.productType == .autoRenewable {
owned = true
}
// 単体購入(非消耗型)の権利も同じ経路で拾う
if transaction.productType == .nonConsumable {
owned = true
}
}
hasPremium = owned
}
// App 起動直後に呼ぶ入口
func bootstrap () async {
await refreshEntitlements () // 1. 現在の権利を照合
startListening () // 2. 以降の更新を監視へ引き継ぐ
}
}
revocationDate の判定を忘れると、返金済みの権利を有効と誤認します。ここは私自身が最初に落とし穴にはまった箇所でした。currentEntitlements は失効・返金されたトランザクションを含めて返す場合があり、revocationDate が nil かどうかを必ず確かめる必要があります。この一行の欠落が、返金後も広告を消し続けるという逆向きの不具合を生みます。
finish() を忘れると再配信が止まらない
await transaction.finish() は地味ですが、StoreKit 2 でもっとも見落とされやすい一行です。トランザクションを finish() しない限り、StoreKit はそれを「未処理」と見なし、起動のたびに Transaction.updates へ再配信し続けます。
再配信そのものは害ではありません。害になるのは、再配信のたびに重い処理を走らせてしまう設計です。私の旧実装では、トランザクション受信のたびにサーバーへレシート照合のリクエストを投げていました。finish() を付け忘れていたため、未完了トランザクションを持つユーザーの端末では、起動ごとに同じ照合が走り、無駄なネットワークと待ち時間が積もっていました。
対処の順序は決まっています。権利をアプリ内に反映し、(サーバー照合が必要ならそれも終え)、その上で finish() を呼ぶ。権利付与の前に finish() してしまうと、付与に失敗したときに再送の保険を失います。付与が確実に成功したことを見届けてから完了印を押す、という順序が安全です。
Antigravity に StoreKitTest で競合そのものを再現させる
ここからが、この不具合を「二度と起こさない」ための工程です。手作業のサンドボックス検証では、Ask to Buy の後日承認をアプリ非起動中に挟むという競合を、狙って再現できません。Xcode の StoreKitTest フレームワークなら SKTestSession を使って、承認・更新・返金をコードから注入できます。
私は Antigravity のエージェントに、競合の再現条件を言葉で渡しました。「アプリ起動前に Ask to Buy を承認済みにし、起動シーケンスで currentEntitlements 照合 → updates 監視の順に処理したとき、hasPremium が true になることを検証するテストを書いてほしい」。エージェントは .storekit 設定ファイルを読み、次のようなテストを組み立てました。
import StoreKitTest
import XCTest
@testable import WallpaperApp
final class EntitlementRaceTests : XCTestCase {
private var session: SKTestSession !
override func setUp () async throws {
session = try SKTestSession ( configurationFileNamed : "Products" )
session. clearTransactions ()
session.disableDialogs = true
}
// 起動前に確定した購入を、起動シーケンスが取りこぼさないことを検証
func testEntitlementResolvedAtLaunch () async throws {
// アプリ非起動中に購入が確定した状況を再現
try await session. buyProduct ( identifier : "com.dolice.wallpaper.premium" )
// 起動シーケンスを実行(照合 → 監視の順)
let store = await EntitlementStore ()
await store. bootstrap ()
let hasPremium = await store.hasPremium
XCTAssertTrue (hasPremium, "起動時照合で権利が反映されていない(競合の再発)" )
}
// 返金後に権利が正しく失効することを検証
func testEntitlementRevokedAfterRefund () async throws {
let result = try await session. buyProduct ( identifier : "com.dolice.wallpaper.premium" )
let store = await EntitlementStore ()
await store. bootstrap ()
try session. refundTransaction ( identifier : UInt (result.id))
await store. refreshEntitlements ()
let hasPremium = await store.hasPremium
XCTAssertFalse (hasPremium, "返金後も権利が残存している(revocationDate 未チェック)" )
}
}
エージェントに任せて良かったのは、SKTestSession の API 形状を私が記憶に頼らず済んだ点です。私は競合の「意味」をレビューに集中できました。生成されたテストをそのまま信じたわけではありません。clearTransactions() の呼び出し位置、disableDialogs の要否、非同期の待ち合わせの取りこぼしを一つずつ確かめ、二箇所だけ手で直しました。エージェントに任せる範囲と、人間が判断する範囲。その線引きこそが、この道具を長く使うための勘所だと感じています。
導入して2週間の実測
再設計を反映したビルドを App Store に出し、2週間観測しました。「課金済みなのに広告が消えない」という主旨の問い合わせは、それまで週あたり数件あったものが 0 件になりました。返金後に広告が復活しない逆向きの不具合も、revocationDate の照合を入れてから報告が止まっています。
副次的な効果もありました。起動ごとに走っていた無駄なレシート照合が消え、未完了トランザクションを抱えていた一部端末で、起動からホーム表示までの体感が軽くなりました。数値で誇るほどの差ではありませんが、finish() 一行の重みを改めて教わりました。再現しない不具合を追うより、再現条件をテストへ固定してしまうほうが、結果として時間を節約できると実感しています。
AdMob の広告出し分けと課金権利は、本来まったく別のレイヤーです。それでも両者は「このユーザーは課金済みか」という一点で交差します。その一点の判定を、画面の都合ではなくアプリの寿命に固定できたことが、今回の最大の収穫でした。
次の一手
もし手元のアプリで StoreKit 2 のリスナーを View の .task に置いているなら、まずはその一箇所だけを App 起動時のタスクへ移してみてください。currentEntitlements の起動時照合と finish() の順序は、その後に足しても間に合います。そして再現しづらい競合こそ、StoreKitTest で「起きる条件」をコードに固定し、回帰テストとして残す価値があります。監視タスクは App 起動時に一度だけ起動する形を推奨します。私自身は、リスナーの起動箇所をコードレビューの必須チェック項目に加えることをお勧めしています。起動シーケンスは短いほど競合の入り込む隙が減るため、照合と監視の二段だけに絞り、余計な初期化を挟まない構成が安全です。
私自身、まだ StoreKit 2 の細部を学び続けている途中です。同じ石を胸に抱えた個人開発者の方と、静かに知見を持ち寄っていけたらうれしく思います。お読みいただき、ありがとうございました。