一份代码,两个地区版本
同一代码库输出两个 Target,绝大部分代码相同,差异集中在登录按钮颜色、主题强调色、用户协议与隐私链接、支持邮箱和产品名称等末端值。
过去,这些差异散落在条件编译中:
final class LoginViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
#if REGION_INTL
loginButton.backgroundColor = UIColor(hex: 0x4A7DFF)
#else
loginButton.backgroundColor = UIColor(hex: 0xE84545)
#endif
#if REGION_INTL
termsLink.url = URL(string: "https://example.com/intl/terms")!
#else
termsLink.url = URL(string: "https://example.com/cn/terms")!
#endif
}
}
每个调用点都要回答“我属于哪个地区”,review 业务逻辑时不断跳过无关分支。手写多处 #if 还会让修复只落在一个 Target,另一个悄悄落后。
代码未必有错,但阅读和维护的成本越来越高。
现成方案为什么不完全合适
配置单例可以集中条件,但每处都变成 Config.shared.xxx,类型不能说明哪些值随地区变化,大配置文件也容易冲突。每个 Target 一份 plist 缺少编译期检查,颜色和 URL 需要解析,重构工具也难追踪。完整依赖注入适合行为差异,但为几个常量到处传递协议与初始化参数,可能过重。
我们想要的是更窄的能力:声明这个值来自哪里,然后保留普通属性读取的语法与类型检查。
新语言能力恰好对应旧问题
Swift 5.1 在 WWDC 2019 带来 Property Wrapper,SwiftUI 的 @State 和 Combine 的 @Published 都使用这种机制。它把“属性如何提供”放进可复用类型,与使用位置分离。
团队没有立即采用,而是在 Xcode 11 与最低支持环境稳定后,于 2020 年 6 月投入生产。价值不在于追新,而在于它终于为已有的重复问题提供了合适语法。
三层设计,只有一处 #if
首先,协议列出允许因地区变化的值:
protocol BrandSheet {
var loginButtonColor: UIColor { get }
var accentColor: UIColor { get }
var termsOfServiceURL: URL { get }
var privacyPolicyURL: URL { get }
var supportEmail: String { get }
}
每个地区用普通 struct 实现常量,再由一个选择文件根据编译条件决定当前 BrandSheet:
struct DomesticBrand: BrandSheet {
let loginButtonColor = UIColor(hex: 0xE84545)
let accentColor = UIColor(hex: 0xD63C3C)
let termsOfServiceURL = URL(string: "https://example.com/cn/terms")!
let privacyPolicyURL = URL(string: "https://example.com/cn/privacy")!
let supportEmail = "support.cn@example.com"
}
struct InternationalBrand: BrandSheet {
let loginButtonColor = UIColor(hex: 0x4A7DFF)
let accentColor = UIColor(hex: 0x3566D6)
let termsOfServiceURL = URL(string: "https://example.com/intl/terms")!
let privacyPolicyURL = URL(string: "https://example.com/intl/privacy")!
let supportEmail = "support@example.com"
}
#if REGION_INTL
let activeBrand: BrandSheet = InternationalBrand()
#else
let activeBrand: BrandSheet = DomesticBrand()
#endif
新增协议属性后,漏配地区会直接编译失败。这比运行时缺少 plist key 更早暴露问题。
最后,包装器通过 key path 从当前 sheet 取值:
@propertyWrapper
struct Branded<Value> {
private let keyPath: KeyPath<BrandSheet, Value>
var wrappedValue: Value {
activeBrand[keyPath: keyPath]
}
init(_ keyPath: KeyPath<BrandSheet, Value>) {
self.keyPath = keyPath
}
}
没有额外存储或状态语义,只是把“当前 Target 应取哪个值”集中到一个地方。
一处条件选择品牌配置,业务代码通过类型化 key path 读取,不再重复编译条件。
登录页面因此更容易读
final class LoginViewController: UIViewController {
@Branded(\.loginButtonColor) private var loginButtonColor: UIColor
@Branded(\.accentColor) private var accentColor: UIColor
@Branded(\.termsOfServiceURL) private var termsURL: URL
@Branded(\.privacyPolicyURL) private var privacyURL: URL
override func viewDidLoad() {
super.viewDidLoad()
loginButton.backgroundColor = loginButtonColor
forgotPasswordButton.tintColor = accentColor
termsLink.url = termsURL
privacyLink.url = privacyURL
}
}
viewDidLoad 只表达页面行为,顶部每个 @Branded 则清楚声明哪些值随 Target 变化。登录流程修改与品牌配置修改落在不同文件,review 不必反复切换语境。
新增地区只增加一个协议实现和选择条件,编译器会列出需要补齐的值,业务页面不必修改。
包装器不能替代流程设计
后来某个地区增加实名验证,曾尝试用一个品牌 Boolean 表达:
@Branded(\.needsRealNameVerification) private var needsRealNameVerification: Bool
虽然能编译,业务分支却仍散落在共享页面中,只是从编译条件变成运行时条件。包装器适合回答取什么值,不适合承担如何决策。
我们最终区分三类差异:
| 差异 | 合适的边界 |
|---|---|
| 颜色、URL、字符串等值 | Property Wrapper 与品牌配置 |
| 额外登录步骤、不同支付流程 | 协议与分地区实现,通过组装入口注入 |
| 某地区不能分发的模块 | Target membership,确保代码不进入对应二进制 |
成本与限制
@Branded 对新同事仍可能显得神秘,因此保持一个短小包装器和一个选择文件,不为了语法漂亮滥加包装器。
它隐藏的仍是进程范围的品牌选择。如果同一二进制需要在测试或运行时切换品牌,直接注入 BrandSheet 更清晰。单个 Target 的测试也无法覆盖另一区域,两个 Target 必须各有 CI 构建与验证。
此外,它虽然与 @Published、@State 共用语法,却没有存储、通知或状态管理能力,只是只读值解析。这个差异必须在文档中说明。
条件编译并没有消失,也不该消失,因为产品确实不同。它只是被放回一个明确位置,由协议守住完整性,让其余代码专注于页面本身。