Всем привет! Меня зовут Мурат Насиров, я Flutter-разработчик в Friflex. Мы разрабатываем мобильные приложения и имеем большой опыт в сфере ритейла. На одном из проектов я столкнулся с внедрением кнопки оплаты через Систему Быстрых Платежей (СБП). В этой статье я хочу поделиться своим опытом и наработками в быстрой интеграции нативных компонентов SDK СБП в кроссплатформенное приложение на Flutter.
Для начала работы нужно иметь специальный .aar
бандл для Andorid и .xcframework
для iOS. Вся интеграция будет происходить на основании этих двух компонентов. Для этого переходим на сайт НСПК и скачиваем виджет СБП.
1. Создание плагина
Для создания плагина на Flutter используется команда (документация):
flutter create --org plugin.sbp_pay --template=plugin --platforms=android,ios sbp
Где:plugin.sbp_pay
— путь до исполняемого файла плагина на Android-стороне--template=plugin
— указание, что создается именно плагинsbp
— название плагина
2. Внедрение SDK на Android
В версии SBP SDK 1.2
Открыв архив с бандлом, нужно переместить папку repo в папку android:
Далее открываем файл build.gradle
, который также находится в этой папке и добавляем:
// Ищем в папке android папку repo
String path = project.mkdir("repo").absolutePath
rootProject.allprojects {
repositories {
maven {
url "$path" // Добавляем бандл с SDK как maven источник
}
}
}
dependencies {
// Добавляем зависимость как транзитивную
implementation('sbp.payments.sdk:sbp_sdk:1.2@aar') { transitive = true }
}
В .aar
бандле СБП используется разрешение, которому нужно отдельное обоснование. Чтобы избежать проблем, нужно открыть sbp_sdk-1.2.aar
как архив и в AndroidManifest.xml
удалить это разрешение:
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
В версии SBP SDK 1.4
Теперь СБП комплектует свой архив немного иначе. В части с файлами для Android сразу находятся .aar
, .pom
и .xml
файлы. Можно использовать тот же путь размещения файлов, как и для версии 1.2:
Соответственно, в месте указания транзитивной зависимости нужно установить версию 1.4 в файле build.gradle
. Вышеописанное разрешение на запрос пакетов всех приложений в этой версии убрали.
Теперь можно приступать к написанию кода на стороне плагина. Чтобы сократить недопонимание, прикладываю реализацию главного класса плагина:
SbpPayPlugin.kt
/** SbpPayPlugin */
class SbpPayPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
private lateinit var channel: MethodChannel
private lateinit var context: Context
private var activity: Activity? = null
companion object {
@JvmStatic
fun registerWith(registrar: PluginRegistry.Registrar) {
val channel = MethodChannel(registrar.messenger(), "sbp_pay")
channel.setMethodCallHandler(SbpPayPlugin())
}
}
override fun onAttachedToEngine(@NonNull flutterPluginBinding:
FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "sbp_pay")
channel.setMethodCallHandler(this)
context = flutterPluginBinding.applicationContext
}
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
when (call.method) {
"init" -> {
// Инициализация SDK
SBP.init(context)
result.success(true)
}
"showPaymentModal" -> {
// СБП с версии 1.2 использует не Activity, а FragmentManager
val fragmentManager = (activity as FlutterFragmentActivity).supportFragmentManager
try {
SBP.showBankSelectorBottomSheetDialog(fragmentManager,
call.arguments as String?
)
result.success(true)
} catch (e: Exception) {
// Перехват ошибок плагина
result.error("-", e.localizedMessage, e.message)
}
}
else -> result.notImplemented()
}
}
override fun onDetachedFromEngine(@NonNull binding:
FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
}
override fun onReattachedToActivityForConfigChanges(binding:
ActivityPluginBinding) {
activity = binding.activity
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
}
override fun onDetachedFromActivity() {
activity = null
}
}
Теперь со стороны вашего приложения, которое будет использовать этот плагин, нужно изменить в Android-части родительский класс, от которого расширяется главный класс, так как в коде выше указан каст к FlutterFragmentActivity
:
sbp_pay/example/android/app/src/main/kotlin/sbp/plugin/sbp_pay_example/MainActivity.kt
class MainActivity: FlutterFragmentActivity()
Помимо перехода на фрагменты, СБП также решили использовать Material 3, поэтому эти стили надо также обновить со стороны приложения. Обычно это LaunchTheme
и NormalTheme
. Тоже самое делаем для папки стилей values-night, если она есть:
sbp_pay/example/android/app/src/main/res/values/styles.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="Theme.Material3.Light.NoActionBar">
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<style name="NormalTheme" parent="Theme.Material3.Light.NoActionBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
3. Внедрение SDK на iOS
На стороне iOS всё проще — нужно просто добавить папку SBPWidget.xcframework в корень папки ios, а затем добавить SDK как pod
зависимость:
В файле sbp_pay.podspec
перед end
нужно добавить этот код:
s.preserve_paths = 'SBPWidget.xcframework/**/*'
s.xcconfig = { 'OTHER_LDFLAGS' => '-framework SBPWidget' }
s.vendored_frameworks = 'SBPWidget.xcframework'
Теперь осталось отредактировать файл плагина:
SbpPayPlugin.swift
import Flutter
import UIKit
import SBPWidget
public class SbpPayPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "sbp_pay", binaryMessenger:
registrar.messenger())
let instance = SbpPayPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
// Запрашиваем при инициализации доступность SDK на iOS
case "init":
if #available(iOS 13.0, *) {
result(true)
} else {
result(false)
}
case "showPaymentModal":
if #available(iOS 13.0, *) {
if let topController = getTopViewController() {
do {
try SBPWidgetSDK.shared.presentBankListViewController(paymentURL: call.arguments
as! String, parentViewController: topController)
result(true)
} catch (e) {
result(FlutterError(code: "PluginError", message: error.localizedDescription, details: nil)) // Перехват ошибок плагина
}
} else {
result(FlutterError(code: "PluginError", message: "SBP: Failed to implement controller", details: nil))
}
} else {
result(false))
}
default:
result(FlutterMethodNotImplemented)
}
}
private func getTopViewController() -> UIViewController? {
if var topController = UIApplication.shared.keyWindow?.rootViewController {
while let presentedViewController = topController.presentedViewController {
topController = presentedViewController
}
return topController
}
return nil
}
}
Как в Android-, так и в iOS-части в документации описаны возможные ошибки и исключения. По желанию их можно передавать через натив в Flutter-приложение.
Мы используем call.arguments
как строку, потому что этим аргументом из плагина будет передаваться ссылка на оплату определенного формата (формат ссылки описан в документации с iOS-частью).
Стоит отметить, что новый виджет СБП работает только с iOS 13. Поэтому нужно учитывать целевую аудиторию, которая будет пользоваться вашим приложением.
4. Подключение плагина на стороне Flutter
Теперь, когда реализация написана на двух нативных частях, можно приступать к созданию платформенных сообщений на стороне Dart:
sbp_pay/lib/sbp_pay.dart
sbp_pay.dart
import 'package:flutter/services.dart';
class SbpPay {
static const MethodChannel _channel = MethodChannel('sbp_pay');
static bool _wasInitialized = false;
/// Флаг доступности [SbpPay] на данном устройстве.
///
/// Доступен только после успешного [init], иначе ошибка.
static bool get isAvailable => _isAvailable;
static late bool _isAvailable;
/// Инициализация плагина SbpPay.
///
/// Возвращает false, если сервис не поддерживается устройством.
static Future<bool> init() async {
if (!_wasInitialized) {
_isAvailable = await _channel
.invokeMethod<bool?>('init')
.then((value) => value ?? false);
_wasInitialized = true;
return _isAvailable;
}
return _isAvailable;
}
/// Вызов нативного окна SbpPay выбора банков.
static Future<void> showPaymentModal(String link) {
return _channel.invokeMethod('showPaymentModal', link);
}
}
Готово! Осталось написать простенькую реализацию в папке example, чтобы протестировать виджет:
sbp_pay/example/lib/main.dart
Формат ссылок, поддерживаемых плагином, описаны в API СБП:
main.dart
import 'package:flutter/material.dart';
import 'package:sbp_pay/sbp_pay.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
// Только ссылки такого формата позволяют открыть модалку, иначе падает исключение
static const _paymentLink =
'https://qr.nspk.ru/AS100001ORTF4GAF80KPJ53K186D9A3G?type=01&bank=100000000007&crc=0C8A';
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Plugin example app'),
),
body: Builder(
builder: (context) {
return Center(
child: TextButton(
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
if (await SbpPay.init()) {
await SbpPay.showPaymentModal(_paymentLink);
} else {
messenger.showSnackBar(
const SnackBar(content: Text('Not supported')),
);
}
},
child: const Text('Running on'),
),
);
}
),
),
);
}
}
Результат
Примерно так будет выглядеть плагин СБП в вашем Flutter-приложении:
Получилась инструкция по написанию собственного Flutter-плагина для Android и iOS со всеми нюансами. Исходный код проекта доступен на GitHub. Версия 1.2 размещена в main
ветке, а версия 1.4 — в ветке v1.4
.
Пишите в комментариях, как вы решали данную проблему, делитесь отзывами и опытом.