大家好我是鸿蒙Jack,本期以我的《时光旅记》APP为例,讲一下我是怎么在"回忆时间轴"里接入 LazyForEach 的。
这个点看起来只是把 ForEach 换成 LazyForEach,但真实项目里没那么简单。《时光旅记》的时间轴不是一行文字列表,它里面有月份分组、回忆卡片、封面图、预览图、心情标记、地点、小本名称、日历模式、筛选状态和滚动位置恢复。如果用户长期使用,瞬间数量会越来越多,一次性把所有卡片和图片组件都构建出来,内存压力和首屏渲染压力都会变大。
所以我在时间轴页做了一层"行数据适配":先把业务数据 MomentTimelineItem 转成统一的 TimelineLazyRow,再交给 LazyForEach + IDataSource 按可视区域创建组件。这样列表滚动时只维护当前屏幕附近的节点,离开可视区域的节点由框架回收,图片也只在可见时真正加载。
这里放一张效果图:

我选的典型场景
这次我不讲所有 LazyForEach 使用点,只讲时间轴页。项目里实际代码在 entry/src/main/ets/pages/timeline/TimelinePage.ets,核心是这几块:
arkts
class TimelineLazyListDataSource implements IDataSource
arkts
private timelineLazyDataSource: TimelineLazyListDataSource = new TimelineLazyListDataSource();
arkts
List({ scroller: this.timelineScroller }) {
LazyForEach(this.timelineLazyDataSource, (row: TimelineLazyRow) => {
this.buildTimelineLazyListItem(row)
}, (row: TimelineLazyRow) => row.key)
}
.cachedCount(1)
这个场景足够典型,因为它同时满足三个条件:数据会增长、单个 item UI 较重、列表中混有多种行类型。普通 ForEach 会按数组把节点一次性构建出来,而 LazyForEach 放在 List 里时,框架会根据可视区域和 cachedCount 按需创建列表项。
这条链路用到了哪些技术栈
这里不是单独一个 API 的事,我在项目里把几块 ArkUI 能力拼在了一起。
ArkTS 负责定义强类型数据结构。时间轴原始数据是 MomentTimelineItem,它内部挂着 MomentRecord,包含标题、正文、封面 URI、媒体列表、时间、地点、心情等字段。为了适配复杂 UI,我没有直接渲染原始数组,而是额外定义了 TimelineLazyRow,用 kind 区分头部、空状态、月份标题、时间轴卡片、日历卡片等行。
ArkUI List 是懒加载真正生效的父容器。官方限制里说得很清楚,LazyForEach 必须放在支持数据懒加载的容器里,比如 List、Grid、Swiper、WaterFlow。如果放在普通 Column 里,它仍然会一次性构建,达不到省内存的目的。
LazyForEach 负责按需迭代数据源。它不直接吃普通数组,而是吃实现了 IDataSource 的对象。框架需要多少行,就调用 totalCount() 和 getData(index) 拿对应数据。
IDataSource + DataChangeListener 负责通知 UI 更新。这里有一个容易踩坑的点:不能靠重新赋值 dataSource 来刷新 LazyForEach,也不能指望 @State 数组变化自动驱动它。数据源变化后,要通过 listener.onDataReloaded()、onDataAdd()、onDataDelete()、onDatasetChange() 这类接口通知框架。
keyGenerator 负责稳定识别列表项。我在项目里用 row.key 做 key,里面会拼上 momentId、createdAt、updatedAt、coverUri、标题、正文长度、心情、地点等信息。这样数据没变时可以复用,数据变了时也能准确刷新。
cachedCount 负责控制预加载数量。《时光旅记》时间轴卡片里有图片和阴影,我设置的是 .cachedCount(1),只多缓存可视区域前后一小段,避免为了滚动预热牺牲太多内存。
Scroller + @Link 负责滚动位置保存。时间轴支持普通模式和日历模式,我用 timelineTimelineScrollY、timelineCalendarScrollY 分别保存滚动偏移,切换配置或打开详情返回后尽量回到原位置。
onVisibleAreaChange 是图片层的二次优化。列表项懒加载解决的是组件节点数量,图片本身也要控制加载时机。我在 TimelineLazyManagedImage 里监听可见区域,只有图片进入可视区域后才调用真正的图片构建器,离开页面时把 shouldLoadImage 复位。
整体架构
时间轴页的关键不是"写一个 LazyForEach",而是先把复杂业务列表整理成稳定的数据源。
#mermaid-svg-fh0XhEwjebYkTRv3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fh0XhEwjebYkTRv3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fh0XhEwjebYkTRv3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fh0XhEwjebYkTRv3 .error-icon{fill:#552222;}#mermaid-svg-fh0XhEwjebYkTRv3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fh0XhEwjebYkTRv3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fh0XhEwjebYkTRv3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fh0XhEwjebYkTRv3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fh0XhEwjebYkTRv3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fh0XhEwjebYkTRv3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fh0XhEwjebYkTRv3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fh0XhEwjebYkTRv3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fh0XhEwjebYkTRv3 .marker.cross{stroke:#333333;}#mermaid-svg-fh0XhEwjebYkTRv3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fh0XhEwjebYkTRv3 p{margin:0;}#mermaid-svg-fh0XhEwjebYkTRv3 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fh0XhEwjebYkTRv3 .cluster-label text{fill:#333;}#mermaid-svg-fh0XhEwjebYkTRv3 .cluster-label span{color:#333;}#mermaid-svg-fh0XhEwjebYkTRv3 .cluster-label span p{background-color:transparent;}#mermaid-svg-fh0XhEwjebYkTRv3 .label text,#mermaid-svg-fh0XhEwjebYkTRv3 span{fill:#333;color:#333;}#mermaid-svg-fh0XhEwjebYkTRv3 .node rect,#mermaid-svg-fh0XhEwjebYkTRv3 .node circle,#mermaid-svg-fh0XhEwjebYkTRv3 .node ellipse,#mermaid-svg-fh0XhEwjebYkTRv3 .node polygon,#mermaid-svg-fh0XhEwjebYkTRv3 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fh0XhEwjebYkTRv3 .rough-node .label text,#mermaid-svg-fh0XhEwjebYkTRv3 .node .label text,#mermaid-svg-fh0XhEwjebYkTRv3 .image-shape .label,#mermaid-svg-fh0XhEwjebYkTRv3 .icon-shape .label{text-anchor:middle;}#mermaid-svg-fh0XhEwjebYkTRv3 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fh0XhEwjebYkTRv3 .rough-node .label,#mermaid-svg-fh0XhEwjebYkTRv3 .node .label,#mermaid-svg-fh0XhEwjebYkTRv3 .image-shape .label,#mermaid-svg-fh0XhEwjebYkTRv3 .icon-shape .label{text-align:center;}#mermaid-svg-fh0XhEwjebYkTRv3 .node.clickable{cursor:pointer;}#mermaid-svg-fh0XhEwjebYkTRv3 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fh0XhEwjebYkTRv3 .arrowheadPath{fill:#333333;}#mermaid-svg-fh0XhEwjebYkTRv3 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fh0XhEwjebYkTRv3 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fh0XhEwjebYkTRv3 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fh0XhEwjebYkTRv3 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fh0XhEwjebYkTRv3 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fh0XhEwjebYkTRv3 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fh0XhEwjebYkTRv3 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fh0XhEwjebYkTRv3 .cluster text{fill:#333;}#mermaid-svg-fh0XhEwjebYkTRv3 .cluster span{color:#333;}#mermaid-svg-fh0XhEwjebYkTRv3 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fh0XhEwjebYkTRv3 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fh0XhEwjebYkTRv3 rect.text{fill:none;stroke-width:0;}#mermaid-svg-fh0XhEwjebYkTRv3 .icon-shape,#mermaid-svg-fh0XhEwjebYkTRv3 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fh0XhEwjebYkTRv3 .icon-shape p,#mermaid-svg-fh0XhEwjebYkTRv3 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fh0XhEwjebYkTRv3 .icon-shape .label rect,#mermaid-svg-fh0XhEwjebYkTRv3 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fh0XhEwjebYkTRv3 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fh0XhEwjebYkTRv3 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fh0XhEwjebYkTRv3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} TimeImprintStore 小本和瞬间数据
MainPage.getTimelineItems
TimelinePage
buildTimelineLazyRows
行类型判断
HEADER 头部
FILTER_SUMMARY 筛选摘要
MONTH_HEADER 月份分组
TIMELINE_CARD 回忆卡片
CALENDAR_MOMENT 日历回忆
TimelineLazyListDataSource
LazyForEach
List cachedCount
buildTimelineLazyListItem
TimelineLazyManagedImage 可见才加载图片
我比较喜欢这套结构的一点是,LazyForEach 不需要知道业务有多复杂。它只关心"总共有多少行"和"第 index 行是什么"。至于这一行应该渲染成月份标题、回忆卡片还是空状态,都交给 buildTimelineLazyListItem() 去分发。
页面刷新时序
TimelineLazyManagedImage List LazyForEach TimelineLazyListDataSource TimelinePage MainPage 用户 TimelineLazyManagedImage List LazyForEach TimelineLazyListDataSource TimelinePage MainPage 用户 #mermaid-svg-iA4pminB3TAHZuMZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-iA4pminB3TAHZuMZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-iA4pminB3TAHZuMZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-iA4pminB3TAHZuMZ .error-icon{fill:#552222;}#mermaid-svg-iA4pminB3TAHZuMZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-iA4pminB3TAHZuMZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-iA4pminB3TAHZuMZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-iA4pminB3TAHZuMZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-iA4pminB3TAHZuMZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-iA4pminB3TAHZuMZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-iA4pminB3TAHZuMZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-iA4pminB3TAHZuMZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-iA4pminB3TAHZuMZ .marker.cross{stroke:#333333;}#mermaid-svg-iA4pminB3TAHZuMZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-iA4pminB3TAHZuMZ p{margin:0;}#mermaid-svg-iA4pminB3TAHZuMZ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iA4pminB3TAHZuMZ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-iA4pminB3TAHZuMZ .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-iA4pminB3TAHZuMZ .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-iA4pminB3TAHZuMZ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-iA4pminB3TAHZuMZ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-iA4pminB3TAHZuMZ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-iA4pminB3TAHZuMZ .sequenceNumber{fill:white;}#mermaid-svg-iA4pminB3TAHZuMZ #sequencenumber{fill:#333;}#mermaid-svg-iA4pminB3TAHZuMZ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-iA4pminB3TAHZuMZ .messageText{fill:#333;stroke:none;}#mermaid-svg-iA4pminB3TAHZuMZ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iA4pminB3TAHZuMZ .labelText,#mermaid-svg-iA4pminB3TAHZuMZ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-iA4pminB3TAHZuMZ .loopText,#mermaid-svg-iA4pminB3TAHZuMZ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-iA4pminB3TAHZuMZ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-iA4pminB3TAHZuMZ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-iA4pminB3TAHZuMZ .noteText,#mermaid-svg-iA4pminB3TAHZuMZ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-iA4pminB3TAHZuMZ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iA4pminB3TAHZuMZ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iA4pminB3TAHZuMZ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iA4pminB3TAHZuMZ .actorPopupMenu{position:absolute;}#mermaid-svg-iA4pminB3TAHZuMZ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-iA4pminB3TAHZuMZ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iA4pminB3TAHZuMZ .actor-man circle,#mermaid-svg-iA4pminB3TAHZuMZ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-iA4pminB3TAHZuMZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt行结构发生变化行结构没有变化 新增瞬间或切换时间轴配置props / watch 参数变化refreshTimelineLazyRows()buildTimelineLazyRows()replaceRows(rows, signature)对比 signaturelistener.onDataReloaded()totalCount()getData(可视区域 index)TimelineLazyRowbuildTimelineLazyListItem(row)图片进入可视区域shouldLoadImage = true不通知,避免无效刷新
这里的 signature 是我自己加的一层保护。时间轴经常因为状态切换触发刷新,如果每次都 onDataReloaded(),列表会做很多没必要的工作。把所有 row key 拼成签名后,只有行结构真的变了才通知 LazyForEach。
核心实现思路
第一步是定义一套行类型。时间轴里不是每一行都是回忆卡片,所以我先用枚举把行类型说清楚:
arkts
enum TimelineLazyRowKind {
HEADER = 0,
EMPTY = 1,
CALENDAR_OVERVIEW = 2,
CALENDAR_SELECTED_HEADER = 3,
CALENDAR_EMPTY = 4,
CALENDAR_MOMENT = 5,
MONTH_HEADER = 6,
TIMELINE_CARD = 7,
UNDER_CARD_TODAY = 8,
UNDER_CARD_ENTRY = 9,
FILTER_SUMMARY = 10
}
第二步是实现 IDataSource。项目里的数据源不做复杂业务,只做三件事:保存 rows、管理 listener、在 rows 变化时通知 LazyForEach。
arkts
class TimelineLazyListDataSource implements IDataSource {
private listeners: DataChangeListener[] = [];
private rows: Array<TimelineLazyRow> = [];
private signature: string = '';
public totalCount(): number {
return this.rows.length;
}
public getData(index: number): TimelineLazyRow {
return this.rows[index];
}
public registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
this.listeners.push(listener);
}
}
public unregisterDataChangeListener(listener: DataChangeListener): void {
const index: number = this.listeners.indexOf(listener);
if (index >= 0) {
this.listeners.splice(index, 1);
}
}
public replaceRows(rows: Array<TimelineLazyRow>, signature: string): void {
if (this.signature === signature) {
return;
}
this.rows = rows;
this.signature = signature;
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataReloaded();
});
}
}
第三步是把业务数据整理成懒加载行。这里我没有把 header、month、card 分散到多个 ForEach 里,因为官方建议一个容器里只放一个 LazyForEach。所以我的做法是:所有行都进入同一个 rows 数组,渲染时再按 kind 分发。
arkts
private refreshTimelineLazyRows(): void {
let rows: Array<TimelineLazyRow> = this.buildTimelineLazyRows();
this.timelineLazyDataSource.replaceRows(rows, this.getTimelineLazyRowsSignature(rows));
}
第四步才是在 List 里调用 LazyForEach:
arkts
List({ scroller: this.timelineScroller }) {
LazyForEach(this.timelineLazyDataSource, (row: TimelineLazyRow) => {
this.buildTimelineLazyListItem(row)
}, (row: TimelineLazyRow) => row.key)
}
.cachedCount(1)
.width('100%')
.height('100%')
这里有两个细节。LazyForEach 的 itemGenerator 每次必须创建一个根组件,所以我在 buildTimelineLazyListItem() 里统一返回 ListItem。另外 key 一定要稳定且唯一,不能偷懒只用 index,否则删除、筛选、切换模式时很容易出现缓存错位或不刷新的问题。
完整代码
下面这份是从《时光旅记》时间轴里抽出来的完整可迁移版本。为了方便你直接看懂,我保留了 LazyForEach、IDataSource、DataChangeListener、cachedCount、滚动位置保存、图片可见才加载、月份分组这些关键点,把项目里的 HDS 导航、分享长图、隐私占位和主题系统先拿掉了。
实际项目中对应的生产版代码在 entry/src/main/ets/pages/timeline/TimelinePage.ets,文章这份更适合作为你接入能力时的模板。
arkts
class MomentRecord {
id: string = '';
title: string = '';
note: string = '';
location: string = '';
moodCode: string = '平静';
coverUri: string = '';
createdAt: string = '';
updatedAt: string = '';
mediaUris: Array<string> = [];
}
class MomentTimelineItem {
notebookId: string = '';
notebookName: string = '';
isPrivacyProtectedSummary: boolean = false;
moment: MomentRecord = new MomentRecord();
}
enum TimelineLazyRowKind {
HEADER = 0,
EMPTY = 1,
FILTER_SUMMARY = 2,
MONTH_HEADER = 3,
TIMELINE_CARD = 4
}
class TimelineLazyRow {
key: string = '';
kind: TimelineLazyRowKind = TimelineLazyRowKind.TIMELINE_CARD;
item: MomentTimelineItem = new MomentTimelineItem();
label: string = '';
isLast: boolean = false;
}
class TimelinePreviewPhotoItem {
key: string = '';
uri: string = '';
isEmpty: boolean = false;
showMoreOverlay: boolean = false;
}
class TimelineLazyListDataSource implements IDataSource {
private listeners: DataChangeListener[] = [];
private rows: Array<TimelineLazyRow> = [];
private signature: string = '';
public totalCount(): number {
return this.rows.length;
}
public getData(index: number): TimelineLazyRow {
return this.rows[index];
}
public registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
this.listeners.push(listener);
}
}
public unregisterDataChangeListener(listener: DataChangeListener): void {
const index: number = this.listeners.indexOf(listener);
if (index >= 0) {
this.listeners.splice(index, 1);
}
}
public replaceRows(rows: Array<TimelineLazyRow>, signature: string): void {
if (this.signature === signature) {
return;
}
this.rows = rows;
this.signature = signature;
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataReloaded();
});
}
}
@Component
struct LazyVisibleImage {
@Prop uri: string = '';
@Prop widthValue: Length = 0;
@Prop heightValue: Length = 0;
@Prop radius: number = 0;
@Prop fit: ImageFit = ImageFit.Cover;
@State shouldLoadImage: boolean = false;
aboutToDisappear(): void {
this.shouldLoadImage = false;
}
build(): void {
Stack({ alignContent: Alignment.Center }) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#EEF0F4')
.borderRadius(this.radius)
if (this.shouldLoadImage && this.uri.trim().length > 0) {
Image(this.uri)
.width('100%')
.height('100%')
.objectFit(this.fit)
.borderRadius(this.radius)
.clip(true)
}
}
.width(this.widthValue)
.height(this.heightValue)
.borderRadius(this.radius)
.clip(true)
.onVisibleAreaChange([0.0, 0.01], (_isExpanding: boolean, currentRatio: number) => {
this.shouldLoadImage = currentRatio > 0;
})
}
}
@Entry
@Component
struct TimelineLazyForEachDemoPage {
@State activeTagFilter: string = '';
@State timelineScrollY: number = 0;
private timelineScroller: Scroller = new Scroller();
private timelineLazyDataSource: TimelineLazyListDataSource = new TimelineLazyListDataSource();
private timelineItems: Array<MomentTimelineItem> = [];
aboutToAppear(): void {
if (this.timelineItems.length === 0) {
this.timelineItems = this.createDemoTimelineItems();
}
this.refreshTimelineLazyRows();
}
build(): void {
Column() {
this.buildToolbar()
List({ scroller: this.timelineScroller }) {
LazyForEach(this.timelineLazyDataSource, (row: TimelineLazyRow) => {
this.buildTimelineLazyListItem(row)
}, (row: TimelineLazyRow) => row.key)
}
.cachedCount(1)
.layoutWeight(1)
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true })
.onAppear(() => {
if (this.timelineScrollY > 0) {
this.timelineScroller.scrollTo({
xOffset: 0,
yOffset: this.timelineScrollY,
animation: false
});
}
})
.onScrollStop(() => {
this.timelineScrollY = Math.max(0, this.timelineScroller.currentOffset().yOffset);
})
}
.width('100%')
.height('100%')
.backgroundColor('#F7F7FA')
}
@Builder
private buildToolbar(): void {
Row({ space: 10 }) {
Button('新增回忆')
.height(38)
.fontSize(14)
.backgroundColor('#E85D8E')
.fontColor(Color.White)
.borderRadius(16)
.onClick(() => {
this.addDemoMoment();
})
Button(this.activeTagFilter.length > 0 ? '清除筛选' : '筛选旅行')
.height(38)
.fontSize(14)
.backgroundColor('#FFFFFF')
.fontColor('#1F2937')
.borderRadius(16)
.onClick(() => {
this.activeTagFilter = this.activeTagFilter.length > 0 ? '' : '旅行';
this.refreshTimelineLazyRows();
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 8 })
.backgroundColor('#F7F7FA')
}
@Builder
private buildTimelineLazyListItem(row: TimelineLazyRow): void {
ListItem() {
if (row.kind === TimelineLazyRowKind.HEADER) {
this.buildHeader()
} else if (row.kind === TimelineLazyRowKind.EMPTY) {
this.buildEmptyState()
} else if (row.kind === TimelineLazyRowKind.FILTER_SUMMARY) {
this.buildFilterSummary()
} else if (row.kind === TimelineLazyRowKind.MONTH_HEADER) {
this.buildMonthHeader(row.label)
} else {
this.buildTimelineCard(row.item, row.isLast)
}
}
.width('100%')
.margin({ bottom: this.getRowBottomGap(row) })
}
@Builder
private buildHeader(): void {
Column({ space: 5 }) {
Text('回忆时间轴')
.fontSize(28)
.fontWeight(FontWeight.Bolder)
.fontColor('#111827')
Text('把所有小本里的瞬间按时间串起来回看')
.fontSize(13)
.fontColor('#6B7280')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
private buildEmptyState(): void {
Column({ space: 8 }) {
Text('当前没有回忆')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#111827')
Text('新增一条瞬间后,LazyForEach 会只构建可视区域附近的卡片。')
.fontSize(13)
.lineHeight(20)
.fontColor('#6B7280')
.textAlign(TextAlign.Center)
}
.width('100%')
.padding(22)
.backgroundColor(Color.White)
.borderRadius(18)
}
@Builder
private buildFilterSummary(): void {
Row({ space: 10 }) {
Text('#' + this.activeTagFilter)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#111827')
.layoutWeight(1)
Text(this.getTimelineItems(0).length.toString() + ' 条回忆')
.fontSize(12)
.fontColor('#6B7280')
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.backgroundColor(Color.White)
.borderRadius(16)
}
@Builder
private buildMonthHeader(monthLabel: string): void {
Row({ space: 10 }) {
Column()
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor('#E85D8E')
Text(monthLabel)
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor('#111827')
Column()
.height(1)
.layoutWeight(1)
.backgroundColor('#E5E7EB')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ top: 8, bottom: 8 })
}
@Builder
private buildTimelineCard(item: MomentTimelineItem, isLast: boolean): void {
Row({ space: 10 }) {
Column() {
Column()
.width(16)
.height(16)
.border({ width: 2, color: '#E85D8E' })
.borderRadius(8)
.backgroundColor(Color.White)
.margin({ top: 30 })
if (!isLast) {
Column()
.width(2)
.height(this.shouldShowPreviewPhotos(item) ? 176 : 118)
.backgroundColor('#E5E7EB')
}
}
.width(20)
.alignItems(HorizontalAlign.Center)
Column({ space: 12 }) {
Row({ space: 14 }) {
if (item.moment.coverUri.length > 0 && !item.isPrivacyProtectedSummary) {
LazyVisibleImage({
uri: item.moment.coverUri,
widthValue: 92,
heightValue: 92,
radius: 16,
fit: ImageFit.Cover
})
} else {
Column() {
Text('隐私')
.fontSize(13)
.fontColor('#6B7280')
}
.width(92)
.height(92)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor('#EEF0F4')
.borderRadius(16)
}
Column({ space: 9 }) {
Row({ space: 8 }) {
Text(this.getTimelineMomentDateLabel(item.moment.createdAt))
.fontSize(13)
.fontColor('#6B7280')
Text(item.moment.moodCode)
.fontSize(12)
.fontColor('#E85D8E')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor('#FFEAF2')
.borderRadius(10)
}
.width('100%')
Text(this.getMomentTitle(item))
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor('#111827')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.getMomentNotePreview(item))
.fontSize(14)
.lineHeight(20)
.fontColor('#4B5563')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
this.buildLocationNotebookRow(item)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.alignItems(VerticalAlign.Top)
if (this.shouldShowPreviewPhotos(item)) {
Row({ space: 8 }) {
ForEach(this.getPreviewPhotoItems(item), (photo: TimelinePreviewPhotoItem) => {
this.buildPreviewThumbnail(photo)
}, (photo: TimelinePreviewPhotoItem) => photo.key)
}
.width('100%')
}
}
.layoutWeight(1)
.padding(16)
.backgroundColor(Color.White)
.borderRadius(18)
.shadow({ radius: 3, color: '#16000000', offsetY: 1 })
.onClick(() => {
this.timelineScrollY = Math.max(0, this.timelineScroller.currentOffset().yOffset);
})
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
@Builder
private buildLocationNotebookRow(item: MomentTimelineItem): void {
Row({ space: 8 }) {
if (item.notebookName.trim().length > 0) {
Text(item.notebookName)
.fontSize(11)
.fontColor('#6B7280')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
if (item.moment.location.trim().length > 0) {
Text(item.moment.location)
.fontSize(11)
.fontColor('#6B7280')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.width('100%')
}
@Builder
private buildPreviewThumbnail(photo: TimelinePreviewPhotoItem): void {
Stack({ alignContent: Alignment.Center }) {
if (!photo.isEmpty) {
LazyVisibleImage({
uri: photo.uri,
widthValue: '100%',
heightValue: '100%',
radius: 12,
fit: ImageFit.Cover
})
}
if (!photo.isEmpty && photo.showMoreOverlay) {
Text('...')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
.width('100%')
.height('100%')
.textAlign(TextAlign.Center)
.backgroundColor('#80000000')
.borderRadius(12)
}
}
.layoutWeight(1)
.aspectRatio(1)
.borderRadius(12)
.clip(true)
.backgroundColor('#EEF0F4')
}
private refreshTimelineLazyRows(): void {
let rows: Array<TimelineLazyRow> = this.buildTimelineLazyRows();
this.timelineLazyDataSource.replaceRows(rows, this.getTimelineLazyRowsSignature(rows));
}
private buildTimelineLazyRows(): Array<TimelineLazyRow> {
let rows: Array<TimelineLazyRow> = [];
rows.push(this.createTimelineLazyRow(TimelineLazyRowKind.HEADER, 'timeline-header'));
let items: Array<MomentTimelineItem> = this.getTimelineItems(0);
if (this.activeTagFilter.trim().length > 0) {
rows.push(this.createTimelineLazyRow(
TimelineLazyRowKind.FILTER_SUMMARY,
'timeline-filter-' + this.activeTagFilter + '-' + items.length.toString()
));
}
if (items.length === 0) {
rows.push(this.createTimelineLazyRow(TimelineLazyRowKind.EMPTY, 'timeline-empty'));
return rows;
}
let currentMonthLabel: string = '';
for (let i: number = 0; i < items.length; i++) {
let item: MomentTimelineItem = items[i];
let monthLabel: string = this.getMonthYearLabel(item.moment.createdAt);
if (monthLabel !== currentMonthLabel) {
currentMonthLabel = monthLabel;
let monthRow: TimelineLazyRow = this.createTimelineLazyRow(
TimelineLazyRowKind.MONTH_HEADER,
'timeline-month-' + monthLabel + '-' + i.toString()
);
monthRow.label = monthLabel;
rows.push(monthRow);
}
let nextMonthLabel: string = i + 1 < items.length ? this.getMonthYearLabel(items[i + 1].moment.createdAt) : '';
let cardRow: TimelineLazyRow = this.createTimelineLazyRow(
TimelineLazyRowKind.TIMELINE_CARD,
'timeline-card-' + i.toString() + '-' + this.getTimelineItemRenderKey(item)
);
cardRow.item = item;
cardRow.isLast = i === items.length - 1 || nextMonthLabel !== monthLabel;
rows.push(cardRow);
}
return rows;
}
private createTimelineLazyRow(kind: TimelineLazyRowKind, key: string): TimelineLazyRow {
let row: TimelineLazyRow = new TimelineLazyRow();
row.kind = kind;
row.key = key;
return row;
}
private getTimelineLazyRowsSignature(rows: Array<TimelineLazyRow>): string {
let keys: Array<string> = [];
for (let i: number = 0; i < rows.length; i++) {
keys.push(rows[i].key);
}
return keys.join('|');
}
private getTimelineItemRenderKey(item: MomentTimelineItem): string {
return [
item.moment.id,
item.moment.createdAt,
item.moment.updatedAt,
item.moment.coverUri,
item.moment.title,
item.moment.note.length.toString(),
item.moment.moodCode,
item.notebookName,
item.moment.location
].join('-');
}
private getTimelineItems(limit: number): Array<MomentTimelineItem> {
let filtered: Array<MomentTimelineItem> = [];
for (let i: number = 0; i < this.timelineItems.length; i++) {
let item: MomentTimelineItem = this.timelineItems[i];
if (this.activeTagFilter.length === 0 ||
item.notebookName.indexOf(this.activeTagFilter) >= 0 ||
item.moment.note.indexOf(this.activeTagFilter) >= 0 ||
item.moment.title.indexOf(this.activeTagFilter) >= 0) {
filtered.push(item);
}
}
if (limit > 0) {
return filtered.slice(0, limit);
}
return filtered;
}
private addDemoMoment(): void {
let item: MomentTimelineItem = new MomentTimelineItem();
let moment: MomentRecord = new MomentRecord();
let now: Date = new Date();
moment.id = 'moment-' + now.getTime().toString();
moment.title = '刚刚记录的新瞬间';
moment.note = '这条数据插入后,数据源会重新生成 rows,并通过 DataChangeListener 通知 LazyForEach 刷新。';
moment.location = '杭州';
moment.moodCode = '开心';
moment.coverUri = '';
moment.createdAt = this.formatDateTime(now);
moment.updatedAt = this.formatDateTime(now);
item.notebookName = '旅行手记';
item.moment = moment;
this.timelineItems.splice(0, 0, item);
this.refreshTimelineLazyRows();
}
private createDemoTimelineItems(): Array<MomentTimelineItem> {
let result: Array<MomentTimelineItem> = [];
result.push(this.createDemoItem('m1', '西湖边的风', '旅行手记', '杭州', '2026-04-23T09:30:00', '把湖边的风、树影和早餐一起记下来。'));
result.push(this.createDemoItem('m2', '绍兴夜路', '旅行手记', '绍兴', '2026-04-18T20:10:00', '从仓桥直街慢慢走回去,路灯把水面照得很安静。'));
result.push(this.createDemoItem('m3', '周末咖啡', '日常小本', '福州', '2026-03-21T15:20:00', '整理照片的时候,顺手把这一段也放进时间轴。'));
result.push(this.createDemoItem('m4', '春天第一张照片', '日常小本', '福州', '2026-03-02T08:12:00', '封面、文字、地点都会一起进入回忆卡片。'));
return result;
}
private createDemoItem(
id: string,
title: string,
notebookName: string,
location: string,
createdAt: string,
note: string
): MomentTimelineItem {
let item: MomentTimelineItem = new MomentTimelineItem();
let moment: MomentRecord = new MomentRecord();
moment.id = id;
moment.title = title;
moment.note = note;
moment.location = location;
moment.moodCode = '平静';
moment.coverUri = '';
moment.createdAt = createdAt;
moment.updatedAt = createdAt;
moment.mediaUris = [];
item.notebookName = notebookName;
item.moment = moment;
return item;
}
private getRowBottomGap(row: TimelineLazyRow): number {
if (row.kind === TimelineLazyRowKind.HEADER) {
return 16;
}
if (row.kind === TimelineLazyRowKind.FILTER_SUMMARY) {
return 12;
}
if (row.kind === TimelineLazyRowKind.MONTH_HEADER) {
return 4;
}
return 0;
}
private getMonthYearLabel(value: string): string {
let sections: Array<string> = value.split('T');
let dateParts: Array<string> = sections[0].split('-');
if (dateParts.length >= 2) {
return dateParts[0] + '年' + Number(dateParts[1]).toString() + '月';
}
return value;
}
private getTimelineMomentDateLabel(value: string): string {
let sections: Array<string> = value.split('T');
let dateParts: Array<string> = sections[0].split('-');
if (dateParts.length >= 3) {
return Number(dateParts[1]).toString() + '月' + Number(dateParts[2]).toString() + '日';
}
return value;
}
private getMomentTitle(item: MomentTimelineItem): string {
let title: string = item.moment.title.trim();
return title.length > 0 ? title : '美好的瞬间';
}
private getMomentNotePreview(item: MomentTimelineItem): string {
let note: string = item.moment.note.trim();
return note.length > 0 ? note : '这一刻没有留下文字';
}
private shouldShowPreviewPhotos(item: MomentTimelineItem): boolean {
return this.getPreviewPhotoItems(item).length > 0 && !this.getPreviewPhotoItems(item)[0].isEmpty;
}
private getPreviewPhotoItems(item: MomentTimelineItem): Array<TimelinePreviewPhotoItem> {
const maxVisibleCount: number = 5;
let result: Array<TimelinePreviewPhotoItem> = [];
let hasOverflow: boolean = item.moment.mediaUris.length > maxVisibleCount;
for (let i: number = 0; i < item.moment.mediaUris.length && result.length < maxVisibleCount; i++) {
let uri: string = item.moment.mediaUris[i].trim();
if (uri.length === 0 || uri === item.moment.coverUri) {
continue;
}
let photo: TimelinePreviewPhotoItem = new TimelinePreviewPhotoItem();
photo.uri = uri;
photo.key = item.moment.id + '-preview-' + result.length.toString() + '-' + uri;
result.push(photo);
}
if (hasOverflow && result.length >= maxVisibleCount) {
result[maxVisibleCount - 1].showMoreOverlay = true;
}
while (result.length < maxVisibleCount && result.length > 0) {
let emptyPhoto: TimelinePreviewPhotoItem = new TimelinePreviewPhotoItem();
emptyPhoto.key = item.moment.id + '-preview-empty-' + result.length.toString();
emptyPhoto.isEmpty = true;
result.push(emptyPhoto);
}
return result;
}
private formatDateTime(date: Date): string {
return date.getFullYear().toString() + '-' +
this.pad2(date.getMonth() + 1) + '-' +
this.pad2(date.getDate()) + 'T' +
this.pad2(date.getHours()) + ':' +
this.pad2(date.getMinutes()) + ':00';
}
private pad2(value: number): string {
return value < 10 ? '0' + value.toString() : value.toString();
}
}
接入时最容易踩的坑
第一个坑是把 LazyForEach 放错容器。它不是放哪都懒,只有放在 List、Grid、Swiper、WaterFlow 这类支持懒加载的容器里才有意义。《时光旅记》时间轴用的是 List,并且列表本身有明确的高度,也就是 .height('100%') 或外层 layoutWeight(1) 能让 List 算出可视区域。
第二个坑是把 dataSource 当普通状态变量换来换去。LazyForEach 的刷新机制不是 this.dataSource = newDataSource,而是数据源内部维护 listener,数据变了以后通知 listener。我的 replaceRows() 里只在签名变化时调用 onDataReloaded(),避免每次状态轻微变化都重建列表。
第三个坑是 key 不稳定。时间轴这种列表会插入月份行、筛选行、空状态行,如果只用 index 做 key,前面插入一行后,后面的 key 全变了,缓存命中和刷新判断都会变差。我在生产代码里用 row.key,业务卡片再通过 getTimelineItemRenderKey() 把真正影响 UI 的字段拼进去。
第四个坑是图片也要懒。LazyForEach 只解决组件节点按需创建,不代表图片资源就一定没有压力。我的做法是卡片进入可视区域后再加载图片,离开页面时把 shouldLoadImage 复位。这样长时间轴里有很多照片时,内存曲线会更稳。
为什么这个方案适合《时光旅记》
《时光旅记》的时间轴会越来越长,而且每条瞬间不是简单文本。它可能有封面图、五宫格预览、地点、心情、隐私占位、所属小本和点击跳转。用 ForEach 的写法很直观,但当回忆数量上来以后,首屏构建和内存占用都会被放大。
换成 LazyForEach 后,页面的思路变成了这样:业务层只负责准备完整数据,适配层把数据整理成稳定的 row,渲染层只创建当前屏幕附近的组件。用户看到的还是同一个时间轴,但底层不再一次性背着所有卡片跑。
这也是我觉得 LazyForEach 在鸿蒙应用里最适合的场景:不是小数组,不是静态菜单,而是这种会持续增长、单项 UI 较重、还需要稳定滚动体验的业务列表。