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

  1. Чтобы собрать .aar из Flutter-модуля, вызываем следующую команду в директории с ним:

         flutter build aar
  2. В выводе этой команды будут конфигурационные строки, предназначенные для вставки в 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-файлы

  1. Вставляем следующий код в 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. Код ниже — в app/build.gradle внутрь блока dependencies:

         implementation project(':flutter')

Проект использует .gradle.kts файлы

Подробнее про миграцию с gradle на gradle.kts читайте тут.

  1. Создаём в корне 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
     ))            
  2. Теперь импортируем этот файл в settings.gradle.kts:

     apply("flutter_init.gradle")
  3. В блоке 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"
++        )
      }
    }
  1. Вставляем зависимости в app/build.gradle.kts (в блок dependencies):

    implementation(project(":flutter"))

Добавление модуля в iOS-проект

Как и в случае с Android, есть два (на самом деле, больше, но они чаще бывают вариациями этих двух) способа добавить Flutter-модуль в нативное iOS-приложение:

Как зависимость CocoaPods

Как фреймворк

Плюсы

Можно оперативно вносить изменения без необходимости пересобирать модуль

Не нужен установленный Flutter SDK

Минусы

Нужен установленный Flutter SDK

Каждый раз необходимо заново собирать модуль при изменениях

Зависимость CocoaPods

Чтобы изменения в исходном коде Flutter-модуля вступали в силу при пересборке нативного приложения, добавляем зависимость от исходного кода Flutter-модуля в iOS-проект.

  1. Добавляем в начало 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.

  2. Добавляем действие в блок target 'MyApp' do (MyApp — это название приложения):

    target 'MyApp' do
        ...
++      install_all_flutter_pods(flutter_application_path)
    end
  1. Добавляем действие в блок post_install:

    post_install do |installer|
        ...
++      flutter_post_install(installer) if defined?(flutter_post_install)
    end
  1. Выполняем команду pod install.

  2. ОБЯЗАТЕЛЬНО:

    • перезапускаем XCode, если он был открыт;

    • выполняем команду rm -rf ~/Library/Developer/Xcode/DerivedData — так мы удаляем все промежуточные результаты сборок, сделанных Xcode раньше, и заставляем Xcode «заняться» нашим билдом с чистого листа;

    • открываем XCode с помощью команды open MyApp.xcworkspace.

  3. Если всё прошло успешно, то при попытке собрать приложение мы видим заветное Build Succeeded.

Embedded Framework

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

  1. Вызываем следующую команду в папке с Flutter-модулем и указываем путь к папке Flutter внутри iOS-приложения:

     flutter build ios-framework --output=path/to/your/ios_app/MyApp/Flutter/
  2. Переходим в Targets->Ваше_приложение->Build Settings и ищем "Framework search paths".

  1. Устанавливаем значения "$(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-модуль в фрагмент.

  1. Для начала добавим фрагмент в 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. Установим FragmentActivity как базовый класс для той Activity, в которой мы планируем использовать Fragment:

    class MainActivity : FragmentActivity() {
        ...
    }
  1. Интегрируйте 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-модуль в один из фрагментов (экранов).

  1. Создадим сперва наш FlutterFragment:

class FlutterExampleFragment : FlutterFragment() {

}

В случае, если нашему Fragment нужны зависимости, объявляем их в конструкторе:

class FlutterExampleFragment public constructor(
    private val someViewModel: SomeViewModel
) : FlutterFragment() {

}
  1. Теперь нам необходимо написать билдер, который, в свою очередь, будет создавать для нас фрагмент. В зависимости от того, используем или не используем ли мы кешированный движок, наследуемся от класса 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
    }
}
  1. 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.

  1. Следущая цель — внедрить 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-экран.

  1. Создадим движок:

class FlutterDependencies: ObservableObject {
    let flutterEngine = FlutterEngine(name: "id_of_engine")
    init(){
        /// Инициализирует движок с роутом по умолчанию и точкой входа main.
        flutterEngine.run()
        // Связывает плагины iOS с движком Flutter.
        GeneratedPluginRegistrant.register(with: self.flutterEngine);
    }
}
  1. Добавим его как EnvironmentObject к тому View, где планируем его использовать:

     window.rootViewController = UIHostingController(
         rootView: YourView().environmentObject(FlutterDependencies())
         )
  2. Добавим FlutterDependencies как поле класса в View:

    struct TaskListView: View {
        @EnvironmentObject var flutterDependencies: FlutterDependencies
        ...
    }
  1. Определим функцию, которая будет вызывать отображение 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)
    }
  2. Используем функцию по назначению и получаем желаемый экран:

        Button(action: {
                    showFlutter()
                }) {
                    Text("Open Flutter")
                }

Получение системных событий

Чтобы получать платформенные коллбэки и поддерживать в дебаг-режиме связь Flutter с приложением в заблокированном состоянии, необходимо унаследовать AppDelegate от FlutterAppDelegate.

Добавление модуля как части экрана

Мы также можем добавить модуль в iOS-приложение в качестве части пользовательского интерфейса — подобно тому, как работают Fragment в Android.

Первые три шага, посвящённые созданию Flutter-движка и его внедрению в экран, повторяем без изменений.

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

    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-модулем и нативным кодом?

Есть два способа:

Входные параметры

Платформенные каналы

Описание

Передача параметров в main()

Передача данных через MethodChannel, EventChannel

Плюсы

Максимальная простота в использовании

Передавать данные можно в любой момент

Минусы

Ограниченный функционал — можно передать данные только при инициализации движка

Сложность (относительно входных параметров)

Входные параметры

Надо всего лишь указать эти параметры при инициализации движка.

С кешированным движком. 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.

  1. Создадим собственную Activity, чьим родителем будет FlutterActivity:

class FlutterEntryActivity : FlutterActivity() {}
  1. Добавим её в 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. Передавать аргументы (например, с прошлого экрана) мы будем через 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 закешированного движка
           }
         }
     }
  2. Теперь нам нужно достать аргумент внутри 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.

  1. Для передачи данных из 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-модулей в одном приложении?

Нет. Однако несмотря на то, что можно подключить только один модуль, нет причин, по которым этот модуль не может содержать в себе другие модули.

Таким образом, модули становятся зависимостями нашего корневого модуля, который и будет интегрирован в нативное приложение.

  1. Для начала создадим модули в дочерней папке корневого модуля (их можно создавать где угодно, но будет логично держать их внутри основного модуля). Модули следует создавать именно как модули (flutter create -t module --org com.example first_module)
    Таким образом мы получим следующую структуру:

  2. Определим входные точки для наших модулей внутри основного модуля:

       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-функцию. Поэтому пойдём другим путём.

  1. Создайте собственную 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() {
 }
  1. Переопределим метод getDartEntrypointFunctionName:

     class FlutterEntryActivity : FlutterActivity() {
         override fun getDartEntrypointFunctionName() :String {
             return "startFirstModule"
         }
     }
  2. Теперь мы можем запустить Activity:

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

iOS

  1. Просто добавим 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

  1. Условимся, что место для инициализации наших движков — это Application-класс. Создадим поле для FlutterEngineGroup, а также заведём id для наших движков:

class MainApplication : Application() {
    // Группа движков с общим скоупом ресурсов.
    lateinit var engineGroup: FlutterEngineGroup

    // Id движков, которые мы будем использовать.
    companion object Factory {
        const val firstNoduleEngineId = "first_engine"
        const val secondNoduleEngineId = "second_engine"
    }
}
  1. Теперь займёмся инициализацией:

    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. Дальше используем движки так же, как выше. Единственное, что следует учесть — новые движки должны быть созданы именно с ипользованием 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, читать логи и другое.

  1. Запускаем нативное приложение.

  2. Убедимся, что мы находимся в той точке приложения, где точно проницилизирован движок того модуля, который мы хотим протестировать.

  3. Вызываем команду flutter attach.

  4. Выбираем устройство, на котором запущено приложение.

  5. Если в этот момент в приложении проиницилизировано несколько движков или на устройстве запущено ещё одно Flutter-приложение (или такое приложение было запущено раньше), мы увидим нечто такое:

В таком случае необходимо:

  • указать id целевого приложения;

  • если приложение присутствует в списке несколько раз, выбираем самый «свежий» порт (такое поведение в настоящий момент было замечено только на iOS (подробнее тут))

  1. Готово, теперь мы можем использовать DevTools, hot restart/reload и другие прелести дебаг-режима в Flutter.

Теперь точно всё

Надеемся, этой информации будет достаточно для базового понимания того, как работает Flutter Add-to-App.

Для тех, кому тема показалась интересной, держите небольшой репозиторий, в котором есть примеры кода, описанные в статье.

Больше полезного про Flutter — в Telegram-канале Surf Flutter Team. 

Кейсы, лучшие практики, новости и вакансии в команду Flutter Surf в одном месте. Присоединяйтесь!

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