일모도원(日暮途遠) 개발자

[Flutter개발] Flavor만들기 본문

Flutter/Flutter개발

[Flutter개발] Flavor만들기

달님개발자 2025. 7. 18. 16:36
반응형

Flavor를 만드는 경우는 보통 아래와 같다.

 

개발용, 릴리즈용, qa용으로 배포가 필요할때.

유료, 무료(광고 포함, 기능 제한)을 할때.

 

안드로이드는 앱 모듈에 있는 build.gradle에 있는 productFlavors를 이용하여 flavor를 만든다.

 

iOS는 두가지 방법이 있는데, flavor별로 타겟을 만들거나, 타겟은 한개(Runner)인데 스킴과 Configuration(Debug, Release등)을 각 flavor별로 만들면 된다.

 

각각 Native로 개발할때는 안드로이드는 안드로이드 스튜디오의 GUI를, iOS는 XCode의 gui를 이용하고 또 소스내에서 바로 어느 flavor인지 쉽게 구분할수 있는데, 플러터로 할때는 Dart코드에서 어떤 Flavor인지 구분을 해야한다.

 

앱이름이나 앱아이콘등은 flavor를 만들때 각각 ios/android폴더내에서 하면 되는데, 내가 헤맨부분은 플러터 코드에서 flavor별로 코드를 다르게 줘야할때 어떤 flavor인지 어떻게 아느냐였다.

 

현재 Cursor AI로 개발중이라 익숙치 않은 VSCode에서 개발중인데, 안드로이드는 flavor없을때는 main.dart를 선택하고 나오는(가끔 안나오기도 한다. ㅠㅠ) 디버그 실행 버튼으로 빌드를 했었다. iOS는 XCode를 열어서 빌드를 했고.

 

근데 안드로이드용 flavor를 만들고 보니 그냥 VSCode의 디버그 실행버튼을 사용할수가 없다. 어느 flavor를 실행할지 저 버튼으로는 구분이 안된다. 그래서 VSCode에서 사용한다는 launch.json을 만들었다.

 

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Ara Thousand (Free)",
      "request": "launch",
      "type": "dart",
      "flutterMode": "debug",
      "args": [
        "--flavor", "free",
        "--dart-define=flavor=free"
      ]
    },
    {
      "name": "Ara Thousand Paid",
      "request": "launch",
      "type": "dart",
      "flutterMode": "release",
      "args": [
        "--flavor", "paid",
        "--dart-define=flavor=paid"
      ]
    }
  ]
}

 

버전은 나같은 1인개발자에게는 큰 의미가 없고. flutterMode는 release/debug중 하나를 선택하면 된다.

args가 헷갈렸는데. --flavor는 build.gradle의 productFlavors를 말한다. 그다음에 "free"를 할지 "paid"를 할지는 본인이 원하는 flavor의 이름을 적어주면 된다. 

    flavorDimensions += listOf("version")
    productFlavors {
        create("free") {
            dimension = "version"
            applicationIdSuffix = ".free"
            versionNameSuffix = "-free"
        }
        create("paid") {
            dimension = "version"
            // 기본 applicationId 사용
            versionNameSuffix = "-paid"
        }
    }

 

 

"--dart-define=flavor=free"가 어려웠는데 "--dart-define"는 Dart 코드에 "컴파일 시점 상수 값을 주입" 하기 위한 옵션이다. 옵션 값인 "flavor=free"는 키와 값으로 되는데 키는 flavor이고 값은 free이다. 이건 productFlavors에  있는 이름과 같이 주는데 그거와는 상관없이 단순히 키/밸류인거 같다. (flavor라는 단어가 여러군데 나오니 헷갈렸다)

 

launch.json이 있으면 이제 flavor별로 선택해서 빌드할수 있다. (아까 main.dart를 선택하면 나오는 빌드 버튼은 더이상 안쓴다)

 

flutter명령어로 바로 줄수도 있는데.

 

기존에 flavor가 없을때는 그냥 아래처럼 했다. (flutter devices를 하면 가능한 디바이스명이 나온다)

--debug대신 --release를 하기도 하고. lib/main만 있으면 "-t lib/main"은 생략해도 된다.

flutter run -d 디바이스명 --debug -t lib/main

 

이제 flavor가 있으니 --flavor로 flavor명을 적어준다. 그리고 "--dart-define"로 컴파일 옵션값을 넣어준다.
flutter run -d 디바이스명 --debug --flavor paid --dart-define=flavor=paid

 

flavor라는 글자가 두개 나오는데 헷갈리지 말자. --flavor는 고정 된 이름이다. 바꾸면 안된다. 앞의 paid도 build.gradle에 정의된 이름이다. flavor=paid는 내가 정해주는 Key/Value이다. 보통 앞의 "--flavor paid"와 동일하게 적는다.

 

"--flavor paid"는 어느 flavor를 빌드할건지 정하는거고. "--dart-define=flavor=paid"는 flutter코드에서 flavor마다 다른 동작을 할때 사용한다.

 

플러터 코드에서는 "String.fromEnvironment"를 사용하면 --dart-define에 정의된 Key에 해당되는 value를 가져올수 있다.

 

아래는 --dart-define의 flavor라는 키에 있는 값을 가져온다. 즉 이걸로 플러터코드에서 원하는 동작을 사용하면 된다.

static String _flavor = const String.fromEnvironment('flavor', defaultValue: '');

 

launch.json을 이용해서 빌드버튼을 사용하던, 아래처럼 cli로 실행하던 --dart-define=에 있는 키의 값을 읽어와서 조절하면 된다.

flutter run -d 디바이스명 --debug --flavor free --dart-define=flavor=free

flutter run -d 디바이스명 --debug --flavor paid --dart-define=flavor=paid

 

너중에 구글플레이에 appbundle을 올릴때는 아래처럼 해서 만든 aab파일을 올리면 된다.

# 배포시(유료) flutter build appbundle --flavor paid --dart-define=flavor=paid
# 배포시(무료) flutter build appbundle --flavor free --dart-define=flavor=free

 

참고로 --dart-define=로 정의된 값은 앱내에 남아 있기 때문에 앱을 폰에서 다시 실행할때도 "flavor=paid"값은 컴파일시 정해진 값이 그대로 사용된다.


이제 iOS다. 나는 iOS용은 주로 XCode로 실행을 했었다. 그래서 launch.json을 만들지 않았었다. 또 flutter run -d도 거의 안했었다.

나중에 앱스토어에 올릴려면 XCode에서 archive로 run해서 바로 올리는게 편했기 때문이다.

 

XCode에서는 flavor를 만들기 위해서는 타겟을 따로 만들거나, 하나의 scheme에 configuration을 각 flavor별로 만들면 된다. 각자 편한걸로 하자.

 

근데 내가 부닦친 문제는 XCode로 실행할때는 --dart-define=의 값을 못주는데 어떻게 Flutter Code에서 어느 flavor인지 알수 있냐였다.

 

flutter run -d  iOS디바이스명 --debug --flavor free --dart-define=flavor=free

위처럼 iOS디바이스명을 쓰고 cli로 하면 --dart-define에 정의된 key/value로 가능한데, xcode에서 할려면 Flutter의 MethodChannel를 사용하면 된다.

 

사실 MethodChannel은 안드로이드에서도 가능하다. 그런데 나는 안드로이드는 launch.json이나 cli로 바로 빌드 하니까 굳이 flutter가 생성한 MainActivity 안드로이드 코드에 MethodChannel를 추가를 안했는데, XCode에서 빌드할때는 이작업이 필요하다.

 

free용 타겟을 생성하던 안하던 일단 해당 타겟에 "Add User-Defined Setting"을 하나 추가한다.

 

 

그리고 원하는 Key값과 value값을 적으면 된다. 여기서 Key는 XCode내부에서 쓰지만, value는 flutter에서 받아서 사용하기 때문에 android에서 정의한 flavor명과 같게 해야 한다. 여기서는 APP_FLAVOR라는 키에 Debug/Release등 전부 paid라는 value를 줬다.

 

 

그다음은 Info.plist를 열어서 저 APP_FLAVOR를 추가해준다.

 

Info.plist에서 Key/Value로 정해주는데 value를 아까 정의한 APP_FLAVOR를 주고 $()로 감싸주면 APP_FLAVOR의 값이 들어간다. (paid던, free던 정해준값) 그리고 여기서도 Key를 정해줘야 하는데 적당히 이름을 주자. 난 App-Flavor로 줬다.(다른 이름 써도 됨)

<dict>
	<key>App-Flavor</key>
	<string>$(APP_FLAVOR)</string>

 

 

이제 Flutter가 생성한 AppDelegate.swift를 보면 아래처럼 되어 있는데, 여기서 Flutter로 flavor값을 넘겨줄수 있다.

import Flutter
import UIKit

@main
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

 

아래 코드를 보면 맨윗줄과 마지막줄은 원래 있던거고. 중간꺼가 새로 추가한 코드다.

GeneratedPluginRegistrant.register(with: self)
    let controller = window.rootViewController as! FlutterViewController  
    
    let flavorChannel = FlutterMethodChannel(  
        name: "flavor",  
        binaryMessenger: controller.binaryMessenger)  
    flavorChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in  
        // Note: this method is invoked on the UI thread            
        let flavor = Bundle.main.infoDictionary?["App-Flavor"]
        result(flavor)
    })
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)

 

살펴보면 일단 MethodChannel을 하나 추가한다. 이름은 "flavor"로 줬다. 여러군데서 flavor를 쓰니 헷갈리지만 일단 이건  MethodChannel이름이다. flutter에서 아래처럼 메소드채널을 선언한다. 이름은 "flavor"로 맞춰준단.

MethodChannel _methodChannel = const MethodChannel('flavor');

 

그다음 flavorChannel.setMethodCallHandler은 flutter에서 해당 메소드 채널을 호출하면 불리는 곳이다. Bundle.main.infoDictionary은 Info.plist를 파싱해서 키-값 딕셔너리 형태로 반환한 것인데 여기서 아까 정의해준 "App-Flavor"가 있으면 값을 담아서 리턴해준다. 그럼 User-Defined에서 정의해준 free라던지 paid가 리턴 된다.

 

flutter에서 아까 선언한 _methodChannel로 invokeMethod를 실행하면 free또는 paid가 들어온다. 만약 잘못되면 null이 들어온다.

final nativeFlavor = await _methodChannel.invokeMethod<String>('getFlavor');
if (nativeFlavor != null) {
}

 

여기서 getFlavor는 원래 아래처럼 swift코드에서 getFlavor일때 작업하게 해야하는데, if문으로 구분안하면 flavorChannel.setMethodCallHandler가 실행되기만 하면 "App-Flavor"가 정의 되어 있으면 값이 리턴된다. 원래는 아래처럼 해야하는데 많은 인터넷 글들은 그냥 if call.method == "getFlavor"가 없는 예제로 되어 있다. (즉 아래 if문이 없으면 flutter에서는  await _methodChannel.invokeMethod<String>('getFlavor999'); 라고 해도 된다는 뜻이다)

flavorChannel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in
    if call.method == "getFlavor" {
        let flavor = Bundle.main.infoDictionary?["App-Flavor"]
        result(flavor)
    } else {
        result(FlutterMethodNotImplemented)
    }
}

 

 

이제 XCode에서도 flavor를 리턴해주는 코드가 생겼는데, 문제는 Cli로 실행할때는 다른 flavor가 들어올수 있으니 이건 flutter에서 우선순위를 두고 처리해야한다.

 

 

cli로 실행할때는 Runner스킴이름을 flavor에 넣어준다.(보통 스킴과 타겟을 이름을 같게 만든다)

만약 스킴명이 Runner와 Runner-free 두개가 있으면 Runner 스킴을 할때는 --flavor를 안넣는다. 넣으면 에러메시지가 뜬다

flutter run -d  iOSdevice --dart-define=flavor=paid 

이경우는 Runner스킴을 실행하는데 --dart-define로는 flavor키에 paid를 주는거다. 이건 기본이 디버그 모드이기 때문에 Debug Configuration만 있으면 된다. --release를 줬을때는 Release Configuration만 있으면 된다.

 

만약 Runner-free스킴을 실행하고 싶으면 --flavor를 줘야한다.

flutter run -d  iOSdevice --flavor Runner-free --dart-define=flavor=free

이경우는 디버깅 모드이므로 configuration이 Debug-Runner-free가 있어야 한다. 

 

만약 AppDelegate.swift에 MethodChannel을 추가했으면 User-Defined에서 정의된 free를 리턴해줄거고. --dart-define=를 통해서도 free가 들어온다. 

 

혹시 값이 다를 경우를 생각해서 아래처럼 분기 처리했다. andorid는  --dart-define=로만 하니까. String.fromEnvironment를 사용하고, iOS는 String.fromEnvironment이 비어있으면 MethodChannel을 사용하게.

// 앱 타이틀 분기용 상수 추가

import 'dart:io';

import 'package:flutter/services.dart';

class FlavorUtil {
  static const String _free = 'free';
  static const String _paid = 'paid';
  static bool isFree() => _flavor == _free;
  static bool isPaid() => _flavor == _paid;
  //이건 "--dart-define=flavor="에서 free, paid등 값을 가져온다.
  static String _flavor = const String.fromEnvironment('flavor', defaultValue: '');

  static Future<void> init() async {
    // iOS에서 실행 중이고 --dart-define로 값을 안 넘겼을 때
    if (Platform.isIOS && _flavor.isEmpty) {
      //이건 현재 iOS만 MethodChannel로 넘겨준다.
      const methodChannel = MethodChannel('flavor');
      try {
        final nativeFlavor = await methodChannel.invokeMethod<String>('getFlavor');
        if (nativeFlavor != null) {
          _flavor = nativeFlavor;
        }
      } catch (e) {
        print('⚠️ Failed to load flavor from native: $e');
      }
    }
  }

  static String getHomeScreenTitle() {
    if (isFree()) {
      return '[$_flavor]무료';
    } else if (isPaid()) {
      return '[$_flavor]유료';
    } else {
      return '[$_flavor]';
    }
  }
}