为什么要"特性开关"
- 主干开发(trunk-based)要求频繁合入半成品代码,但又不希望用户踩雷。
- 同一套代码要在 Debug(调试)、TestFlight(内测)、App Store(正式)三种环境跑,功能粒度不同:
- Debug:全开,方便一口气测到底。
- TestFlight:想让人免费体验付费模块,好收集崩溃与性能数据。
- App Store:只敢把 100% 完成且审核通过的功能放出来。
- "特性开关"(Feature Flag)就是把这些"开/关"从 hard-code 的 if-else 里解放出来,做到编译期可配、运行时可达、后期可远程化。
核心思路三步走
- 用 Xcode Configuration + Swift 编译条件,让编译器帮你"裁剪"代码。
- 把开关做成一个值类型(struct/enum),一次性注入 SwiftUI 环境。
- 视图层只关心"开关是否打开",不关心"当前是什么环境"。
动手实战
-
准备三套 Configuration
复制 Release → 分别取名 TestFlight、AppStore;
在 Build Settings → Swift Compiler - Custom Flags → Active Compilation Conditions 里加上:
- Debug 空着(或 DEBUG)
- TestFlight 加 TESTFLIGHT
- AppStore 加 APPSTORE
-
定义"当前渠道"枚举
swift
/// 当前 App 的发布渠道
public enum Distribution: Sendable {
case debug
case appstore
case testflight
}
extension Distribution {
/// 根据编译条件自动判断
static var current: Self {
#if APPSTORE // 注意顺序:最特殊放前面
return .appstore
#elseif TESTFLIGHT
return .testflight
#else
return .debug
#endif
}
}
- 定义"特性开关"包
swift
/// 把所有可开关项集中在一处,方便 Review & 清理
public struct FeatureFlags: Sendable, Decodable {
public let requirePaywall: Bool // 是否强制付费墙
public let requireOnboarding: Bool // 是否显示新手引导
public let featureX: Bool // 某个尚未发布的新功能
/// 根据渠道给出默认策略
init(distribution: Distribution) {
switch distribution {
case .debug: // 调试:全开,跑通再说
self.requirePaywall = true
self.requireOnboarding = true
self.featureX = true
case .appstore: // 正式:保守,只开已审核模块
self.requirePaywall = true
self.requireOnboarding = true
self.featureX = false
case .testflight: // 内测:给足权限,关闭付费墙
self.requirePaywall = false
self.requireOnboarding = true
self.featureX = true
}
}
}
- 注入 SwiftUI 环境
swift
extension EnvironmentValues {
/// 全局可读的特性开关
@Entry public var featureFlags = FeatureFlags(distribution: .debug)
}
- 在 App 入口统一装配
swift
@main
struct CardioBotApp: App {
var body: some Scene {
WindowGroup {
RootView()
// 用 .current 让编译器自动选对配置
.environment(\.featureFlags, FeatureFlags(distribution: .current))
}
}
}
- 视图层使用示例
swift
struct PaywallView: View {
@Environment(\.featureFlags) private var flags
var body: some View {
if flags.requirePaywall {
// 真正的付费墙
Text("真正的付费墙")
} else {
// TestFlight 直接送
Text("感谢内测,已解锁 Pro 功能")
}
}
}
-
快速本地调试小技巧
在 Xcode Scheme → Run → Arguments → Environment Variables 里加键值
OVERRIDE_PAYWALL = 0/1
,然后在
FeatureFlags.init
里优先读ProcessInfo
,就能在不换 Configuration 的情况下临时翻转开关,调 UI 更爽。
完整可拷贝的"迷你框架"
swift
// 1. 渠道
public enum Distribution: Sendable {
case debug, appstore, testflight
public static var current: Self {
#if APPSTORE
return .appstore
#elseif TESTFLIGHT
return .testflight
#else
return .debug
#endif
}
}
// 2. 开关
public struct FeatureFlags: Sendable {
public let requirePaywall: Bool
public let requireOnboarding: Bool
public let featureX: Bool
public init(distribution: Distribution = .current) {
self.requirePaywall = (distribution != .testflight)
self.requireOnboarding = true
self.featureX = (distribution != .appstore)
}
}
// 3. 环境
extension EnvironmentValues {
@Entry public var featureFlags = FeatureFlags(distribution: .debug)
}
容易踩的坑
-
编译条件大小写敏感,#if APPSTORE 与 "AppStore" 不是一回事。
-
忘了在新增 Configuration 里加 Flag,结果跑到 AppStore 分支却开了 Debug 开关。
-
开关泛滥:半年不清理,代码里满屏
if flags.xx
把可读性拖垮。→ 建议给每个开关建 Issue,上线后第二周就安排清理。
-
开关被逆向:Bool 值直接埋包,越狱用户一翻就能改。
→ 若业务敏感,后期应迁移到服务端签名下发。
后续可扩展场景
-
远程云控开关
把
FeatureFlags
做成 Codable,App 启动时拉 JSON,再结合 Certificate Pinning + 签名验证,实现"秒级"回滚。 -
A/B 实验
在远程 JSON 里加
bucket
字段,按用户 ID 哈希到不同桶,同一个版本跑多套策略。 -
模块级别懒加载
与 SwiftUI
LazyView
或 TCA 的Reducer
组合,开关关闭时连模块都不装入内存,减少启动时间。 -
框架多租户
为不同地区/渠道/品牌生成多套
FeatureFlags
,但共用一套 Core,适合 SDK 厂商。
个人小结
特性开关不是"高大上"的专利,而是"三环境"工程里的最小可用实践。
先用编译条件解决 80% 问题,再逐步过渡到远程+灰度,既不会一上来就"过度设计",又保留了演进的余量。
把开关当"临时脚手架"看,上线第二版就拆,才能避免"flag 债"。
愿我们都能在 trunk-based 的快车道上,合并得早、发布得稳、回滚得秒。