不少 Android 项目在第一个版本里都很"顺":Activity 里发请求、解析 JSON、更新 UI,几百行代码也能按时上线。问题往往出现在后面------接口要增加公共参数、网络库要替换、列表要同时支持缓存、埋点和重试,最后一个小改动牵动十几个页面。
这时我们缺的通常不是又一个设计模式,而是一套判断代码边界的方法。
常说的面向对象六大基本原则包括:
| 原则 | 英文缩写 | 一句话理解 |
|---|---|---|
| 单一职责原则 | SRP | 一个模块只对一类变化负责 |
| 开闭原则 | OCP | 新需求优先通过扩展完成,而不是反复修改稳定代码 |
| 里氏替换原则 | LSP | 子类型必须能安全替换父类型或抽象 |
| 依赖倒置原则 | DIP | 业务策略依赖抽象,不依赖基础设施细节 |
| 接口隔离原则 | ISP | 使用方只依赖自己真正需要的最小能力 |
| 最少知识原则(迪米特法则) | LoD | 对象只和直接协作者沟通,少了解内部结构 |
前五项就是经典的 SOLID 。国内很多资料把迪米特法则加入后,合称"六大原则"。它们不是必须逐条套用的教条,也不是类和接口越多越好。六项原则最终都在解决同一件事:把变化限制在尽可能小的范围内。
一、单一职责原则:一个类只做一件事吗?
单一职责原则(Single Responsibility Principle,SRP)更准确的定义是:
一个模块应该只有一个引起它变化的原因。
"只做一件事"容易被误解。一个 Repository 可以同时包含查询、保存和删除方法,只要这些方法都服务于同一类业务变化,就仍然可以职责单一。真正的问题是把不同角色关心的变化混在一起。
反例:无所不能的 ViewModel
kotlin
class OrderViewModel : ViewModel() {
fun loadOrders() {
// 1. 拼接 URL 和公共参数
// 2. 使用 OkHttp 发请求
// 3. 使用 Gson 解析 JSON
// 4. 写入 Room
// 5. 计算订单展示状态
// 6. 更新界面并上报埋点
}
}
这段代码至少会因为六类原因变化:后端协议、网络框架、序列化格式、数据库结构、业务规则、UI 与埋点。任何一处变化都需要修改同一个类,它也很难被独立测试。
Android 中的拆分方式
kotlin
class OrderViewModel(
private val observeOrders: ObserveOrdersUseCase
) : ViewModel() {
val uiState: StateFlow<OrderUiState> = observeOrders()
.map<List<Order>, OrderUiState> { OrderUiState.Success(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = OrderUiState.Loading
)
}
class OrderRepositoryImpl(
private val remote: OrderRemoteDataSource,
private val local: OrderLocalDataSource
) : OrderRepository {
override fun observeOrders(): Flow<List<Order>> = local.observeOrders()
override suspend fun refresh() {
local.replaceAll(remote.fetchOrders())
}
}
职责可以这样划分:
- ViewModel 负责把业务状态转换成 UI 状态;
- UseCase 负责业务流程与规则;
- Repository 负责协调数据来源;
- RemoteDataSource 负责远端访问;
- LocalDataSource 负责本地持久化;
- Mapper 负责 DTO、Entity 与领域模型转换。
Android 中常见的 SRP 场景
- 把 Activity/Fragment 中的状态管理移入 ViewModel;
- 将 Retrofit 接口、缓存策略和业务规则分开;
- RecyclerView 的 Adapter 只负责列表展示,把点击后的业务动作交给上层;
- WorkManager 的 Worker 负责任务调度,具体同步逻辑由 UseCase 承担;
- Compose 中把有状态组件与无状态 UI 拆分。
不要拆得过细
如果为了 SRP 把每个三行函数都变成一个类,阅读一次业务要跳转十几个文件,复杂度只是从类内部转移到了类之间。判断是否需要拆分,可以问:
- 这几段代码是否由不同原因、不同角色推动变化?
- 它们能否独立测试或复用?
- 拆分后是否形成清晰边界,而不只是增加文件数量?
二、开闭原则:需求变化时,增加代码而不是改遍代码
开闭原则(Open/Closed Principle,OCP)指的是:
软件实体应该对扩展开放,对修改关闭。
"关闭"不是一行旧代码都不能改,而是让已经稳定、验证过的核心流程尽量不因新增类型而被反复修改。
反例:不断增长的 when
kotlin
fun track(event: TrackEvent) {
when (event.channel) {
Channel.FIREBASE -> firebaseAnalytics.logEvent(event.name, event.params)
Channel.UMENG -> MobclickAgent.onEventObject(context, event.name, event.params)
Channel.INTERNAL -> api.upload(event)
}
}
每增加一个埋点平台,都要修改这个分支。如果类似判断散落在多个模块中,新增渠道会变成一次全局搜索。
通过扩展实现变化
kotlin
interface AnalyticsTracker {
fun track(event: TrackEvent)
}
class FirebaseTracker : AnalyticsTracker {
override fun track(event: TrackEvent) {
// 调用 Firebase SDK
}
}
class CompositeTracker(
private val trackers: Set<@JvmSuppressWildcards AnalyticsTracker>
) : AnalyticsTracker {
override fun track(event: TrackEvent) {
trackers.forEach { it.track(event) }
}
}
接入新平台时增加一个 AnalyticsTracker 实现,并通过 Hilt multibinding 注入集合,稳定的调用流程不需要改变。
Android 中常见的 OCP 场景
- RecyclerView 使用不同
ViewHolder/delegate 扩展新的 item 类型; - OkHttp 通过 Interceptor 扩展鉴权、日志、重试和公共参数;
- Retrofit 通过 Converter.Factory 扩展序列化能力;
- 图片加载库通过接口支持磁盘、内存、网络等不同数据源;
- Compose 通过
Modifier链扩展布局和交互行为; - 支付、分享、登录等多渠道能力使用策略实现。
什么时候直接修改更合适
如果需求只会出现一次、变化方向尚不明确,提前设计扩展点可能得到一个没人需要的抽象。OCP 的前提是已经识别到稳定部分和可变部分;没有稳定边界时,先写清楚、补测试,再随着真实变化重构。
三、里氏替换原则:能编译,不代表能替换
里氏替换原则(Liskov Substitution Principle,LSP)要求:
使用父类型或抽象的地方,替换成任意子类型后,程序仍应保持正确行为。
它约束的不只是方法签名,还包括行为契约:输入范围不能被子类无故收紧,输出承诺不能削弱,异常和副作用也应符合调用方预期。
一个违反 LSP 的缓存实现
kotlin
interface UserCache {
suspend fun save(user: User)
suspend fun get(id: String): User?
}
class ReadOnlyUserCache : UserCache {
override suspend fun save(user: User) {
throw UnsupportedOperationException("read only")
}
override suspend fun get(id: String): User? = null
}
ReadOnlyUserCache 虽然实现了接口,却无法履行 save 契约。任何认为 UserCache 可读写的调用方在替换后都会崩溃。
更合理的方式是拆开能力:
kotlin
interface UserReader {
suspend fun get(id: String): User?
}
interface UserWriter {
suspend fun save(user: User)
}
class RoomUserCache(
private val dao: UserDao
) : UserReader, UserWriter {
override suspend fun get(id: String): User? = dao.find(id)?.toDomain()
override suspend fun save(user: User) {
dao.insert(user.toEntity())
}
}
Android 中常见的 LSP 场景
RecyclerView.setLayoutManager()可以接收LinearLayoutManager、GridLayoutManager等实现;- Repository 的生产实现与 Fake 实现应保持相同业务语义;
- 替换 Retrofit、Ktor 或本地 Mock 数据源后,上层不应感知基础设施差异;
- 自定义 View 覆盖父类方法时,不能破坏测量、布局或生命周期约定;
List与可变集合要谨慎替换,不能向调用方暗中暴露可变行为。
检查 LSP 的实用问题
- Fake 是否为了测试方便返回了线上永远不可能出现的状态?
- 某个实现是否对"不支持"的方法直接抛异常?
- 子类是否要求比抽象更严格的参数?
- 替换实现后,调用方是否还要写
if (impl is Xxx)?
如果调用方必须识别具体实现才能正确工作,抽象通常已经失效。
四、依赖倒置原则:让业务规则站在依赖图上层
依赖倒置原则(Dependency Inversion Principle,DIP)包含两层含义:
- 高层策略不应该依赖低层实现,两者都应依赖抽象;
- 抽象不应该依赖细节,细节应该依赖抽象。
这里的"高层"是业务规则,"低层"是 Retrofit、Room、文件系统、第三方 SDK 等技术细节。
直接依赖细节
kotlin
class LoginViewModel : ViewModel() {
private val api = Retrofit.Builder()
.baseUrl(BASE_URL)
.build()
.create(LoginApi::class.java)
fun login(account: String, password: String) {
viewModelScope.launch {
api.login(LoginRequest(account, password))
}
}
}
ViewModel 知道 Retrofit、请求 DTO 和服务地址,测试登录规则时也不得不处理网络细节。
让基础设施实现业务所需的抽象
kotlin
interface AuthRepository {
suspend fun login(account: String, password: String): LoginResult
}
class LoginUseCase(
private val authRepository: AuthRepository
) {
suspend operator fun invoke(account: String, password: String): LoginResult {
require(account.isNotBlank()) { "账号不能为空" }
require(password.length >= 8) { "密码至少 8 位" }
return authRepository.login(account, password)
}
}
class NetworkAuthRepository(
private val api: LoginApi
) : AuthRepository {
override suspend fun login(account: String, password: String): LoginResult =
api.login(LoginRequest(account, password)).toDomain()
}
依赖方向变成:
text
ViewModel -> LoginUseCase -> AuthRepository <- NetworkAuthRepository -> Retrofit
^
└------ FakeAuthRepository(测试)
Hilt/Dagger 等依赖注入框架负责在应用启动时完成"抽象到实现"的组装,但要注意:依赖注入是实现 DIP 的手段,不等于 DIP 本身。即使手动构造对象,只要依赖方向正确,也符合原则。
Android 中常见的 DIP 场景
- Domain 层定义 Repository 接口,Data 层提供实现;
- 业务代码依赖
Clock,而不是直接调用System.currentTimeMillis(); - 文件上传业务依赖
Uploader,而不是直接依赖某云厂商 SDK; - 导航逻辑依赖
Navigator,避免 ViewModel 持有 Activity; - 日志与埋点依赖应用自己的门面接口,隔离第三方 SDK。
五、接口隔离原则:别让调用方被迫依赖无关能力
接口隔离原则(Interface Segregation Principle,ISP)指的是:
客户端不应该依赖它不需要的方法,类之间的依赖应建立在最小接口上。
这里的"客户端"不是手机端,而是接口的使用方。
反例:万能媒体接口
kotlin
interface MediaController {
fun play()
fun pause()
fun seekTo(positionMs: Long)
fun startRecording()
fun stopRecording()
fun takePhoto()
}
一个只播放音频的类被迫实现录音、拍照方法,最后只能留空或抛异常。这也容易进一步违反 LSP。
按使用方需要拆分
kotlin
interface Player {
fun play()
fun pause()
fun seekTo(positionMs: Long)
}
interface Recorder {
fun startRecording()
fun stopRecording()
}
interface CameraCapture {
suspend fun takePhoto(): Uri
}
页面只注入它实际需要的能力:
kotlin
class PodcastViewModel(
private val player: Player
) : ViewModel()
Android 中常见的 ISP 场景
- 将巨大回调接口拆成
OnItemClickListener、OnRetryListener等小接口; - 按业务查询拆分 DAO,避免所有模块都依赖一个"万能 DAO";
- 权限请求器只暴露当前功能需要的权限能力;
- 模块对外只暴露入口路由或用例,不泄露内部 Repository、DAO 和 DTO;
- Kotlin 中用函数类型替代只有一个方法的回调接口。
ISP 关注的是调用方需要什么 ,SRP 关注的是实现方因什么变化。两者经常一起出现,但观察角度不同。
六、最少知识原则:不要穿透对象的内部结构
最少知识原则(Law of Demeter,LoD)又称迪米特法则,核心可以概括为:
只与直接朋友通信,不要让一个对象了解协作者的内部结构。
反例:跨越多层取数据
kotlin
val city = sessionManager
.currentUser
.profile
.address
.city
调用方知道 SessionManager -> User -> Profile -> Address 的完整结构。任何中间层变化、可空字段或延迟加载策略,都可能迫使调用方修改。
提供调用方真正需要的能力
kotlin
class UserSession(
private val userStore: UserStore
) {
fun currentCity(): String? =
userStore.currentUser()?.profile?.address?.city
}
调用方只需要:
kotlin
val city = userSession.currentCity()
内部数据如何组织被限制在 UserSession 内部。
Android 中常见的 LoD 场景
- Fragment 不直接操作 Activity 内部的 View,而是通过导航、回调或共享状态协作;
- ViewModel 不持有 Activity/Fragment,更不沿着 Context 获取其他组件;
- 业务模块不穿透 Repository 直接拿 DAO 或 Retrofit Service;
- Adapter 通过回调上报用户意图,不直接调用页面的 Repository;
- 模块间只通过公开 API 或导航契约通信,不访问对方内部类。
也不要走向"层层转发"
为了遵守 LoD 给每个 getter 再包一层,没有真正隐藏结构,只会制造大量传话方法。好的门面方法应该表达业务意图,例如 canPlaceOrder(),而不只是把 getA().getB().getC()机械改写成三个代理 getter。
七、六大原则如何共同落地:以图片加载为例
假设页面需要显示头像,数据可能来自内存、磁盘或网络。一个可演进的最小设计如下:
kotlin
interface ImageSource {
suspend fun load(key: String): ByteArray?
}
class ImageLoader(
private val sources: List<ImageSource>,
private val decoder: ImageDecoder
) {
suspend fun load(key: String): Bitmap? {
val bytes = sources.firstNotNullOfOrNull { it.load(key) }
return bytes?.let(decoder::decode)
}
}
六项原则在这里不是六份独立代码,而是互相配合:
- SRP:获取字节、解码图片、展示图片分别负责不同变化;
- OCP :新增 CDN、资源文件等来源时增加
ImageSource实现; - LSP :任意
ImageSource都遵守"找不到返回 null"的契约; - DIP :
ImageLoader依赖ImageSource和ImageDecoder抽象; - ISP :数据源只需提供
load,不必实现清缓存、预加载等无关能力; - LoD :页面只调用
imageLoader.load(url),不知道缓存目录或 HTTP 客户端。
这也是为什么设计原则不应该被单独背诵:真实设计中,一个合理边界往往同时体现多项原则。
八、Android 项目中的快速检查清单
做 Code Review 或重构前,可以用下面的问题快速扫描:
职责
- Activity、Fragment、Composable 是否混入请求、缓存和业务规则?
- 一个类是否会因为多种互不相关的原因频繁修改?
扩展
- 增加一种支付、登录或列表类型,是否必须修改多个
when/if? - 变化方向是否已经稳定到值得建立扩展点?
契约
- Fake 和生产实现是否保持相同的成功、失败与空数据语义?
- 某些实现是否存在空实现或
UnsupportedOperationException?
依赖
- ViewModel/UseCase 是否直接依赖 Retrofit、Room 或第三方 SDK?
- Domain 层是否反向引用 Android Framework 类型?
接口
- 调用方是否被迫实现或依赖自己不用的方法?
- 模块的公开 API 是否暴露了内部 DTO、Entity 或工具类?
协作
- 是否出现
a.b.c.d()式的对象链和跨层调用? - 一个模块是否知道另一个模块过多的内部结构?
如果其中几个问题经常同时出现,通常说明边界需要调整,而不是简单再加一个 Utils 类。
九、原则不是目标,可维护性才是
六大原则能帮助 Android 项目获得更好的可测试性、可替换性与扩展性,但每一层抽象都有成本:更多类型、更多跳转、更复杂的依赖图,也可能带来理解和构建负担。
实际开发中可以遵循三个步骤:
- 先写清楚:命名准确、流程直接、测试覆盖关键行为;
- 识别变化:观察哪些代码确实因为不同原因反复修改;
- 在变化处抽象:只隔离真实存在的易变点,不为想象中的未来建框架。
判断设计好坏的标准,不是用了多少接口、Repository 或 UseCase,而是当需求变化时:修改是否集中、旧行为是否稳定、新代码是否容易验证。
六大原则最后可以浓缩成一句话:让稳定的业务规则远离易变的实现细节,让每次变化停在它应该停下的地方。
参考资料
- 面向对象六大基本原则------网络引擎切换
- Robert C. Martin, Agile Software Development: Principles, Patterns, and Practices
- Android 官方架构指南
- Android 依赖注入与 Hilt