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