12k
All articles

Как добавить встроенные покупки в приложение на Flutter

Добавьте встроенные покупки в приложение Flutter с плагином in_app_purchase. Настройте магазины Apple и Google, проверяйте транзакции, восстанавливайте покупки и тестируйте безопасно.

OpenReplay Team
OpenReplay Team
Как добавить встроенные покупки в приложение на Flutter

Если приложение на Flutter продаёт цифровые товары или подписки на iOS или Android, оплату, как правило, нужно принимать через систему встроенных покупок Apple или Google Play Billing. Stripe и собственные формы оплаты для этого не допускаются, а оба магазина взимают комиссию с каждой продажи.

Если ваше приложение уже принимает платежи через Stripe в вебе, может возникнуть желание переиспользовать эту форму оплаты в мобильной сборке. Для цифровых товаров такой план обычно заканчивается отказом на App Review.

Стандартная комиссия Apple составляет 30%. По программе App Store Small Business Program разработчики, чей доход за предыдущий год не превысил 1 млн долларов, платят вместо этого 15% как с платных приложений, так и со встроенных покупок. У Google нет единой ставки, а на странице о сервисных сборах указано, что 99% разработчиков, уплачивающих сбор, могут рассчитывать на ставку 15% или ниже. Stripe по-прежнему остаётся правильным инструментом для физических товаров и услуг, потребляемых вне приложения. Правила изложены в разделе App Store Review Guidelines о встроенных покупках и в политике Google Play в отношении платежей. В ЕС Закон о цифровых рынках (Digital Markets Act, DMA) обязывает магазины допускать альтернативные способы оплаты; механизмы, доступные в ЕС, Apple описывает на своей странице для разработчиков о DMA. Для витрины App Store в США правила Apple теперь также разрешают размещать в приложениях кнопки и ссылки на внешние способы покупки. Правила в отношении ссылок на внешние платежи различаются в зависимости от региона и постоянно меняются, поэтому прежде чем проектировать сценарий оплаты, ознакомьтесь с актуальными правилами обоих магазинов.

В этой статье от начала до конца разбирается официальный плагин in_app_purchase: типы продуктов, настройка магазинов, один рабочий сервисный класс, типичные ошибки, из-за которых теряются покупки, и тестирование. Если вы только начинаете работать с Flutter, начните с руководства Flutter для новых разработчиков.

Ключевые выводы

  • Цифровые товары, продаваемые внутри приложений для iOS и Android, как правило, должны оплачиваться через платёжные системы Apple и Google. Stripe подходит для физических товаров и услуг, потребляемых вне приложения.
  • buyConsumable и buyNonConsumable возвращают Future<bool>, который сообщает лишь о том, что запрос был запущен. Фактический результат приходит в виде PurchaseDetails в purchaseStream.
  • Если для покупки, у которой pendingCompletePurchase равно true, не вызвать completePurchase, Google Play вернёт деньги через три дня, а App Store оставит транзакцию незавершённой.
  • Правила проверки Apple требуют наличия механизма восстановления для восстанавливаемых покупок.
  • Открывайте доступ к контенту только после того, как ваш бэкенд проверит serverVerificationData через App Store Server API или Google Play Developer API.

Расходуемые и нерасходуемые продукты и подписки

Плагин in_app_purchase для Flutter поддерживает три типа продуктов: расходуемые (consumables), нерасходуемые (non-consumables) и подписки. Для расходуемых используется buyConsumable. Для нерасходуемых и подписок — buyNonConsumable. Тип продукта также определяет, что именно должно запоминать ваше приложение.

ТипМетод покупкиПримерЧто должно отслеживать приложениеВосстанавливается?
РасходуемыйbuyConsumable100 монетБаланс, хранящийся на вашем бэкендеНет
НерасходуемыйbuyNonConsumableРазблокировка PremiumФлаг права доступаДа
ПодпискаbuyNonConsumableMonthly ProДата окончания и статус продления по данным серверной проверкиДа

Расходуемые продукты — единственный тип, который магазин не запоминает за вас. После того как пакет монет израсходован, единственная запись о балансе хранится на вашем собственном бэкенде. Право доступа к нерасходуемому продукту всегда можно восстановить. Состояние подписки со временем меняется из-за продлений, истечения срока и возвратов, поэтому считывайте его из проверенных серверных данных, а не из закешированного локального флага.

Настройка магазинов — основная часть работы

Большая часть усилий по добавлению встроенных покупок во Flutter-приложение приходится на App Store Connect и Play Console. Код на Dart получается коротким. Пока оба магазина не настроены, queryProductDetails ничего не вернёт, каким бы правильным ни был ваш код.

Apple (App Store Connect):

  1. Подпишите соглашение Paid Apps и заполните банковские и налоговые данные.
  2. Создайте каждую встроенную покупку с идентификатором продукта, например premium_unlock. Подписки создавайте внутри группы подписок.
  3. Заполните метаданные, чтобы каждый продукт получил статус Ready to Submit.

Google (Play Console):

  1. Загрузите сборку в один из тестовых треков. Подойдёт внутренний трек.
  2. Создайте продукты. Используйте те же идентификаторы, что и в Apple, либо сопоставьте их в коде.
  3. Активируйте их. Google отмечает, что продукты должны быть опубликованы, прежде чем их можно будет протестировать.

Если queryProductDetails возвращает ваши идентификаторы в notFoundIDs, причина почти всегда кроется в настройке магазина, а не в Dart. Типичные причины: несовпадающий идентификатор, неактивный продукт, неподписанное соглашение Paid Apps или отсутствие сборки в тестовом треке Play.

Код встроенных покупок во Flutter: поток, покупка, завершение

Сценарий встроенной покупки во Flutter состоит из четырёх шагов. При запуске подпишитесь на purchaseStream, проверьте isAvailable, загрузите продукты через queryProductDetails, затем вызовите метод покупки. Все результаты обрабатывайте в слушателе потока.

Добавьте зависимость:

dependencies:
  in_app_purchase: ^3.3.1

Пакет in_app_purchase поддерживает Android SDK 24+, iOS 13+ и macOS 10.15+. Реализации для StoreKit и Play Billing — это одобренные (endorsed) федеративные пакеты, которые подключаются автоматически, поэтому добавлять их вручную нужно только в том случае, если вы используете их платформенно-специфичные классы. На платформах Apple по умолчанию используется StoreKit 2 на устройствах, которые его поддерживают.

buyConsumable и buyNonConsumable не возвращают покупку. Распространённая ошибка выглядит так:

// Wrong: the Future resolves to a bool, not a purchase
final result = await iap.buyNonConsumable(purchaseParam: param);
if (result) unlockPremium(); // true only means the sheet opened

В правильном варианте bool трактуется как «запрос запущен», а вся выдача доступа происходит в слушателе потока:

final started = await iap.buyNonConsumable(purchaseParam: param);
if (!started) showError('Could not start purchase');
// Unlocking happens in the purchaseStream handler

Ниже приведён полный сервис, подписка на который выполняется в main() до runApp. README плагина рекомендует подписываться в initState вашего приложения. Подписка в main() происходит на шаг раньше, поэтому слушатель уже существует к моменту, когда плагин может отправить какое-либо обновление.

import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:in_app_purchase/in_app_purchase.dart';

class PurchaseService {
  PurchaseService._();
  static final instance = PurchaseService._();

  static const coins100 = 'coins_100';
  static const premium = 'premium_unlock';

  final InAppPurchase _iap = InAppPurchase.instance;
  StreamSubscription<List<PurchaseDetails>>? _sub;

  void start() {
    _sub ??= _iap.purchaseStream.listen(
      _onPurchases,
      onDone: () => _sub?.cancel(),
      onError: (Object e) {/* log */},
    );
  }

  Future<List<ProductDetails>> loadProducts() async {
    if (!await _iap.isAvailable()) return [];
    final res = await _iap.queryProductDetails({coins100, premium});
    if (res.notFoundIDs.isNotEmpty) {/* store setup problem */}
    return res.productDetails;
  }

  Future<bool> buy(ProductDetails p) {
    final param = PurchaseParam(productDetails: p);
    return p.id == coins100
        ? _iap.buyConsumable(purchaseParam: param)
        : _iap.buyNonConsumable(purchaseParam: param);
  }

  Future<void> restore() => _iap.restorePurchases();

  Future<void> _onPurchases(List<PurchaseDetails> purchases) async {
    for (final p in purchases) {
      var finish = true;
      switch (p.status) {
        case PurchaseStatus.pending:
          // show "waiting for approval"
          break;
        case PurchaseStatus.purchased:
        case PurchaseStatus.restored:
          final valid = await _verifyOnServer(p); // null = unreachable
          if (valid == null) {
            finish = false; // redelivered next launch
          } else if (valid) {
            await _deliver(p);
          }
        case PurchaseStatus.error:
          // show p.error?.message
          break;
        case PurchaseStatus.canceled:
          // no error UI
          break;
      }
      if (finish && p.pendingCompletePurchase) {
        await _iap.completePurchase(p);
      }
    }
  }

  Future<bool?> _verifyOnServer(PurchaseDetails p) async {
    // POST p.verificationData.serverVerificationData,
    // p.productID and platform to your backend
    return true;
  }

  Future<void> _deliver(PurchaseDetails p) async {/* credit coins / set flag */}
}

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  PurchaseService.instance.start();
  runApp(const MyApp());
}

_verifyOnServer — это заглушка для обращения к вашему бэкенду. Она возвращает null, если сервер недоступен: в этом случае покупка остаётся незавершённой и будет доставлена повторно при следующем запуске. Это безопасно только при условии, что ситуация разрешится в течение трёхдневного окна Android.

СтатусЧто делает приложениеВызывать completePurchase?
pendingПоказывает состояние ожидания (например, при Ask to Buy или отложенном способе оплаты)Нет, дождитесь итогового статуса
purchasedПроверяет покупку на сервере, затем выдаёт доступДа, если pendingCompletePurchase
restoredПроверяет, затем повторно выдаёт право доступаДа, если pendingCompletePurchase
errorПоказывает error.messageДа, если pendingCompletePurchase
canceledБез лишнего шума возвращает пользователя на пейволлДа, если pendingCompletePurchase

Что может пойти не так со встроенными покупками

Четыре распространённые ошибки приводят к потере покупок или отказам при проверке: отсутствие кнопки восстановления, слишком поздняя подписка на поток, отсутствие вызова completePurchase и доверие клиенту.

Отсутствие кнопки «Восстановить покупки». Правило 3.1.1 Apple требует, чтобы приложение предоставляло способ восстановить любую восстанавливаемую встроенную покупку. Восстановленные покупки приходят в purchaseStream со статусом PurchaseStatus.restored, поэтому кнопке достаточно вызвать restorePurchases(). Доступ выдаст уже существующий обработчик. Разместите кнопку на пейволле и в настройках:

TextButton(
  onPressed: PurchaseService.instance.restore,
  child: const Text('Restore Purchases'),
)

Если пейволл открывается из push-уведомления или маркетинговой ссылки, настройте маршрутизацию с помощью Deep Linking in Flutter, чтобы кнопка восстановления всегда была доступна в одно касание.

Слишком поздняя подписка на поток. Покупка, завершившаяся, пока приложение было закрыто (например, одобренный запрос Ask to Buy или медленный платёж), доставляется в поток при следующем запуске приложения. Если слушатель подключается на экране, до которого пользователь так и не дошёл, это обновление останется необработанным. Пользователь не получит то, за что заплатил, а на Android покупка, которая так и не была завершена, будет возвращена через три дня.

Покупка не завершается. Если у покупки pendingCompletePurchase равно true, а completePurchase так и не вызывается, Google Play возвращает деньги через три дня, а App Store оставляет транзакцию незавершённой.

Доверие клиенту. Не открывайте доступ к контенту на основании статуса, полученного приложением. Отправляйте verificationData.serverVerificationData на бэкенд и проверяйте его там: через App Store Server API на платформах Apple или через Google Play Developer API на Android. В StoreKit 2 serverVerificationData представляет собой подписанную JWS-транзакцию. Ваш сервер должен проверить её подпись, запросить транзакцию через App Store Server API или сделать и то и другое. Запрос на проверку — обычный вызов бэкенда. Если ваш бэкенд использует GraphQL, статья Consuming GraphQL APIs with Flutter показывает, как его подключить.

Дизайн пейволла и управляемые SDK для подписок, такие как RevenueCat, — отдельные решения, которые строятся поверх этого сценария.

Как тестировать покупки без реальных денег?

И Apple, и Google предоставляют тестовые среды для встроенных покупок. Apple предлагает sandbox-аккаунты и файл конфигурации StoreKit в Xcode. Google — лицензионных тестировщиков в тестовом треке Play.

  • Sandbox-тестировщики Apple: создайте sandbox-аккаунты в App Store Connect и войдите в один из них на устройстве. Покупки в sandbox проходят через ваши реальные продукты в App Store Connect, но без списания средств.
  • Файл конфигурации StoreKit: с помощью файла конфигурации StoreKit в Xcode можно определить продукты локально и тестировать покупки в iOS Simulator ещё до завершения настройки App Store Connect.
  • Лицензионные тестировщики Play: в Play Console откройте Settings > License testing и добавьте аккаунты Google. Тестовые покупки, совершённые лицензионными тестировщиками, не оплачиваются с их аккаунтов. В руководстве Google по тестированию биллинга предупреждается, что тестировщики тестового трека, не являющиеся лицензионными тестировщиками, платят реальные деньги.

Перед отправкой на проверку протестируйте на обеих платформах чистую установку и восстановление покупок.

В релиз

Чтобы сценарий встроенных покупок во Flutter работал корректно, настройте оба магазина, подпишитесь на purchaseStream раньше всего остального, проверяйте каждую покупку на сервере и завершайте каждую покупку, у которой установлен pendingCompletePurchase. Далее создайте по одному продукту в каждой консоли, протестируйте его с sandbox-аккаунтом и лицензионным тестировщиком и убедитесь, что он появляется в productDetails, а не в notFoundIDs.

Часто задаваемые вопросы

Как повысить или понизить уровень подписки на Android с помощью in_app_purchase?

Вызовите buyNonConsumable с GooglePlayPurchaseParam, содержащим ChangeSubscriptionParam. Передайте в него прежний объект GooglePlayPurchaseDetails пользователя и, при желании, ReplacementMode, например ReplacementMode.withTimeProration. Эти классы находятся в пакете in_app_purchase_android, поэтому импортируйте его, когда они понадобятся. На iOS этот шаг можно пропустить: подписки из одной группы подписок и так заменяют друг друга, поэтому переключение берёт на себя App Store.

Работает ли плагин in_app_purchase во Flutter Web?

Нет. Плагину нужен магазин, а поддерживает он только App Store (на iOS и macOS) и Google Play (на Android). У веб-сборки Flutter магазина нет, поэтому для продаж в вебе нужен платёжный сервис для веба, например Stripe. Ограничение на использование Stripe касается цифровых товаров, продаваемых внутри приложений для iOS и Android, а не покупок, совершаемых в вебе.

Чем in_app_purchase отличается от purchases_flutter от RevenueCat?

in_app_purchase отвечает только за взаимодействие с магазином. Серверная проверка, хранение прав доступа и отслеживание состояния подписок остаются на вашем собственном бэкенде. purchases_flutter от RevenueCat объединяет StoreKit и Google Play Billing с облачным бэкендом RevenueCat, который добавляет серверную проверку чеков, отслеживание статуса подписок и вебхуки. Оба пакета всё равно работают через биллинг Apple и Google, поэтому магазины в любом случае берут одинаковую комиссию.

Что происходит с текущими подписчиками, если повысить цену подписки?

Это зависит от магазина, региона и размера повышения. Некоторые повышения требуют согласия подписчика, и если он не соглашается, подписка перестаёт продлеваться. Оба магазина также допускают некоторые повышения лишь с уведомлением. На iOS StoreKit по умолчанию показывает внутри приложения окно согласия с новой ценой, а в README in_app_purchase описано, как отложить его показ до более подходящего момента. На Android Google Play сам уведомляет подписчиков об изменении по электронной почте и через уведомления, поэтому код на Dart для этого не нужен.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.