多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

分层架构设计:从依赖倒置到工程落地,让代码边界清晰可维护

分层架构设计:从依赖倒置到工程落地,让代码边界清晰可维护 1. 分层架构到底在解决什么问题真正驱动一个 APP 必须分层架构的从来不是“架构理念”这种玄乎的东西而是那几次加需求加到头皮发麻的经历。先说我自己的例子早些年我做过一款资讯类 APP第一版为了抢上线时间所有逻辑都往 Activity 里塞网络回调直接操作 UI数据库工具类做成静态方法到处调用。功能确实上线了但第二个迭代就被同组同事的改动牵连——他改了一个数据解析工具类的方法签名直接炸掉了四个页面的展示逻辑。我们花了一整个下午定位最后发现罪魁祸首是一个全局静态变量被某个后台接口的异步回调改了值。这不是代码水平问题这是没分层必然付出的代价。分层架构解决的不是“代码看起来整齐”这种审美问题而是一个实打实的工程问题当变化来临时能不能把影响范围控制在最小。只要你维护的项目不是“写完就永久冻结”只要团队超过一个人只要产品需求发生过一次变更分层这件事就迟早落在你头上只是早做晚做的问题。1.1 为什么 UI、业务、数据必须各住各的房间所谓三层在大部分 APP 项目里其实就是三个职责圈展示层负责把数据变成用户看得懂的样子业务层负责定义“这个功能到底该怎么运转”数据层负责回答“数据到底从哪来、存在哪”。你可能觉得这个划分太简单但绝大多数烂代码恰恰是这三件事混在一起的结果——Activity 里直接写 SQLite 语句、网络报文里带业务判断、数据库字段决定页面是否显示某个按钮。举个例子。你做一个订单模块业务规则是“已支付的订单不能取消”。如果这条规则写在 UI 层那将来小程序端、后台管理系统各写一遍三端规则迟早不一致如果写在业务层UI 再瘦也只是个“翻译官”规则永远只有一份。数据层同理接口从 V1 切到 V2、数据库从本地换云端只要接口契约不变业务层和 UI 层可以完全不动。这就是“各住各的房间”的收益每一层的身份清晰谁出事就改谁不连坐。这里我给一个非常生活化的类比餐厅经营。UI 层就是前厅服务员只负责接单上菜业务层是后厨知道每道菜的配方和制作流程数据层是食材仓库和供应商只管提供猪肉、牛肉。前厅服务员不需要知道猪肉是怎么屠宰上来的后厨不需要知道服务员怎么跟客人推销供应商换了一家只要食材标准不变后厨和前厅一点感觉都没有。反过来如果你让服务员自己去买猪、再自己进后厨炒菜餐厅的乱象就可想而知了。1.2 分层到底能带来哪些看得见的好处先说可测试性。UI 自动化测试成本高、稳定性差而业务层和数据层的单元测试跑起来快、结果可重复。分层之后你可以不启动模拟器就跑完大部分核心业务测试这对一个常年被回归 Bug 折磨的团队来说是实打实的降本。我在之前的项目里测过一版数据上线 CI 跑单元测试从 40 分钟缩短到 8 分钟全因为抽掉了 UI 依赖。再说可替换性。UI 层要从 XML 换 Jetpack Compose或者 iOS 的 UIKit 换 SwiftUI分层后只是换个“皮肤”数据层要从 Retrofit 换 OkHttp、从本地数据库换新方案只要 Repository 接口不变上层无感。我实际经历过一次推送 SDK 的替换因为推送逻辑被封装在业务层背后换 SDK 只改了数据层和几行配置UI 和业务完全没碰。这种“换件不换车”的能力只有分层能给。最后说协作效率。四个人同时改同一个 3000 行文件合并冲突就是灾难分成四层各自维护界面、业务、数据、基础设施的改动互不干扰。尤其团队规模上来之后前端组、业务组、中台组本身就是按层来分工的分层架构不只是代码规范更是组织协作的边界。这里贴一个非常粗的对比维度不分层的典型状态分层后的状态改需求波及面不可控经常改一处崩两处影响范围基本锁定在单一层内测业务规则必须启动完整 APP依赖网络和界面单元测试直连业务层毫秒级出结果换技术方案UI/网络/存储混在一起牵一发动全身只替换对应层实现接口保持不变并行开发一个文件多人改合并冲突高频层与层之间并行互不阻塞新人上手先啃 3000 行“神话类”才敢动手按层理解从职责边界入手更快2. 分层架构的核心设计决策分层这件事最有技术含量的地方其实不是“分”而是“怎么限”——界限画清楚之后真正的挑战是让依赖关系不失控。这一节我把当初在设计阶段反复纠结过的几个关键决策梳理一遍每一个都是踩过坑之后才想明白的。2.1 依赖方向所有箭头只朝一个方向核心原则只有一句话上层可以依赖下层下层绝不能依赖上层。这句话听起来是废话但现实里到处都在违反。最常见的反例是网络层直接回调 UI某个 View 里发起网络请求然后在回调里更新这个 View那么网络层和 UI 层之间就形成了从下往上的依赖。你要换 UI网络层代码得跟着动你要换网络库UI 层也得跟着动。正确做法是UI 只依赖业务层的某个接口业务层只依赖数据层的某个接口至于这个接口背后是网络请求、缓存、还是 Mock 数据上层一概不关心。为什么依赖方向这么要命因为依赖方向就是项目的“水流方向”水只能从高处往低处流一旦出现回流的边这片代码迟早被淹。依赖倒过来的时候A 改一个方法签名B 的所有调用点都要跟着改两者从此被焊死在一起。我见过最夸张的一个遗留项目UI 层直接调用了一个网络层的静态类去拼请求参数网络层又回调 UI 层的方法去刷新界面两个模块互相咬合谁都不敢动对方一行代码。这里有个反直觉的点值得单独说说你说“上层依赖下层”那 ViewModel 该不该感知 Repository 的实现类不该。它应该面向接口编程——ViewModel 眼里只有OrderRepository这个抽象至于实现是RemoteOrderRepository还是LocalOrderRepository由构造时传入。这就是依赖倒置高层模块不依赖低层模块的具体实现两边都依赖抽象。这不是学院派的咬文嚼字它是你能够用 Mock 数据跑通 UI、又能在联调时无缝切真实接口的前提。我在项目里就是这么干的UI 开发阶段全部走本地 Mock后端联调时只改装配代码里的一个实现类页面代码一行没动。2.2 层间通信接口就是两层的“合同”分层架构和别的架构思路最大的区别在于它特别强调层与层之间的接口是正式的、稳定的“合同”。在代码里合同就是抽象类或接口。业务层需要一个OrderRepository它不关心调用者来自 iOS 还是 Android也不关心数据来自 HTTP 还是本地它只定义一组方法getOrderById(id)、getOrders(status)、cancelOrder(id)。谁去实现这些方法数据层说了算谁去调用这些方法业务层和 UI 层说了算。只要合同不变任何一层内部都可以进行无感重构。这里我要重点提醒一个容易被忽视的细节接口不仅要定义方法签名还要明确定义数据模型和错误语义。比如getOrderById查不到订单时是返回 null、抛异常、还是返回一个空状态对象这三种选择会直接导致上层代码的写法完全不同。我之前在项目里吃过一次亏Repository 接口定义的是“订单不存在时返回 null”结果 UI 层写了一大堆判空逻辑后来数据层为了统一错误处理改为抛异常UI 层的判空全部作废报错处理重写了一遍。所以接口合同里数据模型、异常边界、线程模型最好一并写清楚哪怕只是注释几行也能让两层的人少吵十次架。实际上“分层”这个词在不同技术栈里都有对应物。比如热词里常有人搜“嵌入式系统分层软件架构”嵌入式领域的 Driver、OSAL、Application 三层和移动 APP 的数据、业务、展示三层本质思路完全一致——越靠近硬件的层越基础越靠近用户交互的层越易变中间层负责稳定衔接。理解了这一层你会发现分层的通识价值远超某个具体框架。2.3 数据模型DTO、Model、Entity 不是一回事很多刚接触分层的开发者会问数据层从接口拿到的对象能不能直接传到 UI 层展示从“跑得通”的角度能。从“分得清”的角度不能。网络接口返回的 JSON 解析出来的对象是 DTO它描述的是传输格式业务层内部操作的对象是领域 Model它描述的是业务概念数据库表对应的对象是 Entity它描述的是持久化结构。三者字段可能高度雷同但含义完全不同。拿订单来说接口返回的订单 DTO 里有个字段叫status_code是字符串PAID数据库表里的订单 Entity 里有个字段叫status是整数2而业务层真正想用的是一个“订单是否可取消”的布尔含义。如果你把 DTO 一路传到 UI那 UI 就得自己去解析PAID将来接口改成paid你还要改 UI。正确的做法是数据层把 DTO 转成 Model业务层把规则计算好比如 status 什么情况下可取消、什么字段展示成什么文案UI 层只负责按 Model 渲染。这样解释可能更好懂DTO 是“外部世界的语言”Entity 是“仓库里的语言”Model 是“你们公司的业务黑话”。如果让前厅服务员直接跟供应商用“饲料级猪肉”这种术语交流不出错才怪。每层内部有自己的方言而层间的转换代码负责翻译这是分层架构里最容易被低估、也最不能省的一步。转换确实会多写一点样板代码但那点成本比起跨层依赖糊成一片之后的维护代价简直不值一提。2.4 包结构让层的边界在仓库里一眼可见分层的理念最后要落地到工程结构。比较合理的做法是按“层”建顶层包每层往包内部再按“业务模块”分子包。以 Android 项目为例一个典型的目录可以长成这样com.example.app/ ├── data/ # 数据层 │ ├── local/ # 本地数据库、本地存储 │ ├── remote/ # 网络接口实现 │ └── repository # Repository 接口实现DTO 与 Model 的转换 ├── domain/ # 业务层 │ ├── model/ # 业务模型 │ ├── repository # 仓库接口定义合同 │ └── usecase/ # 业务用例 / Interactor └── presentation/ # 展示层 ├── ui/ # Activity / Compose 页面 ├── state/ # UI 状态定义 └── viewmodel/ # 状态持有与页面交互逻辑这个结构最大的价值在于进到仓库的任何人都能通过路径判断这段代码属于哪一层、应该能依赖谁。data包的代码不许被presentation直接引用domain不 import 任何 Android 框架或 iOS 系统库——这些约定不靠自觉靠检查机制。Android 上可以按模块拆 Gradle 工程让domain模块不依赖app模块从编译层面直接卡死违规依赖iOS 上可以用私有 CocoaPods 做隔离实在没条件弄模块化CI 里加一个 import 检查脚本扫到跨界引用就报警。这些机制非常关键分层的“分”靠约定“限”靠机制没有后者前者迟早被打破。3. 实操一个订单列表的分层演进全过程理论说得再多不如拿一段具体代码走一遍。这个例子我尽量保持语言无关核心思路在 Android、iOS、Flutter 里完全通用只是语法不同。需求很简单做一个“我的订单列表”页面进入页面列出现有订单显示状态、金额、时间支持下拉刷新点击可查看详情。就这么一个小需求足够演示“不分层”到“分层”的完整变化。3.1 反面案例ViewModel 里写全流程很多项目的初版长这样class OrderListViewModel(private val apiService: ApiService) : ViewModel() { val orders MutableLiveDataListOrderModel() fun loadOrders() { viewModelScope.launch { val response apiService.getOrders(userId) if (response.code 200) { val list response.data.map { item - OrderModel( id item.orderId, statusText when (item.status) { PAID - 已支付 CANCELLED - 已取消 else - 未知 }, amount item.amount.toString() ) } orders.value list } else { // 处理错误 } } } }这段代码“能用”但它把三件事全揉在了一起网络请求是数据获取状态映射和文案规则是业务逻辑最后输出给列表的数据是展示准备。如果明天接口从getOrders改成getOrdersV2、状态码从PAID改成字符串1你要动这个 ViewModel如果产品说“已支付且超过 24 小时未配送的订单要显示加急标识”你还是要动这个 ViewModel。改一次两次还行改到第十次你就知道什么叫牵一发动全身。更麻烦的是测试。你想验证“未支付订单可以取消、已支付订单不能取消”这种核心规则得先 Mock 网络层返回值还要关心 ViewModel 的生命周期测试又慢又脆。痛点的根源从来不是代码量而是职责没有边界。把网络、规则、展示混在一个类里表面上是少写了几个文件实际上是把未来每一次变更的风险都集中到了一处。3.2 正确姿势让每一层只做一件事同一个功能分层之后的代码结构完全不一样。先定义业务层需要的仓库接口它只声明“订单数据能给我什么”不关心底层实现来自网络还是缓存// domain/repository/OrderRepository.kt interface OrderRepository { suspend fun getOrders(): ListOrder suspend fun cancelOrder(orderId: String) }业务层的 Order 模型是纯粹的 Kotlin 类不携带任何 UI 框架依赖// domain/model/Order.kt data class Order( val id: String, val status: OrderStatus, val amount: String, val createdAt: Long, val isCancelable: Boolean )业务规则单独放进 UseCase比如“已支付订单不可取消”就落在这里// domain/usecase/LoadOrdersUseCase.kt class LoadOrdersUseCase( private val repository: OrderRepository ) { suspend operator fun invoke(): ListOrder { return repository.getOrders().map { order - order.copy( isCancelable order.status OrderStatus.UNPAID ) } } }数据层实现仓库接口DTO 在这一层被翻译成业务 Model// data/repository/OrderRepositoryImpl.kt class OrderRepositoryImpl( private val api: OrderApi, private val cache: OrderCache ) : OrderRepository { override suspend fun getOrders(): ListOrder { val dtoList api.fetchOrders() return dtoList.map { dto - dto.toModel() } } }最后是 UI 层。ViewModel 只调用业务层的 UseCase拿到现成的 Order 模型直接渲染// presentation/orderlist/OrderListViewModel.kt class OrderListViewModel( private val loadOrders: LoadOrdersUseCase ) : ViewModel() { val uiState MutableStateFlowOrderListUiState(Loading) fun refresh() { viewModelScope.launch { uiState.value Loading uiState.value try { Success(loadOrders()) } catch (e: Exception) { Error(e.message ?: 加载失败) } } } }对比这两版你会发现分层版本多了几个类、行数也多了但每一层的职责边界极其清楚换接口、换缓存、改页面样式、改业务规则全部只需要动各自的那一个文件绝不牵连其他层。这就是“庖丁解牛”的感觉——刀顺着骨头缝走哪里该拆、哪里不该碰早就看得明明白白。你改的是后厨的菜谱前厅不需要知道供应商也不需要知道。3.3 依赖怎么“喂”进来从手写注入到容器分层之后最现实的问题是对象怎么创建、怎么装配。订单列表需要OrderListViewModel它需要LoadOrdersUseCaseUseCase 需要OrderRepositoryOrderRepositoryImpl需要ApiService和Cache。这一串依赖关系最简单的做法是手写构造val repo OrderRepositoryImpl(api, cache) val useCase LoadOrdersUseCase(repo) val vm OrderListViewModel(useCase)这种手写组合完全够用尤其是项目不大、团队不追求框架的时候。它最大的优点是零魔法、容易理解、出问题好排查缺点是所有创建代码都要人工维护类一多会有点啰嗦。如果不想手写可以用 Hilt / DaggerAndroid、SwinjectiOS、Riverpod / get_itFlutter这类注入框架让框架通过构造签名自动装配。我的建议是先把分层的接口定义清楚再决定要不要上注入框架。依赖注入框架是锦上添花不是分层的前提。很多团队一上来就搬 Hilt结果接口都没理清楚排查一个“到底谁注入了谁”的问题能查一整天。我自己带项目时一开始统一手写注入等类数量超过三四十个、构造代码明显感觉到重复时才引入容器整个过程非常平滑。3.4 状态管理与单向数据流讲了层和依赖还差一个关键部件页面的状态怎么管。分层的项目里UI 层不建议直接跟数据层对话更合理的模式是单向数据流——事件从 UI 发起进 ViewModel / 业务层变成新的 UI 状态UI 再根据状态渲染。以订单列表为例UI 状态可以这样定义sealed interface OrderListUiState { data object Loading : OrderListUiState data class Success(val orders: ListOrder) : OrderListUiState data class Error(val message: String) : OrderListUiState }UI 订阅这个状态Loading 就转圈Success 就渲染列表Error 就显示重试按钮。用户点击刷新又发出新事件状态再流转一遍。这种模式的好处是状态变化可预测、容易测试每个状态给一个输入断言一个输出、UI 和业务彻底解耦。如果你没精力上任何状态管理框架用一个StateFlow或StateObject维持这种模式就够了。核心是“状态”这个概念不是某个具体框架。很多热词里提到“APP 架构”“app 开发教程”时大家默认会提起 MVVM、MVI 之类的名词但落到实处我发现团队真正受益的往往只是“把状态显式化、把变更集中化”这一点框架反而是次要的。4. 常见问题与排查技巧实录这一节梳理一下我在分层实践里踩过的坑以及后台私信里被问过最多的问题。每一条都是实际项目中真实出现过的看完多少能省点折腾时间。4.1 最大的敌人万能工具类和上帝对象分层架构落地后总会出现一种诡异的现象层也分了、接口也抽了但代码还是难维护。去一看发现项目里有个Utils类几千行里面既有日期格式化、又有加密算法、还有订单状态文案映射、甚至还有网络判断封装。框架是分层的但每一层都往工具类里塞东西这个工具类实际成了新的“上帝对象”谁都可以依赖它谁也说不清它到底归谁管。我的建议是不要把 Utils 当成包罗万象的垃圾桶。工具类要分门别类且要明确归属。日期格式化、字符串处理这类通用能力归为基础设施订单状态文案映射、金额显示规则这是业务规则不该放在通用工具里应当放业务层或展示层的专用对象里。判断标准很简单如果一个工具方法里包含“订单”“用户”“商品”“支付”这类业务关键词它就不是通用工具它是被放错位置的业务代码。这里补一条经验我给团队定过三条“工具类规矩”。第一新工具方法必须写明适用层和典型调用场景第二文件超过 300 行必须拆第三工具方法之间不得互相依赖。三条规矩执行了大半年项目里工具类的膨胀速度明显慢了下来定位问题时也快了很多。你不需要一步到位但一定要有个动态纠偏的机制。4.2 循环依赖先一眼看出再根治分层最常见的违规操作是循环依赖典型症状是编译不过去。这个在 Java/Kotlin 里会直接报 cyclic dependency在 Objective-C 里表现为各种找不到接口的提示在 Flutter 里则是递归导入报错。但循环依赖不一定总是编译期爆出来也可能是运行期莫名其妙崩溃或者改 A 层的文件引发 B 层出现诡异错误——这时候就要怀疑依赖方向出问题了。我的定位思路特别朴素画一张“依赖图”不用工具顺着 import 语句在脑子里走一圈。从 presentation 开始看它 import 了哪些包再顺着那些包继续追如果有一条路径绕回到 presentation那就有环。找到环之后把互相依赖的那部分抽到“下层共用的抽象”里或者把其中一个依赖方向倒过来。比如业务层不该知道“登录页面的 ViewController”它应该只认“当前用户会话”这个抽象具体实现由 UI 层在装配时传入。顺便说一句热词里经常有人搜“app 抓包失败”“this unlicensed adobe app has been disabled”这类跟调试和授权相关的问题我的经验是这类问题大多不是分层架构导致的更多是网络环境、证书、序列号校验方面的独立问题排查时别把锅甩给架构。先确认基础环境再回到代码本身顺序别搞反。4.3 过度分层另一个极端的坑说完了不分层的坏处也要说说另一头分层分得太狠。我见过一个项目一个简单的“获取用户信息”功能愣是拆出了 DTO、Entity、PO、VO、Model 五种对象每一层都在做无意义的字段拷贝一个不到十个字段的请求要经过五层转换才能到达数据层出问题时排查一个字段的传递路径就花半天。分层是为了隔离变化不是为了制造仪式感。判断一个对象要不要拆标准是它是否有独立的生命周期和变化频率。网络 DTO 和数据库 Entity 的字段如果完全相同且几乎没有独立变化的需求直接复用并不是罪过业务模型和展示模型如果几乎一致也可以减少一次转换。分层的成本是样板代码和维护心智收益是隔离变化当收益明显小于成本时就不该硬分。过度分层和完全不分层危险程度是一样的。我刚工作那会儿有个误解总觉得架构越复杂越显得专业。后来自己带团队才明白最好的架构是“即使新同学第一天进来也能快速理解哪些该改、哪些不该碰”的架构。复杂度是有预算的你不能全花在没有实际收益的对象转换上。4.4 分层与 MVVM、Clean Architecture 到底什么关系很多初学者会把“分层架构”和“MVVM”混为一谈这里有必要把关系理清楚。MVVM 主要解决的是“展示层内部”的分工——View 负责渲染ViewModel 负责状态和交互逻辑它规范的是 UI 层内部怎么组织。而分层架构解决的是“整个 APP”的分工——展示层、业务层、数据层的边界。两者不是对立关系而是互补关系MVVM 是展示层内部的一种实现风格分层是整套工程结构的骨架。至于 Clean Architecture则是把分层描述得更细定义出 Entity、Use Case、Interface Adapter、Framework 这些更细的层次核心思想依然是“依赖方向朝内、业务位于中心”。你完全可以采用“分层的工程结构 MVVM 的展示层组织 手写依赖注入”突出一个够用就好不必为了某个名词去推翻整套方案。理解了这层关系你在团队讨论架构时尽可以更淡定。当别人提议“我们用 Clean Architecture 重写吧”你要问的是“具体想解决什么问题、怎么规范依赖方向”而不是急着给项目做一次大手术。架构是手段把项目变得可持续维护才是目的。4.5 多人协作里分层怎么持续保鲜最后补充一个协作层面的实战经验。分层的规矩定了、代码也拆好了但一个团队十来个人时间一长总有人为了赶进度抄近路把跨层依赖写得飞起。我比较推荐的做法是每半年做一次“依赖审计”用脚本统计项目里有多少跨层 import按模块聚合输出一份报告然后花半天时间把明显的违规清理掉。这件事成本极低但能非常有效地防止分层架构在不知不觉中烂掉。另一个做法是在 Code Review 环节加一个固定的检查项本次改动涉及哪些层、有没有跨层引用不该引用的东西。只要把这个问题变成每次提交都问一遍的习惯很多违规在进主干之前就会被拦住。架构这种东西维护的关键在于定期检查、动态纠偏而不是一次性“做对”然后万事大吉。5. 写在最后的一点个人体会说几句实在话收尾。第一分层不是一步到位的不需要在项目第一版就铺满五层架构。你可以先把“展示层、业务层、数据层”这三条线理清等某个模块复杂度上来再按需要往里加深。第二真正考验架构的不是写新功能而是改老功能。下次改需求的时候刻意留意一下“我到底改了哪一层、哪些别的地方被牵连了”如果每次都只动该动的地方说明你的分层是健康的如果一次改动带着一串连锁修改那说明边界该重新划了。最后再分享一个小技巧给项目加一个简单的“分层体检”小脚本统计各层的依赖违例数量每次提交都在本地跑一遍。我在几个项目里试下来这个办法比任何架构文档都管用因为它是机械的、客观的、一票否决的。架构理念可以讲得天花乱坠但真正让代码长期保持健康的永远是那些不起眼的机制和习惯。
返回列表