Things take time

[Flutter] iOS 프로젝트에 Flutter 모듈 import 및 FlutterViewController 넣기(import Flutter Module to Swift/add FlutterView) 본문

Flutter

[Flutter] iOS 프로젝트에 Flutter 모듈 import 및 FlutterViewController 넣기(import Flutter Module to Swift/add FlutterView)

겸손할 겸 2022. 5. 24. 14:12

[개요]

처음부터 플러터 프로젝트로 만들어서 각각의 네이티브 플랫폼 코드를 사용한다면 플랫폼 채널을 통해 각각의 함수를 호출할 수 있고, FlutterPlatformView를 상속받는 뷰를 넣을수도 있다.

 

그런데 만약 기존에 iOS/Android로 개발되어 있는 앱에, 플러터로 된 뷰를 넣고 싶다면? 그 플러터로 된 모듈을 공통으로 작업해서 배포하며 사용하도록하고, 이외에는 각 OS별로 개발하고싶다면 이 글을 참고하도록하자.

 

[참고]

https://docs.flutter.dev/development/add-to-app/ios/project-setup

 

Integrate a Flutter module into your iOS project

Learn how to integrate a Flutter module into your existing iOS project.

docs.flutter.dev

 

https://docs.flutter.dev/development/add-to-app/ios/add-flutter-screen?tab=initial-route-swift-tab 

 

Adding a Flutter screen to an iOS app

Learn how to add a single Flutter screen to your existing iOS app.

docs.flutter.dev

그냥 공홈대로 보면서 하면 되긴하는데, 참고용으로 작성하는 포스팅!

사실 중간 내용들이 이해가 안되서 삽질하며 정리한다.

 

[Create Flutter Module And Add Modult to iOS Project]

먼저 아래의 명령어를 통해 플러터 모듈 템플릿을 만든다.

cd some/path/
flutter create --template module my_flutter

some/path/는 개인적으로 지정하면되나, 나같은 경우에는 이미 iOS프로젝트가 있다 가정했으므로, iOS프로젝트와 같은 경로로 두었다.

MyApp은 iOS프로젝트, my_flutter는 플러터 모듈이다. module이란 공통 디렉토리에 두 개의 프로젝트가 있는 셈.

실제 my_flutter디렉토리 내 히든 폴더들을 살펴보면 .ios폴더가 보인다. 원래 플러터 프로젝트를 생성하면 android/ios와 같은 폴더들이 자동적으로 생성되는데, 모듈로 생성했기 때문에 이처럼 보인다. Podfile에서 보겠지만, 실제 iOS는 다른 프로젝트에 있기때문에 이 지점과 실제 프로젝트간 연결을 하게 될 것이다.

 

다음은 실제 iOS프로젝트와 플러터 모듈간의 연결을 위해 다음과 같은 Podfile을 수정한다. 참고로 Podfile이 없는 기본프로젝트(워크스페이스 파일이 없는 경우)이라면 다음과 같이 Podfile을 생성해야한다.

cd some/path/
touch Podfile
open -e Podfile

https://guides.cocoapods.org/using/using-cocoapods.html

 

CocoaPods Guides

CocoaPods is fully open-sourced, so it depends on community contributions to get better. If you're looking to start working on CocoaPods, this is the place to start.

guides.cocoapods.org

그리고 Podfile을 다음과 같이 작성한다.(platform과 target은 알맞게)

platform :ios, '9.0'

flutter_application_path = '../my_flutter'
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')

target 'MyApp' do
  install_all_flutter_pods(flutter_application_path)
end

post_install do |installer|
  flutter_post_install(installer) if defined?(flutter_post_install)
end

platform 버전은 개발할 프로젝트 deployment target과 맞추고, target도 프로젝트명으로 맞추면 된다. 위의 Podfile은 아무런것도 넣지 않은 기본 Podfile 템플릿이다. 그리고 아래의 명령어를 호출하면 기본 설정은 끝이다.

pod install

Podfile을 살펴보면, my_flutter라는 path에서 Flutter/podhelper.rb라는 파일과 join한다.

그리고 flutter_pods를 설치하는 것으로 보이는데 위에서 말한 히든파일을 열어보면 아래와 같다.

 

즉, 저 Flutter/podhelper.rb파일이 보이는데 이 파일이 실제 iOS프로젝트와 연결하는 것으로 보인다. 여기까지가 플러터 모듈을, 껍데기를 씌워주는 기본 작업은 완료다.

 

[Add Flutter View]

이제 플러터의 뷰를 iOS프로젝트에 넣어보자.

 

1. FlutterEngine 예열하기

플러터엔진이란 객체를 미리 예열하라는 식으로 적혀있는데, Flutter UI를 표시할때는 적지않은 대기 시간비용이 있다고 한다. 그래서 플러터 엔진을 미리 시작하면 이 비용을 절약할수 있다고 한다.

 

AppDelegate의 didFinishLaunchingWithOptions에서 작성한다.

import UIKit
import Flutter
// Used to connect plugins (only if you have plugins with iOS platform code).
import FlutterPluginRegistrant

@UIApplicationMain
class AppDelegate: FlutterAppDelegate { // More on the FlutterAppDelegate.
  lazy var flutterEngine = FlutterEngine(name: "my flutter engine")

  override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Runs the default Dart entrypoint with a default Flutter route.
    flutterEngine.run();
    // Used to connect plugins (only if you have plugins with iOS platform code).
    GeneratedPluginRegistrant.register(with: self.flutterEngine);
    return super.application(application, didFinishLaunchingWithOptions: launchOptions);
  }
}

플러터 모듈이 정상적으로 Pod에 들어왔다면, import가 가능해지고 코드를 보면 기본 iOS AppDelegate의 상속 부분이 달라지는데, FlutterAppDelegate를 들어가서 보면

@interface FlutterAppDelegate
    : UIResponder <UIApplicationDelegate, FlutterPluginRegistry, FlutterAppLifeCycleProvider>

기존에 있던UIResponder, UIApplicationDelegate에 FltuterAppLifeCycleProvider가 추가적으로 있는 것으로 알 수 있다. Swift는 다중상속을 지원하지않는다.

 

즉, UIApplicationDelegate/FltuterAppLifeCycleProvider는 클래스가 아닌 프로토콜이기 때문에 다중상속처럼 보이는 것이지만, 어쨌든 기존 상속부분을 FlutterAppDelegate로 통째로 교체하거나, 아니면 기존 상속에 FltuterAppLifeCycleProvider 프로토콜을 추가적으로 상속받아 추가적으로 함수를 오버라이드하여 적용할 수 있다.(위 가이드 링크 참고)

 

어쨌든, 예열이란 작업은 FlutterEngine의 instance를 먼저 선언하고, 사용하란 의미와 같다. 사용방법은 아래와 같다.

import UIKit
import Flutter

class ViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()

    // Make a button to call the showFlutter function when pressed.
    let button = UIButton(type:UIButton.ButtonType.custom)
    button.addTarget(self, action: #selector(showFlutter), for: .touchUpInside)
    button.setTitle("Show Flutter!", for: UIControl.State.normal)
    button.frame = CGRect(x: 80.0, y: 210.0, width: 160.0, height: 40.0)
    button.backgroundColor = UIColor.blue
    self.view.addSubview(button)
  }

  @objc func showFlutter() {
    let flutterEngine = (UIApplication.shared.delegate as! AppDelegate).flutterEngine
    let flutterViewController =
        FlutterViewController(engine: flutterEngine, nibName: nil, bundle: nil)
    present(flutterViewController, animated: true, completion: nil)
  }
}

소스를 보면, iOS프로젝트 내 뷰 컨트롤러에 버튼이 있고 버튼 클릭이벤트인 showFlutter만 보면 된다.

AppDelegate에 선언된 flutterEngine이란 변수를 가져와, 해당 엔진을 바탕으로 생성된 FlutterViewController 객체인 flutterViewController를 present로 띄우는 간단한 소스다. 이 작업을 수행하면, my_flutter의 main()함수에 있는 run()에 있는 해당 화면을 띄운다.

 

물론 위의 showFlutter대신 아래와 같은 직접 호출로도 할 수 있다.

// Existing code omitted.
func showFlutter() {
  let flutterViewController = FlutterViewController(project: nil, nibName: nil, bundle: nil)
  present(flutterViewController, animated: true, completion: nil)
}

단, 이처럼 사용할 경우는 AppDelegate에 선언된 FlutterEngine객체를 사용하지않고 직접 사용하기 때문에, 권장하진 않는다. flutter화면이 사용되는 경우가 정말 적은 경우나, 뷰 컨트롤러와 플러터 뷰컨트롤러간의 상태를 유지할 필요가 없을 경우 등에 사용한다고 한다. 

 

[Custom]

사실 위의 코드까지는 기본예제와 다름없다. 순서를 정리해보자면, AppDelegate에서 FlutterEngine 인스턴스를 선언해주고, 가져다 쓸 곳에서 해당 엔진을 바탕으로 FlutterViewController객체를 생성해주고, 해당 화면을 present하여 새창으로 띄운다.

 

여기서 생각해 볼것이 있다. 

 

1. FlutterEngine객체는 AppDelegate에서만 사용가능한 것인가 -> X

실제 ViewController에서 FlutterViewController를 생성하기 전에 바로 선언하여 사용해도 된다. 실제 도큐먼트에서도 AppDelegate에서 선언하는 것도 한 가지 방법이라 설명하고 있다. 

 

2. 위의 예제는 main()에 있는 기본 뷰를 띄워주는데, 만약 Flutter에서 만든 다른 화면이 있고 다른 화면을 띄울수 있는가 -> O

flutterEngine객체의 run부분을 다음과 같이 사용하면 된다.

        flutterEngine.run(withEntrypoint: nil, initialRoute: "/gyeom")

여기서 /gyeom으로 라우팅된 위치로 엔진이 실행된다. 당연하겠지만, 이는 플러터에서도 코드 작업을 해야한다.

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        // This is the theme of your application.
        //
        // Try running your application with "flutter run". You'll see the
        // application has a blue toolbar. Then, without quitting the app, try
        // changing the primarySwatch below to Colors.green and then invoke
        // "hot reload" (press "r" in the console where you ran "flutter run",
        // or press Run > Flutter Hot Reload in a Flutter IDE). Notice that the
        // counter didn't reset back to zero; the application is not restarted.
        primarySwatch: Colors.blue,
      ),
      initialRoute: '/',
      routes: {
        '/gyeom': (context) {
          return const GyeomPage();
        }
      },
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

위 처럼, /gyeom이란 라우팅 주소로 넘어오면 GyeomPage()를 호출하고 있다. MyHomePage()가 아님을 유의하자.

 

3. 만약, 여러개의 플러터 화면을 띄우고 싶다면 FlutterEngine객체의 run부분을 재정의하여 initialRoute부분을 바꾸면 되는가? -> X

만약 버튼이 여러 개이고, 각각의 버튼에서는 플러터에서 만든 화면중 하나 하나에 매핑을 하고싶다면, 아래와 같이 사용을 생각해 볼 수 있다.(삽질)

        flutterEngine.run(withEntrypoint: nil, initialRoute: "/gyeom")
        
        ..
        
       
        flutterEngine.run(withEntrypoint: nil, initialRoute: "/")

이런식으로, 사용하는 위치에 하나의 flutterEngine을 불러와 재정의하며 라우팅할 수 있을 거라 생각했으나 불가하다. 내가 이해한 바로는 각 플러터 엔진에는 하나의 라우팅만 허용되므로 아래와 같이 사용한다. 

class AppDelegate: FlutterAppDelegate {
    let engineGroup = FlutterEngineGroup(name: "my flutter engine group", project: nil)
    var firstEngine: FlutterEngine?
    var secondEngine: FlutterEngine?
    
    override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
        firstEngine = engineGroup.makeEngine(withEntrypoint: nil, libraryURI: nil, initialRoute: "/gyeom")
        secondEngine = engineGroup.makeEngine(withEntrypoint: nil, libraryURI: nil, initialRoute: "/")
        
        GeneratedPluginRegistrant.register(with: firstEngine!)
        GeneratedPluginRegistrant.register(with: secondEngine!)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}

FlutterEngineGroup이란 것을 만들고, 해당 인스턴스에서 makeEngine을 호출하여 각각의 엔진을 생성, GeneratedPluginRegistrant으로 등록한다. 저 makeEngine이란 함수에 들어가보면 Creates a running `FlutterEngine` that shares components with this group. 으로 이 그룹으로 묶인 것들은 컴포넌트를 공유하며 running중인 FlutterEngine을 생성한다 되어있다. 그래서 그런지 이전에 했던 코드처럼 flutterEngine.run(with..) 함수를 호출하지 않고 makeEngine으로도 되었다.

 

위의 코드는 러프하게 되어있으므로, 엔진 개수나 등록같은 경우엔 클래스내 생성자를 통해 동적으로 만들어 지게 놓는 것이 가장 좋다. 단순히 보기 좋게 AppDelegate내 하드코드로 작성되었음을 참고하자.

 

그리고 ViewController에서는 아래와 같이 사용할 수 있다.

override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        // Make a button to call the showFlutter function when pressed.
        let button = UIButton(type:UIButton.ButtonType.custom)
        button.addTarget(self, action: #selector(showFlutter), for: .touchUpInside)
        button.setTitle("Show Flutter!", for: UIControl.State.normal)
        button.frame = CGRect(x: 80.0, y: 210.0, width: 160.0, height: 40.0)
        button.tag = 1
        button.backgroundColor = UIColor.blue
        self.view.addSubview(button)
        
        
        let button2 = UIButton(type:UIButton.ButtonType.custom)
        button2.addTarget(self, action: #selector(showFlutter), for: .touchUpInside)
        button2.tag = 2
        button2.setTitle("Show Flutter!", for: UIControl.State.normal)
        button2.frame = CGRect(x: 80.0, y: 260.0, width: 160.0, height: 40.0)
        button2.backgroundColor = UIColor.red
        self.view.addSubview(button2)
    }

    @objc func showFlutter(sender: UIButton) {
        if sender.tag == 1{
          if let flutterEngine = (UIApplication.shared.delegate as! AppDelegate).firstEngine{
                let flutterViewController = FlutterViewController(engine: flutterEngine, nibName: nil, bundle: nil)
                present(flutterViewController, animated: true, completion: nil)
            }
        }else{
            if let flutterEngine = (UIApplication.shared.delegate as! AppDelegate).secondEngine{
                let flutterViewController = FlutterViewController(engine: flutterEngine, nibName: nil, bundle: nil)
                present(flutterViewController, animated: true, completion: nil)
            }
        }
    }

이런식으로 하면, 각 버튼을 누를때 마다 플러터로 만든 각기 다른 화면이 노출될 것이다.

앱 실행했을 때의 ViewController내 두개의 버튼
첫 번째 버튼 클릭시, AppDelegate내 firstEngine("/gyeom")으로 호출이므로, GyeomPage()라는 위젯이 나온다.
두 번째 버튼 클릭시, AppDelegate내 secondEngine("/")으로 호출이므로, 기본 템플릿 화면이 나온다.