
做了这么多年移动端我最高频听到一句话就是“这个项目千万别再搞成一个大 Activity 了”。说这话的往往是被存量代码虐过的人——一个界面文件里塞了网络请求、数据解析、业务判断、界面刷新改一行文案都得从头到尾捋一遍。APP 分层架构就是在这种痛里生长出来的标准答案。但真到自己动手分层时很多人又会被“要不要加 Domain 层”“Repository 接口该放哪”“ViewModel 和 View 的边界到底在哪”这些问题卡住。这篇文章借“庖丁解牛”的思路把 APP 分层这件事从原则到选型、从拆解到落地完整地剖一遍。不管你刚开始写 APP还是正在为老项目架构头疼我希望你看完之后心里对“这一刀该切在哪”能有个清晰的答案。1. 为什么所有 APP 都绕不开分层架构1.1 不分层的前三个月爽然后开始炸我必须承认自己在刚入行前两年也干过“一个 Activity 三千行”的事。那时候觉得写代码就该顺着业务逻辑一路往下写按钮被点击于是发请求请求成功了于是更新控件中间自然穿插权限检查、缓存写入、埋点上报。前三个月的确很爽需求来一个接一个代码行数涨得飞快跑起来也没毛病。问题从第四个月开始冒头。第一个需求变更来了订单状态的文案换个说法。我打开那个巨大的 Activity花了半小时找到 UI 更新的代码改完一行却发现旁边还挂着网络回调里的状态判断——原来的“改文案”变成了“改链路”。更让人崩溃的是另一个同事在同一个文件里改过缓存逻辑Git 合并冲突能打一整天。到半年时没人敢碰那个页面了因为谁都说不清改这一处会影响哪十处。这就是不分层的代价。分层架构解决的核心问题从来不是“能不能跑”而是“改起来怕不怕”。一款 APP 的生命周期里读代码和改代码的时间远远超过写代码的时间。代码如果按职责切不开每一次小改动都会演变成一次全局排查。真正成熟的团队早就把“可维护性”提到和“功能正确”同等重要的位置上了因为功能一定会变而你不想每次变都像拆炸弹。1.2 分层的本质把“变化”和“稳定”隔开一句话记住分层把代码按职责划分成不同级别的抽象层与层之间只通过明确的接口通信高层的业务规则不依赖低层的具体实现细节。我用厨房打比方。一个大饭店的后厨绝对不会只有一个灶台。洗菜有洗菜的水池备菜有切配台掌勺有灶头出菜有传菜窗口。你不会让掌勺师傅自己跑去杀鱼、刮鳞、切姜丝。APP 的分层也是同一个道理——数据获取杀鱼、业务处理切配烹饪、界面展示摆盘传菜各管一段各配各的流程。为什么一定要把“变化”和“稳定”分开因为不同部分的改动频率和改动原因完全不同界面层最容易变。设计师改了配色产品改了文案运营换了入口这些几乎每周都有。业务层相对稳定但逻辑复杂。优惠计算、订单状态流转、权限判断这类代码一旦定型很少大改但写错了就是大事。数据层最容易被替换。今天走网络接口明天可能加缓存今天用 SQLite明天可能换成 Room 或 DataStore。如果这三部分混在一起任何一层的变化都会引发其他层的连锁改动。分层之后改界面不动业务换数据库不碰逻辑这才是架构的复利。它不会让第一版代码更快但会让后续每一版都更容易。1.3 “庖丁解牛”到底在讲架构的什么庖丁解牛的故事大家都不陌生。庖丁说他刚解牛时眼前是一整头牛三年之后他眼里只剩牛体内的筋骨结构顺着骨头缝下刀刀用了十九年还像新的一样。这里面最值得琢磨的是三个层次第一层看整体第二层看结构第三层顺着结构做事。分层架构里这个比喻精准得可怕“看见一整头牛”等于你只看到一整个页面什么代码都在里面只能在肉块上硬切。“看见筋骨结构”等于你看见了职责边界、依赖关系、接口抽象知道刀该往哪走。“游刃有余”等于你改一个需求时能立刻说出该动哪一层、不该动哪一层不需要把整个项目从头读到尾。所以说分层它不是一套要背的模板而是一种“对结构的观察力”。下面这几个小节我会把主流模式、骨架、依赖关节全部按牛的骨骼拆开你对照自己的项目去想会比直接抄代码有用得多。2. 主流分层模式与选型别拿杀鸡刀解牛2.1 MVC经典模型的天然陷阱MVCModel-View-Controller是许多平台原生支持的模式。Android 早期的 Activity 同时扮演 View 和 ControlleriOS 的 UIViewController 也是 View 与 Controller 的结合体。理论划分很清晰View 负责界面展示Controller 负责把用户操作转成业务行为Model 负责数据和业务规则。理想情况下Controller 只做传话筒业务全部放到 Model 里。但现实往往变成Model 退化成单纯的 JSON 解析类所有业务逻辑都被塞进 Controller。于是 Activity 和 ViewController 越来越胖“MVC”被开发者戏称为 Massive View Controller。不是模式本身错而是它留给开发者的自由度太大自律不够的人一定会把业务逻辑往 Controller 里写。我的评价是MVC 适合超轻量项目或者纯粹用来理解概念。一旦你的页面出现“超过三个状态”的交互逻辑它就不是一个值得长期依赖的答案。2.2 MVP把 View 变成提线木偶MVPModel-View-Presenter是对 MVC 的一次纠偏。它的核心思路是让 View 变得更薄只负责渲染和把用户事件转发给 PresenterPresenter 持有 View 的接口引用负责所有交互逻辑Model 只负责数据来源。Presenter 通过 Model 拿数据后再调用 view.showData() 之类的接口刷新界面。MVP 最大的优点是 View 变成纯“提线木偶”可以直接用单元测试测 Presenter不用启动模拟器。缺点也非常明显Presenter 和 View 几乎一对一每个页面要多写一组 View 接口和 Presenter 类接口数量会爆炸。放到庖丁解牛里MVP 相当于把“界面展示”和“业务逻辑”这两根骨头彻底分离。代价是操作细节和接口都会变得繁琐但它保证了切面很干净特别适合那些“界面极简单、逻辑极复杂”的场景。2.3 MVVM数据驱动的主流选择MVVMModel-View-ViewModel是我在中大型移动项目里遇到最多的方案ViewActivity、Fragment、Composable、UIViewController尽量只关心“把状态渲染成界面”不做业务。ViewModel持有界面需要的全部状态接收 View 的事件调用 Domain 层或 Repository 的接口把结果转成新的 State 发出去。Model放远一点说就是 Repository 和 Data Source统一封装远程、本地、缓存等数据来源。MVVM 和 MVP 最大的不同是ViewModel 不持有 View 的引用。界面通过观察StateFlow、LiveData、Observable订阅状态变化这带来一个非常关键的好处ViewModel 完全不依赖具体界面。测试时不用造假的 View旋转屏幕不丢状态后台通知也能被妥善处理。Android 官方从 Jetpack 时代开始就推荐 MVVM 加 RepositoryiOS 那边等价的是 MVVM 加 Combine 或 ObservableObject。新开一个 App大多数场景的默认答案就是 MVVM团队理解成本低官方支持好生态成熟不是最炫的架构但绝对是最划算的架构。2.4 Clean Architecture给依赖规则立宪法Clean Architecture 的核心一句话依赖必须指向内层外层不决定内层。它从里到外分四层Entities领域实体、Use Cases应用特有的业务规则、Interface Adapters把外部数据格式转成领域模型的适配器、Frameworks and DriversUI、数据库、网络这些具体实现。这里容易让人迷惑Clean Architecture 和 MVVM 冲突吗不冲突。MVVM 是“表现层内部怎么分”Clean Architecture 是整条 App 维度的分层宪法。两者配合起来的常见结构是PresentationMVVM- DomainUseCase、模型、Repository 接口- DataRepository 实现、网络、数据库这套组合的好处是用规则约束住人的随意性。项目一大一定会有人忍不住在 Activity 里直接 new 一个 OkHttp 去发请求。Clean Architecture 的依赖规则把这种“越权”变成代码评审时的红线而不是等到出 bug 才去抓。2.5 选型判断表匹配复杂度的才是好架构项目规模我的推荐理由原型、Demo、一两周的内部工具MVC 或干脆按页面分包分层成本高于收益3 到 6 个页面、单人维护的小产品轻量 MVVM不强制 Domain结构清晰且不繁琐中大型产品、多人协作、需求频繁MVVM Domain Repository边界清晰可测试可并行开发超大型多端产品、团队分工很细Clean Architecture 思想 模块化依赖规则明确杜绝越权存量 MVC / MVP 老项目不推倒重来新模块按 MVVM 写渐进式演进比重写稳一句话选型不是越高级越好而是匹配你面前这头牛的大小和复杂度。杀鸡确实不需要庖丁刀法但一头牛摆在面前光靠一把菜刀硬剁只会越剁越乱。3. 庖丁解牛式拆解MVVM Clean 的三层骨架3.1 先看全牛一个标准目录结构我用 Android 举例iOS 思路完全一致。一套典型的分层目录长这样app/src/main/java/com/example/yourapp/ data/ local/ // Room、DataStore、SharedPreferences remote/ // Retrofit、Ktor 等网络请求 repository/ // Repository 接口的实现类 mapper/ // DTO/Entity 与 Domain 模型的转换 domain/ model/ // 纯业务模型User、Order、Product repository/ // Repository 接口定义数据契约 usecase/ // 业务用例LoginUseCase、FetchOrderListUseCase presentation/ ui/ // Activity、Fragment、Adapter、Compose 组件 viewmodel/ // ViewModel UI State theme/ // 主题与样式 navigation/ // 导航路由关键点在于数据流向Presentation 只依赖 Domain 的接口Domain 不依赖任何具体实现Data 负责把网络和数据库包装成接口的实现。用厨房的话说掌勺师傅不需要知道食材是哪个市场买的他只管开单采购那边自然会按单备货。3.2 Data 层只负责“拿来”不负责“判断”Data 层的职责就是提供数据不掺入任何业务判断。网络接口返回 DTO数据库读出 EntityData 层负责把它们转换成 Domain 模型再交出去。这里有三个很实际的教训都是我亲手踩过的第一不要把网络请求全塞进一个巨大的 ApiService 里。按业务域拆开UserApi、OrderApi、ProductApi 分开定义每个接口文件保持短小后续定位问题会快很多。第二不要在 Data 层写业务规则。判断“登录状态是 A 就怎么处理”这类代码属于业务规则请放到 Domain 层。怎么区分呢一个简单测试是说给产品经理听他能直接听懂逻辑那它就是业务规则。第三Mapper 一定要写显式转换。DTO、Entity、Domain 模型各自独立别图省事把数据库实体直接传到 UI 层。今天你嫌多写一个 Mapper 麻烦明天字段改名时你会翻遍整个项目找谁在用这个字段。3.3 Domain 层业务规则的“心脏”很多团队纠结要不要单独抽一个 Domain 层我的经验是做不做看复杂度。Domain 层的价值集中在这三点集中管理业务规则不和任何框架绑定。想从 Retrofit 换成 Ktor甚至把远端改成本地 mockDomain 层代码一行都不用动。UseCase 让“一个动作”成为可命名、可测试、可复用的单元。“登录”是一个 LoginUseCase“下单”是一个 PlaceOrderUseCase。复杂流程可以在一个 UseCase 内部编排多个 Repository 调用这对多步操作特别友好。它是多人协作的契约层。两位开发一个写 UI一个写数据直接对着 Domain 的接口开发谁也不用等谁。如果只是增删改查页面之间几乎不共享逻辑那就用轻量 MVVM 直接调 Repository没必要强行加 Domain 层。但一旦出现这些信号就该引入 Domain同一个业务动作在多个页面被调用且逻辑会变一次操作要编排多个数据源产品规则频繁变更你希望改一处就生效。3.4 Presentation 层尽量无状态Presentation 层只做两件事把 State 渲染成界面把用户动作抛给 ViewModel。注意“无状态”三个字。界面不应该持有业务数据更不应该缓存一个用户对象到处传。所有界面需要的数据都从 ViewModel 暴露的 StateFlow 或 ObservableObject 中获取。深色模式、屏幕旋转、前后台切换View 重建了也不会丢失状态因为状态在 ViewModel 里View 只是它的临时展示窗口。这个“无状态”要求对很多习惯了直接操作控件的开发者来说很难接受但一旦适应你会立刻体会到它的好处界面可以随便重建数据不会丢ViewModel 可以脱离界面测试多个界面共享同一份状态变得理所当然。4. 边界与依赖最容易翻车的四个关节4.1 依赖方向内层永远不知道外层分层的铁律只有一条内层不能知道外层。用 Clean Architecture 的依赖规则表达就是Presentation 可以依赖 DomainData 可以实现 Domain 的接口但 Domain 绝不依赖 Presentation、Data 或任何第三方框架。一旦出现“Domain 里放着 Retrofit 注解”或者“UseCase 直接引用了 Activity”说明你已经破坏了边界。最直接的后果是单元测试起不来——因为 Domain 层要跑测试结果还需要 Android 环境、网络和数据库。我见过一个典型案例有人把登录 Token 存进 SharedPreferences然后在 Domain 层的 UseCase 里直接读取。结果每次在纯 JVM 上跑测试都要拜托 Robolectric 或者 mock 一堆存储工具。问题不在于 Token 存哪而在于 Domain 不该知道存储工具的存在。正确做法是定义一个 SessionRepository 接口Data 层实现它UseCase 只调 sessionRepository.getToken()测试时换一个 Fake 实现就行。4.2 Repository 接口小心“上帝类”Repository 接口放哪层建议放 Domain 层。对 UseCase 来说它只关心“我能拿到什么数据”不关心“用什么技术拿到背后的网络怎么设计”。Data 层实现接口时再去适配网络、数据库、缓存兜底。接口粒度的设计也很讲究我见过三成项目把 UserRepository 设计成两百个方法的上帝类login、updateAvatar、fetchFriends、deleteAccount 全在里面。正确的做法是拆开AuthenticateRepository 管登录、登出、刷新 TokenProfileRepository 管个人资料SocialRepository 管好友关系。一个接口的职责能用一句话说清方法数量控制在 3 到 10 个之间是最舒服的区间。太粗是上帝类太细是类爆炸两个极端都会让你在调用时无所适从。4.3 状态归属别把 UI 状态硬塞进 ViewModel状态管理是 MVVM 里最容易写乱的部分。我判断的标准很简单UI 附属状态留在 View 自己身上比如键盘弹起、列表滚动位置、弹窗开关业务状态归 ViewModel比如登录态、列表数据、加载中与否、失败后展示什么。如果连“键盘弹没弹”都塞进 ViewModel你会制造大量没意义的通知把 ViewModel 的单元测试搞得非常痛苦。反过来如果 ViewModel 里没有登录态只靠页面间传参你就没办法实现“在任意页面强制跳回登录页”这种全局逻辑。还有一条容易被忽视的规则State 要设计成不可变的。Android 用 data class 加 copy()iOS 用 struct。一旦状态可变你很难确定是哪个线程、哪个时序把它改了排查难度翻倍。4.4 错误处理别在每一层都 try-catch分层的另一个常见误区是层层包裹 try-catch。错误被吞掉、重组、再抛出最后你完全不知道失败现场是什么样。我的建议是明确分工Data 层把异常转成可读的失败结果例如封装成 NetworkException但别在这里决定怎么提示用户。Domain 层只判断“这个业务失败后要不要重试、要不要回滚”不做“该弹 Toast 还是弹窗”这种界面决策。Presentation 层把错误翻译成用户能看懂的语言并展示。各层之间的错误传递最好用 Result 或 Sealed Class 这类显式类型。好处是你还能在 Data 层返回“数据来源标记”比如是本地缓存还是网络Presentation 层就能淡定地提示“当前是离线数据”。这种体验只有把错误和数据来源显式建模之后才能做到。5. 实操一个登录场景的三层落地5.1 接口契约Domain 层登录是个再常见不过的场景但特别适合演示分层。我直接写一套能跑的 Kotlin 代码。先定义业务模型和 Repository 接口放在 Domain 层// domain/model/User.kt data class User( val id: String, val nickname: String, val sessionToken: String ) // domain/repository/AuthRepository.kt interface AuthRepository { suspend fun login(username: String, password: String): User suspend fun saveSession(sessionToken: String) suspend fun clearSession() } // domain/usecase/LoginUseCase.kt class LoginUseCase(private val repository: AuthRepository) { suspend operator fun invoke(username: String, password: String): User repository.login(username, password) }LoginUseCase 只依赖 AuthRepository 接口。它完全不知道数据是来自真网络还是本地 mock也不知道 sessionToken 到底存在哪。这就是“内层不知外层”的实操体现。5.2 数据源实现Data 层Data 层负责真正去调网络并把 DTO 转成 Domain 模型// data/remote/AuthApi.kt interface AuthApi { POST(v1/auth/login) suspend fun login(Body request: LoginRequest): LoginResponse POST(v1/auth/logout) suspend fun logout() } // data/remote/LoginRequest.kt data class LoginRequest( val username: String, val password: String ) // data/remote/LoginResponse.kt data class LoginResponse( val uid: String, val nickname: String, val token: String ) { fun toDomain(): User User(uid, nickname, token) } // data/repository/AuthRepositoryImpl.kt class AuthRepositoryImpl( private val api: AuthApi, private val sessionStore: SessionStore ) : AuthRepository { override suspend fun login(username: String, password: String): User { val response api.login(LoginRequest(username, password)) val user response.toDomain() sessionStore.save(user.sessionToken) return user } override suspend fun saveSession(sessionToken: String) { sessionStore.save(sessionToken) } override suspend fun clearSession() { sessionStore.clear() } }这里的 SessionStore 是另一个接口它背后可以是 SharedPreferences、DataStore 或 EncryptedStorage。AuthRepositoryImpl 只依赖它的抽象将来换存储方案时不会牵连登录逻辑。5.3 ViewModelPresentation 层Presentation 层把界面事件接回来并包装成 UI 状态。以 Compose 为例我习惯这样写// presentation/ui/login/LoginUiState.kt data class LoginUiState( val isLoading: Boolean false, val isSuccess: Boolean false, val userName: String , val errorMessage: String? null ) // presentation/ui/login/LoginViewModel.kt class LoginViewModel( private val loginUseCase: LoginUseCase ) : ViewModel() { private val _uiState MutableStateFlow(LoginUiState()) val uiState: StateFlowLoginUiState _uiState.asStateFlow() fun login(username: String, password: String) { if (username.isBlank() || password.isBlank()) { _uiState.update { it.copy(errorMessage 账号和密码不能为空) } return } viewModelScope.launch { _uiState.update { it.copy(isLoading true, errorMessage null) } val result runCatching { loginUseCase(username, password) } result.onSuccess { user - _uiState.update { it.copy( isLoading false, isSuccess true, userName user.nickname ) } }.onFailure { e - _uiState.update { it.copy( isLoading false, errorMessage e.message ?: 登录失败请稍后重试 ) } } } } }注意ViewModel 里没有出现任何 Android View 的引用也没有出现任何网络框架的引用。它只和 UseCase、UI 状态打交道。这就是为什么它可以在纯 JVM 环境下跑测试。5.4 View 只做渲染别越权到了 View 这一层Compose 可以写成这样Composable fun LoginScreen(viewModel: LoginViewModel) { val state by viewModel.uiState.collectAsState() // 输入框、登录按钮这些 UI 元素按状态渲染 // 登录按钮点击时调用 viewModel.login(username, password) // 根据 state.errorMessage 显示错误提示 // 根据 state.isSuccess 跳转首页 }如果用 View 体系Activity 或 Fragment 里也只有一个职责观察状态刷新控件。“点击事件在 View 里直接调 viewModel.login()”是允许的因为接收用户事件本来就是 View 的职责。但不允许在 View 里再做任何判断、请求、缓存。判断属于 ViewModel请求和缓存属于 Data 层各归各。5.5 这样落地带来的三个实战收益第一可测试。LoginViewModel 可以在 JVM 上测传入 FakeLoginUseCase几毫秒跑完不需要模拟器。第二可 mock。想模拟服务器出错只要写一个返回异常的 FakeRepositoryUI 层完全感知不到变化。第三可替换。想把网络从 Retrofit 换成 Ktor只需要重写 AuthApi 和 AuthRepositoryImplDomain 层和 Presentation 层一行不改。接口绑定的是业务抽象不是具体实现。6. 踩过的坑与排查技巧6.1 直连 Repository 的诱惑与代价分层落地时最难抵制的诱惑就是偶尔图省事在 View 里直接调 repository 拿数据。一次两次觉得没什么但只要出现三次Repository 接口就会被 UI 层锁死。UI 需要什么Repository 就得给什么于是 UI 层的改动一定会传导到 Repository分层防线形同虚设。我的粗暴排查方法全局搜一下 Activity 或 Fragment 里有没有出现 Repository 的直接引用。有就是边界被破坏的信号。别急着狡辩先把调用收口到 ViewModel哪怕只是中转一层。6.2 状态在页面之间来回传我见过不少项目登录成功后把 User 对象塞进 Intent传到 HomePage再从 HomePage 塞到 ProfilePage。这么做短期内能跑实际上却把页面间的数据传递硬编码成了耦合。一旦数据来源变了所有接收端都要跟着改。更好的做法是让相关页面从同一个单一可信来源读取数据比如共享的 UserSession 组件或者一个全局会话状态。这样你会发现页面之间不需要传参了因为大家观察的是同一个数据源。6.3 别指望分层解决所有问题分层不是银弹。它解决的是维护性和可测试性问题不解决性能问题、不解决网络质量问题、不解决需求频繁变化到你以为自己在做另一个产品的根本矛盾。如果你的界面卡顿优先查掉帧和渲染而不是责备架构不够好。如果你的网络请求总是超时去查接口设计和重试策略分层能做的只是让重试逻辑位置更清晰。分层最大的价值是把问题限定到一个可控范围内而不是让问题消失。6.4 分层让三天 bug 变成半小时 bug我遇到过特别能说明问题价值的一个 bug接口返回了 nullUI 显示空页面数据库里却明明有数据。排查了三天最后发现是 Data 层 Repository 实现里“读缓存”和“读网络”的顺序写反缓存覆盖了网络结果。这种 bug 恰恰是分层的好朋友。因为职责清楚你只需要分别给 Presentation、Domain、Data 三层各加一条日志就能立刻确定是哪一层错了。换到巨型 Activity 里你可能要从三千行代码里捞那两行错误逻辑。我做技术这么多年越来越认同一个判断分层不会让代码变少但一定会让代码变透明而透明是软件工程里最稀缺的东西。如果只能用一句话总结这几年对分层的体会那就是分层不是一套固定模板而是一种“看见结构”的能力。你不需要每个项目都上 Clean Architecture 全家桶但必须有意识地追问自己三个问题——这一行代码到底属于哪一层它的依赖方向对吗换一个人来改他能不能猜到你为什么这么切我在老项目里见过最严重的混乱从来不是架构选得不够潮而是所有人都知道架构图长什么样却没有一个人遵守。架构和刀法一样关键不在招式多漂亮而在你的每一次下刀都落在骨头缝里。