Compose Multiplatform і Flutter: порівняння фреймворків

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

Вітаю! Я — Артем, Senior Mobile Developer в компанії Newsoft, маю вісім років досвіду розробки мобільних застосунків, працював з Android і Flutter.

Сьогодні розглянемо Compose Multiplatform для кросплатформної мобільної розробки і порівняємо його з іншим популярним фреймворком від Google — Flutter. Для початку пару слів про кожну технологію, раптом хто не в курсі.

Flutter — фреймворк від Google для кросплатформної розробки, стабільна версія якої вийшла у 2018 році. Підтримує розробку під iOS, Android, Web, Windows, Linux та Mac. Використовує мову Dart, що також є внутрішньою розробкою Google (детальніше пізніше).

Compose Multiplatform — також фреймворк для кросплатформної розробки. Сам UI-фреймворк розробляється компанією Google, а от мова програмування, Kotlin, як і кросплатформна частина (KMP), розробляються компанією JetBrains. Підтримувані платформи: Android, iOS (Alpha), Windows, MacOS, Linux та Web (Experimental).

Спробуймо порівняти різні аспекти цих двох таких різних, але водночас таких схожих технологій, і виставити бали в кожній з категорій. Оцінки та категорії будуть субʼєктивними і геть не науковими, але, тим не менш, має бути цікаво. Також зосередимося конкретно на мобільній розробці, адже такий формат є найбільш поширеним.

Поїхали!

Setup

Як для Flutter, так і для Compose Multiplatform є гарно розписані сетап-гайди. Для кожної із цих технологій основна IDE — це Android Studio. Для Flutter необхідно доставити Flutter Plugin. Для iOS-розробки необхідно мати інстальований xcode.

Жодних проблем ні в одному, ні в іншому випадку я не мав. Хоча в гайді для Compose Multiplatform зазначено, що найновіша версія xcode може не працювати і треба буде ставити одну зі старіших версій. У моєму випадку з xcode 15 проблем не було.

Тут нічия:

Flutter — 1

Compose Multiplatform — 1


Архітектура фреймворка

Flutter

У серці Flutter лежить Engine, написаний на C/C++. Він є відповідальним за відмальовку графіки (Impeller для iOS і в Preview для Android, Skia для інших платформ), I/O операції, Dart runtime, compile toolchain та інше.

Flutter Engine запаковуєтсья в аплікацію разом з вашим Dart-кодом і дещо збільшує розмір (приблизно на 3-4 МБ для Android і 10 для iOS). Релізна версія використовує AOT-компіляцію. Debug версія використовує Dart VM, що дозволяє hot reload і роботу дебагера. Дебаг версія використовує JIT-компіляцію.

Compose Multiplatform

Compose Multiplatform базується на Jetpack Compose від Google і KMP від JetBrains.

Основна відмінність від звичайного Compose — це додаткові KMP-таргети та відповідні зміни, завдяки яким можна писати не тільки кросплатформний Kotlin-код, а й також використовувати Compose UI на різних платформах.

Compose Multiplatform, як і Flutter, використовує Skia для рендерингу на Android, і Skiko для інших платформ (Skia for Kotlin).

Висновок

Тут варто зазначити декілька моментів.

Спільним для обох фреймворків є те, що Flutter і Compose Multiplatform не використовують рідних для платформи віджетів під капотом, як це робить React Native. У кожному разі фреймворк самостійно відмальовує всі компоненти з нуля, використовуючи Skia або аналоги. Що є плюсом, як на мене.

Саму архітектуру варто розглянути з точки зору того, як це впливає безпосередньо на розробку застосунків. З Flutter все просто: пишемо код на Dart, він може працювати на будь-яких підтримуваних платформах. Якщо треба, пишемо platform-specific код, підключаємо його до Dart через channel або використовуємо плагіни, підготовані спільнотою.

З Compose Multiplatform усе трохи складніше. Тут ми використовуємо Kotlin Multiplatform. У людини, яка не дуже занурювалася в тему та вперше почула про Compose Multiplatform, може скластися хибне уявлення, що ця магічна технологія дозволить вам просто перезібрати ваш Android-застосунок на Compose, і він запуститься на iOS.

Але це не зовсім так, і є низка обмежень, з якими вам доведеться працювати.

Окрім змін до структури проєкту, вам також доведеться переглянути ваш код і ваші залежності. Код, який залежить від Android- або від Java-пакетів, працюватиме тільки якщо ви залишите його як Android-specific, а це не зовсім те, чого ми хочемо від кросплатформного застосунку.

Такі ж обмеження стосуються і сторонніх бібліотек.

Детальніше про бібліотеки в наступному пункті.

Тут віддаю перевагу Flutter, бо поточна ситуація з Compose Multiplatform не ідеальна, і може бути складною для новачків.

Flutter — 2

Compose Multiplatform — 1


Сторонні бібліотеки

Flutter

Для Flutter існує прекрасний сайт pub.dev, на якому можна шукати бібліотеки (packages) для Flutter і Dart. Тут ми можемо побачити популярні, рекомендовані командою Flutter packages, також є зручний пошук. Спільнота Flutter доволі активна, і можна знайти бібліотеки для будь-якої потреби.

Також є офіційні packages від Google для інтеграції Firebase.

Compose Multiplatform

Значна кількість бібліотек від Google не будуть працювати (якщо не всі), тому доведеться використовувати сторонні бібліотеки практично для всього, як в старі-добрі часи. До прикладу, не підтримуються compose navigation, view model, room. Але водночас багато популярних бібліотек можна використовувати з multiplatform, наприклад, Koin, Kodein, Realm та SQLDelight.

Для навігації і MVVM/MVI також уже є KMP-сумісні бібліотеки. Проблема в тому, що значна кількість бібліотек, написаних для Android або Java, не будуть працювати через обмеження, про які я писав в розділі «Архітектура».

Звісно, якщо ви пишете проєкт з нуля, а не переконвертовуєте наявний, проблема буде не так відчутна, і можна буде одразу обрати сумісні бібліотеки. Чогось схожого до pub.dev нема, але є непогана сторінка на github, де можна подивитись список бібліотек, які працюють з KMP.

Перемога за Flutter:

Flutter — 3

Compose Multiplatform — 1


Мова програмування

Flutter: Dart

Dart — це внутрішня розробка Google, яка мала би стати альтернативою-вбивцею JavaScript. Перша версія не мала строгої типізації. Сучасний Dart має опціональну строгу систему типізації, підтримку null safety. Має підтримку обʼєктно-орієнтованого та функціонального програмування.

У 2023 році вийшло оновлення Dart 3, яке додало модифікатори interface class, base class, final class та sealed class. Для асинхронного програмування є механізм async / await. Для реактивного програмування є Streams і бібліотека rxdart.

Загалом, мова непогана, дещо схожа на Java, але з деякими особливостями. Вона не настільки лаконічна як Kotlin, має більш роздутий синтаксис, і не має такої маленької, але важливої фічі як data classes.

Приклад простого класу і nullability (цей клас не має імплементації equals, hashCode і toString, для цього потрібно підключати бібліотеки, наприклад цю або цю)

abstract interface class Named {
  String get name;
}
final class Person implements Named {
  final String firstName;
  final String lastName;
  Person({required this.firstName, required this.lastName});
  @override
  String get name => '$firstName $lastName';
}
void checkPersonName() {
  Person? person = Person(firstName: 'Tobey', lastName: 'Maguire');
  debugPrint(person.name);
  person = null;
  debugPrint(person?.name);
}

Compose Multiplatform: Kotlin

Kotlin розробляється компанією JetBrains, на початку мова мала стати альтернативою-вбивцею Java. Зараз Kotlin є основною мовою програмування під Android, може компілюватись не тільки в JVM байткод, а й також і в нативні бінарники або в JavaScript.

Підтримує строгу типізацію, null safety, має підтримку обʼєктно-орієнтованого та функціонального програмування, а також інтероп з Java і Objective-C. Для асинхронного програмування є механзім coroutines. Для реактивного — Flow.

Kotlin має велику кількість приємного синтаксичного цукру, і загалом дуже лаконічний синтаксис.

Ось приклад коду на Kotlin, який робить майже те саме, що і код на Dart. Тут також маємо data class, а це означає, що equals, hashCode, toString згенеровані автоматично.

 interface Named {
        val name: String
    }
    data class Person(val firstName: String, val lastName: String) : Named {
        override val name: String
            get() = "$firstName $lastName"
    }
    fun checkPersonName() {
        var person: Person? = Person(firstName = "Tobey", lastName = "Maguire")
        println(person?.name)
        person = null
        println(person?.name);
    }

Висновок

Обидві мови є доволі зручними, простими у вивченні, мають різні сучасні фішки.

Але тут я буду покладатися на особистий досвід, і мені програмувати на Kotlin в рази приємніше, аніж на Dart. Dart дає інструменти, щоби писати чистий код, має підтримку асинхронного та реактивного програмування з коробки, але деякі рішення мені виглядають дивними, та і синтаксис місцями кострубатий у порівнянні з Kotlin.

Flutter — 3

Compose Multiplatform — 2


UI-фреймворк

Обидва фреймворки дотримуються парадигми Declarative UI. Звісно, є багато нюансів і відмінностей, про які можна писати окрему статтю.

Тут подивимось на основну цеглинку, з якої ми будемо складати наш UI, це допоможе скласти приблизне уявлення щодо роботи з фреймворками в цілому. Для Flutter — це widget, для Compose — це composable function.

Спробуймо імплементувати простий віджет, в якому буде кілька полів з текстом, і внутрішній стейт, який можна змінювати натисканням на кнопку.

Flutter Stateful Widget

Flutter розрізняє Stateless- і Stateful-віджети як окремі класи. У випадку Stateful-віджета потрібно імплементувати два класи, сам віджет і клас State:

class CounterWidget extends StatefulWidget {
  final Color backgroundColor;
  final Color borderColor;
  const CounterWidget({
    super.key,
    required this.backgroundColor,
    required this.borderColor,
  });
  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
  int _counter = 0;
  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        const Padding(
          padding: EdgeInsets.only(bottom: 16),
          child: Text('Flutter running on iOS'),
        ),
        const Text(
          'You have pushed the button this many times:',
        ),
        Text(
          '$_counter',
          style: Theme.of(context).textTheme.headlineMedium,
        ),
        InkWell(
          customBorder: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(8),
          ),
          onTap: () {
            setState(() {
              _counter++;
            });
          },
          child: Ink(
            width: 100,
            height: 32,
            decoration: BoxDecoration(
                color: widget.backgroundColor,
                border: Border.all(color: widget.borderColor, width: 2.0),
                borderRadius: BorderRadius.circular(8)),
            child: const Center(child: Text('INCREMENT')),
          ),
        )
      ],
    );
  }
}

Тут варто зауважити пару моментів. Параметри можна прокинути через конструктор в клас State, але це не обовʼязково, і до полів класу Widget можна звертатися через поле widget. Також, коли ви працюєте з UI, для багатьох базових речей використовуються віджети.

Хочете додати padding? Вам потрібен віджет Padding. Хочете зробити щось клікабельним? Загорніть в InkWell + Ink (ця комбінація дає ripple-ефект) або в GestureDetector.

BoxDecoration використовується, щоби додати рамку (border) з заокругленими краями. Саме значення counter лежить в класі CounterWidgetState, ми можемо змінити його за допомогою метода setState. Це змінить сам стейт і наново викличе метод build.

Результат

Composable functions

Compose також розрізняє поняття stateful і stateless. В обох варіантах ми використовуємо функцію з анотацією @Composable. Різниця лиш в тому, чи ми запамʼятовуємо state за допомогою функції remember, чи ні.

@Composable
    fun Counter(backgroundColor: Color, borderColor: Color) {
        var counter by remember { mutableStateOf(0) }
        Column(
            modifier = Modifier.fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center,
        ) {
            Text(
                modifier = Modifier.padding(bottom = 16.dp),
                text = "Compose Multiplatform running on iOS"
            )
            Text(
                text = "You have pushed the button this many times:"
            )
            Text(
                "$counter", style = MaterialTheme.typography.h4
            )
            val shape = RoundedCornerShape(8.dp)
            Box(contentAlignment = Alignment.Center,
                modifier = Modifier.size(width = 120.dp, height = 32.dp)
                    .border(width = 2.0.dp, color = borderColor, shape = shape)
                    .background(color = backgroundColor, shape = shape)
                    .clip(shape = shape)
                    .clickable {
                        counter++
                    }
            ) {
                Text("INCREMENT")
            }
        }
    }

Цей код імплементує такий самий віджет, як і код на Flutter. Тут маємо декілька ключових відмінностей. Ми працюємо з одною Composable функцією, і щоби зробити її stateful, все, що нам треба, це рядок

var counter by remember { mutableStateOf(0) }

Далі ми можемо працювати з counter як зі звичайним полем: оновлюємо значення і відповідна частина UI перемальовується. Запамʼятовувати треба тільки immutable-обʼєкти, модифікація mutable-обʼєкта не призведе до рекомпозиції.

Структура віджета дуже подібна до того, що ми мали з Flutter, але тут деякі речі досягаються за допомогою модифікаторів, а не окремих віджетів. Зверніть увагу на Modifier.padding, Modifier.clickable. Рамка (border) також імплементована за допомогою Modifier.border.

Результат

Висновок

Хоча код для імплементації суттєво відрізняється, в основі обох фреймворків лежить схожа філософія. І так, на Flutter доводиться писати більшу кількість коду, але причина частково не в самому фреймворку, а в мові Dart.

Обидва є зручними для роботи, сучасними декларативними UI фреймворками, тут нічия:

Flutter — 4

Compose Multiplatform — 3


Комунікація з платформою

Коли мовиться про кросплатформну розробку, то рано чи пізно доведеться стикнутися з потребою комунікації з рідним API платформи. Погляньмо, які можливості для цього надає Compose Multiplatform і Flutter.

Flutter

Flutter має механізм platform channels, за допомогою якого можна обмінюватися примітивними типами даних з рідним кодом (Swift або Kotlin в нашому випадку). Механізм працює асинхронно, щоби не блокувати UI.

Додаємо channel до Flutter:

const platform = MethodChannel('samples.flutter.dev/platform');
Future<void> _getPlatform() async {
  final platformName = await platform.invokeMethod<String>('getPlatform') ?? '';
  setState(() {
    _platformName = platformName;
  });
}

Android

class MainActivity: FlutterActivity() {
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(
            flutterEngine.dartExecutor.binaryMessenger,
            "samples.flutter.dev/platform"
        ).setMethodCallHandler { call, result ->
            if (call.method == "getPlatform") {
                result.success(getPlatform())
            } else {
                result.notImplemented()
            }
        }
    }
    private fun getPlatform(): String {
        return "Android ${android.os.Build.VERSION.SDK_INT}"
    }
} 

iOS

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
      
  let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
      
      let platformChannel = FlutterMethodChannel(name: "samples.flutter.dev/platform",
                                                binaryMessenger: controller.binaryMessenger)
      platformChannel.setMethodCallHandler({
        (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
          guard call.method == "getPlatform" else {
              result(FlutterMethodNotImplemented)
              return
            }
            self.getPlatform(result: result)
      })
      
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
    
  private func getPlatform(result: FlutterResult) {
    let platform = UIDevice.current.systemName + " " + UIDevice.current.systemVersion
      result(platform)
  }
}

Механізм нескладний, свою роботу виконує, але, як ми бачимо, доводиться писати на трьох різних мовах.

Також Flutter надає можливість вбудовувати нативні iOS і Android вʼюшки. У зворотну сторону теж працює, і є можливість вбудовувати Flutter в наявні нативні iOS- і Android-проєкти.

Варто зазначити, що для багатьох випадків, де вам потрібно працювати з нативним кодом або нативними віджетами вже є створені офіційні або неофіційні бібліотеки.

До прикладу, ось Flutter widget для роботи з Google maps. А ось бібліотека для роботи з локальними сповіщеннями.

Compose Multiplatform

Основна перевага тут — це те, що ви можете комунікувати з рідним API за допомогою Kotlin. KMP надає механізм expect / actual, який є простим в використанні.

Наприклад, створюємо expect-клас в common-модулі:

// KMP Class Definition
expect class Platform() {
    val name: String
}
Actual класс в iOS модулі, який використовує api iOS:
// iOS
actual class Platform actual constructor() {
    actual val name: String =
        UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
} 
Actual класс в Android модулі, який використовує  android api: 
// Android
actual class Platform actual constructor() {
    actual val name: String = "Android ${android.os.Build.VERSION.SDK_INT}"
}

Це все, тепер ми можемо використовувати клас Platform як звичайний клас, конкретна імплементація буде автоматично вибрана залежно від платформи.

Також є інтерорп з objective-C.

Щодо використання Compose-віджетів разом з рідними віджетами, ситуація описана далі.

Android: більшість віджетів в Jetpack Compose і Compose Multiplatform ідентичні. Тут все просто, треба тільки модифікувати місця, де є Android-specific код. До прикладу, для доступу до ресурсів можна використовувати бібліотеку moko.

Compose Multiplatform також є сумісним з UIKit та SwiftUI

Висновок

Compose виграє, адже завдяки силі Kotlin Multiplatform ми можемо комунікувати з native стороною, використовуючи одну мову програмування.

Flutter — 4

Compose Multiplatform — 4


Tooling

Flutter. Робота з UI

Нема превʼю, тож міняємо код — дивимось на емулятор. Ця проблема частково компенсується тим, що Flutter має швидкий і добре працюючий hot reload. Тобто ми міняємо свій віджет, зберігаємо файл, і зміни тут таки відображаються на емуляторі.

Flutter. Профайлинг

Є доволі зручна тула, Flutter Dev Tools. Тут можемо дивитись на структуру UI, network calls, перфоманс застосунку. Запускається в браузері, тому це теж плюс, адже якщо ви захочете використовувати VS Code замість AS, то ви можете легко зробити це.

Flutter. Debugger

Він є, він працює. Нюанс в тому, що працює він просто жахливо. Довго підключається, глючить, перестрибує на рандомні рядки, зупиняється на видалених раніше брейкпойнтах. Якщо ваш застосунок працює добре, після підключення дебагера він може просто повиснути, або почати тупити.

Зачасту простіше додати якнайбільше логів, аніж підключати дебагер.

Compose Multiplatform. Робота з UI

Для Jetpack Compose є дуже крута фіча, анотація @Preview, яка дозволяє відразу в IDE бачити як віджет буде виглядати на різних екранах.

От тільки @Preview не працює з Compose Multiplatform, і її можна додавати тільки до composable функцій в Android-модулі. В теорії, можна мати самі composable-функції в common-модулі, а в Android-модулі додати прості обгортки з анотацією @Preview, але і з таким підходом наразі є issue.

Може, і тут допоможе hot reload?

Що ж, ні, на Android він ніби як є, але працює криво, і по суті треба однаково перезапускати activity, а на iOS такої можливості в принципі немає, і треба перезбирати і перезапускати застосунок кожен раз.

Дебагер працює на iOS і Android, але на великому проєкті я не перевіряв. На Android працює профайлер в Android Studio.

На iOS мали би працювати xcode instruments. Мені підключити не вдалось, але на презентації все вийшло.

Висновок

Поки розробляти під Flutter дещо зручніше, але це з часом може змінитися на користь Compose Multiplatform.

Flutter — 5

Compose Multiplatform — 4


Зрілість

Flutter 1.0 зʼявився в далекому 2018 році. За цей час і сам фреймворк, і мова Dart стабілізувались і обросли функціоналом, і зараз їх можна спокійно рекомендувати до використання в великих production-застосунках.

Є історії успіху, з якими можна ознайомитись на цій сторінці. Також є велике комʼюніті, яке завжди допоможе з проблемами і питаннями. Велика кількість сторонніх бібліотек — це ще один плюс.

Compose Multiplatform мені сподобався, адже я дуже люблю Compose і Kotlin. Але треба визнати, що для мобільної кросплатформної розробки він ще поки занадто сирий, і про це відкрито говорять самі розробники. Ось скриншот з презентації від травня 2023 року:

Тут маємо плюс бал для Flutter:

Flutter — 6

Compose Multiplatform — 4


Фінальний результат

Flutter — 6

Compose Multiplatform — 4

Flutter має свої недоліки, але це доросла технологія, готова до використання, яку можна сміливо вибирати для мобільної кросплатформної розробки.

Compose Multiplatform — технологія дуже перспективна, і Google вже працюють над підтримкою Compose Multiplatform для Jetpack. За декілька років Compose Multiplatform може стати сильним конкурентом для Flutter, що дещо дивно, адже обидва частково або повністю розробляються компанією Google. Можливо, на цвинтарі продуктів Google зʼявиться новачок.

Але зараз Compose Multiplatform я би став використовувати хіба для експериментів або pet-проєктів.

👍ПодобаєтьсяСподобалось17
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Після офіційного оголошення Google про підтримку Kotlin Multiplatform та інтеграції мультиплатформенності в деякі ключові бібліотеки, можна сказати, що наразі результат як мінімум — нічия. Хоча платформі поки що не вистачає «зрілості», це лише питання часу, коли вона перестане сприйматися як експеримент і стане звичним інструментом для створення мультиплатформених додатків.

Такий розвиток подій є цілком можливим.

Абсолютно закономірний результат, який зрозумілий одразу після «Compose Multiplatform — iOS (Alpha)». Дякую за цікавий матеріал, єдине що ще можна було б додати так це приклади додатків в проді.

додатків де повністю код на Kotlin Multiplatform IOS та Android в продашині дуже мало. Дивився стрім JetBrains там вони самі казали, що здивувалися що такі додатки взагалі існують вже) і потім був стрім з тими розробниками

А можеш той стрім пошарити ? Цікаво подивитися)

Писав минулого року додаток на RN, плювався, а потiм перейшов на Flutter i це просто кайф.

Дякую, досить цікава стаття) Як би ще туди третім прикладом для порівняння RN додати, було б круто)

Дякую. Колись давно пробував RN, але він мені щось геть не сподобався, на відміну від цих двох технологій.

Фінальний результат
Flutter — 6

Я так i знав)

Підписатись на коментарі