Поддерживать одно приложение выгоднее, чем два, поэтому многие компании переносят приложения на Flutter. Но не всегда можно полностью переписать рабочее приложение с нуля. И тогда в лучах софитов появляется Flutter Add-to-App — способ интеграции Flutter-модуля в уже существующее нативное приложение.
Меня зовут Сергей, я разработчик в Surf Flutter Team. И сегодня мы разберёмся, как пользоваться этим инструментом, на что обратить внимание и какие проблемы могут возникнуть при интеграции.
О чём статья
В этом материале мы:
-
поговорим о том, что такое Flutter Add-to-App и для чего он нужен;
-
разберёмся, как создать и добавить Flutter-модуль в существующее Android и iOS приложение;
-
рассмотрим виды интеграции Flutter-модуля в нативное приложение (как экран, фрагмент, модальное окно);
-
узнаем, как обмениваться данными между Flutter-модулем и нативным кодом;
-
попробуем добавить сразу несколько Flutter-модулей в приложение;
-
получим ответ на вопрос: как отлаживать Flutter-модуль в существующем приложении.
Что такое Flutter Add-to-App
Flutter Add-to-App — это инструмент, который позволяет интегрировать Flutter-модуль в существующее Android и iOS приложение. Это означает, что можно использовать Flutter для написания отдельных экранов, модальных окон, виджетов, а затем встроить их в существующее приложение.
С технической точки зрения, обычное Flutter-приложение — это частный случай Flutter Add-to-App. Ведь в таком случае в полный экран отображается интегрированный Flutter-модуль: в Android у нас есть FlutterActivity
, которая задаётся в качестве основной Activity
, а в случае iOS — FlutterViewController
, которая задаётся в качестве корневого контроллера.
Создание Flutter-модуля
Создадим модуль — выполним следующую команду в любой подходящей директории. В дальнейшем остановимся на мысли, что эта директория находится на одном уровне с нашими нативными приложениями:
flutter create -t module --org com.example flutter_module
Добавление модуля в Android-проект
Есть два способа добавить Flutter-модуль в проект:
Как .aar-библиотеку |
Как gradle-зависимость |
|
---|---|---|
Плюсы |
Не нужен установленный Flutter SDK |
Все изменения, внесённые в Flutter-модуль, вступают в силу при пересборке нативного приложения |
Минусы |
При изменениях в Flutter-модуле придётся пересобрать его |
Нужен установленный Flutter SDK (вряд ли это минус для Flutter-разработчика) |
.aar-библиотека
Нужно собрать .aar-библиотеку из Flutter-модуля и добавить её в Android-проект.
-
Чтобы собрать .aar из Flutter-модуля, вызываем следующую команду в директории с ним:
flutter build aar
-
В выводе этой команды будут конфигурационные строки, предназначенные для вставки в
app/build.gradle
.Пример вывода команды
-
если проект использует
.gradle
файлы, просто делаем то, что описано в выводе команды; -
если проект использует
.gradle.kts
файлы, нужно:-
адаптировать синтаксис
gradle
. Например, вместо:maven { url '../flutter_module/build/host/outputs/repo' } maven { url 'http://download.flutter.io' }
нужно написать:
maven( url = "../flutter_module/build/host/outputs/repo" ) maven( url = "https://storage.googleapis.com/download.flutter.io" )
-
обратить внимание на наличие файла
settings.gradle.kt
и блокаdependencyResolutionManagement
в нём. Если такой блок есть, необходимо вставить в него зависимости, приведённые выше:dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() ++ maven( ++ url = "../flutter_module/build/host/outputs/repo" ++ ) ++ maven( ++ url = "https://storage.googleapis.com/download.flutter.io" ++ ) } }
-
Зависимость от исходного кода
Чтобы изменения в исходном коде Flutter-модуля вступали в силу при пересборке нативного приложения, добавляем зависимость от исходного кода Flutter-модуля в Android-проект. Способ добавления будет разниться в зависимости от того, используем ли мы .gradle
или .gradle.kts
файлы.
Проект использует .gradle-файлы
-
Вставляем следующий код в
settings.gradle
:include ':app' // assumed existing content setBinding(new Binding([gradle: this])) // new evaluate(new File( // new settingsDir.parentFile, // new 'flutter_module/.android/include_flutter.groovy' // new ))
-
Код ниже — в
app/build.gradle
внутрь блокаdependencies
:implementation project(':flutter')
Проект использует .gradle.kts файлы
Подробнее про миграцию с gradle на gradle.kts читайте тут.
-
Создаём в корне Android-проекта gradle-файл (например,
flutter_init.gradle
) и вставляем в него код:include ':app' // assumed existing content setBinding(new Binding([gradle: this])) // new evaluate(new File( // new settingsDir.parentFile, // new 'flutter_module/.android/include_flutter.groovy' // new ))
-
Теперь импортируем этот файл в
settings.gradle.kts
:apply("flutter_init.gradle")
-
В блоке
dependencyResolutionManagement
делаем так:
dependencyResolutionManagement { -- repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) ++ repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) repositories { google() mavenCentral() ++ maven ( ++ url= "https://storage.googleapis.com/download.flutter.io" ++ ) } }
-
Вставляем зависимости в
app/build.gradle.kts
(в блокdependencies
):
implementation(project(":flutter"))
Добавление модуля в iOS-проект
Как и в случае с Android, есть два (на самом деле, больше, но они чаще бывают вариациями этих двух) способа добавить Flutter-модуль в нативное iOS-приложение:
Как зависимость CocoaPods |
Как фреймворк |
|
---|---|---|
Плюсы |
Можно оперативно вносить изменения без необходимости пересобирать модуль |
Не нужен установленный Flutter SDK |
Минусы |
Нужен установленный Flutter SDK |
Каждый раз необходимо заново собирать модуль при изменениях |
Зависимость CocoaPods
Чтобы изменения в исходном коде Flutter-модуля вступали в силу при пересборке нативного приложения, добавляем зависимость от исходного кода Flutter-модуля в iOS-проект.
-
Добавляем в начало
Podfile
проекта следующие строки:flutter_application_path = '../fluter_module' # flutter_module это название модуля load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
Если в проекте нет
Podfile
, выполняем командуpod init
.Команды, связанные с
pod
, могут не запускаться, если Project Format установлен вXcode 14.0-compatible
. Понижаем версию до 13. -
Добавляем действие в блок
target 'MyApp' do
(MyApp
— это название приложения):
target 'MyApp' do ... ++ install_all_flutter_pods(flutter_application_path) end
-
Добавляем действие в блок
post_install
:
post_install do |installer| ... ++ flutter_post_install(installer) if defined?(flutter_post_install) end
-
Выполняем команду
pod install
. -
ОБЯЗАТЕЛЬНО:
-
перезапускаем XCode, если он был открыт;
-
выполняем команду
rm -rf ~/Library/Developer/Xcode/DerivedData
— так мы удаляем все промежуточные результаты сборок, сделанных Xcode раньше, и заставляем Xcode «заняться» нашим билдом с чистого листа; -
открываем XCode с помощью команды
open MyApp.xcworkspace
.
-
-
Если всё прошло успешно, то при попытке собрать приложение мы видим заветное
Build Succeeded
.
Embedded Framework
Если мы хотим интегрировать Flutter-модуль как фреймворк, выполняем следующие шаги:
-
Вызываем следующую команду в папке с Flutter-модулем и указываем путь к папке Flutter внутри iOS-приложения:
flutter build ios-framework --output=path/to/your/ios_app/MyApp/Flutter/
-
Переходим в
Targets->Ваше_приложение->Build Settings
и ищем"Framework search paths"
.
-
Устанавливаем значения
"$(PROJECT_DIR)/Flutter/{configuration}/"
, где configuration — это одно из значений:"Debug"
,"Profile"
,"Release"
.
Способы интеграции Flutter-модуля в нативное приложение. Android
Вот мы и перешли к самому интересному — способам интеграции Flutter-модуля в Android-приложение.
Немного теории
Познакомимся с основными «действующими лицами»:
-
FlutterEngine
— это движок Flutter, который отвечает за работу с Flutter-модулем. Он управляет жизненным циклом Flutter-модуля, обеспечивает его взаимодействие с нативным кодом; -
FlutterActivity
,FlutterFragment
— это классы, которые реализуют интеграцию Flutter-модуля в Android-приложение. Они наследуются отActivity
иFragment
соответственно и предоставляют методы для управления жизненным циклом приложения. Оба эти класса также реализуют интерфейсFlutterEngineProvider
, который предоставляет им доступ кFlutterEngine
.
Процесс запуска Flutter-модуля в Android-приложении делится на несколько этапов:
-
Создание
FlutterEngine
и его конфигурация:-
указание точки входа (по умолчанию это
main.dart
и функцияmain()
); -
входные аргументы для функции
main()
; -
начальный роутинг (по умолчанию это
/
).
-
-
С момента создания
FlutterEngine
выполняется весь код Flutter-модуля, начиная с функцииmain()
; -
Создание
FlutterActivity
илиFlutterFragment
и передача им созданногоFlutterEngine
; -
Содержимое модуля, связанного с движком, который был передан в
FlutterActivity
илиFlutterFragment
, отображается внутриFlutterActivity
илиFlutterFragment
.
Интегрируем Activity
Добавим FlutterActivity
в AndroidManifest
внутри блока Application
:
<activity android:name="io.flutter.embedding.android.FlutterActivity" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize" />
Теперь осталось запустить наш Activity. Самый простой способ — вызвать метод startActivity
:
myButton.setOnClickListener { startActivity( this, FlutterActivity.createDefaultIntent(this), null, ) }
Но такой способ не предполагает возможности конфигурации FlutterEngine
. Для примера передадим альтернативный стартовый роут:
myButton.setOnClickListener { startActivity( this, FlutterActivity .withNewEngine() .initialRoute("/my_route") .build(this), null, ) }
Уже лучше. Но что, если мы используем движок для экрана, допустим, какой-нибудь Корзины, которую пользователь может открывать и закрывать несколько раз за период работы приложения? В таком случае движок создаётся каждый раз при открытии экрана, а это не хорошо. В таких ситуациях уместнее использовать кешированный движок.
Использование кешированного движка
Под кешированием подразумевается «прогрев» движка до момента его использования. В зависимости от целей можно выполнять его в любой точке приложения. Например, если Flutter используется только в каком-то модуле приложения (например, в модуле оплаты), инициализировать его имеет смысл в том случае, если пользователь зашёл в Корзину.
В качестве примера выполним прогрев в классе Application
:
AndroidManifest.xml
:
<application -- android:name=".Application" ++ android:name=".MainApplication"
MainApplication.kt
:
class MainApplication : Application() { lateinit var flutterEngine : FlutterEngine companion object Factory { // Задаём для id движка абсолютно любое строковое значения. Например, это: val flutterEngineId = "id_of_flutter_engine" } override fun onCreate() { super.onCreate() flutterEngine = FlutterEngine(this) // Тут мы можем установить начальный роут flutterEngine.navigationChannel.setInitialRoute("your/route/here") // Выполняем Dart-код flutterEngine.dartExecutor.executeDartEntrypoint( DartExecutor.DartEntrypoint.createDefault() ) // Кешируем движок под нашим id FlutterEngineCache .getInstance() .put(flutterEngineId, flutterEngine) } }
Таким образом, вызов FlutterActivity
теперь будет выглядеть так:
startActivity( this, FlutterActivity.withCachedEngine(MainApplication.flutterEngineId).build(this), null, )
Использование закешированного движка подразумевает, что:
вызов кода, содержащегося в
main
, выполняется при вызове функцииflutterEngine.dartExecutor.executeDartEntrypoint
;состояние модуля в момент повторного открытия будет содержать данные с момента прошлого запуска.
Интегрируем Fragment
Можно интегрировать свой Flutter-модуль в фрагмент.
-
Для начала добавим фрагмент в View:
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/fragment_container_view" android:tag="flutter_fragment" android:layout_width="match_parent" android:layout_height="300dp" android:name="io.flutter.embedding.android.FlutterFragment" />
-
Установим
FragmentActivity
как базовый класс для той Activity, в которой мы планируем использовать Fragment:
class MainActivity : FragmentActivity() { ... }
-
Интегрируйте
FlutterFragment
во View:
class MainActivity : FragmentActivity() { companion object { private const val TAG_FLUTTER_FRAGMENT = "flutter_fragment" } private var flutterFragment: FlutterFragment? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.main_screen) val fragmentManager: FragmentManager = supportFragmentManager flutterFragment = fragmentManager.findFragmentByTag(TAG_FLUTTER_FRAGMENT) as? FlutterFragment if (flutterFragment == null) { val newFragment = FlutterFragment.createDefault() // Используем этот метод, если у нас закеширован движок. // // val newFragment = FlutterFragment.withCachedEngine(MainApplication.flutterEngineId).build() as? FlutterFragment // А так можно отображать фрагмент с прозрачностью. // // val flutterFragment = FlutterFragment.withNewEngine() // .transparencyMode(TransparencyMode.transparent) // .build() // Если мы хотим, чтобы наш фрагмент не перекрывал остальные элементы экрана, нам нужно установить // соответствующий режим рендера // val flutterFragment = FlutterFragment.withNewEngine() // .renderMode(RenderMode.texture) // .build() flutterFragment = newFragment fragmentManager.beginTransaction() .add(R.id.fragment_container_view, newFragment, TAG_FLUTTER_FRAGMENT).commit() } } /// Методы ниже служат для передачи во фрагмент информации ОС, получаемой активити. override fun onPostResume() { super.onPostResume() flutterFragment?.onPostResume() } override fun onNewIntent(@NonNull intent: Intent) { flutterFragment?.onNewIntent(intent) } override fun onBackPressed() { super.onBackPressed() flutterFragment?.onBackPressed() } override fun onRequestPermissionsResult( requestCode: Int, permissions: Array<String?>, grantResults: IntArray ) { super.onRequestPermissionsResult( requestCode, permissions, grantResults ) flutterFragment?.onRequestPermissionsResult( requestCode, permissions, grantResults ) } override fun onActivityResult( requestCode: Int, resultCode: Int, data: Intent? ) { super.onActivityResult(requestCode, resultCode, data) flutterFragment?.onActivityResult( requestCode, resultCode, data ) } override fun onUserLeaveHint() { super.onUserLeaveHint() flutterFragment?.onUserLeaveHint() } override fun onTrimMemory(level: Int) { super.onTrimMemory(level) flutterFragment?.onTrimMemory(level) } }
Интегрируем Fragment (в роли экрана)
В Android-разработке распространён подход, при котором в приложении есть всего одна Activity и много Fragment, каждый из которых представляет из себя экран.
В силу того, что создать экземпляр FlutterFragment
могут только предназначенные для этого билдеры (об этом ниже), мы не можем просто подставить наши фрагменты в граф навигации. Вместо этого мы пойдём более длинным путём.
Итак, наши условия — проект с навигационным графом, в котором каждый из экранов представляет из себя Fragment.
Конечная цель — внедрить наш Flutter-модуль в один из фрагментов (экранов).
-
Создадим сперва наш
FlutterFragment
:
class FlutterExampleFragment : FlutterFragment() { }
В случае, если нашему Fragment нужны зависимости, объявляем их в конструкторе:
class FlutterExampleFragment public constructor( private val someViewModel: SomeViewModel ) : FlutterFragment() { }
-
Теперь нам необходимо написать билдер, который, в свою очередь, будет создавать для нас фрагмент. В зависимости от того, используем или не используем ли мы кешированный движок, наследуемся от класса
CachedEngineFragmentBuilder
илиNewEngineFragmentBuilder
соответственно.В примере рассмотрим первый вариант (единственное отличие первого от второго — обязательный параметр
engineId
):
class CustomCachedEngineFragmentBuilder(engineId: String) : CachedEngineFragmentBuilder(FlutterExampleFragment::class.java, engineId) { fun buildWithParam(mSomeViewModel: SomeViewModel): FlutterExampleFragment { val frag = FlutterExampleFragment(mSomeViewModel) /// Именно здесь задаются различные «подкапотные» значения /// для Fragment, из-за которых мы не можем просто так /// создать инстанс Fragment самим без билдера. frag.arguments = createArgs() return frag } }
-
Fragment, который встроен в навигационный граф, будет вместилищем нашего
FlutterFragment
. Поскольку мы рассматриваем ситуацию, когда нашFlutterFragment
выступает как экран целиком, убедимся, что в layout родительского Fragment осталось только то, что нужно:
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".fragments.example.ExampleFragment"> <androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/flutter_example_fragment" android:tag="flutter_example_fragment" android:layout_width="match_parent" android:layout_height="match_parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" android:name="io.flutter.embedding.android.FlutterFragment" /> </androidx.constraintlayout.widget.ConstraintLayout>
Особое внимание уделим свойству tag
— именно по нему мы найдём контейнер для интеграции FlutterFragment.
-
Следущая цель — внедрить
FlutterFragment
в родительский фрагментclass ExampleFragment : Fragment() { companion object { // должно совпадать с тегом из layout. private const val TAG_FLUTTER_FRAGMENT = "flutter_example_fragment" } private var flutterFragment: FlutterExampleFragment? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { val fragmentManager: FragmentManager = parentFragmentManager flutterFragment = fragmentManager .findFragmentByTag(TAG_FLUTTER_FRAGMENT) as? FlutterExampleFragment /// В силу того, что состояние фрагмента после его «ухода» с экрана /// подчищается не полностью, возможны проблемы при повторном /// его использовании - такие как MissingPluginException /// при использовании платформенного канала. /// Поэтому, если фрагмент был создан ранее, удаляем его /// и добавляем заново. if (flutterFragment != null) { fragmentManager.beginTransaction().remove(flutterFragment as? FlutterFragment).commit() } /// Прокидываем здесь id нашего движка и передаём зависимости. var newFlutterFragment = CustomCachedEngineFragmentBuilder(MainApplication.exampleModuleEngineId) .buildWithParam(mSomeViewModel) flutterFragment = newFlutterFragment fragmentManager .beginTransaction() .add( R.id.flutter_example_fragment, newFlutterFragment, TAG_FLUTTER_FRAGMENT ) .commit() return binding.root } }
Способы интеграции Flutter-модуля в нативное приложение. iOS
Немного теории
«Действующие лица» в iOS примерно те же, что и в Android:
-
FlutterEngine
— уже знакомый нам движок Flutter, который отвечает за работу с Flutter-модулем; -
FlutterViewController
— это контроллер, который наследуется отUIViewController
и обеспечивает интеграцию Flutter-модуля в iOS-приложение.
Процесс интеграции Flutter-модуля в iOS-приложение тоже можно разделить на несколько этапов, которые в целом аналогичны этапам на Android:
-
Создание
FlutterEngine
и его конфигурация; -
С момента создания
FlutterEngine
выполняется весь код Flutter-модуля, начиная с функцииmain()
; -
Создание
FlutterViewController
и передача ему созданногоFlutterEngine
; -
Содержимое модуля, связанного с движком, который был передан в
FlutterViewController
, отображается внутриView
, которая связана с контроллером.
Добавление экрана
Добавим в iOS-приложение Flutter-экран.
-
Создадим движок:
class FlutterDependencies: ObservableObject { let flutterEngine = FlutterEngine(name: "id_of_engine") init(){ /// Инициализирует движок с роутом по умолчанию и точкой входа main. flutterEngine.run() // Связывает плагины iOS с движком Flutter. GeneratedPluginRegistrant.register(with: self.flutterEngine); } }
-
Добавим его как
EnvironmentObject
к томуView
, где планируем его использовать:window.rootViewController = UIHostingController( rootView: YourView().environmentObject(FlutterDependencies()) )
-
Добавим
FlutterDependencies
как поле класса вView
:
struct TaskListView: View { @EnvironmentObject var flutterDependencies: FlutterDependencies ... }
-
Определим функцию, которая будет вызывать отображение Flutter-экрана:
func showFlutter() { guard let windowScene = UIApplication.shared.connectedScenes .first(where: { $0.activationState == .foregroundActive && $0 is UIWindowScene }) as? UIWindowScene, let window = windowScene.windows.first(where: \.isKeyWindow), let rootViewController = window.rootViewController else { return } let flutterViewController = FlutterViewController( /// Можно не указывать движок - тогда он будет создан с нуля. engine: flutterDependencies.flutterEngine, nibName: nil, bundle: nil) /// Указываем стиль отображения - в данном случае это модальное окно. flutterViewController.modalPresentationStyle = .pageSheet flutterViewController.isViewOpaque = false rootViewController.present(flutterViewController, animated: true) }
-
Используем функцию по назначению и получаем желаемый экран:
Button(action: { showFlutter() }) { Text("Open Flutter") }
Получение системных событий
Чтобы получать платформенные коллбэки и поддерживать в дебаг-режиме связь Flutter с приложением в заблокированном состоянии, необходимо унаследовать AppDelegate
от FlutterAppDelegate
.
Добавление модуля как части экрана
Мы также можем добавить модуль в iOS-приложение в качестве части пользовательского интерфейса — подобно тому, как работают Fragment в Android.
Первые три шага, посвящённые созданию Flutter-движка и его внедрению в экран, повторяем без изменений.
-
Реализуем обёртку, которая будет вместилищем модуля. Эта обёртка реализует протокол
UIViewControllerRepresentable
и именно её мы вставим в вёрстку. В качестве параметра указываем движок — он необходим для инициализации контроллера.
struct FlutterViewControllerWrapper: UIViewControllerRepresentable { var engine: FlutterEngine func makeUIViewController(context: Context) -> FlutterViewController { return FlutterViewController(engine: engine, nibName: nil, bundle: nil) } func updateUIViewController(_ uiViewController: FlutterViewController, context: Context) { // Место для обновления конфигурации с учётом // нового состояния приложения. } }
-
Размещаем обёртку внутри вёрстки:
var body: some View { NavigationView { List { Button(action: { onPressed() }) { Text("Some button") } /// fluttenDependencies - это тот же объект, который фигурировал в прошлом примере. FlutterViewControllerWrapper(engine: flutterDependencies.engine).frame(width: 200, height: 300) } .navigationBarTitle(Text("Example")) } }
Обмен данными между Flutter-модулем и нативным кодом
Вот мы и научились интегрировать Flutter-модуль в приложение. Но достаточно ли этого? Мы говорили о примере с экраном Корзины — туда нам точно нужно передавать данные. В таком случае возникает вопрос — как тогда передать данные между Flutter-модулем и нативным кодом?
Есть два способа:
Входные параметры |
Платформенные каналы |
|
---|---|---|
Описание |
Передача параметров в |
Передача данных через |
Плюсы |
Максимальная простота в использовании |
Передавать данные можно в любой момент |
Минусы |
Ограниченный функционал — можно передать данные только при инициализации движка |
Сложность (относительно входных параметров) |
Входные параметры
Надо всего лишь указать эти параметры при инициализации движка.
С кешированным движком. Android
class MainApplication : Application(), Configuration.Provider { lateinit var flutterEngine : FlutterEngine companion object Factory { val flutterEngineId = "id_of_flutter_engine" } override fun onCreate() { super.onCreate() flutterEngine = FlutterEngine(this) flutterEngine.dartExecutor.executeDartEntrypoint( ++ DartExecutor.DartEntrypoint.createDefault(), ++ listOf("arg1","arg2"), ++ ) FlutterEngineCache .getInstance() .put(flutterEngineId, flutterEngine) } }
Без кешированного движка
startActivity( context, FlutterActivity .withNewEngine() ++ .dartEntrypointArgs(listOf("arg1","arg2")) null, )
iOS
class FlutterDependencies: ObservableObject { let flutterEngine = FlutterEngine(name: "my flutter engine") init(){ flutterEngine.run( withEntrypoint: nil, libraryURI: nil, initialRoute: nil, ++ entrypointArgs: ["arg1", "arg2"] ) GeneratedPluginRegistrant.register(with: self.flutterEngine); } }
Со стороны Flutter
void main(List<String> args) { runApp(MyApp(args: args)); }
Платформенные каналы
Использовать входные параметры не всегда удобно. Например, если нужно передать данные во время работы приложения. В таких случаях стоит обратить внимание на платформенные каналы.
Android
Прежде всего необходимо озаботиться передачей параметров в Activity.
-
Создадим собственную Activity, чьим родителем будет
FlutterActivity
:
class FlutterEntryActivity : FlutterActivity() {}
-
Добавим её в
AndroidManifest
:
<activity android:name="your.package.name.FlutterEntryActivity" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize" />
-
Передавать аргументы (например, с прошлого экрана) мы будем через
Extras
(это не единственный способ, можно воспользоваться любым).Определим фабрику для создания Activity:
class FlutterEntryActivity : FlutterActivity() { companion object Factory { const val ARG_KEY = "flutter_arg" fun withState(context: Context, state: String): Intent { // Тк фабрики класса FlutterActivity нам не подходят (у нас собственный класс), // мы используем NewEngineIntentBuilder, который принимает тип нашего активити // и возвращает необходимый для создания активити интент. return NewEngineIntentBuilder( FlutterEntryActivity::class.java ).build(context).putExtra(ARG_KEY, state) // При использовании кешированного движка можно взять // CachedEngineIntentBuilder - для его использования понадобится // id закешированного движка } } }
-
Теперь нам нужно достать аргумент внутри Activity и передать его в Flutter-модуль:
/// ОБЯЗАТЕЛЬНО обратите внимание, что перегружаете именно ЭТОТ метод (с ЭТИМ аргументом). Иначе /// этот метод вызываться не будет. override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val engine = flutterEngine ?: return // Создаём канал и по нему передаём данные в Flutter-модуль val channel = MethodChannel(engine.dartExecutor.binaryMessenger, "your_channel_name") val arg = intent.getStringExtra(FlutterEntryActivity.ARG_KEY) ?: throw Exception() // Как только активити была создана, собираем наши данные для передачи и передаём их по каналу. channel.invokeMethod("sendInputArgs", arg) }
Важно понимать, что эти данные будут получены асинхронно, и придётся их «дожидаться» во Flutter-экране.
Можно использовать передачу данных по платформенному каналу в течение всего жизненного цикла Activity.
-
Для передачи данных из Flutter-модуля в нативный код используем
MethodChannel
:
++ class FlutterEntryActivity : FlutterActivity(), MethodCallHandler { companion object Factory { const val ARG_KEY = "flutter_arg" fun withState(context: Context, state: String): Intent { return CachedEngineIntentBuilder( FlutterEntryActivity::class.java, ENGINE_ID, ).build(context).putExtra(ARG_KEY, state) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val engine = flutterEngine ?: return val channel = MethodChannel(engine.dartExecutor.binaryMessenger, "android_app") ++ channel.setMethodCallHandler(this) val arg = intent.getStringExtra(FlutterEntryActivity.ARG_KEY) ?: throw Exception() channel.invokeMethod("sendInputArgs", arg) } ++ override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { ++ when (call.method) { ++ "sendDataToNativeSide" -> { ++ // ваша обработка ++ } ++ } ++ } }
iOS
Для передачи данных во Flutter-модуль в iOS немного модифицируем функцию showFlutter
:
func showFlutter() { guard let windowScene = UIApplication.shared.connectedScenes .first(where: { $0.activationState == .foregroundActive && $0 is UIWindowScene }) as? UIWindowScene, let window = windowScene.windows.first(where: \.isKeyWindow), let rootViewController = window.rootViewController else { return } let flutterViewController = FlutterViewController( engine: flutterDependencies.flutterEngine, nibName: nil, bundle: nil) flutterViewController.modalPresentationStyle = .pageSheet flutterViewController.isViewOpaque = false rootViewController.present(flutterViewController, animated: true) ++ let channel = FlutterMethodChannel( ++ name: "ios_app", ++ binaryMessenger: flutterViewController.binaryMessenger ++ ) ++ channel.invokeMethod("passArgs", arguments: "hello from ios") }
И, соответственно, на стороне Flutter:
MethodChannel _channel = MethodChannel('ios_app'); _channel.setMethodCallHandler((call) { switch (call.method) { case 'passArgs': print(call.arguments); break; default: throw MissingPluginException(); } });
Для получения данных из Flutter-модуля в нативный код установим обработчик методов:
func showFlutter() { guard let windowScene = UIApplication.shared.connectedScenes .first(where: { $0.activationState == .foregroundActive && $0 is UIWindowScene }) as? UIWindowScene, let window = windowScene.windows.first(where: \.isKeyWindow), let rootViewController = window.rootViewController else { return } let flutterViewController = FlutterViewController( engine: flutterDependencies.flutterEngine, nibName: nil, bundle: nil) flutterViewController.modalPresentationStyle = .pageSheet flutterViewController.isViewOpaque = false rootViewController.present(flutterViewController, animated: true) ++ let channel = FlutterMethodChannel( ++ name: "ios_app", ++ binaryMessenger: flutterViewController.binaryMessenger ++ ) ++ channel.setMethodCallHandler( ++ { ++ (call: FlutterMethodCall, result: FlutterResult) -> Void in ++ switch (call.method) { ++ case "sendDataToNativeSide": ++ /// обработка метода ++ break ++ default: ++ result(FlutterMethodNotImplemented) ++ } ++ } ++ ) }
Можно ли использовать несколько Flutter-модулей в одном приложении?
Нет. Однако несмотря на то, что можно подключить только один модуль, нет причин, по которым этот модуль не может содержать в себе другие модули.
Таким образом, модули становятся зависимостями нашего корневого модуля, который и будет интегрирован в нативное приложение.
-
Для начала создадим модули в дочерней папке корневого модуля (их можно создавать где угодно, но будет логично держать их внутри основного модуля). Модули следует создавать именно как модули (
flutter create -t module --org com.example first_module
)
Таким образом мы получим следующую структуру: -
Определим входные точки для наших модулей внутри основного модуля:
import 'package:first_module/main.dart' as first; import 'package:second_module/main.dart' as second; // Функцию main не удаляем void main() {} // Добавляем, чтобы при релизной сборке компилятор не удалил входные точки @pragma('vm:entry-point') void startFirstModule(List<String> args) { first.main(); } @pragma('vm:entry-point') void startSecondModule(List<String> args) { second.main(); }
Запуск модулей в нативном приложении
Теперь дело за малым — надо запустить модуль и указать нужную точку входа.
Android
С кешированием движка
В MainApplication.kt
(или любом другом файле, где инициализируется движок) нужно определить по одному движку на модуль, который мы планируем запускать:
class MainApplication : Application() { lateinit var firstModuleFlutterEngine: FlutterEngine lateinit var secondModuleFlutterEngine: FlutterEngine companion object Factory { val flutterFirstModuleEngineId = "id_of_flutter_engine" val flutterSecondModuleEngineId = "id_of_second_flutter_engine" } override fun onCreate() { super.onCreate() firstModuleFlutterEngine = FlutterEngine(this) val pathToBundle = FlutterInjector.instance().flutterLoader().findAppBundlePath() firstModuleFlutterEngine.dartExecutor.executeDartEntrypoint( DartExecutor.DartEntrypoint( pathToBundle, "startFirstModule", // указываем название функции, с которой «стартует» желаемый модуль. ) ) secondModuleFlutterEngine = FlutterEngine(this) secondModuleFlutterEngine.dartExecutor.executeDartEntrypoint( DartExecutor.DartEntrypoint( pathToBundle, "startSecondModule", ) ) FlutterEngineCache .getInstance() .put(flutterFirstModuleEngineId, firstModuleFlutterEngine) FlutterEngineCache .getInstance() .put(flutterSecondModuleEngineId, secondModuleFlutterEngine) } }
Без кеширования движка
К сожалению, фабрика FlutterActivity
с созданием нового движка не позволяет указывать entrypoint-функцию. Поэтому пойдём другим путём.
-
Создайте собственную Activity, унаследованную от
FlutterActivity
:AndroidManifest.xml
:
<activity android:name="com.example.flt_integration_test.FlutterEntryActivity" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize" />
FlutterEntryActivity.kt
:
class FlutterEntryActivity : FlutterActivity() { }
-
Переопределим метод
getDartEntrypointFunctionName
:class FlutterEntryActivity : FlutterActivity() { override fun getDartEntrypointFunctionName() :String { return "startFirstModule" } }
-
Теперь мы можем запустить Activity:
startActivity( context, FlutterActivity.NewEngineIntentBuilder( FlutterEntryActivity::class.java).build(context), null, )
iOS
-
Просто добавим entrypoint в функцию
run
нашего движка:
import SwiftUI import Flutter import FlutterPluginRegistrant class FlutterDependencies: ObservableObject { let flutterEngine = FlutterEngine(name: "my flutter engine") init(){ -- flutterEngine.run() ++ flutterEngine.run(withEntrypoint: "startFirstModule") GeneratedPluginRegistrant.register(with: self.flutterEngine); } } @main struct MyApp: App { @StateObject var flutterDependencies = FlutterDependencies() var body: some Scene { WindowGroup { ContentView().environmentObject(flutterDependencies) } } }
Казалось бы, на этом всё, но есть, что ещё улучшить.
Использование FlutterEngineGroup
При использовании нескольких движков или модулей грех не использовать FlutterEngineGroup
.
Что такое FlutterEngineGroup
Сущность, которая содержит набор движков и предоставляет каждому из них доступ к общим ресурсам (ассеты, исходники Flutter) — таким образом, многие вещи, общие для разных модулей, грузятся лишь один раз и переиспользуются впоследствии.
Следовательно, он идеально подходит для нашего случая — нескольких модулей в одном приложении.
Android
-
Условимся, что место для инициализации наших движков — это
Application
-класс. Создадим поле дляFlutterEngineGroup
, а также заведём id для наших движков:
class MainApplication : Application() { // Группа движков с общим скоупом ресурсов. lateinit var engineGroup: FlutterEngineGroup // Id движков, которые мы будем использовать. companion object Factory { const val firstNoduleEngineId = "first_engine" const val secondNoduleEngineId = "second_engine" } }
-
Теперь займёмся инициализацией:
override fun onCreate() { super.onCreate() engineGroup = FlutterEngineGroup(this) val pathToBundle = FlutterInjector.instance().flutterLoader().findAppBundlePath() /// Запускаем наши движки val firstEngine = engineGroup.createAndRunEngine( this, DartExecutor.DartEntrypoint( pathToBundle, "startFirstModule", ), ) val secondEngine = engineGroup.createAndRunEngine( this, DartExecutor.DartEntrypoint( pathToBundle, "startSecondModule", ), ) /// И регистрируем их в кеше. FlutterEngineCache.getInstance().put( firstNoduleEngineId, firstEngine, ) FlutterEngineCache.getInstance().put( secondNoduleEngineId, secondEngine, ) }
-
Дальше используем движки так же, как выше. Единственное, что следует учесть — новые движки должны быть созданы именно с ипользованием
engineGroup
. Таким образом, мы используем ресурсы уже созданных движков и бережём память и время.
iOS
Отрефакторим наш класс FlutterDependencies
, созданный ранее:
class FlutterDependencies: ObservableObject { let flutterEngineGroup = FlutterEngineGroup( name: "flutter_engine_group", project: FlutterDartProject() ) lazy var addTodoFlutterEngine: FlutterEngine = { return flutterEngineGroup.makeEngine( withEntrypoint: "startAddModule", libraryURI: "package:flutter_module/main.dart", initialRoute: "/" ) }() lazy var editTodoFlutterEngine: FlutterEngine = { return flutterEngineGroup.makeEngine( withEntrypoint: "startEditModule", libraryURI: "package:flutter_module/main.dart", initialRoute: "/" ) }() init(){ addTodoFlutterEngine.run() editTodoFlutterEngine.run() GeneratedPluginRegistrant.register(with: self.addTodoFlutterEngine) GeneratedPluginRegistrant.register(with: self.editTodoFlutterEngine) } }
Отладка
Для начала — ничего не мешает запустить модуль сам по себе (кроме его потенциального общения с платформой, которого, очевидно, не будет без запущенного поверх нативного приложения). Поэтому такой вариант нам в большинстве случаев не подходит.
Поэтому будем тестировать наш модуль в рамках нативного приложения. И будем делать это со всеми удобствами, к которым мы привыкли, будучи Flutter-разработчиками.
Как это работает
Для дебага мы будем использовать команду flutter attach
. Она ищет процессы, которые создают запущенные нами FlutterEngine
и «прицепляется» к ним, позволяя нам выполнять hot restart/reload, читать логи и другое.
-
Запускаем нативное приложение.
-
Убедимся, что мы находимся в той точке приложения, где точно проницилизирован движок того модуля, который мы хотим протестировать.
-
Вызываем команду
flutter attach
. -
Выбираем устройство, на котором запущено приложение.
-
Если в этот момент в приложении проиницилизировано несколько движков или на устройстве запущено ещё одно Flutter-приложение (или такое приложение было запущено раньше), мы увидим нечто такое:
В таком случае необходимо:
-
указать id целевого приложения;
-
если приложение присутствует в списке несколько раз, выбираем самый «свежий» порт (такое поведение в настоящий момент было замечено только на iOS (подробнее тут))
-
Готово, теперь мы можем использовать DevTools, hot restart/reload и другие прелести дебаг-режима в Flutter.
Теперь точно всё
Надеемся, этой информации будет достаточно для базового понимания того, как работает Flutter Add-to-App.
Для тех, кому тема показалась интересной, держите небольшой репозиторий, в котором есть примеры кода, описанные в статье.
Больше полезного про Flutter — в Telegram-канале Surf Flutter Team.
Кейсы, лучшие практики, новости и вакансии в команду Flutter Surf в одном месте. Присоединяйтесь!
ссылка на оригинал статьи https://habr.com/ru/articles/822693/
Добавить комментарий