React Native in-app purchases: конфигурация и получение списка продуктов

Всем привет, я Ваня — фулстак разработчик в Adapty. Сегодня расскажу про добавление in-app покупок в приложения на React Native.

Кросс-платформенные фреймворки для разработки приложений сильно облегчают жизнь разработчикам, позволяя писать сразу для нескольких платформ. Однако есть свои нюансы. Например, в React Native не существует базового инструмента для внедрения внутренних покупок. Поэтому неминуемо приходится пользоваться сторонними библиотеками.

Самые популярные библиотеки для добавления покупок в приложения на React Native — это react-native-iap, expo-in-app-purchases. А расскажу я про react-native-adapty, потому что по сравнению с другими библиотеками, у неё есть преимущества:

  • в отличие от других библиотек, есть серверная валидация покупок;

  • библиотека поддерживает все текущие фичи магазинов от промо-офферов до оплаты наперёд, а поддержка новых функций появляется в ней быстро;

  • код получается чище и понятнее;

  • можно менять предлагаемые продукты, добавлять и убирать предложения без прохождения полного релиз-цикла: не надо выпускать беты и ждать одобрения.

Кроме того, Adapty SDK даёт много других бонусов: встроенная аналитика по главным метрикам, когортный анализ, серверная валидация покупок, A/B тестирование пейволлов, промо-кампании по гибким сегментам, интеграции с системами аналитики.

Но пока поговорим о настройке in-app purchases на React Native. Вот что нам сегодня предстоит:

  1. Почему Expo не подойдёт для внедрения in-app purchases в приложениях на React Native.

  2. Создание аккаунта разработчика — ссылки на инструкции.

  3. Настройка Adapty:
    конфигурация App Store
    конфигурация Play Store

  4. Добавление подписок.

  5. Создание пейволла.

  6. Установка react-native-adapty.

  7. Пример приложения и результат.

В статье мы попробуем написать приложение, которое будет показывать картинки котов подписанным юзерам, а остальным — предложение подписаться.

Почему Expo не подойдёт для внедрения in-app purchases в приложениях на React Native

> TL;DR — Expo managed не поддерживает нативные методы магазинов для работы с покупками (сторкитов). Нужен либо совсем чистый RN, либо Expo bare workflow.

Тех, кто сразу подумал о том, чтобы использовать Expo, придётся разочаровать — он для этих целей не годится. Expo — это фреймворк для React Native, с которым разработка многократно упрощается. Однако для покупок/подписок их полноценный workflow не подходит. Expo не использует нативный код в своих методах и компонентах (только js), что требуется для сторкитов. Сделать покупки через js в мобильных магазинах не выйдет, так что надо эджектиться.

Создание аккаунта разработчика

Для начала нам предстоит настроить аккаунты в магазинах, создать и подготовить покупки/подписки и для iOS, и для Android. Этот процесс вряд ли займет больше 20 минут.

Если вы еще не настроили аккаунты разработчика и продукты в App Store Connect и/или Google Play Console, то можете познакомиться с инструкциями по ссылкам:

  • Для iOS — от начала статьи и вплоть до «Получение списка SKProduct» — там уже начинается нативная реализация

  • Для Android — аналогично от начала статьи до «Получение списка продуктов в приложении».

Настройка Adapty

В случае react-native-adapty для начала надо настроить дашборд Adapty. Это занимает немного времени, зато по сравнению с хардкодом, в результате вы получите все описанные выше преимущества.

На третьем шаге после регистрации нам предлагают сконфигурироваться для App Store и Google Play:

Для работы с iOS нам нужно:

  • указать Bundle ID;

  • установить App Store Server Notifications;

  • App Store Connect shared secret.

Это обязательные поля для того, чтобы покупки работали.

К каждому полю есть Read how — там очень понятные и актуальные пошаговые туториалы, лучше посмотрите туда.

Bundle ID — это уникальный ID вашего приложения. Здесь он должен совпадать с тем, что вы написали в Xcode в Targets > [App Name] > General:

Для Android обязательны Package Name и Service Account Key File.Точно так же есть актуальные Read how. Package name — аналог Bundle ID с iOS. Нужно указать такой же, как и в коде. Найти его можно в файле /android/app/build.gradle в разделе android.defaultConfig.applicationId:

На четвёртом шаге нам предлагают добавить Adapty SDK в наше приложение. Однако мы этот шаг пропустим и настроимся чуть позже.

После того, как зарегистрировались, идем в настройки и запоминаем, что здесь лежит наш ключ (Public SDK key) — он нам понадобится чуть позднее:

Добавление подписки

Для разных подписок в Adapty существуют продукты. У вас может быть подписка только на фото котов, но на месяц, полгода, год. Каждый этот вариант будет соответствовать одному продукту в Adapty.

Давайте укажем в дашборде, что у нас есть один продукт. Идем в раздел Products & A/B Tests → Products, кликаем Create product.

Здесь надо указать название продукта — то, как конкретная подписка будет выглядеть в интерфейсе Adapty.

Также надо указать App Store Product ID и Play Store Product ID. Период и название можно выставить для работы с аналитикой. Сохраняемся.

Создание пейволла

Теперь надо создать пейволл — экран, который ограничивает доступ к платным функциям и предлагает купить подписку. В пейволл надо будет добавить созданный продукт. Для этого в этом же разделе Products & A/B Tests → Paywalls кликаем Create paywall.

  • В Paywall name вносим название пейволла. Важно делать его таким, чтобы потом и вы, и ваши коллеги могли легко по названию разобраться, что это за пейволл.

  • В Paywall ID я напишу cats_paywall — по этому ID мы будем искать пейволл в приложении.

  • В выпадающем меню Product добавляем нашу подписку.

Сохраняемся.

Вот и вся настройка, дальше будем устанавливать зависимости и кодить.

Установка react-native-adapty

1. Сначала подключаем зависимость:

yarn add react-native-adapty

2. Устанавливаем поды для iOS. Если у вас нет CLI pod, то рекомендую скачать. Она еще не раз вам пригодится в iOS-разработке.

# поды устанавливаются в нативный iOS проект, по умолчанию это папка /ios
pod install --project-directory=ios

3. Так как на iOS React Native проекты написаны на Obj-C, нам надо создать Swift Bridging Header, чтобы Obj-C мог читать Swift библиотеки. Для этого просто идем в Xcode проект, создаем новый любой Swift файл. Xcode нам предложит создать bridging header — то, что нам и нужно. Соглашаемся.

4. Для Android надо убедиться, что в проекте (/android/build.gradle по дефолту) стоит kotlin-gradle-plugin версии 1.4.0 или выше:

... buildscript { 
  ... dependencies { 
    ... classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.0" 
  } 
} ...

5. Для Android надо включить multiDex, на этот раз в конфигурации приложения (/android/app/build.gradle по дефолту):

... 
android { 
  ... defaultConfig { 
    ... multiDexEnabled true 
  } 
}

Все настроено! Теперь можно отдыхать начинать кодить! 🎉

Кодим: Получение списка продуктов в приложении

Под капотом react-native-adapty происходит много полезных штук, которые обязательно пригодятся, если не сейчас, то несколько позже. Поэтому библиотеку следует инициализировать в самом начале флоу. Идем как можно выше в приложении (можно прямо в App.tsx) и инициализируемся:

// импортируем метод
import { activateAdapty } from 'react-native-adapty';

// У меня в корне был такой компонент App
const App: React.FC = () => {
  ...
  // вызываем его один раз в корневом компоненте на маунте
  useEffect(() => {
    activateAdapty({ sdkKey: 'MY_PUBLIC_KEY' });
  },[]);
  ...
}

Соответственно, вместо MY_PUBLIC_KEY пишем тот самый Public SDK ключ, который мы получили при настройке дашборда. На самом деле, метод activateAdapty() можно вызывать и больше одного раза, и больше, чем в одном месте — ничего страшного не будет, однако мы ограничимся такой версией.

Теперь можем получать те продукты, которые мы добавили в дашборде Adapty:

import { adapty } from 'react-native-adapty';

async function getProducts() {
	const {paywalls, products} = await adapty.paywalls.getPaywalls();

	return products;
}

Теперь к практике. Давайте попробуем написать небольшое приложение, где мы сможем смотреть продукты из наших пейволлов и делать покупки.

Пример приложения

Здесь я позволю себе писать достаточно плотно, чтобы не усложнять базовую логику. Также писать буду на typescript, чтобы показать, где какие типы фигурируют. Проверять весь код буду на своем стареньком iPhone 8 — важно понимать, что, начиная с iOS 14, App Store запрещает использовать сторкиты на симуляторах — только реальные девайсы.

1. Итак, сначала напишем корневой компонент App.tsx, в котором будет кнопка, которая показывает пейволл. Я уже настроил навигацию через react-native-navigation. Как по мне, он намного круче, чем рекомендуемый официальной документацией react-navigation.

Корневой компонент App.tsx
import React, { useEffect, useState } from "react";
import { Button, StyleSheet, View } from "react-native";
import { adapty, activateAdapty, AdaptyPaywall } from "react-native-adapty";

export const App: React.FC = () => {
  const [paywalls, setPaywalls] = useState<AdaptyPaywall[]>([]);

  useEffect(() => {
    async function fetchPaywalls(): Promise<void> {
      await activateAdapty({ sdkKey: "MY_PUBLIC_KEY" });

      const result = await adapty.paywalls.getPaywalls();
      setPaywalls(result.paywalls);
    }

    fetchPaywalls();
  }, []);

  return (
    <View style={styles.container}>
      <Button
        title="Показать пейволл"
        onPress={() => {
          const paywall = paywalls.find(
            (paywall) => paywall.developerId === "cats_paywall"
          );

          if (!paywall) {
            return alert("Нет такого пейволла!");
          }
					// Переход на пейволл...
        }}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1, alignItems: "center", justifyContent: "center" },
});

Разберемся, что происходит. При маунте компонента вызывается наша функция fetchPaywalls(), которая активирует SDK и сохраняет пейволлы в стейт, чтобы при таче по кнопке нам не пришлось бы ждать фетча. На вью у нас только одна кнопка, которая должна переводить к пейволлу, который мы подготовили раньше в дашборде.

На самом деле, мы могли не сохранять пейволлы в стейт, а фетчить их прямо тут — adapty.paywalls.getPaywalls() по умолчанию возьмет их из кэша (а закэшит при открытии приложения), то есть не надо будет ждать, пока метод сходит на сервер.

Вот, что вышло:

2. Теперь напишем компонент пейволла в том же файле:

Компонент пейволла:
// тут добавилось импортов
import React, { useEffect, useState } from "react";
import {
  Button,
  SafeAreaView,
  StyleSheet,
  Text,
  View,
  PlatformColor,
} from "react-native";
import {
  adapty,
  activateAdapty,
  AdaptyPaywall,
  AdaptyProduct,
} from "react-native-adapty";
import { Navigation } from "react-native-navigation";

// ...

interface PaywallProps {
  paywall: AdaptyPaywall;
  onRequestBuy: (product: AdaptyProduct) => void | Promise<void>;
}
export const Paywall: React.FC<PaywallProps> = ({ paywall, onRequestBuy }) => {
  const [isLoading, setIsLoading] = useState<boolean>(false);

  return (
    <SafeAreaView style={styles.container}>
      {paywall.products.map((product) => (
        <View key={product.vendorProductId}>
          <Text>{product.localizedTitle}</Text>
          <Button
            title={`Купить за ${product.localizedPrice}`}
            disabled={isLoading}
            onPress={async () => {
              try {
                setIsLoading(true);
                await onRequestBuy(product);
              } catch (error) {
                alert("Произошла ошибка :(");
              } finally {
                setIsLoading(false);
              }
            }}
          />
        </View>
      ))}
    </SafeAreaView>
  );
};

// Тоже новый ключ
const styles = StyleSheet.create({
  container: { flex: 1, alignItems: "center", justifyContent: "center" },
  paywallContainer: {
    flex: 1,
    alignItems: "center",
    justifyContent: "space-evenly",
    backgroundColor: PlatformColor("secondarySystemBackground"),
  },
});

Тут мы просто мапим продукты из пейволла и показываем к каждому продукту кнопку покупки.

3. Чтобы посмотреть, как это выглядит, давайте зарегистрируем этот экран в react-native-navigation. Если у вас другой навигатор, можете пропускать этот шаг. Мой корневой index.js выглядит так:

Корневой index.js
import "react-native-gesture-handler";
import { Navigation } from "react-native-navigation";

import { App, Paywall } from "./App";

Navigation.registerComponent("Home", () => App);
Navigation.registerComponent("Paywall", () => Paywall);

Navigation.events().registerAppLaunchedListener(() => {
  Navigation.setRoot({
    root: { stack: { children: [{ component: { name: "Home" } }] } },
  });
});

4. Теперь осталось только добавить действие на тап кнопки «Показать пейволл»; в моем случае, он будет открывать модальное окно через Navigation:

Navigation.showModal<PaywallProps>({
    component: {
      name: "Paywall",
      passProps: {
        paywall,
        onRequestBuy: async (product) => {
          const purchase = await adapty.purchases.makePurchase(product);
          // Делаем все, что нам нужно...
          console.log("purchase", purchase);
        },
      },
    },
  });
Весь файл App.tsx
import React, { useEffect, useState } from "react";
import {
  Button,
  SafeAreaView,
  StyleSheet,
  Text,
  View,
  PlatformColor,
} from "react-native";
import {
  adapty,
  activateAdapty,
  AdaptyPaywall,
  AdaptyProduct,
} from "react-native-adapty";
import { Navigation } from "react-native-navigation";
export const App: React.FC = () => {
  const [paywalls, setPaywalls] = useState<AdaptyPaywall[]>([]);
  useEffect(() => {
    async function fetchPaywalls(): Promise<void> {
      await activateAdapty({
        sdkKey: "MY_PUBLIC_KEY",
      });

      const result = await adapty.paywalls.getPaywalls();
      setPaywalls(result.paywalls);
    }

    fetchPaywalls();
  }, []);

  return (
    <View style={styles.container}>
      <Button
        title="Показать пейволл"
        onPress={() => {
          const paywall = paywalls.find(
            (paywall) => paywall.developerId === "cats_paywall"
          );

          if (!paywall) {
            return alert("Нет такого пейволла!");
          }

          Navigation.showModal<PaywallProps>({
            component: {
              name: "Paywall",
              passProps: {
                paywall,
                onRequestBuy: async (product) => {
                  const purchase = await adapty.purchases.makePurchase(product);
                  // Делаем все, что нам нужно
                  console.log("purchase", purchase);
                },
              },
            },
          });
        }}
      />
    </View>
  );
};

interface PaywallProps {
  paywall: AdaptyPaywall;
  onRequestBuy: (product: AdaptyProduct) => void | Promise<void>;
}
export const Paywall: React.FC<PaywallProps> = ({ paywall, onRequestBuy }) => {
  const [isLoading, setIsLoading] = useState<boolean>(false);

  return (
    <SafeAreaView style={styles.paywallContainer}>
      {paywall.products.map((product) => (
        <View key={product.vendorProductId}>
          <Text>{product.localizedTitle}</Text>
          <Button
            title={`Купить за ${product.localizedPrice}`}
            disabled={isLoading}
            onPress={async () => {
              try {
                setIsLoading(true);
                await onRequestBuy(product);
              } catch (error) {
                alert("Произошла ошибка :(");
              } finally {
                setIsLoading(false);
              }
            }}
          />
        </View>
      ))}
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1, alignItems: "center", justifyContent: "center" },
  paywallContainer: {
    flex: 1,
    alignItems: "center",
    justifyContent: "space-evenly",
    backgroundColor: PlatformColor("secondarySystemBackground"),
  },
});

Вот и все! Теперь мы можем показывать пейволлы юзерам.

Если вы хотите протестировать покупки на iOS сэндбоксе, то вам нужно будет завести сэндбокс-тестер аккаунт. Тут стоит помнить, что сэндбокс-подписки инвалидируются быстрее, чтобы было удобнее тестировать. На андроиде дополнительные аккаунты не нужны, и даже можно тестить на симуляторе; подписки также ускорены.

Проверка юзера на наличие активных подписок

Теперь остается вопрос, где хранить информацию об активных подписках, чтобы давать конечному пользователю его премиум контент. Здесь Adapty нам тоже поможет — он сохраняет все покупки, связанные с пользователем. Давайте сделаем так: если у юзера нет подписки, то мы также показываем кнопку пейволла, но если подписка есть, то мы показываем кота.

Информация об активной подписке берется из кэша или сервера, так что нужен будет лодер. Для простоты давайте добавим стейты isLoading и isPremium:

// ...
export const App: React.FC = () => {
	const [isLoading, setIsLoading] = useState<boolean>(true);
	const [isPremium, setIsPremium] = useState<boolean>(false);
  const [paywalls, setPaywalls] = useState<AdaptyPaywall[]>([]);

	useEffect(() => {
    async function fetchPaywalls(): Promise<void> {
      try {
        await activateAdapty({
          sdkKey: "MY_PUBLIC_KEY",
        });

        const profile = await adapty.purchases.getInfo();
        const isSubscribed = profile.accessLevels.premium.isActive;
        setIsPremium(isSubscribed);

        if (!isSubscribed) {
          const result = await adapty.paywalls.getPaywalls();
          setPaywalls(result.paywalls);
        }
      } finally {
        setIsLoading(false);
      }
    }

    fetchPaywalls();
  }, []);

  // ...
}
// ...

Смотрим, что изменилось: добавились два флага в стейте. Все содержимое fetchPaywalls() мы завернули в try-catch, чтобы код гарантированно доходил до setIsLoading(false) в любом сценарии. Для проверки на подписку мы сначала получаем профиль юзера (там вся информация о его покупках), и далее смотрим на значение profile.accessLevels.premium.isActive. accessLevels — это уровни доступа, например, Gold, Premium и другие. Мы можем использовать столько, сколько нам нужно, однако давайте оставим по дефолту. Адапти создает для нас premium, и большинству людей этого хватит. isActive будет true, пока есть активная подписка с таким access level.

Дальше вроде все понятно: если у юзера премиум, то нам и не надо фетчить пейволлы, просто вырубаем лодер и показываем контент:

export const App: React.FC = () => {
// ...
const renderContent = (): React.ReactNode => {
  if (isLoading) {
    return <Text>Loading...</Text>;
  }

  if (isPremium) {
    return (
      <Image
        source={{
          uri: "https://25.media.tumblr.com/tumblr_lugj06ZSgX1r4xjo2o1_500.gif",
          width: Dimensions.get("window").width * 0.8,
          height: Dimensions.get("window").height * 0.8,
        }}
      />
    );
  }

  return (
    <Button
      title="Показать пейволл"
      onPress={() => {
        const paywall = paywalls.find(
          (paywall) => paywall.developerId === "cats_paywall"
        );

        if (!paywall) {
          return alert("Нет такого пейволла!");
        }

        Navigation.showModal<PaywallProps>({
          component: {
            name: "Paywall",
            passProps: {
	            paywall,
		          onRequestBuy: async (product) => {
		            const purchase = await adapty.purchases.makePurchase(product);
	              const isSubscribed =
		              purchase.purchaserInfo.accessLevels?.premium.isActive;
                setIsPremium(isSubscribed);
                Navigation.dismissAllModals();
              },
            },
          },
        });
      }}
    />
  );
};

return <View style={styles.container}>{renderContent()}</View>;
};

Здесь мы добавили функцию, которая рендерит нужный контент, и добавили в onRequestBuy кусок логики — обновление стейта isPremium и закрытие модального окна.

Смотрим, что вышло:

Весь файл
import React, { useEffect, useState } from "react";
import {
  Button,
  SafeAreaView,
  StyleSheet,
  Text,
  View,
  PlatformColor,
  Image,
  Dimensions,
} from "react-native";
import {
  adapty,
  activateAdapty,
  AdaptyPaywall,
  AdaptyProduct,
} from "react-native-adapty";
import { Navigation } from "react-native-navigation";

export const App: React.FC = () => {
  const [isLoading, setIsLoading] = useState<boolean>(true);
  const [isPremium, setIsPremium] = useState<boolean>(false);
  const [paywalls, setPaywalls] = useState<AdaptyPaywall[]>([]);

  useEffect(() => {
    async function fetchPaywalls(): Promise<void> {
      try {
        await activateAdapty({
          sdkKey: "MY_PUBLIC_KEY",
        });

        const profile = await adapty.purchases.getInfo();
        const isSubscribed = profile.accessLevels.premium.isActive;
        setIsPremium(isSubscribed);

        if (!isSubscribed) {
          const result = await adapty.paywalls.getPaywalls();
          setPaywalls(result.paywalls);
        }
      } finally {
        setIsLoading(false);
      }
    }

    fetchPaywalls();
  }, []);

  const renderContent = (): React.ReactNode => {
    if (isLoading) {
      return <Text>Loading...</Text>;
    }

    if (isPremium) {
      return (
        <Image
          source={{
            uri: "https://25.media.tumblr.com/tumblr_lugj06ZSgX1r4xjo2o1_500.gif",
            width: Dimensions.get("window").width * 0.8,
            height: Dimensions.get("window").height * 0.8,
          }}
        />
      );
    }

    return (
      <Button
        title="Показать пейволл"
        onPress={() => {
          const paywall = paywalls.find(
            (paywall) => paywall.developerId === "cats_paywall"
          );

          if (!paywall) {
            return alert("Нет такого пейволла!");
          }

          Navigation.showModal<PaywallProps>({
            component: {
              name: "Paywall",
              passProps: {
                paywall,
                onRequestBuy: async (product) => {
                  const purchase = await adapty.purchases.makePurchase(product);
                  const isSubscribed =
                    purchase.purchaserInfo.accessLevels?.premium.isActive;
                  setIsPremium(isSubscribed);
                  Navigation.dismissAllModals();
                },
              },
            },
          });
        }}
      />
    );
  };

  return <View style={styles.container}>{renderContent()}</View>;
};
interface PaywallProps {
  paywall: AdaptyPaywall;
  onRequestBuy: (product: AdaptyProduct) => void | Promise<void>;
}
export const Paywall: React.FC<PaywallProps> = ({ paywall, onRequestBuy }) => {
  const [isLoading, setIsLoading] = useState<boolean>(false);

  return (
    <SafeAreaView style={styles.paywallContainer}>
      {paywall.products.map((product) => (
        <View key={product.vendorProductId}>
          <Text>{product.localizedTitle}</Text>
          <Button
            title={`Купить за ${product.localizedPrice}`}
            disabled={isLoading}
            onPress={async () => {
              try {
                setIsLoading(true);
                await onRequestBuy(product);
              } catch (error) {
                alert("Произошла ошибка :(");
              } finally {
                setIsLoading(false);
              }
            }}
          />
        </View>
      ))}
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1, alignItems: "center", justifyContent: "center" },
  paywallContainer: {
    flex: 1,
    alignItems: "center",
    justifyContent: "space-evenly",
    backgroundColor: PlatformColor("secondarySystemBackground"),
  },
});

Что в итоге

В итоге, у нас вышло замечательное и очень полезное приложение с подписками. Те, кто платят, будут видеть котов, а другим будет показываться пейволл. Этих знаний должно хватить, чтобы вы смогли построить все необходимое для покупок в вашем приложении. А для тех, кто хочет погрузиться в мир возможностей сторкитов чуть глубже, продолжение следует. Спасибо!

Про Adapty

Adapty не только упрощает техническую реализацию подписок:

  • Встроенная аналитика позволяет быстро понять основные метрики приложения.

  • Когортный анализ отвечает на вопрос, как быстро сходится экономика.

  • А/Б тесты увеличивают выручку приложения.

  • Интеграции с внешними системами позволяют отправлять транзакции в сервисы атрибуции и продуктовой аналитики.

  • Промо кампании уменьшают отток аудитории.

  • Open source SDK позволяет интегрировать подписки в приложение за несколько часов.

  • Серверная валидация и API для работы с другими платформами.

Познакомьтесь подробнее с этими возможностями, чтобы быстрее внедрить подписки в своё приложение и улучшить конверсии.

Источник 📢