面向对象六大基本原则:从概念到 Android 实战

不少 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 把每个三行函数都变成一个类,阅读一次业务要跳转十几个文件,复杂度只是从类内部转移到了类之间。判断是否需要拆分,可以问:

  1. 这几段代码是否由不同原因、不同角色推动变化?
  2. 它们能否独立测试或复用?
  3. 拆分后是否形成清晰边界,而不只是增加文件数量?

二、开闭原则:需求变化时,增加代码而不是改遍代码

开闭原则(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)包含两层含义:

  1. 高层策略不应该依赖低层实现,两者都应依赖抽象;
  2. 抽象不应该依赖细节,细节应该依赖抽象。

这里的"高层"是业务规则,"低层"是 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 项目获得更好的可测试性、可替换性与扩展性,但每一层抽象都有成本:更多类型、更多跳转、更复杂的依赖图,也可能带来理解和构建负担。

实际开发中可以遵循三个步骤:

  1. 先写清楚:命名准确、流程直接、测试覆盖关键行为;
  2. 识别变化:观察哪些代码确实因为不同原因反复修改;
  3. 在变化处抽象:只隔离真实存在的易变点,不为想象中的未来建框架。

判断设计好坏的标准,不是用了多少接口、Repository 或 UseCase,而是当需求变化时:修改是否集中、旧行为是否稳定、新代码是否容易验证。

六大原则最后可以浓缩成一句话:让稳定的业务规则远离易变的实现细节,让每次变化停在它应该停下的地方。


参考资料

相关推荐
程序员-珍2 小时前
关于协程相关问题
android·安卓
wdfk_prog2 小时前
Wi-Fi Direct 教程 04:Interface 协议子系统初始化——WPA/EAPOL、WPS、DPP/NAN、GAS 与 P2P callback
android·运维·服务器·ubuntu·p2p·wps·wifi-direct
警醒与鞭策11 小时前
【无标题】
android·unity·性能优化·游戏引擎·perforce
传奇开心果编程13 小时前
【Jetpack Compose进阶学与练】第14课:系列收尾复习总结;Compose项目常见坑点汇总;学习路线与后续学习方向
android·学习·ui·kotlin·android jetpack
李游Leo15 小时前
《HarmonyOS 7 精准碰一碰跨设备协作开发实战》07:异常恢复、状态机与CrossDrop工程收尾【鸿蒙心迹】
android·harmonyos
方白羽18 小时前
Android APK安全防护
android·安全·apk
小黄人软件18 小时前
AndroidStudio老项目 macOS运行BlueTooth蓝牙串口助手(Android+Studio源码).rar
android·macos
00后程序员张18 小时前
苹果App Store上架指南:费用、原理与步骤详解
android·ios·小程序·https·uni-app·iphone·webview
天神哥哥啊18 小时前
cocos联调注意事项-安卓
android