1. 簡介
上次更新時間:2023 年 7 月 11 日
如要在 Flutter 應用程式中加入應用程式內購項目,必須正確設定應用程式和 Play 商店、驗證購買交易,並授予必要權限 (例如訂閱福利)。
在本程式碼研究室中,您要將三種應用程式內購新增至應用程式 (為您提供的),並透過 Firebase 使用 Dart 後端驗證這些購買交易。該應用程式中的 Dash Clicker 應用程式含有使用 Dash 吉祥物做為貨幣的遊戲。您將新增下列購買選項:
- 可一次購買 2000 個破折號的購買選項。
- 只要購買一次即可升級,還能將舊款 Dash 打造成現代風格的 Dash。
- 將自動產生的點擊次數加倍的訂閱。
第一個購買選項可讓使用者直接受益於 2000 破折號。供使用者直接購買,開放多次購買。這稱為消耗性產品,因為它是直接消耗的,可以多次消耗。
第二個選項是將「破折號」升級為更漂亮的「破折號」。這個容器只能購買一次,而且不可購買。這類交易稱為非消耗性產品,因為應用程式不能使用,但永遠有效。
第三個購買選項為訂閱項目。訂閱期間,使用者就能更快取得 Dashes,但停止付費後,相關福利也會隨之失效。
後端服務 (系統也會為您提供) 以 Dart 應用程式的形式運作,驗證購買交易是否完成,並使用 Firestore 儲存這些內容。Firestore 縮短作業時間,不過您可以在正式版應用程式中使用任何類型的後端服務。
建構項目
- 您將擴充應用程式,支援消耗性交易和訂閱。
- 您也會擴充 Dart 後端應用程式,用來驗證及儲存已購買的商品。
課程內容
- 如何為可購買的產品設定 App Store 和 Play 商店。
- 如何與商店通訊,以驗證購物交易並將其儲存在 Firestore 中。
- 如何管理應用程式中的購買交易。
軟硬體需求
- Android Studio 4.1 以上版本
- Xcode 12 以上版本 (適用於 iOS 開發作業)
- Flutter SDK
2. 設定開發環境
如要開始進行本程式碼研究室,請下載程式碼,並變更 iOS 的軟體包 ID 和 Android 的套件名稱。
下載程式碼
如要從指令列複製 GitHub 存放區,請使用下列指令:
git clone https://github.com/flutter/codelabs.git flutter-codelabs
或者,如果您已安裝 GitHub 的 cli 工具,請使用下列指令:
gh repo clone flutter/codelabs flutter-codelabs
程式碼範例會複製到 flutter-codelabs
目錄,當中包含一系列程式碼研究室的程式碼。本程式碼研究室的程式碼位於 flutter-codelabs/in_app_purchases
。
flutter-codelabs/in_app_purchases
下方的目錄結構包含一系列快照,您應在每個已命名步驟的結尾處顯示這張快照。範例程式碼位於步驟 0,因此尋找相符的檔案非常簡單,如下所示:
cd flutter-codelabs/in_app_purchases/step_00
如果想要快轉或查看步驟完成後的畫面,請查看以您感興趣的步驟命名的目錄。最後一個步驟的程式碼位於 complete
資料夾下。
設定範例專案
在常用 IDE 中開啟 step_00
的範例專案。我們先前是使用 Android Studio 製作螢幕截圖,但使用 Visual Studio Code 也是個不錯的選擇。使用任一編輯器,確保已安裝最新的 Dart 和 Flutter 外掛程式。
您要執行的應用程式必須與 App Store 和 Play 商店溝通,才能瞭解哪些產品和價格。每個應用程式都有專屬 ID 可供識別。對 iOS App Store 而言,這稱為軟體包識別碼,在 Android Play 商店則是應用程式 ID。這類 ID 通常以反向網域名稱標記法建立,舉例來說,如要製作 flutter.dev 的應用程式內購應用程式,我們會使用 dev.flutter.inapppurchase
。假設應用程式的 ID,您現在要在專案設定中進行設定。
請先設定 iOS 軟體包 ID。
在 Android Studio 中開啟專案,在 iOS 資料夾上按一下滑鼠右鍵,點選「Flutter」,然後在 Xcode 應用程式中開啟該模組。
在 Xcode 的資料夾結構中,Runner 專案位於頂端,而 Flutter、Runner 和 Products 目標則位於 Runner 專案下方。按兩下執行者即可編輯專案設定,然後按一下登入與功能。在「團隊」欄位下方輸入您剛才選取的軟體包 ID,即可設定團隊。
您現在可以關閉 Xcode,然後返回 Android Studio 完成 Android 設定。方法是開啟 android/app,
底下的 build.gradle
檔案,然後將 applicationId
(下方螢幕截圖中的第 37 行) 變更為應用程式 ID,與 iOS 軟體包 ID 相同。請注意,iOS 和 Android 商店的 ID 不必完全相同,但保持相同並不容易出錯,因此在本程式碼研究室中,我們也會使用相同的 ID。
3. 安裝外掛程式
在程式碼研究室的這部分,您將安裝 in_app_purchase 外掛程式。
在 pubspec 中新增依附元件
將 in_app_purchase
新增至 pubspec 的依附元件中,將 in_app_purchase
新增至 pubspec:
$ cd app $ flutter pub add in_app_purchase
pubspec.yaml
dependencies:
..
cloud_firestore: ^4.0.3
firebase_auth: ^4.2.2
firebase_core: ^2.5.0
google_sign_in: ^6.0.1
http: ^0.13.4
in_app_purchase: ^3.0.1
intl: ^0.18.0
provider: ^6.0.2
..
按一下「pub get」下載套件,或是在指令列中執行 flutter pub get
。
4. 設定 App Store
如要設定應用程式內購項目並在 iOS 上進行測試,您必須在 App Store 建立新的應用程式,然後在該平台上建立可購買的產品。您不必發布任何內容,也不必將應用程式送交 Apple 審查。您必須擁有開發人員帳戶才能執行這項操作。如果沒有,請加入 Apple 開發人員計畫。
付費應用程式協議
如要使用應用程式內購功能,你還需與 App Store Connect 中的付費應用程式簽訂有效協議。前往 https://appstoreconnect.apple.com/,然後按一下「協議、稅金與銀行」。
這裡會顯示免費和付費應用程式的協議。免費應用程式的狀態必須為有效,付費應用程式的狀態為新的。請務必查看、接受條款,並輸入所有必要資訊。
正確設定一切後,付費應用程式的狀態就會生效。這點非常重要,因為在沒有有效協議的情況下,你就無法進行應用程式內購交易。
註冊應用程式 ID
請在 Apple 開發人員入口網站建立新的 ID。
選擇應用程式 ID
選擇應用程式
請提供一些說明,並設定軟體包 ID 來比對軟體包 ID 和先前在 XCode 中設定的值。
如要進一步瞭解如何建立新的應用程式 ID,請參閱開發人員帳戶說明。
建立新的應用程式
在 App Store Connect 中使用專屬軟體包 ID 建立新的應用程式,
如要進一步瞭解如何建立新應用程式及管理協議,請參閱 App Store Connect 說明。
如要測試應用程式內購,您需要有沙箱測試使用者。此測試使用者不應連至 iTunes,只能用來測試應用程式內購。不得使用已用於 Apple 帳戶的電子郵件地址。在「使用者和存取權」中,前往「沙箱」下方的「測試人員」,即可建立新的沙箱帳戶,或是管理現有的沙箱 Apple ID。
現在您可以在 iPhone 上設定沙箱使用者,方法是依序前往「設定」>App Store >沙箱帳戶。
設定應用程式內購項目
現在,請設定三個可購買的項目:
dash_consumable_2k
:可多次購買的消耗性商品,每次消費可獲得 2000 破折號 (應用程式內貨幣) 給使用者。dash_upgrade_3d
:不可消耗的「升級」也就是只購買一次的消費,而且會提供兩種截然不同的 Dash。dash_subscription_doubler
:訂閱項目在訂閱期間的每次點擊向使用者提供兩倍點數。
前往「應用程式內購」>管理。
使用指定 ID 建立應用程式內購項目:
- 將
dash_consumable_2k
設為「Consumable」。
使用 dash_consumable_2k
做為產品 ID。參考名稱只會用於應用程式商店連結,只需設為 dash consumable 2k
並新增您購買的本地化內容即可。呼叫購買交易 Spring is in the air
,並以 2000 dashes fly out
做為說明。
- 將
dash_upgrade_3d
設為非消耗性。
使用 dash_upgrade_3d
做為產品 ID。將參考名稱設為 dash upgrade 3d
,並新增購買交易的本地化版本。呼叫購買交易 3D Dash
,並以 Brings your dash back to the future
做為說明。
- 將
dash_subscription_doubler
設為自動續訂。
訂閱流程略有不同。首先,您必須設定參照名稱和產品 ID:
接著,您必須建立訂閱群組。如果多個訂閱項目屬於同一個群組,使用者就只能訂閱其中一個項目,但可以在這些訂閱項目之間輕鬆升級或降級。只要呼叫這個群組「subscriptions
」即可。
接著,輸入訂閱時間長度和本地化版本。將這個訂閱項目命名為「Jet Engine
」,說明為「Doubles your clicks
」。按一下「儲存」。
按一下「儲存」按鈕後,請新增訂閱價格。選擇想要的價格。
現在,你應該會在購買清單中看到這三項交易:
5. 設定 Play 商店
與 App Store 一樣,您也必須擁有 Play 商店的開發人員帳戶。如果還沒有帳戶,請註冊帳戶。
建立新的應用程式
在 Google Play 管理中心建立新的應用程式:
- 開啟 Play 管理中心。
- 選取「所有應用程式」>「建立應用程式。
- 為應用程式選擇預設語言並設定名稱。輸入要在 Google Play 顯示的應用程式名稱。您日後可以變更名稱。
- 指明應用程式是遊戲。日後可再變更這項設定。
- 指定您的應用程式是否收費。
- 新增電子郵件地址,方便 Play 商店使用者就這個應用程式相關事宜與您聯絡。
- 完成《內容規範》和美國出口法律聲明。
- 選取「建立應用程式」。
建立應用程式後,請前往資訊主頁,然後完成「設定應用程式」部分中的所有工作。您可以在此處提供應用程式的部分資訊,例如內容分級和螢幕截圖。
簽署應用程式
您必須先將至少一個版本上傳至 Google Play,才能測試應用程式內購。
為此,您需要使用偵錯金鑰以外的其他版本簽署發布子版本。
建立 KeyStore
如果您已有 KeyStore,請跳到下一個步驟。如果沒有,請在指令列中執行下列指令來建立。
如果是 Mac/Linux 電腦,請使用以下指令:
keytool -genkey -v -keystore ~/key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias key
在 Windows 上,使用以下指令:
keytool -genkey -v -keystore c:\Users\USER_NAME\key.jks -storetype JKS -keyalg RSA -keysize 2048 -validity 10000 -alias key
這個指令會將 key.jks
檔案儲存在主目錄中。如要將檔案儲存在其他位置,請變更傳遞至 -keystore
參數的引數。保留
keystore
檔案不公開;就不要將其列入公開原始碼控管!
從應用程式參照 KeyStore
建立名為 <your app dir>/android/key.properties
的檔案,其中包含 KeyStore 的參照:
storePassword=<password from previous step>
keyPassword=<password from previous step>
keyAlias=key
storeFile=<location of the key store file, such as /Users/<user name>/key.jks>
設定 Gradle 登入功能
編輯 <your app dir>/android/app/build.gradle
檔案,設定應用程式的簽署方式。
在 android
區塊前,加入屬性檔案中的 KeyStore 資訊:
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
// omitted
}
將 key.properties
檔案載入 keystoreProperties
物件。
在 buildTypes
區塊前方加入下列程式碼:
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now,
// so `flutter run --release` works.
signingConfig signingConfigs.debug
}
}
使用簽署設定資訊在模組 build.gradle
檔案中設定 signingConfigs
區塊:
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
應用程式的發布子版本現在會自動簽署。
如要進一步瞭解如何簽署應用程式,請前往 developer.android.com 參閱「簽署應用程式」一文。
上傳您的第一個版本
應用程式完成簽署設定後,您應該可以執行下列指令來建構應用程式:
flutter build appbundle
根據預設,這個指令會產生發布子版本,您可以在 <your app dir>/build/app/outputs/bundle/release/
找到輸出內容
在 Google Play 管理中心的資訊主頁中,依序前往「發布」>「版本」測試 >封閉測試,以及建立新的封閉測試版本。
在本程式碼研究室中,您只須使用 Google 簽署應用程式,因此請在「Play 應用程式簽署」下方按下「繼續」,選擇加入計畫。
接著,上傳由建構指令產生的 app-release.aab
應用程式套件。
按一下「儲存」,然後點選「檢查版本」。
最後,按一下「開始推出至內部測試」,即可啟用內部測試版本。
設定測試使用者
如要測試應用程式內購,您必須在 Google Play 管理中心的兩個位置新增測試人員的 Google 帳戶:
- 針對特定測試群組 (內部測試)
- 以授權測試人員的身分
首先,請將測試人員新增至內部測試群組。依序前往「發布」>「發布」測試 >內部測試,然後點選「測試人員」分頁標籤。
按一下「建立電子郵件名單」,建立新的電子郵件名單。為清單命名,然後新增需要存取測試應用程式內購功能的 Google 帳戶電子郵件地址。
接著,勾選清單的核取方塊,然後按一下「儲存變更」。
接著,新增授權測試人員:
- 返回 Google Play 管理中心的「所有應用程式」檢視畫面。
- 前往「設定」>授權測試。
- 為需要測試應用程式內購功能的測試人員新增相同的電子郵件地址。
- 將「授權回應」設為
RESPOND_NORMALLY
。 - 按一下 [儲存變更]。
設定應用程式內購項目
現在,您要在應用程式中設定可購買的商品。
和在 App Store 中一樣,您必須定義三種不同的購買行為:
dash_consumable_2k
:可多次購買的消耗性商品,每次消費可獲得 2000 破折號 (應用程式內貨幣) 給使用者。dash_upgrade_3d
:不可消耗的「升級」也就是只能購買一次的消費,因此使用者點選的 Dash 差不多。dash_subscription_doubler
:訂閱項目在訂閱期間的每次點擊向使用者提供兩倍點數。
首先,請新增消耗性和非消耗性產品。
- 前往 Google Play 管理中心,然後選取您的應用程式。
- 前往「營利」>「營利」產品 >應用程式內產品。
- 按一下「建立產品」
- 輸入產品的所有必要資訊。請確認產品 ID 與要使用的 ID 完全一致。
- 按一下 [儲存]。
- 按一下「啟用」。
- 重複執行非消耗性「升級」的程序購買。
接著,請新增訂閱項目:
- 前往 Google Play 管理中心,然後選取您的應用程式。
- 前往「營利」>「營利」產品 >訂閱項目。
- 按一下「建立訂閱項目」
- 輸入訂閱項目的所有必要資訊。請確認產品 ID 與要使用的 ID 完全一致。
- 點選「儲存」。
現在,Play 管理中心應該就能完成您的購買交易。
6. 設定 Firebase
在本程式碼研究室中,您將使用後端服務驗證及追蹤使用者的購買。
使用後端服務有幾個優點:
- 你可以安全地驗證交易。
- 您可以對應用程式商店中的帳單事件做出回應。
- 您可以追蹤資料庫中的交易。
- 使用者無法藉由倒轉系統時鐘,難以騙取您的應用程式提供付費功能。
設定後端服務的方法有很多種,不過只要利用 Google 自有的 Firebase,即可透過 Cloud Functions 和 Firestore 完成這項作業。
編寫後端的動作不在本程式碼研究室的討論範圍,因此範例程式碼已包含處理基本購買交易的 Firebase 專案,讓您踏出第一步。
範例應用程式也包含 Firebase 外掛程式。
您只需要建立自己的 Firebase 專案,為 Firebase 設定應用程式和後端,最後部署後端即可。
建立 Firebase 專案
前往 Firebase 控制台,建立新的 Firebase 專案。在這個範例中,您可以呼叫專案 Dash Clicker。
在後端應用程式中,您將購買行為與特定使用者建立連結,因此需要驗證。您需要使用 Google 登入功能,透過 Firebase 的驗證模組完成這項程序。
- 在 Firebase 資訊主頁中,前往「驗證」並視需要啟用。
- 前往「登入方式」分頁,啟用 Google 登入服務供應商。
由於您會使用 Firebase 的 Firestore 資料庫,因此請一併啟用這項功能。
請按照下列方式設定 Cloud Firestore 規則:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /purchases/{purchaseId} {
allow read: if request.auth != null && request.auth.uid == resource.data.userId
}
}
}
設定 Firebase for Flutter
如要在 Flutter 應用程式中安裝 Firebase,建議您使用 FlutterFire CLI。按照設定網頁中的指示操作。
執行 Fltterfire 設定時,請選取您在上一個步驟中建立的專案。
$ flutterfire configure
i Found 5 Firebase projects.
? Select a Firebase project to configure your Flutter application with ›
❯ in-app-purchases-1234 (in-app-purchases-1234)
other-flutter-codelab-1 (other-flutter-codelab-1)
other-flutter-codelab-2 (other-flutter-codelab-2)
other-flutter-codelab-3 (other-flutter-codelab-3)
other-flutter-codelab-4 (other-flutter-codelab-4)
<create a new project>
接著,選取兩個平台來啟用 iOS 和 Android。
? Which platforms should your configuration support (use arrow keys & space to select)? ›
✔ android
✔ ios
macos
web
當系統提示您覆寫 firebase_options.dart 時,請選取「是」。
? Generated FirebaseOptions file lib/firebase_options.dart already exists, do you want to override it? (y/n) › yes
設定 Firebase for Android:後續步驟
在 Firebase 資訊主頁中,前往「專案總覽」,選擇「設定」並選取「一般」分頁標籤。
向下捲動至「您的應用程式」,然後選取「dashclicker (Android)」應用程式。
如要允許在偵錯模式下使用 Google 登入,您必須提供偵錯憑證的 SHA-1 雜湊指紋。
取得偵錯簽署憑證雜湊
在 Flutter 應用程式專案的根目錄中,將目錄變更為 android/
資料夾,然後產生簽署報告。
cd android ./gradlew :app:signingReport
系統會顯示完整的簽署金鑰清單。由於要尋找偵錯憑證的雜湊,因此請找出 Variant
和 Config
屬性設為 debug
的憑證。KeyStore 可能位於 .android/debug.keystore
下的主資料夾。
> Task :app:signingReport
Variant: debug
Config: debug
Store: /<USER_HOME_FOLDER>/.android/debug.keystore
Alias: AndroidDebugKey
MD5: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
SHA1: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
SHA-256: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
Valid until: Tuesday, January 19, 2038
複製 SHA-1 雜湊,並填寫應用程式提交互動視窗的最後一個欄位。
設定 iOS 版 Firebase:後續步驟
使用 Xcode
開啟 ios/Runnder.xcworkspace
。或是使用所選 IDE。
在 VSCode 上按一下 ios/
資料夾,然後按一下 open in xcode
。
在 Android Studio 中,在 ios/
資料夾上按一下滑鼠右鍵,然後依序點選 flutter
和 open iOS module in Xcode
選項。
如要允許 iOS 裝置上的 Google 登入功能,請將 CFBundleURLTypes
設定選項新增至建構作業 plist
檔案。詳情請參閱 google_sign_in
套件說明文件。在本例中,檔案為 ios/Runner/Info-Debug.plist
和 ios/Runner/Info-Release.plist
。
已新增鍵/值組合,但必須替換其值:
- 從
GoogleService-Info.plist
檔案取得REVERSED_CLIENT_ID
的值,但周圍不要加上<string>..</string>
元素。 - 在
CFBundleURLTypes
鍵下,取代ios/Runner/Info-Debug.plist
和ios/Runner/Info-Release.plist
檔案中的值。
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<!-- TODO Replace this value: -->
<!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
<string>com.googleusercontent.apps.REDACTED</string>
</array>
</dict>
</array>
Firebase 設定現已完成。
7. 聆聽購買更新
在程式碼研究室的這部分,您將準備應用程式購買產品。這個程序包括監聽購買狀態更新,以及應用程式啟動後發生的錯誤。
聆聽購買資訊更新
在 main.dart,
中,找出含有 Scaffold
的小工具 MyHomePage
,其中包含兩個頁面的 BottomNavigationBar
。這個頁面也會為 DashCounter
、DashUpgrades,
和 DashPurchases
建立三個 Provider
。DashCounter
會追蹤目前數量,並自動遞增。DashUpgrades
會管理可透過 Dashes 購買的升級授權。本程式碼研究室著重於 DashPurchases
。
根據預設,系統一開始要求該物件時,會定義供應器的物件。這個物件會在應用程式啟動時直接監聽購買更新,因此請使用 lazy: false
停用這個物件的延遲載入功能:
lib/main.dart
ChangeNotifierProvider<DashPurchases>(
create: (context) => DashPurchases(
context.read<DashCounter>(),
),
lazy: false,
),
您還需要 InAppPurchaseConnection
的例項。不過,如要繼續測試應用程式,您需要透過某種方式模擬連線。若要這麼做,請建立可在測試中覆寫的執行個體方法,並將其新增至 main.dart
。
lib/main.dart
// Gives the option to override in tests.
class IAPConnection {
static InAppPurchase? _instance;
static set instance(InAppPurchase value) {
_instance = value;
}
static InAppPurchase get instance {
_instance ??= InAppPurchase.instance;
return _instance!;
}
}
如要持續進行測試,您必須稍微更新測試。查看 GitHub 上的 widget_test.dart,以取得 TestIAPConnection
的完整程式碼。
test/widget_test.dart
void main() {
testWidgets('App starts', (WidgetTester tester) async {
IAPConnection.instance = TestIAPConnection();
await tester.pumpWidget(const MyApp());
expect(find.text('Tim Sneath'), findsOneWidget);
});
}
在 lib/logic/dash_purchases.dart
中,前往 DashPurchases ChangeNotifier
的程式碼。目前只有 DashCounter
可以新增至你購買的破折號。
新增串流訂閱屬性 _subscription
(類型為 StreamSubscription<List<PurchaseDetails>> _subscription;
)、IAPConnection.instance,
和匯入項目。完成的程式碼應如下所示:
lib/logic/dash_purchases.dart
import 'package:in_app_purchase/in_app_purchase.dart';
class DashPurchases extends ChangeNotifier {
late StreamSubscription<List<PurchaseDetails>> _subscription;
final iapConnection = IAPConnection.instance;
DashPurchases(this.counter);
}
late
關鍵字會新增至 _subscription
,因為 _subscription
已在建構函式中初始化。這項專案預設為不可為空值 (NNBD),因此未宣告為空值的屬性必須含有非空值。late
限定詞可讓您延遲定義這個值。
在建構函式中,取得 purchaseUpdatedStream
並開始監聽串流。在 dispose()
方法中取消串流訂閱項目。
lib/logic/dash_purchases.dart
class DashPurchases extends ChangeNotifier {
DashCounter counter;
late StreamSubscription<List<PurchaseDetails>> _subscription;
final iapConnection = IAPConnection.instance;
DashPurchases(this.counter) {
final purchaseUpdated =
iapConnection.purchaseStream;
_subscription = purchaseUpdated.listen(
_onPurchaseUpdate,
onDone: _updateStreamOnDone,
onError: _updateStreamOnError,
);
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
Future<void> buy(PurchasableProduct product) async {
// omitted
}
void _onPurchaseUpdate(List<PurchaseDetails> purchaseDetailsList) {
// Handle purchases here
}
void _updateStreamOnDone() {
_subscription.cancel();
}
void _updateStreamOnError(dynamic error) {
//Handle error here
}
}
現在應用程式會收到購買交易更新,所以在下一節進行購買!
在繼續操作之前,請使用「flutter test"
」執行測試,確認所有設定正確無誤。
$ flutter test
00:01 +1: All tests passed!
8. 購物
在程式碼研究室的這部分,您要將現有的模擬產品取代為實際可購買的產品。這類產品會從商店載入 (顯示在清單中),並在輕觸產品時購買。
調整 PurchasableProduct
PurchasableProduct
會顯示模擬產品。使用下列程式碼取代 purchasable_product.dart
中的 PurchasableProduct
類別,以顯示實際內容:
lib/model/purchasable_product.dart
import 'package:in_app_purchase/in_app_purchase.dart';
enum ProductStatus {
purchasable,
purchased,
pending,
}
class PurchasableProduct {
String get id => productDetails.id;
String get title => productDetails.title;
String get description => productDetails.description;
String get price => productDetails.price;
ProductStatus status;
ProductDetails productDetails;
PurchasableProduct(this.productDetails) : status = ProductStatus.purchasable;
}
在 dash_purchases.dart,
中移除虛擬購買交易,並用空白清單取代 List<PurchasableProduct> products = [];
載入可用的購買項目
如要讓使用者能夠購買商品,請在商店中載入購買項目。首先,請確認商店是否可購買。如果商店缺貨,將 storeState
設為 notAvailable
時,系統會向使用者顯示錯誤訊息。
lib/logic/dash_purchases.dart
Future<void> loadPurchases() async {
final available = await iapConnection.isAvailable();
if (!available) {
storeState = StoreState.notAvailable;
notifyListeners();
return;
}
}
等商店上架後,請載入現有購買項目。根據先前的 Firebase 設定,您會看到 storeKeyConsumable
、storeKeySubscription,
和 storeKeyUpgrade
。如果無法購買預期的購買項目,請將這項資訊輸出至控制台。您可能也會想將這項資訊
傳送至後端服務
await iapConnection.queryProductDetails(ids)
方法會同時傳回找不到的 ID 和可購買的產品。使用回應中的 productDetails
更新使用者介面,並將 StoreState
設為 available
。
lib/logic/dash_purchases.dart
import '../constants.dart';
Future<void> loadPurchases() async {
final available = await iapConnection.isAvailable();
if (!available) {
storeState = StoreState.notAvailable;
notifyListeners();
return;
}
const ids = <String>{
storeKeyConsumable,
storeKeySubscription,
storeKeyUpgrade,
};
final response = await iapConnection.queryProductDetails(ids);
for (var element in response.notFoundIDs) {
debugPrint('Purchase $element not found');
}
products = response.productDetails.map((e) => PurchasableProduct(e)).toList();
storeState = StoreState.available;
notifyListeners();
}
在建構函式中呼叫 loadPurchases()
函式:
lib/logic/dash_purchases.dart
DashPurchases(this.counter) {
final purchaseUpdated = iapConnection.purchaseStream;
_subscription = purchaseUpdated.listen(
_onPurchaseUpdate,
onDone: _updateStreamOnDone,
onError: _updateStreamOnError,
);
loadPurchases();
}
最後,將「storeState
」欄位的值從 StoreState.available
變更為 StoreState.loading:
lib/logic/dash_purchases.dart
StoreState storeState = StoreState.loading;
顯示可購買的產品
請考慮使用 purchase_page.dart
檔案。PurchasePage
小工具會根據 StoreState
顯示 _PurchasesLoading
、_PurchaseList,
或 _PurchasesNotAvailable,
。小工具也會顯示使用者在下一個步驟中的購買記錄。
_PurchaseList
小工具會顯示可購買的產品清單,並將購買要求傳送至 DashPurchases
物件。
lib/pages/purchase_page.dart
class _PurchaseList extends StatelessWidget {
@override
Widget build(BuildContext context) {
var purchases = context.watch<DashPurchases>();
var products = purchases.products;
return Column(
children: products
.map((product) => _PurchaseWidget(
product: product,
onPressed: () {
purchases.buy(product);
}))
.toList(),
);
}
}
如果產品設定正確,您應該就能在 Android 和 iOS 商店看到可購買的產品。請注意,購買相關項目後,可能需要經過一段時間才會進入個別控制台查看。
返回 dash_purchases.dart
,實作這個函式來購買產品。您只需要將消耗性產品與非消耗性產品區隔開來。升級授權和訂閱產品為非消耗性產品。
lib/logic/dash_purchases.dart
Future<void> buy(PurchasableProduct product) async {
final purchaseParam = PurchaseParam(productDetails: product.productDetails);
switch (product.id) {
case storeKeyConsumable:
await iapConnection.buyConsumable(purchaseParam: purchaseParam);
break;
case storeKeySubscription:
case storeKeyUpgrade:
await iapConnection.buyNonConsumable(purchaseParam: purchaseParam);
break;
default:
throw ArgumentError.value(
product.productDetails, '${product.id} is not a known product');
}
}
繼續操作前,請建立變數 _beautifiedDashUpgrade
並更新 beautifiedDash
getter 來參照。
lib/logic/dash_purchases.dart
bool get beautifiedDash => _beautifiedDashUpgrade;
bool _beautifiedDashUpgrade = false;
_onPurchaseUpdate
方法會接收購買交易更新、更新購買頁面中顯示的產品狀態,並將交易套用至計數器邏輯。處理交易後,請務必呼叫 completePurchase
,讓商店知道購買交易已正確處理。
lib/logic/dash_purchases.dart
Future<void> _onPurchaseUpdate(
List<PurchaseDetails> purchaseDetailsList) async {
for (var purchaseDetails in purchaseDetailsList) {
await _handlePurchase(purchaseDetails);
}
notifyListeners();
}
Future<void> _handlePurchase(PurchaseDetails purchaseDetails) async {
if (purchaseDetails.status == PurchaseStatus.purchased) {
switch (purchaseDetails.productID) {
case storeKeySubscription:
counter.applyPaidMultiplier();
break;
case storeKeyConsumable:
counter.addBoughtDashes(2000);
break;
case storeKeyUpgrade:
_beautifiedDashUpgrade = true;
break;
}
}
if (purchaseDetails.pendingCompletePurchase) {
await iapConnection.completePurchase(purchaseDetails);
}
}
9. 設定後端
在前往追蹤和驗證購買之前,請先設定 Dart 後端以支援相關操作。
在本節中,從 dart-backend/
資料夾作為根資料夾工作。
確定您已安裝下列工具:
- Dart
- Firebase CLI
基礎專案總覽
本程式碼研究室的某些部分不在本程式碼研究室的涵蓋範圍內,因此包含在範例程式碼中。在開始之前,建議您先瞭解範例程式碼中的現有內容,瞭解該如何建構內容。
此後端程式碼可在本機電腦上執行,您無須部署即可使用。不過,您必須能夠從開發裝置 (Android 或 iPhone) 連線至執行伺服器的電腦。裝置必須位於相同的網路中,而且您必須知道電腦的 IP 位址,才能使用上述服務。
嘗試使用下列指令執行伺服器:
$ dart ./bin/server.dart
Serving at http://0.0.0.0:8080
Dart 後端會使用 shelf
和 shelf_router
提供 API 端點。根據預設,伺服器不會提供任何路徑。稍後,您將建立處理購買交易驗證程序的路徑。
範例程式碼中已包含的一個部分是 lib/iap_repository.dart
中的 IapRepository
。由於學習如何與 Firestore (或資料庫) 互動大致上與此程式碼研究室無關,因此範例程式碼含有可讓您在 Firestore 中建立或更新購買交易的函式,以及這些購買交易的所有類別。
設定 Firebase 存取權
您必須具備服務帳戶存取金鑰,才能存取 Firebase Firestore。方法是開啟 Firebase 專案設定,前往「服務帳戶」部分,然後選取「產生新的私密金鑰」。
將下載的 JSON 檔案複製到 assets/
資料夾,並重新命名為 service-account-firebase.json
。
設定 Google Play 存取權
如要存取 Play 商店來驗證購買交易,您必須建立具有這些權限的服務帳戶,並下載該帳戶的 JSON 憑證。
- 前往 Google Play 管理中心,然後從「所有應用程式」頁面開始操作。
- 前往「設定」>「設定」API 存取權。如果 Google Play 管理中心要求您建立的是現有專案或連結至現有專案,請先完成相關操作,再返回這個頁面。
- 找到您可以定義服務帳戶的部分,然後按一下「建立新的服務帳戶」。
- 在彈出的對話方塊中按一下「Google Cloud Platform」連結。
- 選取所需的專案,如果找不到這個帳戶,請確認您已在右上方的「帳戶」下拉式清單下登入正確的 Google 帳戶。
- 選取專案後,按一下頂端選單列中的「+ Create Service Account」。
- 提供服務帳戶的名稱,並視需要輸入說明,讓您記住該帳戶的用途,然後進行下一個步驟。
- 為服務帳戶指派「編輯者」角色。
- 完成精靈,返回開發人員控制台的「API 存取權」頁面,然後按一下 [重新整理服務帳戶]。清單中應會顯示新建立的帳戶。
- 按一下「授予存取權」以授予新服務帳戶的存取權。
- 向下捲動至「財務資料」區塊。同時選取「查看財務資料、訂單和取消訂閱問卷回覆情形」和「管理訂單和訂閱」。
- 按一下「邀請使用者」。
- 帳戶設定完成後,您只需產生一些憑證。返回 Cloud 控制台,在服務帳戶清單中找出您的服務帳戶,按一下垂直三點圖示,然後選擇「管理金鑰」。
- 建立新的 JSON 金鑰,然後下載該金鑰。
- 將下載的檔案重新命名為
service-account-google-play.json,
,並移至assets/
目錄。
我們還需要完成另外一項操作,請開啟 lib/constants.dart,
,並將 androidPackageId
的值替換成您為 Android 應用程式選擇的套件 ID。
設定 Apple App Store 存取權
如要存取 App Store 來驗證購買交易,您必須設定共用密鑰:
- 開啟 App Store Connect。
- 前往「我的應用程式」,然後選取您的應用程式。
- 在側欄導覽中,前往「應用程式內購」>管理。
- 在清單右上方,按一下「App-Specific Shared Secret」(應用程式專屬共用密鑰)。
- 產生並複製新密鑰。
- 開啟
lib/constants.dart,
,將appStoreSharedSecret
的值替換為剛才產生的共用密鑰。
常數設定檔
繼續操作之前,請確認已在 lib/constants.dart
檔案中設定下列常數:
androidPackageId
:Android 使用的套件 ID。例如:com.example.dashclicker
appStoreSharedSecret
:存取 App Store Connect 的共用密鑰,以便執行購買交易驗證。bundleId
:iOS 上使用的軟體包 ID。例如:com.example.dashclicker
目前您可以忽略其餘的常數。
10. 驗證購買交易
在 iOS 和 Android 上,驗證購買交易的一般流程大同小異。
兩間商店的應用程式都會在交易完成時收到憑證。
應用程式會將這個權杖傳送至後端服務,接著應用程式會使用提供的權杖,透過對應商店的伺服器驗證購買交易。
接著,後端服務可選擇儲存購買交易,無論購買交易是否有效,都回應應用程式。
透過後端服務 (而非在使用者裝置上執行的應用程式) 進行驗證,您可以防止使用者存取付費功能,例如,倒轉系統時鐘。
設定 Flutter 面
設定驗證方法
在您將購買交易傳送至後端服務時,您想確認使用者在購物過程中已通過驗證。範例應用程式中已為您新增大部分的驗證邏輯,您只需確保在使用者尚未登入時,PurchasePage
會顯示登入按鈕。將下列程式碼新增至 PurchasePage
建構方法的開頭:
lib/pages/purchase_page.dart
import '../logic/firebase_notifier.dart';
import '../model/firebase_state.dart';
import 'login_page.dart';
class PurchasePage extends StatelessWidget {
const PurchasePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
var firebaseNotifier = context.watch<FirebaseNotifier>();
if (firebaseNotifier.state == FirebaseState.loading) {
return _PurchasesLoading();
} else if (firebaseNotifier.state == FirebaseState.notAvailable) {
return _PurchasesNotAvailable();
}
if (!firebaseNotifier.loggedIn) {
return const LoginPage();
}
// omitted
透過應用程式通話驗證端點
在應用程式中建立 _verifyPurchase(PurchaseDetails purchaseDetails)
函式,透過 http 後呼叫呼叫 Dart 後端上的 /verifypurchase
端點。
傳送所選商店 (Play 商店為 google_play
,App Store 為 app_store
)、serverVerificationData
和 productID
。伺服器會傳回狀態碼,指出購買交易是否已通過驗證。
在應用程式常數中,將伺服器 IP 設為本機電腦 IP 位址。
lib/logic/dash_purchases.dart
FirebaseNotifier firebaseNotifier;
DashPurchases(this.counter, this.firebaseNotifier) {
// omitted
}
在 main.dart:
中建立含有 DashPurchases
的 firebaseNotifier
lib/main.dart
ChangeNotifierProvider<DashPurchases>(
create: (context) => DashPurchases(
context.read<DashCounter>(),
context.read<FirebaseNotifier>(),
),
lazy: false,
),
在 FirebaseNotifier 中為使用者新增 getter,以便將使用者 ID 傳遞至驗證購買函式。
lib/logic/firebase_notifier.dart
User? get user => FirebaseAuth.instance.currentUser;
將函式 _verifyPurchase
新增至 DashPurchases
類別。這個 async
函式會傳回布林值,指出購買交易是否已通過驗證。
lib/logic/dash_purchases.dart
Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) async {
final url = Uri.parse('http://$serverIp:8080/verifypurchase');
const headers = {
'Content-type': 'application/json',
'Accept': 'application/json',
};
final response = await http.post(
url,
body: jsonEncode({
'source': purchaseDetails.verificationData.source,
'productId': purchaseDetails.productID,
'verificationData':
purchaseDetails.verificationData.serverVerificationData,
'userId': firebaseNotifier.user?.uid,
}),
headers: headers,
);
if (response.statusCode == 200) {
print('Successfully verified purchase');
return true;
} else {
print('failed request: ${response.statusCode} - ${response.body}');
return false;
}
}
套用購買交易前,請在 _handlePurchase
中呼叫 _verifyPurchase
函式。購買交易必須通過驗證,才能套用。在正式版應用程式中,您可以進一步指定這項設定,例如在商店暫時無法使用時套用試用訂閱方案。不過,本例中盡量簡單,並且只在交易成功驗證後套用購買交易。
lib/logic/dash_purchases.dart
Future<void> _onPurchaseUpdate(
List<PurchaseDetails> purchaseDetailsList) async {
for (var purchaseDetails in purchaseDetailsList) {
await _handlePurchase(purchaseDetails);
}
notifyListeners();
}
Future<void> _handlePurchase(PurchaseDetails purchaseDetails) async {
if (purchaseDetails.status == PurchaseStatus.purchased) {
// Send to server
var validPurchase = await _verifyPurchase(purchaseDetails);
if (validPurchase) {
// Apply changes locally
switch (purchaseDetails.productID) {
case storeKeySubscription:
counter.applyPaidMultiplier();
break;
case storeKeyConsumable:
counter.addBoughtDashes(1000);
break;
}
}
}
if (purchaseDetails.pendingCompletePurchase) {
await iapConnection.completePurchase(purchaseDetails);
}
}
在應用程式中,現在可以驗證購買交易。
設定後端服務
接下來,請設定 Cloud 函式,用於在後端驗證購買交易。
建構購買處理常式
由於兩間商店的驗證流程大致相同,請設定抽象的 PurchaseHandler
類別,並分別為各商店實作不同的實作方式。
首先,請將 purchase_handler.dart
檔案新增至 lib/
資料夾,該資料夾定義一個抽象的 PurchaseHandler
類別,其中包含兩種用來驗證兩種不同購買交易的抽象方法:訂閱和非訂閱。
lib/purchase_handler.dart
import 'products.dart';
/// Generic purchase handler,
/// must be implemented for Google Play and Apple Store
abstract class PurchaseHandler {
/// Verify if non-subscription purchase (aka consumable) is valid
/// and update the database
Future<bool> handleNonSubscription({
required String userId,
required ProductData productData,
required String token,
});
/// Verify if subscription purchase (aka non-consumable) is valid
/// and update the database
Future<bool> handleSubscription({
required String userId,
required ProductData productData,
required String token,
});
}
如您所見,每個方法都需要三個參數:
userId:
:已登入使用者的 ID,您可以將購買交易連結至該使用者。productData:
產品相關資料。請稍候片刻。token:
:商店提供給使用者的權杖。
此外,為了讓這類購買處理常式更容易使用,請新增適用於訂閱項目和非訂閱項目的 verifyPurchase()
方法:
lib/purchase_handler.dart
/// Verify if purchase is valid and update the database
Future<bool> verifyPurchase({
required String userId,
required ProductData productData,
required String token,
}) async {
switch (productData.type) {
case ProductType.subscription:
return handleSubscription(
userId: userId,
productData: productData,
token: token,
);
case ProductType.nonSubscription:
return handleNonSubscription(
userId: userId,
productData: productData,
token: token,
);
}
}
現在,您只需要針對這兩種情況呼叫 verifyPurchase
,但仍有各自的實作方式!
ProductData
類別包含不同可購買產品的基本資訊,包括產品 ID (有時也稱為 SKU) 和 ProductType
。
lib/products.dart
class ProductData {
final String productId;
final ProductType type;
const ProductData(this.productId, this.type);
}
ProductType
可以是訂閱項目或非訂閱項目。
lib/products.dart
enum ProductType {
subscription,
nonSubscription,
}
最後,產品清單會定義為同一個檔案中的對應項目。
lib/products.dart
const productDataMap = {
'dash_consumable_2k': ProductData(
'dash_consumable_2k',
ProductType.nonSubscription,
),
'dash_upgrade_3d': ProductData(
'dash_upgrade_3d',
ProductType.nonSubscription,
),
'dash_subscription_doubler': ProductData(
'dash_subscription_doubler',
ProductType.subscription,
),
};
接著,請定義 Google Play 商店和 Apple App Store 的預留位置實作方式。開始使用 Google Play:
建立 lib/google_play_purchase_handler.dart
,並新增可以擴充您剛才編寫的 PurchaseHandler
的類別:
lib/google_play_purchase_handler.dart
import 'dart:async';
import 'package:googleapis/androidpublisher/v3.dart' as ap;
import 'constants.dart';
import 'iap_repository.dart';
import 'products.dart';
import 'purchase_handler.dart';
class GooglePlayPurchaseHandler extends PurchaseHandler {
final ap.AndroidPublisherApi androidPublisher;
final IapRepository iapRepository;
GooglePlayPurchaseHandler(
this.androidPublisher,
this.iapRepository,
);
@override
Future<bool> handleNonSubscription({
required String? userId,
required ProductData productData,
required String token,
}) async {
return true;
}
@override
Future<bool> handleSubscription({
required String? userId,
required ProductData productData,
required String token,
}) async {
return true;
}
}
目前,它會為處理常式方法傳回 true
;稍後再檢查
您可能已經注意到,建構函式會擷取 IapRepository
的執行個體。購買處理常式之後會使用這個執行個體儲存 Firestore 中的購買項目相關資訊。如要與 Google Play 通訊,請使用我們提供的 AndroidPublisherApi
。
接著,對應用程式商店處理常式進行相同的操作。建立 lib/app_store_purchase_handler.dart
,並新增再次擴充 PurchaseHandler
的類別:
lib/app_store_purchase_handler.dart
import 'dart:async';
import 'package:app_store_server_sdk/app_store_server_sdk.dart';
import 'constants.dart';
import 'iap_repository.dart';
import 'products.dart';
import 'purchase_handler.dart';
class AppStorePurchaseHandler extends PurchaseHandler {
final IapRepository iapRepository;
AppStorePurchaseHandler(
this.iapRepository,
);
@override
Future<bool> handleNonSubscription({
required String userId,
required ProductData productData,
required String token,
}) {
return true;
}
@override
Future<bool> handleSubscription({
required String userId,
required ProductData productData,
required String token,
}) {
return true;
}
}
太好了!您現在有兩個購買處理常式。接下來,我要建立 Purchase 驗證 API 端點。
使用購買處理常式
開啟 bin/server.dart
,並使用 shelf_route
建立 API 端點:
bin/server.dart
Future<void> main() async {
final router = Router();
final purchaseHandlers = await _createPurchaseHandlers();
router.post('/verifypurchase', (Request request) async {
final dynamic payload = json.decode(await request.readAsString());
final (:userId, :source, :productData, :token) = getPurchaseData(payload);
final result = await purchaseHandlers[source]!.verifyPurchase(
userId: userId,
productData: productData,
token: token,
);
if (result) {
return Response.ok('all good!');
} else {
return Response.internalServerError();
}
});
await serveHandler(router);
}
({
String userId,
String source,
ProductData productData,
String token,
}) getPurchaseData(dynamic payload) {
if (payload
case {
'userId': String userId,
'source': String source,
'productId': String productId,
'verificationData': String token,
}) {
return (
userId: userId,
source: source,
productData: productDataMap[productId]!,
token: token,
);
} else {
throw const FormatException('Unexpected JSON');
}
}
上述程式碼會執行下列作業:
- 定義系統會從您先前建立的應用程式呼叫的 POST 端點。
- 將 JSON 酬載解碼,並擷取下列資訊:
userId
:目前登入的使用者 IDsource
:已使用,可以是app_store
或google_play
。productData
:從您先前建立的productDataMap
取得。token
:包含要傳送至商店的驗證資料。- 根據來源呼叫
verifyPurchase
方法,針對GooglePlayPurchaseHandler
或AppStorePurchaseHandler
。 - 如果驗證成功,這個方法會將
Response.ok
傳回用戶端。 - 如果驗證失敗,這個方法會將
Response.internalServerError
傳回用戶端。
建立 API 端點後,您必須設定兩個購買處理常式。您必須載入在上個步驟取得的服務帳戶金鑰,並設定不同服務的存取權,包括 Android Publisher API 和 Firebase Firestore API。接著,建立兩個使用不同依附元件的購買處理常式:
bin/server.dart
Future<Map<String, PurchaseHandler>> _createPurchaseHandlers() async {
// Configure Android Publisher API access
final serviceAccountGooglePlay =
File('assets/service-account-google-play.json').readAsStringSync();
final clientCredentialsGooglePlay =
auth.ServiceAccountCredentials.fromJson(serviceAccountGooglePlay);
final clientGooglePlay =
await auth.clientViaServiceAccount(clientCredentialsGooglePlay, [
ap.AndroidPublisherApi.androidpublisherScope,
]);
final androidPublisher = ap.AndroidPublisherApi(clientGooglePlay);
// Configure Firestore API access
final serviceAccountFirebase =
File('assets/service-account-firebase.json').readAsStringSync();
final clientCredentialsFirebase =
auth.ServiceAccountCredentials.fromJson(serviceAccountFirebase);
final clientFirebase =
await auth.clientViaServiceAccount(clientCredentialsFirebase, [
fs.FirestoreApi.cloudPlatformScope,
]);
final firestoreApi = fs.FirestoreApi(clientFirebase);
final dynamic json = jsonDecode(serviceAccountFirebase);
final projectId = json['project_id'] as String;
final iapRepository = IapRepository(firestoreApi, projectId);
return {
'google_play': GooglePlayPurchaseHandler(
androidPublisher,
iapRepository,
),
'app_store': AppStorePurchaseHandler(
iapRepository,
),
};
}
驗證 Android 購買交易:實作購買程序
接下來,請繼續導入 Google Play 購買處理常式。
Google 已提供 Dart 套件,可讓您與驗證購買交易所需的 API 互動。您已在 server.dart
檔案中初始化這些變數,現在可在 GooglePlayPurchaseHandler
類別中使用。
為非訂閱類型的購買交易實作處理常式:
lib/google_play_purchase_handler.dart
@override
Future<bool> handleNonSubscription({
required String? userId,
required ProductData productData,
required String token,
}) async {
print(
'GooglePlayPurchaseHandler.handleNonSubscription'
'($userId, ${productData.productId}, ${token.substring(0, 5)}...)',
);
try {
// Verify purchase with Google
final response = await androidPublisher.purchases.products.get(
androidPackageId,
productData.productId,
token,
);
print('Purchases response: ${response.toJson()}');
// Make sure an order id exists
if (response.orderId == null) {
print('Could not handle purchase without order id');
return false;
}
final orderId = response.orderId!;
final purchaseData = NonSubscriptionPurchase(
purchaseDate: DateTime.fromMillisecondsSinceEpoch(
int.parse(response.purchaseTimeMillis ?? '0'),
),
orderId: orderId,
productId: productData.productId,
status: _nonSubscriptionStatusFrom(response.purchaseState),
userId: userId,
iapSource: IAPSource.googleplay,
);
// Update the database
if (userId != null) {
// If we know the userId,
// update the existing purchase or create it if it does not exist.
await iapRepository.createOrUpdatePurchase(purchaseData);
} else {
// If we do not know the user id, a previous entry must already
// exist, and thus we'll only update it.
await iapRepository.updatePurchase(purchaseData);
}
return true;
} on ap.DetailedApiRequestError catch (e) {
print(
'Error on handle NonSubscription: $e\n'
'JSON: ${e.jsonResponse}',
);
} catch (e) {
print('Error on handle NonSubscription: $e\n');
}
return false;
}
您可以透過類似方式更新訂閱項目購買處理常式:
lib/google_play_purchase_handler.dart
/// Handle subscription purchases.
///
/// Retrieves the purchase status from Google Play and updates
/// the Firestore Database accordingly.
@override
Future<bool> handleSubscription({
required String? userId,
required ProductData productData,
required String token,
}) async {
print(
'GooglePlayPurchaseHandler.handleSubscription'
'($userId, ${productData.productId}, ${token.substring(0, 5)}...)',
);
try {
// Verify purchase with Google
final response = await androidPublisher.purchases.subscriptions.get(
androidPackageId,
productData.productId,
token,
);
print('Subscription response: ${response.toJson()}');
// Make sure an order id exists
if (response.orderId == null) {
print('Could not handle purchase without order id');
return false;
}
final orderId = extractOrderId(response.orderId!);
final purchaseData = SubscriptionPurchase(
purchaseDate: DateTime.fromMillisecondsSinceEpoch(
int.parse(response.startTimeMillis ?? '0'),
),
orderId: orderId,
productId: productData.productId,
status: _subscriptionStatusFrom(response.paymentState),
userId: userId,
iapSource: IAPSource.googleplay,
expiryDate: DateTime.fromMillisecondsSinceEpoch(
int.parse(response.expiryTimeMillis ?? '0'),
),
);
// Update the database
if (userId != null) {
// If we know the userId,
// update the existing purchase or create it if it does not exist.
await iapRepository.createOrUpdatePurchase(purchaseData);
} else {
// If we do not know the user id, a previous entry must already
// exist, and thus we'll only update it.
await iapRepository.updatePurchase(purchaseData);
}
return true;
} on ap.DetailedApiRequestError catch (e) {
print(
'Error on handle Subscription: $e\n'
'JSON: ${e.jsonResponse}',
);
} catch (e) {
print('Error on handle Subscription: $e\n');
}
return false;
}
}
新增以下方法,以便剖析訂單 ID,以及兩種剖析購買狀態的方法。
lib/google_play_purchase_handler.dart
/// If a subscription suffix is present (..#) extract the orderId.
String extractOrderId(String orderId) {
final orderIdSplit = orderId.split('..');
if (orderIdSplit.isNotEmpty) {
orderId = orderIdSplit[0];
}
return orderId;
}
NonSubscriptionStatus _nonSubscriptionStatusFrom(int? state) {
return switch (state) {
0 => NonSubscriptionStatus.completed,
2 => NonSubscriptionStatus.pending,
_ => NonSubscriptionStatus.cancelled,
};
}
SubscriptionStatus _subscriptionStatusFrom(int? state) {
return switch (state) {
// Payment pending
0 => SubscriptionStatus.pending,
// Payment received
1 => SubscriptionStatus.active,
// Free trial
2 => SubscriptionStatus.active,
// Pending deferred upgrade/downgrade
3 => SubscriptionStatus.pending,
// Expired or cancelled
_ => SubscriptionStatus.expired,
};
}
現在您的 Google Play 購買交易應會通過驗證,並儲存在資料庫中。
接下來,我們來看看 iOS 適用的 App Store 交易。
驗證 iOS 購買交易:實作購買處理常式
如果是在 App Store 驗證交易,系統會提供名為 app_store_server_sdk
的第三方 Dart 套件,讓整個流程變得更加輕鬆。
首先,建立 ITunesApi
執行個體。請使用沙箱設定並啟用記錄功能,以便偵錯。
lib/app_store_purchase_handler.dart
final _iTunesAPI = ITunesApi(
ITunesHttpClient(
ITunesEnvironment.sandbox(),
loggingEnabled: true,
),
);
與 Google Play API 不同的是,App Store 針對訂閱項目和非訂閱項目,使用相同的 API 端點。這表示這兩個處理常式都可以使用相同的邏輯。合併兩者,使兩者呼叫相同的實作方式:
lib/app_store_purchase_handler.dart
@override
Future<bool> handleNonSubscription({
required String userId,
required ProductData productData,
required String token,
}) {
return handleValidation(userId: userId, token: token);
}
@override
Future<bool> handleSubscription({
required String userId,
required ProductData productData,
required String token,
}) {
return handleValidation(userId: userId, token: token);
}
/// Handle purchase validation.
Future<bool> handleValidation({
required String userId,
required String token,
}) async {
//..
}
現在,請實作 handleValidation
:
lib/app_store_purchase_handler.dart
/// Handle purchase validation.
Future<bool> handleValidation({
required String userId,
required String token,
}) async {
print('AppStorePurchaseHandler.handleValidation');
final response = await _iTunesAPI.verifyReceipt(
password: appStoreSharedSecret,
receiptData: token,
);
print('response: $response');
if (response.status == 0) {
print('Successfully verified purchase');
final receipts = response.latestReceiptInfo ?? [];
for (final receipt in receipts) {
final product = productDataMap[receipt.productId];
if (product == null) {
print('Error: Unknown product: ${receipt.productId}');
continue;
}
switch (product.type) {
case ProductType.nonSubscription:
await iapRepository.createOrUpdatePurchase(NonSubscriptionPurchase(
userId: userId,
productId: receipt.productId ?? '',
iapSource: IAPSource.appstore,
orderId: receipt.originalTransactionId ?? '',
purchaseDate: DateTime.fromMillisecondsSinceEpoch(
int.parse(receipt.originalPurchaseDateMs ?? '0')),
type: product.type,
status: NonSubscriptionStatus.completed,
));
break;
case ProductType.subscription:
await iapRepository.createOrUpdatePurchase(SubscriptionPurchase(
userId: userId,
productId: receipt.productId ?? '',
iapSource: IAPSource.appstore,
orderId: receipt.originalTransactionId ?? '',
purchaseDate: DateTime.fromMillisecondsSinceEpoch(
int.parse(receipt.originalPurchaseDateMs ?? '0')),
type: product.type,
expiryDate: DateTime.fromMillisecondsSinceEpoch(
int.parse(receipt.expiresDateMs ?? '0')),
status: SubscriptionStatus.active,
));
break;
}
}
return true;
} else {
print('Error: Status: ${response.status}');
return false;
}
}
現在,您的 App Store 購物交易應該會通過驗證,並儲存在資料庫中!
執行後端
此時,您可以執行 dart bin/server.dart
來提供 /verifypurchase
端點。
$ dart bin/server.dart
Serving at http://0.0.0.0:8080
11. 追蹤消費記錄
建議的追蹤使用者是在後端服務中這是因為後端可以回應來自商店的事件,因此因為快取的緣故,執行過時資訊的可能性較低,也較不容易遭到竄改。
首先,請透過您開發的 Dart 後端,在後端設定商店事件處理作業。
在後端處理商店事件
如果發生任何帳單事件 (例如訂閱項目續訂),商店能夠告知後端。您可以在後端處理這些事件,讓資料庫中的購買交易保持在最新狀態。本節將同時為 Google Play 商店和 Apple App Store 進行設定。
處理 Google Play 帳款服務事件
Google Play 會透過所謂的「雲端 Pub/Sub 主題」提供帳單事件。這些其實是訊息佇列,可發布訊息並取用。
由於這是 Google Play 特有的功能,因此您要在 GooglePlayPurchaseHandler
中加入這項功能。
首先,開啟 lib/google_play_purchase_handler.dart
並新增 PubsubApi 匯入:
lib/google_play_purchase_handler.dart
import 'package:googleapis/pubsub/v1.dart' as pubsub;
接著,將 PubsubApi
傳遞至 GooglePlayPurchaseHandler
,並修改類別建構函式來建立 Timer
,如下所示:
lib/google_play_purchase_handler.dart
class GooglePlayPurchaseHandler extends PurchaseHandler {
final ap.AndroidPublisherApi androidPublisher;
final IapRepository iapRepository;
final pubsub.PubsubApi pubsubApi; // new
GooglePlayPurchaseHandler(
this.androidPublisher,
this.iapRepository,
this.pubsubApi, // new
) {
// Poll messages from Pub/Sub every 10 seconds
Timer.periodic(Duration(seconds: 10), (_) {
_pullMessageFromPubSub();
});
}
Timer
已設為每十秒呼叫 _pullMessageFromSubSub
方法。你可以根據自己的偏好調整時間長度。
然後建立 _pullMessageFromSubSub
lib/google_play_purchase_handler.dart
/// Process messages from Google Play
/// Called every 10 seconds
Future<void> _pullMessageFromPubSub() async {
print('Polling Google Play messages');
final request = pubsub.PullRequest(
maxMessages: 1000,
);
final topicName =
'projects/$googlePlayProjectName/subscriptions/$googlePlayPubsubBillingTopic-sub';
final pullResponse = await pubsubApi.projects.subscriptions.pull(
request,
topicName,
);
final messages = pullResponse.receivedMessages ?? [];
for (final message in messages) {
final data64 = message.message?.data;
if (data64 != null) {
await _processMessage(data64, message.ackId);
}
}
}
Future<void> _processMessage(String data64, String? ackId) async {
final dataRaw = utf8.decode(base64Decode(data64));
print('Received data: $dataRaw');
final dynamic data = jsonDecode(dataRaw);
if (data['testNotification'] != null) {
print('Skip test messages');
if (ackId != null) {
await _ackMessage(ackId);
}
return;
}
final dynamic subscriptionNotification = data['subscriptionNotification'];
final dynamic oneTimeProductNotification =
data['oneTimeProductNotification'];
if (subscriptionNotification != null) {
print('Processing Subscription');
final subscriptionId =
subscriptionNotification['subscriptionId'] as String;
final purchaseToken = subscriptionNotification['purchaseToken'] as String;
final productData = productDataMap[subscriptionId]!;
final result = await handleSubscription(
userId: null,
productData: productData,
token: purchaseToken,
);
if (result && ackId != null) {
await _ackMessage(ackId);
}
} else if (oneTimeProductNotification != null) {
print('Processing NonSubscription');
final sku = oneTimeProductNotification['sku'] as String;
final purchaseToken =
oneTimeProductNotification['purchaseToken'] as String;
final productData = productDataMap[sku]!;
final result = await handleNonSubscription(
userId: null,
productData: productData,
token: purchaseToken,
);
if (result && ackId != null) {
await _ackMessage(ackId);
}
} else {
print('invalid data');
}
}
/// ACK Messages from Pub/Sub
Future<void> _ackMessage(String id) async {
print('ACK Message');
final request = pubsub.AcknowledgeRequest(
ackIds: [id],
);
final subscriptionName =
'projects/$googlePlayProjectName/subscriptions/$googlePlayPubsubBillingTopic-sub';
await pubsubApi.projects.subscriptions.acknowledge(
request,
subscriptionName,
);
}
您剛剛新增的程式碼每隔十秒就會與 Google Cloud 的 Pub/Sub 主題通訊,並要求收到新訊息。接著,處理 _processMessage
方法中的每則訊息。
這個方法會將收到的訊息解碼,並取得每筆購買交易 (訂閱項目和非訂閱項目) 的更新資訊,並視需要呼叫現有的 handleSubscription
或 handleNonSubscription
。
每則訊息都需要使用 _askMessage
方法確認。
接著,將必要的依附元件新增至 server.dart
檔案。將 PubsubApi.cloudPlatformScope 新增至憑證設定:
bin/server.dart
final clientGooglePlay =
await auth.clientViaServiceAccount(clientCredentialsGooglePlay, [
ap.AndroidPublisherApi.androidpublisherScope,
pubsub.PubsubApi.cloudPlatformScope, // new
]);
接著,建立 PubsubApi 執行個體:
bin/server.dart
final pubsubApi = pubsub.PubsubApi(clientGooglePlay);
最後,將其傳遞至 GooglePlayPurchaseHandler
建構函式:
bin/server.dart
return {
'google_play': GooglePlayPurchaseHandler(
androidPublisher,
iapRepository,
pubsubApi, // new
),
'app_store': AppStorePurchaseHandler(
iapRepository,
),
};
Google Play 設定
您已編寫程式碼來使用 Pub/Sub 主題的帳單事件,但尚未建立 Pub/Sub 主題,也尚未發布任何帳單事件。該開始設定了。
請先建立 Pub/Sub 主題:
- 前往 Google Cloud 控制台的 Cloud Pub/Sub 頁面。
- 確認您已進入 Firebase 專案,然後按一下「+ 建立主題」。
- 為新主題命名,名稱必須與在
constants.ts
中為GOOGLE_PLAY_PUBSUB_BILLING_TOPIC
設定的值相同。在這個範例中,您可以將其命名為play_billing
。如果選擇其他,請務必更新constants.ts
。建立主題。 - 在 Pub/Sub 主題清單中,找到您剛剛建立的主題,依序點選垂直三點圖示 >「查看權限」。
- 在右側欄中選擇「新增主體」。
- 這裡新增
google-play-developer-notifications@system.gserviceaccount.com
,並授予其 Pub/Sub 發布者角色。 - 儲存權限變更。
- 複製您剛剛建立的主題主題名稱。
- 再次開啟 Play 管理中心,然後從「所有應用程式」清單中選擇您的應用程式。
- 向下捲動並前往「營利」>「營利」營利設定:
- 填入完整主題並儲存變更。
所有 Google Play 帳款服務活動都會按照主題發布。
處理 App Store 帳單事件
接著,對 App Store 帳單事件執行相同操作。以下兩種方式可以有效處理 App Store 的購買交易更新。一種是實作您提供給 Apple 的 Webhook,並用來與您的伺服器通訊。在本程式碼研究室中,第二種方法就是連線至 App Store Server API,然後手動取得訂閱資訊。
之所以將本程式碼研究室著重於第二個解決方案,是因為您必須公開伺服器至網際網路,才能實作 Webhook。
在正式環境中,最好能同時使用這兩種環境。用來從 App Store 取得事件的 Webhook,以及 Server API,讓您在遺漏事件或需要重新確認訂閱狀態時一併取得。
首先,開啟 lib/app_store_purchase_handler.dart
,並新增 AppStoreServerAPI 依附元件:
lib/app_store_purchase_handler.dart
final AppStoreServerAPI appStoreServerAPI;
AppStorePurchaseHandler(
this.iapRepository,
this.appStoreServerAPI, // new
)
修改建構函式,加入會呼叫 _pullStatus
方法的計時器。這個計時器每 10 秒都會呼叫 _pullStatus
方法。你可以視需求調整計時器時間長度。
lib/app_store_purchase_handler.dart
AppStorePurchaseHandler(
this.iapRepository,
this.appStoreServerAPI,
) {
// Poll Subscription status every 10 seconds.
Timer.periodic(Duration(seconds: 10), (_) {
_pullStatus();
});
}
接著,建立 _pullStatus 方法,如下所示:
lib/app_store_purchase_handler.dart
Future<void> _pullStatus() async {
print('Polling App Store');
final purchases = await iapRepository.getPurchases();
// filter for App Store subscriptions
final appStoreSubscriptions = purchases.where((element) =>
element.type == ProductType.subscription &&
element.iapSource == IAPSource.appstore);
for (final purchase in appStoreSubscriptions) {
final status =
await appStoreServerAPI.getAllSubscriptionStatuses(purchase.orderId);
// Obtain all subscriptions for the order id.
for (final subscription in status.data) {
// Last transaction contains the subscription status.
for (final transaction in subscription.lastTransactions) {
final expirationDate = DateTime.fromMillisecondsSinceEpoch(
transaction.transactionInfo.expiresDate ?? 0);
// Check if subscription has expired.
final isExpired = expirationDate.isBefore(DateTime.now());
print('Expiration Date: $expirationDate - isExpired: $isExpired');
// Update the subscription status with the new expiration date and status.
await iapRepository.updatePurchase(SubscriptionPurchase(
userId: null,
productId: transaction.transactionInfo.productId,
iapSource: IAPSource.appstore,
orderId: transaction.originalTransactionId,
purchaseDate: DateTime.fromMillisecondsSinceEpoch(
transaction.transactionInfo.originalPurchaseDate),
type: ProductType.subscription,
expiryDate: expirationDate,
status: isExpired
? SubscriptionStatus.expired
: SubscriptionStatus.active,
));
}
}
}
}
這個方法的運作方式如下:
- 使用 IapRepository 從 Firestore 取得有效訂閱項目的清單。
- 它會向 App Store Server API 要求訂閱狀態。
- 取得該訂閱購買交易的上一筆交易。
- 檢查到期日,
- 如果訂閱項目已過期,系統會更新 Firestore 中的訂閱項目狀態。
最後,加入設定 App Store Server API 存取權的所有必要程式碼:
bin/server.dart
// add from here
final subscriptionKeyAppStore =
File('assets/SubscriptionKey.p8').readAsStringSync();
// Configure Apple Store API access
var appStoreEnvironment = AppStoreEnvironment.sandbox(
bundleId: bundleId,
issuerId: appStoreIssuerId,
keyId: appStoreKeyId,
privateKey: subscriptionKeyAppStore,
);
// Stored token for Apple Store API access, if available
final file = File('assets/appstore.token');
String? appStoreToken;
if (file.existsSync() && file.lengthSync() > 0) {
appStoreToken = file.readAsStringSync();
}
final appStoreServerAPI = AppStoreServerAPI(
AppStoreServerHttpClient(
appStoreEnvironment,
jwt: appStoreToken,
jwtTokenUpdatedCallback: (token) {
file.writeAsStringSync(token);
},
),
);
// to here
return {
'google_play': GooglePlayPurchaseHandler(
androidPublisher,
iapRepository,
pubsubApi,
),
'app_store': AppStorePurchaseHandler(
iapRepository,
appStoreServerAPI, // new
),
};
應用程式商店設定
接下來,設定 App Store:
- 登入 App Store Connect,然後選取「使用者和存取權」。
- 前往 [金鑰類型]>應用程式內購。
- 輕觸「加號」圖示以新增一個
- 為轉換命名,例如:「程式碼研究室金鑰」
- 下載含有金鑰的 p8 檔案。
- 將檔案複製到素材資源資料夾,名稱為「
SubscriptionKey.p8
」。 - 從新建立的金鑰複製金鑰 ID,並在
lib/constants.dart
檔案中設為appStoreKeyId
常數。 - 複製金鑰清單頂端的核發機構 ID,然後在
lib/constants.dart
檔案中將其 ID 設為appStoreIssuerId
常數。
追蹤裝置上的購買交易
追蹤交易最安全的方式是在伺服器端這麼做,因為用戶端較不安全,但您需要透過某種方式將資訊傳回用戶端,應用程式才能對訂閱狀態資訊採取行動。只要將購買項目儲存在 Firestore 中,就能輕鬆將資料同步到用戶端並自動更新。
您已在應用程式中納入 IAPRepo,也就是 Firestore 存放區,當中包含 List<PastPurchase> purchases
中使用者的所有購買資料。存放區也包含 hasActiveSubscription,
,如果交易具有 productId storeKeySubscription
的狀態為尚未過期,則此值為 true。如果使用者未登入,清單就不會顯示任何內容。
lib/repo/iap_repo.dart
void updatePurchases() {
_purchaseSubscription?.cancel();
var user = _user;
if (user == null) {
purchases = [];
hasActiveSubscription = false;
hasUpgrade = false;
return;
}
var purchaseStream = _firestore
.collection('purchases')
.where('userId', isEqualTo: user.uid)
.snapshots();
_purchaseSubscription = purchaseStream.listen((snapshot) {
purchases = snapshot.docs.map((DocumentSnapshot document) {
var data = document.data();
return PastPurchase.fromJson(data);
}).toList();
hasActiveSubscription = purchases.any((element) =>
element.productId == storeKeySubscription &&
element.status != Status.expired);
hasUpgrade = purchases.any(
(element) => element.productId == storeKeyUpgrade,
);
notifyListeners();
});
}
所有購買邏輯都位於 DashPurchases
類別中,應套用或移除訂閱項目。因此,請將 iapRepo
新增為類別中的屬性,並在建構函式中指派 iapRepo
。接下來,請直接在建構函式中新增事件監聽器,並移除 dispose()
方法中的事件監聽器。事件監聽器一開始只是一個空白函式。由於 IAPRepo
是 ChangeNotifier
,而且每當 Firestore 中的購買交易發生變更時,您都會呼叫 notifyListeners()
,因此當購買的產品有所變更時,系統一律會呼叫 purchasesUpdate()
方法。
lib/logic/dash_purchases.dart
IAPRepo iapRepo;
DashPurchases(this.counter, this.firebaseNotifier, this.iapRepo) {
final purchaseUpdated =
iapConnection.purchaseStream;
_subscription = purchaseUpdated.listen(
_onPurchaseUpdate,
onDone: _updateStreamOnDone,
onError: _updateStreamOnError,
);
iapRepo.addListener(purchasesUpdate);
loadPurchases();
}
@override
void dispose() {
iapRepo.removeListener(purchasesUpdate);
_subscription.cancel();
super.dispose();
}
void purchasesUpdate() {
//TODO manage updates
}
接著,將 IAPRepo
提供給 main.dart.
中的建構函式。您可以使用 context.read
取得存放區,因為該存放區已在 Provider
中建立。
lib/main.dart
ChangeNotifierProvider<DashPurchases>(
create: (context) => DashPurchases(
context.read<DashCounter>(),
context.read<FirebaseNotifier>(),
context.read<IAPRepo>(),
),
lazy: false,
),
接著,編寫 purchaseUpdate()
函式的程式碼。在 dash_counter.dart,
中,applyPaidMultiplier
和 removePaidMultiplier
方法會將調節係數分別設為 10 或 1,因此您不必檢查訂閱項目是否已套用。訂閱狀態變更時,您也可以更新可購買產品的狀態,這樣訂購的產品就會顯示在購買頁面上。根據是否購買升級授權設定 _beautifiedDashUpgrade
屬性。
lib/logic/dash_purchases.dart
void purchasesUpdate() {
var subscriptions = <PurchasableProduct>[];
var upgrades = <PurchasableProduct>[];
// Get a list of purchasable products for the subscription and upgrade.
// This should be 1 per type.
if (products.isNotEmpty) {
subscriptions = products
.where((element) => element.productDetails.id == storeKeySubscription)
.toList();
upgrades = products
.where((element) => element.productDetails.id == storeKeyUpgrade)
.toList();
}
// Set the subscription in the counter logic and show/hide purchased on the
// purchases page.
if (iapRepo.hasActiveSubscription) {
counter.applyPaidMultiplier();
for (var element in subscriptions) {
_updateStatus(element, ProductStatus.purchased);
}
} else {
counter.removePaidMultiplier();
for (var element in subscriptions) {
_updateStatus(element, ProductStatus.purchasable);
}
}
// Set the Dash beautifier and show/hide purchased on
// the purchases page.
if (iapRepo.hasUpgrade != _beautifiedDashUpgrade) {
_beautifiedDashUpgrade = iapRepo.hasUpgrade;
for (var element in upgrades) {
_updateStatus(
element,
_beautifiedDashUpgrade
? ProductStatus.purchased
: ProductStatus.purchasable);
}
notifyListeners();
}
}
void _updateStatus(PurchasableProduct product, ProductStatus status) {
if (product.status != ProductStatus.purchased) {
product.status = ProductStatus.purchased;
notifyListeners();
}
}
現在,您已確定在後端服務中的訂閱和升級狀態始終是最新狀態,並與應用程式保持同步。應用程式會據此執行動作,並在 Dash Clicker 遊戲中套用訂閱和升級功能。
12. 全部完成!
恭喜!您已完成程式碼研究室。您可以在 complete 資料夾中找到本程式碼研究室完成後的程式碼。
詳情請參閱其他 Flutter 程式碼研究室。