在这里插入图片描述
在这里插入图片描述

实例:影音收藏夹(Media)|技术:影音字段、分类/评分/状态、MediaDao
对应代码:entry/src/main/ets/database/MediaDao.ets(已编译通过)

一、业务需求分析:影音收藏的数据形态

影音收藏夹(电影/剧集/综艺/纪录片)是内容管理类应用——数据形态与前面的「工具类」(待办、记账)不同:每条记录是「作品」,有类型、评分、状态等丰富的描述属性

核心业务需求:

  1. 收藏作品:标题、类型(电影/剧集/综艺/纪录片)、题材、评分、状态(想看/在看/已看)、年份;
  2. 分类筛选:只看「电影」或「剧集」——IN 多值筛选(一次选多个类型);
  3. 评分排序:按评分降序——高分作品在前(ORDER BY rating DESC);
  4. 状态管理:想看 → 在看 → 已看 的状态切换(追剧进度);
  5. 多条件组合:类型 + 评分下限 + 状态的组合查询——高级筛选。

二、字段设计:作品的「描述性」字段

影音表 media

字段名 类型 约束 说明
id INTEGER PRIMARY KEY AUTOINCREMENT 自增主键
title TEXT NOT NULL 作品标题
type TEXT NOT NULL 类型(电影/剧集/综艺/纪录片)
genre TEXT DEFAULT ‘’ 题材(科幻/喜剧/悬疑…)
rating REAL NOT NULL DEFAULT 0 评分 0~10
poster TEXT DEFAULT ‘🎬’ 占位海报 emoji
status INTEGER NOT NULL DEFAULT 0 0 想看 / 1 在看 / 2 已看
year INTEGER NOT NULL DEFAULT 2020 上映年份
remark TEXT DEFAULT ‘’ 备注
created_time INTEGER NOT NULL 收藏时间戳

设计要点拆解

1. type 用 TEXT 存分类名。电影/剧集/综艺/纪录片——为什么不用数字枚举?因为 type 要参与 IN 筛选IN ('电影', '剧集'))和分类分组GROUP BY type),字符串直接用,语义自明。当枚举值「本身就是展示文案」且需要可读筛选时,用文本更直接——这与记账本 type(参与计算)不同,判断标准还是「是否参与聚合计算」。

2. rating 用 REAL 存 0~10 评分。评分是「排序键」——ORDER BY rating DESC 高分在前。REAL 支持小数(8.3、9.4),比整数精确。页面用「★」字符画星级(★☆☆☆☆)展示。

3. status 用 0/1/2 数字。想看/在看/已看——追剧状态机,参与过滤(WHERE status=1),数字编码。状态切换逻辑:想看→在看→已看→想看(循环)。

4. poster 用 emoji 占位。真实影音存海报 URL,Demo 用 emoji(🌍☢️🏜️🎤)占位——每部作品一个贴切 emoji(流浪地球 🌍、奥本海默 ☢️),海报墙有「封面感」。

5. genre 与 year 增强描述。题材(科幻/悬疑)+ 上映年份——让卡片信息丰富,也是潜在筛选维度(按年份、按题材)。

三、建表 SQL 与索引

CREATE TABLE IF NOT EXISTS media (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  type TEXT NOT NULL,
  genre TEXT DEFAULT '',
  rating REAL NOT NULL DEFAULT 0,
  poster TEXT DEFAULT '🎬',
  status INTEGER NOT NULL DEFAULT 0,
  year INTEGER NOT NULL DEFAULT 2020,
  remark TEXT DEFAULT '',
  created_time INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_media_rating ON media (rating);

idx_media_rating 索引:服务核心排序 ORDER BY rating DESC——按评分倒序是收藏列表的默认视图,索引让排序走 B+ 树。

四、MediaDao 封装:核心查询方法族

数据层核心 MediaDao,查询方法族围绕「类型 + 评分 + 状态」三个维度组合:

全部收藏(评分降序)

static async queryAll(context: common.Context): Promise<MediaItem[]> {
  const store = await MediaDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(MediaDao.TABLE);
  predicates.orderByDesc('rating').orderByDesc('created_time');
  const result = await store.query(predicates);
  return MediaDao.collect(result);
}

评分降序 + 同分按收藏时间倒序——双键排序(先评分后时间)。

IN 分类筛选(一次多类型)

static async queryByTypes(context: common.Context, types: string[]): Promise<MediaItem[]> {
  const store = await MediaDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(MediaDao.TABLE);
  predicates.in('type', types).orderByDesc('rating');
  const result = await store.query(predicates);
  return MediaDao.collect(result);
}

predicates.in('type', types) 是 IN 多值筛选——等价于 WHERE type IN ('电影', '剧集')。这是本实例的核心技术点:一次筛选多个分类,比多次 OR 拼接简洁,比「分类选一个」灵活。

按状态筛选

static async queryByStatus(context: common.Context, status: number): Promise<MediaItem[]> {
  const store = await MediaDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(MediaDao.TABLE);
  predicates.equalTo('status', status).orderByDesc('rating');
  const result = await store.query(predicates);
  return MediaDao.collect(result);
}

多条件组合查询(类型 + 评分下限 + 状态)——本实例的集大成者:

static async queryFilter(context: common.Context, types: string[], minRating: number, status: number): Promise<MediaItem[]> {
  const store = await MediaDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(MediaDao.TABLE);
  predicates.in('type', types).greaterThanOrEqualTo('rating', minRating);
  if (status >= 0) {
    predicates.equalTo('status', status);
  }
  predicates.orderByDesc('rating').orderByDesc('created_time');
  const result = await store.query(predicates);
  return MediaDao.collect(result);
}

三条件组合的链式写法in('type', ...) + greaterThanOrEqualTo('rating', ...) + 可选 equalTo('status', ...)——IN + 范围 + 等值的组合覆盖了筛选的大多数场景。status >= 0 守卫让状态可空(-1 表示不过滤)。

五、CRUD 与统计

新增收藏

static async insert(context: common.Context, m: MediaItem): Promise<number> {
  const store = await MediaDao.getStore(context);
  const values: relationalStore.ValuesBucket = {
    title: m.title, type: m.type, genre: m.genre, rating: m.rating,
    poster: m.poster, status: m.status, year: m.year,
    remark: m.remark, created_time: m.createdTime,
  };
  return await store.insert(MediaDao.TABLE, values);
}

更新收藏(编辑,含状态切换——想看→在看→已看 由页面计算新状态后全量更新):

static async update(context: common.Context, m: MediaItem): Promise<number> {
  const store = await MediaDao.getStore(context);
  const values: relationalStore.ValuesBucket = {
    title: m.title, type: m.type, genre: m.genre, rating: m.rating,
    poster: m.poster, status: m.status, year: m.year, remark: m.remark,
  };
  const predicates = new relationalStore.RdbPredicates(MediaDao.TABLE);
  predicates.equalTo('id', m.id);
  return await store.update(values, predicates);
}

收藏总数与平均分(标题栏统计 + 平均分展示):

static async count(context: common.Context): Promise<number> {
  // COUNT(*)
}

static async avgRating(context: common.Context): Promise<number> {
  const store = await MediaDao.getStore(context);
  const result = await store.querySql(`SELECT AVG(rating) AS avg FROM ${MediaDao.TABLE}`);
  let avg = 0;
  if (result.goToNextRow()) {
    avg = result.getDouble(result.getColumnIndex('avg'));
  }
  result.close();
  return avg;
}

AVG(rating) 是 SQLite 聚合函数——标题栏「共 16 部作品 · 均分 8.6」的平均分来自它。聚合函数第一次以 AVG 出现(前面实例用过 SUM/COUNT/GROUP BY),AVG 将在健康数据实例(14)大放异彩。

六、技术要点对照表

技术点 实现方式 生产价值
IN 筛选 predicates.in(‘type’, types) 一次多分类
评分排序 orderByDesc(‘rating’) 高分在前
评分索引 idx_media_rating 排序走索引
多条件组合 IN + 范围 + 等值链式 高级筛选
状态机 status 0/1/2 循环切换 追剧进度
AVG 聚合 AVG(rating) 平均分展示

七、文章小结

影音收藏夹的数据层核心是**「类型 + 评分 + 状态」三维度 + IN 多值筛选**:type 用文本直接参与 IN 筛选、rating 驱动评分降序排序(带索引)、status 数字编码驱动状态过滤,三者的链式组合构成多条件查询。AVG 聚合函数首次登场(平均分统计)。这是内容管理类应用的通用数据模型——作品库、书单、歌单都是同一形态。

下一篇(13-2)展示海报墙网格 UI——分类筛选条 + 评分卡 + 状态切换,像流媒体首页。

八、字段选型:type 用文本还是数字枚举

维度 TEXT 存分类名 INTEGER 存枚举值
语义可读性 库里直接是「电影」 0/1/2 需查映射表
IN 多值筛选 IN ('电影', '剧集') IN (0, 1) 均可
GROUP BY 分组结果 直接可读 数字需二次翻译
存储体积 略大 最小
新增分类 直接插新文本 需加映射、易漏

判断标准还是那句:是否参与聚合计算。记账本的 type 参与 SUM 分类汇总,用数字便于计算;影音的 type 只做筛选和分组、结果直接上屏,用文本让 SQL 与调试输出都可读。若未来 type 参与统计(如「各类型均分」),GROUP BY type 出来的仍是可读文案,无需改表。

九、rating REAL 与排序细节

REAL 支持 8.3、9.4 这类小数评分,配合 ORDER BY rating DESC 实现「高分在前」。两个使用注意:

  1. 评分是排序键,等值比较要谨慎:REAL 是浮点,WHERE rating = 8.6 可能因浮点误差匹配不到,筛选一律用 greaterThanOrEqualTo 范围比较;
  2. 双键排序保证稳定:同分作品按 created_time 倒序,海报墙顺序不抖动。

页面星级用四舍五入把 10 分制转 5 星:

function stars(rating: number): string {
  const n = Math.round(rating / 2);   // 8.6 → 4 星
  return '★'.repeat(n) + '☆'.repeat(5 - n);
}

十、状态机的字段设计

status 用数字编码,因为它是「程序逻辑状态」而非展示文案——文案(想看/在看/已看)由页面 statusNames 数组映射:

编码 状态 展示 过滤
0 想看 想看 WHERE status = 0
1 在看 在看 WHERE status = 1
2 已看 已看 WHERE status = 2

循环切换由页面算好新状态再更新,数据层只需一次普通 update

// 状态 + 按钮:想看 → 在看 → 已看 → 想看
m.status = (m.status + 1) % 3;
await MediaDao.update(context, m);

数字编码的扩展性:未来加「弃剧」只追加编码 3 和文案映射,已有数据零迁移;若用文本存状态,改名就要全表 UPDATE。

十一、poster emoji 占位的扩展

emoji 是「零成本海报」,语义自明且无需图片资源。真实项目的扩展路径:poster 字段存图片 URL,表结构零改动——TEXT 字段内容从 emoji 换成 https://... 即可。页面按内容分支渲染,老数据 emoji 兜底不破图:

if (item.poster.startsWith('http')) {
  Image(item.poster).width('100%').aspectRatio(2 / 3);
} else {
  Text(item.poster).fontSize(48);   // 老数据 emoji 兜底
}

进阶方向:本地 file:// 路径(下载缓存)、九宫格海报墙(Grid)、加载失败占位图。

十二、索引设计回顾

索引 服务查询 价值
idx_media_rating ORDER BY rating DESC 默认视图排序走索引
主键 id WHERE id = ? 更新/删除 PRIMARY KEY 自带
(可选)idx_media_type WHERE type IN (…) 类型筛选频繁时加

取舍原则:收藏夹读多写少,索引的写放大可忽略;若双键排序成为唯一默认视图,可升级为联合索引 (rating, created_time) 一次覆盖两个排序键。

十三、FAQ

Q1:type 为什么不直接用数字枚举?
因为 type 不参与计算,文本让 IN 筛选、GROUP BY、调试输出都可读;「参与聚合计算才用数字」是贯穿全系列的判断标准。

Q2:评分为什么用 REAL 不用 INTEGER?
评分有小数(8.3/9.4),REAL 才能精确表达;且评分是排序键,小数精度直接决定排序结果。

Q3:状态切换只改 status 字段可以吗?
可以,用 update + equalTo('id', ...) 条件只更新 status 列即可;本实例为代码简洁全量更新,效果等价。

Q4:emoji 海报什么时候换真实图片?
接入真实片源时。poster 字段类型不变,只换内容为 URL,页面加 http 分支渲染即可,迁移成本极低。

Q5:排序为什么加 created_time 第二键?
保证同分作品顺序稳定,避免海报墙刷新后顺序抖动;双键排序是列表类应用的通用防御写法。

Q6:索引会不会拖慢写入?
会有一点写放大,但收藏夹是读多写少场景,idx_media_rating 的读收益远大于写成本;避免给每个列都建索引即可。
在这里插入图片描述

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐