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 FlutterActivitywhich is set as the main Activityand in the case of iOS – FlutterViewControllerwhich 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.

  1. To build .aar from a Flutter module, call the following command in the directory with it:

         flutter build aar
  2. 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 block dependencyResolutionManagement 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

  1. 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
     ))            
  2. The code below is in app/build.gradle inside the block dependencies:

         implementation project(':flutter')

The project uses .gradle.kts files

Read more about migration from gradle to gradle.kts here.

  1. 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
     ))            
  2. Now let's import this file into settings.gradle.kts:

     apply("flutter_init.gradle")
  3. 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"
++        )
      }
    }
  1. Inserting dependencies into app/build.gradle.kts (per block dependencies):

    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.

  1. 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 Podfileexecute the command pod init.

    Related commands podmay not run if Project Format is set to Xcode 14.0-compatible. We downgrade the version to 13.

  2. 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
  1. Adding an action to the block post_install:

    post_install do |installer|
        ...
++      flutter_post_install(installer) if defined?(flutter_post_install)
    end
  1. Execute the command pod install.

  2. 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.

  3. 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:

  1. 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/
  2. Let's go to Targets->Ваше_приложение->Build Settings and looking for "Framework search paths".

  1. 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 from Activity And Fragment accordingly, and provide methods for managing the application lifecycle. Both of these classes also implement the interface FlutterEngineProviderwhich gives them access to FlutterEngine.

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 function main());

    • 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 function main();

  • Creation FlutterActivity or FlutterFragment and transferring what they created FlutterEngine;

  • Contents of the module associated with the engine that was passed to FlutterActivity or FlutterFragmentdisplayed inside FlutterActivity or FlutterFragment.

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 mainexecuted when calling the function flutterEngine.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.

  1. 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" />
  1. Let's install FragmentActivity as a base class for the Activity in which we plan to use Fragment:

    class MainActivity : FragmentActivity() {
        ...
    }
  1. 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).

  1. 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() {

}
  1. 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 or NewEngineFragmentBuilder 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
    }
}
  1. Fragment, which is built into the navigation graph, will be the container for our FlutterFragment. Since we are considering a situation where our FlutterFragment 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.

  1. The next goal is to implement FlutterFragment to parent fragment

    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
      }
    }

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 from UIViewController 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 function main();

  • Creation FlutterViewController and transferring to him what was created FlutterEngine;

  • Contents of the module associated with the engine that was passed to FlutterViewControllerdisplayed inside Viewwhich is associated with the controller.

Adding a screen

Let's add a Flutter screen to the iOS application.

  1. 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);
    }
}
  1. Let's add it as EnvironmentObject besides Viewwhere we plan to use it:

     window.rootViewController = UIHostingController(
         rootView: YourView().environmentObject(FlutterDependencies())
         )
  2. Let's add FlutterDependencies as a class field in View:

    struct TaskListView: View {
        @EnvironmentObject var flutterDependencies: FlutterDependencies
        ...
    }
  1. 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)
    }
  2. 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.

  1. 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) {
            // Место для обновления конфигурации с учётом 
            // нового состояния приложения.
        }
    }
  1. 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 main()

Data transfer via MethodChannel, EventChannel

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.

  1. Let's create our own Activity, whose parent will be FlutterActivity:

class FlutterEntryActivity : FlutterActivity() {}
  1. 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"
  />
  1. 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 закешированного движка
           }
         }
     }
  2. 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.

  1. 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.

  1. 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:

  2. 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.

  1. 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() {
 }
  1. Let's override the method getDartEntrypointFunctionName:

     class FlutterEntryActivity : FlutterActivity() {
         override fun getDartEntrypointFunctionName() :String {
             return "startFirstModule"
         }
     }
  2. Now we can start the Activity:

         startActivity(
             context,
             FlutterActivity.NewEngineIntentBuilder(
                 FlutterEntryActivity::class.java).build(context),
             null,
         )

iOS

  1. 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

  1. Let's agree that the place to initialize our engines is Application-Class. Let's create a field for FlutterEngineGroupand 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"
    }
}
  1. 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,
        )
    }
  1. 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 FlutterDependenciescreated 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.

  1. We launch the native application.

  2. 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.

  3. Call the team flutter attach.

  4. Select the device on which the application is running.

  5. 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))

  1. 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!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *