搜狐技术产品

从 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 嵌套,直接评论“这里应该用策略模式”可能效果不佳。更好的方式是:

  1. 从“为什么”开始:解释当前写法的潜在问题,如“如果未来再增加一种用户类型,我们需要修改这个函数的多个地方,容易出错”,而不是直接给出方案。
  2. 提供小而美的示例:展示重构后的代码会是多么清晰和易于扩展,让对方看到收益。
  3. 结对编程:对于复杂的重构,可以坐下来一起完成,这既是传授知识,也是建立信任的过程。
  4. 建立共识:在团队内部分享类似本文这样的文章,讨论并形成关于“何时进行重构”的共识,将其纳入团队的编码规范中。

总结

从 if-else 到设计模式的转变,本质上是从“让代码工作”到“让代码活得更久、更好”的思维升级。Kotlin 凭借其现代化的语言特性,如密封类、数据类、高阶函数等,让这些经典模式的实现变得前所未有的简洁和强大。

关键不在于记住所有模式的 UML 图,而在于理解它们背后所解决的问题——封装变化、降低耦合、提升内聚。当你下一次写下 else if 时,不妨停下来思考一秒:这个分支逻辑未来会变吗?这里的职责是否单一?我能让它变得更易于测试和扩展吗?

这个思考的过程,就是你从初级走向资深的关键一步。

-- END --