说实话,刚开始做前后端分离的Android项目时,我踩过不少坑。今天就把我这些年的实战经验,连同代码细节,掰开了揉碎了讲给你听。咱们不整那些虚头巴脑的理论,直接聊干货。
一、为什么要前后端分离?这不是赶时髦
先别急着写代码,咱们得想清楚一个问题:这玩意儿到底值不值得做?
很多团队一开始觉得:“前后端不分离不好吗?一套代码打天下,多省事。” 结果呢?APP版本迭代慢,后端改个接口,安卓这边还得跟着发版;前端换个UI,后端接口又出bug。两边互相甩锅,项目进度拖得比蜗牛还慢。
前后端分离的核心价值就三点:
- 解耦:Android和后端各司其职,互不干扰
- 复用:同一套API,iOS、Web、小程序都能用
- 效率:Android开发专注体验,后端专注业务逻辑
举个例子,我们团队去年做的是一个电商APP。如果不分离,每次大促活动,安卓这边要同时打包、测试、上架,后端要改库存逻辑、订单逻辑,两边协调时间差就要两天。分离之后,后端改完接口直接部署,安卓这边用Mock数据先开发,等接口稳定再联调,整体效率提升了40%。
二、架构设计:别一上来就写代码
很多初学者拿到项目就开干,结果做到一半发现结构乱成一锅粥。架构设计这步省不得,但我说的“设计”不是让你画几十页的UML图,而是把核心结构想清楚。
2.1 推荐的分层架构
咱们用的这套架构,是经过三个项目验证的:
app/
├── data/ # 数据层
│ ├── remote/ # 网络请求
│ ├── local/ # 本地存储
│ └── model/ # 数据模型
├── domain/ # 领域层(业务逻辑)
│ ├── repository/ # 仓库接口
│ └── usecase/ # 用例
└── presentation/ # 展示层
├── ui/ # 界面
└── vm/ # ViewModel
别嫌麻烦,这套结构的好处是:每个层职责单一,改动某一层不会影响其他层。
比如,你把OkHttp换成Retrofit,只改data/remote里的内容;后端换了返回结构,只改data/model;至于界面怎么展示,完全不用动。
2.2 网络层选型:Retrofit + OkHttp
这个组合几乎是Android网络层的标配了。别去碰原生HttpClient,过时了。
先看下基础配置,这是我在项目里实际用的:
// RetrofitClient.kt
object RetrofitClient {
private const val BASE_URL = "https://api.example.com/"
private val okHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.addInterceptor { chain ->
val original = chain.request()
val request = original.newBuilder()
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("App-Version", BuildConfig.VERSION_NAME)
.header("Device-Type", "Android")
.build()
chain.proceed(request)
}
.build()
val api: ApiService = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(
GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss").create()
))
.build()
.create(ApiService::class.java)
}
注意看那几个Header,App-Version和Device-Type很重要。后端需要知道请求从哪个版本、哪个设备来,方便排查问题和做灰度发布。
2.3 接口定义:规范大于一切
// ApiService.kt
interface ApiService {
// 登录
@POST("user/login")
suspend fun login(@Body request: LoginRequest): ApiResponse<LoginResponse>
// 获取商品列表(分页)
@GET("products")
suspend fun getProducts(
@Query("page") page: Int = 1,
@Query("size") size: Int = 20,
@Query("category") category: String? = null
): ApiResponse<PagedResponse<Product>>
// 上传商品图片
@Multipart
@POST("products/{id}/images")
suspend fun uploadProductImage(
@Path("id") productId: Long,
@Part image: MultipartBody.Part
): ApiResponse<ImageUploadResult>
}
这里有个关键点:所有接口统一返回ApiResponse<T>。别后端返回A接口是JSON,B接口是XML,C接口直接是数据对象。统一封装之后,Android这边处理起来会轻松很多。
统一响应格式大概长这样:
// ApiResponse.kt
data class ApiResponse<T>(
val code: Int, // 业务状态码
val message: String, // 提示信息
val data: T? = null, // 数据
val traceId: String? = null // 链路追踪ID,方便排查问题
)
三、接口对接:这里才是坑最多的地方
架构搭好了,接下来就是和后端对接接口了。这部分我要重点说说,因为80%的问题都出在这里。
3.1 问题一:接口定义文档和实际实现不一致
这是最常见的问题。后端说“我接口写完了”,结果Android这边一调,字段名对不上,类型也对不上。
解决方案:
- 接口文档先行:在写代码之前,先让后端把接口文档写好,Android和后端一起评审
- 使用Swagger/OpenAPI:别用Word文档,太容易过时了。用Swagger,接口改了文档自动更新
- Mock数据先行开发:后端接口没好?没关系,先定义好数据结构,用Mock数据把界面开发完
比如我们项目里,用MockWebServer做本地Mock:
// MockApiService.kt(本地Mock版)
class MockApiService {
fun getMockProductList(): PagedResponse<Product> {
return PagedResponse(
total = 100,
page = 1,
size = 20,
items = (1..20).map { index ->
Product(
id = index.toLong(),
name = "测试商品 $index",
price = 99.99 * index,
imageUrl = "https://via.placeholder.com/150"
)
}
)
}
}
这样即使后端拖更,Android这边也能先开发完界面和逻辑。
3.2 问题二:分页加载体验差
分页这个需求太常见了,但很多团队做得很粗糙。上拉加载更多的时候,用户不知道有没有数据、加载失败了、还是已经到底了。
解决方案:用StateFlow管理加载状态
// ProductViewModel.kt
class ProductViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ProductUiState())
val uiState: StateFlow<ProductUiState> = _uiState.asStateFlow()
private val repository: ProductRepository = ProductRepositoryImpl()
fun loadProducts(page: Int) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val response = repository.getProducts(page)
_uiState.update {
it.copy(
products = if (page == 1) response.items else it.products + response.items,
isLoading = false,
hasMore = response.items.size >= PAGE_SIZE,
currentPage = page
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, error = e.message)
}
}
}
}
}
data class ProductUiState(
val products: List<Product> = emptyList(),
val isLoading: Boolean = false,
val hasMore: Boolean = true,
val error: String? = null,
val currentPage: Int = 1
)
对应的Fragment里这样用:
// ProductFragment.kt
class ProductFragment : Fragment() {
private val viewModel: ProductViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerView.apply {
adapter = ProductAdapter(
onLoadMore = { viewModel.loadProducts(viewModel.uiState.value.currentPage + 1) }
)
}
// 监听状态变化
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
when {
state.isLoading && state.currentPage == 1 -> {
// 显示加载中的ProgressBar
}
state.error != null -> {
// 显示错误提示
Snackbar.make(view, state.error, Snackbar.LENGTH_LONG).show()
}
!state.hasMore -> {
// 显示“没有更多了”
}
else -> {
// 更新列表
(recyclerView.adapter as ProductAdapter).submitList(state.products)
}
}
}
}
}
// 初始加载
viewModel.loadProducts(1)
}
}
这个方案的好处是:状态清晰,逻辑集中,不会出现多次点击导致重复请求的问题。
3.3 问题三:网络请求失败处理不规范
很多开发者写网络请求,要么完全不管,要么统一弹个Toast说“网络错误”。用户看了半天也不懂到底啥错误。
解决方案:建立统一的错误处理机制
// NetworkError.kt
sealed class NetworkError {
object NetworkUnavailable : NetworkError()
object Timeout : NetworkError()
object ServerError : NetworkError()
data class HttpError(val code: Int) : NetworkError()
data class ValidationError(val message: String) : NetworkError()
object Unknown : NetworkError()
}
// ApiResult.kt
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val error: NetworkError, val message: String? = null) : ApiResult<Nothing>()
object Loading : ApiResult<Nothing>()
}
// 扩展函数,方便转换
suspend inline fun <reified T : Any> RetrofitCall.executeSafe(): ApiResult<T> {
return try {
val response = execute()
when {
response.isSuccessful -> {
val body = response.body()
if (body != null) {
ApiResult.Success(body)
} else {
ApiResult.Error(NetworkError.ServerError, "响应体为空")
}
}
response.code() == 401 -> ApiResult.Error(NetworkError.HttpError(401), "登录已过期")
response.code() in 500..599 -> ApiResult.Error(NetworkError.ServerError, "服务器错误")
else -> ApiResult.Error(NetworkError.HttpError(response.code()), response.errorBody()?.string())
}
} catch (e: IOException) {
ApiResult.Error(NetworkError.NetworkUnavailable, e.message)
} catch (e: HttpTimeoutException) {
ApiResult.Error(NetworkError.Timeout, "请求超时,请检查网络")
} catch (e: Exception) {
ApiResult.Error(NetworkError.Unknown, e.message)
}
}
用的时候就很简单:
when (val result = api.login(request).executeSafe()) {
is ApiResult.Success -> {
// 登录成功,跳转
}
is ApiResult.Error -> {
when (result.error) {
is NetworkError.NetworkUnavailable -> showNetworkErrorDialog()
is NetworkError.Timeout -> showToast("网络连接超时")
is NetworkError.ValidationError -> showToast(result.message ?: "参数错误")
else -> showToast("操作失败,请稍后重试")
}
}
is ApiResult.Loading -> {
// 显示加载动画
}
}
这样用户看到的错误信息是具体且有用的,而不是冷冰冰的“网络错误”。
3.4 问题四:Token管理混乱
登录状态管理是前后端分离项目的核心问题。Token怎么存?过期了怎么处理?并发请求怎么排队?
解决方案:用Repository模式统一管理
// TokenManager.kt
class TokenManager @Inject constructor(
private val sharedPreferences: SharedPreferences
) {
companion object {
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_EXPIRES_AT = "expires_at"
}
fun saveTokens(access: String, refresh: String, expiresAt: Long) {
sharedPreferences.edit()
.putString(KEY_ACCESS_TOKEN, access)
.putString(KEY_REFRESH_TOKEN, refresh)
.putLong(KEY_EXPIRES_AT, expiresAt)
.apply()
}
fun getAccessToken(): String? = sharedPreferences.getString(KEY_ACCESS_TOKEN, null)
fun isTokenExpired(): Boolean {
val expiresAt = sharedPreferences.getLong(KEY_EXPIRES_AT, 0)
return System.currentTimeMillis() >= expiresAt
}
fun clearTokens() {
sharedPreferences.edit()
.clear()
.apply()
}
}
// AuthInterceptor.kt
class AuthInterceptor(
private val tokenManager: TokenManager
) : Interceptor {
private val refreshLock = Any()
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
// 添加Token
val token = tokenManager.getAccessToken()
if (token != null) {
request.header("Authorization", "Bearer $token")
}
val response = chain.proceed(request.build())
// 如果401且未正在刷新,尝试刷新Token
if (response.code == 401 && !isRefreshing) {
synchronized(refreshLock) {
if (!isRefreshing) {
isRefreshing = true
val refreshToken = tokenManager.getRefreshToken()
if (refreshToken != null) {
try {
val newTokens = refreshTokenService.refresh(refreshToken)
tokenManager.saveTokens(
newTokens.accessToken,
newTokens.refreshToken,
newTokens.expiresAt
)
// 重试原请求
val retryRequest = chain.request().newBuilder()
.header("Authorization", "Bearer ${newTokens.accessToken}")
.build()
return chain.proceed(retryRequest)
} catch (e: Exception) {
// 刷新失败,清除Token,跳转登录页
tokenManager.clearTokens()
EventBus.post(LoginExpiredEvent)
}
} else {
tokenManager.clearTokens()
EventBus.post(LoginExpiredEvent)
}
}
}
}
return response
}
private var isRefreshing = false
}
这里有个关键点:刷新Token时要防止并发请求。如果多个请求同时返回401,不能每个都去刷新Token,否则会造成并发问题。用synchronized或者 Kotlin 的锁机制来处理这个问题。
四、数据持久化:别把所有数据都存内存里
Android内存有限,尤其是列表数据,不能每次打开页面都重新请求。我们需要本地缓存。
4.1 Room数据库的基本用法
// ProductEntity.kt
@Entity(tableName = "products")
data class ProductEntity(
@PrimaryKey val id: Long,
val name: String,
val price: Double,
val imageUrl: String,
val category: String,
val updatedAt: Long
)
// ProductDao.kt
@Dao
interface ProductDao {
@Query("SELECT * FROM products ORDER BY updatedAt DESC")
fun getAllProducts(): Flow<List<ProductEntity>>
@Query("SELECT * FROM products WHERE category = :category")
fun getProductsByCategory(category: String): Flow<List<ProductEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertProducts(products: List<ProductEntity>)
@Query("DELETE FROM products")
suspend fun clearAll()
}
// ProductRepository.kt
class ProductRepositoryImpl @Inject constructor(
private val apiService: ApiService,
private val productDao: ProductDao
) : ProductRepository {
override fun getProducts(page: Int): Flow<ApiResult<PagedResponse<Product>>> = flow {
emit(ApiResult.Loading)
try {
// 先查本地缓存
val cachedProducts = productDao.getAllProducts().first()
if (cachedProducts.isNotEmpty()) {
emit(ApiResult.Success(convertToDto(cachedProducts)))
}
// 再请求网络
val networkResponse = apiService.getProducts(page)
productDao.insertProducts(convertToEntity(networkResponse.items))
emit(ApiResult.Success(networkResponse))
} catch (e: Exception) {
emit(ApiResult.Error(NetworkError.Unknown, e.message))
}
}
}
这个思路叫Cache-Aside Pattern(旁路缓存)。先查本地,本地没有再查网络,然后把结果存回本地。这样既保证了数据的新鲜度,又提升了加载速度。
4.2 缓存策略要灵活
不是所有数据都要缓存。比如:
- 商品列表:可以缓存,用户可能反复查看
- **用户个人信息
