12.03 数据解析-数据模型设计

2025/5/16 Android 开发基础

12.01、12.02 解决了"怎么解析",本节解决解析的另一半——解析出来的东西长什么样。

数据模型(DTO)设计是解析工作的上层建筑:接口对接的摩擦、UI 层的判空地狱、服务端改字段的连锁修改,绝大部分根源都在模型设计上。本节给出分层的架构图、七条设计原则、一个完整优秀实践案例与反例对照。

# 一、DTO / VO 分层:为什么要拆两层

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
解析失败让整页空白崩溃 用户看到白屏 原则七 宽容解析兜底

# 五、常见问题

  1. "小项目要不要分层?"——单一接口直接 DTO 到 UI 没问题;判断标准是"服务端字段会不会变、UI 要不要复用数据",满足任一就开始拆 Mapper。
  2. "DTO 之间字段大量重复怎么办?"——正常的:DTO 按接口建模(不共享),VO 按页面建模(可共享);重复发生在 DTO 层是设计使然。
  3. "Room 实体和 DTO 什么关系?"——独立第三层(缓存持久化模型),Room→VO 由 DAO 层映射;不要让 JSON 的 DTO 直接做 Room @Entity(生命周期与字段形状都不同,见 09 数据存储 的现代衔接节)。
  4. "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 图片加载。