문득 Flutter 작업을 하면서, 네이티브에서는 어떻게 띄워지는지 궁금해졌다.
Flutter 는 skia 그래픽 엔진을 통해 각 플랫폼에 종속되지 않고 화면을 자체적으로 그려준다.
물론 이렇게는 알고 있다.
void main() {
runApp(const MyApp());
}
main 함수를 통해 시작되어, MyApp 이라는 Widget 를 켜줌으로써 flutter 앱은 시작된다.
그런데 어떻게 Android 에서 인식되어 앱이 실행되는걸까?
flutter 프로젝트를 생성하며 같이 생기는 .android 모듈로 들어가 보았다.
아래는 AndroidManifest.xml 내용이다.
<application
android:label="playground"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<!-- key point -->
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
여기서 <!-- key point -->
아래 intent-filter
로 인해 MainActivity 가 앱의 시작점이 된다.
그럼 MainActivity 는 뭘까? 아래 코드이다.
package com.example.playground
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity() {
}
위 내용을 통해 flutter 로 android 앱을 빌드하게 되면 MainActivity 를 실행하고
MainActivity 자체는 Flutter 와 연관이 있다는 걸 알 수 있다.
그런데.... MainActivity 내용이 너무 빈약하다.
보다시피 MainActivity 코드는 달랑 저거인데, flutter UI 를 보여주고 있다.
어떻게 가능한걸까? 공식문서와 코드를 좀 더 확인할 필요를 느꼈다.
이제 본론으로 들어가 FlutterActivity 코드를 파헤쳐보려 한다.
IDE 상에서 직접 타고 들어가 볼 수도 있고, 공식문서에서도 볼 수 있다.
1차적으론 공식문서 내용을 참고하려 한다.
먼저 정의를 보자면 이렇다.
전체 화면 Flutter UI 를 표시
하는 Activity여기서는 제법 실험해보고 싶은 것들이 있었다. 하나씩 실험해보려 한다.
FlutterActivity가 실행하는 진입점을 변경하려면
FlutterActivity를 하위 클래스로 만들고, getDartEntrypointFunctionName()
을 재정의 해야 한다.
먼저 getDartEntrypointFunctionName() 에 대해 확인해보았다.
// FlutterActivity.kt (내장 코드) - 내가 짠 코드 아님 ㅇㅅㅇ..
@NonNull
public String getDartEntrypointFunctionName() {
try {
// 1
Bundle metaData = getMetaData();
// 2
String desiredDartEntrypoint =
metaData != null ? metaData.getString(DART_ENTRYPOINT_META_DATA_KEY) : null;
return desiredDartEntrypoint != null ? desiredDartEntrypoint : DEFAULT_DART_ENTRYPOINT;
} catch (PackageManager.NameNotFoundException e) {
return DEFAULT_DART_ENTRYPOINT;
}
}
getMetaData()
는 AndroidManifest.xml
에 명세된 meta 데이터들을 Bundle 형태로 가져온다.<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
뭘 가져오는지 쉽게 알 수 있었다. io.flutter.Entrypoint
키값에 매치되는 값을 가져온다.main
을 반환하게 된다.main
... dart 코드에서 본 것 같지 않은가?
이를 통해 android 모듈에서 어떻게 flutter dart 코드의 Screen 을 불러내는지 알 수 있었다.
여기서 더 나아가 간단한 코드로 실험해보았다.
// main.dart
void main() {
runApp(const MyApp());
}
// 1
void mainSecond() {
runApp(const SecondScreen());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
initialRoute: '/',
routes: {
'/': (context) => const FirstScreen(),
'/second': (context) => const SecondScreen(),
},
);
}
}
// ...
// android 모듈 내 MainActivity.kt
class MainActivity : FlutterActivity() {
override fun getDartEntrypointFunctionName(): String {
Log.i("riflockle7", super.getDartEntrypointFunctionName())
// 1
return "mainSecond"
}
}
mainSecond()
함수를 새로 만들어주었다.
그리고 MainActivity.kt 에서는 강제로 mainSecond
를 반환하도록 해주었다.
(// 1
코드 참고)
이렇게 수정한 뒤 flutter 프로젝트에서 run 을 해주니 SecondScreen 이 실행되었다.
android 폴더에서 빌드 후 실행해도 결과는 동일하다.
android 폴더에서 run 시 주의할 점
코드 수정 시 flutter 가 아닌 android 폴더에서만 run 을 실행하면 변경사항이 반영되지 않는다.
예를 들어 위의 코드 상태에서 Log 내용과 mainSecond 함수명을 수정한 뒤 android 폴더에서 run 을 하면 수정 내용이 반영되지 않는다.수정 내용을 반영하려면 flutter app 에서 build 및 run 을 실행해야 한다.
위 내용은 응용하여 getDartEntrypointFunctionName() 재정의 필요 없이
AndroidManifest 에 io.flutter.EntryPoint
메타데이터를 추가하여 처리할 수 있다.
<meta-data
android:name="io.flutter.Entrypoint"
android:value="mainSecond" />
<activity>
태그 내에 위의 meta 데이터를 추가하면, 강제로 mainSecond 문자열을 반환하지 않아도 된다.
Dart 진입점의 인수는 문자열 목록으로 Dart의 진입점 함수에 전달된다.
FlutterActivity.NewEngineIntentBuilder.dartEntrypointArgs 를 통해
FlutterActivity.NewEngineIntentBuilder
를 사용하여 전달할 수 있다.
(초기 경로(initialRoute) 제어에도 블록 내용이 사용된다.)
엔진 직접 설정으로는 애로사항이 있었다.
다만 MainActivity 내에 아래 함수를 재정의하면 main 함수에 인수를 받는 것을 확인했다.
// main.dart
void main(List<String> args) {
print('riflockle7 $args');
runApp(const MyApp());
}
// android 모듈 내 MainActivity.kt
class MainActivity : FlutterActivity() {
override fun getDartEntrypointArgs(): MutableList<String> {
return mutableListOf("비둘기")
}
}
dart 의 main 함수에도 문자열 배열을 인수를 받을 수 있도록 해야한다/
이렇게 처리하면 "비둘기" 항목이 있는 배열을 dart 에서 받을 수 있다.
getDartEntrypointArgs() 함수 원본을 보면 dart_entrypoint_args
을 getExtra 형태로 가져온다.
이를 응용하여 처리도 가능해 보였는데, 이는 포스팅 올린 후 확인해보려 한다.
처음 로드되는 Flutter 경로는 "/" 이다.
초기 경로는 FlutterActivityLaunchConfigs.EXTRA_INITIAL_ROUTE 에서 문자열로 경로 이름을 전달하여 명시적으로 지정할 수 있다.
class MainActivity : FlutterActivity() {
override fun getInitialRoute(): String {
return "/second"
}
}
이 역시 이런식으로 처리는 가능해보이지만, MaterialApp 에서 initialRoute 를 설정하는 게 더 좋아 보였다.
이렇게 FlutterActivity 하위 클래스에서 Dart 진입점, Dart 진입점 인수 및 초기 경로 등을 제어할 수 있다.
FlutterEngine 에 대한 내용도 나오는 데, 요 내용은 깊게 들어가면 산으로 갈 것 같아 다른 포스트에서 언급하려 한다.
일단 이 포스트에서는 새로운 FlutterEngine 을 만드는 대신, 캐시된 FlutterEngine
을 사용할 수 있다 정도만 언급하려 한다.
FlutterFragment
와 FlutterView
를 사용할 수 있다.
이 경우에도 android 의 생명주기와 관련된 코드 작업이 필요할 수 있다.
Android 테마를 사용하여 LaunchTheme, NormalTheme 를 만들 수 있다.
시작 테마에서 windowBackground를 시작 화면에 대해 원하는 Drawable로 설정할 수 있으며
일반 테마에서 일반적으로 Flutter 콘텐츠 뒤에 나타나야 하는 원하는 배경색으로 windowBackground를 설정할 수 있다
직접 보는 게 나을 것 같아 코드를 가져와 보았다.
<!-- styles.xml -->
<resources>
<!-- 프로세스가 시작되는 동안 Android Window에 적용되는 테마 -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- activity 위에 splash screen 을 보여준다. 플러터 엔진이 첫 번째 프레임을 그려냈을 때 자동으로 제거된다. -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- 프로세스가 시작되자마자 Android Window 에 테마가 적용된다.
이 테마는 Flutter UI가 초기화되는 동안 Android Window 의 색상을 결정하고 Flutter UI 가 실행되는 동안의 색상도 결정한다.
이 테마는 Flutter의 Android embedding V2 부터만 사용된다. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
<!-- AndroidManifest.xml -->
<activity
android:name=".MainActivity"
...
android:theme="@style/LaunchTheme">
<!--
Android 프로세스가 시작되는 즉시 이 활동에 적용할 Android 테마를 지정합니다.
이 테마는 Flutter UI 가 초기화되는 동안 사용자에게 표시됩니다.
그 후 이 테마는 계속해서 Flutter UI 뒤의 창 배경을 결정합니다.
-->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
...
</activity>
styles.xml
에서 dark theme 인 경우, Light 대신 Dark 가 들어간다.
LaunchTheme
는 우리가 각 Activity 에서 theme 를 적용하는 것을 이야기하는 것 같았다.
실제로 배경 색상을 바꿔보니 즉각 적용되는 걸 확인했다.
NormalTheme
는 화면에 반영된 내용을 즉각 확인하지 못했다.
찾아보니 Android의 기본 테마와는 별개로, 자체적으로 Theme 를 가지고 있지 않은
일부 Flutter Widget 에 적용되는 테마라고 하니 못 느끼는 게 정상인가 싶기도 하다.
간단하게 보려고 했는 데 주석만 벌써 한 바퀴 돌고 나니 제법 많은 내용의 글이 써졌다....
이번 포스트는 맛보기만 하고, 다음 포스트에서 FlutterActivity 코드를 분석해보려 한다.