SOLID 设计原则在 Android 中的实战应用
引言
大多数开发者都听说过 SOLID 设计原则,这些原则由鲍勃大叔(罗伯特・C・马丁)提出,有助于提高代码的可测试性和关注点分离。Android 开发者的对话中热衷讨论 MVVM,MVP,却很少提及 SOLID。但其实这些设计原则早已渗透在 Android 开发的方方面了,是一名高阶 Android 开发者必备的思想基础。本文通过多个示例为大家介绍 SOLID 在 Android 开发中的应用场景。
1. 单一职责原则(SRP)
SRP 要求一个类的逻辑行为都源于某一个功能的需求,即这个类只负责一个明确的职责。
在 Android 开发中,类很容易迅速变得臃肿 —— 比如 Activity、Fragment 或 ViewModel 要处理用户界面、导航、API 调用、错误处理等等。SRP 原则鼓励我们拆分职责,以便每个类都能把一件事做好。
示例 1:剥离 ViewModel 中的非 UI 逻辑
classLoginUseCase(privateval repository: LoginRepository) {
suspendfunlogin(username: String, password: String): Result<User> {
if (username.isEmpty()) return Result.failure(Exception("Username empty"))
return repository.login(username, password)
}
}
classLoginViewModel(privateval useCase: LoginUseCase) : ViewModel() {
val uiState = MutableStateFlow<UiState>(UiState.Idle)
funlogin(username: String, password: String) {
viewModelScope.launch {
uiState.value = UiState.Loading
val result = useCase.login(username, password)
uiState.value = if (result.isSuccess) UiState.Success else UiState.Error
}
}
}
将业务逻辑从 ViewModel 中分离出来并放入 UseCase,实现了关注点分离,提高了可测试性。ViewModel 专注于用户界面状态管理,而 UseCase 可复用且可单独测试。
示例 2:从 Repo 中提取 API 调用
interfaceRemoteDataSource{
suspendfunlogin(username: String, password: String): Result<User>
}
classLoginRepository(privateval remote: RemoteDataSource) {
suspendfunlogin(username: String, password: String) = remote.login(username, password)
}
将 API 调用的职责委托给 RemoteDataSource,使仓库专注于数据协调。这使得在测试时更容易模拟替换远程请求。
示例 3:拆分 Fragment 中的职责
classProfileFragment : Fragment() {
privateval viewModel: ProfileViewModel by viewModels()
overridefunonViewCreated(view: View, savedInstanceState: Bundle?) {
setupUi()
observeViewModel()
}
privatefunsetupUi() { /* setup views, listeners */ }
privatefunobserveViewModel() { /* collect flows */ }
}
不要把所有内容都堆在 onViewCreated 方法里,给每个方法分配特定的任务。这可以提高代码可读性,并确保 Fragment 遵循 SRP 原则 —— 每个方法执行一项任务。
2. 开闭原则(OCP)
软件实体应该对扩展开放,对修改关闭。我们应该在不修改现有代码的情况下添加新功能,可以降低出现错误的风险,让代码库具备扩展性。
示例 1:为页面状态扩展密封类
sealedclassUiState{
object Loading : UiState()
dataclassSuccess(valdata: String) : UiState()
dataclassError(val message: String) : UiState()
}
我们用密封类 UiState 表示页面的数据状态。
dataclassEmpty(val reason: String) : UiState()
若我们要添加一个新的状态,例如 Empty(空数据),依据开闭原则,我们不应该修改现有的 UiState 类,而应该通过密封类的继承,扩展 UiState 类。Kotlin 为了让大家更好地遵循 OCP,在 1.5 之后允许密封类在文件之外定义子类,让代码可以安全演进,意图明确。
示例 2:使用 DI 自定义 ViewModelFactory
传统方式中,我们经常会在 ViewModel 中依赖一个具体 UseCase,当我们需要升级依赖时,则必须修改 ViewModel 代码,违反开闭原则。
classMyViewModelFactory@Injectconstructor(
privateval useCase: SomeUseCase
) : ViewModelProvider.Factory {
overridefun<T : ViewModel>create(modelClass: Class<T>): T {
return MyViewModel(useCase) as T
}
}
而通过自定义 ViewModelFactory 和依赖注入,我们可以实现对扩展开放,对修改关闭。
当有新需求,要求更新 UseCase 依赖时,我们通过创建新的 UseCase 实例并传递给工厂,以此来扩展 ViewModel 的功能(对扩展开放),而不需要修改 ViewModel 类本身(对修改关闭)。
示例 3:Composable Modifier 扩展
在 Jetpack Compose 里,Modifier 是用于修改可组合项(Composable)外观和行为的工具。可以把它看作是一系列装饰器,能以链式调用的方式添加到可组合项上,为其添加各种效果,像大小、边距、点击事件、背景颜色等。
Modifier 的实现是封装好的,我们在使用时无需关心其内部实现细节。当要修改 Composable 的行为时,只需添加或移除相应的 Modifier(对扩展开放),而不用修改 Modifier 以及 Composable 本身的代码(对修改关闭)。
fun Modifier.errorBorder(): Modifier = this
.border(2.dp, Color.Red)
TextField(
value = username,
onValueChange = { username = it },
modifier = Modifier
.fillMaxWidth()
.then(if (hasError) Modifier.errorBorder() else Modifier)
)
且像上面例子 errorBorder 所示,Modifier 可以通过链式调用自定义 Modifier。这也是一种“对扩展开放”的体现。
综上,Modifier 允许在不修改基础用户界面组件的情况下扩展用户界面行为。通过 OCP 的思想让用户界面保持灵活且可复用。
3. 里氏替换原则(LSP)
子类型必须能够替换它们的基类型。 任何子类或实现都应该能够在基类型被期望出现的任何地方使用,而不会引入错误。
示例 1:使用模板方法的 BaseFragment
abstractclassBaseFragment : Fragment() {
abstractfunsetupObservers()
abstractfunsetupUI()
overridefunonViewCreated(view: View, savedInstanceState: Bundle?) {
setupUI()
setupObservers()
}
}
classLoginFragment : BaseFragment() {
overridefunsetupUI() { /* Login UI */ }
overridefunsetupObservers() { /* ViewModel observers */ }
}
我们有一个基础的 BaseFragment 类,它定义了一些通用的生命周期行为。其子类无需重写这些生命周期方法,但是 setupUI、setupObservers 等抽象方法,需要由子类来实现具体的逻辑。
所有子 Fragment 都可以自动继承 Base 的初始化逻辑,并在合适的时机被安全调用。
示例 2:Multi-type RecyclerView
abstractclassBaseViewHolder(view: View) : RecyclerView.ViewHolder(view) {
abstractfunbind(item: ListItem)
}
classTextViewHolder(view: View) : BaseViewHolder(view) {
overridefunbind(item: ListItem) { /* Bind text */ }
}
classImageViewHolder(view: View) : BaseViewHolder(view) {
overridefunbind(item: ListItem) { /* Bind image */ }
}
每种 ViewHolder 类型都可以在期望 BaseViewHolder 的任何地方使用。RecyclerAdapter 逻辑保持通用,但行为可定制。
示例 3:替换 CoroutineDispatcher 进行测试
openclassCoroutineDispatcherProvider{
openval io = Dispatchers.IO
openval main = Dispatchers.Main
}
classTestDispatcherProvider : CoroutineDispatcherProvider() {
overrideval io = UnconfinedTestDispatcher()
overrideval main = UnconfinedTestDispatcher()
}
我们可以定义一个 CoroutineDispatcherProvider 类提供默认 Dispatcher。针对测试代码和生产代码提供不同的子类。
这就是里氏替换原则的体现,子类型能够安全地替换基类型,不会影响程序的正常运行,并且可以根据不同的需求(生产环境或测试环境)灵活地切换具体的实现。
4. 接口隔离原则(ISP)
一个类不应该被迫依赖它们不使用的接口。 这意味着保持接口简洁且专注 —— 没有臃肿的方法契约。
示例 1:拆分 Repository 接口
interfaceAuthRepository{
suspendfunlogin(username: String, password: String): Result<User>
}
interfaceProfileRepository{
suspendfungetProfile(): Result<UserProfile>
}
很多项目中将 User 相关的请求封装为 UserRepository,但其实不符合 ISP 原则。比如 LoginViewModel 不需要了解 getProfile () 方法,因为它用不到。
所以推荐大家将 UserRepository 拆成多个,更小的接口减少了耦合,简化了测试。
示例 2:RecyclerView 事件点击
interfaceOnImageClickListener{
funonImageClick(url: String)
}
interfaceOnTextClickListener{
funonTextClick(text: String)
}
RecyclerView 中的 ViewHolder 可能有多种视图组件组成,不同组件对应不同的 Listener。
如果我们充分识别到不同样式的列表条目是否对视图组件的需求不同,组件需要具备灵活移除的能力,那我们就不应该设置一个 ViewHolder 级别的 Listener,而应该针对不同组件拆分不同接口,让每个只实现它需要的功能。这使组件更加轻量级,避免了令人困惑的方法存根(Method Stub,即为了是实现无用接口而保留 Stub 空实现)。
示例 3:拆分特定用途的视图接口
interfaceLoginContract{
funshowLoginForm()
funshowLoginSuccess()
}
interfaceSignupContract{
funshowSignupForm()
funshowSignupSuccess()
}
虽然,上面两个接口的方法雷同,但是仍然拆分两个接口,而不是合并成包含 showForm 和 showSignup 的唯一接口。因为这两个接口对应的页面(登录和注册)并不同,随着需求迭代,未来的差异也许会越来越大。
除非我们能确定两个页面存在功能上的父子关系,否则我们应该避免两个不同页面通过同一个接口共享方法。避免未来“登录”需要空实现一个“注册”专用的方法,坚持 ISP 思想可以减少这类代码的腐化。
5. 依赖倒置原则(DIP)
依赖于抽象,而非具体实现。
这是现代安卓开发中推崇的使用 MVX 以及 DI 构建可扩展应用架构的基础思想。
示例 1:将 UseCase 注入 ViewModel
classLoginViewModel(privateval loginUseCase: LoginUseCase) : ViewModel() {
funlogin() = viewModelScope.launch {
loginUseCase.login()
}
}
Android 官方架构推荐为 ViewModel 注入 UseCase,因此,ViewModel 可以只调用抽象,不需要关心具体实现是什么,这有利于我们在 TestCase 中轻松替换一个 Mock 的 UseCase 辅助测试。
示例 2:抽象数据源
interfaceUserDataSource{
suspendfunfetchUser(): User
}
classRemoteUserDataSource : UserDataSource {
overridesuspendfunfetchUser() = api.getUser()
}
classLocalUserDataSource : UserDataSource {
overridesuspendfunfetchUser() = db.getUser()
}
仓库模式(Repository Pattern)中离不开抽象数据源的定义,我们可以在运行时在数据源之间任意切换 —— 例如,在线时使用远程数据源,离线时使用本地数据源,而无需更改代码的其他部分。
示例 3:用户认证存储接口
假设存在一个用户认证系统,其中业务模块负责处理用户登录和认证逻辑,而 AuthStorage 作为底层模板用于存储用户认证信息。
interfaceAuthStorage{
suspendfunsaveToken(token: String)
suspendfungetToken(): String?
}
classDataStoreAuthStorage(...) : AuthStorage {
overridesuspendfunsaveToken(token: String) { /* Save to DataStore */ }
overridesuspendfungetToken(): String? { /* Load from DataStore */ }
}
业务模块只依赖 AuthStorage 接口而非具体实现,我们可以将其替换为其他东西,比如 DataStore 亦或者 EncryptedSharedPrefs。
结论
SOLID 原则并非只是抽象理论,做一个简单回顾:
SRP 拆分职责,让类保持专注。 OCP 在不修改现有代码的情况下安全扩展。 LSP 确保安全的继承和多态性。 ISP 有助于拆分臃肿的接口。 DIP 解耦逻辑,提高可测试性和灵活性。
从将 UseCase 注入 ViewModel 开始,希望大家举一反三,不断融入自己的代码,打造一个真正“坚固”的架构。