Всем привет, меня зовут Сурен, я SDK Engineer в qonversion.io.

Мы - data платформа для приложений с подписками. Наши мобильные SDK предоставляют интерфейс для работы со StoreKit и Google Billing Client, принимают пуши, отображают экраны, построенные в визуальном конструкторе экранов и многое другое.

Сегодня хотел бы рассказать про StoreKit 2, который был представлен на WWDC 21.

На WWDC 21 Apple в очередной раз представила немало новинок. Одним из крутых обновлений является StoreKit 2. В последние годы встроенные покупки и подписки становятся основой большого количества приложений и приносят огромные деньги Apple и разработчикам. Так на WWDC 21 Apple поделились информацией, что за всё время существования AppStore они выплатили разработчикам 230 миллиардов долларов. Причём значительный рост произошел за последние несколько лет. Именно поэтому в последнее время и Apple, и Google делают такой упор на всём, что связано со встроенными покупками. На WWDC 20 был показан StoreKitTransactionManager с .storekit файлом, которые очень сильно упростили тестирование встроенных покупок. Кстати, ждать пришлось всего лишь с iOS 3 до iOS 14. Подробнее о новинках тестирования - тут.

Прежде чем обсудить все прелести StoreKit 2, давайте поймём, что было не так со старым StoreKit:

  • Сложность понимания

  • Плохая архитектура

  • Нет валидации покупок

  • Нет автоматической синхронизации между своими устройствами

  • Нет данных о существующих покупках

  • Нет ряда полезных данных

Давайте коротко (местами не очень) и по порядку:

  1. StoreKit действительно сложно понимать (здорово, если вам не сложно) в сравнении с другими нативными SDK. Запутанная система, продукты, транзакции, платежи, реквесты, рецепты, рефреши, зачем-то ещё сервер нужен. А что юзер вообще покупал, а что до сих пор активно? Как всё это понимать? Отчасти из-за этого появились целые SaaS проекты, которые предоставляют инфраструктуру для работы со StoreKit, как на клиенте, так и на сервере.

  2. Плохая архитектура. Речь не о том, что вы не сможете собрать нужную вам архитектуру вашего приложения, а о том, что код работы со StoreKit становится очень непонятным из-за особенностей реализации обратной связи с его стороны. А конкретнее, речь про делегаты и обсерверы. Загрузку продуктов мы инициируем в одном месте, а ответ прилетает от SKProductsRequestDelegate совсем в другом. Нет хотя бы привычного ответа в completion блоке. Та же история с самой покупкой и SKPaymentTransactionObserver. И если вам вдруг нужны данные из запроса покупки, то придется как-то их отдельно хранить, сопоставлять и надеяться, что всё пойдет по тому плану, который вы себе придумали при написании этого кода. Иначе непонятно, обработаем ли мы вообще эту покупку в итоге. И в случае с подписками и нерасходуемыми покупками это ещё не так страшно из-за возможности сделать restore. А вот потерять consumable, она же расходуемая покупка, будет совсем неприятно.

  3. Нет валидации покупок. Речь про валидацию из “коробки”. Да, вы можете помучаться, расшифровать рецепт прямо на устройстве. Это решит часть проблем, но всё-равно полностью не обезопасит от тех, кто захочет вас обмануть и попользоваться вашим приложением бесплатно. Возможно, вам кажется, что это не так страшно, но, к моему большому удивлению, достаточно много людей "фродят". Используют jailbreak, чтобы бесплатно получить доступ к приложениям. Наглядно я это увидел, когда к нам подключился новый клиент и начал засыпать нас жалобами о плохо работающем SDK. Утверждал, что в своей аналитике он видит слишком частые ресторы от пользователей, а доступы в приложении так и не появляются. На деле же оказалось, что это просто были "фродеры", которые очень удивились, что у них пропали доступы (мы валидируем покупки через сервера Apple) и очень настойчиво пытались их восстановить, чего сделать, естественно, не могли из-за серверной валидации.

  4. Нет автоматической синхронизации между своими устройствами. Представьте, что у вас есть iPhone и iPad (или не представляйте, вдруг у вас действительно есть), вы сделали покупку на iPhone, а на iPad доступы вы всё равно не получили, хотя эти оба устройства - ваши, оба залогинены под одним и тем же Apple ID. Это не что-то невероятно неудобное, конечно, но тем не менее, вам придется пойти на экран покупок, нажать "Восстановить покупки", и только тогда покупки станут доступны на вашем втором/третьем/четвертом устройстве.

  5. Нет данных о существующих покупках. Опять же, не то чтобы очень сложно реализовать эту логику самому, но всё же это требует дополнительных действий. Каждый раз при совершении покупки или автообновлении подписки вы получаете транзакцию о покупке продукта. Больше эту информацию вы никак из StoreKit не получите, кроме как вызвав рестор, который нельзя вызывать "просто потому что хочется". Соответственно, вам необходимо эту информацию где-то (UserDefaults/ваш сервер) хранить, чтобы давать пользователю доступы, и надеяться, что это всё отрабатывается правильно. Но после переустановки, в случае использования UserDefaults, все эти данные, конечно, будут потеряны.

  6. Нет ряда полезных данных. Возьмём для примера самые простые и распространенные случаи. Покупки часто предлагаются со скидочным предложением или триалом. Так вот, у вас нет возможности узнать, была ли у текущего пользователя покупка в этой группе продуктов, чтобы принять решение о предоставлении ему скидки/триала. А ещё без своего сервера и App Store Server Notifications вы, скорее всего, не узнаете о том, что пользователь отписался или планирует это сделать. А значит, не сможете предложить ему скидку, узнать, что ему не нравится и попробовать каким-либо способом удержать его.

Уверен, у каждого может быть ещё несколько своих личных "болей", но давайте пока остановимся на этом списке и перейдем к StoreKit 2.

Что же нового появилось в StoreKit 2?

Apple у себя на главной StoreKit выделяют много пунктов, но я бы предложил остановиться на двух основных. Это Swift-first design и обновление API. Договоримся сразу, что документация на данный момент достаточно кривая и неполная (и обновляется активно прямо сейчас), поэтому ссылок на неё не будет.

Swift-first design

В StoreKit 2 вовсю используются нововведения Swift. А конкретно, очень сильно упрощает жизнь Concurrency со своими async и await.

Это решает (частично или полностью, тут каждый пусть для себя решит) ту самую боль с плохой архитектурой.

Загрузка продуктов

Раньше вам необходимо было создать SKProductsRequest, стать его делегатом, запустить этот request и обязательно сохранить на него сильную ссылку, чтобы система не убила его до завершения. Выглядело это примерно так:

let productsRequest = SKProductsRequest(productIdentifiers: identifiers)
productsRequest.delegate = self
productsRequest.start()
        
self.productsRequest = productsRequest

А потом получать ответ вот так:

extension StoreKitService: SKProductsRequestDelegate {
    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
        // handle products here
    }
}

Со StoreKit 2 это будет выглядеть вот так:

let storeProducts = try await Product.request(with: identifiers)

И всё

На следующей строке у вас уже будут все загруженные продукты.

Покупка

Раньше покупка совершалась следующим образом:

func purchase(_ product: SKProduct) {
    let payment = SKPayment(product: product)
    SKPaymentQueue.default().add(payment)
}

А ответ получать нужно было так:

extension StoreKitService: SKPaymentTransactionObserver {
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        transactions.forEach { transaction in
            switch transaction.transactionState {
            case .purchased:
                // handle purchased
            case .failed:
                // handle failed
            case .deferred:
                // handle deferred
            case .restored:
                // handle restored

            default: break
            }
        }
}

В результате вы получите массив с одной транзакцией по продукту, который вы и покупали. А ещё в эту функцию попадают все транзакции после вызова функции restoreCompletedTransactions(), которая используется для восстановления покупок. Поэтому и возвращается массив. И в этом всём вам ещё где-то надо сохранить связь с тем, кто попросил эту покупку, потому что вам необходимо вернуть ему ответ. Как вариант, в классе хранить completion, в котором потом планируете вернуть ответ. Ведь напрямую у вас этого completion уже нет, вы находитесь в другой независимой функции. Ну и если вам нужны были какие-то дополнительные данные, то их тоже храните где-то, а потом синкайте.

Как это будет выглядеть со StoreKit 2:

func purchase(_ product: Product) async throws -> Transaction? {
    let result = try await product.purchase()
    
    switch result {
    case .success(let verification):
        // handle success
        ...
        return result
    
    case .userCancelled, .pending:
        // handle if needed
    
    default: break
}

И снова всё.

Уже на второй строке у вас есть результат покупки. Дальше вы его просто обрабатываете. Также обратите внимание, что у результата есть case .userCancelled, который, как вы уже догадались, означает, что пользователь отменил процесс покупки. Раньше вы могли узнать о том, что покупка была отменена пользователем, только обработав transactionState у SKPaymentTransaction. И если там будет .failed, то дальше вам необходимо проверить код ошибки и понять, что это была отмена, а не ошибка. И это важно, потому что в случае ошибки неплохо бы показать её пользователю. А если он сам отменил покупку, будет как-то странно показывать ему ошибку. Здорово, что теперь это отдельный case в PurchaseResult и его можно обрабатывать отдельно ото всех ошибок.

И, конечно, теперь вам не надо запоминать, кто просил покупку, и думать, как её вернуть. Вы просто в нужный момент сделаете return result.

Валидация

Также, как вы можете заметить, при успешной покупке возвращается значение verification, которое говорит, была ли провалидирована ваша покупка StoreKit. Дальше уже вам решать, хотите вы смотреть на это поле или нет, доверяете ли вы валидации на девайсе или всё-таки хотите проверить на своём или чьём-то ещё сервере.

Дополнительные опции покупки

Теперь при совершении покупки вы можете передавать дополнительные опции. На данный момент их несколько, но действительно интересной мне кажется только одна.

Персонализация

Не знаю, как это назвать... Назовём персонализацией. В общем, теперь при совершении покупки вы можете передавать дополнительные опции, которые останутся с этой покупкой навсегда.

Таким образом, вы всегда сможете идентифицировать, какой пользователь совершил эту покупку. Это удобно, когда вы, например, хотите предоставлять доступ не исходя из покупок на AppStore-аккаунте пользователя, а ориентируясь на вашу внутреннюю авторизацию. Например, если вы -- стриминговый сервис и хотите давать доступ по вашему внутреннему аккаунту, на каком бы из своих девайсов пользователь ни находился: купил подписку на iPhone, смотрит на TV и так далее. Для этого достаточно просто передать ваш внутренний идентификатор пользователя при совершении покупки. Делается это следующий образом:

let result = try await product.purchase(options::[.appAccountToken(yourAppToken))])

И другие

В менее интересные, на мой взгляд, опции попадают:

  • обновленный интерфейс реализации промо покупок

  • возможность покупать продукты "пачками"

Например, если вы в своём приложении продаете монеты по 1000 штук, пользователь может выбрать сразу 5 таких пачек, купить и оплатить их разом, получив сразу 5000 монет.

И еще

По части обновления интерфейса можно много говорить, так как обновлено и подстроено под новые возможности Swift примерно всё, но это не так интересно. Добавлю только, что также обновился и интерфейс SKPaymentTransactionObserver, который использовался для отслеживания новых покупок , которые могут приходить в любой момент. Например, если это подтверждение покупки с родительского аккаунта, SCA, просто автоматическое автопродление подписки и так далее. Теперь это listener у объекта Transaction. И "слушать" новые транзакции можно так:

func listenForTransactions() -> Task.Handle<Void, Error> {
    return detach {
        for await result in Transaction.listener {
            do {
                // handle transaction result here
            }
        }
    }
}

Главное, всегда при работе с транзакциями не забывайте их завершать, вызвав transaction.finish(), иначе они каждый раз при перезапуске будут продолжать прилетать в listener. Впрочем, в этом плане ничего и не изменилось.

Итог по Swift-first design

Как мы видим, нововведения Swift в паре с новым интерфейсом StoreKit 2 значительно упрощают жизнь разработчикам и помогают сэкономить время, силы и нервы. Сейчас в пару строк кода, и с возможностью удобно обрабатывать ответ, можно:

  • Загрузить продукты

  • Начать покупку

  • Обработать результат покупки

  • Верифицировать покупку

  • Получить новые транзакции

Также обратите внимание, что сами Apple говорят, что StoreKit 2 значительно повышает уровень безопасности в плане верификации покупок, но это всё ещё не замена вашей серверной валидации. И точно не повод от неё отказываться, если она у вас была.

Новый мощный API

Тут Apple действительно добавили много нового и полезного. Практически всё интересное, что связано с Products и Purchases мы рассмотрели в первой части. Давайте теперь рассмотрим остальное.

Были переработаны, улучшены и расширены многие сущности и поля. Информации о продуктах, покупках и статусах подписок стало сильно больше. Фактически Apple добавила в публичное API StoreKit 2 большое количество данных, которые раньше были доступны только в рецепте. Так как в нем информация зашифрована, то большинство данных, предоставляемых публичным API StoreKit 2, будeт также шифроваться при помощи JWS. Например, информация о транзакциях и автопродлении подписки. И да, StoreKit 2 будет автоматически валидировать эти данные.

Давайте рассмотрим самые интересные из новинок API:

  • Транзакции

  • Активные покупки или Current entitlements

  • Информация о подписке

  • Автоматическая синхронизация покупок

Транзакции

Теперь вы можете получить список всех транзакций (или только последнюю) по продукту прямо из StoreKit 2. Раньше для этого приходилось разбирать рецепт. Данные о транзакциях могут пригодиться, например, для какой-то аналитики, или если хотите показать пользователю, что и когда он покупал у вас в приложении.

Активные покупки, или Current entitlements

Если помните, выше мы обсуждали, что хранить историю активных покупок, чтобы по ним давать доступы в ваше приложение, придется самим. Теперь StoreKit 2 сделает это за вас. Обратите внимание, что в этом списке хранятся только:

  • активные подписки

  • НЕрасходуемые покупки, они же non-consumable purchases Расходуемых покупок, таких как монеты/патроны/бензин, в этом списке не будет. Их вы должны обрабатывать сразу при покупке.

Информация о подписке

В этом разделе обсудим большую часть интересного, что содержит в себе объект SubscriptionInfo. Как и в случае с транзакциями, раньше эта информация была доступна только в рецепте и требовала определенной работы на вашем сервере.

Intro offer eligibility

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

static func isEligibleForIntroOffer(for groupID: String) async -> Bool

Renewal state

Состояние автообновления подписки, которое раньше тоже было доступно только в рецепте.

Есть несколько состояний:

  1. subscribed - подписка активна

  2. expired - подписка истекла

  3. inBillingRetryPeriod - была ошибка при попытке оплаты

  4. inGracePeriod - отсрочка платежа по подписке. Если grace period у вашей подписки включен и произошла ошибка при оплате, то у пользователя будет ещё какое-то время, пока подписка работает, хотя оплаты ещё не было. Количество дней отсрочки может быть от 6 до 16 в зависимости от длительности самой подписки.

  5. revoked - доступ ко всем подпискам этой группы отклонён AppStore.

Renewal info

В этом объекте будет отображаться всё, что связано с автообновлением подписки. Например, вы можете узнать такую информацию:

  1. willAutoRenew - флаг, который подскажет, будет ли подписка автопродлена. Если нет, то с какой-то долей вероятности пользователь не планирует дальше использовать подписку в вашем приложении. Самое время подумать о том, как его удержать.

  2. autoRenewPreference - ID подписки, на которую произойдет автообновление. Например, вы можете проверить, что пользователь сделал downgrade и планирует пользоваться более дешевой версией вашей подписки. В таком случае при желании можете попробовать предложить ему скидку и удержать его на более премиальной версии.

  3. expirationReason - а здесь вы можете более подробно посмотреть причины истечения срока подписки.

Важное замечание по поводу статусов подписки. StoreKit 2 возвращает массив статусов. Нужно это, чтобы покрыть случаи, когда у пользователя несколько подписок на один продукт. Например, одну подписку он купил сам, а вторая ему досталась через family sharing. Таким образом, вы сможете провалидировать весь массив и разблокировать функциональность в вашем приложении, ссылаясь на подписку с самым высоким уровнем доступа.

Синхронизация покупок

Также одним из крутых нововведений StoreKit 2 является синхронизация покупок между разными устройствами с одним Apple ID. То есть теперь, если пользователь совершил покупку на своём iPhone, а потом зашел на iPad, то на нем тоже эта покупка тоже будет доступна. Не надо будет совершать дополнительных действий вроде вызова restore().

Однако, тут есть оговорка со стороны Apple, что автоматическая синхронизация должна покрыть подавляющее большинство случаев. Но так как их устройства используют миллионы людей в огромном количестве стран, то на всякий случай есть ручной вызов синхронизации покупок - AppStore.sync(). По сути то же самое, что и restore(), но вызывать надо будет сильно реже.

И также Apple предупреждает, что вызывать AppStore.sync() стоит только в ответ на действие пользователя (нажатие кнопки), потому что вызов инициирует показ окна с вводом пароля, и если вы будете вызывать его просто где-то на старте, то пользовательский опыт внутри вашего приложения будет немного странным.

Итог по новому API StoreKit 2

Большое количество информации, которое раньше можно было получить только при расшифровке рецепта на своём сервере (делать это прямо в приложении - не лучший выбор), теперь доступно в самом StoreKit, и это сильно облегчает работу. Намного проще стало проверять статусы подписок и давать доступы в приложении.

Решены ли проблемы старого StoreKit?

Давайте посмотрим на те проблемы, которые мы обсуждали в начале статьи.

  1. Сложно понимать? Стало ли проще понимать устройство StoreKit? В целом, мне всё ещё кажется, что это сложный механизм, который требует глубокого погружения. Но в StoreKit 2 точно будет меньше вопросов: "А где мне получить эти данные?". Потому что раньше они лежали в рецепте, который даже глазами нигде и никак не посмотреть без предварительной расшифровки. Сейчас большое количество нужных полей доступно "из коробки".

  2. Плохая архитектура? Улучшилась ли архитектура? Несомненно. Это даже невозможно сравнивать, спасибо Swift Concurrency.

  3. Нет валидации покупок? Валидация покупок появилась в StoreKit 2, но валидация на API всё ещё надёжнее даже по словам Apple, которые любят хвалить свои новинки. Но в любом случае это огромный плюс в копилку Apple и StoreKit 2.

  4. Нет автоматической синхронизации между своими устройствами? Покупки наконец-то доступны на всех ваших девайсах сразу и без лишней возни.

  5. Нет данных о существующих покупках? Это может показаться мелочью, но это нужно было всем, кто хоть раз реализовывал встроенные покупки.

  6. Нет ряда полезных данных? В зависимости от ваших задач вам всё ещё могут понадобиться данные из рецепта, но даже то, что появилось в StoreKit 2, закроет большое количество потребностей.

В целом, лично для меня StoreKit 2 кажется очень крутым и лично мне было бы достаточно даже просто всего того, что появилось благодаря Swift Concurrency, потому что раньше было действительно много боли. Я часто говорю "раньше", как-будто бы эта боль закончится в ближайшее время... Нет, очевидно, что до полного переезда на StoreKit 2 ещё совсем не близко, но это уже хоть какая-то надежда на светлое будущее.

Пишите, что вы думаете о StoreKit 2 и какие ещё моменты можете выделить в плюсы для себя лично. Если с какими-то моими не согласны - тоже пишите, будет интересно услышать альтернативное мнение.

Как я и говорил, не буду давать ссылки на документацию, хотя она уже неплохо обновилась, пока писалась статья, но всё ещё очень сырая. Посмотрите лучше сессию с WWDC 21, где вы найдете весь этот материал (и даже больше) без моего оценочного суждения.