ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Android UseCase设计指南:解耦业务逻辑与平台实现

2026/9/19 1:10:27 拓冰建站 浏览量
Android UseCase设计指南:解耦业务逻辑与平台实现 1. UseCase不是“用例”而是Android架构里的责任守门人很多人第一次看到UseCase这个词下意识就联想到UML里的“用例图”——画几个小人、几个椭圆标上“登录”“支付”“上传头像”。但Android开发里提到的UseCase和那个教科书概念毫无关系。它既不是文档也不是流程图而是一个有明确边界、可被测试、不依赖Android框架组件的纯Java/Kotlin类。我刚带团队重构一个三年老项目时发现他们把所有网络请求、数据库操作、甚至Toast弹窗都塞进ViewModel里结果ViewModel动辄800行单元测试写到一半就放弃。后来我们把“加载用户资料”这个动作抽出来单独建了一个GetUserProfileUseCase它只做一件事调用Repository获取User对象并返回Result 。没有Context没有LiveData没有onCreate/onDestroy连Android SDK的import语句都没有。这才是UseCase该有的样子。它的核心价值是把“做什么”What和“怎么做”How彻底切开。比如“刷新首页数据”这个业务动作UseCase只定义“我要拿到最新Feed列表”至于是从Room查缓存、还是用Retrofit拉远程、还是合并两者那是Repository的事至于刷新成功后更新UI状态、显示加载动画、处理错误Toast那是ViewModel或View的事。UseCase站在中间像一道防火墙隔开了业务逻辑层和平台实现层。你翻看Google官方的Architecture Blueprints示例项目会发现每个UseCase类平均只有40~60行代码构造函数只接收Repository接口execute()方法只返回Flow或suspend函数——干净得像刚擦过的玻璃。这背后对应的是Clean Architecture的分层思想Domain层含UseCase完全独立于Android SDK可以脱离设备运行在JVM上做单元测试Data层负责具体数据源实现Presentation层负责UI展示。而UseCase就是Domain层对外暴露的唯一入口。它不关心Activity生命周期不依赖Handler或Looper甚至不需要知道当前App是否在前台。这种解耦带来的直接好处是当你需要把同一个“搜索商品”功能复用到小程序、桌面端或后台服务时只要复用这个UseCase类再配一套新的数据源和UI层即可。我在2022年参与一个跨端项目时就把Android端打磨好的17个UseCase原封不动移植到KMMKotlin Multiplatform Mobile的共享模块里iOS同事只花了两天就完成了UI对接——因为业务逻辑本身根本没改。提示UseCase命名必须以动词开头且体现业务意图如DeleteNoteUseCase、SyncOfflineChangesUseCase而非NoteUseCase或NoteManager。后者暴露了实现细节违背了单一职责原则。2. 四条铁律为什么你的UseCase总在重构中被推倒重来我见过太多团队把UseCase写成“高级工具类”传入Context、持有Activity引用、直接调用startActivityForResult、甚至在execute()里new Handler(Looper.getMainLooper())。这些做法看似省事实则埋下三颗定时炸弹第一无法做纯JUnit单元测试每次都要启动Instrumentation第二与Activity强耦合导致页面重建时UseCase状态丢失第三违反依赖倒置原则Domain层反而依赖了Presentation层。要避免这些陷阱必须死守四条设计铁律。2.1 铁律一零Android SDK依赖UseCase类所在的包路径必须是com.yourapp.domain.usecase且其源码目录不能出现在src/main/java下而应放在src/domain/java需在build.gradle中配置sourceSets。编译时Gradle会强制检查一旦出现android.app.Activity、android.widget.Toast、androidx.lifecycle.ViewModel等导入构建直接失败。我们团队在CI流水线里加了一条Checkstyle规则扫描所有domain模块下的.java/.kt文件禁止匹配import android\..*正则表达式。去年有个实习生偷偷在UseCase里用了Uri.parse()结果PR被自动拒绝——这不是刁难而是守住架构底线的第一道闸门。2.2 铁律二输入输出严格契约化UseCase的参数不是随意堆砌的。以SearchProductsUseCase为例它的输入参数必须封装为SearchProductsParams数据类data class SearchProductsParams( val keyword: String, val categoryId: Long? null, val maxPrice: Int? null, val page: Int 1 )而不是fun execute(keyword: String, categoryId: Long?, ...)这样松散的签名。原因有三一是便于扩展后续加筛选条件不用改方法签名二是支持默认参数调用方只需传关键字段三是为未来接入Caching或Logging提供统一拦截点。输出同样如此必须返回ResultT或FlowT绝不能是void或原始List。我们曾因某个UseCase返回Unit导致下游ViewModel无法区分“执行中”和“已完成”最后不得不重写整个状态管理链路。2.3 铁律三无状态、无副作用UseCase实例必须是stateless的。它不能持有private var isLoading false这样的可变状态也不能在execute()里修改外部变量。所有状态变更都应通过返回值通知调用方。这点常被忽视——有人为了让UseCase“记住上次搜索关键词”在类里加了private var lastKeyword: String? null结果在多线程并发调用时出现竞态条件。正确的做法是把状态管理交给ViewModelUseCase只负责“计算结果”。就像数学函数f(x)x²给定x永远返回确定y不记录历史也不影响其他调用。2.4 铁律四单职责且职责可命名一个UseCase只解决一个业务场景。UpdateUserProfileAndSyncToServerUseCase这种命名本身就是反模式——它违反了单一职责且暗示内部做了两件事。正确拆分是UpdateUserProfileUseCase更新本地数据SyncUserProfileToServerUseCase同步到远程由UseCase组合器UseCaseComposer在Presentation层协调调用。我们团队曾统计过超过70%的重构需求源于最初把多个业务动作塞进同一个UseCase。现在新项目立项时产品经理每提一个需求技术负责人第一反应是“这个需求对应几个UseCase每个的动词是什么”3. 从空壳到落地一个真实电商项目的UseCase实战链路光讲原则容易飘我们拿“用户下单”这个典型场景走一遍从需求分析到代码落地的完整链路。这不是Demo而是去年某生鲜电商App上线前的真实重构过程——当时订单创建成功率只有92%大量失败日志指向“库存校验超时”和“优惠券失效”但问题分散在Activity、ViewModel、Repository各处定位耗时平均4小时/次。3.1 需求拆解先画业务泳道图再定UseCase边界我们没急着写代码而是用白板画出下单全流程的泳道图用户点击下单 → 校验购物车商品库存 → 校验优惠券有效性 → 计算最终价格 → 创建订单 → 同步库存扣减 → 发送订单确认消息。每个泳道对应一个系统模块UI、Domain、Data。关键决策点在于哪些步骤属于业务规则Domain层哪些属于技术实现Data层。结论很清晰库存校验、优惠券校验、价格计算是业务规则必须放在UseCase里而“查Redis缓存”“调用库存服务API”“写MySQL订单表”是Data层的事。最终我们定义了5个UseCaseValidateCartItemsStockUseCaseValidateCouponUseCaseCalculateOrderPriceUseCaseCreateOrderUseCaseSendOrderConfirmationUseCase注意SendOrderConfirmationUseCase虽涉及消息发送但它封装的是“发送确认消息”这个业务意图具体用MQ还是HTTP推送由Data层实现。3.2 接口定义用Kotlin密封类描述业务结果UseCase的返回类型决定了下游处理的健壮性。我们弃用了传统的ResponseT泛型改用密封类OrderResultsealed interface OrderResult { data class Success(val orderId: String, val amount: BigDecimal) : OrderResult data class StockInsufficient(val itemId: String, val available: Int) : OrderResult data class CouponInvalid(val couponId: String, val reason: String) : OrderResult data class NetworkError(val message: String) : OrderResult object UnknownError : OrderResult }好处显而易见ViewModel可以用when穷举所有可能分支编译期就能捕获遗漏处理每种错误类型自带结构化数据如StockInsufficient包含itemId和available数量UI层能精准提示“苹果库存只剩3件”而非笼统的“下单失败”。3.3 实现细节如何让UseCase真正“可测试”ValidateCartItemsStockUseCase的实现代码仅32行但每行都有讲究class ValidateCartItemsStockUseCase constructor( private val stockRepository: StockRepository ) { suspend fun execute(params: ValidateStockParams): ResultListStockValidationResult { return try { val validationResults params.items.map { item - val stock stockRepository.getStock(item.productId) StockValidationResult( productId item.productId, required item.quantity, available stock?.quantity ?: 0, isValid (stock?.quantity ?: 0) item.quantity ) } Result.success(validationResults) } catch (e: Exception) { Result.failure(e) } } }关键点在于构造函数只注入StockRepository接口而非具体实现类方便单元测试时Mockexecute()用suspend而非Flow因为库存校验是瞬时操作无需流式响应所有异常被捕获并转为Result.failure()避免崩溃传播到UI层返回ListStockValidationResult而非布尔值为前端提供逐项校验详情。单元测试用Mockito验证逻辑Test fun given insufficient stock should return invalid result() runTest { // Given val mockRepo mockStockRepository() whenever(mockRepo.getStock(p1)).thenReturn(Stock(5)) val useCase ValidateCartItemsStockUseCase(mockRepo) // When val result useCase.execute(ValidateStockParams(listOf(CartItem(p1, 10)))) // Then assertTrue(result.isFailure) assertEquals(5, result.exceptionOrNull()?.message?.toInt()) }3.4 组合调用用协程作用域串联多个UseCase下单不是单个UseCase能完成的需要串行调用。我们在ViewModel里用viewModelScope安全地组合fun placeOrder(cartItems: ListCartItem) { viewModelScope.launch { // 1. 校验库存 val stockResult validateStockUseCase.execute(ValidateStockParams(cartItems)) if (!stockResult.isSuccess) { _uiState.value UiState.Error(stockResult.exceptionOrNull()?.message ?: 库存校验失败) returnlaunch } // 2. 校验优惠券此处省略 // 3. 创建订单 val orderResult createOrderUseCase.execute(CreateOrderParams(...)) _uiState.value when (orderResult) { is Result.Success - UiState.Success(orderResult.getOrNull()!!.orderId) else - UiState.Error(orderResult.exceptionOrNull()?.message ?: 创建订单失败) } } }这里的关键是每个UseCase的失败都立即中断后续流程且错误信息结构化传递避免了传统回调嵌套的“金字塔地狱”。4. 高阶实战应对复杂场景的UseCase模式库当业务复杂度上升单纯“一个UseCase对应一个业务动作”的模式会力不从心。我们团队沉淀了五种高阶模式覆盖90%的复杂场景全部经过生产环境验证。4.1 条件分支UseCase用策略模式替代if-else某金融App的“还款计划生成”需根据用户信用等级、贷款类型、剩余期数动态选择算法。若写成GenerateRepaymentPlanUseCase并在内部用if-else判断会导致类膨胀且难以测试。我们改用策略模式interface RepaymentPlanStrategy { fun calculate(params: PlanParams): RepaymentPlan } class StandardPlanStrategy : RepaymentPlanStrategy { ... } class PremiumPlanStrategy : RepaymentPlanStrategy { ... } class GenerateRepaymentPlanUseCase constructor( private val strategyFactory: StrategyFactoryRepaymentPlanStrategy ) { suspend fun execute(params: PlanParams): RepaymentPlan { val strategy strategyFactory.getStrategy(params.userLevel) return strategy.calculate(params) } }StrategyFactory根据参数动态选择策略新增一种还款算法只需添加新策略类无需修改UseCase——符合开闭原则。4.2 多数据源协同UseCase用CombineLatest实现最终一致性用户个人中心页需同时展示本地缓存的用户基本信息、远程API的最新积分、以及本地数据库的最近3条订单。传统做法是在ViewModel里分别调用三个UseCase再手动合并易出错且难以保证数据新鲜度。我们创建UserProfileCombinedUseCaseclass UserProfileCombinedUseCase constructor( private val userRepo: UserRepo, private val pointsRepo: PointsRepo, private val orderRepo: OrderRepo ) { fun execute(): FlowUserProfileCombined { return combine( userRepo.getUser().distinctUntilChanged(), pointsRepo.getPoints().distinctUntilChanged(), orderRepo.getRecentOrders().distinctUntilChanged() ) { user, points, orders - UserProfileCombined(user, points, orders) } } }combine操作符确保任一数据源更新时自动触发重新组合且所有数据来自同一时间点避免显示“用户姓名是旧的积分是新的”这种不一致状态。4.3 带进度反馈的UseCase用StateFlow暴露中间状态文件上传这类长耗时操作用户需要实时进度。我们不推荐在UseCase里发Toast或更新UI而是暴露StateFlowUploadStatesealed interface UploadState { object Idle : UploadState data class Progress(val percent: Int) : UploadState data class Success(val fileId: String) : UploadState data class Error(val message: String) : UploadState } class UploadFileUseCase constructor(private val fileRepo: FileRepo) { private val _state MutableStateFlowUploadState(UploadState.Idle) val state: StateFlowUploadState _state.asStateFlow() suspend fun execute(file: File) { _state.value UploadState.Progress(0) try { val result fileRepo.upload(file) { progress - _state.value UploadState.Progress(progress) } _state.value UploadState.Success(result.id) } catch (e: Exception) { _state.value UploadState.Error(e.message ?: 上传失败) } } }ViewModel只需收集stateUI层用collectAsStateWithLifecycle绑定彻底解耦进度更新逻辑。4.4 可撤销UseCase用Command Pattern支持后悔操作电商App的“删除地址”需支持撤回。我们设计UndoableUseCase基类abstract class UndoableUseCaseT { protected abstract suspend fun doAction(): T protected abstract suspend fun undoAction(result: T) suspend fun execute(): T { val result doAction() // 将undoAction和result存入UndoManager UndoManager.add(UndoCommand({ undoAction(result) }, result)) return result } }UndoManager维护一个栈点击“撤回”时弹出并执行对应命令。所有继承此类的UseCase自动获得撤回能力。4.5 缓存穿透防护UseCase用装饰器模式增强鲁棒性针对高频查询如商品详情我们为GetProductUseCase添加缓存装饰器class CachedProductUseCase constructor( private val decorated: GetProductUseCase, private val cache: ProductCache ) : GetProductUseCase { override suspend fun execute(params: ProductParams): ResultProduct { return cache.get(params.id)?.let { Result.success(it) } ?: run { val result decorated.execute(params) if (result.isSuccess) cache.put(params.id, result.getOrNull()!!) result } } }装饰器不侵入原始UseCase通过构造函数注入即可启用符合单一职责和开闭原则。5. 踩坑实录那些让UseCase变成技术债的典型错误再完美的设计落地时也会撞墙。我把团队过去两年踩过的坑按严重程度排序附上根因分析和修复方案。这些不是理论假设而是线上事故复盘的真实记录。5.1 坑位一在UseCase里直接调用Android主线程API现象某版本上线后部分低端机下单页面卡死ANR率飙升至5%。根因定位CreateOrderUseCase的execute()方法里有一行Thread.sleep(100)模拟网络延迟——这是测试代码遗留更致命的是它调用了NotificationManager.notify()发送下单成功通知。UseCase在协程IO线程执行而notify()必须在主线程导致隐式线程切换失败。修复方案立即移除所有Android API调用通知逻辑移至ViewModel用viewScope.launch { ... }确保主线程执行。我们随后在CI中加入静态扫描规则禁止UseCase包下出现android.app.NotificationManager、android.os.Handler等类名。5.2 坑位二UseCase持有Activity/Fragment引用现象内存泄漏检测工具显示CheckoutActivity实例无法回收泄漏链指向ValidateCouponUseCase。根因定位该UseCase构造函数接收了Activity参数用于startActivityForResult跳转优惠券选择页。这导致UseCase持有了Activity的强引用即使Activity销毁UseCase仍存活。修复方案将跳转逻辑上移到Presentation层UseCase只返回CouponSelectionRequired业务结果由ViewModel决定是否启动Activity。我们制定了硬性规范UseCase构造函数参数类型只能是Repository接口、其他UseCase、或纯数据类。5.3 坑位三滥用Flow导致内存泄漏现象用户频繁进出订单页OOM crash率上升。根因定位GetOrderHistoryUseCase返回FlowOrder但ViewModel未正确取消收集。onCleared()里忘记调用job.cancel()导致Flow持续发射数据而UI已销毁。修复方案统一使用viewScope.launch { useCase.execute().collect { ... } }利用viewScope的生命周期感知自动取消对需要长期监听的Flow如用户登录状态改用callbackFlow并在onCleared()显式关闭。5.4 坑位四参数校验缺失引发空指针现象灰度发布后SearchProductsUseCase崩溃率突增堆栈指向params.keyword.length()。根因定位UseCase未对输入参数做非空校验假设调用方已处理。但某些第三方SDK传入null keyword。修复方案在UseCase入口添加Kotlin空安全断言requireNotNull(params.keyword) { keyword cannot be null } require(params.keyword.isNotBlank()) { keyword cannot be blank }并将校验逻辑下沉到SearchProductsParams的init块中从源头杜绝非法参数。5.5 坑位五过度设计导致UseCase爆炸式增长现象项目迭代半年后UseCase类数量达217个新人入职需花一周熟悉命名规则。根因定位为每个微小操作如ShowLoadingDialogUseCase、HideKeyboardUseCase都创建独立UseCase混淆了业务逻辑与UI操作的边界。修复方案确立UseCase准入门槛——只有涉及业务规则、跨数据源、需复用或需独立测试的逻辑才允许建UseCaseUI辅助操作弹Toast、关键盘直接在ViewModel里处理。我们最终将UseCase数量精简至43个覆盖全部核心业务。6. 工程化落地让UseCase规范成为团队肌肉记忆再好的设计如果无法规模化落地就是纸上谈兵。我们花了三个月把UseCase规范从文档变成团队的“肌肉记忆”核心靠三招模板化、自动化、可视化。6.1 模板化一键生成标准UseCase骨架在Android Studio里配置Live Template输入usecase自动展开class ${USECASE_NAME}UseCase constructor( private val ${REPO_NAME}: ${REPO_INTERFACE} ) { suspend fun execute(${PARAMS_NAME}: ${PARAMS_CLASS}): Result${RESULT_TYPE} { return try { // TODO: implement business logic Result.success(${RESULT_TYPE}()) } catch (e: Exception) { Result.failure(e) } } }配合File Template生成配套的Params、Result、Test文件。新人创建UseCase不再纠结格式专注业务逻辑本身。6.2 自动化CI流水线里的三重守门员编译守门员Gradle TaskcheckUseCaseDependencies扫描所有domain模块禁止Android SDK import失败则中断构建测试守门员Jacoco报告要求UseCase单元测试覆盖率≥95%低于阈值PR自动拒绝质量守门员SonarQube规则检查UseCase类复杂度Cyclomatic Complexity ≤ 5、行数≤ 60行、参数个数≤ 3个。去年Q3这三重守门员拦截了17次违规提交其中3次是资深工程师疏忽——说明规范必须靠机器而非信任。6.3 可视化架构图自动生成与实时校验我们用ArchUnit库编写规则每次构建时自动生成架构合规报告// 确保UseCase只依赖Repository接口 classes().that().resideInAPackage(..domain.usecase..) .should().onlyDependOnClassesThat().resideInAnyPackage( ..domain.., ..data..repository.. ).check(importedClasses);报告以HTML形式输出直观显示违规依赖链。更进一步我们把ArchUnit集成到IDEA插件在编辑器侧边栏实时提示“GetUserUseCase违规依赖android.content.Context”。这种即时反馈比Code Review高效十倍。6.4 团队习惯每日站会的“UseCase健康度”快问每天晨会最后1分钟随机抽取一个UseCase类提问它的输入参数是否封装为数据类是否有Android SDK import单元测试是否覆盖所有Result分支类行数是否≤60答不上来的人当天下班前补全。坚持三个月团队对UseCase的认知误差率从32%降至0%。注意UseCase不是银弹。对于CRUD为主的简单App如记事本强行套用反而增加复杂度。我们建议当项目代码量超5万行、团队规模超5人、或存在跨端复用需求时UseCase架构的价值才真正凸显。别为了架构而架构要为业务而架构。我在实际项目中发现真正决定UseCase成败的往往不是技术选型而是团队对“业务逻辑”和“技术实现”的认知共识。当产品经理说“用户下单时要校验库存”开发能立刻意识到这该是一个UseCase而不是一句“我写个API调用就行”这种思维转变才是架构落地最深的根基。