Привет! На связи снова Сергей Арсёнов, руководитель мобильной разработки в компании r_keeper. Я уже рассказывал, как и почему мы выбрали стек Kotlin Multiplatform Mobile + UI на Flutter для обновления нашего мобильного приложения для официантов. А теперь посмотрим, что из этого вышло на стадии продакшн (спойлер: все получилось, но проблем хватило).

Итак, мы выбрали технологический стек для нового b2b-приложения для официантов. Казалось, дальше будет просто: бери готовую структуру проекта из отлично работающего тестового приложения и добавляй необходимый функционал… Но нет.

Связь логики и представления

Из тестового проекта мы взяли схему, по которой бизнес-логика инкапсулируется в виде некоего SDK с набором public-методов. По сути для слоя представления этот SDK является «черным ящиком», с которым можно взаимодействовать только через методы и получать / отправлять данные. 

Поскольку платформенная часть — это своего рода прокси между KMM SDK и Flutter UI, ей незачем знать про сущности и другие особенности реализации. Поэтому для передачи объектов из КММ во Flutter и назад мы выбрали сериализацию / десериализацию в JSON, благо в Kotlin это работает практически «из коробки», без доработки data-классов. В Dart, к сожалению, все устроено не так удобно, но для экономии сил можно использовать online-генераторы классов из JSON вроде этого или этого

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

В отличие от тестового проекта, в рабочей версии пришлось использовать подписки на потоки данных. Kotlin умеет отправлять эти данные через flow, а Flutter — слушать их через eventChannel. Но между ними находится еще и платформенная часть, работающая как прокси.

Тут мы наткнулись на первую сложность. В Android-версии все было прекрасно — для нее не проблема работать с flow- и suspend-методами SDK из платформенной части. Но когда мы полностью описали весь интерфейс, сделали рабочую версию под Android и попытались собрать то же самое под iOS, выяснилось, что в Swift flow недоступны. Пришлось рефакторить наш SDK и его работу с платформой: теперь в нем нет suspend- и flow-методов, а подписка на потоки данных из бизнес-логики осуществляется, для совместимости, через колбеки.

Ниже приведу пример кода для обеих платформ, который позволяет общаться Flutter- и KMM-частям, возможно, кому-то этот плод наших изысканий будет полезным

Код для Android

class MainActivity: FlutterActivity() {
  private val CHANNEL = "mobwaiter/platform"
  private val CONNECT_CHANNEL = "mobwaiter/connect_channel"

  //создание объекта SDK и  подстановка туда необходимых платформо-зависимостей 
	private val gateway: MobwaiterSDKGateway = MobwaiterSDK(
       firebaseAnalyticsFactory = FirebaseAnalyticsFactory(context),
       driverFactory = DatabaseDriverFactory(context),
       httpClientFactory = HttpClientFactory(),
       xmlToJsonConverterFactory = XmlToJsonConverterFactory()
	).gateway

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    //Инициализация каналов для связи флаттера с нативным андроидом
    val binaryMessenger = flutterEngine?.dartExecutor?.binaryMessenger
    val connectChannel = EventChannel(binaryMessenger, CONNECT_CHANNEL)
    val methodChannel = MethodChannel(binaryMessenger, CHANNEL)

    //обработчик, делающий передачу вызова от флаттер в КММ
  	methodChannel.setMethodCallHandler{ call, result ->
  		gateway.processCall(call.method, call.arguments, CallHandlerImpl(result) )
  	}
    //передача колбэка, позволяющего вызывать методы во флаттер из КММ
  	gateway.setCallbacks(CallbackHandlerImpl(methodChannel))
	}

  override fun onDestroy() {
     super.onDestroy()
     gateway.destroy();
  }
}

//Позволяет возвращать результаты из KMM-части во флаттер
class CallHandlerImpl(
       private val callResult : MethodChannel.Result
) : CallHandler {
   override fun success(result: Any?) =
           callResult.success(result)
  
   override fun error(errorCode: String?, errorMessage: String?, errorDetails: Any?) =
           callResult.error(errorCode,errorMessage, errorDetails)
}

//Позволяет из KMM вызывать во Flutter методы с аргументами
class CallbackHandlerImpl(
       private val methodChannel : MethodChannel
) : CallbackHandler {
   override fun invokeMethod(method: String, arguments: Any?) {
       methodChannel.invokeMethod(method, arguments)
   }
}

Код для iOS

import UIKit
import Flutter
import shared
 
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    
    private var sdk: MobwaiterSDK!;
    private var gateway: MobwaiterSDKGateway!;
    
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    
    //создание объекта SDK и подстановка туда платформо-зависимостей
    sdk = MobwaiterSDK(
        firebaseAnalyticsFactory: FirebaseAnalyticsFactory(), driverFactory: DatabaseDriverFactory(), httpClientFactory: HttpClientFactory(), xmlToJsonConverterFactory: XmlToJsonConverterFactory()
    );
    gateway = sdk.gateway;
    
    //инициализация каналов для связи нативной iOS части с Flutter
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    
    let mobwaiterChannel = FlutterMethodChannel(name: "mobwaiter/platform",
                                                  binaryMessenger: controller.binaryMessenger)
    
    
    //обработчик, делающий вызов от флаттер в КММ
    mobwaiterChannel.setMethodCallHandler({
      (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
        
        let callHandler = CallHandlerImpl(resultHandler: result)
        self.gateway.processCall(method: call.method, arguments: call.arguments, callHandler: callHandler)
    })
 
    //передача в КММ колбэка, позволяющего делать вызовы во флаттер
    self.gateway.setCallbacks(callback: CallbackHandlerImpl(mobwaiterChannel: mobwaiterChannel))
    
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
    
}
 
class CallHandlerImpl : CallHandler{
    let resultHandler : FlutterResult
    
    init(resultHandler : @escaping FlutterResult) {
        self.resultHandler = resultHandler
    }
    
    func error(errorCode: String?, errorMessage: String?, errorDetails: Any?) {
        resultHandler(FlutterError(code: errorCode!, message: errorMessage, details: errorDetails))
    }
    
    func success(result: Any?) {
        resultHandler(result)
    }
}
 
class CallbackHandlerImpl : CallbackHandler{
    
    let mobwaiterChannel : FlutterMethodChannel
    
    init(mobwaiterChannel : FlutterMethodChannel){
        self.mobwaiterChannel = mobwaiterChannel
    }
    func invokeMethod(method: String, arguments: Any?) {
        mobwaiterChannel.invokeMethod(method, arguments: arguments)
    }
}

При передаче данных из Flutter в KMM нужно внимательно следить за соответствием типов данных: то, что без проблем передается из Flutter в Android, не обязательно так же нормально попадет из Flutter в iOS, например, arraylist. С этим нам тоже пришлось помучиться.

Работа с бэкендом

Еще одной проблемой стало древнее, как слеза мамонта, XML API. Официальная кроссплатформенная библиотека kotlinx.serialization не поддерживает XML, а найти XML-десериализатор для Kotlin Native невозможно — все библиотеки собраны исключительно для Kotlin / JVM. 

Поэтому сначала, чтобы не терять темп, мы вынесли десериализацию в платформенную часть KMM, а потом своими силами портировали в кроссплатформенную часть XML-JSON-парсер, написанный для Android / JVM.

Работа с базой данных

В этой части каких-то особенностей работы именно с кроссплатформой мы не заметили. Разве что пришлось отойти отойти от привычных по Room ORM, и писать sql-запросы и схемы таблиц вручную. SQLDelight умеет генерить по этому описанию объекты и методы, но просто описать объекты и автоматически получить работу с БД не выйдет. Также,  то, что SQLDelight по умолчанию не поддерживает Foreign Keys стало для нас сюрпризом. Мы потратили немало времени, пока разобрались, что происходит и почему не работает каскадное удаление. 

Вот что нужно прописать в Android-части кода KMM при инициализации драйвера, чтобы ключи заработали:

    actual fun createDriver(): SqlDriver {
        return AndroidSqliteDriver(AppDatabase.Schema, context, "rkmobwaiter.db",
                callback = object : AndroidSqliteDriver.Callback(AppDatabase.Schema) {
                    override fun onOpen(db: SupportSQLiteDatabase) {
                        db.execSQL("PRAGMA foreign_keys=ON;");
                    }
                })
    }

Реализация представления

Интересной задачей стала разработка архитектуры Flutter-части. Я уже упоминал, что Flutter предполагает декларативную верстку, то есть написание Dart-кода в виде дерева виджетов (view), состоянием которых нужно как-то управлять. Официальная документация Flutter рекомендует использовать или так называемые Stateful-widgets, которые сами в себе хранят свое состояние, или пакет provider — некую реализацию классического шаблона «Наблюдатель».

В тестовом приложения, пока UI-часть была небольшой, эти схемы работали нормально, но по мере роста реального приложения они начали превращать Dart-код в нечитаемую кашу. Выходом стало использование пакета flutter_bloc, вариации знакомого многим веб-разработчикам Redux-подхода от Felix Angelov. Пришлось полностью отрефакторить уже написанный код, но оно того стоило. Код структурировался достаточно для того, чтобы новым разработчикам можно было сказать «Делай так» и наращивать функционал приложения без костылей и потери управляемости.

Сам bloc со своими State и Events нам показался немного избыточным, там очень много шаблонного кода. Мы используем так называемые cubit - вариацию bloc. Там присутствуют те же самые state для вьюшек, но вместо events просто вызываются методы в этом кубите. Это показалось нам более удобным

 Вообще, хочется заметить, что официальные плагины Flutter и Dart для Android Studio пока довольно кривые — они могут внезапно перестать подсвечивать синтаксис или осуществлять переход между классами по Ctrl. Работать одновременно с KMM и Flutter неудобно, в одном SDK через два плагина — невозможно. Поэтому разработчикам приходится держать открытыми сразу два окна с приложением: в первом код подсвечивает Flutter-плагин, во втором — Kotlin. Неудобно, но жить можно.

И последний момент: импорт библиотек в actual-классах иногда приходится делать наугад, без подсказки.

Что мы поняли?

Вот какие выводы мы сделали.

  • Flutter удобен и приятен. Начать писать рабочий код всего через пару недель изучения — не проблема для нормального разработчика. Плюс по нему очень много документации, есть большое сообщество — все проблемы можно решить.

  • С KMM иногда сложно из-за его новизны, минимума документации и небольшого количества кроссплатформенных библиотек. При их использовании всегда есть шанс наткнуться на проблему, которая не описана ни на Stack Overflow, ни в issues к репозиторию на GitHub. 

  • Kotlin и его coroutines чудесны — код пишется влет.

  • Обойтись только знанием Flutter и Kotlin не выйдет — нужно разбираться и в нативных платформах. Как минимум, придется научиться писать код Swift и собирать приложения в Xcode. Впрочем, это разовые задачи — достаточно хотя бы одного такого специалиста в команде.

  • Выбранная связка реально экономит ресурсы: мы смогли довести проект с нуля до стадии MVP на iOS и Android за 3 месяца, а если бы API был не XML и адаптирован под мобильные приложения, управились за 2,5. По нашим оценкам, написание аналогичных версий под NativeUI заняло бы почти в два раза больше человеко-часов. 

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

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

Наш первый опыт разработки на связке KMM + UI на Flutter в итоге был признан успешным, так что теперь мы планируем переводить и другие мобильные проекты r_keeper на этот стек.

Спасибо, что прочитали. В рамках статьи было сложно вместить все моменты и тонкости, которые нам встретились, поэтому буду рад ответить на вопросы, если что-то оказалось недостаточно раскрыто или осталось непонятно.

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


  1. funny_falcon
    08.10.2021 20:32

    Я прошу прощения за холиварный вопрос, но зачем понадобился Котлин? Почему не хватило Дарта?


    1. ris58h
      08.10.2021 21:09

      Об этом написано в первом же параграфе.


      1. funny_falcon
        08.10.2021 22:04

        В первом параграфе есть ссылка на статью "выбор стека". В той статье есть описание тестирования трёх версий. Но среди них нет варианта "чистый Flutter (Dart)".

        Edit: ещё раз перечитал первые 3 абзаца, и все равно не понял. (И один плюс случайго вашему ответу поставил).


        1. ris58h
          08.10.2021 22:48

          Из той статьи:

          Не было уверенности, что неопытный в этой технологии разработчик сможет качественно выстроить архитектуру и написать бизнес-логику. Мы также опасались, что кроссплатформа не позволит полностью реализовать все специфические особенности каждой платформы, вроде работы с push-уведомлениями или железом.

          И такой же ответ есть на такой же вопрос в комментариях к той статье

          https://habr.com/ru/company/r_k/blog/578386/#comment_23502098


          1. funny_falcon
            09.10.2021 07:20
            +2

            Не было уверенности, что неопытный в этой технологии разработчик сможет качественно выстроить архитектуру и написать бизнес-логику.

            Т.е. вы даже не попробовали. И даже не захотели пробовать.

            Тогда правильным ответом на мой первый вопрос будет: "Мы не знаем, хватило бы или не хватило Dart. Мы любим Kotlin, и будем писать на нём как можно больше, даже если это приводит к "любопытным" гибридам и "интересным" трудностям".

            Простите за сарказм.


            1. SergeiArsyonov Автор
              09.10.2021 09:51

              Если резюмировать - то разработчики отлично знали котлин и не знали дарт. Плюс хотели иметь резервный вариант по-быстрому написать UI на нативке, если вдруг flutter-UI будет медленным или неудобным.

              Сейчас, по прошествии полугода, выбор пал бы на чистый флаттер, полагаю

              https://habr.com/ru/company/r_k/blog/578386/comments/#comment_23502166

              даже не знаю, что еще сюда добавить


              1. funny_falcon
                09.10.2021 11:09

                Я извиняюсь за этот тред. Просто я бываю занудой и букой.


                1. SergeiArsyonov Автор
                  10.10.2021 13:25

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


  1. Sn1cKa
    12.10.2021 08:23
    +1

    Спасибо за интересную статью, хотелось бы узнать мнение всех ребят из команды: все ли готовы остаться на данном стеке или при работе с Flutter хотят попробовать следующий проект на чистом Flutter.

    P.S. Для моделей используйте Build Runner и Retrofit Generator - они позволяют держать описание моделей в чистоте и не писать бойлерплета!


    1. SergeiArsyonov Автор
      14.10.2021 18:05

      Спасибо!
      Ну, если ребята не поленятся, сами ответят, но один разработчик из команды просит задачи только на Flutter. Другой разработчик перешел в соседний проект и начал писать тот на чистом Dart-е. Остальные пока не имели возможности выбирать, но тоже, насколько я знаю, готовы пробовать новое на чистом флаттере. Впрочем, есть соседняя команда, которая, по определенным причинам, выбрала стек flutter+kmm
      Так что ответ, скорее, второе