3. 接口响应数据模型设计:数据类定义、字段映射、@SerializedName、嵌套对象

3.1 数据类在 Retrofit 中的角色

Retrofit 通过转换器(Converter)将 HTTP 响应体 JSON 自动反序列化为 Kotlin 数据类实例。数据类定义了响应中每个字段的类型、名称和默认值,是整个网络层的数据契约。设计不当将直接导致解析失败、字段丢失、空指针或运行时异常。

角色说明
类型安全契约编译期确定字段类型,Gson/Moshi 按此映射 JSON 值
自动序列化/反序列化无需手动解析 JSONObject/JSONArray
嵌套结构表达数据类内部可嵌套其他数据类,对应 JSON 对象层级
空安全与默认值Kotlin 的可空类型与默认值处理服务端字段缺失

3.2 字段映射原理与 @SerializedName

默认情况下,Gson 将 JSON 的 key 与数据类的属性名按字符完全匹配。但服务端接口常用下划线命名法(snake_case),而 Kotlin 代码习惯 驼峰命名法(camelCase)。@SerializedName 注解用于建立两者之间的映射关系。

// 服务端返回示例
{
  "user_id": 1001,
  "nick_name": "张三",
  "avatar_url": "https://example.com/avatar.png"
}

// Kotlin 数据类定义
data class UserInfo(
    @SerializedName("user_id")
    val userId: Int,
    @SerializedName("nick_name")
    val nickName: String,
    @SerializedName("avatar_url")
    val avatarUrl: String?
)

3.3 嵌套对象与层级建模

服务端返回的 JSON 通常包含多层嵌套,数据类必须逐层对应。

// JSON 结构
{
  "code": 200,
  "message": "success",
  "data": {
    "id": 42,
    "profile": {
      "real_name": "李四",
      "age": 28
    }
  }
}

// Kotlin 数据类嵌套定义
data class ApiResponse<T>(
    val code: Int,
    val message: String,
    val data: T?
)

data class UserDetail(
    val id: Long,
    val profile: Profile
)

data class Profile(
    @SerializedName("real_name")
    val realName: String,
    val age: Int
)
⚠️ 注意:嵌套深度建议控制在 3 层以内,过深增加解析开销且难以维护。对于通用分页结构,可提取为独立数据类复用。

3.4 字段映射流程(内联 SVG)

@SerializedName 字段映射流程 JSON 响应体 (snake_case) "user_id" "nick_name" "avatar_url" "profile" → 嵌套对象 "real_name" "age" @SerializedName user_id → userId nick_name → nickName avatar_url → avatarUrl real_name → realName Kotlin 数据类 (camelCase) val userId: Int val nickName: String val avatarUrl: String? val realName: String val age: Int 通过 @SerializedName 将 snake_case JSON key 映射为 Kotlin 风格的 camelCase 属性名

3.5 常见错误与避坑指南

错误模式现象解决方案
字段名不匹配对应字段为 null 或默认值使用 @SerializedName 明确映射服务端 key
声明为非空类型但 JSON 缺失反序列化抛出 NullPointerException使用可空类型 String? 并设置默认值
嵌套对象类型不匹配解析失败,data 为 null嵌套必须定义为内部 data class,且字段类型精确
泛型擦除导致转换异常运行时类型丢失使用 object : TypeToken<ApiResponse<UserData>>() {}.type 显式传递类型
字段名含关键字 (如 class)编译错误使用 @SerializedName 并改属性名,如 clazz

3.6 调试技巧:日志验证字段映射

在开发阶段,通过 OkHttp 日志拦截器打印完整响应体,再与数据类期望结构逐一比对。

// 在 OkHttpClient 配置中添加日志拦截器
val loggingInterceptor = HttpLoggingInterceptor().apply {
    level = HttpLoggingInterceptor.Level.BODY
}
val client = OkHttpClient.Builder()
    .addInterceptor(loggingInterceptor)
    .build()

// 日志输出示例
{
  "user_id": 1001,
  "nick_name": "张三",
  "avatar_url": null
}

// 对应数据类字段梳理
userId   ← "user_id"      ✓
nickName ← "nick_name"    ✓
avatarUrl ← "avatar_url"  ? 允许 null
💡 调试建议:在 Android Studio 的 Debug 视图下展开 Retrofit 返回的 Response.body(),查看每个字段的值是否与预期一致。若发现字段为 null,优先检查 @SerializedName 拼写和 JSON key 大小写。

3.7 性能与稳定性优化

  • 按需定义字段:只声明业务需要的字段,忽略无用字段可减少解析时间与内存占用。
  • 避免过深嵌套:嵌套深度 > 3 层时考虑使用 @JsonAdapter 自定义反序列化,或扁平化结构。
  • 使用 @SerializedName 减少冗余:对频繁出现的长 key 映射为短属性名,提升代码可读性。
  • 统一基类处理通用字段:将 code、message 等通用字段提取到 BaseResponse,使用泛型传递 data。
// 通用基类示例
open class BaseResponse(
    val code: Int = -1,
    val message: String = ""
)

data class UserListResponse(
    val data: List<UserInfo>? = null
) : BaseResponse()

3.8 完整示例:多嵌套数据模型

// 服务端原始响应
{
  "status": 0,
  "data": {
    "list": [
      {
        "product_id": "P001",
        "product_name": "智能手表",
        "price_info": {
          "current": 299.00,
          "original": 399.00,
          "currency": "CNY"
        }
      }
    ],
    "page": {
      "total": 1,
      "page_size": 20,
      "current_page": 1
    }
  }
}

// Kotlin 数据模型
data class ProductListResponse(
    val status: Int,
    val data: ProductListData?
)

data class ProductListData(
    val list: List<Product>,
    val page: PageInfo
)

data class Product(
    @SerializedName("product_id")
    val productId: String,
    @SerializedName("product_name")
    val productName: String,
    @SerializedName("price_info")
    val priceInfo: PriceInfo
)

data class PriceInfo(
    val current: Double,
    val original: Double,
    val currency: String
)

data class PageInfo(
    val total: Int,
    @SerializedName("page_size")
    val pageSize: Int,
    @SerializedName("current_page")
    val currentPage: Int
)
⚠️ 特别注意:嵌套数据类中的字段如果预期服务端一定返回,则使用非空类型;若可能缺失(如价格历史字段偶发),必须使用可空类型 Double? 或设置默认值 0.0,避免整体解析失败。