12.03 数据解析-数据模型设计
12.01、12.02 解决了"怎么解析",本节解决解析的另一半——解析出来的东西长什么样。
数据模型(DTO)设计是解析工作的上层建筑:接口对接的摩擦、UI 层的判空地狱、服务端改字段的连锁修改,绝大部分根源都在模型设计上。本节给出分层的架构图、七条设计原则、一个完整优秀实践案例与反例对照。
# 一、DTO / VO 分层:为什么要拆两层
| 层 | 角色 | 特点 |
|---|---|---|
| DTO(Data Transfer Object) | 服务端 JSON 的忠实映射 | 字段名/类型/结构完全跟随接口文档,用 @SerializedName 做映射,无任何业务逻辑 |
| Mapper | DTO → VO 的唯一翻译点 | 纯函数:入参 DTO 出参 VO,时间格式化、状态计算等"翻译"都在这里 |
| VO(View Object,业务/UI 模型) | 按 UI 需要重组的数据 | 字段名/类型以UI 语义为准(dateText、isTop),可直接绑进列表 |
核心思想:服务端字段是不稳定的外部依赖(改字段名、调结构、多版本兼容都要发生在这里),UI 是频繁变化的需求方。两层之间隔一个 Mapper,变化就被隔离在映射层——服务端改字段只动 DTO,需求改展示只动 VO/Mapper,两边永不互相传染。
小项目/单一接口可以直接 DTO 顶到 UI;但只要满足"接口多、字段复杂、团队 >1 人"任一条件,分层的维护收益立刻超过成本。
# 二、七条设计原则
# 原则一:可空语义——包装类型表达"缺失"
// ✘ 差:基本类型把"缺失"吞成 0/false
public class UserDto {
private int age; // 后端没返回时 = 0 —— "0岁"还是"没填"?
private boolean isVip; // false —— 没返回还是真不是VIP?
}
// ✔ 好:包装类型保留三态(有值/null/默认)
public class UserDto {
private Integer age; // null = 后端未返回
private Boolean vip;
// Mapper里做兜底:vo.setAgeText(user.age == null ? "未知" : user.age + "岁")
}
判据:字段语义上存在"未提供/未知"状态 → 包装类型;确定必有(如 id)→ 基本类型。
# 原则二:集合预初始化——消灭 UI 判空
// ✘ 差:tags 可能是 null,UI 层 if (user.tags != null && !user.tags.isEmpty()) 到处都是
private List<String> tags;
// ✔ 好:声明即初始化,null 永远不会出现
private List<String> tags = new ArrayList<>();
集合字段缺失时 Gson 不会赋值,字段保持初始化的空集合——null 检查集中在模型里做一次,而不是在 UI 里做 N 次。
# 原则三:映射策略全项目统一
字段名摩擦(12.01)必须在模型层一次性解决,二选一并写进团队规范:
// 策略A:显式逐字段映射(推荐——字段的"出身"一目了然)
@SerializedName("art_title")
private String title;
// 策略B:全局命名策略(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
// 适合服务端规范稳定的项目;遇到特例仍需逐字段处理
new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
禁止混用:一半字段靠全局策略、一半靠 @SerializedName,排查字段对不上时会疯。
# 原则四:嵌套建模,拒绝巨平铺类
// ✘ 差:把嵌套结构拍平成一个大类,字段冗余且语义丢失
public class OrderDto {
private long orderId;
private String userName; // 收件人姓名
private String userPhone;
private String addressCity;
private String addressStreet; // 三组字段实际是三个对象
}
// ✔ 好:按语义域嵌套
public class OrderDto {
private long orderId;
private UserBrief user; // {name, phone}
private Address address; // {city, street}
}
嵌套深度过深(>4 层)时可做受控扁平化:DTO 保持服务端结构,Mapper 输出扁平 VO。
# 原则五:UI 语义进 VO,View 不做计算
// ✘ 差:每个用到时间的地方都写一遍格式化+判空
// 三个Fragment里三份 SimpleDateFormat —— 格式不一致、时区bug、重复代码
String text = new SimpleDateFormat("MM-dd").format(new Date(dto.publishTime * 1000));
// ✔ 好:翻译一次,到处使用
public class ArticleVo {
public String dateText; // Mapper 已格式化:"11-14" / "昨天" / "3天前"
public boolean isTop; // 业务判断结果(如 status==2 置顶)
public String authorName; // 兜底后的展示名
}
# 原则六:不可变倾向
字段不可变 = 消灭"某处悄悄改了数据"这类 bug:
// Kotlin 首选:data class + val(20章后回头复习)
data class ArticleVo(
val id: Long,
val title: String,
val dateText: String,
val isTop: Boolean = false // VO层给默认值
)
// Java 的务实写法:字段 final + 构造注入(Gson反射赋值要求无final限制——
// 实践中Java端DTO允许非final,VO尽量final)
public final class ArticleVo {
public final long id;
public final String title;
public ArticleVo(long id, String title) { this.id = id; this.title = title; }
}
# 原则七:版本兼容思维
- 服务端新增字段 → 对旧客户端无害(Gson 忽略未知 key),DTO 不用改
- 服务端删字段/改类型 → 破坏性变更,客户端旧版本解析失败——接口评审时盯这两点,或后端保证只增不改
- 客户端做宽容解析:关键字段缺失时给默认值(Mapper 兜底),而不是让整页崩掉
# 三、完整优秀实践案例:文章列表接口
从服务端 JSON 到 RecyclerView 可用的列表,端到端示例。
# 3.1 服务端接口(真实感十足的不规范字段)
{
"status": 0,
"message": "success",
"data": {
"has_more": true,
"next_start": 20,
"article_list": [
{
"art_id": 101,
"art_title": "Handler 消息机制原理",
"author": {"name": "JiangMing", "verified": true},
"publish_time": 1731561600,
"status": 2
}
]
}
}
# 3.2 DTO 层:忠实映射(含包装类型/集合初始化)
/**
* 文章列表接口DTO——字段结构完全跟随服务端.
*/
public class ArticleListDto {
private int status; // 0=成功
private String message;
private DataBean data;
public static class DataBean {
private Boolean hasMore = false; // 原则一:包装类型
private Integer nextStart = 0;
// 原则二:集合预初始化
private List<ArticleDto> articleList = new ArrayList<>();
}
public static class ArticleDto {
@SerializedName("art_id")
private Long id; // 原则三:显式映射
@SerializedName("art_title")
private String title;
private AuthorBean author;
@SerializedName("publish_time")
private Long publishTime; // 秒级时间戳
private Integer status; // 2=置顶(业务码)
public static class AuthorBean {
private String name;
private Boolean verified = false;
}
}
}
# 3.3 VO 层:UI 语义模型
/**
* 文章列表VO——RecyclerView直接可绑定的形态.
*/
public final class ArticleVo {
public final long id;
public final String title;
public final String authorName; // 兜底后的展示名
public final boolean authorVerified;
public final String dateText; // 已格式化:"今天 10:20" / "11-14"
public final boolean isTop; // 置顶标记(UI角标)
public ArticleVo(long id, String title, String authorName,
boolean authorVerified, String dateText, boolean isTop) {
this.id = id;
this.title = title;
this.authorName = authorName;
this.authorVerified = authorVerified;
this.dateText = dateText;
this.isTop = isTop;
}
}
# 3.4 Mapper:唯一翻译点(纯函数,单测友好)
/**
* DTO→VO 映射器:所有"翻译"集中于此.
*/
public final class ArticleMapper {
private ArticleMapper() { }
// 单条映射:空值兜底全部在这做
public static ArticleVo toVo(ArticleListDto.ArticleDto dto) {
if (dto == null) {
return null;
}
long id = dto.id != null ? dto.id : 0L;
String title = dto.title != null ? dto.title : "(无标题)";
String authorName = (dto.author != null && dto.author.name != null)
? dto.author.name : "匿名作者";
boolean verified = dto.author != null
&& Boolean.TRUE.equals(dto.author.verified);
String dateText = formatTime(dto.publishTime); // 时间翻译
boolean isTop = dto.status != null && dto.status == 2; // 业务码→UI标记
return new ArticleVo(id, title, authorName, verified, dateText, isTop);
}
// 列表映射:null安全的批量转换
public static List<ArticleVo> toVoList(List<ArticleListDto.ArticleDto> dtoList) {
List<ArticleVo> result = new ArrayList<>();
if (dtoList == null) {
return result;
}
for (ArticleListDto.ArticleDto dto : dtoList) {
ArticleVo vo = toVo(dto);
if (vo != null) {
result.add(vo);
}
}
return result;
}
// 时间戳→展示文案(业务规则集中一处)
private static String formatTime(Long seconds) {
if (seconds == null || seconds <= 0) {
return "";
}
long now = System.currentTimeMillis() / 1000;
long diffDays = (now - seconds) / 86400;
if (diffDays <= 0) {
return new java.text.SimpleDateFormat("今天 HH:mm", java.util.Locale.CHINA)
.format(new java.util.Date(seconds * 1000));
}
return new java.text.SimpleDateFormat("MM-dd", java.util.Locale.CHINA)
.format(new java.util.Date(seconds * 1000));
}
}
# 3.5 组装:解析 → 映射 → 分页信息
/**
* 列表页数据装配:解析结果转成"UI可用的页面数据".
*/
public final class ArticlePageVo {
public final List<ArticleVo> articles; // 已预初始化,永不为null
public final boolean hasMore; // 分页标记
public final int nextStart;
public ArticlePageVo(ArticleListDto dto) {
ArticleListDto.DataBean data = dto != null ? dto.data : null;
this.articles = data != null
? ArticleMapper.toVoList(data.articleList) : new ArrayList<>();
this.hasMore = data != null && Boolean.TRUE.equals(data.hasMore);
this.nextStart = data != null && data.nextStart != null ? data.nextStart : 0;
}
}
// 网络层调用(11章Retrofit + 10章线程范式)
ThreadPoolManager.execute(() -> {
Response<ArticleListDto> resp = RetrofitManager.getArticleApi()
.getArticles(page).execute(); // 子线程同步调用
if (resp.isSuccessful() && resp.body() != null && resp.body().status == 0) {
ArticlePageVo pageVo = new ArticlePageVo(resp.body()); // 装配VO
new Handler(Looper.getMainLooper()).post(() ->
adapter.submitList(pageVo.articles)); // 切主线程
}
});
# 3.6 单元测试:Mapper 是纯函数,测起来零依赖
@Test
public void toVo_normalFields_mapped() {
ArticleListDto.ArticleDto dto = new ArticleListDto.ArticleDto();
dto.id = 101L; dto.title = "标题"; dto.publishTime = 1731561600L; dto.status = 2;
ArticleVo vo = ArticleMapper.toVo(dto);
assertEquals(101L, vo.id);
assertEquals("标题", vo.title);
assertTrue(vo.isTop); // status=2 → 置顶
assertFalse(vo.dateText.isEmpty()); // 时间已格式化
}
@Test
public void toVo_nullFields_fallbackApplied() {
ArticleVo vo = ArticleMapper.toVo(new ArticleListDto.ArticleDto());
assertEquals("(无标题)", vo.title); // 缺失兜底
assertEquals("匿名作者", vo.authorName);
}
这就是"优秀实践"的完整闭环:接口变化 → 只改 DTO/Mapper(有单测保护)→ UI 无感;UI 需求变化 → 只改 VO/Mapper → 数据层无感。
# 四、反例对照表
| 反面写法 | 后果 | 对应原则 |
|---|---|---|
UI 层直接拿 dto.data.articleList.get(0).author.name | 四层判空,漏一层就 NPE | 分层 + Mapper 兜底 |
int status 接"缺失"语义 | "没数据"和"0"混淆 | 原则一 包装类型 |
List<Tags> tags; 不初始化 | UI 判空散落 | 原则二 集合预初始化 |
全局命名策略 + 个别 @SerializedName 混用 | 字段对不上时无从排查 | 原则三 映射统一 |
| 一个 40 字段的"万能 DTO"全项目共用 | 任何接口改动全项目编译告警 | 原则四 按接口建模 |
Fragment 里 SimpleDateFormat 格式化时间 | 三处三格式、时区 bug | 原则五 语义进 VO |
| 解析失败让整页空白崩溃 | 用户看到白屏 | 原则七 宽容解析兜底 |
# 五、常见问题
- "小项目要不要分层?"——单一接口直接 DTO 到 UI 没问题;判断标准是"服务端字段会不会变、UI 要不要复用数据",满足任一就开始拆 Mapper。
- "DTO 之间字段大量重复怎么办?"——正常的:DTO 按接口建模(不共享),VO 按页面建模(可共享);重复发生在 DTO 层是设计使然。
- "Room 实体和 DTO 什么关系?"——独立第三层(缓存持久化模型),Room→VO 由 DAO 层映射;不要让 JSON 的 DTO 直接做 Room
@Entity(生命周期与字段形状都不同,见 09 数据存储 的现代衔接节)。 - "Java 写这么多 Mapper 类太啰嗦?"——Kotlin 的 data class + 扩展函数
fun ArticleDto.toVo(): ArticleVo能把 Mapper 压缩到接近零成本(20 Kotlin 基础 展开采用此写法)。
# 结束
本节把数据模型设计单独成篇:DTO/VO/Mapper 三层架构(配图:服务端不稳定字段被隔离在映射层)、七条设计原则(可空语义、集合预初始化、映射统一、嵌套建模、UI 语义进 VO、不可变倾向、版本兼容),以及从服务端 JSON 到 RecyclerView 的端到端优秀实践案例(DTO → Mapper → VO → 装配 → 单测)与反例对照表。一句话总结:让变化只发生在 Mapper 一处。下一节:12.04 图片加载。