从 if-else 到策略模式:Kotlin 高阶程序员必经之路
作为 Android 和 Kotlin 开发者,我们每天都在与逻辑分支打交道。if-else 作为最基础的控制流语句,是我们编码生涯的起点。它简单、直接,能快速解决问题。然而,随着项目变得越来越复杂,无处不在的 if-else 嵌套往往会演变成难以维护、难以测试、难以扩展的“代码泥潭”。
初级开发者习惯于用 if-else 堆砌功能,而资深开发者则懂得在恰当的时机,引入设计模式来重构和优化。这并非要全盘否定 if-else,而是要学会识别那些预示着“坏味道”的场景,并用更优雅、更具扩展性的方式去解决它们。
本文将从 Android/Kotlin 开发的实际场景出发,探讨如何将常见的 if-else 逻辑,逐步重构为更强大的设计模式。我们将覆盖策略模式、密封类、工厂模式、构建器、单例、高阶函数以及观察者模式,并深入讨论它们在 Kotlin 中的现代化应用、潜在的坑点以及如何在团队中推广这些实践。
1. 策略模式(Strategy Pattern):应对多变的业务规则
if-else 最常见的“重灾区”之一,就是处理多种相似但行为不同的业务规则。一个典型的例子是电商 App 中的运费计算。
❌ 坏味道:不断膨胀的运费计算逻辑
想象一下,你的代码里有这样一个函数:
// 初始版本,简单直接
funcalculateShippingFee(order: Order): Double {
returnif (order.type == "STANDARD") {
if (order.weight > 10) 15.0else5.0// 标准快递,按重量分档
} elseif (order.type == "EXPRESS") {
if (order.weight > 5) 25.0else10.0// 加急快递,分档不同
} elseif (order.type == "SAME_DAY") {
40.0// 当日达,固定费用
} else {
0.0// 其他未知类型
}
}
这段代码在初期可能工作得很好。但很快,产品经理带来了新需求:“我们要为 VIP 用户增加一种‘优先配送’,免除基础运费”、“我们要做活动,推出‘周末特惠’运送方式”。每增加一种运送方式,你就必须修改 calculateShippingFee 函数,加入更多的 else if 分支。这违反了软件设计中的 开闭原则(对扩展开放,对修改关闭),并且测试会变得异常痛苦,因为你需要覆盖所有分支。
✅ 好方案:用策略模式封装变化
策略模式的核心思想是:定义一系列算法,将每个算法封装起来,并使它们可以互相替换。
在我们的例子中,每一种运费计算规则就是一种“策略”。
第一步:定义策略接口
interfaceShippingStrategy{
funcalculate(order: Order): Double
}
第二步:创建具体的策略实现
每个策略类只负责一种计算逻辑,符合 单一职责原则。
classStandardShipping : ShippingStrategy {
overridefuncalculate(order: Order): Double = if (order.weight > 10) 15.0else5.0
}
classExpressShipping : ShippingStrategy {
overridefuncalculate(order: Order): Double = if (order.weight > 5) 25.0else10.0
}
classSameDayShipping : ShippingStrategy {
overridefuncalculate(order: Order): Double = 40.0
}
// 新增“优先配送”变得非常简单
classPriorityShipping : ShippingStrategy {
overridefuncalculate(order: Order): Double = 0.0// VIP 专享,免运费
}
第三步:使用策略
现在,调用方不再关心具体的计算逻辑,而是根据订单类型获取对应的策略并执行。我们可以用一个工厂来分发策略。
object ShippingStrategyFactory {
privateval strategies = mapOf(
"STANDARD" to StandardShipping(),
"EXPRESS" to ExpressShipping(),
"SAME_DAY" to SameDayShipping(),
"PRIORITY" to PriorityShipping()
)
funget(type: String): ShippingStrategy? = strategies[type]
}
// 使用起来非常清爽
val strategy = ShippingStrategyFactory.get(order.type)
val fee = strategy?.calculate(order) ?: 0.0// 如果找不到策略,给个默认值
💡 实践思考与坑点
优势:扩展性极强,新增策略无需修改任何原有代码。每个策略都可以独立进行单元测试,代码清晰度大大提升。 潜在坑点:当策略数量非常多时, ShippingStrategyFactory可能会变得臃肿。不过,在现代 Android 开发中,我们可以利用依赖注入框架(如 Hilt 或 Koin)来管理这些策略,例如,使用@IntoMap将所有ShippingStrategy实现自动注入到一个 Map 中,完全移除手动维护的工厂。
2. 密封类(Sealed Class):为业务状态建模
当 if-else 或 when 语句用于判断一组固定的、已知的类型或状态时,Kotlin 的密封类是绝佳的替代品。
❌ 坏味道:基于字符串或枚举的脆弱判断
处理网络请求的返回结果是一个常见场景。
// 使用常量字符串
constval SUCCESS = "SUCCESS"
constval ERROR = "ERROR"
constval LOADING = "LOADING"
funhandleState(state: String, data: String?, errorMessage: String?) {
if (state == SUCCESS) {
showContent(data)
} elseif (state == ERROR) {
showError(errorMessage)
} elseif (state == LOADING) {
showLoading()
}
// 问题:如果新增一个 "EMPTY" 状态,编译器不会提醒我在这里处理
}
使用枚举会好一些,但依然无法携带不同状态下的关联数据。例如,SUCCESS 状态有关联数据,而 ERROR 状态有关联错误信息。
✅ 好方案:用密封类实现“无效状态不可表示”
密封类允许我们定义一个严格的、可穷举的类层次结构。它的所有直接子类都必须在同一个文件中定义,这使得编译器能够知晓所有可能的情况。
sealedclassUiState<out T> {
dataclassSuccess<T>(valdata: T) : UiState<T>()
dataclassError(val message: String, val code: Int? = null) : UiState<Nothing>()
object Loading : UiState<Nothing>()
}
// 在 ViewModel 中使用
val userState = MutableStateFlow<UiState<User>>(UiState.Loading)
// 在 UI 层处理
funrender(state: UiState<User>) {
when (state) {
is UiState.Success -> showContent(state.data)
is UiState.Error -> showError(state.message)
is UiState.Loading -> showLoading()
// 这里不需要 else!因为 when 已经穷举了所有可能
}
}
💡 实践思考与坑点
优势: 编译期安全:当你在 when表达式中使用密封类时,如果遗漏了任何一个子类,编译器会报错,强制你处理所有情况。这在需求变更时(如增加Empty或Offline状态)是救命稻草。类型安全与数据传递:每个状态都可以携带自己的数据类型,例如 Success携带User对象,Error携带String。这是枚举无法做到的。代码可读性:它清晰地定义了业务的所有合法状态,实现了“让无效状态不可表示”的编程思想。 潜在坑点:密封类的子类必须嵌套或定义在同一个文件中。这对于大多数场景是合理的,但如果你的状态定义需要跨模块(Module)扩展,密封类就不适用了。此时,你可能需要回归到更传统的接口+实现模式。
3. 工厂模式(Factory Pattern):解耦对象的创建与使用
当对象的创建逻辑比较复杂,或者你想对调用者隐藏具体实现类的细节时,工厂模式就派上用场了。
❌ 坏味道:在业务代码中散落的 new 或构造函数调用
假设你的 App 支持多种支付方式:
funonPayButtonClick(paymentMethod: String, amount: Double) {
val paymentProcessor: PaymentProcessor = if (paymentMethod == "WECHAT_PAY") {
WechatPaymentProcessor(apiKey = "...", appId = "...")
} elseif (paymentMethod == "ALI_PAY") {
AlipayPaymentProcessor(partnerId = "...")
} else {
throw IllegalArgumentException("Unsupported payment method")
}
paymentProcessor.pay(amount)
}
这里的 onPayButtonClick 方法承担了太多的职责:它既要知道如何选择支付处理器,又要知道如何创建它们(包括它们的依赖,如 apiKey)。
✅ 好方案:用工厂封装创建逻辑
第一步:定义通用接口和实现
interfacePaymentProcessor{
funpay(amount: Double)
}
classWechatPaymentProcessor(...) : PaymentProcessor { ... }
classAlipayPaymentProcessor(...) : PaymentProcessor { ... }
第二步:创建工厂
object PaymentProcessorFactory {
funcreate(method: PaymentMethod, config: PaymentConfig): PaymentProcessor {
returnwhen (method) {
PaymentMethod.WECHAT_PAY -> WechatPaymentProcessor(config.wechatApiKey, config.wechatAppId)
PaymentMethod.ALI_PAY -> AlipayPaymentProcessor(config.alipayPartnerId)
}
}
}
enumclassPaymentMethod{ WECHAT_PAY, ALI_PAY }
// 使用
val processor = PaymentProcessorFactory.create(selectedMethod, paymentConfig)
processor.pay(amount)
💡 实践思考与坑点:拥抱依赖注入
在现代 Android/Kotlin 开发中,一个独立的、手写的工厂类有时仍显得有些“笨重”。更现代的做法是 将工厂的职责交给依赖注入(DI)框架。
例如,使用 Hilt,你可以这样做:
// 在 Module 中提供不同的支付实现
@Module
@InstallIn(SingletonComponent::class)
object PaymentModule {
@Provides
@Named("wechat")
funprovideWechatProcessor(): PaymentProcessor = WechatPaymentProcessor(...)
@Provides
@Named("alipay")
funprovideAlipayProcessor(): PaymentProcessor = AlipayPaymentProcessor(...)
}
// 在 ViewModel 中注入一个 Map,DI 框架自动扮演了工厂的角色
@HiltViewModel
classCheckoutViewModel@Injectconstructor(
privateval processors: Map<String, @JvmSuppressWildcards PaymentProcessor>
) : ViewModel() {
funpay(method: String, amount: Double) {
val processor = processors[method] ?: throw IllegalArgumentException("Unsupported method")
processor.pay(amount)
}
}
优势:完全解耦。ViewModel 只需声明它需要一个 Map<String, PaymentProcessor>,而无需关心这些处理器来自哪里、如何被创建。测试时,你可以轻松地注入一个包含 mock 处理器的 Map。潜在坑点:过度使用 DI 和复杂的绑定规则可能会让新手感到困惑。关键是保持 Module 配置的清晰和合理分组。
4. 构建器模式(Builder):告别冗长的构造函数
当一个对象有很多可选配置参数时,传统的构造函数或多个重载会变得难以管理。Java 开发者对此深有体会,并催生了经典的 Builder 模式。
❌ 坏味道:Android AlertDialog 的链式调用
经典的 Java 风格 Builder:
// Java 风格
new AlertDialog.Builder(context)
.setTitle("退出")
.setMessage("您确定要退出应用吗?")
.setPositiveButton("确定", ...)
.setNegativeButton("取消", ...)
.setCancelable(false)
.create()
.show();
这种方式虽然解决了构造函数参数过多的问题,但在 Kotlin 中,我们有更简洁、更地道的选择。
✅ 好方案:利用数据类和命名参数
Kotlin 的 data class 配合 默认参数值 和 命名参数,提供了一种天然的、轻量级的“构建器”实现。
dataclassDialogConfig(
val title: String,
val message: String,
val positiveButtonText: String = "确定",
val negativeButtonText: String? = "取消",
val cancelable: Boolean = true,
val onPositiveClick: () -> Unit,
val onNegativeClick: (() -> Unit)? = null
)
funshowCustomDialog(context: Context, config: DialogConfig) {
// ... 构建和显示 Dialog 的逻辑
}
// 使用时,代码意图非常清晰
showCustomDialog(context, DialogConfig(
title = "删除确认",
message = "此操作不可恢复,确定要删除吗?",
cancelable = false,
onPositiveClick = { /* 执行删除操作 */ }
))
💡 实践思考与坑点
优势: 不可变性: data class默认是不可变的(如果属性都用val),这在多线程和状态管理中是巨大的优势。简洁性:无需编写单独的 Builder类,代码量大大减少。可读性:命名参数使得每个参数的含义一目了然,不会弄错顺序。 潜在坑点:如果配置项之间有复杂的依赖或校验规则(例如,设置了 A就必须设置B),data class可能不足以表达这些约束。在这种情况下,你可以结合init块进行校验,或者创建一个带有私有构造函数的类和一个 DSL 风格的构建函数来提供更强的约束力。
5. 单例模式(Singleton):全局管理器的边界
单例确保一个类只有一个实例,并提供一个全局访问点。在 Kotlin 中,object 关键字让实现单例变得轻而易举。
✅ 好方案:用 object 实现线程安全的单例
object AnalyticsManager {
init {
// 初始化 Firebase, Mixpanel, etc.
println("AnalyticsManager initialized.")
}
funtrackScreen(screenName: String) {
// ... 上报逻辑
println("Tracking screen: $screenName")
}
funtrackEvent(eventName: String, params: Map<String, Any>) {
// ... 上报逻辑
println("Tracking event: $eventName with $params")
}
}
// 在任何地方直接使用
AnalyticsManager.trackScreen("HomePage")
Kotlin 的 object 声明在底层被编译为静态代码块初始化,这保证了其初始化过程的 线程安全。
💡 实践思考与坑点:谨慎使用单例
虽然 object 很方便,但单例模式在现代软件设计中被认为是一种需要警惕的模式,因为它本质上是 全局状态。
优势:实现简单,提供方便的全局访问。 潜在坑点: 测试困难:依赖于单例的类很难进行单元测试,因为你无法轻易地 mock 或替换这个全局实例。 隐藏依赖:代码直接调用 AnalyticsManager.trackEvent(...),这层依赖关系是隐式的,不像通过构造函数注入那样明确。生命周期问题:如果单例持有对 Context或其他短生命周期对象的引用,极易导致内存泄漏。规避建议: 优先考虑依赖注入:与其让代码直接依赖 AnalyticsManager,不如定义一个AnalyticsService接口,然后通过 DI 框架将它的object实现注入到需要它的地方。这样在测试时,你就可以注入一个假的实现。明确边界:只将那些真正意义上全局、无状态或状态与应用生命周期完全绑定的服务作为单例,如日志、分析、配置中心等。
6. 高阶函数与 Map:让分支逻辑成为数据
面对一长串基于常量或枚举的 if-else 或 when,我们可以用一种更函数式、更具扩展性的方式来重构。
❌ 坏味道:根据指令执行不同操作
funhandleAction(action: String) {
when (action) {
"LOGIN" -> navigateToLogin()
"LOGOUT" -> performLogout()
"VIEW_PROFILE" -> navigateToProfile()
"SETTINGS" -> navigateToSettings()
// 新增一个动作,就要修改这里
else -> showUnsupportedAction()
}
}
✅ 好方案:用 Map 存储“指令-动作”映射
在 Kotlin 中,函数是一等公民。我们可以将函数(或 lambda)作为值存入一个 Map 中。
val actions: Map<String, () -> Unit> = mapOf(
"LOGIN" to ::navigateToLogin,
"LOGOUT" to ::performLogout,
"VIEW_PROFILE" to ::navigateToProfile,
"SETTINGS" to ::navigateToSettings
)
funhandleAction(action: String) {
val task = actions[action]
if (task != null) {
task.invoke()
} else {
showUnsupportedAction()
}
// 或者更简洁:
// actions[action]?.invoke() ?: showUnsupportedAction()
}
💡 实践思考与坑点
优势: 可扩展:新增一个动作,只需向 Map 中添加一个条目,完全无需修改 handleAction函数。这个 Map 甚至可以从配置文件或服务器动态构建。去耦合: handleAction函数不再与具体的操作逻辑耦合,它只负责查询和执行。潜在坑点: 性能:对于性能极其敏感的热点路径,Map 的查找开销(虽然很小)理论上会比编译期优化的 when表达式要高。但在绝大多数 UI 和业务逻辑场景中,这点差异可以忽略不计。内存:如果 Map 中存储的 lambda 捕获了外部的大对象(如 Activity),需要注意潜在的内存泄漏风险。
7. 观察者模式与 Lambda:简化事件流处理
观察者模式在 UI 编程中无处不在,用于实现事件的发布-订阅。传统上,这需要定义接口和匿名内部类。
✅ 好方案:用 Lambda 和 Flow/LiveData 简化观察
Kotlin 的 lambda 表达式极大地简化了观察者的实现。在现代 Android 开发中,我们通常不直接手写观察者模式,而是利用 Jetpack 提供的组件。
手写一个简化的观察者:
classThemeManager{
privateval listeners = mutableListOf<(isDark: Boolean) -> Unit>()
funobserve(listener: (isDark: Boolean) -> Unit) {
listeners.add(listener)
}
funremove(listener: (isDark: Boolean) -> Unit) {
listeners.remove(listener)
}
funnotify(isDark: Boolean) {
listeners.forEach { it.invoke(isDark) }
}
}
// 使用
val themeListener = { isDark: Boolean -> updateUi(isDark) }
themeManager.observe(themeListener)
// ... 在适当的时候
themeManager.remove(themeListener) // 千万别忘了移除!
更现代的方式:使用 StateFlow
StateFlow 是一个内置了观察者模式、线程安全且感知生命周期的强大工具。
// 在 ViewModel 或 Repository 中
object ThemeRepository {
privateval _isDarkTheme = MutableStateFlow(false)
val isDarkTheme: StateFlow<Boolean> = _isDarkTheme
funtoggleTheme() {
_isDarkTheme.value = !_isDarkTheme.value
}
}
// 在 Fragment 或 Activity 中安全地观察
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
ThemeRepository.isDarkTheme.collect { isDark ->
updateUiForTheme(isDark)
}
}
}
💡 实践思考与坑点
优势:使用 Flow或LiveData,我们无需手动管理监听器列表,框架会为我们处理生命周期问题,自动在界面不可见时停止接收更新,有效避免内存泄漏。潜在坑点:手写观察者模式时,最常见的坑就是 忘记在组件销毁时移除监听器,导致内存泄漏。这也是为什么强烈推荐使用具备生命周期感知能力的组件。
什么时候不必用设计模式?警惕过度设计
设计模式是解决特定问题的优秀“药方”,但“是药三分毒”。如果你的逻辑真的很简单,并且在可预见的未来都不会改变,那么一个简单的 if-else 就是最好的选择。
“过早的优化是万恶之源。” —— Donald Knuth
记住 YAGNI 原则(You Ain't Gonna Need It)。在代码清晰可读的前提下,不要为了使用模式而使用模式。例如,一个只有两种固定状态的判断,用 if-else 或 when 就足够了,强行套用策略模式只会增加不必要的复杂性。
如何在团队中推动重构?
在代码评审(Code Review)中看到队友写下了复杂的 if-else 嵌套,直接评论“这里应该用策略模式”可能效果不佳。更好的方式是:
从“为什么”开始:解释当前写法的潜在问题,如“如果未来再增加一种用户类型,我们需要修改这个函数的多个地方,容易出错”,而不是直接给出方案。 提供小而美的示例:展示重构后的代码会是多么清晰和易于扩展,让对方看到收益。 结对编程:对于复杂的重构,可以坐下来一起完成,这既是传授知识,也是建立信任的过程。 建立共识:在团队内部分享类似本文这样的文章,讨论并形成关于“何时进行重构”的共识,将其纳入团队的编码规范中。
总结
从 if-else 到设计模式的转变,本质上是从“让代码工作”到“让代码活得更久、更好”的思维升级。Kotlin 凭借其现代化的语言特性,如密封类、数据类、高阶函数等,让这些经典模式的实现变得前所未有的简洁和强大。
关键不在于记住所有模式的 UML 图,而在于理解它们背后所解决的问题——封装变化、降低耦合、提升内聚。当你下一次写下 else if 时,不妨停下来思考一秒:这个分支逻辑未来会变吗?这里的职责是否单一?我能让它变得更易于测试和扩展吗?
这个思考的过程,就是你从初级走向资深的关键一步。
-- END --