上周在做订单轨迹页的时候,产品要求在同一个页面里展示物流流转、审核记录、操作日志三类数据,并且要按日期分组、支持点击节点高亮、还要在鸿蒙设备上有足够流畅的滚动体验。我第一时间想到的是去社区找现成的时间线组件,结果在React Native鸿蒙跨平台环境里翻了半天:官方生态里有时间相关的列表方案,但专为鸿蒙适配的时间线组件基本没有;通用的时间线库又大多绑定在特定业务形态上,比如固定左侧时间轴、右侧卡片,改造成本远大于自研。最后我决定从零实现一个RN鸿蒙跨平台的时间线组件,把这种组件拆成了数据建模、状态管理、UI组件、样式系统和渲染逻辑五个部分逐一解决。
这篇文章就按我实际开发时的顺序来写,重点讲清楚每一块的设计取舍、关键代码和踩过的坑。适合正在做RN鸿蒙开发、或者需要自研时间线类组件的团队参考。先说明一下背景:我们的业务运行在鸿蒙系统上,RN这套JS代码在鸿蒙环境里可以正常跑,但原生依赖、样式表现、尺寸单位、滚动性能这些跟iOS/Android有明显差异,不提前摸清楚,后面每个环节都会踩坑。
1. 为什么要在RN鸿蒙环境下自研时间线组件
1.1 社区方案在鸿蒙环境下的三个缺口
现在社区里不是没有时间线组件,我在调研阶段看了好几套方案,但放到鸿蒙环境下基本都绕不开三个问题。
第一,原生依赖适配缺口。很多时间线组件在设计时只考虑了iOS和Android的View体系,部分库会依赖原生模块做事件分发、测量布局,鸿蒙的RN适配层还没有完整实现这些原生接口,导致组件在鸿蒙上初始化失败、白屏,或者功能降级。这种问题不是改JS层能解决的,得等原生侧适配,周期不可控。
第二,定制深度不够。通用时间线组件的API往往绑定了一种展示形态,最常见的是“左侧时间轴+右侧卡片”。一旦业务需要双列交错、时间轴居中、分组吸顶、每个节点内容完全自定义,这些库的扩展点根本不够用,改到最后等于在别人的设计上叠补丁,比自研还累。
第三,数据来源和交互场景复杂。我们要展示的物流流转、审核记录、操作日志,数据来自不同接口,状态字段都不一致,还要支持点击节点联动、动态刷新、跳转到指定时间点。通用组件给的是纯展示能力,对这类数据驱动型交互基本没有帮助。
我在立项前用了三个标准判断是否自研:有没有鸿蒙原生适配层,业务定制深度是否超过组件API覆盖范围的30%,后续迭代频率和优先级高不高。三个标准里只要满足一条,自研就比改造划算。我们三条全中,所以决定自己写。
1.2 自研组件需要覆盖的四个场景约束
自研不是上来就画界面,第一步是把场景约束想清楚。项目里这四个场景几乎覆盖了时间线组件的全部使用方式。
第一个场景是长列表滚动。时间线可能承载500条以上的记录,如果不用虚拟化列表,鸿蒙设备上滑动会明显掉帧。所以渲染层从一开始就绑定列表虚拟化思路,而不是普通的ScrollView套map。
第二个场景是分组和定位。时间线按天分组,用户要能快速跳到“今天”或者某个历史日期,这要求组件对分组边界、节点位置有可计算的能力。
第三个场景是节点状态高亮。每个节点有进行中、已完成、异常等状态,点击节点要能在整条时间线里高亮选中项,并且联动页面其他模块。
第四个场景是动态刷新。订单轨迹每十秒轮询一次,服务端返回全量数据,不能因为刷新就让列表回到顶部或者闪动。这需要数据层具备增量合并能力,渲染层具备局部更新能力。
这四个场景决定了组件不是“列表加一条竖线”那么简单,而是一个数据驱动、带交互状态、需要虚拟化渲染的复合组件。后面每一个模块的设计,其实都是在解决这几个约束。
1.3 设计目标:五层分离,各管各的
动手写代码之前,我先在文档里定了一个目标:把组件拆成五个独立模块,数据层只管数据的形状,状态层只管服务端数据到UI呈现的过程,UI层只管节点的展示和交互,样式层管颜色和鸿蒙适配,渲染层管滚动和性能。
五层分离带来的直接好处是,后期换主题、换数据源、换布局模式都不会互相拖累。比如样式层如果和UI组件耦合在一起,换深色模式就得改所有组件文件;渲染逻辑如果和数据模型耦合,改数据字段就得重新处理虚拟化测量。实际开发中这个设计帮了大忙,我后面每一部分都会提到它的作用。
2. 数据建模:时间线不是“数组+日期”这么简单
2.1 先区分业务数据和渲染数据
时间线组件最容易被低估的是数据建模。很多人拿到服务端的事件流数组,直接塞进组件里map渲染,短期内没毛病,但只要加入分组、虚拟化、滚动定位,代码很快就会失控。
问题出在把两类数据混在了一起。业务数据是服务端给的,通常是扁平的记录数组,每项包含标题、时间、状态、描述,节点之间只是前后顺序关系。渲染数据是组件真正消费的,它需要包含分组信息、节点在列表中的位置、虚拟化渲染所需的偏移量。这两者之间要做一层转换,而不是在组件里实时算。
我在数据层定义了三个核心结构:节点(TimelineNode)、分组(TimelineGroup)、扁平索引(FlatNode)。下面给出TypeScript定义。
export type TimelineNodeStatus = 'done' | 'pending' | 'failed' | 'running'; export interface TimelineNode { id: string; title: string; description?: string; timestamp: number; status: TimelineNodeStatus; kind?: TimelineNodeKind; // 物流节点、审核节点、日志节点 payload?: Record<string, unknown>; } export interface TimelineFlatNode { key: string; node: TimelineNode; groupId: string; groupIndex: number; nodeIndex: number; isLastInGroup: boolean; } export interface TimelineGroup { id: string; label: string; timestampStart: number; timestampEnd: number; flatNodeIndexes: number[]; // 指向扁平列表中属于该分组的节点范围 }关键在于TimelineFlatNode把分组和节点做了一次关联,渲染层不需要在渲染时反复查找节点属于哪个分组、是不是组内最后一个节点。这些判断如果放在render函数里做,列表一大就会出现没必要的重复计算。
2.2 状态字段为什么要单独建模
节点状态我在建模时用了四态:done、pending、failed、running。一开始有同事问,为什么不用success和error两个值就够,反正UI只有绿色和红色。但实际业务里,“未发生”和“正在发生”是两种完全不同的视觉含义,一个灰一个蓝,文案也不一样;“失败”则要单独触发异常提示。四态是最低粒度,后面映射到颜色和图标时可以合并,但建模时如果一开始就合并,遇到新状态再加就要改数据源,成本更高。
另外我把kind跟status分开。kind表示节点类型,比如物流、审核、日志,它决定节点左侧的图标样式;status表示节点当前所处状态,它决定颜色。两者如果混成一个字段,会出现“审核失败”和“物流失败”两种语义撞车的情况,UI上就不知道画什么图标了。
2.3 分组逻辑与扁平化索引
服务端返回的原始数据是跨天的,时间线要按天分组。我的做法是写一个纯函数做归一化,把原始记录数组转换成{ groups, flatNodes }结构。
function normalizeTimelineData(events: RawEvent[]): { groups: TimelineGroup[]; flatNodes: TimelineFlatNode[] } { const sorted = [...events].sort((a, b) => b.timestamp - a.timestamp); const groups: TimelineGroup[] = []; const flatNodes: TimelineFlatNode[] = []; const groupMap = new Map<string, TimelineGroup>(); sorted.forEach((event, index) => { const groupId = formatDateKey(event.timestamp); let group = groupMap.get(groupId); if (!group) { group = { id: groupId, label: formatDateLabel(event.timestamp), timestampStart: event.timestamp, timestampEnd: event.timestamp, flatNodeIndexes: [], }; groupMap.set(groupId, group); groups.push(group); } const flatNode: TimelineFlatNode = { key: event.id, node: { ...mapEventToNode(event) }, groupId, groupIndex: groups.length - 1, nodeIndex: group.flatNodeIndexes.length, isLastInGroup: false, }; group.flatNodeIndexes.push(flatNodes.length); flatNodes.push(flatNode); }); // 单独标记每个分组最后一个节点 for (const group of groups) { const lastIndex = group.flatNodeIndexes[group.flatNodeIndexes.length - 1]; flatNodes[lastIndex].isLastInGroup = true; } return { groups, flatNodes }; }这个函数有两个设计点值得说明。第一,分组只做一次,放在数据层,渲染层拿到的直接是分组信息和扁平索引,不需要每次渲染都重新算。第二,flatNodeIndexes存的是索引而不是引用,方便后面虚拟化渲染时通过getItemLayout快速定位,也方便增量更新时只替换变化的节点对象。
2.4 节点高度要不要写进数据模型
这是个看起来像UI问题、实际上会影响数据层的问题。如果节点内容高度是固定的,比如限制两行文字,超出截断,那我可以在数据层直接算出每个节点的累积偏移量,滚动跳转用这个偏移量就行。如果节点高度完全自适应,再塞进数据层,渲染时又需要对每个节点做测量,数据层反而成为性能瓶颈。
我的实践是采用“预估高度+实际修正”的双轨方案。数据层只存一个预估高度estimatedHeight,用于滚动定位和getItemLayout的初始值;真实高度由渲染层在onLayout里上报,更新到高度缓存表里。数据层只管大致位置,渲染层管精确位置,两个层的职责边界就清楚了。
3. 状态管理:把服务端数据、UI状态和派生状态分开
3.1 时间线状态管理的三个典型误区
时间线组件的状态管理,最常见的三个误区我在项目里都踩过。
第一个误区是把全量节点数据放进全局Store,所有页面共享。这种做法的直接后果是,任何节点状态变化都会触发Store订阅者更新,哪怕页面只是依赖其中一条记录,也会跟着重渲染。时间线这种长列表,全部节点都订阅全局Store,性能会迅速恶化。
第二个误区是每个节点内部自己用useState维护选中状态。选中一个节点、联动其他节点时,跨组件通信会变得很麻烦,而且组件卸载后状态就丢了,滚动回来后无法恢复。
第三个误区是把所有状态一股脑塞进组件顶部的useState,加载状态、选中状态、分组展开状态、请求错误状态各管各的,更新路径一多,很容易出现状态不同步,比如数据加载成功后忘了重置错误状态。
正确的做法是分层。全局Store只放服务端原始数据,因为它是多个页面可能共享的资源;请求状态、选中状态、展开分组、滚动锚点这些UI状态,放在组件内部的useReducer或Context里,组件卸载就丢弃,这符合它们的生命周期。
3.2 用useReducer管理组件内状态
我自定义了一个useTimelineHook,核心是一个useReducer。状态对象包含数据快照、加载状态、错误信息、选中节点、展开的分组ID集合。
type TimelineState = { flatNodes: TimelineFlatNode[]; groups: TimelineGroup[]; status: 'idle' | 'loading' | 'success' | 'error'; error?: string; selectedNodeId?: string; expandedGroupIds: string[]; }; type TimelineAction = | { type: 'timeline/loaded'; payload: TimelineData } | { type: 'timeline/loadError'; payload: string } | { type: 'timeline/selectNode'; payload: { id: string; groupId: string } } | { type: 'timeline/toggleGroup'; payload: string }; const initialState: TimelineState = { flatNodes: [], groups: [], status: 'idle', expandedGroupIds: [], }; function timelineReducer(state: TimelineState, action: TimelineAction): TimelineState { switch (action.type) { case 'timeline/loaded': return { ...state, status: 'success', flatNodes: action.payload.flatNodes, groups: action.payload.groups }; case 'timeline/selectNode': return { ...state, selectedNodeId: action.payload.id, expandedGroupIds: state.expandedGroupIds.includes(action.payload.groupId) ? state.expandedGroupIds : [...state.expandedGroupIds, action.payload.groupId], }; case 'timeline/toggleGroup': return { ...state, expandedGroupIds: state.expandedGroupIds.includes(action.payload) ? state.expandedGroupIds.filter((id) => id !== action.payload) : [...state.expandedGroupIds, action.payload], }; default: return state; } }一个值得展开的设计点是,selectNodeaction在设置选中节点的同时,会检查该节点所在分组是否展开,如果没有就自动展开。这个逻辑放在Reducer里,而不是外部序列化调用两个setState,可以避免中间态导致的UI闪烁。
3.3 增量更新:服务端轮询下保持列表稳定
时间线轮询刷新是最容易出问题的场景。订单轨迹每隔十秒返回全量数据,如果直接替换,所有节点引用都变了,FlatList会重新计算布局,用户滑动的位置会跳动,体验很差。
我的做法是维护一个Map<string, TimelineNode>作为服务端数据缓存,新数据到达时做一次diff,只把变更的节点替换进新的Map,然后从Map生成新的flatNodes数组。
function mergeTimelineNodes(prevMap: Map<string, TimelineNode>, incoming: TimelineNode[]) { const nextMap = new Map(prevMap); let changed = false; for (const node of incoming) { const prev = nextMap.get(node.id); if (!prev || prev.status !== node.status || prev.title !== node.title || prev.description !== node.description) { nextMap.set(node.id, node); changed = true; } } return { map: nextMap, changed }; }这里有两个关键点。第一,只有实际内容变化了才更新Map中的节点引用,没变化的节点保持原引用,React.memo就有机会跳过渲染。第二,我用Map而不是普通数组做缓存,因为Map按id查询是O(1),而数组的find是O(n),节点多了以后差距非常明显。
如果刷新时只是尾部新增节点,前面所有节点引用都不变,FlatList几乎可以做到零额外渲染,滚动位置也稳定。
3.4 选中态和展开态的持久化
选中状态放在组件内Reducer后,有一个隐藏问题:FlatList滚动时,选中的节点可能被回收,再滚回来时卡片高亮要还在。因为选中ID是存在组件级Reducer里的,只要组件没有卸载,状态就不会丢,所以滚动回收后重新渲染时依然能取到selectedNodeId,问题自然解决。
但如果页面做了Tab切换或者跳转二级页面再返回,组件卸载后选中态就没了,这时候才需要全局Store。项目里这个时间线目前没有这种跨页恢复需求,所以我先放组件级,等有需求再上提。状态管理的核心原则是,能放低层就不放高层,过早把UI状态全局化,等于自己给自己制造重渲染压力。
4. UI组件:从圆点到内容区的职责边界与布局模式
4.1 组件树结构与单一职责
UI层我把时间线拆成了小组件树:
Timeline ├── TimelineGroupHeader(分组头,日期标签) ├── TimelineNode(单个节点) │ ├── TimelineIndicator(圆点/状态图标) │ ├── TimelineContent(内容区,接受业务自定义) │ └── TimelineConnector(连接线,非最后一个节点时渲染) ├── TimelineEmpty(空态) ├── TimelineLoading(加载态) └── TimelineError(错误态)每个子组件职责非常单一。TimelineIndicator只负责画圆点和状态图标,它不感知业务,不知道内容是物流还是审核,只根据status渲染对应颜色和图标。TimelineContent直接接受children,业务方想放什么就放什么,组件本身不限制高度和内容结构。TimelineConnector只负责节点之间的竖向连接线,而且只在非最后一个节点时渲染。
这种拆法有一个实际好处:状态动画可以放在Indicator内部,比如节点从pending变成running时,圆点有一个呼吸闪烁动画,动画只影响Indicator这一个小组件,不会牵动周围内容重渲染。
4.2 三种布局模式的计算逻辑
时间线组件常见的布局模式有三种:时间轴靠左、时间轴居中、左右交替。我用一个layoutMode属性切换,默认left。
export type TimelineLayoutMode = 'left' | 'center' | 'alternate'; type TimelineLayoutConfig = { axisWidth: number; contentPadding: number; }; function getLayoutConfig(mode: TimelineLayoutMode): TimelineLayoutConfig { switch (mode) { case 'left': return { axisWidth: 48, contentPadding: 12 }; case 'center': return { axisWidth: 16, contentPadding: 32 }; case 'alternate': return { axisWidth: 16, contentPadding: 32 }; } }left模式最简单,时间轴固定48宽度,内容区从圆点右侧开始。center模式需要让圆点列居中,内容区根据节点奇偶性分别靠左或靠右,并且要给内容区左右留出足够的padding,否则文字会压到中线圆点上。alternate模式跟center类似,但是通过节点的index奇偶决定内容区方向,这里有个视觉陷阱:如果整个列表的节点数不是偶数,最后一条节点会出现只有左侧内容、没有右侧内容的情况,看起来不对称。处理方式是额外判断最后一个节点,如果它落在内容区为左的那一侧,就用一个透明占位节点补齐视觉上的对称性。
4.3 组件间通信用Context,不靠层层传Props
节点点击高亮后,节点内部状态需要同步给其他模块。一开始我用props把selectedNodeId层层往下传,结果是每次选中变化,所有节点都因为props变化而触发re-render,即使它们并没有选中。
后来改成Context方案:在Timeline组件内部创建一个TimelineContext,只向子组件暴露selectedNodeId和selectNode方法,然后所有TimelineNode通过useContext取用。
const TimelineContext = createContext<{ selectedNodeId?: string; selectNode: (id: string, groupId: string) => void; } | null>(null); export function useTimelineContext() { const ctx = useContext(TimelineContext); if (!ctx) { throw new Error('useTimelineContext must be used within Timeline'); } return ctx; }配合React.memo,只有真正消费这个Context的组件会在选中态变化时重渲染,其他节点即使props没变也能跳过。这个优化对长时间线特别明显,选中一个节点时,不是全列表都跟着动。
4.4 onLayout收集节点真实高度
如果节点高度完全自适应,滚动定位时就需要拿到真实高度。我的做法是每个TimelineNode在onLayout回调里上报自身高度,父组件维护一个heightCache: Map<string, number>。
function handleNodeLayout(nodeId: string, height: number) { heightCacheRef.current.set(nodeId, height); }这里有一个容易写错的地方,onLayout在滚动过程中会被频繁触发,每次触发都更新Ref没问题,但不要再触发setState。如果高度值一变化就setState,会引起滚动中反复重渲染。正确做法是高度Cache只存在Ref里,用于计算偏移量,不参与页面渲染。只有getItemLayout需要读到最新高度时,通过Ref读取即可。
5. 样式系统:鸿蒙环境下的单位、线条与主题坑
5.1 尺寸单位与字体缩放的适配
RN在iOS和Android上的标准尺寸单位分别是pt和dp,鸿蒙使用vp作为逻辑像素单位。RN跑到鸿蒙上时,大部分样式数值会由鸿蒙运行时做换算,但实测下来,同样的数字在不同分辨率和字体缩放下,视觉比例会有偏差。
时间线组件里这个偏差很容易暴露,因为圆点、连接线是装饰元素,卡片高度是内容元素。如果圆点和文字间距都用绝对数值,系统字体调大后,内容区行高会变,而装饰圆点大小不变,整体看起来不协调。
我的处理原则是:装饰元素用固定数值,比如圆点直径12,连接线宽1;内容区用lineHeight + padding的组合,让文本撑起高度,而不是给卡片一个绝对高度。这样字体变化时,卡片会跟着变,装饰元素依然稳定,视觉上不会东倒西歪。
5.2 1px线条与hairlineWidth的坑
时间线的灵魂是那条竖向连接线。在Android上常见做法是用StyleSheet.hairlineWidth拿物理1px的宽度,但鸿蒙适配层对hairlineWidth的支持并不稳定,实测有时候会返回0,导致线条直接消失;有时候又返回一个过大的值,线条显得很粗。
我最后采用了固定线宽的方案:
export const timelineStyles = StyleSheet.create({ connectorLine: { borderLeftWidth: 1, borderLeftColor: 'rgba(180, 180, 180, 0.45)', }, });固定1宽度,配上半透明颜色,视觉上稳定且不会太厚重。这里还踩了一个坑:不要用opacity去给整条线做透明度,因为圆点和连接线如果是父子关系,子元素会被父级透明度影响,效果会一起变淡。正确做法是给borderLeftColor直接使用带alpha的rgba值,保证透明度只作用于线条颜色本身。
5.3 阴影、圆角与层级表达
鸿蒙上的阴影支持不如iOS原生,boxShadow属性在部分RN鸿蒙版本里不生效或表现异常。时间线节点卡片如果要体现层级,过度依赖阴影会翻车。
稳妥的方案是用“背景色+1px边框+圆角”来代替阴影。深色模式下阴影更不可靠,描边方案反而更稳定。我写了一个通用的卡片样式:
const card = StyleSheet.create({ nodeCard: { backgroundColor: 'var(--color-surface)', borderRadius: 8, borderWidth: StyleSheet.hairlineWidth, borderColor: 'rgba(0, 0, 0, 0.08)', }, });注意borderWidth这时可以用hairlineWidth,如果鸿蒙上表现异常再改成固定0.5或1。卡片本身是层级容器,用描边确认边界比阴影更可靠。
5.4 动态主题的色板设计
时间线是要支持深浅色模式的,颜色不能散落在各个组件里。我定义了一套颜色Token,通过ThemeContext下发给所有子组件。
export const lightColors = { lineColor: 'rgba(180, 180, 180, 0.45)', indicatorDone: '#3BA55D', indicatorPending: '#B0B0B0', indicatorFailed: '#E04F4F', indicatorRunning: '#3A8DFF', textPrimary: '#1A1A1A', textSecondary: '#8A8A8A', surfaceColor: '#FFFFFF', } as const; export const darkColors = { lineColor: 'rgba(90, 90, 90, 0.6)', indicatorDone: '#4DC86F', indicatorPending: '#6A6A6A', indicatorFailed: '#FF6B6B', indicatorRunning: '#5BA3FF', textPrimary: '#F2F2F2', textSecondary: '#9A9A9A', surfaceColor: '#1E1E1E', } as const;所有组件引用颜色时都从ThemeContext取,禁止直接用十六进制写死。好处是换主题只要改Provider,不需要动组件内部。鸿蒙设备切深浅色非常频繁,集中管理颜色Token能省去大量返工。
6. 渲染逻辑:虚拟化、连接线与滚动性能的取舍
6.1 虚拟化列表下连接线为什么会断
时间线渲染和普通列表最大的区别就是那条贯穿始终的连接线。FlatList是虚拟化列表,只渲染视口附近的item,如果把连接线画在每个节点内部,那么滚动时未渲染节点区域会缺少线段,视觉上出现断裂。这是自研时间线组件最容易被低估的问题。
我试过两种方案。方案一是每个节点既画向上半段线,又画向下半段线,段的长度固定,这样虚拟化后上下相邻的段仍能拼接起来。方案二是把整条连接线画在列表容器层,用一个绝对定位的竖向View固定在整个滚动容器的高度上,内容层随便滚,线条不参与虚拟化。
方案二视觉上最稳,但它要求容器层预先知道总高度,而节点高度不固定时,总高度无法提前计算。所以我最终还是采用了方案一的变体:节点内部只画向下的半段线,长度固定,节点与节点之间的间距也固定,这样滚动时线段能自然拼接。
6.2 getItemLayout的使用前提
FlatList虚拟化性能优化最有效的手段是getItemLayout,它可以让列表在不知道实际内容高度的情况下直接算偏移量,省去动态测量。
const getItemLayout = (data: TimelineFlatNode[] | null | undefined, index: number) => ({ length: ITEM_HEIGHT, offset: GROUP_HEADER_HEIGHT + ITEM_HEIGHT * index, index, });但这里有个前提:每项高度必须固定,否则偏移量是错的,滚动时列表会出现位置跳动。项目里如果无法保证节点高度固定,我的建议是用一个estimatedItemHeight初始值配合getItemLayout,同时在onLayout里把真实高度写入缓存。关键点是offset的计算要能把分组头的高度也算进去,否则分组吸顶时滚动定位会偏移。
6.3 减少重渲染:memo、Context与稳定回调
渲染层性能最后一道防线是控制重渲染范围。我在组件里做了三件很具体的事。
第一,装饰性组件全部包React.memo。TimelineIndicator、TimelineConnector这类组件,数据不变时绝对不重渲染。第二,renderItem里不写内联箭头函数,而是提取成一个命名组件,回调函数用useCallback缓存。第三,把主题Context和选中态Context分开,主题变化不会导致所有节点重渲染,选中态变化也不会让所有节点跟着动。
const renderItem = useCallback( ({ item }: { item: TimelineFlatNode }) => ( <MemoizedTimelineNode node={item} onSelect={handleSelect} /> ), [handleSelect] );这个模式是长列表性能的基本功,尤其鸿蒙设备上的RN运行时,重渲染开销比iOS更大,提前控制好能省掉后面大部分优化工作。
6.4 锚点跳转的滚动定位实现
产品要求“回到今天”“跳到某个分组”,滚动定位走了不少弯路。先用scrollToIndex,在节点高度固定时没问题,但高度不固定时直接失效。后来改成基于heightCache的scrollToOffset,先算出目标节点前的所有节点高度和,再加上分组头高度,就是offset。
还有一个隐蔽的坑:分组头开了stickyHeaderIndices后,scrollToIndex算出来的偏移量会被吸顶高度吃掉一部分,目标节点跑到吸顶层下面被盖住。解决方法是计算offset时把吸顶分组头高度加上去:
function getOffsetForNode(nodeIndex: number, groupHeaderHeight: number): number { let offset = 0; for (let i = 0; i < nodeIndex; i++) { offset += heightCacheRef.current.get(flatNodes[i].key) ?? estimatedItemHeight; } offset += groupHeaderHeight * (groupIndex + 1); return offset; }说实话,鸿蒙上stickyHeaderIndices的兼容性不如iOS稳定,如果不需要吸顶效果,我建议直接关掉,能省掉一大部分定位相关的兼容性处理。
7. 完整的组件代码骨架与踩坑实录
7.1 数据层代码骨架
数据层核心就是normalize这个纯函数,服务端返回的原始事件流经过它变成组件能消费的分组和扁平索引,这段代码前面已经给过,这里只强调一个调用约定:这个函数必须在数据到达时调用一次,不能在render函数里调用,否则每次渲染都会重新分组排序。
7.2 状态层Hook骨架
useTimeline对外暴露三个能力:刷新数据、选中节点、跳转到指定分组。
export function useTimeline(dataSource: () => Promise<RawEvent[]>) { const [state, dispatch] = useReducer(timelineReducer, initialState); const nodeMapRef = useRef(new Map<string, TimelineNode>()); const refresh = useCallback(async () => { dispatch({ type: 'timeline/loading' }); try { const events = await dataSource(); const { map } = mergeTimelineNodes(nodeMapRef.current, events); nodeMapRef.current = map; const normalized = normalizeTimelineData(Array.from(map.values())); dispatch({ type: 'timeline/loaded', payload: normalized }); } catch (err) { dispatch({ type: 'timeline/loadError', payload: (err as Error).message }); } }, [dataSource]); const selectNode = useCallback((id: string, groupId: string) => { dispatch({ type: 'timeline/selectNode', payload: { id, groupId } }); }, []); return { state, refresh, selectNode }; }7.3 UI组件渲染骨架
Timeline主体用FlatList渲染,分组头通过renderSectionHeader实现,数据层已经给好了groups和flatNodes,渲染层只是消费。
export function Timeline({ data, layoutMode = 'left' }: TimelineProps) { const { state, selectNode } = useTimeline(() => Promise.resolve(data)); const { flatNodes } = state; return ( <FlatList data={flatNodes} renderItem={renderItem} keyExtractor={(item) => item.key} getItemLayout={getItemLayout} windowSize={7} removeClippedSubviews /> ); }7.4 踩坑列表
我把这次开发中遇到的坑整理成了表格,方便直接查阅。
| 现象 | 根因 | 解决方式 | 后续注意 |
|---|---|---|---|
| 滚动时连接线出现断痕 | 虚拟化回收未渲染节点,节点内线段缺失 | 每个节点画固定长度向下半段,间距保持一致 | 节点间距调整时,线段长度也要同步调整 |
| hairlineWidth在鸿蒙返回0 | 鸿蒙适配层对hairlineWidth支持不稳定 | 固定1宽度+rgba半透明色 | 不要用opacity做整线透明,会影响子元素 |
| 字体缩放后节点内部溢出 | 内容区固定高度,文字撑不开 | 内容区改用padding+lineHeight自适应 | getItemLayout要用预估高度,onLayout再修正 |
| 分组吸顶遮挡目标节点 | stickyHeaderIndices导致scrollToIndex偏移 | offset计算时加上吸顶分组头高度 | 不需要吸顶时直接关掉,省兼容性处理 |
| 深色模式阴影异常 | 鸿蒙对elevation和boxShadow支持不完整 | 卡片改用背景色+1px描边+圆角 | 深浅色模式各跑一遍对比度检查 |
| 全量刷新导致列表跳动 | 轮询返回全量数据直接替换引用 | 用Map做缓存,diff后只替换变更节点 | 无变化节点保持引用不变,配合React.memo |
7.5 给后续扩展留的接口
时间线组件不可能一次性满足所有需求,设计时留了三个扩展点。第一个是节点内容完全开放,TimelineContent接受任意ReactNode,业务方想渲染什么就渲染什么。第二个是状态图标可注册,维护一个Map<TimelineNodeStatus, ReactNode>,不同业务可以注册自己的图标组件。第三个是分组粒度可配置,现在是按天分组,如果后续需要按月、按小时分组,只需要改normalizeTimelineData里的formatDateKey函数,渲染层不用动。
这次时间线组件做完之后,我自己最大的感受是:时间线组件真正的难点不在画一条线和几个圆点,而在数据组织、状态边界和渲染策略。五个层分开之后,后面加主题、改布局、接新数据源都变得很顺,不用回头重构。最后再提一个小技巧:调试时间线性能时,可以先把FlatList的windowSize调大观察滚动,只要重渲染次数明显下降,再把windowSize慢慢调回去。这个参数在鸿蒙设备上的表现和iOS差别不小,值得单独跑一遍真机验证。