how to add Flutter to an application
How to add Flutter to your application
It is more profitable to maintain one application than two, which is why many companies are migrating applications to Flutter. But it's not always possible to completely rewrite a working application from scratch. And then appears in the spotlight Flutter Add-to-App — a way to integrate a Flutter module into an existing native application.
My name is Sergey, I am a developer at Surf Flutter Team. And today we will figure out how to use this tool, what to look for and what problems may arise during integration.
What is the article about?
In this material we:
Let's talk about what Flutter Add-to-App is and what it is needed for;
Let's figure out how to create and add a Flutter module to an existing Android and iOS application;
Let's look at the types of integration of a Flutter module into a native application (as a screen, a fragment, a modal window);
learn how to exchange data between the Flutter module and native code;
Let's try to add several Flutter modules to the application at once;
We will get the answer to the question: how to debug a Flutter module in an existing application.
What is Flutter Add-to-App
Flutter Add-to-App is a tool that allows you to integrate a Flutter module into an existing Android and iOS application. This means that you can use Flutter to write individual screens, modals, widgets, and then embed them into an existing application.
From a technical point of view, a regular Flutter application is a special case of Flutter Add-to-App. Indeed, in this case, the integrated Flutter module is displayed in full screen: in Android we have FlutterActivity
which is set as the main Activity
and in the case of iOS – FlutterViewController
which is set as the root controller.
Creating a Flutter module
Let's create a module – run the following command in any suitable directory. In the future, we will dwell on the idea that this directory is on the same level as our native applications:
flutter create -t module --org com.example flutter_module
Adding a Module to an Android Project
There are two ways to add a Flutter module to a project:
Like .aar library | Like gradle dependency | |
---|---|---|
pros | No need to have Flutter SDK installed | All changes made to the Flutter module take effect when the native application is rebuilt |
Minuses | If changes are made to the Flutter module, you will have to rebuild it | You need the Flutter SDK installed (hardly a minus for a Flutter developer) |
.aar-library
You need to collect the .aar library from the Flutter module and add it to the Android project.
To build .aar from a Flutter module, call the following command in the directory with it:
flutter build aar
The output of this command will contain configuration lines to be inserted into
app/build.gradle
.Example command output
if the project uses
.gradle
files, just do what is described in the command output;if the project uses
.gradle.kts
files you need:adapt syntax
gradle
. For example, instead of:maven { url '../flutter_module/build/host/outputs/repo' } maven { url 'http://download.flutter.io' }
you need to write:
maven( url = "../flutter_module/build/host/outputs/repo" ) maven( url = "https://storage.googleapis.com/download.flutter.io" )
pay attention to the presence of the file
settings.gradle.kt
and blockdependencyResolutionManagement
in him. If there is such a block, the dependencies given above must be inserted into it: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" ++ ) } }
Source code dependency
To ensure that changes in the Flutter module source code take effect when the native application is rebuilt, we add a dependency on the Flutter module source code to the Android project. The method of adding will vary depending on whether we use .gradle
or .gradle.kts
files.
The project uses .gradle files
Paste the following code into
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 ))
The code below is in
app/build.gradle
inside the blockdependencies
:implementation project(':flutter')
The project uses .gradle.kts files
Read more about migration from gradle to gradle.kts here.
Create a gradle file in the root of the Android project (for example,
flutter_init.gradle
) and paste the code into it: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 ))
Now let's import this file into
settings.gradle.kts
:apply("flutter_init.gradle")
In the block
dependencyResolutionManagement
do this:
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"
++ )
}
}
Inserting dependencies into
app/build.gradle.kts
(per blockdependencies
):
implementation(project(":flutter"))
Adding a module to an iOS project
As with Android, there are two (actually, more, but they are often variations of these two) ways to add a Flutter module to a native iOS app:
How addicted CocoaPods are | Like a framework | |
---|---|---|
pros | You can quickly make changes without having to rebuild the module | No need to have Flutter SDK installed |
Minuses | Requires Flutter SDK installed | Each time you need to reassemble the module when making changes |
CocoaPods addiction
To ensure that changes in the Flutter module source code take effect when the native application is rebuilt, we add a dependency on the Flutter module source code to the iOS project.
Add to the beginning
Podfile
project the following lines:flutter_application_path="../fluter_module" # flutter_module это название модуля load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
If the project does not
Podfile
execute the commandpod init
.Related commands
pod
may not run if Project Format is set toXcode 14.0-compatible
. We downgrade the version to 13.Adding an action to the block
target 'MyApp' do
(MyApp
is the name of the application):
target 'MyApp' do
...
++ install_all_flutter_pods(flutter_application_path)
end
Adding an action to the block
post_install
:
post_install do |installer|
...
++ flutter_post_install(installer) if defined?(flutter_post_install)
end
Execute the command
pod install
.NECESSARILY:
restart XCode if it was open;
execute the command
rm -rf ~/Library/Developer/Xcode/DerivedData
– this is how we delete all the intermediate results of assemblies made by Xcode earlier, and force Xcode to “take on” our build from scratch;open Xcode using the command
open MyApp.xcworkspace
.
If everything went well, then when we try to build the application we see the treasured
Build Succeeded
.
Embedded Framework
If we want to integrate the Flutter module as a framework, we perform the following steps:
Call the following command in the folder with the Flutter module and specify the path to the Flutter folder inside the iOS application:
flutter build ios-framework --output=path/to/your/ios_app/MyApp/Flutter/
Let's go to
Targets->Ваше_приложение->Build Settings
and looking for"Framework search paths"
.
Setting the values
"$(PROJECT_DIR)/Flutter/{configuration}/"
where configuration is one of the values:"Debug"
,"Profile"
,"Release"
.
Ways to integrate a Flutter module into a native application. Android
Now we’ve moved on to the most interesting part – ways to integrate a Flutter module into an Android application.
A little theory
Let's get acquainted with the main “actors”:
FlutterEngine
is the Flutter engine, which is responsible for working with the Flutter module. It manages the life cycle of a Flutter module and ensures its interaction with native code;FlutterActivity
,FlutterFragment
are classes that implement the integration of a Flutter module into an Android application. They are inherited fromActivity
AndFragment
accordingly, and provide methods for managing the application lifecycle. Both of these classes also implement the interfaceFlutterEngineProvider
which gives them access toFlutterEngine
.
The process of launching a Flutter module in an Android application is divided into several stages:
Creation
FlutterEngine
and its configuration:specifying the entry point (by default this is
main.dart
and functionmain()
);input arguments for the function
main()
;initial routing (default is
/
).
Since creation
FlutterEngine
The entire code of the Flutter module is executed, starting with the functionmain()
;Creation
FlutterActivity
orFlutterFragment
and transferring what they createdFlutterEngine
;Contents of the module associated with the engine that was passed to
FlutterActivity
orFlutterFragment
displayed insideFlutterActivity
orFlutterFragment
.
Integrating Activity
Let's add FlutterActivity
V AndroidManifest
inside the block 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"
/>
Now all that remains is to launch our Activity. The easiest way is to call the method startActivity
:
myButton.setOnClickListener {
startActivity(
this,
FlutterActivity.createDefaultIntent(this),
null,
)
}
But this method does not require configuration options. FlutterEngine
. For example, let's pass an alternative starting route:
myButton.setOnClickListener {
startActivity(
this,
FlutterActivity
.withNewEngine()
.initialRoute("/my_route")
.build(this),
null,
)
}
Already better. But what if we use a screen engine for, say, some kind of Recycle Bin, which the user can open and close several times during the operation of the application? In this case, the engine is created every time the screen is opened, and this is not good. In such situations, it is more appropriate to use a cached engine.
Using a cached engine
By caching we mean “warming up” the engine until it is used. Depending on your goals, you can run it anywhere in the application. For example, if Flutter is used only in some application module (for example, in the payment module), it makes sense to initialize it if the user has entered the Shopping Cart.
As an example, let's do a warm-up in the classroom. 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)
}
}
So the challenge FlutterActivity
will now look like this:
startActivity(
this,
FlutterActivity.withCachedEngine(MainApplication.flutterEngineId).build(this),
null,
)
Using a cached engine means that:
calling the code contained in
main
executed when calling the functionflutterEngine.dartExecutor.executeDartEntrypoint
;the state of the module at the time of re-opening will contain data from the moment of the last launch.
Integrating Fragment
You can integrate your Flutter module into a fragment.
First, let's add a fragment to 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" />
Let's install
FragmentActivity
as a base class for the Activity in which we plan to use Fragment:
class MainActivity : FragmentActivity() {
...
}
Integrate
FlutterFragment
in 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)
}
}
Integrating Fragment (as a screen)
In Android development, a common approach is in which the application has only one Activity and many Fragments, each of which represents a screen.
Due to the fact that create an instance FlutterFragment
Only builders designed for this can do this (more on this below), we cannot simply substitute our fragments into the navigation graph. Instead, we'll take the longer route.
So, our conditions — a project with a navigation graph, in which each of the screens is a Fragment.
Final goal — embed our Flutter module into one of the fragments (screens).
Let's create ours first
FlutterFragment
:
class FlutterExampleFragment : FlutterFragment() {
}
If our Fragment needs dependencies, we declare them in the constructor:
class FlutterExampleFragment public constructor(
private val someViewModel: SomeViewModel
) : FlutterFragment() {
}
Now we need to write a builder, which, in turn, will create a fragment for us. Depending on whether we use or not use a cached engine, we inherit from the class
CachedEngineFragmentBuilder
orNewEngineFragmentBuilder
respectively.In the example, we will consider the first option (the only difference between the first and the second is the required parameter
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, which is built into the navigation graph, will be the container for our
FlutterFragment
. Since we are considering a situation where ourFlutterFragment
acts as the entire screen, let’s make sure that only what is needed is left in the layout of the parent 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>
We will pay special attention to the property tag
— this is how we will find a container for integration FlutterFragment.
The next goal is to implement
FlutterFragment
to parent fragmentclass 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 } }
Ways to integrate a Flutter module into a native application. iOS
A little theory
The “actors” in iOS are approximately the same as in Android:
FlutterEngine
— the already familiar Flutter engine, which is responsible for working with the Flutter module;FlutterViewController
is a controller that inherits fromUIViewController
and provides integration of the Flutter module into the iOS application.
The process of integrating a Flutter module into an iOS application can also be divided into several stages, which are generally similar to the stages on Android:
Creation
FlutterEngine
and its configuration;Since creation
FlutterEngine
The entire code of the Flutter module is executed, starting with the functionmain()
;Creation
FlutterViewController
and transferring to him what was createdFlutterEngine
;Contents of the module associated with the engine that was passed to
FlutterViewController
displayed insideView
which is associated with the controller.
Adding a screen
Let's add a Flutter screen to the iOS application.
Let's create the engine:
class FlutterDependencies: ObservableObject {
let flutterEngine = FlutterEngine(name: "id_of_engine")
init(){
/// Инициализирует движок с роутом по умолчанию и точкой входа main.
flutterEngine.run()
// Связывает плагины iOS с движком Flutter.
GeneratedPluginRegistrant.register(with: self.flutterEngine);
}
}
Let's add it as
EnvironmentObject
besidesView
where we plan to use it:window.rootViewController = UIHostingController( rootView: YourView().environmentObject(FlutterDependencies()) )
Let's add
FlutterDependencies
as a class field inView
:
struct TaskListView: View {
@EnvironmentObject var flutterDependencies: FlutterDependencies
...
}
Let's define a function that will display the Flutter screen:
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) }
We use the function as intended and get the desired screen:
Button(action: { showFlutter() }) { Text("Open Flutter") }
Receiving system events
To receive platform callbacks and maintain Flutter communication in debug mode with the application in a blocked state, you need to inherit AppDelegate
from FlutterAppDelegate
.
Adding a module as part of the screen
We can also add a module to an iOS app as part of the UI – similar to how Fragments work in Android.
The first three steps dedicated to the creation of a Flutter engine and its implementation in the screen, we repeat without changes.
Let's implement a wrapper that will house the module. This wrapper implements the protocol
UIViewControllerRepresentable
and this is exactly what we will insert into the layout. We specify the engine as a parameter – it is necessary to initialize the controller.
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) {
// Место для обновления конфигурации с учётом
// нового состояния приложения.
}
}
Place the wrapper inside the layout:
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"))
}
}
Data exchange between Flutter module and native code
So we learned how to integrate the Flutter module into the application. But is this enough? We talked about the example with the Trash screen – we definitely need to transfer data there. In this case, the question arises – how then to transfer data between the Flutter module and the native code?
There are two ways:
Input parameters | Platform channels | |
---|---|---|
Description | Passing parameters to | Data transfer via |
pros | Maximum ease of use | You can transfer data at any time |
Minuses | Limited functionality – you can transfer data only when the engine is initialized | Complexity (relative to input parameters) |
Input parameters
You just need to specify these parameters when initializing the engine.
With a cached engine. 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)
}
}
No cached engine
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);
}
}
From the Flutter side
void main(List<String> args) {
runApp(MyApp(args: args));
}
Platform channels
Using input parameters is not always convenient. For example, if you need to transfer data while the application is running. In such cases, it is worth paying attention to platform channels.
Android
First of all, you need to take care of passing parameters to the Activity.
Let's create our own Activity, whose parent will be
FlutterActivity
:
class FlutterEntryActivity : FlutterActivity() {}
Let's add it to
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"
/>
We will pass arguments (for example, from the previous screen) via
Extras
(this is not the only way, you can use any).Let's define a factory for creating an 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 закешированного движка } } }
Now we need to get the argument inside the Activity and pass it to the Flutter module:
/// ОБЯЗАТЕЛЬНО обратите внимание, что перегружаете именно ЭТОТ метод (с ЭТИМ аргументом). Иначе
/// этот метод вызываться не будет.
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)
}
It is important to understand that this data will be received asynchronously, and you will have to “wait” for it in the Flutter screen.
You can use platform channel data transfer throughout the entire lifecycle of the Activity.
To transfer data from a Flutter module to native code, we use
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
To transfer data to the Flutter module in iOS, we slightly modify the function 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")
}
And, accordingly, on the Flutter side:
MethodChannel _channel = MethodChannel('ios_app');
_channel.setMethodCallHandler((call) {
switch (call.method) {
case 'passArgs':
print(call.arguments);
break;
default:
throw MissingPluginException();
}
});
To receive data from a Flutter module into native code, install a method handler:
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)
++ }
++ }
++ )
}
Is it possible to use multiple Flutter modules in one application?
No. However, although only one module can be connected, there is no reason why that module cannot contain other modules.
Thus, the modules become dependencies of our root module, which will be integrated into the native application.
First, let's create modules in a child folder of the root module (they can be created anywhere, but it would be logical to keep them inside the main module). Modules should be created exactly as modules (
flutter create -t module --org com.example first_module
)
Thus we get the following structure:Let's define the entry points for our modules inside the main 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(); }
Running modules in a native application
Now it’s a small matter – you need to launch the module and specify the desired entry point.
Android
With engine caching
IN MainApplication.kt
(or any other file where the engine is initialized) we need to define one engine per module that we plan to run:
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)
}
}
No engine caching
Unfortunately, the factory FlutterActivity
with the creation of a new engine does not allow specifying an entrypoint function. Therefore, let's take a different route.
Create your own Activity inherited from
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() {
}
Let's override the method
getDartEntrypointFunctionName
:class FlutterEntryActivity : FlutterActivity() { override fun getDartEntrypointFunctionName() :String { return "startFirstModule" } }
Now we can start the Activity:
startActivity( context, FlutterActivity.NewEngineIntentBuilder( FlutterEntryActivity::class.java).build(context), null, )
iOS
Just add entrypoint to the function
run
our engine:
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)
}
}
}
It would seem that this is all, but there is still room for improvement.
Using FlutterEngineGroup
When using several engines or modules, it’s a sin not to use FlutterEngineGroup
.
What is FlutterEngineGroup
An entity that contains a set of engines and provides each of them with access to shared resources (assets, Flutter sources) – thus, many things common to different modules are loaded only once and reused subsequently.
Therefore, it is ideal for our case – several modules in one application.
Android
Let's agree that the place to initialize our engines is
Application
-Class. Let's create a field forFlutterEngineGroup
and also create an id for our engines:
class MainApplication : Application() {
// Группа движков с общим скоупом ресурсов.
lateinit var engineGroup: FlutterEngineGroup
// Id движков, которые мы будем использовать.
companion object Factory {
const val firstNoduleEngineId = "first_engine"
const val secondNoduleEngineId = "second_engine"
}
}
Now let's do the initialization:
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,
)
}
Next we use the engines in the same way as above. The only thing that should be taken into account is that new engines must be created using
engineGroup
. Thus, we use the resources of already created engines and save memory and time.
iOS
Let's refactor our class FlutterDependencies
created earlier:
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)
}
}
Debugging
To begin with, nothing prevents you from launching the module by itself (except for its potential communication with the platform, which, obviously, will not happen without a native application running on top). Therefore, this option is not suitable for us in most cases.
Therefore, we will test our module within a native application. And we will do this with all the conveniences to which we are accustomed as Flutter developers.
How it works
To debug we will use the command flutter attach
. It looks for processes that create the ones we run FlutterEngine
and “attaches” to them, allowing us to perform hot restart/reload, read logs and more.
We launch the native application.
Let's make sure that we are at the point in the application where the engine of the module we want to test is precisely initialized.
Call the team
flutter attach
.Select the device on which the application is running.
If at this moment several engines are initialized in the application or another Flutter application is running on the device (or such an application was launched earlier), we will see something like this:
In this case it is necessary:
specify the id of the target application;
if the application is present in the list several times, select the most recent port (this behavior has currently been noticed only on iOS (more details here))
Done, now we can use DevTools, hot restart/reload and other delights of debug mode in Flutter.
Now that's it for sure
We hope this information will be enough for a basic understanding of how Flutter Add-to-App works.
For those who find the topic interesting, keep small repositorywhich contains the code examples described in the article.
More useful information about Flutter can be found in the Surf Flutter Team Telegram channel.
Cases, best practices, news and vacancies for the Flutter Surf team in one place. Join us!