
Привет! На связи снова Сергей Арсёнов, руководитель мобильной разработки в компании 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 на этот стек.
Спасибо, что прочитали. В рамках статьи было сложно вместить все моменты и тонкости, которые нам встретились, поэтому буду рад ответить на вопросы, если что-то оказалось недостаточно раскрыто или осталось непонятно.
