Start Debugging

Cómo mantener appFlavor con valor después de un hot restart al usar flutter attach

flutter attach no tiene opción --flavor, así que el compilador residente que lanza nunca define FLUTTER_APP_FLAVOR y la constante appFlavor pasa a null en el primer hot restart. Tres soluciones: default-flavor en pubspec.yaml, flutter run --use-application-binary y un canal de plataforma que lee el flavor de forma nativa. Verificado en Flutter 3.47.2 / Dart 3.13.2.

appFlavor es una constante de tiempo de compilación, no una consulta en tiempo de ejecución, y flutter attach no tiene opción --flavor. Por eso, cuando te conectas a una app compilada y lanzada fuera de flutter run, el compilador frontend que arranca la herramienta se inicia sin -DFLUTTER_APP_FLAVOR=..., y el primer hot restart reemplaza tu dev correcto por null. La solución más rápida es default-flavor en pubspec.yaml, que FlutterCommand.getBuildInfo() lee para todos los comandos, incluido attach. Si necesitas que el flavor varíe por invocación, usa flutter run --use-application-binary=<path> --flavor dev en lugar de conectarte, o deja de leer appFlavor y obtén el flavor desde la plataforma. Todo lo que sigue se comprobó con Flutter 3.47.2 y Dart 3.13.2 en el canal stable.

appFlavor son once líneas de const, y eso es toda la historia

La gente asume que appFlavor le pregunta algo al engine. No lo hace. Esta es la declaración completa, en packages/flutter/lib/src/services/flavor.dart de la rama stable:

// Flutter 3.47.2, packages/flutter/lib/src/services/flavor.dart
/// The flavor this app was built with.
///
/// This is equivalent to the value argued to the `--flavor` option at build time.
/// This will be `null` if the `--flavor` option was not provided.
const String? appFlavor = String.fromEnvironment('FLUTTER_APP_FLAVOR') != ''
    ? String.fromEnvironment('FLUTTER_APP_FLAVOR')
    : null;

String.fromEnvironment lo resuelve el CFE cuando compila el kernel, a partir de los flags -D que recibe el proceso del compilador. No tiene nada que ver con Platform.environment, nada que ver con la VM en ejecución y nada que ver con el APK que instalaste. El valor que tuviera el compilador en el momento de producir el kernel queda grabado.

Eso importa porque una sesión de depuración de Flutter tiene dos compiladores en su vida. El primero se ejecuta al compilar la app: flutter build apk --flavor dev --debug resuelve --flavor en FlutterCommand.getBuildInfo() y añade FLUTTER_APP_FLAVOR=dev a los dart defines, que terminan como -DFLUTTER_APP_FLAVOR=dev en la línea de comandos del frontend server. El segundo se ejecuta durante todo el resto de la sesión: el compilador residente que flutter run o flutter attach mantiene vivo para atender hot reload y hot restart. En packages/flutter_tools/lib/src/compile.dart los defines se vuelcan sobre ese proceso exactamente una vez, cuando arranca:

// Flutter 3.47.2, packages/flutter_tools/lib/src/compile.dart (abridged)
final List<String> command = <String>[
  engineDartPath,
  ...
  '--sdk-root', sdkRoot,
  '--target=$targetModel',
  '--no-print-incremental-dependencies',
  for (final Object dartDefine in dartDefines) '-D$dartDefine',
  ...buildModeOptions(buildMode, dartDefines),
  if (trackWidgetCreation) '--track-creation-locations',
  ...
];

No hay ningún canal para cambiar dartDefines después de eso. Cada hot reload y cada hot restart del resto de la sesión los atiende ese único proceso con ese único conjunto de defines. Si FLUTTER_APP_FLAVOR no estaba en la línea de comandos cuando arrancó, ningún reinicio lo va a recuperar.

Qué registra attach y qué no

El constructor de AttachCommand es una lista de llamadas uses*. Esta es la parte relevante, literal de stable:

// Flutter 3.47.2, packages/flutter_tools/lib/src/commands/attach.dart
addBuildModeFlags(verboseHelp: verboseHelp, defaultToRelease: false, excludeRelease: true);
usesTargetOption();
usesPortOptions(verboseHelp: verboseHelp);
usesIpv6Flag(verboseHelp: verboseHelp);
usesFilesystemOptions(hide: !verboseHelp);
usesFuchsiaOptions(hide: !verboseHelp);
usesDartDefineOption();
usesDeviceUserOption();

usesFlavorOption() no está. RunCommand la llama en la línea 40 de run.dart; AttachCommand nunca lo hace. Y getBuildInfo(), que attach sí llama, resuelve el flavor así:

// Flutter 3.47.2, packages/flutter_tools/lib/src/runner/flutter_command.dart
final String? defaultFlavor = project.manifest.defaultFlavor;
final String? cliFlavor = getValue(BuildInfoOptions.flavor);
final String? flavor = cliFlavor ?? defaultFlavor;

_ensureReservedDartDefineIsUnset(kAppFlavor, dartDefines);
if (flavor != null) {
  dartDefines.add('$kAppFlavor=$flavor');
}

Sin la opción --flavor registrada, cliFlavor es null. Si defaultFlavor también es null, flavor es null, el if nunca se ejecuta y el compilador residente arranca sin el define. La app en el dispositivo es una compilación dev; el compilador que la atiende cree que los flavors no existen.

La reproducción, en cuatro pasos

Este es flutter/flutter#192261, reportado el 2026-09-03 y confirmado por triaje contra 3.47.2 stable. Se reproduce tanto en platform-android como en platform-ios.

  1. Dale product flavors a una app y lee la constante en algún lugar visible:

    // Flutter 3.47.2 / Dart 3.13.2
    import 'package:flutter/material.dart';
    import 'package:flutter/services.dart';
    
    void main() => runApp(const FlavorApp());
    
    class FlavorApp extends StatelessWidget {
      const FlavorApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          home: Scaffold(
            body: Center(
              child: Text('appFlavor = $appFlavor',
                  style: const TextStyle(fontSize: 28)),
            ),
          ),
        );
      }
    }
  2. Compílala y lánzala con el flavor, pero fuera de flutter run:

    flutter build apk --flavor dev --debug
    adb install -r build/app/outputs/flutter-apk/app-dev-debug.apk
    adb shell monkey -p com.example.flavors.dev 1
  3. Conéctate: flutter attach --debug. La pantalla sigue diciendo appFlavor = dev, porque todavía no se ha recompilado nada.

  4. Pulsa R para un hot restart. La pantalla ahora dice appFlavor = null.

El paso 3 es lo que hace confuso el diagnóstico. El valor es correcto hasta el primer restart, así que el error parece pertenecer al código que acabas de editar y no a la herramienta.

Solución 1: default-flavor en pubspec.yaml

default-flavor se añadió al esquema del pubspec en flutter/flutter#147968, y #169298 movió su resolución a FlutterCommand.getBuildInfo() para que aplique a todos los comandos que construyen un BuildInfo, no solo a run y build. attach es uno de esos comandos. Por eso esto funciona.

  1. Añade el campo bajo la clave flutter: en pubspec.yaml:

    # pubspec.yaml, Flutter 3.47.2
    name: flavors_example
    environment:
      sdk: ^3.13.0
    
    flutter:
      uses-material-design: true
      default-flavor: dev
  2. Reinicia la sesión de attach. flutter attach --debug ahora resuelve flavor a dev desde el manifiesto, añade FLUTTER_APP_FLAVOR=dev a los defines y el hot restart sigue devolviendo dev.

  3. Sigue usando --flavor de forma explícita donde exista la opción. El propio texto de ayuda de usesFlavorOption() dice que “Overrides the value of the default-flavor entry in the flutter pubspec”, así que flutter run --flavor staging sigue ganando sobre default-flavor: dev.

La limitación es exactamente la que esperarías de un valor guardado en un archivo versionado: es un flavor, para todo el mundo, en todos los attach. Si tu CI se conecta a una compilación staging en una máquina y a una dev en otra, default-flavor no puede seguirlas. Hay una propuesta abierta, #191376, para permitir valores de default-flavor específicos por plataforma, pero eso tampoco hace que el valor sea por invocación.

Por qué se rechaza —dart-define=FLUTTER_APP_FLAVOR

La solución obvia es poner el define a mano, y attach sí registra usesDartDefineOption(), así que el flag se parsea. Aun así falla:

flutter attach --debug --dart-define=FLUTTER_APP_FLAVOR=dev
# FLUTTER_APP_FLAVOR is used by the framework and cannot be set using
# --dart-define or --dart-define-from-file

Esa protección es deliberada, y también cubre el entorno del proceso:

// Flutter 3.47.2, packages/flutter_tools/lib/src/runner/flutter_command.dart
void _ensureReservedDartDefineIsUnset(String define, List<String> dartDefines) {
  if (_platform.environment[define] != null) {
    throwToolExit('$define is used by the framework and cannot be set in the environment.');
  }
  if (dartDefines.any((String d) => d == define || d.startsWith('$define='))) {
    throwToolExit(
      '$define is used by the framework and cannot be '
      'set using --${FlutterOptions.kDartDefinesOption} or --${FlutterOptions.kDartDefineFromFileOption}',
    );
  }
}

FLUTTER_APP_FLAVOR acompaña a FLUTTER_BUILD_NAME, FLUTTER_BUILD_NUMBER y FLUTTER_ENABLED_FEATURE_FLAGS en esa lista reservada. Definir la variable en tu shell antes de ejecutar la herramienta tampoco la esquiva: la primera rama comprueba _platform.environment y sale con un mensaje distinto. Si antes hacías esto en compilaciones web, eso es #172165, cerrado como inválido: el comportamiento anterior a 3.32 era el accidente, no el rechazo actual.

Solución 2: ejecuta el binario ya compilado en lugar de conectarte

La mayoría recurre a flutter attach porque la app la compiló algo distinto de flutter run: una tarea de Gradle, un esquema de Xcode, un arnés de instrumentación. Si lo único que necesitas es “instala este artefacto y dame un ciclo de hot restart”, flutter run hace justo eso y, a diferencia de attach, acepta --flavor:

# Flutter 3.47.2. run registers both --use-application-binary and --flavor.
flutter run \
  --use-application-binary=build/app/outputs/flutter-apk/app-dev-debug.apk \
  --flavor dev

RunCommand llama a usesFlavorOption() y lee --use-application-binary en prebuiltApplicationBinaryPath, así que obtienes un HotRunner real sobre un binario que compilaste tú, con FLUTTER_APP_FLAVOR=dev en el compilador residente. En iOS, apúntalo al bundle .app que produce flutter build ios --flavor dev --debug o tu esquema de Xcode. Esto es lo más parecido a una solución correcta sin tocar tu código Dart, y es la primera a la que recurro en CI.

No ayuda si de verdad no controlas el lanzamiento, por ejemplo cuando una app nativa anfitriona incrusta Flutter como módulo y arranca el engine por su cuenta. Para eso está la solución 3.

Solución 3: lee el flavor desde la plataforma, no desde el kernel

Si el flavor tiene que sobrevivir a un attach arbitrario, deja de pedirle a una constante de tiempo de compilación un valor que el compilador no conoce. El flavor ya está presente de forma nativa, en BuildConfig.FLAVOR en Android y en el build setting que gobierne tu esquema de Xcode, y un canal de plataforma lo lee después de que el isolate se reinicie, no antes de compilar. La técnica es la misma que cubre añadir código específico de plataforma sin escribir un plugin.

Android primero. AGP 8.0 dejó de generar BuildConfig a menos que lo pidas, y la plantilla de app de Flutter no lo pide, así que actívalo:

// android/app/build.gradle.kts, AGP 8.13, Flutter 3.47.2
android {
    namespace = "com.example.flavors"

    buildFeatures {
        buildConfig = true
    }

    flavorDimensions += "env"
    productFlavors {
        create("dev") {
            dimension = "env"
            applicationIdSuffix = ".dev"
        }
        create("staging") {
            dimension = "env"
            applicationIdSuffix = ".staging"
        }
    }
}

Después responde a una llamada del canal en MainActivity:

// android/app/src/main/kotlin/com/example/flavors/MainActivity.kt
package com.example.flavors

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(
            flutterEngine.dartExecutor.binaryMessenger,
            "com.example.flavors/flavor",
        ).setMethodCallHandler { call, result ->
            when (call.method) {
                "getFlavor" -> result.success(BuildConfig.FLAVOR)
                else -> result.notImplemented()
            }
        }
    }
}

En iOS, añade un build setting definido por el usuario en cada xcconfig (APP_FLAVOR = dev en Debug-dev.xcconfig), exponlo en Info.plist como una cadena FLUTTER_APP_FLAVOR con valor $(APP_FLAVOR), y léelo del bundle:

// ios/Runner/AppDelegate.swift, Xcode 26.4, Flutter 3.47.2
import Flutter
import UIKit

@main
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller = window?.rootViewController as! FlutterViewController
    let channel = FlutterMethodChannel(
      name: "com.example.flavors/flavor",
      binaryMessenger: controller.binaryMessenger)
    channel.setMethodCallHandler { call, result in
      guard call.method == "getFlavor" else {
        result(FlutterMethodNotImplemented)
        return
      }
      result(Bundle.main.object(forInfoDictionaryKey: "FLUTTER_APP_FLAVOR") as? String)
    }
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

Y del lado de Dart, resuélvelo una vez y usa appFlavor como respaldo en las plataformas que no tienen manejador nativo:

// Flutter 3.47.2 / Dart 3.13.2
import 'package:flutter/services.dart';

const _channel = MethodChannel('com.example.flavors/flavor');

/// Survives hot restart under `flutter attach`, because it is a call, not a const.
Future<String?> resolveFlavor() async {
  try {
    return await _channel.invokeMethod<String>('getFlavor') ?? appFlavor;
  } on MissingPluginException {
    return appFlavor; // desktop, web, unit tests
  }
}

El costo es que resolveFlavor() es asíncrono y appFlavor no lo es, así que todo lo que ramificaba según el flavor de forma síncrona en main() ahora tiene que esperarlo antes de runApp, o leerlo desde un provider. Eso es una refactorización real, y por eso solo la haría cuando las soluciones 1 y 2 no estén disponibles.

Cosas que parecen este error pero no lo son

El hotfix de 3.32.1. Si buscas este síntoma vas a llegar a #165803 y #169160, donde appFlavor pasaba a null tras un hot restart con flutter run --flavor a secas y durante flutter test --flavor. Esa era otra causa: KernelSnapshot en build_system/targets/common.dart se saltaba añadir el flavor cuando ya había un define presente del tramo de xcodebuild de la compilación. El PR #169602 lo cambió para quitar cualquier entrada existente y volver a añadir la suya al final, y la entrada del changelog llegó en Flutter 3.32.1. Si estás en 3.32.1 o posterior y sigues viendo null con flutter run, ese es un error nuevo, no este.

Hot reload, no solo hot restart. El conjunto de defines queda fijado cuando arranca el compilador residente, así que gobierna cada compilación incremental, no solo los restarts completos. Un hot reload que recompile una biblioteca que lee appFlavor puede reevaluar la constante a null en esa biblioteca mientras otras conservan el valor anterior. No des por hecho que r es seguro porque R no lo sea.

Flavors que solo existen en Gradle. appFlavor reporta el valor pasado a --flavor, que debe coincidir con el nombre de un product flavor. Si renombraste un flavor en build.gradle.kts pero seguiste compilando con el nombre viejo, la compilación falla antes de que esto importe. Configurar las propias dimensiones de flavor queda fuera del alcance de este artículo; si lo que falla es assembleDevDebug, eso está más cerca de la lista de comprobación de assembleDebug con código de salida 1.

Acciones “Attach” del IDE. IntelliJ chocó con la misma pared desde el otro lado en flutter-intellij#5237, donde la acción Attach pasaba --flavor y moría con Could not find an option named 'flavor'. La solución del IDE fue quitar el flag, y por eso conectarse desde Android Studio o VS Code muestra este comportamiento también. default-flavor es hoy lo único que arregla la ruta del IDE, porque no controlas su línea de comandos.

La propuesta upstream en #192261 es de una línea: llamar a usesFlavorOption() en el constructor de AttachCommand. getBuildInfo() ya convierte la opción en el define, así que no hace falta más plomería. Hasta que eso aterrice, trata a appFlavor bajo attach como “correcto exactamente una vez”, y elige la de las tres soluciones anteriores que se corresponda con cuánto controlas del lanzamiento.

Relacionado

Fuentes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Volver