Всем привет! Меня зовут Мурат Насиров, я 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:

Так выглядит структура SDK версии 1.2 для Android
Так выглядит структура SDK версии 1.2 для 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:

Так выглядит структура SDK версии 1.4 для Android
Так выглядит структура SDK версии 1.4 для Android

Соответственно, в месте указания транзитивной зависимости нужно установить версию 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зависимость:

Структура SDK для iOS
Структура SDK для iOS

В файле 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)
    }
  }

  @available(iOS 13.0, *)
  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.

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

Комментарии (1)


  1. comerc
    21.10.2023 09:15

    Первая звёздочка на GitHub - моя!