← Volver al blog

Feature flags con UserDefaults en macOS

Cómo usar UserDefaults como sistema de feature flags local en macOS: un @propertyWrapper tipado, reset limpio y log automático de flags activos al arrancar.

Hay un momento en cualquier app donde quieres cambiar algo sin recompilar. Un overlay de debug, el entorno de la API, algo que no está listo todavía. Firebase Remote Config existe para eso, pero para una app personal es sobreingeniería desde el día uno.

Lo que uso yo: UserDefaults. Ya lo tienes, ya funciona.

El wrapper

El problema con UserDefaults directo es que terminas con strings mágicos regados por todo el código. Lo resolví con un @propertyWrapper:

@propertyWrapper
struct UserDefault<Value> {
    let key: String
    let defaultValue: Value

    var wrappedValue: Value {
        get {
            guard let stored = UserDefaults.standard.object(forKey: key) else {
                return defaultValue
            }
            guard let value = stored as? Value else {
                assertionFailure("[FeatureFlag] '\(key)': se esperaba \(Value.self), se encontró \(type(of: stored))")
                return defaultValue
            }
            return value
        }
        set {
            UserDefaults.standard.set(newValue, forKey: key)
        }
    }

    var projectedValue: Self { self }

    func reset() {
        UserDefaults.standard.removeObject(forKey: key)
    }

    var isModified: Bool {
        UserDefaults.standard.object(forKey: key) != nil
    }
}

wrappedValue es el valor que lees y escribes directamente. Con projectedValue expones el wrapper entero via $, que da acceso a reset() e isModified. El assertionFailure en el getter atrapa errores de tipo en debug — si alguien guardó un string donde esperas un bool, te lo dice fuerte y claro. En release simplemente regresa el default.

Cómo lo organizo

Todo en un enum:

enum FeatureFlag {
    @UserDefault(key: "debug_overlay", defaultValue: false)
    static var debugOverlay: Bool

    @UserDefault(key: "api_environment", defaultValue: "production")
    static var apiEnvironment: String

    @UserDefault(key: "beta_feed", defaultValue: false)
    static var betaFeed: Bool

    @UserDefault(key: "use_mock_data", defaultValue: false)
    static var useMockData: Bool
}

El uso queda limpio:

FeatureFlag.debugOverlay           // lee → false
FeatureFlag.debugOverlay = true    // activa
FeatureFlag.$debugOverlay.reset()  // borra la llave del disco

Una cosa que vale aclarar: = false y .reset() no son lo mismo. Si activaste el flag con defaults write desde terminal, = false escribe false sobre ese valor. El reset() borra la llave completa, así que el sistema vuelve a leer el defaultValue del código. Si quieres volver al estado “nunca se tocó”, es .reset().

Sin olvidar qué está activo

UserDefaults persiste entre sesiones. Activas algo, te distraes, y tres días después la app se comporta rara y no entiendes por qué. Ya me pasó.

Lo que agrego es un log al arrancar que lista los flags que están activos. isModified revisa si la llave existe en disco — si no existe, el sistema está leyendo el default del código y ese flag no cuenta:

extension FeatureFlag {
    static func resetAll() {
        $debugOverlay.reset()
        $apiEnvironment.reset()
        $betaFeed.reset()
        $useMockData.reset()
    }

    #if DEBUG
    static func logModified() {
        let active = [
            $debugOverlay.isModified   ? "debugOverlay = \(debugOverlay)" : nil,
            $apiEnvironment.isModified ? "apiEnvironment = \(apiEnvironment)" : nil,
            $betaFeed.isModified       ? "betaFeed = \(betaFeed)" : nil,
            $useMockData.isModified    ? "useMockData = \(useMockData)" : nil,
        ].compactMap { $0 }

        guard !active.isEmpty else { return }
        print("[FeatureFlag] Flags activos:")
        active.forEach { print("  · \($0)") }
    }
    #endif
}

Y en el entry point:

@main
struct MyApp: App {
    init() {
        #if DEBUG
        FeatureFlag.logModified()
        #endif
    }
}

Cada vez que arranca la app en debug ves en consola qué flags están activos. El resetAll() lo uso cuando quiero limpiar todo de un solo golpe antes de hacer un build de prueba.

Para qué los uso

El que más enciendo es el debug overlay. Tengo un HUD que muestra FPS, uso de memoria y qué endpoint está respondiendo. Lo prendo cuando estoy debugging y lo apago antes de los screenshots. Sin el flag ese código viviría comentado en algún rincón.

El cambio de entorno también me ahorra tiempo. Antes editaba una constante y recompilaba. Ahora cambio el flag y reinicio. El useMockData lo activo cuando el backend está caído y necesito seguir trabajando en UI — me ha salvado varias tardes.

Cómo activarlos

Lo más cómodo es el scheme de Xcode. En Product → Scheme → Edit Scheme → Arguments Passed On Launch agrego -debug_overlay YES. UserDefaults lee esos argumentos al arrancar, sin código extra de tu parte.

Desde terminal, si la app ya está instalada:

open -a "MyApp" --args -debug_overlay YES -api_environment staging

O con defaults write, que persiste entre reinicios:

defaults write com.tuempresa.tuapp debug_overlay -bool YES
defaults write com.tuempresa.tuapp api_environment -string staging

Con App Groups cambias UserDefaults.standard por UserDefaults(suiteName: "group.com.tuempresa.shared") y los flags quedan disponibles para la app y sus extensiones (útil si tienes un Menu Bar extra o una extensión de Safari).

Lo que no puede hacer

Cambiar comportamiento en producción sin un update. Para eso necesitas algo remoto. Para mis apps eso no es un requisito, así que vivo con ello.