Будучи Flutter-разработчиками, мы часто сталкиваемся с необходимостью написания кода, ориентированного на конкретную платформу. Хоть Flutter и предоставляет полноценный фреймворк для создания кроссплатформенных приложений, интеграция нативного функционала иногда может быть весьма обременительной. Именно здесь на помощь приходит Kotlin Multiplatform (KMP). На мой взгляд, KMP — это не просто инструмент, который конкурирует с Flutter, скорее, он предлагает мощный способ рационализировать разработку плагинов, позволяя разработчикам экономить время, беречь силы и писать эффективный, легко сопровождаемый код.

В этой статье я поделюсь своим опытом использования KMP для создания библиотеки общих настроек (Shared Preferences) для Flutter под названием SharedPrefsKMP. Эта библиотека упрощает управление общими настройками в Android и iOS, демонстрируя, как KMP может улучшить процесс разработки на Flutter.

Почему именно Kotlin Multiplatform?

Kotlin Multiplatform позволяет разработчикам писать общий код, который можно запускать на нескольких платформах, включая Android и iOS. Используя KMP, Flutter-разработчики могут создавать плагины с общей бизнес-логикой, сокращать избыточность и поддерживать единую кодовую базу для определенных функций. Это, в свою очередь, сокращает время, необходимое на написание и отладку кода, что позволяет сосредоточиться на создании исключительного пользовательского опыта.

Шаг 1: Создаем KMP-библиотеку

  • Откройте Android Studio: Запустите Android Studio на своем компьютере. Убедитесь, что у вас установлена последняя версия, чтобы получить доступ ко всем возможностям Kotlin Multiplatform.

  • Создайте новый проект:

  • Выберете на начальном экране “New Project”.

  • В окне выбора шаблона проекта выберите "Kotlin Multiplatform Library". Если эта опция не отображается, вам может потребоваться установить плагин Kotlin Multiplatform из магазина плагинов.

Шаг 2: Выбираем IOS-фреймворк

  • После выбора KMP-библиотеки нам нужно выбрать IOS-фреймворк.

  • Нажмите на IOS Framwork Distribution и выберите XCFramework.

Для чего нам нужен iOS-фреймворк?

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

В контексте Kotlin Multiplatform iOS-фреймворк позволяет разработчикам создавать общий код, к которому могут обращаться приложения на Swift и Objective-C, что способствует повторному использованию кода и сокращает количество кода, специфичного для конкретной платформы.

Почему стоит выбрать именно XCFramework?

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

Поддержка кроссплатформенности: XCFrameworks может объединять двоичные файлы для нескольких платформ (iOS, macOS, tvOS, watchOS) и архитектур (arm64, x86_64) в один пакет. Это упрощает распространение программного продукта, поскольку разработчикам не нужно создавать заморачиваться с разными файлами фреймворков для разных целевых платформ.

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

Задел на будущее: Используя XCFrameworks, разработчики могут гарантировать совместимость с будущими версиями Xcode и iOS, поскольку Apple продолжает совершенствовать свои стратегии распространения фреймворков. XCFrameworks разработаны таким образом, чтобы изменения в базовых системах сборки и упаковки его не затрагивали.

Повышенная производительность сборки: XCFrameworks позволяет ускорить сборку, поскольку Xcode может напрямую связать нужную архитектуру без необходимости собирать или управлять несколькими копиями одного и того же фреймворка.

Оптимизированное управление версиями: При использовании XCFrameworks управление версиями и обновлениями становится проще, поскольку все необходимые двоичные файлы включены в один пакет, что снижает сложность управления зависимостями.

Теперь, после завершения создания kmp-библиотеки, нам нужно дождаться, когда gradle завершит синхронизацию.

Наборы исходников в Kotlin Multiplatform

В процессе создания KMP-библиотеки вы столкнетесь с определенными папками, называемыми source sets (наборы исходников). Эти наборы исходников очень важны для организации вашего кода для разных платформ, позволяя вам совместно использовать логику, реализуя при этом специфическую для каждой платформы функциональность. В вашем проекте вы найдете три основных набора исходников:

  1. commonMain

  2. androidMain

  3. iosMain

Что из себя представляют наборы исходников?

Наборы исходников — это каталоги, содержащие код Kotlin для разных платформ. Набор исходников commonMain — это место, куда помещается код, который можно использовать на всех платформах. Наборы исходников androidMain и iosMain предназначены для специфических реализаций, позволяющих использовать нативные API и функции, уникальные для Android и iOS.

Подготовка вашего проекта

Прежде чем приступать к самому коду, неплохо бы очистить изначальную заготовку, предоставляемую шаблоном проекта Kotlin Multiplatform. Например, мы можем найти такие классы, как Project и Greetings, которые нам не понадобятся для нашей библиотеки общих настроек. Вот что нам нужно сделать:

  1. Удалите ненужные классы: Откройте проект и перейдите к набору исходников commonMain. Удалите файлы Project.kt и Greetings.kt (или любые другие классы, которые не относятся к вашей библиотеке).

2. Организуйте свой код: Сосредоточьтесь на реализации необходимых интерфейсов и классов в папках commonMain, androidMain и iosMain.

Создание Expect-класса в commonMain

Внутри набора commonMain необходимо создать expect-класс для функционала общих настроек. Это важная часть использования Kotlin Multiplatform и позволяет определить интерфейс или функционал, который будет реализована по-разному на каждой платформе.

Что здесь означает слово "Expect"?

В Kotlin Multiplatform ключевое слово expect используется для объявления в общем коде класса, функции или свойства, которые будут иметь специфические для каждой платформы реализации. Таким образом можно определить общий интерфейс, который будет использоваться на всех платформах (например, Android и iOS), но при этом каждая платформа сможет предоставить свою уникальную реализацию.

Зачем нужно использовать Expect?

  1. Совместное использование кода: Expect-класс позволяет записывать основную логику в набор commonMain, гарантируя, что один и тот же функционал может быть использован как на Android, так и на iOS без дублирования.

  2. Реализации для конкретных платформ: Хоть интерфейс остается неизменным, вы можете по-разному реализовать детали того, как общие настройки будут работать для каждой платформы, в соответствующих наборах исходников androidMain и iosMain.

  3. Гибкость и сопровождаемость: Если вам нужно внести изменения или добавить функции, вы можете легко модифицировать общий интерфейс или специфические для платформы реализации, не затрагивая всю кодовую базу.

Создаем Expect-класс 

  1. Создайте новый файл Kotlin: Создайте в наборе исходников commonMain новый файл Kotlin под названием SharedPrefs.kt.

  2. Определите Expect-класс: Объявите в этом файле expect-класс для общих настроек. Вот простой пример:

package com.mohaberabi.sharedprefskmp

expect class SharedPrefs {
    fun getString(key: String): String?
    fun setString(key: String, value: String)
    fun getInt(key: String, default: Int): Int?
    fun setInt(key: String, value: Int)
    fun getBoolean(key: String, default: Boolean): Boolean?
    fun setBoolean(key: String, value: Boolean)
    fun remove(key: String)
    fun clear()
    fun getDouble(key: String, default: Double): Double

    fun setDouble(key: String, value: Double)

    fun getStringList(key: String): List<String>?
    fun setStringList(key: String, values: List<String>)
    fun contains(key: String): Boolean
}

Реализация Android-части 

Теперь, когда мы определили expect-класс для общих настроек в наборе исходников commonMain, мы начнем создавать реальный класс SharedPrefs для Android. В этой реализации мы воспользуемся встроенной функцией SharedPreferences, предоставляемой Android, которая позволяет нам сохранять простые типы данных.

Использование класса SharedPreferences в Android является простым и эффективным, что делает его отличным выбором для работы с пользовательскими настройками и настройками приложений. Давайте рассмотрим особенности реализации этого класса для Android и убедимся, что он соответствует ожиданиям, заложенным в наш общий код.

Реализация класса SharedPrefs для Android

  1. Перейдите к набору исходников Android: Откройте проект Kotlin Multiplatform в Android Studio. В окне проекта найдите набор исходников androidMain, который обычно находится в каталоге src/androidMain/kotlin/com/yourpackage/.

  2. Создайте класс SharedPrefs: Создайте внутри каталога androidMain новый Kotlin-файл под названием SharedPrefs.kt. Этот файл будет содержать фактическую реализацию класса SharedPrefs для Android.

  3. Реализуйте класс SharedPrefs: Откройте файл SharedPrefs.kt и напишите свой класс SharedPrefs. Например, вы можете использовать этот код:

package com.mohaberabi.sharedprefskmp

import android.content.Context

actual class SharedPrefs(
    private val context: Context
) {


    private val prefs by lazy {
        context.applicationContext.getSharedPreferences("kmpPrefs", Context.MODE_PRIVATE)
    }

    actual fun getString(key: String): String? = prefs.getString(key, null)

    actual fun setString(
        key: String,
        value: String
    ) = prefs.edit().putString(key, value).apply()

    actual fun getInt(key: String, default: Int): Int? = prefs.getInt(key, default)
    actual fun setInt(key: String, value: Int) = prefs.edit().putInt(key, value).apply()
    actual fun getBoolean(key: String, default: Boolean): Boolean? = prefs.getBoolean(key, default)
    actual fun setBoolean(key: String, value: Boolean) = prefs.edit().putBoolean(key, value).apply()


    actual fun remove(key: String) = prefs.edit().remove(key).apply()

    actual fun clear() = prefs.edit().clear().apply()
    actual fun getDouble(key: String, default: Double): Double {
        val value = prefs.getString(key, null)
        return value?.toDoubleOrNull() ?: default
    }

    actual fun setDouble(key: String, value: Double) {
        prefs.edit().putString(key, value.toString()).apply()
    }

    actual fun getStringList(key: String): List<String>? {
        val stringSet = prefs.getStringSet(key, null)
        return stringSet?.toList()
    }

    actual fun setStringList(
        key: String,
        values: List<String>
    ) {
        prefs.edit().putStringSet(key, values.toSet()).apply()
    }

    actual fun contains(key: String): Boolean = prefs.contains(key)
}

Реализация iOS-части: UserDefaults и взаимодействие с Kotlin

Теперь, когда мы реализовали Android-часть нашего класса SharedPrefs, пришло время сосредоточиться на реализации для iOS. В iOS мы воспользуемся классом UserDefaults, который обеспечивает простой способ сохранения пар ключ-значение при запуске приложения. UserDefaults обычно используется для хранения пользовательских и других настроек, что делает его подходящим выбором для нашей SharedPrefs.

Пара слов о UserDefaults

UserDefaults — это встроенный класс в iOS, который позволяет разработчикам хранить небольшие объемы данных в. Он использует механизм хранения пар «ключ‑значение», что позволяет легко получать и хранить простые типы данных, такие как строки, целые числа, логические значения и массивы.

Взаимодействие Kotlin и iOS

Одной из сильных сторон Kotlin Multiplatform является его способность взаимодействовать с существующим кодом iOS. При использовании KMP Kotlin может легко вызывать код на Swift и Objective-C, что означает, что мы можем использовать весь функционал, доступный нам в iOS SDK.

  • Kotlin в Objective-C: Код Kotlin может быть скомпилирован в формат, совместимый с Objective-C, что позволит вам вызывать функции Kotlin из Objective-C классов.

  • Objective-C в Swift: Objective-C долгое время был основой для разработки iOS. С появлением Swift компания Apple позаботилась о том, чтобы Objective-C и Swift могли работать вместе. Это означает, что любой Objective-C код может быть легко вызван из Swift.

Таким образом, если в iOS SDK есть какой-либо код или фреймворк, вы можете легко вызвать его в KMP-коде. Эта возможность гарантирует, что вы сможете использовать специфические для платформы функции, не теряя при этом преимуществ общего кода.

В нашем случае мы создадим iOS-реализацию класса SharedPrefs с помощью UserDefaults, что позволит нам в полной мере воспользоваться преимуществами совместимости Kotlin со Swift и Objective-C. Благодаря такому подходу мы можем поддерживать единую кодовую базу, гарантируя, что наше приложение будет вести себя так, как мы ожидаем на платформах Android и iOS.

Хочу отметить, что при реализации iOS-части наше внимание будет сосредоточено на создании бесшовного опыта, использующего лучшие практики разработки Kotlin и iOS.

Реализация iOS-части

  1. Откройте каталог iosMain: В проекте Kotlin Multiplatform перейдите в каталог iosMain.

  2. Создайте класс SharedPrefs: Создайте в каталоге iosMain новый Kotlin-файл под названием SharedPrefs.kt.

  3. Реализуйте класс SharedPrefs: Напишите код для класса SharedPrefs, используя UserDefaults:

package com.mohaberabi.sharedprefskmp


import platform.Foundation.NSUserDefaults

actual class SharedPrefs {


    companion object {
        private const val DOMAIN_NAME = "kmpPrefs"
    }

    private val prefs by lazy {
        NSUserDefaults.standardUserDefaults().apply {
            persistentDomainForName(DOMAIN_NAME)
        }
    }

    actual fun getString(key: String): String? = prefs.stringForKey(key)

    actual fun setString(key: String, value: String) {
        prefs.setObject(value, forKey = key)
        prefs.synchronize()
    }

    actual fun getInt(key: String, default: Int): Int? = prefs.integerForKey(key).toInt()

    actual fun setInt(key: String, value: Int) {
        prefs.setInteger(value.toLong(), forKey = key)
        prefs.synchronize()
    }

    actual fun getBoolean(key: String, default: Boolean): Boolean? {
        return try {
            prefs.boolForKey(key)
        } catch (e: Exception) {
            default
        }
    }

    actual fun setBoolean(key: String, value: Boolean) {
        prefs.setBool(value, forKey = key)
        prefs.synchronize()
    }

    actual fun remove(key: String) {
        prefs.removeObjectForKey(key)
        prefs.synchronize()
    }

    actual fun clear() {

        prefs.removePersistentDomainForName(DOMAIN_NAME)
        prefs.synchronize()
    }

    actual fun getDouble(key: String, default: Double): Double {

        return try {
            prefs.doubleForKey(key)
        } catch (e: Exception) {
            default
        }
    }

    actual fun setDouble(key: String, value: Double) {
        prefs.setDouble(value, forKey = key)
        prefs.synchronize()
    }

    actual fun getStringList(key: String): List<String>? {
        val array = prefs.arrayForKey(key) as? List<String>
        return array ?: emptyList()
    }

    actual fun setStringList(key: String, values: List<String>) {
        prefs.setObject(values, forKey = key)
        prefs.synchronize()
    }

    actual fun contains(key: String): Boolean = prefs.objectForKey(key) != null
}

Релиз артефактов для Android и iOS

Чтобы упростить процесс релиза нашей KMP-библиотеки, мы сгенерируем локальный AAR (Android Archive) и XCFramework для iOS. Выполните следующие шаги:

1. Создайте AAR артефакт для Android 

  • Откройте свой проект в Android Studio.

  • Перейдите в каталог android вашего KMP-проекта.

  • Откройте файл build.gradle.kts.

  • Убедитесь, что плагин kotlin-multiplatform применен и настроен на включение общего модуля.

  • Используйте следующую команду Gradle в терминале

./gradlew assembleRelease

После этого мы найдем релиз android aar для нашей библиотеки здесь: 

shared/build/outputs/aar/shared-release.aar 

2. Создание XCFramework для iOS

  • Откройте свой проект в Xcode.

  • Выберите таргет для вашей мультиплатформенной библиотеки Kotlin.

  • В настройках сборки убедитесь, что для фреймворка iOS заданы правильные конфигурации.

  • Используйте следующую команду в терминале, чтобы сгенерировать XCFramework

./gradlew assembleXCFramework

Вы найдете фреймворк для ios внутри:
shared/build/XCFrameowk/release/sahred.xcframework

Теперь, после того как мы закончили работу над KMP-частью нам нужно создать flutter-приложение, чтобы использовать библиотеку, которую мы только что создали.

Перейдите в android studio или другую IDE по вашему выбору и создайте flutter-приложение. Сделайте так, чтобы оно поддерживало ios и android, поскольку сейчас наша библиотека предназначена только для ios и android.

После создания flutter-приложения вам нужно будет открыть android-модуль flutter-приложения в android studio. Для этого откройте в android studio проект, перейдя в каталог flutter-приложения и выбрав android. Вам придется подождать некоторое время, пока gradle android studio не закончит синхронизацию.

После этого откройте модуль .app, а затем build.gradle.

Сначала нам нужно изменить minsdk, так как созданная нами библиотека предназначена только для minsdk 24.

Теперь нам нужно создать новую папку под android/app/libs

Здесь будет .aar, который мы только что создали.

1. Создайте новый каталог для AAR

  • Перейдите в каталог проекта Android.

  • Перейдите в каталог app  (например, android/app).

  • Создайте новый каталог под названием libs, если он еще не существует.

2. Скопируйте AAR-файл 

  • Найдите AAR-файл, который вы сгенерировали из KMP-библиотеки. Он должен находиться в папке android/build/outputs/aar/.

  • Скопируйте .aar-файл из этого каталога.

3. Вставьте AAR-файл 

  • Вставьте скопированный AAR-файл в только что созданную директорию libs (android/app/libs).

4. Обновите build.gradle, чтобы включить в него AAR

  • Откройте файл build.gradle для вашего модуля приложения Android (находится в android/app/build.gradle).

  • Добавьте следующую строку в блок dependencies, чтобы включить AAR-файл:

Теперь нам нужно использовать этот .aar в android. Поэтому в том же app/build.gradle создайте новый блок dependecies {}, чтобы включить aar-библиотеку в качестве локальной зависимости в наше flutter-приложение под android:

dependencies {
    implementation(files("libs/shared-release.aar"))
}
defaultConfig {
        // TODO: Укажите свой собственный уникальный идентификатор приложения 
        applicationId = "com.mohaberabi.fluttersharedprefskmp.shraed_prefskmp"
        // Вы можете изменить следующие значения в соответствии с требованиями вашего приложения.

       // Для получения дополнительной информации см.: https://flutter.dev/to/review-gradle-config.
        minSdk = 24
        targetSdk = flutter.targetSdkVersion
        versionCode = flutter.versionCode
        versionName = flutter.versionName
    }

    buildTypes {
        release {
            // TODO: Добавьте собственную конфигурацию подписи для сборки релиза.
            // Пока что подписываемся отладочными ключами, чтобы `flutter run --release` работал.
            signingConfig = signingConfigs.debug
        }
    }
}

dependencies {
    implementation(files("libs/shared-release.aar"))
}

flutter {
    source = "../.."
}

Теперь нам нужно задействовать его в файле MainActivity.kt Android-части flutter-приложения.

Перейдите к файлу app/src/main/kotlint/MainActivity.kt.

Нам нужно переопределить метод configureFlutterEngine из FlutterActivity, чтобы мы могли создать flutter-канал для связи с ним и нашим flutter-приложением: 

package com.mohaberabi.fluttersharedprefskmp.shraed_prefskmp

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import com.mohaberabi.sharedprefskmp.SharedPrefs

class MainActivity : FlutterActivity() {

    companion object {
        private const val CHANNEL_NAME = "com.mohaberabi.fluttersharedprefs.kmp"
        private const val GET_STRING = "getString"
        private const val SET_STRING = "setString"
        private const val GET_INT = "getInt"
        private const val SET_INT = "setInt"
        private const val GET_BOOLEAN = "getBoolean"
        private const val SET_BOOLEAN = "setBoolean"
        private const val REMOVE = "remove"
        private const val CLEAR = "clear"
        private const val GET_STRING_LIST = "getStringList"
        private const val SET_STRING_LIST = "setStringList"
        private const val CONTAINS = "contains"
    }

    private lateinit var sharedPrefs: SharedPrefs

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        sharedPrefs = SharedPrefs(applicationContext)
        MethodChannel(
            flutterEngine.dartExecutor.binaryMessenger,
            CHANNEL_NAME
        ).setMethodCallHandler { call, result ->
            when (call.method) {
                GET_STRING -> {
                    val key = call.argument<String>("key") ?: ""
                    val value = sharedPrefs.getString(key)
                    result.success(value)
                }

                SET_STRING -> {
                    val key = call.argument<String>("key") ?: ""
                    val value = call.argument<String>("value") ?: ""
                    sharedPrefs.setString(key, value)
                    result.success(null)
                }

                GET_INT -> {
                    val key = call.argument<String>("key") ?: ""
                    val default = call.argument<Int>("default") ?: 0
                    val value = sharedPrefs.getInt(key, default)
                    result.success(value)
                }

                SET_INT -> {
                    val key = call.argument<String>("key") ?: ""
                    val value = call.argument<Int>("value") ?: 0
                    sharedPrefs.setInt(key, value)
                    result.success(null)
                }

                GET_BOOLEAN -> {
                    val key = call.argument<String>("key") ?: ""
                    val default = call.argument<Boolean>("default") ?: false
                    val value = sharedPrefs.getBoolean(key, default)
                    result.success(value)
                }

                SET_BOOLEAN -> {
                    val key = call.argument<String>("key") ?: ""
                    val value = call.argument<Boolean>("value") ?: false
                    sharedPrefs.setBoolean(key, value)
                    result.success(null)
                }

                REMOVE -> {
                    val key = call.argument<String>("key") ?: ""
                    sharedPrefs.remove(key)
                    result.success(null)
                }

                CLEAR -> {
                    sharedPrefs.clear()
                    result.success(null)
                }

                GET_STRING_LIST -> {
                    val key = call.argument<String>("key") ?: ""
                    val value = sharedPrefs.getStringList(key)
                    result.success(value)
                }

                SET_STRING_LIST -> {
                    val key = call.argument<String>("key") ?: ""
                    val values = call.argument<List<String>>("values") ?: emptyList()
                    sharedPrefs.setStringList(key, values)
                    result.success(null)
                }

                CONTAINS -> {
                    val key = call.argument<String>("key") ?: ""
                    val exists = sharedPrefs.contains(key)
                    result.success(exists)
                }

                else -> {
                    result.notImplemented()
                }
            }
        }
    }
}
  • Название и методы канала:

  • Константа CHANNEL_NAME используется для установления канала связи между Flutter и нативным кодом.

  • Здесь объявлено несколько констант, представляющих имена методов (GET_STRING, SET_STRING и т.д.), соответствующих операциям, которые можно выполнять с общими настройками.

  • Экземпляр SharedPrefs:

  • Приватная переменная sharedPrefs объявлена для хранения экземпляра класса SharedPrefs (из KMP-библиотеки). Этот экземпляр используется для взаимодействия с общими настройками.

  • Метод configureFlutterEngine:

  • Этот метод переопределяет поведение по умолчанию для настройки движка Flutter.

  • Экземпляр sharedPrefs инициализируется контекстом приложения, чтобы обеспечить доступ к общим настройкам.

  • Канал MethodChannel нужен для прослушивания вызовов методов из Flutter.

  • Обработчик вызова метода:

  • Внутри setMethodCallHandler используется оператор when для реагирования на различные вызовы методов из Flutter.

  • Каждому случаю соответствует операция с общими настройками, например:

  • Get и Set String: Получение или сохранение строкового значения.

  • Get и Set Int: Получение или сохранение целочисленного значения с возможностью выбора значения по умолчанию.

  • Get и Set Boolean: Получение или сохранение булевого значения, в том числе по умолчанию.

  • Remove и Clear: удаление конкретного ключа или очистка всех настроек.

  • Get и Set String List: Работа со списками строк.

  • Contains: Проверьте, существует ли определенный ключ в общих настройках.

  • Результаты обработки:

  • Для каждой операции результат отправляется обратно во Flutter с помощью result.success() или result.notImplemented(), если метод не распознан.

Реализация iOS-части KMP-библиотеки общих настроек Flutter 

Чтобы интегрировать KMP-библиотеку общих настроек в ваш Flutter-проект для iOS, выполните следующие простые шаги для добавления локального фреймворка в Xcode:

Откройте проект iOS в Xcode:

  • Перейдите в каталог вашего Flutter-проекта.

  • Откройте папку ios и найдите файл .xcworkspace (не файл .xcodeproj).

  • Дважды кликните по файлу .xcworkspace, чтобы запустить Xcode.

Выберите таргет

  • В навигаторе проектов Xcode найдите и выберите название вашего проекта в верхней части левой боковой панели.

  • В главном окне вы увидите настройки проекта. Кликните на таргете вашего приложения (обычно она называется так же, как и ваш проект).

Зайдите в раздел Frameworks:

  • Выбрав таргет, найдите вкладку "Build Phases" в главном окне настроек проекта.

  • Прокрутите вниз до раздела "Link Binary With Libraries".

Добавьте локальный фреймворк:

  • Нажмите на кнопку "+" в нижней части раздела "Link Binary With Libraries". Откроется диалог для добавления фреймворков и библиотек.

  • В поле поиска введите имя локального фреймворка, который вы хотите добавить (например, имя вашего KMP-фреймворка общих настроек).

  • Если фреймворк не указан в списке, нажмите "Add Other...", затем перейдите к месту, где хранится.framework-файл (обычно в каталоге build KMP-библиотеки).

  • Выберите фреймворк и нажмите "Open".

Таким образом библиотека будет добавлена в наше flutter-приложение под ios в качестве фреймворка.
Теперь нам нужно перейти к файлу Runner/AppDelegate.swift.
Сначала импортируем shared в верхней части файла:

import Flutter
import UIKit
import shared
import Flutter
import UIKit
import shared

@main
@objc class AppDelegate: FlutterAppDelegate {

    private let channelName = "com.mohaberabi.fluttersharedprefs.kmp"
    private var sharedPrefs: SharedPrefs?

    struct MethodNames {
        static let getString = "getString"
        static let setString = "setString"
        static let getInt = "getInt"
        static let setInt = "setInt"
        static let getBoolean = "getBoolean"
        static let setBoolean = "setBoolean"
        static let remove = "remove"
        static let clear = "clear"
        static let getStringList = "getStringList"
        static let setStringList = "setStringList"
        static let contains = "contains"
    }

    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
        let methodChannel = FlutterMethodChannel(name: channelName, binaryMessenger: controller.binaryMessenger)

        methodChannel.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in
            guard let self = self else { return }

            if self.sharedPrefs == nil {
                self.sharedPrefs = SharedPrefs()
            }

            switch call.method {
            case MethodNames.getString:
                self.handleGetString(call: call, result: result)

            case MethodNames.setString:
                self.handleSetString(call: call, result: result)

            case MethodNames.getInt:
                self.handleGetInt(call: call, result: result)

            case MethodNames.setInt:
                self.handleSetInt(call: call, result: result)

            case MethodNames.getBoolean:
                self.handleGetBoolean(call: call, result: result)

            case MethodNames.setBoolean:
                self.handleSetBoolean(call: call, result: result)

            case MethodNames.remove:
                self.handleRemove(call: call, result: result)

            case MethodNames.clear:
                self.handleClear(result: result)

            case MethodNames.getStringList:
                self.handleGetStringList(call: call, result: result)

            case MethodNames.setStringList:
                self.handleSetStringList(call: call, result: result)

            case MethodNames.contains:
                self.handleContains(call: call, result: result)

            default:
                result(FlutterMethodNotImplemented)
            }
        }

        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }

    private func handleGetString(call: FlutterMethodCall, result: @escaping FlutterResult) {
        if let args = call.arguments as? [String: Any], let key = args["key"] as? String {
            let value = sharedPrefs?.getString(key: key)
            result(value)
        } else {
            result(FlutterError(code: "INVALID_ARGUMENT", message: "Key is required", details: nil))
        }
    }

    private func handleSetString(call: FlutterMethodCall, result: @escaping FlutterResult) {
        if let args = call.arguments as? [String: Any],
           let key = args["key"] as? String,
           let value = args["value"] as? String {
            sharedPrefs?.setString(key: key, value: value)
            result(nil)
        } else {
            result(FlutterError(code: "INVALID_ARGUMENT", message: "Key and value are required", details: nil))
        }
    }
    private func handleGetInt(call: FlutterMethodCall, result: @escaping FlutterResult) {
        if let args = call.arguments as? [String: Any], let key = args["key"] as? String {
            let defaultValue = Int32(args["default"] as? Int ?? 0)
            let value = sharedPrefs?.getInt(key: key, default: defaultValue)
            result(value)
        } else {
            result(FlutterError(code: "INVALID_ARGUMENT", message: "Key is required", details: nil))
        }
    }

    private func handleSetInt(call: FlutterMethodCall, result: @escaping FlutterResult) {
        if let args = call.arguments as? [String: Any],
           let key = args["key"] as? String,
           let value = args["value"] as? Int {
            sharedPrefs?.setInt(key: key, value: Int32(value))
            result(nil)
        } else {
            result(FlutterError(code: "INVALID_ARGUMENT", message: "Key and value are required", details: nil))
        }
    }


    private func handleGetBoolean(call: FlutterMethodCall, result: @escaping FlutterResult) {
        if let args = call.arguments as? [String: Any], let key = args["key"] as? String {
            let defaultValue = args["default"] as? Bool ?? false
            let value = sharedPrefs?.getBoolean(key: key, default: defaultValue)
            result(value)
        } else {
            result(FlutterError(code: "INVALID_ARGUMENT", message: "Key is required", details: nil))
        }
    }

    private func handleSetBoolean(call: FlutterMethodCall, result: @escaping FlutterResult) {
        if let args = call.arguments as? [String: Any],
           let key = args["key"] as? String,
           let value = args["value"] as? Bool {
            sharedPrefs?.setBoolean(key: key, value: value)
            result(nil)
        } else {
            result(FlutterError(code: "INVALID_ARGUMENT", message: "Key and value are required", details: nil))
        }
    }

    private func handleRemove(call: FlutterMethodCall, result: @escaping FlutterResult) {
        if let args = call.arguments as? [String: Any], let key = args["key"] as? String {
            sharedPrefs?.remove(key: key)
            result(nil)
        } else {
            result(FlutterError(code: "INVALID_ARGUMENT", message: "Key is required", details: nil))
        }
    }

    private func handleClear(result: @escaping FlutterResult) {
        sharedPrefs?.clear()
        result(nil)
    }

    private func handleGetStringList(call: FlutterMethodCall, result: @escaping FlutterResult) {
        if let args = call.arguments as? [String: Any], let key = args["key"] as? String {
            let value = sharedPrefs?.getStringList(key: key)
            result(value)
        } else {
            result(FlutterError(code: "INVALID_ARGUMENT", message: "Key is required", details: nil))
        }
    }

    private func handleSetStringList(call: FlutterMethodCall, result: @escaping FlutterResult) {
        if let args = call.arguments as? [String: Any],
           let key = args["key"] as? String,
           let values = args["values"] as? [String] {
            sharedPrefs?.setStringList(key: key, values: values)
            result(nil)
        } else {
            result(FlutterError(code: "INVALID_ARGUMENT", message: "Key and values are required", details: nil))
        }
    }

    private func handleContains(call: FlutterMethodCall, result: @escaping FlutterResult) {
        if let args = call.arguments as? [String: Any], let key = args["key"] as? String {
            let exists = sharedPrefs?.contains(key: key)
            result(exists)
        } else {
            result(FlutterError(code: "INVALID_ARGUMENT", message: "Key is required", details: nil))
        }
    }
}

Мы закончили с нативными частями, и теперь нам нужно создать класс вызова fltuter-канала.
Закройте xcode и все инстансы IDE, кроме flutter-приложения, и давайте пока сосредоточимся на flutter-части.

abstract interface class PrefsParams {
  static const String key = "key";
  static const String value = "value";
  static const String defaultt = "default";
  static const String values = "values";
}

abstract interface class PrefsConst {
  static const MethodChannel channel =
      MethodChannel('com.mohaberabi.fluttersharedprefs.kmp');
  static const String getString = "getString";
  static const String setString = "setString";
  static const String getInt = "getInt";
  static const String setInt = "setInt";
  static const String getBoolean = "getBoolean";
  static const String setBoolean = "setBoolean";
  static const String remove = "remove";
  static const String clear = "clear";
  static const String getStringList = "getStringList";
  static const String setStringList = "setStringList";
  static const String contains = "contains";
}

В папке lib flutter-приложения создайте новый .dart-файл под названием shared_prefs_param.dart, который будет содержать ключи params для канала и вызовов методов:

import 'package:shraed_prefskmp/shared_prefs_params.dart';

class SharedPrefs {
  Future<String?> getString(String key) async {
    final String? value = await PrefsConst.channel.invokeMethod(
      PrefsConst.getString,
      {PrefsParams.key: key},
    );
    return value;
  }

  Future<void> setString(String key, String value) async {
    await PrefsConst.channel.invokeMethod(
      PrefsConst.setString,
      {PrefsParams.key: key, PrefsParams.value: value},
    );
  }

  Future<int?> getInt(String key, {int defaultValue = 0}) async {
    final int? value = await PrefsConst.channel.invokeMethod(
      PrefsConst.getInt,
      {PrefsParams.key: key, PrefsParams.defaultt: defaultValue},
    );
    return value;
  }

  Future<void> setInt(String key, int value) async {
    await PrefsConst.channel.invokeMethod(
      PrefsConst.setInt,
      {PrefsParams.key: key, PrefsParams.value: value},
    );
  }

  Future<bool?> getBoolean(String key, {bool defaultValue = false}) async {
    final value = await PrefsConst.channel.invokeMethod(
      PrefsConst.getBoolean,
      {PrefsParams.key: key, PrefsParams.defaultt: defaultValue},
    );
    if (value == null) {
      return null;
    } else {
      if (value is bool) {
        return value;
      } else if (value is int) {
        if (value == 0) {
          return false;
        } else {
          return true;
        }
      }
    }
    return value;
  }

  Future<void> setBoolean(String key, bool value) async {
    await PrefsConst.channel.invokeMethod(
      PrefsConst.setBoolean,
      {PrefsParams.key: key, PrefsParams.value: value},
    );
  }

  Future<void> remove(String key) async {
    await PrefsConst.channel.invokeMethod(
      PrefsConst.remove,
      {PrefsParams.key: key},
    );
  }

  Future<void> clear() async {
    await PrefsConst.channel.invokeMethod(PrefsConst.clear);
  }

  Future<List<String>?> getStringList(String key) async {
    final List<Object?>? value = await PrefsConst.channel.invokeMethod(
      PrefsConst.getStringList,
      {PrefsParams.key: key},
    );
    return value?.cast<String>();
  }

  Future<void> setStringList(String key, List<String> values) async {
    await PrefsConst.channel.invokeMethod(
      PrefsConst.setStringList,
      {PrefsParams.key: key, PrefsParams.values: values},
    );
  }

  Future<bool> contains(String key) async {
    final bool exists = await PrefsConst.channel.invokeMethod(
      PrefsConst.contains,
      {PrefsParams.key: key},
    );
    return exists;
  }
}

Затем создайте новый .dart-файл shared_prefs.dart, который будет содержать вызовы каналов для созданной нами нативной части:

import 'package:flutter/material.dart';
import 'shared_prefs.dart';

class SharedPrefsTestScreen extends StatefulWidget {
  @override
  _SharedPrefsTestScreenState createState() => _SharedPrefsTestScreenState();
}

class _SharedPrefsTestScreenState extends State<SharedPrefsTestScreen> {
  final SharedPrefs _sharedPrefs = SharedPrefs();

  final TextEditingController _stringKeyController = TextEditingController();
  final TextEditingController _stringValueController = TextEditingController();
  final TextEditingController _intKeyController = TextEditingController();
  final TextEditingController _intValueController = TextEditingController();
  final TextEditingController _boolKeyController = TextEditingController();
  final TextEditingController _boolValueController = TextEditingController();
  final TextEditingController _listKeyController = TextEditingController();
  final TextEditingController _listValueController = TextEditingController();

  String? _outputString;
  int? _outputInt;
  bool? _outputBool;
  List<String>? _outputList;

  void _setString() async {
    await _sharedPrefs.setString(
        _stringKeyController.text, _stringValueController.text);
  }

  void _getString() async {
    final value = await _sharedPrefs.getString(_stringKeyController.text);
    setState(() {
      _outputString = value;
    });
  }

  void _setInt() async {
    await _sharedPrefs.setInt(
        _intKeyController.text, int.parse(_intValueController.text));
  }

  void _getInt() async {
    final value = await _sharedPrefs.getInt(_intKeyController.text);
    setState(() {
      _outputInt = value;
    });
  }

  void _setBoolean() async {
    await _sharedPrefs.setBoolean(_boolKeyController.text,
        _boolValueController.text.toLowerCase() == 'true');
  }

  void _getBoolean() async {
    final value = await _sharedPrefs.getBoolean(_boolKeyController.text);
    setState(() {
      _outputBool = value;
    });
  }

  void _setStringList() async {
    final values =
        _listValueController.text.split(',').map((e) => e.trim()).toList();
    await _sharedPrefs.setStringList(_listKeyController.text, values);
  }

  void _getStringList() async {
    final value = await _sharedPrefs.getStringList(_listKeyController.text);
    setState(() {
      _outputList = value;
    });
  }

  void _remove(String key) async {
    await _sharedPrefs.remove(key);
  }

  void _clear() async {
    await _sharedPrefs.clear();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Shared Preferences Test'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: SingleChildScrollView(
          child: Column(
            children: [
              TextField(
                controller: _stringKeyController,
                decoration: const InputDecoration(labelText: 'String Key'),
              ),
              TextField(
                controller: _stringValueController,
                decoration: const InputDecoration(labelText: 'String Value'),
              ),
              ButtonsRow(
                onRemove: () => _remove(_stringKeyController.text),
                onset: _setString,
                output: _outputString,
                onGet: _getString,
              ),
              TextField(
                controller: _intKeyController,
                decoration: const InputDecoration(labelText: 'Integer Key'),
              ),
              TextField(
                controller: _intValueController,
                decoration: const InputDecoration(labelText: 'Integer Value'),
                keyboardType: TextInputType.number,
              ),
              ButtonsRow(
                onRemove: () => _remove(_boolKeyController.text),
                onset: _setInt,
                output: _outputInt?.toString(),
                onGet: _getInt,
              ),
              TextField(
                controller: _boolKeyController,
                decoration: const InputDecoration(labelText: 'Boolean Key'),
              ),
              TextField(
                controller: _boolValueController,
                decoration: const InputDecoration(
                    labelText: 'Boolean Value (true/false)'),
              ),
              ButtonsRow(
                onRemove: () => _remove(_boolKeyController.text),
                onset: _setBoolean,
                output: _outputBool?.toString(),
                onGet: _getBoolean,
              ),
              TextField(
                controller: _listKeyController,
                decoration: const InputDecoration(labelText: 'List Key'),
              ),
              TextField(
                controller: _listValueController,
                decoration: const InputDecoration(
                    labelText: 'List Values (comma separated)'),
              ),
              ButtonsRow(
                onRemove: () => _remove(_listKeyController.text),
                onset: _setStringList,
                output: _outputList?.join(", "),
                onGet: _getStringList,
              ),
              ElevatedButton(
                onPressed: _clear,
                child: const Text('Clear All'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class ButtonsRow extends StatelessWidget {
  final void Function() onset;
  final void Function() onRemove;
  final void Function() onGet;
  final String? output;

  const ButtonsRow({
    super.key,
    required this.onRemove,
    required this.onset,
    required this.output,
    required this.onGet,
  });

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        ElevatedButton(
          onPressed: onset,
          child: const Text('Set'),
        ),
        ElevatedButton(
          onPressed: onGet,
          child: const Text('Get'),
        ),
        ElevatedButton(
          onPressed: onRemove,
          child: const Text('Remove'),
        ),
        if (output != null) Text("output : $output"),
      ],
    );
  }
}

Теперь, когда мы закончили с логикой, нам нужно создать несколько flutter-экранов, чтобы проверить код, который мы только что написали.
Мы создадим простой ui-экран с примитивными ui-элементами, чтобы проверить класс, который мы сделали.
Для этого создайте новый .dart-файл и назовите его shared_prefs_screen.dart:

import 'package:flutter/material.dart';
import 'shared_prefs.dart';

class SharedPrefsTestScreen extends StatefulWidget {
 @override
 _SharedPrefsTestScreenState createState() => _SharedPrefsTestScreenState();
}

class _SharedPrefsTestScreenState extends State<SharedPrefsTestScreen> {
 final SharedPrefs _sharedPrefs = SharedPrefs();

 final TextEditingController _stringKeyController = TextEditingController();
 final TextEditingController _stringValueController = TextEditingController();
 final TextEditingController _intKeyController = TextEditingController();
 final TextEditingController _intValueController = TextEditingController();
 final TextEditingController _boolKeyController = TextEditingController();
 final TextEditingController _boolValueController = TextEditingController();
 final TextEditingController _listKeyController = TextEditingController();
 final TextEditingController _listValueController = TextEditingController();

 String? _outputString;
 int? _outputInt;
 bool? _outputBool;
 List<String>? _outputList;

 void _setString() async {
   await _sharedPrefs.setString(
       _stringKeyController.text, _stringValueController.text);
 }

 void _getString() async {
   final value = await _sharedPrefs.getString(_stringKeyController.text);
   setState(() {
     _outputString = value;
   });
 }

 void _setInt() async {
   await _sharedPrefs.setInt(
       _intKeyController.text, int.parse(_intValueController.text));
 }

 void _getInt() async {
   final value = await _sharedPrefs.getInt(_intKeyController.text);
   setState(() {
     _outputInt = value;
   });
 }

 void _setBoolean() async {
   await _sharedPrefs.setBoolean(_boolKeyController.text,
       _boolValueController.text.toLowerCase() == 'true');
 }

 void _getBoolean() async {
   final value = await _sharedPrefs.getBoolean(_boolKeyController.text);
   setState(() {
     _outputBool = value;
   });
 }

 void _setStringList() async {
   final values =
       _listValueController.text.split(',').map((e) => e.trim()).toList();
   await _sharedPrefs.setStringList(_listKeyController.text, values);
 }

 void _getStringList() async {
   final value = await _sharedPrefs.getStringList(_listKeyController.text);
   setState(() {
     _outputList = value;
   });
 }

 void _remove(String key) async {
   await _sharedPrefs.remove(key);
 }

 void _clear() async {
   await _sharedPrefs.clear();
 }

 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(
       title: const Text('Shared Preferences Test'),
     ),
     body: Padding(
       padding: const EdgeInsets.all(16.0),
       child: SingleChildScrollView(
         child: Column(
           children: [
             TextField(
               controller: _stringKeyController,
               decoration: const InputDecoration(labelText: 'String Key'),
             ),
             TextField(
               controller: _stringValueController,
               decoration: const InputDecoration(labelText: 'String Value'),
             ),
             ButtonsRow(
               onRemove: () => _remove(_stringKeyController.text),
               onset: _setString,
               output: _outputString,
               onGet: _getString,
             ),
             TextField(
               controller: _intKeyController,
               decoration: const InputDecoration(labelText: 'Integer Key'),
             ),
             TextField(
               controller: _intValueController,
               decoration: const InputDecoration(labelText: 'Integer Value'),
               keyboardType: TextInputType.number,
             ),
             ButtonsRow(
               onRemove: () => _remove(_boolKeyController.text),
               onset: _setInt,
               output: _outputInt?.toString(),
               onGet: _getInt,
             ),
             TextField(
               controller: _boolKeyController,
               decoration: const InputDecoration(labelText: 'Boolean Key'),
             ),
             TextField(
               controller: _boolValueController,
               decoration: const InputDecoration(
                   labelText: 'Boolean Value (true/false)'),
             ),
             ButtonsRow(
               onRemove: () => _remove(_boolKeyController.text),
               onset: _setBoolean,
               output: _outputBool?.toString(),
               onGet: _getBoolean,
             ),
             TextField(
               controller: _listKeyController,
               decoration: const InputDecoration(labelText: 'List Key'),
             ),
             TextField(
               controller: _listValueController,
               decoration: const InputDecoration(
                   labelText: 'List Values (comma separated)'),
             ),
             ButtonsRow(
               onRemove: () => _remove(_listKeyController.text),
               onset: _setStringList,
               output: _outputList?.join(", "),
               onGet: _getStringList,
             ),
             ElevatedButton(
               onPressed: _clear,
               child: const Text('Clear All'),
             ),
           ],
         ),
       ),
     ),
   );
 }
}

class ButtonsRow extends StatelessWidget {
 final void Function() onset;
 final void Function() onRemove;
 final void Function() onGet;
 final String? output;

 const ButtonsRow({
   super.key,
   required this.onRemove,
   required this.onset,
   required this.output,
   required this.onGet,
 });

 @override
 Widget build(BuildContext context) {
   return Row(
     children: [
       ElevatedButton(
         onPressed: onset,
         child: const Text('Set'),
       ),
       ElevatedButton(
         onPressed: onGet,
         child: const Text('Get'),
       ),
       ElevatedButton(
         onPressed: onRemove,
         child: const Text('Remove'),
       ),
       if (output != null) Text("output : $output"),
     ],
   );
 }
}

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

Stateful виджет:

  • Экран реализован как stateful виджет, то есть он может сохранять информацию о состоянии (например, пользовательские вводы и выводы) в течение своего жизненного цикла.

Текстовые контроллеры:

  • Он использует несколько экземпляров TextEditingController для управления пользовательским вводом для различных типов данных (строка, целое число, логическая переменная и список). Каждый контроллер соответствует полю с ключом или значением.

Выходные переменные:

  • Четыре переменные (_outputString, _outputInt, _outputBool и _outputList) определены для хранения значений, полученных из общих настроек.

CRUD-операции 

  • На экране представлены методы для выполнения операций создания, чтения, обновления и удаления (CRUD):

  • Операции установки значений: Методы _setString, _setInt, _setBoolean и _setStringList сохраняют значения в общих настройках на основе пользовательского ввода.

  • Операции получения значений: Методы _getString, _getInt, _getBoolean и _getStringList извлекают значения из общих настроек и обновляют выходные переменные.

  • Удаление и очистка: Метод _remove удаляет определенное значение, а метод _clear удаляет все записи из общих настроек.

  • Пользовательский интерфейс: состоит из ряда виджетов TextField для ввода данных пользователем и виджетов ButtonsRow, которые предоставляют кнопки для установки, получения и удаления значений. Результаты операций отображаются рядом с кнопками.

  • Виджет ButtonsRow: Это отдельный stateless виджет, в котором заключены кнопки для установки, получения и удаления значений, а также отображения вывода. Он улучшает организацию и возможность повторного использования макета кнопок.

Теперь перейдем к главному .dart-файлу и удалим стандартный код из flutter sdk, чтобы он выглядел следующим образом:

import 'package:flutter/material.dart';
import 'package:shraed_prefskmp/shared_prefs_screen.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
          useMaterial3: true,
        ),
        home: SharedPrefsTestScreen());
  }
}

Давайте запустим приложение, чтобы протестировать его:

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

Исходный код нашей KMP-библиотеки и  flutter-приложения можно найти на GitHub


Всех Flutter-разработчиков приглашаем на открытые уроки:

  • 6 февраля 20:00 — Flutter и Firebase: создание серверлесс-приложения.
    Записаться

  • 19 февраля 20:00 — Дополненная реальность во Flutter: создание интерактивных приложений с использованием ARKit, ARCore и Flutter.
    Записаться

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