鸿蒙海量数据场景存储高级优化:分页游标查询/虚拟列表数据源/数据库懒加载/分层缓存策略
·


一、前置思考
1.1 海量数据场景的四大痛点
聊天记录、相册大图库、离线地图、商品列表——数据量一旦上万,常规实现全线崩溃:
痛点1: 一次性查 5 万条 → 内存爆炸 + 首屏 3 秒
痛点2: OFFSET 翻页 → 翻到第 500 页越来越慢
痛点3: 列表滑到底部加载 → 偶发白屏/跳动
痛点4: 全量数据反复查库 → 数据库成为瓶颈
1.2 海量数据的核心矛盾
数据全量 vs 界面增量
→ 数据可以很多, 但用户一次只看到一屏 (约 20 条)
→ 核心思路: 按需加载 + 增量渲染 + 分级缓存
查询方式 vs 翻页深度
→ LIMIT/OFFSET 深翻页慢 (要跳过前面所有行)
→ 游标分页 O(logN) 稳定 (记住位置)
数据源 vs 内存
→ 虚拟列表只渲染可视区 → 内存恒定
1.3 本文路线
给出游标分页、虚拟列表数据源、懒加载、分层缓存四件套,覆盖查询层、渲染层、缓存层。
二、核心原理
2.1 分页查询:OFFSET vs 游标
OFFSET 分页:
SELECT * FROM msg ORDER BY id DESC LIMIT 20 OFFSET 10000
→ 数据库必须扫描并丢弃前 10000 行
→ 翻页越深越慢 (O(N))
游标分页 (Keyset):
SELECT * FROM msg WHERE id < :lastId ORDER BY id DESC LIMIT 20
→ 利用主键索引 B-Tree 定位, 每页 O(logN)
→ 翻页深度不影响性能
| 方式 | 稳定性 | 深翻页 | 新增数据影响 |
|---|---|---|---|
| OFFSET | 不稳定 | 慢 | 翻页错位 |
| 游标 | 稳定 | 快 | 无影响 |
2.2 虚拟列表原理
普通列表: 渲染全部 5 万条 → 5 万个节点 → 内存爆炸
虚拟列表: 只渲染可视区 ± 缓冲区的节点
┌──────────────┐
│ 可视区 20条 │ ← 渲染
│ 缓冲 5条 │ ← 预渲染
│ (未渲染区域) │ ← 不创建节点
│ (未渲染区域) │
└──────────────┘
→ 无论数据多少, 节点数恒定 (~30)
2.3 数据库懒加载
懒加载 = 不加载 = 用到才加载
- 列表首屏: 只查第一页
- 滚动到底: 预取下一页
- 图片: 可视才解码 (Image 懒加载)
- 详情: 点击才查
2.4 分层缓存策略
L1 内存: 当前页 + 前后页 (LruCache)
L2 磁盘: 已加载过的数据块 (KV/文件)
L3 数据库: 全量权威数据
L4 远端: 首次数据源
滚动回看: L1 命中 → 秒开
重启回看: L2 命中 → 免重查
冷启动: L3 查询 + 游标分页
三、源码/API 深度解析
3.1 游标分页封装
import { relationalStore } from '@kit.ArkData';
interface PageResult<T> {
items: T[];
nextCursor: number | null; // 下一页游标, null 表示没有更多
}
class CursorPager<T> {
private readonly pageSize: number;
constructor(pageSize: number) { this.pageSize = pageSize; }
// 首屏: 无游标
async first(rdb: relationalStore.RdbStore): Promise<PageResult<T>> {
return this.next(rdb, null);
}
// 下一页: 携带游标
async next(rdb: relationalStore.RdbStore, cursor: number | null): Promise<PageResult<T>> {
let sql = 'SELECT * FROM msg';
const args: relationalStore.ValueType[] = [];
if (cursor !== null) {
sql += ' WHERE id < ?';
args.push(cursor);
}
sql += ' ORDER BY id DESC LIMIT ?';
args.push(this.pageSize + 1); // 多取1条判断是否还有更多
const rs = await rdb.querySql(sql, args);
const items: T[] = [];
while (rs.goToNextRow()) {
items.push(this.rowToItem(rs));
}
rs.close();
const hasMore = items.length > this.pageSize;
const pageItems = hasMore ? items.slice(0, this.pageSize) : items;
const lastId = pageItems.length > 0
? (pageItems[pageItems.length - 1] as unknown as { id: number }).id
: 0;
return {
items: pageItems as T[],
nextCursor: hasMore ? lastId : null
};
}
private rowToItem(rs: relationalStore.ResultSet): T {
// 业务字段映射
return {
id: rs.getLong(rs.getColumnIndex('id')),
content: rs.getString(rs.getColumnIndex('content'))
} as T;
}
}
3.2 虚拟列表数据源(LazyForEach)
// 鸿蒙 LazyForEach 天然支持虚拟列表
// 核心: 提供 ID 生成器 + 按需创建
class ChatDataSource {
private total: number;
constructor(total: number) { this.total = total; }
totalCount(): number { return this.total; }
getData(index: number): string {
// 从缓存/数据库按需加载该条数据
return `消息 ${index}: 第${index}条内容`;
}
getIndex(key: string): number { return parseInt(key); }
}
// 使用:
// LazyForEach(this.dataSource, (item: string) => { ... })
// 只有可视区的 item 会创建节点, 滑动时复用
3.3 懒加载 + 预取
// 滚动监听: 接近底部时预取下一页
// 在 List onReachEnd 中触发
onReachEnd: () => {
if (this.hasNext && !this.loading) {
this.loadNextPage(); // 游标分页拉下一页
}
}
// 预取策略: 距底部 3 屏时提前拉取
onScrollIndex: (first: number, last: number) => {
if (last > this.loadedCount - 30) { this.loadNextPage(); }
}
四、企业级实战落地
4.1 聊天记录流式加载
架构:
首屏: 拉最近 20 条 (游标 = maxId)
上滑加载更早: 游标 = 当前最旧 id, 继续拉
新消息: 顶部插入 + 滚动定位
图片/文件: 可视区才解码加载
缓存: 已加载页 → L1/L2, 重启秒回
4.2 相册大图库
数据: MediaLibrary 全量照片 (可能 10 万张)
渲染: 虚拟网格 (3列) + 懒加载缩略图
分页: 游标按时间倒序
缓存: 缩略图 L2 磁盘缓存 (键 = 照片id+尺寸)
详情: 点击才加载原图 (渐进式)
4.3 分级缓存实现
class TieredDataStore {
// L1: 内存页缓存
private pages: Map<number, any[]> = new Map(); // pageNo -> items
private readonly maxPages = 6; // 只保留前后几页
// L2: 磁盘块缓存
// L3: 数据库
async loadPage(pageNo: number): Promise<any[]> {
// L1
const mem = this.pages.get(pageNo);
if (mem) { return mem; }
// L2 (磁盘)
const disk = await this.readDisk(pageNo);
if (disk) {
this.cacheMem(pageNo, disk);
return disk;
}
// L3 (数据库游标查询)
const db = await this.queryDb(pageNo);
this.writeDisk(pageNo, db);
this.cacheMem(pageNo, db);
return db;
}
private cacheMem(pageNo: number, items: any[]): void {
this.pages.set(pageNo, items);
// LRU 淘汰: 超过 maxPages 删除最旧页
if (this.pages.size > this.maxPages) {
const oldest = this.pages.keys().next().value;
this.pages.delete(oldest);
}
}
}
4.4 性能基准(模拟)
| 方案 | 10万条查询 | 深翻页(第5000页) | 渲染节点数 |
|---|---|---|---|
| 全量加载 | 2.8s + 500MB内存 | - | 100000 |
| OFFSET 分页 | 0.3s/页 | 1.2s | 20+ |
| 游标分页 | 0.3s/页 | 0.3s | 20+ |
| 游标+虚拟列表 | 0.3s/页 | 0.3s | ~30 |
五、问题排查与性能优化
| 坑 | 现象 | 原因 | 解决 |
|---|---|---|---|
| 首屏卡 | 一次性查全部 | 全量加载 | 首屏只查一页 |
| 深翻页慢 | 越翻越卡 | OFFSET | 游标分页 |
| 内存暴涨 | 列表卡死 | 全量渲染 | LazyForEach 虚拟列表 |
| 图片闪烁 | 滑动反复加载 | 无图片缓存 | L2 磁盘缓存 |
| 翻页错位 | 新数据插入乱 | OFFSET | 游标 + 位置记忆 |
| 回看慢 | 上滑重新查 | 无内存缓存 | L1 页缓存 |
| 数据库热点 | 频繁全表查 | 无缓存 | 分层缓存 |
5.1 滚动位置保持
场景: 看第 1000 条, 杀进程重启 → 回到顶部体验差
方案: 记录当前游标/索引到 KV → 重启恢复
5.2 缓存与数据一致性
新消息插入 → 只影响首页, 旧页缓存不受影响
删除消息 → 游标分页天然容忍 (id 不存在就跳过)
数据更新 → 该条所在的页缓存失效, 重查该页
5.3 图片/媒体懒加载规范
列表: 只加载缩略图 (200px), 虚拟列表可视区预取
详情: 渐进式加载原图 (先模糊后清晰)
内存: 缩略图走 L1 小容量, 原图不缓存
磁盘: 缩略图 L2 长 TTL, 原图短 TTL
六、高阶总结与最佳实践
- 查询用游标分页:OFFSET 深翻页必慢,游标基于索引 O(logN) 稳定。
- 渲染用虚拟列表:LazyForEach 只渲染可视区,10 万条与 100 条节点数一样。
- 加载用懒加载:首屏一页、滚动预取、图片可视才解码。
- 缓存用分层:L1 内存页缓存 + L2 磁盘块缓存 + L3 数据库 + L4 远端。
- 体验细节:滚动位置保持、新消息顶部插入、图片渐进式加载。
一句话记住:查数据用游标、渲染用虚拟、加载用懒、缓存用分层——海量数据的秘诀是"永远只处理用户看得见的那一屏"。
更多推荐



所有评论(0)