下面把这个流程展开说明每一层具体做什么、传什么数据、代码怎么写以及几个容易出问题的地方。示例使用 KotlinAndroid其他平台思路相同。一、完整时序数据在各层之间怎么流动用户 View(界面) ViewModel UseCase Repository 服务端 │ 点击提交 │ │ │ │ │ │───────────▶│ │ │ │ │ │ │ submit() │ │ │ │ │ │──────────────▶│ │ │ │ │ │ 状态:loading │ │ │ │ │ │◀──────────────│ │ │ │ │ 按钮变灰 │ │ invoke(购物车,地址) │ │ │ │ │───────────────▶│ │ │ │ │ │ │ 1.校验地址/起送价 │ │ │ │ │ │ 2.预估价格 │ │ │ │ │ │ createOrder() │ │ │ │ │ │────────────────▶│ POST /orders │ │ │ │ │ │───────────────▶│ │ │ │ │ │ │ 校验库存、 │ │ │ │ │ │ 重算价格、 │ │ │ │ │ 订单数据 │ 创建订单 │ │ │ │ │◀───────────────│ │ │ │ │ │ 写入本地数据库 │ │ │ │ │ Order │ │ │ │ │ │◀────────────────│ │ │ │ │ Result(成功) │ │ │ │ │ │◀───────────────│ │ │ │ │ 事件:跳转支付 │ │ │ │ │ │◀──────────────│ │ │ │ │ 进入支付页 │ │ │ │ │每层的输入和输出层输入输出关心的问题View用户点击调用 ViewModel按钮、loading、弹窗长什么样ViewModel用户操作页面状态 一次性事件当前页面处于什么状态UseCase购物车、地址订单 或 具体错误业务规则是否满足Repository下单参数订单领域模型数据从网络来还是本地来API / 数据库请求 DTO响应 DTO接口格式、表结构二、各层代码0. 领域模型各层共用的“业务语言”// 金额统一用“分”Long不要用 Double避免 0.1 0.2 ≠ 0.3 的精度问题dataclassCartItem(valdishId:String,valname:String,valprice:Long,valcount:Int)dataclassCart(valshopId:String,valitems:ListCartItem,valcouponId:String?)dataclassAddress(valid:String,vallat:Double,vallng:Double,valdetail:String)dataclassOrder(valid:String,valpayAmount:Long,// 服务端算出的实付金额valstatus:OrderStatus)enumclassOrderStatus{WAIT_PAY,PAID,CANCELLED}1. 数据层Repository接口定义在领域层实现放在数据层依赖倒置领域层不依赖具体的网络库和数据库。// 领域层只定义“我需要什么能力” interfaceOrderRepository{suspendfuncreateOrder(cart:Cart,address:Address,requestId:String):Order}// 数据层网络接口Retrofit interfaceOrderApi{POST(orders)suspendfuncreateOrder(Header(Idempotency-Key)requestId:String,// 防重复下单后面详细讲Bodybody:CreateOrderRequest):CreateOrderResponse}// 网络传输用的 DTO字段名跟接口文档走dataclassCreateOrderRequest(valshop_id:String,valaddress_id:String,valitems:ListItemDto,valcoupon_id:String?)dataclassItemDto(valdish_id:String,valcount:Int)dataclassCreateOrderResponse(valorder_id:String,valpay_amount:Long,valstatus:String)// 数据层Repository 实现 classOrderRepositoryImpl(privatevalapi:OrderApi,privatevaldao:OrderDao// Room 数据库):OrderRepository{overridesuspendfuncreateOrder(cart:Cart,address:Address,requestId:String):Order{// 1. 领域模型 → 请求 DTOvalrequestCreateOrderRequest(shop_idcart.shopId,address_idaddress.id,itemscart.items.map{ItemDto(it.dishId,it.count)},// 只传 ID 和数量不传价格coupon_idcart.couponId)// 2. 调用网络valrespapi.createOrder(requestId,request)// 3. 响应 DTO → 领域模型valorderOrder(idresp.order_id,payAmountresp.pay_amount,statusOrderStatus.valueOf(resp.status))// 4. 写入本地数据库订单列表页、支付页可以直接读断网也能看dao.insert(order.toEntity())returnorder}}为什么要区分 DTO 和领域模型后端改了字段名如pay_amount→actual_amount只需要改数据层的转换代码上面的 UseCase、ViewModel、界面都不受影响。2. 领域层PlaceOrderUseCase// 业务错误要具体界面才能给出准确提示sealedclassPlaceOrderError:Exception(){objectNoAddress:PlaceOrderError()objectOutOfRange:PlaceOrderError()// 超出配送范围dataclassBelowMinimum(valdiff:Long):PlaceOrderError()// 差多少起送objectEmptyCart:PlaceOrderError()}classPlaceOrderUseCase(privatevalorderRepo:OrderRepository,privatevalshopRepo:ShopRepository,privatevalpriceCalculator:PriceCalculator){suspendoperatorfuninvoke(cart:Cart,address:Address?,requestId:String):ResultOrderrunCatching{// ① 校验if(cart.items.isEmpty())throwPlaceOrderError.EmptyCartif(addressnull)throwPlaceOrderError.NoAddressvalshopshopRepo.getShop(cart.shopId)if(!shop.canDeliverTo(address))throwPlaceOrderError.OutOfRange// ② 计算商品总价判断起送价valgoodsTotalpriceCalculator.goodsTotal(cart)if(goodsTotalshop.minOrderAmount){throwPlaceOrderError.BelowMinimum(shop.minOrderAmount-goodsTotal)}// ③ 真正下单orderRepo.createOrder(cart,address,requestId)}}这一层完全不涉及Android、界面、网络库可以直接写单元测试Testfun低于起送价时返回差额()runTest{valuseCasePlaceOrderUseCase(fakeOrderRepo,fakeShopRepo(minOrder2000),PriceCalculator())valresultuseCase(cartOf(price1500),someAddress,req-1)assertEquals(PlaceOrderError.BelowMinimum(500),result.exceptionOrNull())}3. 表现层ViewModel// 持续状态界面要一直显示的dataclassConfirmUiState(valsubmitting:Booleanfalse,valestimatedPrice:Long0,valerrorMsg:String?null)// 一次性事件只执行一次的跳转、ToastsealedclassConfirmEvent{dataclassGoToPay(valorderId:String,valamount:Long):ConfirmEvent()}classConfirmOrderViewModel(privatevalplaceOrder:PlaceOrderUseCase,privatevalcart:Cart,privatevaraddress:Address?):ViewModel(){privateval_stateMutableStateFlow(ConfirmUiState())valstate:StateFlowConfirmUiState_stateprivateval_eventsChannelConfirmEvent()valevents_events.receiveAsFlow()// 进入确认页时生成一次重试时复用同一个 IDprivatevalrequestIdUUID.randomUUID().toString()funsubmit(){if(_state.value.submitting)return// 防止连点viewModelScope.launch{_state.update{it.copy(submittingtrue,errorMsgnull)}placeOrder(cart,address,requestId).onSuccess{order-_state.update{it.copy(submittingfalse)}_events.send(ConfirmEvent.GoToPay(order.id,order.payAmount))}.onFailure{e-_state.update{it.copy(submittingfalse,errorMsge.toMessage())}}}}privatefunThrowable.toMessage()when(this){PlaceOrderError.NoAddress-请选择收货地址PlaceOrderError.OutOfRange-该地址超出配送范围isPlaceOrderError.BelowMinimum-还差${diff/100.0}元起送isIOException-网络不太好请重试else-下单失败请稍后再试}}4. 表现层界面ComposeComposablefunConfirmOrderScreen(vm:ConfirmOrderViewModel,navController:NavController){valstatebyvm.state.collectAsState()// 监听一次性事件LaunchedEffect(Unit){vm.events.collect{event-when(event){isConfirmEvent.GoToPay-navController.navigate(pay/${event.orderId})}}}Column{Text(预计支付 ¥${state.estimatedPrice/100.0})state.errorMsg?.let{Text(it,colorColor.Red)}Button(onClick{vm.submit()},enabled!state.submitting// 提交中按钮不可点){if(state.submitting)CircularProgressIndicator(Modifier.size(16.dp))elseText(提交订单)}}}界面里没有任何业务判断只是“状态是什么就显示什么”。三、几个关键细节面试和实际开发都常问细节 1客户端算的价格只是“预估”以服务端为准前面架构概述里说过“价格计算放在服务端客户端不可信”这里 UseCase 又在算价格并不矛盾客户端计算为了体验 - 确认页即时显示“预计 ¥28.5” - 判断是否达到起送价提前提示用户 → 可能被篡改也可能因为优惠过期而不准 服务端计算为了正确 - 根据商品 ID 从数据库查真实价格 - 重新校验优惠券、库存、活动 → 最终支付金额以服务端返回的 payAmount 为准所以请求里只传商品 ID 和数量不传价格。如果服务端价格和预估不同例如优惠券刚过期应该提示用户“价格有变化”再让用户确认。细节 2防止重复下单两道防线第一道客户端 submitting true 时按钮禁用submit() 直接 return → 防止用户手抖连点 第二道服务端幂等更重要 请求头带 Idempotency-Key同一次下单用同一个 requestId 服务端记录requestId → orderId 同一个 requestId 第二次请求时直接返回已创建的订单不再新建细节 3网络超时 ≠ 下单失败这和之前讲的丢包问题直接相关情况 A请求包丢了 → 服务端没收到订单没创建 情况 B响应包丢了 → 服务端已创建订单只是客户端不知道 客户端看到的都是“超时”无法区分如果超时后生成新的 requestId 重试情况 B 就会产生两个订单。正确做法超时后 → 提示“网络不好请重试” 用户重试 → 使用同一个 requestId 服务端 情况 A → 正常创建订单 情况 B → 发现 requestId 已存在返回原订单 ✓这也是上面代码中requestId放在 ViewModel 属性里、而不是每次submit()时生成的原因。细节 4跳转用“一次性事件”不要放在状态里错误做法state 里放 navigateToPay true → 屏幕旋转后界面重建重新读取状态又跳转一次 正确做法用 Channel 发送事件只被消费一次细节 5为什么下单成功后写入本地数据库1. 支付页、订单列表直接读数据库不用再请求一次 2. 断网时也能查看“我刚下的订单” 3. 后续订单状态变化推送或轮询更新数据库 所有观察数据库的页面会自动刷新四、服务端收到 POST /orders 后做了什么简述1. 网关校验登录 token、限流 2. 幂等检查requestId 是否已处理过Redis 3. 校验店铺是否营业、地址是否在配送范围 4. 查询真实价格、校验优惠券、锁定库存 5. 数据库事务中创建订单状态 待支付 6. 设置超时任务15 分钟未支付自动取消、释放库存 7. 返回订单 ID 和实付金额五、分层带来的实际好处变化需要改哪里其他层确认页改版只改 View不动起送规则调整只改 UseCase不动后端接口字段改名只改 DTO 转换不动Retrofit 换成别的网络库只改数据层不动写单元测试UseCase 用假的 Repository 即可测试不需要真机和网络总结1. View只显示状态、转发点击 2. ViewModel管理页面状态loading、错误和一次性事件跳转 3. UseCase业务校验地址、配送范围、起送价不依赖任何框架 4. Repository模型转换、调用接口、写入本地数据库 关键细节 - 客户端价格只是预估最终以服务端为准请求中不传价格 - 防重复下单按钮禁用 服务端幂等键 - 超时不代表失败重试必须复用同一个 requestId - 跳转用一次性事件避免界面重建后重复跳转一句话分层让“下单”这件事中界面、业务规则、数据来源各管各的而幂等和超时处理决定了这个下单按钮在弱网下是否可靠。