Android项目前后端分离实战:从接口设计到跨域问题解决的全过程解析
为什么选择前后端分离
先聊聊我们为什么要折腾这个事儿。想象一下,你带着团队做了一个电商App,用了一年时间,功能很完善,用户量也上来了。这时候老板说:”咱们做个Web管理后台吧,方便运营同学看数据。”你内心一紧——因为你的Android代码里混着大量业务逻辑,HTTP请求、数据处理、UI渲染全都搅在一起。改个接口要重新发版,前端同事想联调只能等你,整个节奏乱成一团麻。
前后端分离的核心价值就在这里:把数据交付和业务逻辑彻底分开,两边独立开发、独立部署、互不阻塞。Android只负责展示和交互,后端只负责数据和接口。这对团队协作效率的提升是质的飞跃。
接口设计:先定规矩再干活
接口设计是前后端分离的基石,这一步没做好,后面全是坑。我们从一个具体的场景出发——做一个”用户中心”模块,包含用户登录、获取用户信息、修改个人资料三个核心接口。
接口设计风格选择
目前业界主流有两种风格:RESTful 和 GraphQL。对于大多数Android项目,尤其是偏传统的后端团队,RESTful 更加友好,也更容易被后端同学接受。我们这里以RESTful为基础,结合一些实用主义考量来设计。
# 用户相关接口设计表
GET /api/v1/users/profile 获取当前用户信息
PUT /api/v1/users/profile 更新用户信息
POST /api/v1/auth/login 用户登录
POST /api/v1/auth/logout 用户登出
注意几个细节:
第一,版本号要放在URL里。/api/v1/ 这个前缀不是形式,而是实实在在的版本控制手段。等以后要出 v2 版本时,你可以同时维护两个版本的接口,让老用户继续用 v1,新用户用 v2,平滑过渡。
第二,资源用名词,动作用HTTP方法。这是REST的核心思想。你要获取用户资料,不是搞一个 /getUserProfile 这种带动词的接口,而是用 GET /users/profile。后端同学一看就知道这是什么操作。
第三,统一响应结构。这能极大降低Android端的解析成本。我们定义这样一个固定格式:
{
"code": 200,
"message": "success",
"data": {
"userId": "10086",
"nickname": "码农小明",
"avatar": "https://cdn.example.com/avatar/10086.jpg",
"phone": "138****8888"
}
}
// Android端对应的DataClass定义
data class ApiResponse<T>(
val code: Int,
val message: String,
val data: T?
)
// 用户信息的具体数据结构
data class UserProfile(
val userId: String,
val nickname: String,
val avatar: String,
val phone: String
)
这样Android端只需要解析一层通用结构,再根据业务取对应的 data 字段,代码整洁多了。
登录接口的特殊处理
登录接口比较特殊,因为涉及到token。我们来看看完整的登录流程设计:
POST /api/v1/auth/login
请求体:
{
"username": "xiaoming",
"password": "your_password"
}
响应体:
{
"code": 200,
"message": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresIn": 7200,
"userProfile": {
"userId": "10086",
"nickname": "码农小明",
"avatar": "https://cdn.example.com/avatar/10086.jpg"
}
}
}
注意 expiresIn 这个字段,它告诉客户端token的有效期(秒)。Android端拿到这个值之后,可以提前刷新token,避免用户正在操作中token突然失效。
网络层架构:Kotlin协程 + OkHttp + Retrofit
接口设计好了,接下来是Android端的网络层搭建。这是整个项目的命脉,必须设计得健壮、清晰、易于维护。
为什么选这三件套
OkHttp是底层引擎,性能稳定,连接池管理优秀;Retrofit在OkHttp之上封装了注解式的接口声明,让网络请求像调用本地方法一样自然;Kotlin协程则是异步处理的现代方案,相比以前的RxJava,协程更轻量、更直观,代码可读性极强。
完整的网络层实现
我们一步一步来搭建。首先是依赖配置:
// build.gradle (app)
dependencies {
// 网络相关
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Kotlin协程
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// 数据持久化(保存token)
implementation("androidx.security:security-crypto:1.1.0-alpha06")
}
接下来是基础URL的常量管理,避免到处硬编码:
// NetConstants.kt
object NetConstants {
// 正式环境
const val BASE_URL = "https://api.yourapp.com"
// 测试环境(开发时用)
const val BASE_URL_DEBUG = "http://192.168.1.100:8080"
// 请求超时时间(毫秒)
const val CONNECT_TIMEOUT = 30_000L
const val READ_TIMEOUT = 30_000L
const val WRITE_TIMEOUT = 30_000L
}
然后是核心的 Retrofit 实例工厂。这里有个关键设计——单例 + 懒加载,确保全局只有一个Retrofit实例,复用连接池:
// RetrofitClient.kt
object RetrofitClient {
private val okHttpClient = buildOkHttpClient()
private val retrofit = Retrofit.Builder()
.baseUrl(NetConstants.BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(buildGson()))
.build()
fun <T> create(serviceClass: Class<T>): T {
return retrofit.create(serviceClass)
}
private fun buildOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(NetConstants.CONNECT_TIMEOUT, TimeUnit.MILLISECONDS)
.readTimeout(NetConstants.READ_TIMEOUT, TimeUnit.MILLISECONDS)
.writeTimeout(NetConstants.WRITE_TIMEOUT, TimeUnit.MILLISECONDS)
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.addInterceptor(RequestHeaderInterceptor()) // 请求头拦截器
.addInterceptor(ResponseInterceptor()) // 响应拦截器
.addInterceptor(CacheInterceptor()) // 缓存拦截器(可选)
.build()
}
private fun buildGson(): Gson {
return GsonBuilder()
.setDateFormat("yyyy-MM-dd HH:mm:ss")
.serializeNulls() // 允许序列化null值,避免后端返回null时报错
.create()
}
}
这里引入了三个自定义拦截器,我们一个一个看:
请求头拦截器——负责给每个请求自动附加token和公共参数:
// RequestHeaderInterceptor.kt
class RequestHeaderInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
// 构建新的请求头
val newRequest = originalRequest.newBuilder()
.header("Content-Type", "application/json")
.header("Accept", "application/json")
// 动态添加token
.header("Authorization", "Bearer ${TokenManager.getToken()}")
// 添加公共参数,比如设备信息
.header("Device-Id", DeviceUtils.getDeviceId())
.header("App-Version", BuildConfig.VERSION_NAME)
.build()
return chain.proceed(newRequest)
}
}
// TokenManager.kt - Token管理工具类
object TokenManager {
private const val PREF_NAME = "app_prefs"
private const val KEY_TOKEN = "user_token"
fun getToken(): String {
val prefs = MyApplication.context?.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
return prefs?.getString(KEY_TOKEN, "") ?: ""
}
fun saveToken(token: String) {
val prefs = MyApplication.context?.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
prefs?.edit()?.putString(KEY_TOKEN, token)?.apply()
}
fun clearToken() {
val prefs = MyApplication.context?.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
prefs?.edit()?.remove(KEY_TOKEN)?.apply()
}
}
响应拦截器——统一处理业务错误码,让调用方不用每次都判断code:
// ResponseInterceptor.kt
class ResponseInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
// 只处理成功的HTTP响应(2xx)
if (!response.isSuccessful) {
when (response.code) {
401 -> {
// Token过期,清理并跳转登录页
TokenManager.clearToken()
EventBus.post(LoginExpiredEvent())
}
403 -> {
// 无权限
ToastUtils.show("您没有权限执行此操作")
}
404 -> {
// 资源不存在
ToastUtils.show("请求的资源不存在")
}
500 -> {
// 服务器内部错误
ToastUtils.show("服务器开小差了,请稍后重试")
}
}
throw ApiException(response.code, response.message)
}
return response
}
}
// 自定义异常类
class ApiException(val code: Int, val message: String) : Exception("$code: $message")
这样设计的好处是,你在ViewModel里调用接口时,只需要关心业务成功的情况,401/403/500这些网络层问题在拦截器里统一处理了,调用代码干净清爽。
API服务接口定义
网络基础设施搭好了,现在定义具体的API接口:
// AuthApi.kt - 认证相关接口
interface AuthApi {
/**
* 用户登录
* POST /api/v1/auth/login
*/
@POST("auth/login")
suspend fun login(
@Body request: LoginRequest
): ApiResponse<LoginResponse>
/**
* 用户登出
* POST /api/v1/auth/logout
*/
@POST("auth/logout")
suspend fun logout(): ApiResponse<Void>
}
// UserProfileApi.kt - 用户信息相关接口
interface UserProfileApi {
/**
* 获取当前用户信息
* GET /api/v1/users/profile
*/
@GET("users/profile")
suspend fun getProfile(): ApiResponse<UserProfile>
/**
* 更新用户信息
* PUT /api/v1/users/profile
*/
@PUT("users/profile")
suspend fun updateProfile(
@Body request: UpdateProfileRequest
): ApiResponse<UserProfile>
}
// 请求体的DataClass定义
data class LoginRequest(
val username: String,
val password: String
)
data class LoginResponse(
val token: String,
val expiresIn: Int,
val userProfile: UserProfile
)
data class UpdateProfileRequest(
val nickname: String?,
val avatar: String?
)
这里用到了 suspend 函数,意味着这些方法只能在协程中调用。这是现代Android网络编程的标准做法。
ViewModel层封装
网络请求不应该直接放在Activity或Fragment里,应该交给ViewModel。这样配置变更(如屏幕旋转)不会导致重复请求:
// AuthViewModel.kt
class AuthViewModel : ViewModel() {
private val authApi = RetrofitClient.create(AuthApi::class.java)
// 使用StateFlow管理UI状态
private val _loginState = MutableStateFlow<LoginState>(LoginState.Idle)
val loginState: StateFlow<LoginState> = _loginState
private val _profileState = MutableStateFlow<ProfileState>(ProfileState.Loading)
val profileState: StateFlow<ProfileState> = _profileState
/**
* 用户登录
*/
fun login(username: String, password: String) {
viewModelScope.launch {
_loginState.value = LoginState.Loading
try {
val response = authApi.login(LoginRequest(username, password))
if (response.code == 200 && response.data != null) {
// 保存token
TokenManager.saveToken(response.data.token)
// 保存用户信息
UserDataManager.saveProfile(response.data.userProfile)
_loginState.value = LoginState.Success(response.data.userProfile)
} else {
_loginState.value = LoginState.Error(response.message ?: "登录失败")
}
} catch (e: Exception) {
_loginState.value = LoginState.Error(e.message ?: "网络异常")
}
}
}
/**
* 获取用户信息
*/
fun fetchProfile() {
viewModelScope.launch {
_profileState.value = ProfileState.Loading
try {
val response = authApi.getProfile()
if (response.code == 200 && response.data != null) {
UserDataManager.saveProfile(response.data)
_profileState.value = ProfileState.Success(response.data)
} else {
_profileState.value = ProfileState.Error(response.message ?: "获取信息失败")
}
} catch (e: Exception) {
_profileState.value = ProfileState.Error(e.message ?: "网络异常")
}
}
}
/**
* 退出登录
*/
fun logout() {
viewModelScope.launch {
try {
authApi.logout()
} catch (e: Exception) {
// 登出接口失败也不影响本地清理
} finally {
TokenManager.clearToken()
UserDataManager.clearData()
}
}
}
}
// 状态密封类(比用多个LiveData更优雅)
sealed class LoginState {
object Idle : LoginState()
object Loading : LoginState()
data class Success(val profile: UserProfile) : LoginState()
data class Error(val message: String) : LoginState()
}
sealed class ProfileState {
object Loading : ProfileState()
data class Success(val profile: UserProfile) : ProfileState()
data class Error(val message: String) : ProfileState()
}
在Fragment中使用
// ProfileFragment.kt
class ProfileFragment : Fragment() {
private val viewModel: AuthViewModel by viewModels()
private var _binding: FragmentProfileBinding? = null
private val binding get() = _binding!!
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 收集登录状态
viewLifecycleOwner.lifecycleScope.launch {
viewModel.loginState.collect { state ->
when (state) {
is LoginState.Loading -> {
binding.progressBar.visibility = View.VISIBLE
}
is LoginState.Success -> {
binding.progressBar.visibility = View.GONE
navigateToMain()
}
is LoginState.Error -> {
binding.progressBar.visibility = View.GONE
Toast.makeText(context, state.message, Toast.LENGTH_SHORT).show()
}
LoginState.Idle -> {}
}
}
}
// 收集用户信息状态
viewLifecycleOwner.lifecycleScope.launch {
viewModel.profileState.collect { state ->
when (state) {
is ProfileState.Loading -> {
binding.loadingView.visibility = View.VISIBLE
}
is ProfileState.Success -> {
binding.loadingView.visibility = View.GONE
binding.tvNickname.text = state.profile.nickname
Glide.with(this@ProfileFragment)
.load(state.profile.avatar)
.into(binding.ivAvatar)
}
is ProfileState.Error -> {
binding.loadingView.visibility = View.GONE
}
}
}
}
binding.btnLogout.setOnClickListener {
viewModel.logout()
navigateToLogin()
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
到这里,Android端的网络层基本框架已经完整了。接下来我们重点解决前后端分离中最容易踩坑的问题——跨域。
跨域问题:前后端分离的”相爱相杀”
跨域(CORS,Cross-Origin Resource Sharing)是前后端分离项目中最常见也最容易让人头疼的问题。很多Android开发者第一次遇到跨域时会一脸懵——”我明明发了请求,为什么报错了?”
首先要澄清一个概念:跨域问题主要发生在Web端(浏览器),Android原生App不存在跨域限制。浏览器的同源策略是为了安全考虑,限制JavaScript不能随意访问其他域名的数据。但Android的OkHttp是一个纯粹的HTTP客户端,它不受浏览器同源策略的限制。
那么为什么我们还要讨论跨域?因为有两种情况需要关注:
第一种情况:你的Android项目里嵌入了WebView,WebView里的H5页面访问你的API时,就会受到跨域限制。
第二种情况:你的项目有Web管理后台,和Android共用同一套API,Web端开发时就会遇到跨域问题。
我们来逐一解决。
后端CORS配置(以Spring Boot为例)
大多数Android项目对接的是Java后端(Spring Boot),这里给出最标准的配置方式:
// CorsConfig.java
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**") // 对所有API路径生效
.allowedOriginPatterns("*") // 允许所有来源(生产环境建议指定域名)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true) // 允许携带Cookie/Authorization头
.maxAge(3600); // 预检请求缓存1小时
}
}
或者用注解方式,适合粒度更细的控制:
@RestController
@RequestMapping("/api/v1/users")
@CrossOrigin(
originPatterns = "*",
allowedHeaders = "*",
methods = {RequestMethod.GET, RequestMethod.PUT},
maxAge = 3600
)
public class UserProfileController {
@GetMapping("/profile")
public ResponseEntity<ApiResponse<UserProfile>> getProfile() {
// 业务逻辑...
}
}
生产环境的谨慎做法
上面的配置在生产环境直接使用 allowedOriginPatterns("*") 虽然方便,但从安全角度考虑,建议明确指定允许的域名:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
// 从配置文件读取允许的域名列表
@Value("${cors.allowed-origins}")
private List<String> allowedOrigins;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins(allowedOrigins.toArray(new String[0]))
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
# application.yml
cors:
allowed-origins:
- https://www.yourapp.com
- https://admin.yourapp.com
- http://localhost:3000 # 开发环境
Android端的特殊处理
虽然Android App不受跨域限制,但有一种情况需要注意:如果你的后端启用了CORS并且做了严格的来源校验,比如某些网关或WAF(Web应用防火墙)会检查请求头中的 Origin 或 Referer,Android端发出的请求可能因为没有这些头而被拦截。
解决方案很简单,在请求头中主动加上来源信息:
// RequestHeaderInterceptor.kt 改进版
class RequestHeaderInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val newRequest = originalRequest.newBuilder()
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("Authorization", "Bearer ${TokenManager.getToken()}")
.header("Device-Id", DeviceUtils.getDeviceId())
.header("App-Version", BuildConfig.VERSION_NAME)
// 主动添加Origin头,避免被严格的安全网关拦截
.header("Origin", NetConstants.BASE_URL)
.header("X-Request-Source", "android-app") // 自定义头标识来源
.build()
return chain.proceed(newRequest)
}
}
WebView中的跨域问题
如果你的Android项目中有WebView需要访问API,处理方式就不同了。WebView本质上运行的是浏览器内核,确实受跨域限制。这时候需要在WebView设置中关闭同源策略:
// WebApiHelper.kt
object WebApiHelper {
fun setupWebView(webView: WebView) {
val settings = webView.settings
// 允许文件协议访问(本地HTML)
settings.allowFileAccess = true
// 允许混合内容(HTTP混合HTTPS)
settings.mixedContentMode = WebSettings.MATCH_ALL
// 注意:在API 17+中,setAllowUniversalAccessFromFileURLs
// 和 setAllowFileAccessFromFileURLs 默认已经是true
// 添加JavaScript接口,让H5页面可以调用原生方法获取token
webView.addJavascriptInterface(TokenBridge(), "AndroidBridge")
}
// Token传递桥梁
class TokenBridge {
@JavascriptInterface
fun getToken(): String {
return TokenManager.getToken()
}
}
}
H5页面这样获取token:
// H5页面中的代码
fetch('/api/v1/users/profile', {
method: 'GET',
headers: {
'Authorization': 'Bearer ' + AndroidBridge.getToken(),
'Content-Type': 'application/json'
}
})
.then(res => res.json())
.then(data => {
console.log('用户信息:', data);
});
这样H5页面通过桥接的方式获取token,绕过了跨域限制。
完整的实战项目结构
下面给你一个完整的Android项目结构参考,你可以直接照着搭:
com.example.usercenter/
├── network/
│ ├── RetrofitClient.kt # Retrofit单例工厂
│ ├── Interceptors.kt # 拦截器集合(请求头、响应处理、日志)
│ ├── ApiServices.kt # 所有API接口定义
│ ├── model/
│ │ ├── ApiResponse.kt # 通用响应封装
│ │ ├── LoginRequest.kt # 登录请求体
│ │ └── UserProfile.kt # 用户信息模型
│ └── exception/
│ └── ApiException.kt # 网络异常类
├── manager/
│ ├── TokenManager.kt # Token管理
│ └── UserDataManager.kt # 用户数据持久化
├── viewmodel/
│ ├── AuthViewModel.kt # 认证相关ViewModel
│ └── ProfileViewModel.kt # 用户信息相关ViewModel
├── ui/
│ ├── login/
│ │ ├── LoginFragment.kt
│ │ └── LoginViewModel.kt
│ └── profile/
│ ├── ProfileFragment.kt
│ └── ProfileViewModel.kt
└── utils/
├── DeviceUtils.kt # 设备信息工具
└── EventBus.kt # 事件总线(用于跨页面通信)
调试技巧:如何快速定位网络问题
开发过程中网络问题不可避免,分享几个实用的调试技巧:
技巧一:开启OkHttp日志拦截器
// 在Debug包中开启详细日志
if (BuildConfig.DEBUG) {
val interceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
okHttpClient.newBuilder().addInterceptor(interceptor)
}
这样所有请求和响应的完整内容都会打印到Logcat,搜索 OkHttp 标签就能看到所有网络详情。
技巧二:使用Charles/Fiddler抓包
在设备上安装Charles的证书,或者通过adb reverse将设备的端口映射到电脑,用Charles拦截所有网络请求。这对于排查”为什么请求发出去了但没收到响应”特别有用。
# 将手机的8888端口映射到电脑
adb reverse tcp:8888 tcp:8888
然后设置Charles的代理为电脑IP的8888端口,就可以抓包了。
技巧三:本地Mock数据开发
在接口还没到位的时候,可以先用Mock服务器或本地JSON文件来开发。推荐用MockServer或者简单的本地HTTP服务器:
// MockApiService.kt - 用于开发阶段的Mock数据
object MockApiService {
fun getMockProfile(): UserProfile {
return UserProfile(
userId = "MOCK_001",
nickname = "开发模式用户",
avatar = "https://via.placeholder.com/150",
phone = "138****0000"
)
}
fun getMockLoginResponse(): LoginResponse {
return LoginResponse(
token = "mock_token_12345",
expiresIn = 7200,
userProfile = getMockProfile()
)
}
}
通过BuildConfig的Flavor或者开关来控制是否使用Mock数据,这样前后端可以并行开发,互不阻塞。
总结
前后端分离不是一种技术炫技,而是团队协作的必然选择。合理的接口设计让双方有章可循,规范的网络层架构让代码可维护,正确处理跨域问题让联调不再痛苦。
最关键的是记住:好的架构不是设计出来的,是在解决问题的过程中逐步演进出来的。不要一开始就追求完美的分层,先让项目跑起来,再逐步优化。我们上面给出的框架是一个经过多个项目验证的稳定方案,你可以根据实际项目规模做增减。
如果还有任何细节想深入探讨,随时问我就好。
