1. 简介
如需向 Flutter 应用添加应用内购商品,您需要正确设置应用商店和 Play 商店、验证购买交易,以及授予必要的权限(例如订阅福利)。
在此 Codelab 中,您将向一个应用(已提供)添加三种类型的应用内购,并使用 Dart 后端与 Firebase 一起验证这些购买交易。所提供的应用 Dash Clicker 包含一款使用 Dash 吉祥物作为货币的游戏。您将添加以下购买选项:
- 一次性购买 2,000 个 Dash 的购买选项(可重复使用)。
- 一次性升级购买交易,可将旧版 Dash 板块改为新版 Dash 板块。
- 一种订阅,可将自动生成的点击次数翻倍。
第一个购买选项可直接为用户提供 2,000 个 Dash。这些资源可直接供用户使用,并且可以多次购买。由于此类资源会被直接消耗且可多次消耗,因此被称为消耗型资源。
第二种方法是将 Dash 升级为更美观的 Dash。此服务只需购买一次,即可永久使用。此类购买交易称为“非消耗型购买交易”,因为应用无法消耗此类购买交易,但其有效期为永久。
第三种也是最后一种购买选项是订阅。订阅有效期间,用户将更快获得 Dash 短语,但当用户停止付费订阅后,相关福利也会随之消失。
后端服务(也由我们提供)会作为 Dart 应用运行,验证购买交易是否已完成,并使用 Firestore 存储这些交易。Firestore 用于简化此流程,但在正式版应用中,您可以使用任何类型的后端服务。
构建内容
- 您将扩展应用以支持消耗型购买交易和订阅。
- 您还将扩展 Dart 后端应用,以验证和存储购买的商品。
学习内容
- 如何在 App Store 和 Play 商店中配置可购买的商品。
- 如何与商店通信以验证购买交易并将其存储在 Firestore 中。
- 如何管理应用内的购买交易。
所需条件
- Android Studio 4.1 或更高版本
- Xcode 12 或更高版本(用于 iOS 开发)
- Flutter SDK
2. 设置开发环境
如需开始此 Codelab,请下载代码,然后更改 iOS 的软件包标识符和 Android 的软件包名称。
下载代码
如需从命令行克隆 GitHub 代码库,请使用以下命令:
git clone https://github.com/flutter/codelabs.git flutter-codelabs
或者,如果您已安装 GitHub 的 CLI 工具,请使用以下命令:
gh repo clone flutter/codelabs flutter-codelabs
示例代码会克隆到包含一系列 Codelab 代码的 flutter-codelabs
目录中。此 Codelab 的代码位于 flutter-codelabs/in_app_purchases
中。
flutter-codelabs/in_app_purchases
下的目录结构包含一系列快照,这些快照显示了您在完成每个命名步骤后应达到的状态。起始代码位于第 0 步,因此只需执行以下操作即可找到匹配的文件:
cd flutter-codelabs/in_app_purchases/step_00
如果您想跳转到后面的步骤或查看某个步骤之后的内容,请查看以相应步骤命名的目录。最后一步的代码位于 complete
文件夹下。
设置入门级项目
在您喜欢的 IDE 中打开 step_00/app
中的起始项目。我们使用 Android Studio 截取了屏幕截图,但 Visual Studio Code 也是不错的选择。无论使用哪种编辑器,请确保安装了最新的 Dart 和 Flutter 插件。
您要开发的应用需要与 App Store 和 Play 商店通信,以了解哪些商品有售以及价格。每个应用都有一个唯一 ID。对于 iOS App Store,此 ID 称为软件包标识符;对于 Android Play 商店,此 ID 称为应用 ID。这些标识符通常使用反向域名标记法创建。例如,为 flutter.dev 创建应用内购买应用时,您需要使用 dev.flutter.inapppurchase
。为您的应用构想一个标识符,您现在将在项目设置中进行设置。
首先,为 iOS 设置软件包标识符。为此,请在 Xcode 应用中打开 Runner.xcworkspace
文件。
在 Xcode 的文件夹结构中,Runner 项目位于顶部,Flutter、Runner 和 Products 目标位于 Runner 项目下方。双击 Runner 以修改项目设置,然后点击 Signing & Capabilities(签名和功能)。在团队字段下输入您刚刚选择的软件包标识符,以设置您的团队。
现在,您可以关闭 Xcode 并返回 Android Studio,以完成 Android 的配置。为此,请打开 android/app,
下的 build.gradle.kts
文件,然后将 applicationId
(在下方屏幕截图的 24 行)更改为应用 ID,该 ID 与 iOS 软件包标识符相同。请注意,iOS 和 Android 商店的 ID 不必完全相同,但保持它们相同会降低出错几率,因此在此 Codelab 中,我们也将使用完全相同的标识符。
3. 安装插件
在此 Codelab 的此部分中,您将安装 in_app_purchase 插件。
在 pubspec 中添加依赖项
将 in_app_purchase
添加到 pubspec 的依赖项中,以将 in_app_purchase
添加到 pubspec:
$ cd app $ flutter pub add in_app_purchase dev:in_app_purchase_platform_interface
打开 pubspec.yaml
,并确认您现在已将 in_app_purchase
列为 dependencies
下的条目,并将 in_app_purchase_platform_interface
列为 dev_dependencies
下的条目。
pubspec.yaml
dependencies: flutter: sdk: flutter cloud_firestore: ^5.6.3 cupertino_icons: ^1.0.8 firebase_auth: ^5.4.2 firebase_core: ^3.11.0 google_sign_in: ^6.2.2 http: ^1.3.0 intl: ^0.20.2 provider: ^6.1.2 in_app_purchase: ^3.2.1 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^5.0.0 in_app_purchase_platform_interface: ^1.4.0
点击 pub get 下载软件包,或在命令行中运行 flutter pub get
。
4. 设置 App Store
如需在 iOS 上设置应用内购买功能并对其进行测试,您需要在 App Store 中创建新应用,并在其中创建可购买的商品。您无需发布任何内容,也无需将应用发送给 Apple 进行审核。您需要拥有开发者账号才能执行此操作。如果您还没有 Apple 开发者账号,请注册 Apple 开发者计划。
付费应用协议
如需使用应用内购,您还需要在 App Store Connect 中拥有有效的付费应用协议。前往 https://appstoreconnect.apple.com/,然后点击协议、税务和银行。
您会在此处看到免费应用和付费应用的协议。免费应用的状态应为“有效”,付费应用的状态为“新”。请务必查看条款、接受条款并输入所有必要信息。
所有设置正确无误后,付费应用的状态将变为“已启用”。这一点非常重要,因为如果未签署有效的协议,您将无法试用应用内购买功能。
注册应用 ID
在 Apple 开发者门户中创建新的标识符。访问 https://developer.apple.com/account/resources/identifiers/list,然后点击“标识符”标题旁边的“加号”图标 。
选择应用 ID
选择应用
提供一些说明,并将软件包 ID 设置为与之前在 XCode 中设置的软件包 ID 相同的值。
如需有关如何创建新应用 ID 的更多指导,请参阅开发者账号帮助。
创建新应用
在 App Store Connect 中使用您的唯一软件包标识符创建新应用。
如需有关如何创建新应用和管理协议的更多指导,请参阅 App Store Connect 帮助。
如需测试应用内购买交易,您需要创建沙盒测试用户。此测试用户不应与 iTunes 相关联,仅用于测试应用内购买交易。您不能使用已用于 Apple 账号的电子邮件地址。在用户和访问权限中,前往沙盒以创建新的沙盒账号或管理现有的沙盒 Apple ID。
现在,您可以在 iPhone 上依次前往设置 > 开发者 > 沙盒 Apple 账号,设置沙盒用户。
配置应用内购买
现在,您需要配置三个可购买的商品:
dash_consumable_2k
:可多次购买的消耗型购买交易,每次购买可为用户提供 2, 000 个 Dash(应用内货币)。dash_upgrade_3d
:一次性购买的非消耗型“升级”购买交易,可为用户提供外观不同的 Dash 来点击。dash_subscription_doubler
:一种订阅,可在订阅期间让用户每次点击获得的 Dash 数量翻倍。
前往应用内购。
使用指定的 ID 创建应用内商品:
- 将
dash_consumable_2k
设置为消耗型资源。使用dash_consumable_2k
作为商品 ID。引用名称仅在 App Store Connect 中使用,只需将其设置为dash consumable 2k
即可。设置空闲时段。商品必须在沙盒用户所在的国家/地区提供。
添加价格,并将价格设置为
$1.99
或其他币种的等值。为购买交易添加本地化内容。使用
2000 dashes fly out
作为说明调用购买交易Spring is in the air
。添加评价屏幕截图。除非商品要送审,否则内容无关紧要,但商品必须处于“可提交”状态,这是应用从 App Store 提取商品时所必需的。
- 将
dash_upgrade_3d
设置为非消耗型。使用dash_upgrade_3d
作为商品 ID。将引用名称设置为dash upgrade 3d
。使用Brings your dash back to the future
作为说明调用购买交易3D Dash
。将价格设置为$0.99
。配置库存状况并上传评价屏幕截图,方法与dash_consumable_2k
商品相同。 - 将
dash_subscription_doubler
设置为自动续订的订阅。订阅流程略有不同。首先,您必须创建一个订阅组。如果多项订阅属于同一组,用户只能同时订阅其中一种,但可以轻松在这些订阅之间升级或降级。只需将此组命名为subscriptions
即可。并为订阅组添加本地化设置。
接下来,您将创建订阅。将“参考名称”设置为
dash subscription doubler
,将“商品 ID”设置为dash_subscription_doubler
。接下来,选择 1 周的订阅时长和本地化设置。为此订阅命名为
Jet Engine
,并添加说明Doubles your clicks
。将价格设置为$0.49
。配置库存状况并上传评价屏幕截图,方法与dash_consumable_2k
商品相同。
您现在应该会在列表中看到商品:
5. 设置 Play 商店
与 App Store 一样,您还需要拥有 Play 商店开发者账号。如果您还没有账号,请注册账号。
创建新应用
在 Google Play 管理中心内创建新应用:
- 打开 Play 管理中心。
- 依次选择所有应用 > 创建应用。
- 选择默认语言,并为您的应用添加名称。输入您希望应用在 Google Play 上显示的名称。您日后可以更改此名称。
- 指明您的应用是游戏。您可以在以后更改此设置。
- 指明您的应用是免费应用还是付费应用。
- 填写“内容准则”和“美国出口法律”声明。
- 选择创建应用。
创建应用后,前往信息中心,然后完成设置应用部分中的所有任务。在这里,您可以提供有关应用的一些信息,例如内容分级和屏幕截图。
对应用进行签名
若要测试应用内购买功能,您需要至少将一个 build 上传到 Google Play。
为此,您需要使用调试密钥以外的其他内容对发布 build 进行签名。
创建密钥库
如果您已有密钥库,请跳至下一步。如果没有,请在命令行中运行以下命令来创建一个。
在 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
将文件设为私有;请勿将其签入公共源代码控制系统!
从应用引用密钥库
创建一个名为 <your app dir>/android/key.properties
的文件,其中包含对密钥库的引用:
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.kts
文件,为应用配置签名。
在 android
代码块前面添加属性文件中的密钥库信息:
import java.util.Properties import java.io.FileInputStream plugins { // omitted } val keystoreProperties = Properties() val keystorePropertiesFile = rootProject.file("key.properties") if (keystorePropertiesFile.exists()) { keystoreProperties.load(FileInputStream(keystorePropertiesFile)) } android { // omitted }
将 key.properties
文件加载到 keystoreProperties
对象中。
将 buildTypes
代码块更新为:
buildTypes { release { signingConfig = signingConfigs.getByName("release") } }
使用签名配置信息配置模块的 build.gradle.kts
文件中的 signingConfigs
块:
signingConfigs { create("release") { keyAlias = keystoreProperties["keyAlias"] as String keyPassword = keystoreProperties["keyPassword"] as String storeFile = keystoreProperties["storeFile"]?.let { file(it) } storePassword = keystoreProperties["storePassword"] as String } } buildTypes { release { signingConfig = signingConfigs.getByName("release") } }
应用的发布 build 现在将自动签名。
如需详细了解如何为应用签名,请参阅 developer.android.com 上的为应用签名。
上传您的首个 build
将应用配置为签名后,您应该能够通过运行以下命令构建应用:
flutter build appbundle
此命令默认会生成一个发布 build,输出结果可在 <your app dir>/build/app/outputs/bundle/release/
下找到
在 Google Play 管理中心的控制台中,依次前往测试和发布 > 测试 > 封闭式测试,然后创建新的封闭式测试版本。
接下来,上传 build 命令生成的 app-release.aab
app bundle。
点击保存,然后点击检查发布版本。
最后,点击开始面向封闭式测试发布以启用封闭式测试版本。
设置测试用户
如需测试应用内购买功能,您必须在 Google Play 管理中心的两个位置添加测试人员的 Google 账号:
- 到特定测试轨道(内部测试)
- 作为许可测试人员
首先,将测试人员添加到内部测试轨道。返回到测试和发布 > 测试 > 内部测试,然后点击测试人员标签页。
点击创建电子邮件收件人列表,创建新的电子邮件收件人列表。为列表命名,然后添加需要有权测试应用内购买的 Google 账号的电子邮件地址。
接下来,选中相应列表的复选框,然后点击保存更改。
然后,添加许可测试人员:
- 返回 Google Play 管理中心的所有应用视图。
- 依次前往设置 > 许可测试。
- 添加需要能够测试应用内购买的测试人员的电子邮件地址。
- 将许可响应设置为
RESPOND_NORMALLY
。 - 点击保存。
配置应用内购买
现在,您将配置可在应用中购买的商品。
与在 App Store 中一样,您必须定义三种不同的购买交易:
dash_consumable_2k
:可多次购买的消耗型购买交易,每次购买可为用户提供 2, 000 个 Dash(应用内货币)。dash_upgrade_3d
:一次性购买的非消耗型“升级”购买交易,可为用户提供外观不同的 Dash 点击对象。dash_subscription_doubler
:一种订阅,可在订阅期间让用户每次点击获得的 Dash 数量翻倍。
首先,添加消耗型和非消耗型商品。
- 前往 Google Play 管理中心,然后选择您的应用。
- 依次前往创收 > 商品 > 应用内商品。
- 点击创建商品
- 输入商品的所有必需信息。请确保商品 ID 与您打算使用的 ID 完全一致。
- 点击保存。
- 点击启用。
- 对非消耗型“升级”购买交易重复上述过程。
接下来,添加订阅:
- 前往 Google Play 管理中心,然后选择您的应用。
- 依次前往创收 > 商品 > 订阅。
- 点击创建订阅
- 输入订阅的所有必要信息。请确保商品 ID 与您打算使用的 ID 完全一致。
- 点击保存
现在,您应该已在 Play 管理中心内设置购买交易。
6. 设置 Firebase
在此 Codelab 中,您将使用后端服务来验证和跟踪用户的购买交易。
使用后端服务具有以下几点优势:
- 您可以安全地验证交易。
- 您可以对应用商店中的结算事件做出响应。
- 您可以在数据库中跟踪购买交易。
- 用户将无法通过快退系统时钟来欺骗您的应用提供付费功能。
虽然有许多方法可以设置后端服务,但您将使用 Google 自己的 Firebase 通过 Cloud Functions 和 Firestore 来实现这一点。
编写后端超出了此 Codelab 的范围,因此起始代码已包含一个用于处理基本购买交易的 Firebase 项目,以便您快速上手。
该入门应用还包含 Firebase 插件。
接下来,您只需创建自己的 Firebase 项目、为 Firebase 配置应用和后端,最后部署后端即可。
创建 Firebase 项目
前往 Firebase 控制台,然后创建一个新的 Firebase 项目。在此示例中,将项目命名为 Dash Clicker。
在后端应用中,您需要将购买交易与特定用户相关联,因此需要进行身份验证。为此,请将 Firebase 的身份验证模块与 Google 登录功能结合使用。
- 在 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。按照设置页面中说明的说明操作。
运行 flutterfire configure 时,选择您在上一步中刚刚创建的项目。
$ 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
的证书。密钥库可能位于主文件夹下的 .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 哈希,然后填写应用提交模态对话框中的最后一个字段。
最后,再次运行 flutterfire configure
命令,以更新应用以包含签名配置。
$ flutterfire configure ? You have an existing `firebase.json` file and possibly already configured your project for Firebase. Would you prefer to reuse the valus in your existing `firebase.json` file to configure your project? (y/n) › yes ✔ You have an existing `firebase.json` file and possibly already configured your project for Firebase. Would you prefer to reuse the values in your existing `firebase.json` file to configure your project? · yes
设置适用于 iOS 的 Firebase:后续步骤
使用 Xcode
打开 ios/Runner.xcworkspace
。或者使用您偏好的 IDE。
在 VSCode 中,右键点击 ios/
文件夹,然后点击 open in xcode
。
在 Android Studio 中,右键点击 ios/
文件夹,然后依次点击 flutter
和 open iOS module in Xcode
选项。
如需允许在 iOS 上使用 Google 登录,请将 CFBundleURLTypes
配置选项添加到 build plist
文件中。(如需了解详情,请参阅 google_sign_in
软件包文档。)在本例中,文件为 ios/Runner/Info.plist
和 ios/Runner/Info.plist
。
键值对已添加,但其值必须替换:
- 从
GoogleService-Info.plist
文件中获取REVERSED_CLIENT_ID
的值,不带其周围的<string>..</string>
元素。 - 替换
ios/Runner/Info.plist
文件中CFBundleURLTypes
键下的值。
<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. 监听购买交易更新
在此 Codelab 的此部分中,您将准备好应用以购买商品。此过程包括在应用启动后监听购买交易更新和错误。
监听购买交易更新
在 main.dart,
中,找到包含 Scaffold
且 BottomNavigationBar
包含两个页面的 widget MyHomePage
。此页面还会为 DashCounter
、DashUpgrades,
和 DashPurchases
创建三个 Provider
。DashCounter
会跟踪短划线的当前计数并自动递增。DashUpgrades
会管理您可以使用 Dash 购买的升级。此 Codelab 重点介绍 DashPurchases
。
默认情况下,提供程序的对象是在首次请求该对象时定义的。此对象会在应用启动时直接监听购买交易更新,因此请使用 lazy: false
停用此对象的延迟加载:
lib/main.dart
ChangeNotifierProvider<DashPurchases>( create: (context) => DashPurchases( context.read<DashCounter>(), ), lazy: false, // Add this line ),
您还需要一个 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!; } }
如果您希望测试继续运行,则必须稍微更新测试。
test/widget_test.dart
import 'package:dashclicker/main.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; // Add this import import 'package:in_app_purchase_platform_interface/src/in_app_purchase_platform_addition.dart'; // And this import void main() { testWidgets('App starts', (tester) async { IAPConnection.instance = TestIAPConnection(); // Add this line await tester.pumpWidget(const MyApp()); expect(find.text('Tim Sneath'), findsOneWidget); }); } class TestIAPConnection implements InAppPurchase { // Add from here @override Future<bool> buyConsumable( {required PurchaseParam purchaseParam, bool autoConsume = true}) { return Future.value(false); } @override Future<bool> buyNonConsumable({required PurchaseParam purchaseParam}) { return Future.value(false); } @override Future<void> completePurchase(PurchaseDetails purchase) { return Future.value(); } @override Future<bool> isAvailable() { return Future.value(false); } @override Future<ProductDetailsResponse> queryProductDetails(Set<String> identifiers) { return Future.value(ProductDetailsResponse( productDetails: [], notFoundIDs: [], )); } @override T getPlatformAddition<T extends InAppPurchasePlatformAddition?>() { // TODO: implement getPlatformAddition throw UnimplementedError(); } @override Stream<List<PurchaseDetails>> get purchaseStream => Stream.value(<PurchaseDetails>[]); @override Future<void> restorePurchases({String? applicationUserName}) { // TODO: implement restorePurchases throw UnimplementedError(); } @override Future<String> countryCode() { // TODO: implement countryCode throw UnimplementedError(); } } // To here.
在 lib/logic/dash_purchases.dart
中,找到 DashPurchases ChangeNotifier
的代码。目前,您只能将 DashCounter
添加到已购买的 Dash 中。
添加一个串流订阅属性 _subscription
(类型为 StreamSubscription<List<PurchaseDetails>> _subscription;
)、IAPConnection.instance,
和导入内容。生成的代码应如下所示:
lib/logic/dash_purchases.dart
import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; // Add this import import '../main.dart'; // And this import import '../model/purchasable_product.dart'; import '../model/store_state.dart'; import 'dash_counter.dart'; class DashPurchases extends ChangeNotifier { DashCounter counter; StoreState storeState = StoreState.available; late StreamSubscription<List<PurchaseDetails>> _subscription; // Add this line List<PurchasableProduct> products = [ PurchasableProduct( 'Spring is in the air', 'Many dashes flying out from their nests', '\$0.99', ), PurchasableProduct( 'Jet engine', 'Doubles you clicks per second for a day', '\$1.99', ), ]; bool get beautifiedDash => false; final iapConnection = IAPConnection.instance; // And this line DashPurchases(this.counter); Future<void> buy(PurchasableProduct product) async { product.status = ProductStatus.pending; notifyListeners(); await Future<void>.delayed(const Duration(seconds: 5)); product.status = ProductStatus.purchased; notifyListeners(); await Future<void>.delayed(const Duration(seconds: 5)); product.status = ProductStatus.purchasable; notifyListeners(); } }
late
关键字会添加到 _subscription
,因为 _subscription
是在构造函数中进行初始化的。此项目默认设置为非 null 可选 (NNBD),这意味着未声明为可为 null 的属性必须具有非 null 值。借助 late
限定符,您可以延迟定义此值。
在构造函数中,获取 purchaseUpdated
数据流并开始监听数据流。在 dispose()
方法中,取消流订阅。
lib/logic/dash_purchases.dart
class DashPurchases extends ChangeNotifier { DashCounter counter; StoreState storeState = StoreState.notAvailable; // Modify this line late StreamSubscription<List<PurchaseDetails>> _subscription; List<PurchasableProduct> products = [ PurchasableProduct( 'Spring is in the air', 'Many dashes flying out from their nests', '\$0.99', ), PurchasableProduct( 'Jet engine', 'Doubles you clicks per second for a day', '\$1.99', ), ]; bool get beautifiedDash => false; final iapConnection = IAPConnection.instance; DashPurchases(this.counter) { // Add from here final purchaseUpdated = iapConnection.purchaseStream; _subscription = purchaseUpdated.listen( _onPurchaseUpdate, onDone: _updateStreamOnDone, onError: _updateStreamOnError, ); } @override void dispose() { _subscription.cancel(); super.dispose(); } // To here. Future<void> buy(PurchasableProduct product) async { product.status = ProductStatus.pending; notifyListeners(); await Future<void>.delayed(const Duration(seconds: 5)); product.status = ProductStatus.purchased; notifyListeners(); await Future<void>.delayed(const Duration(seconds: 5)); product.status = ProductStatus.purchasable; notifyListeners(); } // Add from here void _onPurchaseUpdate(List<PurchaseDetails> purchaseDetailsList) { // Handle purchases here } void _updateStreamOnDone() { _subscription.cancel(); } void _updateStreamOnError(dynamic error) { //Handle error here } // To here. }
现在,应用会收到购买交易更新,因此在下一部分中,您将进行购买交易!
在继续操作之前,请使用“flutter test"
”运行测试,以验证所有设置是否正确无误。
$ flutter test 00:01 +1: All tests passed!
8. 购物
在本 Codelab 的这一部分,您将现有的模拟商品替换为实际可购买的商品。这些商品会从商店加载,以列表的形式显示,用户点按商品即可购买。
调整 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; } }
商店可用时,加载可供购买的内容。根据之前的 Google Play 和 App Store 设置,您应该会看到 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); 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
微件会显示 _PurchasesLoading
、_PurchaseList,
或 _PurchasesNotAvailable,
,具体取决于 StoreState
。该 widget 还会显示用户的过往购买交易,以便在下一步中使用。
_PurchaseList
widget 会显示可购买的商品列表,并向 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); case storeKeySubscription: case storeKeyUpgrade: await iapConnection.buyNonConsumable(purchaseParam: purchaseParam); 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(); case storeKeyConsumable: counter.addBoughtDashes(2000); case storeKeyUpgrade: _beautifiedDashUpgrade = true; } } if (purchaseDetails.pendingCompletePurchase) { await iapConnection.completePurchase(purchaseDetails); } }
9. 设置后端
在继续跟踪和验证购买交易之前,请设置 Dart 后端以支持此操作。
在本部分中,将 dart-backend/
文件夹作为根目录。
请确保已安装以下工具:
- Dart
- Firebase CLI
基础项目概览
由于此项目的某些部分被视为超出此 Codelab 的范围,因此已包含在起始代码中。在开始之前,最好先查看起始代码中已有的内容,以了解如何构建项目。
此后端代码可以在您的机器上本地运行,您无需部署即可使用。不过,您需要能够从开发设备(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 或一般数据库交互与本 Codelab 无关,因此起始代码包含用于在 Firestore 中创建或更新购买交易的函数,以及这些购买交易的所有类。
设置 Firebase 访问权限
如需访问 Firebase Firestore,您需要有服务账号访问密钥。如需生成私钥,请打开 Firebase 项目设置,前往服务账号部分,然后选择生成新的私钥。
将下载的 JSON 文件复制到 assets/
文件夹,然后将其重命名为 service-account-firebase.json
。
设置 Google Play 访问权限
如需访问 Play 商店以验证购买交易,您必须生成具有这些权限的服务账号,并下载其 JSON 凭据。
- 访问 Google Cloud 控制台中的 Google Play Android Developer API 页面。
如果 Google Play 管理中心要求您创建或关联现有项目,请先完成相应操作,然后返回此页面。
- 接下来,前往“服务账号”页面,然后点击 + 创建服务账号。
- 输入服务账号名称,然后点击创建并继续。
- 选择 Pub/Sub Subscriber 角色,然后点击 Done(完成)。
- 创建账号后,前往管理密钥。
- 依次选择添加密钥 > 创建新密钥。
- 创建并下载 JSON 密钥。
- 将下载的文件重命名为
service-account-google-play.json,
,并将其移至assets/
目录。 - 接下来,前往 Play 管理中心的用户和权限页面
- 点击邀请新用户,然后输入之前创建的服务账号的电子邮件地址。您可以在“服务账号”页面
的表格中找到该电子邮件地址
- 向应用授予查看财务数据和管理订单和订阅权限。
- 点击邀请用户。
我们还需要做的一件事是打开 lib/constants.dart,
,并将 androidPackageId
的值替换为您为 Android 应用选择的软件包 ID。
设置 Apple App Store 访问权限
如需访问 App Store 以验证购买交易,您必须设置共享密钥:
- 打开 App Store Connect。
- 前往我的应用,然后选择您的应用。
- 在边栏导航栏中,依次选择常规 > 应用信息。
- 点击“应用专用共享 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
的 build 方法开头:
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({super.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 中为 User 添加一个 getter,以便将用户 ID 传递给 verifyPurchase 函数。
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) { return true; } else { 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(); case storeKeyConsumable: counter.addBoughtDashes(1000); case storeKeyUpgrade: _beautifiedDashUpgrade = true; } } } if (purchaseDetails.pendingCompletePurchase) { await iapConnection.completePurchase(purchaseDetails); } }
现在,应用中一切都已准备就绪,可以验证购买交易了。
设置后端服务
接下来,设置后端以便在后端验证购买交易。
构建购买交易处理程序
由于这两家商店的验证流程几乎完全相同,因此请为每家商店分别设置抽象 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, }) async { return true; } @override Future<bool> handleSubscription({ required String userId, required ProductData productData, required String token, }) async { return true; } }
太棒了!现在,您有两个购买交易处理脚本。接下来,我们来创建购买验证 API 端点。
使用购买交易处理程序
打开 bin/server.dart
,然后使用 shelf_route
创建 API 端点:
bin/server.dart
import 'dart:convert'; // new import 'package:firebase_backend_dart/helpers.dart'; import 'package:firebase_backend_dart/products.dart'; // new import 'package:shelf/shelf.dart'; // new import 'package:shelf_router/shelf_router.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.call); } ({ 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
import 'dart:convert'; import 'dart:io'; // new import 'package:firebase_backend_dart/app_store_purchase_handler.dart'; // new import 'package:firebase_backend_dart/google_play_purchase_handler.dart'; // new import 'package:firebase_backend_dart/helpers.dart'; import 'package:firebase_backend_dart/iap_repository.dart'; // new import 'package:firebase_backend_dart/products.dart'; import 'package:firebase_backend_dart/purchase_handler.dart'; // new import 'package:googleapis/androidpublisher/v3.dart' as ap; // new import 'package:googleapis/firestore/v1.dart' as fs; // new import 'package:googleapis_auth/auth_io.dart' as auth; // new import 'package:shelf/shelf.dart'; import 'package:shelf_router/shelf_router.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
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, }; } /// 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; }
您的 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) { 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 通过所谓的 Cloud 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
配置为每 10 秒调用一次 _pullMessageFromPubSub
方法。您可以根据自己的偏好调整时长。
然后,创建 _pullMessageFromPubSub
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, ); }
您刚刚添加的代码会每 10 秒与 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 主题:
- 将
constants.dart
中的googleCloudProjectId
的值设置为您的 Google Cloud 项目的 ID。 - 访问 Google Cloud 控制台中的 Cloud Pub/Sub 页面。
- 确保您当前位于 Firebase 项目中,然后点击 + 创建主题。
- 为新主题指定一个名称,该名称与
constants.dart
中为googlePlayPubsubBillingTopic
设置的值相同。在本例中,将其命名为play_billing
。如果您选择其他内容,请务必更新constants.dart
。创建主题。 - 在 Pub/Sub 主题列表中,点击您刚刚创建的主题对应的三个垂直点,然后点击查看权限。
- 在右侧边栏中,选择添加主账号。
- 在这里,添加
[email protected]
,然后授予其 Pub/Sub 发布商的角色。 - 保存权限更改。
- 复制您刚刚创建的主题的主题名称。
- 再次打开 Play 管理中心,然后从所有应用列表中选择您的应用。
- 向下滚动,然后依次前往创收 > 创收设置。
- 填写完整的主题,然后保存更改。
现在,所有 Google Play 结算事件都将发布到该主题。
处理 App Store 结算事件
接下来,对 App Store 结算事件执行相同的操作。您可以通过两种有效的方式实现在 App Store 中处理购买交易更新。一种是实现您提供给 Apple 的 webhook,Apple 会使用该 webhook 与您的服务器通信。第二种方法(您将在此 Codelab 中找到该方法)是连接到 App Store Server API 并手动获取订阅信息。
本 Codelab 之所以重点介绍第二种解决方案,是因为您必须将服务器公开到互联网上才能实现网络钩子。
在生产环境中,理想情况下,您应该同时拥有这两者。用于从 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:
- 登录 App Store Connect,然后选择用户和访问权限。
- 依次前往集成 > 密钥 > 应用内购买。
- 点按“加号”图标以添加新付款方式。
- 为其命名,例如“Codelab 密钥”。
- 下载包含密钥的 p8 文件。
- 将其复制到 assets 文件夹,并命名为
SubscriptionKey.p8
。 - 从新创建的密钥中复制密钥 ID,并将其设置为
lib/constants.dart
文件中的appStoreKeyId
常量。 - 复制密钥列表顶部的颁发者 ID,并将其设置为
lib/constants.dart
文件中的appStoreIssuerId
常量。
跟踪设备上的购买交易
在服务器端跟踪购买交易是最安全的方式,因为客户端很难保证安全,但您需要找到一些方法将信息传回客户端,以便应用根据订阅状态信息采取行动。通过在 Firestore 中存储购买交易,您可以轻松将数据同步到客户端并自动进行更新。
您已在应用中添加 IAPRepo,这是 Firestore 仓库,其中包含 List<PastPurchase> purchases
中的所有用户购买交易数据。该代码库还包含 hasActiveSubscription,
,如果存在状态未过期的 productId storeKeySubscription
购买交易,则 hasActiveSubscription,
的值为 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((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() { _subscription.cancel(); iapRepo.removeListener(purchasesUpdate); 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 点击游戏。
12. 全部完成!
恭喜!您已完成此 Codelab。您可以在 complete 文件夹中找到此 Codelab 的完整代码。
如需了解详情,请尝试学习其他 Flutter Codelab。