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