ストリーミングはほとんどのブラウザと
Developerアプリで視聴できます。
-
プロアクティブなApp内課金の復元機能を実装する
Appを最初に開いたときに、ユーザーのApp内課金へのアクセスをプロアクティブに復元する方法をご覧ください。StoreKitやStoreKit 2を使用して、既存のサブスクリプションへのインスタントアクセスを提供する方法をはじめ、クライアントとサーバの両方で実装するベストプラクティスも紹介します。ユーザーの購入状況を判断する方法や、あなたのAppでカスタマイズされたオンボーディングエクスペリエンスを作成する方法をご覧ください。
リソース
- App Store Server Notifications
- CloudKit
- Determining service entitlement on the server
- Implementing a store in your app using the StoreKit API
- Introducing StoreKit 2
- Reducing Involuntary Subscriber Churn
関連ビデオ
WWDC22
Tech Talks
WWDC21
WWDC20
-
ダウンロード
こんにちは David Wendlandです App Storeのコマース 技術アドボケートです ユーザーが何もしなくても ユーザーの新規 現在 過去の 購入履歴を プロアクティブに識別し ユーザーのAppでファーストクラスの 体験を提供する方法をご紹介します StoreKit 2とオリジナルの StoreKitでこれを行う方法を説明し あなたのAppの オンボーディング体験を すべてのユーザーに 最適化できるようにします プロアクティブなApp内課金の復元 の定義から説明します ユーザーがAppを起動すると デバイス上ですぐに 利用できるデータを使って そのユーザーが新規ユーザーか 既存ユーザーかを判断するための トランザクションを プロアクティブにチェックし 「購入履歴の復元」ボタンの タップやパスワード入力などの ユーザーのアクションを必要とせずに それを実行することができます これにより 現在のユーザーにはAppで 商品やサービスを提供し 新規ユーザーには Appで最新の商品を提供し 過去のサブスクリプション登録者には 登録特典を提供するなど ユーザーの購入履歴や 状態に合わせた App体験が 可能になります これがプロアクティブな 復元です StoreKitを使用して 新規 既存 過去のユーザーに対し 全デバイスで Appの体験を 自動的に最適化します この例を見てみましょう 私たちのOcean Journalという Appを紹介します これはよくある マーケティング方法で 異なるコールトゥアクションから 選択することができます App内課金を試みて FaceIDなどの 生体認証で認証するか Appアカウントを 作成していれば サインインし 場合によっては キーチェーンでパスワードを入力するか 自分がアクティブな サブスクリプション登録者ならば 「購入履歴の復元」 ボタンで認証します 新しいデバイスで アクティブなサブスクリプション登録者にとっては どの選択肢を選べばいいのか 必ずしも明確ではありません また Appのデータが すぐに利用できるため 当社のプロアクティブな App内課金の復元 ベストプラクティスによって この体験を合理化できます もし私が 新しい端末でこのAppを起動し すでにサブスクリプション登録していた場合 Appを起動した時点で 何もしなくても 自動的にサービスが再開されます Appが私の Proサブスクリプションを認識し お気に入りのビーチを読み込んで 波のコンディションを表示し ライブカメラ機能を 有効にしました この体験によって あなたのAppは他と差別化され 今回は iOS 15以降のStoreKit 2で これを行う方法について説明します さらに あなたのAppが 以前の版のiOSをサポートする場合 オリジナルのStoreKitとverifyReceipt エンドポイントを使って この同じ素晴らしい体験を 作り出す方法を説明します そのような背景を踏まえて 今回取り上げるのは以下の通りです まず AppがStoreKitを使って ユーザーのApp内購入に基づき パーソナライズされた 体験を生成するために使う コアとなるユーザーのプロダクト状態 について詳しく説明します そして StoreKit 2を使った実装の手順を SK Demo Appを使った サンプルコード付きで 確認します App内課金の種類とそのコアとなる ユーザーのプロダクト状態を見て
パーソナライズされたオンボーディング体験 のいくつかの例を見てみましょう まず プロアクティブな 復元に適用される App内課金の種類は 非消耗型 非更新サブスクリプション 自動更新サブスクリプションです これらはすべて ユーザーの取引履歴に残り 常にStoreKitで 利用できます そのため Appはユーザーごとに 各商品やサブスクリプショングループの 購入状況を 特定することができます 非更新サブスクリプションと 自動更新サブスクリプションについては ユーザーのプロダクト状態を確認しながら 「サブスクリプション」という言葉を使い 両方を参照する ことにします Aooがパーソナライズできる 3つのコア状態を説明します 新規のお客様を 細かく確認してみましょう この状態は 現在または過去の App内課金取引がない サインインしたApp Storeの Apple IDを表します この状態は通常 Appのデフォルトの マーケティング方法として使われます Ocean Journal Appは 月刊と年刊を 1ヶ月の無料トライアルで 商品化しています 2つ目のコア状態として 「購入者」と「アクティブ加入者」があります この状態でユーザーは アクティブな取引を行っており あなたのAppは 購入した商品やサービスへの アクセスをユーザーに 許可する義務があります Ocean Journal Appは プレミアムライブビーチカムで ユーザーが希望する ビーチをすぐに紹介します プロアクティブにサービスを再開したため 購入ボタンは表示されていません 各購入商品または 有効なサブスクリプションについて トランザクションは静的かつ 一意のオリジナルトランザクションIDを持ち このIDはユーザーのApple IDと ストアフロントに対して永続的です お客様の取引状況を 管理するために オリジナルの取引IDをシステム上の アカウントに関連付けます 匿名アカウント またはユーザーがあなたのシステムで 作成したアカウントの いずれかを使用できます App Storeサーバ通知の 機能を活用する場合 元のトランザクションIDを 知ることは非常に重要で サーバーでトランザクションの状況を 常に把握できます 例えば ユーザーのサブスクリプションが 自動更新されず 「課金リトライ」と 呼ばれる状態になった場合 最長で60日間 サブスクリプションの回復を 試みるという シナリオがあります あなたがApp Store Connectの請求の 猶予期間機能を有効にしている場合 請求の猶予期間に入っている サブスクリプション登録者は Appleがサブスクリプションの 回復を試みる間 サブスクリプションサービスへのアクセス を継続することができます 彼らがまだサービスに アクセスしている間に 支払いの 問題を解決するための 簡単なコールトゥアクション 提示するようにしてください 請求の再試行と 請求の猶予期間について詳しくは 不本意なサブスクリプション登録者の 喪失を減らすための セッションリンクと リソースをご覧ください 最後のコア状態は 非アクティブな 購入者またはサブスクリプション登録者です この状態は 過去にApp内課金を行ったが 有効期限切れや 取り消しにより その商品やサービスを受ける 権利を失ったユーザーを表します これらの取引は永続的で オリジナルの取引IDを含んでいるため デバイスやプラットフォームを超えて ステータスを維持できます サブスクリプションの場合 非アクティブは有効期限で判断されます App内課金の場合 失効日がある場合は 非アクティブになります これは 取引が払い戻された場合 またはファミリー共有を 通じて付与されたアクセスが 取り消された場合に発生します 有効期限が切れたり 無効になった アクティブでない登録者に対しては サブスクリプションを再開してもらうための オファーの表示を検討しましょう また 請求の再試行の 状態にあるユーザーには 支払い詳細を 解決するための行動を 促すことを 忘れないでください App内で購入された商品の 復元や ユーザーに合わせたAppの カスタマイズを行うために Appが使用する 3つの状態を説明します これらの体験をOcean Journal Appと 一緒に並べて見てみましょう
新規ユーザーには 最新の商品や 紹介キャンペーンを見せます 現在アクティブなユーザーは すべてのデバイスから あなたのAppの商品やサービスに 効率的にアクセスできるため 快適に感じることでしょう アクティブでない サブスクリプション登録者には オファーコードや プロモーションオファーを使い 最新のウィンバックオファーを 表示できます ここまで3つのユーザーの コアな状態について説明し これらの状態をサポートするだけで ユーザーの大きな利益になると説明しました しかしもちろん その体験を さらに発展させるチャンスもあります Appは 提供する商品 ビジネスモデル ポリシー 優先順位に合わせて ユーザー体験を拡張したり 改良したり することができます しかし Appにプロアクティブな 復元を実装する 準備をする際に 考慮すべき点がいくつかあります
複数の商品または サブスクリプション グループをサポートする場合 ユーザーの状態は 各商品および 各グループごとに決定されます したがって ハイブリッド状態や その他の依存関係を 考慮する必要が あるかもしれません プラットフォーム外の 活動や それがユーザーのプロダクト状態に どう影響するかを検討します また App Storeサーバ通知は すべてのApp内課金タイプの サーバー間の ステータスを 維持するために重要です バージョン2では 新しい通知タイプとサブタイプが 28のユニークな イベントをサポートし ほぼリアルタイムでサーバに安全に 送信されます バージョン2への統合や 移行については 「App内課金の統合と 移行の検討」で解説しています StoreKit 2とオリジナルの StoreKitフレームワークとの 互換性やベストプラクティス についても解説しています ユーザーのプロダクト状態や そのサポート そしてその体験が どうユーザーのためになるか をお話ししてきました では 実装の詳細を 説明しましょう 今回は StoreKit 2を使った プロアクティブな復元を利用して アップデートしたSK Demo Appを使ってみます このセッションでSK Demo Appが ダウンロード可能になります ここでSK Demoの新規ユーザー (App内課金を行っていないユーザー) に対するデフォルトの体験を 確認しましょう 「Shop」ボタンをタップすると 商品が表示されます App内課金で 購入可能な車種は 上部に表示されています ナビゲーションサービスは 月額の自動更新サブスクリプションで ユーザーが選べる3つの サービスレベルを用意しています またその下には 1度だけ アクセス可能な 非更新サブスクリプションを 用意しています これは 商品が購入されて いない状態での Appの新しいユーザー体験を お見せするものです このAppがどのように ユーザーの現在の購買履歴と 過去の購買履歴を判断しているか を見てみましょう Appを起動したらすぐに 3つのステップを 実行することが 求められます 最も重要なのは「Buy」 ボタンがユーザーに表示される前に これらのステップを 完了させることです 最初のステップは AppがApp Storeからの 取引に耳を傾ける 必要があることです App Storeの ベストプラクティスで ファミリー共有の承認と購入のリクエストや コードの利用 サブスクリプションの自動更新 または購入が中断された場合など 取引はいつでも表示される 可能性があるからです また 払い戻しにより アクセスができない場合や ファミリー共有で共有されなくなった トランザクションを Appで 受け取ることもあります これは すでに アクセスが許可され その状態がアクティブから 非アクティブに移行している場合 その後のAppの起動でより 適用されることになります トランザクションが見つかった場合 それは未完成のトランザクションとみなされ 検証を行い ユーザーへ送り 完了として マークする必要となります これにより Appが取引を見逃すことなく 優れたユーザー体験を 提供できます SK Demo AppがStoreKit 2でどう 取引を聞き取るか見てみましょう ここでは listenForTransactions という関数を使用します サインインしたApp Storeのユーザーに対し 未完了のトランザクション またはトランザクション の更新を返します ここで見つかった取引について StoreKit 2はこれらの 取引の真偽を検証します そして 私のAppが コンテンツを配信したり アクセスを許可したり ユーザーの商品ステータスを更新した後 私はApp Storeに購入が 配信されたことを示す為 取引を終了します トランザクションが終了すると StoreKitを介して どのデバイスでも あなたのAppに 戻されなくなります この最初のステップは 全Appにとって重要で 今後 すべてのAppの 起動時に発生します ステップ2は そのユーザーの プロダクト状態を決定し currentEntitlementsを使い 現在有効なトランザクションを プロアクティブに 要求することで行われます 特に自動更新の サブスクリプションについては キャンセル 請求の再試行 保留中のダウングレードなど ユーザーの更新状態を 考慮し さらに Product.SubscriptionInfo.RenewalState を使用することになります SK Demo Appでこれを どう実現するか見てみましょう これは 永続的なApp内 課金の種類ごとに ユーザーのプロダクト状態を 追跡する関数 updateCustomerProductStatus から始まります 次に StoreKit 2の currentEntitlementsメソッドを使い 購入タイプの それぞれをループします これは ユーザーが権利を有する可能性のある 商品の取引を返送します この取引を商品種別ごとに 記録しています 非消耗型の 商品についてはこちら 非更新サブスクリプションに ついてはこちら アクティブな登録者なのか 非アクティブな登録者なのかを 判断するために 非更新サブスクリプション登録者の 有効期限を計算する ロジックを追加しています 最後に アクティブな自動更新の サブスクリプションをチェックし その状態をサブスクリプション グループに適用してみます 請求の再試行 期限切れ 取り消しなどの 非アクティブな状態を考慮するため 可変サブスクリプション グループステータスには Product.SubscriptionInfo.RenewalState を使用しています ユーザーの トランザクションを取得し 各商品またはサブスクリプショングループの ユーザーステータスを決定したため Appには さまざまな ユースケースに合わせてApp体験を パーソナライズするための ロジックが用意されました SK Demo Appの ソースコードを見てみましょう 3つのApp内課金 商品タイプすべてにおいて 有効な取引が ないと判断された場合 ユーザーには先に説明したデフォルトの 新規ユーザー体験が表示され 「Shop」ページ へのシンプルな コールトゥアクションが 表示されます 購入履歴がある場合は App起動時に 購入履歴が表示され それに応じて全商品の 「Buy」ボタンが 更新されます ここでは非消耗型については 購入したものを表示し Appは購入された 非消耗型を表示するか ショップをに訪れるための コールトゥアクションを 提供します アクティブな商品については ユーザーが 非更新サブスクリプションおよび 自動更新サブスクリプションの ナビゲーションサービスのアクティブな 契約者であるかどうかを処理します 最後の部分は 非アクティブな サブスクリプション登録者を考慮したものです 契約期間が満了したもの 取り消されたもの 請求が再試行状態 にあるものです 次にSK Demo Appに アクセスしてみましょう 非消耗型と自動更新の 両方のアクティブな ユーザーをシミュレート したいと思います レースカーを購入し プロナビに加入すると デモAppでは それらの購入が成功し 確認され 有効になったことを示す 緑のチェックマークが 適用されます これらの購入により 非消耗型のための 私の商品に対するユーザー状態は 「購入済み」となります そして私は「アクティブな サブスクリプション登録者」となります これで 新しいデバイスに Appをインストールすると 初めてSK Demo Appを 起動したときに 1 2 3の ステップをプロアクティブに 実行するようになりました このデモAppでは 何もしなくても 購入した2つの商品への アクセスが回復しているのが おわかりいただけると思います デモAppのため 配信される商品は その程度となります しかしあなたのAppでは このプロセスでアクティブなユーザーが 既に所有している商品の 購入を提案されることなく 商品やサービスが自動的に有効に することができます 既存のユーザーにとっては これは素晴らしいことです サインインや「購入履歴の復元 」 のタップが不要になりました うまくいきました AppはAPIやデータを すぐに利用できます StoreKit 2で行うための 3つのステップを紹介しました では StoreKit 2の パワーを活用できない 旧バージョンのiOSで ユーザーに同じ体験を提供する 方法について 説明したいと思います オリジナルのStoreKitでは iOS 7以降のApp内課金を プロアクティブに復元することで StoreKit 2と同様の 手順でユーザーのプロダクト状態を 判断することになります ユーザーの プロダクト状態を判断するために サーバがverifyReceipt エンドポイントを使用し 最新の取引を検証し 取得することが必要です App StoreからAppをインストールすると デバイスにAppレシートが保存されます しかし Sandboxや TestFlightでテストする場合 App内課金が完了するか 復元された後でなければ Appのレシートはでないことに 留意してください Appのレシートが 存在しないと判明した場合 これはSandbox でのみ発生し App内課金が発見されない 新規ユーザーと同じシナリオと考えます App Storeから最新のトランザクションを 取得するためには 過去に作成した Appレシートで十分です そのため「購入履歴の復元」や 「レシートの更新」などの カスタマーアクションは 必要ありません 非消耗型 非更新サブスクリプション 自動更新サブスクリプションの 最新トランザクション を受信するために verifyReceiptの リクエストに共有シークレットを 含めるだけです ここで 先ほど確認した3つの 導入ステップを振り返ってみましょう この違いは ステップ2の ユーザーのプロダクト状態の 把握にあります ユーザーのプロダクト状態の判断方法は デバイス上のAppのレシートから始まり サーバがApp Store verifyReceipt エンドポイントを使い検証します このプロセスを 見てみましょう まず Appのレシートを 取得する必要がありますが 当社のデベロッパ向けドキュメント にあるこのサンプルにあるように appStoreReceiptURLプロパティを 使っていることを確認してください Appのレシートで デバイスからサーバとApp Storeに どのように送信されるか 見てみましょう デバイス上のAppは この左側にあります まず Appの レシートを取得して サーバーに送信し App Store verifyReceipt エンドポイントで検証します その応答から お客様の商品の状態を判断し その状態をAppに送ります お客様のプロダクト状態を 把握するために WWDC2020の Entitlement Engineを 使用しました 非消耗型や非更新サブスクリプション に対応し App内課金がない場合の 新規ユーザー状態も扱えるよう更新しました
Entitlement Engineの 使用方法については 「サブスクリプションのための アーキテクチャ設計」セッションで サンプルプロジェクトの ダウンロードをおすすめします このビデオのリソースで セッションへのリンクを見れます これで サーバからユーザーの プロダクト状態を受け取る ステップ2が完了しました StoreKit 2とオリジナルの StoreKitフレームワークを使い App起動時にすぐにApp体験を パーソナライズできるようになりました 最後にベストプラクティスを 紹介したいと思います まず App内に「購入履歴の復元」 ボタンを用意し続けることです あまり使われませんが 問題が発生した場合や ユーザーが別のApple IDを使用する場合 Appに強制的にApple IDの トランザクションを 復元させる 機会を提供します ユーザーApp内課金をデバイス上で 初めてプロアクティブに復元する場合 Appを最適化し ユーザーのプロダクト状態を判断するのに 役立つデータを安全に 保存することが推奨されます CloudKitは 柔軟性 セキュリティ そしてユーザーのデバイス間で 同期する機能を備えているため 検討すべき機能です StoreKitを使用する場合 実装テストは非常に重要です StoreKit 2では Sandbox TestFlight Xcode StoreKitのテストで プロアクティブな復元実装を テストができます オリジナルのStoreKitを 使用している場合 SandboxやTestFlightでのテスト時には Appの領収書がないことがありますが App StoreからAppを インストールした場合 常に存在することを 覚えておくことが重要です Appのレシートがない場合 Appはデフォルトの 新規ユーザー体験を使用し 購入履歴の復元ボタンをすぐに 利用できることをお勧めします Appをアップデートすることで ユーザーのアクションタップや 認証なしに購入の有無を プロアクティブに確認できます 新規ユーザー アクティブユーザー 非アクティブユーザーのプロダクト状態に合わせ App起動時にすぐにユーザー体験を 調整できるようにします App Storeサーバ通知V2を 導入することで すべてのApp内課金タイプ について サーバ間でユーザーの トランザクションの ステータスを維持できます これにより ユーザーの バックエンドは 返金 トランザクションの取り消し サブスクリプションの更新 請求の再試行 有効期限切れなど トランザクションで発生した あらゆる変更を ほぼ リアルタイムで知ることができます ありがとうございました DaniとIanがStoreKit Server API サーバ通知V2の 素晴らしいアップデート についてお伝えする 「App内課金の最新情報」も ぜひご覧ください ありがとうございました お気をつけて
-
-
11:16 - Transaction Listener at app launch
//Transaction Listener at app launch func listenForTransactions() -> Task<Void, Error> { return Task.detached { //Iterate through any transactions which didn't come from a direct call to `purchase()`. for await result in Transaction.updates { do { let transaction = try self.checkVerified(result) //Deliver products to the user. await self.updateCustomerProductStatus() //Always finish a transaction await transaction.finish() } catch { //StoreKit transaction failed verification, don't deliver content to user. print("Transaction failed verification") } } } }
-
12:27 - Determine customer product state
//Determine customer product state func updateCustomerProductStatus() async { var purchasedCars: [Product] = [] var purchasedSubscriptions: [Product] = [] var purchasedNonRenewableSubscriptions: [Product] = [] //Iterate through all of the user's purchased products. for await result in Transaction.currentEntitlements { do { //First check if the transaction is verified. If the transaction is not verified //we'll catch the `failedVerification` error. let transaction = try checkVerified(result) //Check the `productType` of the transaction and get the corresponding product from the store. switch transaction.productType { case .nonConsumable: if let car = cars.first(where: { $0.id == transaction.productID }) { purchasedCars.append(car) } //..
-
12:56 - Determine customer product state
//Determine customer product state case .nonRenewable: if let nonRenewable = nonRenewables.first(where: { $0.id == transaction.productID }), transaction.productID == "nonRenewing.standard" { //Non-renewing subscriptions have no inherent expiration. let currentDate = Date() let expirationDate = Calendar(identifier: .gregorian).date(byAdding: DateComponents(year: 1), to: transaction.purchaseDate)! if currentDate < expirationDate { purchasedNonRenewableSubscriptions.append(nonRenewable) } } //..
-
13:09 - Determine customer product state
//Determine customer product state case .autoRenewable: if let subscription = subscriptions.first(where: { $0.id == transaction.productID }) { purchasedSubscriptions.append(subscription) } default: break } } catch { print() } } //Update the Store information with the purchased products. self.purchasedCars = purchasedCars self.purchasedNonRenewableSubscriptions = purchasedNonRenewableSubscriptions self.purchasedSubscriptions = purchasedSubscriptions //Check subscriptionGroupStatus to learn auto-renewable subscription state subscriptionGroupStatus = try? await subscriptions.first?.subscription?.status.first?.state }
-
13:45 - Updating my car view at app launch
//Updating my car view at app launch if store.purchasedCars.isEmpty && store.purchasedNonRenewableSubscriptions.isEmpty && store.purchasedSubscriptions.isEmpty { VStack { Text("SK Demo App") .bold() .font(.system(size: 50)) .padding(.bottom, 20) Text("🏎💨") .font(.system(size: 120)) .padding(.bottom, 20) Text("Head over to the shop to get started!") .font(.headline) NavigationLink { StoreView() } //… } } }
-
13:59 - Updating my car view at app launch
//Updating my car view at app launch else { List { Section("My Cars") { if !store.purchasedCars.isEmpty { ForEach(store.purchasedCars) { product in NavigationLink { ProductDetailView(product: product) } label: { ListCellView(product: product, purchasingEnabled: false) } } } else { Text("You don't own any car products. \nHead over to the shop to get started!") } } //…
-
14:20 - Updating my car view at app launch
//Updating my car view at app launch Section("Navigation Service") { if !store.purchasedNonRenewableSubscriptions.isEmpty || !store.purchasedSubscriptions.isEmpty { ForEach(store.purchasedNonRenewableSubscriptions) { product in NavigationLink { ProductDetailView(product: product) } label: { ListCellView(product: product, purchasingEnabled: false) } } ForEach(store.purchasedSubscriptions) { product in NavigationLink { ProductDetailView(product: product) } label: { ListCellView(product: product, purchasingEnabled: false) } } }
-
14:30 - Updating my car view at app launch
//Updating my car view at app launch else { if let subscriptionGroupStatus = store.subscriptionGroupStatus { if subscriptionGroupStatus == .expired || subscriptionGroupStatus == .revoked { Text("Welcome Back! \nHead over to the shop to get started!") } else if subscriptionGroupStatus == .inBillingRetryPeriod { //Provide a deep link from your app to https://apps.apple.com/account/billing. Text("Please verify your billing details.") } } else { Text("You don't own any subscriptions. \nHead over to the shop to get started!") } } }
-
17:42 - Fetch App Receipt Data
//Fetch App Receipt Data public func getReceipt() { if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL, FileManager.default.fileExists(atPath: appStoreReceiptURL.path) { do { let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped) print(receiptData) let receiptString = receiptData.base64EncodedString(options: []) print("receipt send it to your server: \(receiptString)") // Read receiptData } catch { print("Couldn't read receipt data with error: " error.localizedDescription) } } }
-
-
特定のトピックをお探しの場合は、上にトピックを入力すると、関連するトピックにすばやく移動できます。
クエリの送信中にエラーが発生しました。インターネット接続を確認して、もう一度お試しください。