向 Flutter 应用添加应用内购商品

1. 简介

向 Flutter 应用添加应用内购商品需要正确设置应用商店和 Play 商店、验证购买交易,以及授予必要的权限,例如订阅福利。

在此 Codelab 中,您将向一个(为您提供的)应用添加三种类型的应用内购商品,并使用带有 Firebase 的 Dart 后端验证这些购买交易。所提供的应用 Dash Clicker 包含一款使用 Dash 吉祥物作为货币的游戏。您将添加以下购买选项:

  1. 可重复购买的购买选项,一次性购买 2,000 个 Dash。
  2. 一次性升级购买交易,可将旧版 Dash 升级为新版 Dash。
  3. 可使自动生成的点击次数翻倍的订阅。

第一个购买选项可让用户直接获得 2,000 个 Dash。这些商品可直接供用户使用,并且可以多次购买。这称为“消耗型商品”,因为它们可直接消耗,并且可以多次消耗。

第二种方案是将信息中心升级为更美观的信息中心。此功能只需购买一次,即可永久使用。此类购买交易称为“非消耗型”,因为应用无法消耗此类购买交易,但此类购买交易永久有效。

第三种也是最后一种购买选项是订阅。订阅有效期间,用户可以更快地获得 Dash,但当用户停止支付订阅费用时,相应福利也会随之消失。

后端服务(也为您提供)以 Dart 应用的形式运行,验证购买交易是否已完成,并使用 Firestore 存储购买交易。我们使用 Firestore 来简化流程,但在实际应用中,您可以使用任何类型的后端服务。

300123416ebc8dc1.png 7145d0fffe6ea741.png 646317a79be08214.png

构建内容

  • 您将扩展应用以支持消耗型购买交易和订阅。
  • 您还将扩展 Dart 后端应用,以验证和存储购买的商品。

学习 内容

  • 如何配置 App Store 和 Play 商店以提供可购买的商品。
  • 如何与商店通信以验证购买交易并将其存储在 Firestore 中。
  • 如何在应用中管理购买交易。

所需条件

  • Android Studio
  • Xcode(用于 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

示例代码会被克隆到 flutter-codelabs 目录中,该目录包含一系列 Codelab 的代码。此 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 文件。

a9fbac80a31e28e0.png

在 Xcode 的文件夹结构中,Runner 项目位于顶部,而 FlutterRunnerProducts target 位于 Runner 项目下方。双击 Runner 以修改项目设置,然后点击 Signing & Capabilities。在团队字段下输入您刚刚选择的软件包标识符,以设置您的团队。

812f919d965c649a.jpeg

现在,您可以关闭 Xcode 并返回 Android Studio,以完成 Android 的配置。为此,请打开 android/app, 下的 build.gradle.kts 文件,并将 applicationId(在下面的屏幕截图中位于第 24 行)更改为应用 ID,该 ID 与 iOS 软件包标识符相同。请注意,iOS 和 Android 商店的 ID 不必相同,但保持相同可减少出错的可能性,因此在此 Codelab 中,我们也将使用相同的标识符。

e320a49ff2068ac2.png

3. 安装插件

在此 Codelab 的这一部分中,您将安装 in_app_purchase 插件。

在 pubspec 中添加依赖项

通过将 in_app_purchase 添加到项目的依赖项中,将 in_app_purchase 添加到 pubspec:

$ cd app
$ flutter pub add in_app_purchase dev:in_app_purchase_platform_interface
Resolving dependencies... 
Downloading packages... 
  characters 1.4.0 (1.4.1 available)
  flutter_lints 5.0.0 (6.0.0 available)
+ in_app_purchase 3.2.3
+ in_app_purchase_android 0.4.0+3
+ in_app_purchase_platform_interface 1.4.0
+ in_app_purchase_storekit 0.4.4
+ json_annotation 4.9.0
  lints 5.1.1 (6.0.0 available)
  material_color_utilities 0.11.1 (0.13.0 available)
  meta 1.16.0 (1.17.0 available)
  provider 6.1.5 (6.1.5+1 available)
  test_api 0.7.6 (0.7.7 available)
Changed 5 dependencies!
7 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

打开 pubspec.yaml,确认您现在在 dependencies 下看到 in_app_purchase 列为条目,并在 dev_dependencies 下看到 in_app_purchase_platform_interface

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  cloud_firestore: ^6.0.0
  cupertino_icons: ^1.0.8
  firebase_auth: ^6.0.1
  firebase_core: ^4.0.0
  google_sign_in: ^7.1.1
  http: ^1.5.0
  intl: ^0.20.2
  provider: ^6.1.5
  logging: ^1.3.0
  in_app_purchase: ^3.2.3

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0
  in_app_purchase_platform_interface: ^1.4.0

4. 设置 App Store

如需在 iOS 上设置应用内购买并进行测试,您需要在 App Store 中创建新应用,并在其中创建可购买的商品。您无需发布任何内容,也无需将应用送交 Apple 审核。您需要拥有开发者账号才能执行此操作。如果您没有 Apple 开发者账号,请加入 Apple 开发者计划

如需使用应用内购,您还需要在 App Store Connect 中签订有效的付费应用协议。前往 https://appstoreconnect.apple.com/,然后点击协议、税务和银行业务

11db9fca823e7608.png

您将在此处看到免费应用和付费应用的协议。免费应用的状态应为“有效”,付费应用的状态应为“新”。请务必查看并接受相关条款,然后输入所有必需的信息。

74c73197472c9aec.png

如果所有设置都正确无误,付费应用的状态将为“有效”。这一点非常重要,因为如果没有有效协议,您将无法尝试应用内购。

4a100bbb8cafdbbf.jpeg

注册应用 ID

在 Apple Developer Portal 中创建新的标识符。访问 developer.apple.com/account/resources/identifiers/list,然后点击标识符标题旁边的“加号”图标。

55d7e592d9a3fc7b.png

选择应用 ID

13f125598b72ca77.png

选择应用

41ac4c13404e2526.png

提供一些说明,并将软件包 ID 设置为与之前在 Xcode 中设置的软件包 ID 相同的值。

9d2c940ad80deeef.png

如需详细了解如何创建新的应用 ID,请参阅开发者账号帮助

创建新应用

在 App Store Connect 中使用您的唯一软件包标识符创建新应用。

10509b17fbf031bd.png

5b7c0bb684ef52c7.png

如需详细了解如何创建新应用和管理协议,请参阅 App Store Connect 帮助

如需测试应用内购,您需要沙盒测试用户。此测试用户不应与 iTunes 关联,仅用于测试应用内购。您无法使用已用于 Apple 账号的电子邮件地址。在用户和访问权限中,前往沙盒以创建新的沙盒账号或管理现有的沙盒 Apple ID。

2ba0f599bcac9b36.png

现在,您可以前往设置 > 开发者 > 沙盒 Apple 账号,在 iPhone 上设置沙盒用户。

74a545210b282ad8.png eaa67752f2350f74.png

配置应用内购买交易

现在,您将配置三个可购买的商品:

  • dash_consumable_2k:一种可多次购买的消耗型商品,每次购买可为用户提供 2, 000 个 Dash(应用内货币)。
  • dash_upgrade_3d:一种不可消耗的“升级”购买交易,只能购买一次,可让用户点击外观不同的 Dash。
  • dash_subscription_doubler:订阅后,用户在订阅期内每次点击可获得两倍的冲刺次数。

a118161fac83815a.png

前往应用内购

使用指定的 ID 创建应用内购买商品:

  1. dash_consumable_2k 设置为消耗型。使用 dash_consumable_2k 作为商品 ID。参考名称仅在 App Store Connect 中使用,只需将其设置为 dash consumable 2k 即可。1f8527fc03902099.png 设置空闲时段。商品必须在沙盒用户的所在国家/地区提供。bd6b2ce2d9314e6e.png 添加价格并将价格设置为 $1.99 或其他币种的等值价格。926b03544ae044c4.png 添加购买交易的本地化信息。使用 2000 dashes fly out 作为说明调用购买 Spring is in the aire26dd4f966dcfece.png 添加评价屏幕截图。除非商品已送审,否则内容并不重要,但商品必须处于“准备提交”状态,这样应用才能从 App Store 中提取商品。25171bfd6f3a033a.png
  2. dash_upgrade_3d 设置为不可消耗型。使用 dash_upgrade_3d 作为商品 ID。将参考名称设置为 dash upgrade 3d。使用 Brings your dash back to the future 作为说明调用购买 3D Dash。将价格设置为 $0.99。配置库存状况并上传评价屏幕截图,方式与 dash_consumable_2k 商品相同。83878759f32a7d4a.png
  3. dash_subscription_doubler 设置为自动续订的订阅。订阅流程略有不同。首先,您必须创建订阅组。如果多个订阅属于同一组,用户一次只能订阅其中一个,但可以在这些订阅之间升级或降级。只需拨打此群组 subscriptions393a44b09f3cd8bf.png 并为订阅组添加本地化版本。595aa910776349bd.png 接下来,您将创建订阅。将“参考名称”设置为 dash subscription doubler,并将“产品 ID”设置为 dash_subscription_doubler7bfff7bbe11c8eec.png 接下来,选择 1 周的订阅时长和本地化版本。将此订阅命名为 Jet Engine,并添加说明 Doubles your clicks。将价格设置为 $0.49。配置库存状况并上传评价屏幕截图,方式与 dash_consumable_2k 商品相同。44d18e02b926a334.png

您现在应该会在列表中看到商品:

17f242b5c1426b79.png d71da951f595054a.png

5. 设置 Play 商店

与 App Store 类似,您还需要一个 Play 商店开发者账号。如果您还没有账号,请注册一个账号

创建新应用

在 Google Play 管理中心内创建新应用:

  1. 打开 Play 管理中心
  2. 依次选择所有应用 > 创建应用
  3. 选择默认语言,并为您的应用添加名称。输入您希望应用在 Google Play 上显示的名称。您日后可以更改此名称。
  4. 指明您的应用是游戏。您可以在以后更改此设置。
  5. 指明您的应用是免费应用还是付费应用。
  6. 填写“内容准则”和“美国出口法律”声明。
  7. 选择创建应用

创建应用后,前往信息中心,完成设置应用部分中的所有任务。在此步骤中,您需要提供有关应用的某些信息,例如内容分级和屏幕截图。13845badcf9bc1db.png

对应用进行签名

如需测试应用内购,您需要至少将一个 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")
        }
    }

现在,应用的发布版本将自动签名。

如需详细了解如何为您的应用签名,请参阅 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 账号:

  1. 到特定测试轨道(内部测试)
  2. 作为许可测试人员

首先,将测试人员添加到内部测试轨道。返回到测试和发布 > 测试 > 内部测试,然后点击测试人员标签页。

a0d0394e85128f84.png

点击创建电子邮件收件人列表,创建新的电子邮件收件人列表。为该列表命名,然后添加需要访问权限才能测试应用内购商品的 Google 账号的电子邮件地址。

接下来,选中相应列表对应的复选框,然后点击保存更改

然后,添加许可测试人员:

  1. 返回 Google Play 管理中心的所有应用视图。
  2. 依次前往设置 > 许可测试
  3. 添加需要能够测试应用内购的测试人员的相同电子邮件地址。
  4. 许可响应设置为 RESPOND_NORMALLY
  5. 点击保存

a1a0f9d3e55ea8da.png

配置应用内购买交易

现在,您将配置可在应用内购买的商品。

与在 App Store 中一样,您必须定义三种不同的购买交易:

  • dash_consumable_2k:一种可多次购买的消耗型商品,每次购买可为用户提供 2, 000 个 Dash(应用内货币)。
  • dash_upgrade_3d:只能购买一次的不可消耗型“升级”购买交易,可让用户点击外观不同的 Dash。
  • dash_subscription_doubler:订阅后,用户在订阅期内每次点击可获得两倍的冲刺次数。

首先,添加消耗品和非消耗品。

  1. 前往 Google Play 管理中心,然后选择您的应用。
  2. 前往创收 > 商品 > 应用内商品
  3. 点击创建商品图标 c8d66e32f57dee21.png
  4. 输入商品的所有必需信息。确保商品 ID 与您打算使用的 ID 完全一致。
  5. 点击保存
  6. 点击启用
  7. 针对非消耗型“升级”购买交易重复此流程。

接下来,添加订阅:

  1. 前往 Google Play 管理中心,然后选择您的应用。
  2. 依次前往创收 > 商品 > 订阅
  3. 点击创建订阅图标 32a6a9eefdb71dd0.png
  4. 输入订阅的所有必需信息。确保商品 ID 与您打算使用的 ID 完全一致。
  5. 点击保存

现在,您应该已在 Play 管理中心内设置好购买交易。

6. 设置 Firebase

在此 Codelab 中,您将使用后端服务来验证和跟踪用户的购买交易。

使用后端服务具有以下几项优势:

  • 您可以安全地验证交易。
  • 您可以对应用商店中的结算事件做出反应。
  • 您可以在数据库中跟踪购买交易。
  • 用户无法通过回拨系统时钟来欺骗您的应用,让其提供付费功能。

虽然有很多方法可以设置后端服务,但您将使用 Google 自己的 Firebase,通过 Cloud Functions 和 Firestore 来完成此操作。

编写后端不在本 Codelab 的讨论范围内,因此起始代码已包含一个处理基本购买交易的 Firebase 项目,可帮助您入门。

起始应用中还包含 Firebase 插件。

您只需创建自己的 Firebase 项目,为 Firebase 配置应用和后端,最后部署后端即可。

创建 Firebase 项目

前往 Firebase 控制台,然后创建一个新的 Firebase 项目。在此示例中,将项目命名为 Dash Clicker。

在后端应用中,您需要将购买交易与特定用户相关联,因此需要进行身份验证。为此,请将 Firebase 的身份验证模块与 Google 登录搭配使用。

  1. 在 Firebase 控制台中,前往身份验证并根据需要启用它。
  2. 前往登录方法标签页,然后启用 Google 登录提供方。

fe2e0933d6810888.png

由于您还将使用 Firebase 的 Firestore 数据库,因此也请启用此功能。

d02d641821c71e2c.png

按如下方式设置 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
    }
  }
}

为 Flutter 设置 Firebase

建议使用 FlutterFire CLI 在 Flutter 应用上安装 Firebase。按照设置页面中的说明操作。

运行 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>

接下来,选择 iOSAndroid 以启用这两个平台。

? 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

为 Android 设置 Firebase:后续步骤

在 Firebase 控制台中,前往项目概览,选择设置,然后选择常规标签页。

向下滚动到您的应用,然后选择 dashclicker (android) 应用。

b22d46a759c0c834.png

如需在调试模式下允许 Google 登录,您必须提供调试证书的 SHA-1 哈希指纹。

获取调试签名证书哈希

在 Flutter 应用项目的根目录中,将目录更改为 android/ 文件夹,然后生成签名报告。

cd android
./gradlew :app:signingReport

系统会显示一个包含大量签名密钥的列表。由于您要查找调试证书的哈希值,因此请查找 VariantConfig 属性设置为 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/ 文件夹,然后依次点击 flutteropen iOS module in Xcode 选项。

如需在 iOS 上启用 Google 登录,请将 CFBundleURLTypes 配置选项添加到 build plist 文件中。(如需了解详情,请查看 google_sign_in 软件包文档。)在本例中,该文件为 ios/Runner/Info.plist

键值对已添加,但其值必须替换:

  1. GoogleService-Info.plist 文件中获取 REVERSED_CLIENT_ID 的值,不包括周围的 <string>..</string> 元素。
  2. 替换 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 的微件 MyHomePage,该 Scaffold 包含两个页面。BottomNavigationBar此页面还为 DashCounterDashUpgrades,DashPurchases 创建了三个 ProviderDashCounter 跟踪短划线的当前数量并自动递增。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 中,前往 DashPurchasesChangeNotifier 的代码。目前,您只能向购买的 Dash 添加 DashCounter

添加一个流订阅属性 _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

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:in_app_purchase/in_app_purchase.dart';

import '../main.dart';
import '../model/purchasable_product.dart';
import '../model/store_state.dart';
import 'dash_counter.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 设置,您应该会看到 storeKeyConsumablestoreKeySubscription,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();                                       // Add this line
  }

最后,将 storeState 字段的值从 StoreState.available 更改为 StoreState.loading:

lib/logic/dash_purchases.dart

StoreState storeState = StoreState.loading;

展示可供购买的商品

purchase_page.dart 文件为例。PurchasePage widget 会根据 StoreState 显示 _PurchasesLoading_PurchaseList,_PurchasesNotAvailable,。该 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 应用商店中看到可用的商品。请注意,在相应控制台中输入购买交易后,可能需要过一段时间才能看到这些交易。

ca1a9f97c21e552d.png

返回到 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/ 文件夹为根目录。

请确保您已安装以下工具:

基本项目概览

由于此项目的某些部分被视为超出此 Codelab 的范围,因此它们包含在起始代码中。在开始之前,最好先了解起始代码中已包含的内容,以便了解您将如何构建内容。

此后端代码可在您的机器上本地运行,您无需部署即可使用。不过,您需要能够从开发设备(Android 或 iPhone)连接到服务器将要运行的机器。为此,两台设备必须位于同一网络中,并且您需要知道自己设备的 IP 地址。

尝试使用以下命令运行服务器:

$ dart ./bin/server.dart

Serving at http://0.0.0.0:8080

Dart 后端使用 shelfshelf_router 来提供 API 端点。默认情况下,服务器不提供任何路由。稍后,您将创建一个路由来处理购买交易验证流程。

起始代码中已包含的一部分是 lib/iap_repository.dart 中的 IapRepository。由于学习如何与 Firestore 或一般数据库互动与本 Codelab 无关,因此起始代码包含用于在 Firestore 中创建或更新购买交易的函数,以及这些购买交易的所有类。

设置 Firebase 访问权限

如需访问 Firebase Firestore,您需要服务账号访问密钥。如需生成私钥,请打开 Firebase 项目设置,前往服务账号部分,然后选择生成新的私钥

27590fc77ae94ad4.png

将下载的 JSON 文件复制到 assets/ 文件夹,并将其重命名为 service-account-firebase.json

设置 Google Play 访问权限

如需访问 Play 商店以验证购买交易,您必须生成具有这些权限的服务账号,并下载该服务账号的 JSON 凭据。

  1. 访问 Google Cloud 控制台中的 Google Play Android Developer API 页面629f0bd8e6b50be8.png 如果 Google Play 管理中心要求您创建项目或关联到现有项目,请先完成此操作,然后再返回此页面。
  2. 接下来,前往“服务账号”页面,然后点击 + 创建服务账号8dc97e3b1262328a.png
  3. 输入服务账号名称,然后点击创建并继续4fe8106af85ce75f.png
  4. 选择 Pub/Sub Subscriber 角色,然后点击 Done(完成)。a5b6fa6ea8ee22d.png
  5. 创建账号后,前往管理密钥eb36da2c1ad6dd06.png
  6. 依次选择添加密钥 > 创建新密钥e92db9557a28a479.png
  7. 创建并下载 JSON 密钥。711d04f2f4176333.png
  8. 将下载的文件重命名为 service-account-google-play.json,,并将其移至 assets/ 目录。
  9. 接下来,前往 Play 管理中心中的用户和权限页面28fffbfc35b45f97.png
  10. 点击邀请新用户,然后输入之前创建的服务账号的电子邮件地址。您可以在“服务账号”页面的表格中找到该电子邮件地址e3310cc077f397d.png
  11. 为应用授予查看财务数据管理订单和订阅权限。a3b8cf2b660d1900.png
  12. 点击邀请用户

我们还需要做一件事,那就是打开 lib/constants.dart,,并将 androidPackageId 的值替换为您为 Android 应用选择的软件包 ID。

设置 Apple App Store 访问权限

如需访问 App Store 以验证购买交易,您必须设置共享密钥:

  1. 打开 App Store Connect
  2. 前往我的应用,然后选择您的应用。
  3. 在边栏导航中,依次前往常规 > 应用信息
  4. 点击应用专用共享密钥标题下的管理ad419782c5fbacb2.png
  5. 生成新的密钥,然后复制该密钥。b5b72a357459b0e5.png
  6. 打开 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 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import '../logic/dash_purchases.dart';
import '../logic/firebase_notifier.dart';                  // Add this import
import '../model/firebase_state.dart';                     // And this import
import '../model/purchasable_product.dart';
import '../model/store_state.dart';
import '../repo/iap_repo.dart';
import 'login_page.dart';                                  // And this one as well

class PurchasePage extends StatelessWidget {
  const PurchasePage({super.key});

  @override
  Widget build(BuildContext context) {                     // Update from here
    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();
    }                                                      // To here.

    // ...

从应用调用验证端点

在应用中,创建一个 _verifyPurchase(PurchaseDetails purchaseDetails) 函数,该函数使用 http post 调用来调用 Dart 后端上的 /verifypurchase 端点。

发送所选商店(google_play 表示 Play 商店,app_store 表示 App Store)、serverVerificationDataproductID。服务器会返回状态代码,指明购买交易是否已验证。

在应用常量中,将服务器 IP 配置为本地机器 IP 地址。

lib/logic/dash_purchases.dart

import 'dart:async';
import 'dart:convert';                                     // Add this import

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;                   // And this import
import 'package:in_app_purchase/in_app_purchase.dart';

import '../constants.dart';
import '../main.dart';
import '../model/purchasable_product.dart';
import '../model/store_state.dart';
import 'dash_counter.dart';
import 'firebase_notifier.dart';                           // And this one

class DashPurchases extends ChangeNotifier {
  DashCounter counter;
  FirebaseNotifier firebaseNotifier;                       // Add this line
  StoreState storeState = StoreState.loading;
  late StreamSubscription<List<PurchaseDetails>> _subscription;
  List<PurchasableProduct> products = [];

  bool get beautifiedDash => _beautifiedDashUpgrade;
  bool _beautifiedDashUpgrade = false;

  final iapConnection = IAPConnection.instance;

  DashPurchases(this.counter, this.firebaseNotifier) {     // Update this line
    final purchaseUpdated = iapConnection.purchaseStream;
    _subscription = purchaseUpdated.listen(
      _onPurchaseUpdate,
      onDone: _updateStreamOnDone,
      onError: _updateStreamOnError,
    );
    loadPurchases();
  }

main.dart: 中添加了 firebaseNotifier,并创建了 DashPurchases

lib/main.dart

        ChangeNotifierProvider<DashPurchases>(
          create: (context) => DashPurchases(
            context.read<DashCounter>(),
            context.read<FirebaseNotifier>(),
          ),
          lazy: false,
        ),

在 FirebaseNotifier 中为 User 添加一个 getter,以便您可以将用户 ID 传递给验证购买交易的函数。

lib/logic/firebase_notifier.dart

  Future<FirebaseFirestore> get firestore async {
    var isInitialized = await _isInitialized.future;
    if (!isInitialized) {
      throw Exception('Firebase is not initialized');
    }
    return FirebaseFirestore.instance;
  }

  User? get user => FirebaseAuth.instance.currentUser;     // Add this line

  Future<void> load() async {
    // ...

将函数 _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(2000);
          case storeKeyUpgrade:
            _beautifiedDashUpgrade = true;
        }
      }
    }

    if (purchaseDetails.pendingCompletePurchase) {
      await iapConnection.completePurchase(purchaseDetails);
    }
  }

在应用中,一切都已就绪,可以验证购买交易了。

设置后端服务

接下来,设置用于在后端验证购买交易的后端。

构建购买处理程序

由于两个商店的验证流程几乎完全相同,因此请设置一个抽象 PurchaseHandler 类,并为每个商店分别实现。

be50c207c5a2a519.png

首先,向 lib/ 文件夹添加一个 purchase_handler.dart 文件,您可以在其中定义一个抽象 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 中。您可以使用提供的 AndroidPublisherApi 与 Google Play 进行通信。

接下来,对应用商店处理脚本执行相同的操作。创建 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';

import 'package:firebase_backend_dart/helpers.dart';
import 'package:firebase_backend_dart/products.dart';
import 'package:shelf/shelf.dart';
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');
  }
}

代码正在执行以下操作:

  1. 定义一个 POST 端点,该端点将从您之前创建的应用中调用。
  2. 对 JSON 载荷进行解码,并提取以下信息:
    1. userId:已登录的用户 ID
    2. source:所用商店,可以是 app_storegoogle_play
    3. productData:从您之前创建的 productDataMap 中获取。
    4. token:包含要发送给商店的验证数据。
  3. verifyPurchase 方法的调用,针对 GooglePlayPurchaseHandlerAppStorePurchaseHandler,具体取决于来源。
  4. 如果验证成功,该方法会向客户端返回 Response.ok
  5. 如果验证失败,该方法会向客户端返回 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 已经提供了用于与您需要验证购买交易的 API 进行互动的 Dart 软件包。您已在 server.dart 文件中初始化了这些变量,现在可以在 GooglePlayPurchaseHandler 类中使用它们。

实现非订阅型购买的处理程序:

lib/google_play_purchase_handler.dart

  /// Handle non-subscription purchases (one time purchases).
  ///
  /// Retrieves the purchase status from Google Play and updates
  /// the Firestore Database accordingly.
  @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 don't 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 don't 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 {

    // See next step
  }

现在,实现 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 通过所谓的 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/$googleCloudProjectId/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/$googleCloudProjectId/subscriptions/$googlePlayPubsubBillingTopic-sub';
    await pubsubApi.projects.subscriptions.acknowledge(
      request,
      subscriptionName,
    );
  }

您刚刚添加的代码每隔 10 秒与 Google Cloud 中的 Pub/Sub 主题通信一次,并请求新消息。然后,在 _processMessage 方法中处理每条消息。

此方法会对传入的消息进行解码,并获取有关每项购买交易(订阅和非订阅)的更新信息,必要时会调用现有的 handleSubscriptionhandleNonSubscription

每条消息都需要使用 _askMessage 方法进行确认。

接下来,将必需的依赖项添加到 server.dart 文件中。将 PubsubApi.cloudPlatformScope 添加到凭据配置中:

bin/server.dart

import 'package:googleapis/pubsub/v1.dart' as pubsub;      // Add this import

  final clientGooglePlay = await auth
      .clientViaServiceAccount(clientCredentialsGooglePlay, [
        ap.AndroidPublisherApi.androidpublisherScope,
        pubsub.PubsubApi.cloudPlatformScope,               // Add this line
      ]);

然后,创建 PubsubApi 实例:

bin/server.dart

  final pubsubApi = pubsub.PubsubApi(clientGooglePlay);

最后,将其传递给 GooglePlayPurchaseHandler 构造函数:

bin/server.dart

  return {
    'google_play': GooglePlayPurchaseHandler(
      androidPublisher,
      iapRepository,
      pubsubApi,                                           // Add this line
    ),
    'app_store': AppStorePurchaseHandler(
      iapRepository,
    ),
  };

Google Play 设置

您已编写代码来使用 pub/sub 主题中的结算事件,但尚未创建 pub/sub 主题,也没有发布任何结算事件。是时候进行设置了。

首先,创建一个 Pub/Sub 主题:

  1. constants.dartgoogleCloudProjectId 的值设置为您的 Google Cloud 项目 ID。
  2. 访问 Google Cloud 控制台中的 Cloud Pub/Sub 页面
  3. 确保您位于 Firebase 项目中,然后点击 + 创建主题d5ebf6897a0a8bf5.png
  4. 为新主题命名,名称与 constants.dart 中为 googlePlayPubsubBillingTopic 设置的值相同。在本例中,将其命名为 play_billing。如果您选择其他内容,请务必更新 constants.dart。创建主题。20d690fc543c4212.png
  5. 在 Pub/Sub 主题列表中,点击刚刚创建的主题对应的三个垂直点,然后点击查看权限ea03308190609fb.png
  6. 在右侧边栏中,选择添加主账号
  7. 在此处添加 google-play-developer-notifications@system.gserviceaccount.com,然后授予其 Pub/Sub 发布商的角色。55631ec0549215bc.png
  8. 保存权限更改。
  9. 复制您刚刚创建的主题的主题名称
  10. 再次打开 Play 管理中心,然后从所有应用列表中选择您的应用。
  11. 向下滚动,然后依次前往创收 > 创收设置
  12. 填写完整的主题,然后保存更改。7e5e875dc6ce5d54.png

现在,所有 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;                 // Add this member

  AppStorePurchaseHandler(
    this.iapRepository,
    this.appStoreServerAPI,                                  // And this parameter
  );

修改构造函数以添加将调用 _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

  /// Request the App Store for the latest subscription status.
  /// Updates all App Store subscriptions in the database.
  /// NOTE: This code only handles when a subscription expires as example.
  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,
            ),
          );
        }
      }
    }
  }

此方法的工作原理如下:

  1. 使用 IapRepository 从 Firestore 获取有效订阅的列表。
  2. 对于每个订单,它都会向 App Store Server API 请求订阅状态。
  3. 获取相应订阅购买交易的最后一笔交易。
  4. 检查失效日期。
  5. 更新 Firestore 上的订阅状态,如果订阅已过期,则会标记为已过期。

最后,添加所有必要的代码来配置 App Store Server API 访问权限:

bin/server.dart

import 'package:app_store_server_sdk/app_store_server_sdk.dart';  // Add this import
import 'package:firebase_backend_dart/constants.dart';            // And this one.


  // 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,                                     // Add this argument
    ),
  };

App Store 设置

接下来,设置 App Store:

  1. 登录 App Store Connect,然后选择用户和访问权限
  2. 依次前往集成 > 密钥 > 应用内购
  3. 点按“加号”图标以添加新的卡券。
  4. 为其命名,例如“Codelab 密钥”。
  5. 下载包含密钥的 p8 文件。
  6. 将其复制到素材资源文件夹,并命名为 SubscriptionKey.p8
  7. 复制新创建的密钥的密钥 ID,并将其设置为 lib/constants.dart 文件中的 appStoreKeyId 常量。
  8. 复制密钥列表顶部的发布者 ID,并将其设置为 lib/constants.dart 文件中的 appStoreIssuerId 常量。

9540ea9ada3da151.png

跟踪设备上的购买交易

跟踪购买交易的最安全方式是在服务器端进行跟踪,因为客户端很难保证安全,但您需要通过某种方式将信息返回给客户端,以便应用可以根据订阅状态信息采取行动。通过在 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((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() 方法中移除监听器。起初,监听器可以只是一个空函数。由于 IAPRepoChangeNotifier,并且您会在每次 Firestore 中的购买交易发生变化时调用 notifyListeners(),因此当购买的产品发生变化时,系统始终会调用 purchasesUpdate() 方法。

lib/logic/dash_purchases.dart

import '../repo/iap_repo.dart';                              // Add this import

class DashPurchases extends ChangeNotifier {
  DashCounter counter;
  FirebaseNotifier firebaseNotifier;
  StoreState storeState = StoreState.loading;
  late StreamSubscription<List<PurchaseDetails>> _subscription;
  List<PurchasableProduct> products = [];
  IAPRepo iapRepo;                                           // Add this line

  bool get beautifiedDash => _beautifiedDashUpgrade;
  bool _beautifiedDashUpgrade = false;
  final iapConnection = IAPConnection.instance;

  // Add this.iapRepo as a parameter
  DashPurchases(this.counter, this.firebaseNotifier, this.iapRepo) {
    final purchaseUpdated = iapConnection.purchaseStream;
    _subscription = purchaseUpdated.listen(
      _onPurchaseUpdate,
      onDone: _updateStreamOnDone,
      onError: _updateStreamOnError,
    );
    iapRepo.addListener(purchasesUpdate);
    loadPurchases();
  }

  Future<void> loadPurchases() async {
    // Elided.
  }

  @override
  void dispose() {
    _subscription.cancel();
    iapRepo.removeListener(purchasesUpdate);                 // Add this line
    super.dispose();
  }

  void purchasesUpdate() {
    //TODO manage updates
  }

接下来,在 main.dart. 中向构造函数提供 IAPRepo。您可以使用 context.read 获取代码库,因为它已在 Provider 中创建。

lib/main.dart

        ChangeNotifierProvider<DashPurchases>(
          create: (context) => DashPurchases(
            context.read<DashCounter>(),
            context.read<FirebaseNotifier>(),
            context.read<IAPRepo>(),                         // Add this line
          ),
          lazy: false,
        ),

接下来,编写 purchaseUpdate() 函数的代码。在 dash_counter.dart, 中,applyPaidMultiplierremovePaidMultiplier 方法分别将乘数设置为 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。您可以在 android_studio_folder.png complete 文件夹中找到此 Codelab 的完整代码。

如需了解详情,请尝试学习其他 Flutter Codelab