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

一、前置思考

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

六、高阶总结与最佳实践

  1. 查询用游标分页:OFFSET 深翻页必慢,游标基于索引 O(logN) 稳定。
  2. 渲染用虚拟列表:LazyForEach 只渲染可视区,10 万条与 100 条节点数一样。
  3. 加载用懒加载:首屏一页、滚动预取、图片可视才解码。
  4. 缓存用分层:L1 内存页缓存 + L2 磁盘块缓存 + L3 数据库 + L4 远端。
  5. 体验细节:滚动位置保持、新消息顶部插入、图片渐进式加载。

一句话记住:查数据用游标、渲染用虚拟、加载用懒、缓存用分层——海量数据的秘诀是"永远只处理用户看得见的那一屏"。

Logo

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

更多推荐