flavorizr

빌드 환경을 분리를 위한 flutter_flavorizr의 거의 모든 것읽는데 11분 정도 걸려요.

이 포스트는 계속해서 업데이트 됩니다.
유용한 댓글이나 경험을 남겨주시면 해당 내용도 추가하겠습니다!

Getting Started

flutter_flavorizr의 사용방법에 대해 설명합니다.

pubspec.yaml

해당 파일에서 하단에 flavorizr 필드를 작성합니다.

pubspec.yaml
  # Flavor settings
  flavorizr:
    flavors:
      dev:
        app:
          name: "DEV APP_NAME"
        android:
          applicationId: "dev.package.name.app_name"
          # icon: "assets/app_icon.png"
          firebase:
            config: "android/firebase/dev/google-services.json"
        ios:
          bundleId: "dev.bundle.id.appName"
          # icon: "assets/app_icon.png"
          firebase:
            config: "ios/firebase/dev/GoogleService-Info.plist"

      prod:
        app:
          name: "APP_NAME"
        android:
          applicationId: "package.name.app_name"
          # icon: "assets/app_icon.png"
          firebase:
            config: "android/firebase/prod/google-services.json"
        ios:
          bundleId: "dev.bundle.id.appName"
          # icon: "assets/app_icon.png"
          firebase:
            config: "ios/firebase/prod/GoogleService-Info.plist"

위와 같은 경우에는 dev, prod 두 개의 flavor 환경을 구축하는 경우입니다.

다음은 아래에 추가로 필드를 기입하면 됩니다.
공통사항은 app, AOS 전용사항은 android, iOS 전용사항은 ios 아래에 기입하면 됩니다.

자세한 내용은 flavorizr의 공식 문서를 참고하시면 좋습니다.

CLI

pubspec.yaml 작성을 완료했다면, 아래의 명령어를 사용하면 자동 빌드됩니다.

bash
  dart run flutter_flavorizr

빌드가 완료되면 아래의 파일들이 자동 생성 및 자동 수정될 것 입니다.

  • main.dart

  • main_prod.dart
    prod 빌드 환경에 맞는 초기 세팅을 진행하는 파일입니다.

    main_prod.dart
      // Import from prod dependency
      import 'package:flutter/material.dart';
    
      import 'flavors.dart';
    
      import 'main.dart' as runner;
    
      Future<void> main() async {
        F.appFlavor = Flavor.prod;
    
        await runner.main();
      }
    
  • main_dev.dart
    dev 빌드 환경에 맞는 초기 세팅을 진행하는 파일입니다.

    main_dev.dart
      // Import from dev dependency
      import 'package:flutter/material.dart';
    
      import 'flavors.dart';
    
      import 'main.dart' as runner;
    
      Future<void> main() async {
        F.appFlavor = Flavor.dev;
    
        await runner.main();
      }
    
  • flavors.dart
    flavor 관련 static field를 관리하는 클래스 파일입니다.

    flavors.dart
      enum Flavor {
        dev,
        prod,
      }
    
      class F {
        static Flavor? appFlavor;
    
        static String get name => appFlavor?.name ?? '';
    
        static String get title {
          switch (appFlavor) {
            case Flavor.dev:
              return 'DEV APP_NAME';
            case Flavor.prod:
              return 'APP_NAME';
            default:
              return 'title';
          }
        }
      }
    
  • pages/my_home_page.dart

VSCode debug setting

디버깅 시 아래의 명령어를 사용해서 실행할 수 있습니다.

bash
  flutter run --flavor prod -t lib/main_prod.dart
  flutter run --flavor dev -t lib/main_dev.dart

하지만, VSCode의 디버깅 탭을 100% 활용하기 위해 추가 설정을 진행하면 좋습니다.

우선, 프로젝트 폴더에서 .vscode 폴더를 생성, 내부에 아래의 파일을 추가합니다.

launch.json
  {
    "configurations": [
      {
        "name": "dev",
        "request": "launch",
        "type": "dart",
        "args": ["--flavor", "dev"],
        "program": "lib/main_dev.dart"
      },
      {
        "name": "prod",
        "request": "launch",
        "type": "dart",
        "args": ["--flavor", "prod"],
        "program": "lib/main_prod.dart"
      }
    ]
  }

이렇게 설정하면 명령어 없이 디버깅 탭에서 버튼을 통해 실행할 수 있습니다.

Tips & Addon(?)

flavor를 사용할 때 추가로 구현하면 좋은 것들, 혹은 firebase와 같은 추가 설정이 필요한 부분에 대해 설명합니다.

Config

flavor의 주된 사용 목적에는 prod, dev 에서의 API endpoint를 다르게 설정하는 등, 앱 내부에서 상수들을 별도로 관리하는데에 있을 것 입니다.
따라서 이러한 처리를 자동으로 하도록 Config 객체를 만들어 관리하는 방법에 대해 소개하겠습니다.

우선 코드 전문부터 보겠습니다.

config.dart
  import 'package:child_goods_store_flutter/constants/networks.dart';
  import 'package:child_goods_store_flutter/flavors.dart';

  class Configs {
    // Add configs here
    final String baseUrl;

    // Initialize configs here
    Config._dev() : baseUrl = Networks.devBaseUrl;

    Config._prod() : baseUrl = Networks.baseUrl;

    factory Config(Flavor? flavor) {
      switch (flavor) {
        case Flavor.dev:
          _instance = Config._dev();
          break;
        case Flavor.prod:
          _instance = Config._prod();
          break;
        default:
          _instance = Config._dev();
          break;
      }
      return instance;
    }

    static Config? _instance;
    static Config get instance => _instance ?? Config(F.appFlavor ?? Flavor.dev);
  }

위 코드는 일단 Configs 싱글톤 객체를 생성하는 파일입니다.
그 과정에서 기본적으로 F(lavor) 클래스의 appFlavor static 필드를 생성자에 주입 받습니다.

즉, Configs.instance 를 호출하는 것 만으로토 현재 실행 환경의 flavor가 적용된 Configs 싱글톤 객체를 반환받을 수 있습니다.

여기에 추가로 본인이 원하는 필드(e.g. baseUrl)를 추가하고, 각 생성자에서 각 환경에 맞게 초기화 해주시면 됩니다.

그럼 앞으로 Configs.instance.baseUrl를 사용하는 것 만으로도 빌드 환경에 따라 다른 값이 알아서 적용되게 됩니다.

Firebase

firebase의 google-services.json이나, GoogleService-Info.plist를 빌드 환경에 맞게 알아서 처리하는 방법도 알아봅시다.

firebase 프로젝트 및 service 파일을 빌드 환경에 맞게 분리하면 좋은게,
가끔 Test 라며 푸쉬 알림이 실 사용자에게 전송되는 일을 예방할 수 있기 때문입니다.

우선 firebase 프로젝트를 생성합니다.
자세한 생성 방법 및 flutterfire CLI 설치 방법은 생략합니다.

다음은 프로젝트 폴더에서 아래의 CLI 명령어를 실행하여 firebase 프로젝트에 flutter 앱을 추가해줍니다.

bash
  flutterfire config \      
  --project=firebase_project_name \
  --ios-bundle-id=bundle.id.appName \
  --android-package-name=package.name.app_name

위 명령어는 firebase_project_name firebase 프로젝트에 prod 빌드 환경의 앱을 추가하는 명령어 입니다.

  1. 만약 같은 firebase 프로젝트에 dev 빌드 환경의 앱을 추가하려면 아래의 명령어를 입력하면 됩니다.

    bash
      flutterfire config \      
      --project=firebase_project_name \
      --ios-bundle-id=dev.bundle.id.appName \
      --android-package-name=dev.package.name.app_name
    

    2번째 부터 ios/firebase_app_id_file.json 파일이 중복된다는 경고가 나오는데,
    이는 개발환경에 무관하게 동일한 파일이기 때문에 override 해도 상관 없습니다.

  2. 만약 다른 firebase 프로젝트에 dev 빌드 환경의 앱을 추가하려면 아래의 명령어를 입력하면 됩니다.

    bash
      flutterfire config \      
      --project=dev_firebase_project_name \
      --ios-bundle-id=dev.bundle.id.appName \
      --android-package-name=dev.package.name.app_name
    

성공적으로 추가 되었다면, 자동으로 생성되는 google-services.json이나, GoogleService-Info.plist는 일단 제거해줍니다.

여기까지 성공했다면 firebase 프로젝트에 4개의 앱이 추가되었을 것입니다.
(AOS prod, dev / iOS prod, dev)

240302-233903

이제 google-services.json, GoogleService-Info.plist 파일을 다운로드 받아 pubspec.yaml 파일에 적힌 경로에 각각 다운로드 합니다.

pubspec.yaml
  # Flavor settings
  flavorizr:
    flavors:
      dev:
        app:
          name: "DEV APP_NAME"
        android:
          applicationId: "dev.package.name.app_name"
          # icon: "assets/app_icon.png"
          firebase:
            config: "android/firebase/dev/google-services.json"
        ios:
          bundleId: "dev.bundle.id.appName"
          # icon: "assets/app_icon.png"
          firebase:
            config: "ios/firebase/dev/GoogleService-Info.plist"

      prod:
        app:
          name: "APP_NAME"
        android:
          applicationId: "package.name.app_name"
          # icon: "assets/app_icon.png"
          firebase:
            config: "android/firebase/prod/google-services.json"
        ios:
          bundleId: "dev.bundle.id.appName"
          # icon: "assets/app_icon.png"
          firebase:
            config: "ios/firebase/prod/GoogleService-Info.plist"

해당 경로는 본인 프로젝트의 파일 관리 정책에 맞게 알아서 설정해주시면 됩니다.

그리고 다시 아래의 명령어를 실행하면 알아서 firebase 환경 설정이 완료됩니다.

bash
  dart run flutter_flavorizr

반드시 관련 파일은 .gitignore에 추가하여 보안에 유의합시다.

.gitignore
  # Secret - Common
  lib/configs/firebase_options.dart
  lib/configs/firebase_options_dev.dart
  # Secret - Android
  android/firebase
  android/app/src/prod/google-services.json
  android/app/src/dev/google-services.json
  # Secret - iOS
  ios/firebase
  ios/firebase_app_id_file.json
  ios/Runner/prod/GoogleService-Info.plist
  ios/Runner/dev/GoogleService-Info.plist
  # Secret - iOS auto generated
  ios/Runner/GoogleService-Info.plist

마지막으로 main_prod.dart, main_dev.dart에 초기화 설정을 해주면 끝입니다.

main_prod.dart
  // Import from prod dependency
  import 'package:child_goods_store_flutter/configs/firebase_options.dart';
  import 'package:firebase_core/firebase_core.dart';
  import 'package:flutter/material.dart';

  import 'flavors.dart';

  import 'main.dart' as runner;

  Future<void> main() async {
    F.appFlavor = Flavor.prod;

    WidgetsFlutterBinding.ensureInitialized();

    await Firebase.initializeApp(
      options: DefaultFirebaseOptions.currentPlatform,
    );

    await runner.main();
  }
main_dev.dart
  // Import from dev dependency
  import 'package:child_goods_store_flutter/configs/firebase_options_dev.dart';
  import 'package:firebase_core/firebase_core.dart';
  import 'package:flutter/material.dart';

  import 'flavors.dart';

  import 'main.dart' as runner;

  Future<void> main() async {
    F.appFlavor = Flavor.dev;

    WidgetsFlutterBinding.ensureInitialized();

    await Firebase.initializeApp(
      options: DefaultFirebaseOptions.currentPlatform,
    );

    await runner.main();
  }

(자세히 보면 firebase_options.dart 파일의 import 경로가 다릅니다)

TroubleShoots

글쓴이가 실제로 해보며 겪은 문제와 그에 대한 해결법에 대해 정리해봤습니다.

Unable to load contents of file list

글쓴이는 아래와 같은 에러를 본 적이 있습니다.

Error (Xcode): Unable to load contents of file list: '/Target Support Files/Pods-Runner/Pods-Runner-frameworks-Debug-dev-input-files.xcfilelist'

Error (Xcode): Unable to load contents of file list: '/Target Support Files/Pods-Runner/Pods-Runner-frameworks-Debug-dev-output-files.xcfilelist'

해당 에러는 특정 파일이 존재하지 않을 때 발생하는 에러로, flavorizr 생성 CLI 명렁어를 실행했음에도 생성되지 않는 경우가 종종 있었습니다.
그럴 경우에는 아래의 스탭을 천천히 밟아가면 대부분 해결될 것입니다.

  1. ios → Xcode로 열기 → 상단 상태바에서 Product → Clean Build Folder

    240302-235526

  2. 아래 명령어로 pod 업데이트, depencency 초기화 및 재설치

    bash
      cd ios/
      sudo gem update cocoapods --pre
      pod update
      pod repo update
      pod deintegrate
      pod install
    
  3. flavorizr 명령어 재실행

    bash
      dart run flutter_flavorizr
    
  4. ios → Xcode로 열기 → Runner(PROJECT) → Info → Configurations 의 파일이 잘 설정되어있는지 확인

    240303-000448

  5. 아래의 파일의 include가 아래와 같이 작성되어있는지 확인

    ios/Flutter/Debug.xcconfig
      #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
      #include "Generated.xcconfig"
    
    ios/Flutter/Release.xcconfig
      #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
      #include "Generated.xcconfig"
    

FirebaseCommandException

글쓴이는 아래와 같은 에러를 본 적이 있습니다.

FirebaseCommandException: An error occured on the Firebase CLI when attempting to run a command.
COMMAND: firebase apps:create ios child_goods_store_flutter (ios) --bundle-id=dev.io.github.u3C1S.child_goods_store_flutter --json --project=child-goods-store
ERROR: Failed to create iOS app for project child-goods-store. See firebase-debug.log for more info.

이 경우에는 iOS bundleId가 snake_case로 작성되어서 발생한 문제였습니다.

반드시, iOS bundleId는 camelCase로 작성하도록 합시다.