ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

鸿蒙React Native提示聚合页开发:状态管理与性能优化实战

鸿蒙React Native提示聚合页开发:状态管理与性能优化实战

前一阵在做鸿蒙系统上的React Native页面迁移,其中一个模块叫CareTipsScreen,名字听起来洋气,翻译过来就是“关怀提示页”。它做的事情很聚焦:把设备侧需要用户关注的信息,比如电池电量偏低、存储空间快满了、系统有更新待安装、某个后台权限被系统回收,全部以卡片列表的形式集中展示。这是这套鸿蒙组件开发系列的第七篇,前几篇陆续搭完了主框架和几个基础页面,这篇专门落在提示聚合页上。

这个模块交到我手里时,产品只给了一句描述:“搞一个页面,把所有的‘建议’都放上去。”听起来轻巧,真正动手才发现到处都是选择:提示数据从哪来、什么该展示什么该过滤、用户看完之后怎么处理、列表卡顿怎么优化、鸿蒙设备上安全区怎么适配……今天这篇就把我从需求收敛、数据结构设计、状态管理、列表渲染优化,到鸿蒙适配和最终真机验证的完整过程拆开讲,每一步都会说清楚当时为什么这么选。这篇不是纯贴代码的教程,更像一次完整的组件复盘,适合正在做鸿蒙端React Native适配、或者打算把现有跨平台应用迁到鸿蒙生态的移动端开发参考。

1. 先搞清楚CareTipsScreen要解决的业务问题

1.1 提示聚合页和普通列表页的本质区别

开始写代码之前,我花了不少时间想一个问题:CareTipsScreen和订单列表、资讯Feed这些常见列表页,到底差在哪里?

订单列表的核心是“浏览”,用户在里面滑动查看、筛选、进入详情;资讯Feed的核心是“消费”,用户不断下滑获取新内容。但CareTipsScreen这类提示聚合页的核心是“决策”。用户打开这个页面,不是为了刷内容,而是想知道“现在有没有什么需要我处理的”。每一条提示都对应一个待办或者潜在风险,页面必须让用户在三秒钟之内判断出“这条跟我有没有关系”“我要不要处理”,然后给出明确的操作出口。

这个定位直接影响后续设计决策。比如我当时就明确砍掉了“无限滚动”这个需求,列表不需要上拉加载更多;每条卡片必须带操作按钮,不能只展示文案;信息层级上,危险级别的提示要一眼抓住眼球,普通建议则要安静地待在那里。

如果一开始没把“决策型列表”这个本质想清楚,很容易把它做成第二个公告栏或消息中心,界面花哨,但用户扫完一圈不知道该干嘛。

1.2 数据模型:一条提示必须包含什么字段

需求收敛完,第一步是定数据结构。这个页面涉及的提示类型很多,为了不让后端接口各写各的,我把所有的“提示”统一收敛成一套CareTip模型。这里直接给出我当时的TypeScript定义:

export interface CareTip { // 全局唯一标识,列表key和数据更新都靠它 id: string; // 一级标题,一句话说清是什么事 title: string; // 二级描述,补充必要的细节,最多两行不能更多 description: string; // 严重级别,直接决定卡片的视觉权重 level: 'info' | 'warning' | 'critical'; // 点击卡片之后的行为类型:跳转、弹窗、打开系统设置等等 actionType?: 'navigate' | 'dialog' | 'openSetting'; // 行为参数,比如要跳转的路由名、要展示的文案 actionPayload?: Record<string, unknown>; // 过期时间戳,过了这个时间这条提示不再展示 expireAt: number; // 创建时间,用于同级别提示排序 createdAt: number; }

这个字段看起来不多,但每一项都是被实际需求逼出来的。

level字段决定了页面不能只做一套样式。info级别的提示用中性配色,warning用黄色系,critical用红色系,还要配合陀螺仪图标或感叹号图标。

actionType非常关键。提示页最怕的是“资讯化”,用户看完满屏文字,想处理却无处下手。所以每条提示必须回答一个问题:点了它之后干什么。要么跳转到具体设置页,要么弹窗确认,要么直接打开系统设置某个面板。

expireAt是我后来补上的。有段时间发现某些本地策略生成的提示永远在列表里待着,比如“建议开启自动备份”,用户一直忽略它就永远显示。加入过期时间之后,策略可以给每条提示一个有效期,过期自动下沉或移除,列表就不会变成一潭死水。

1.3 与后端接口的分工边界

这里有一个容易踩的坑:CareTipsScreen的数据不能全部依赖后端接口,也不能全部依赖本地实时计算,必须是“本地为主、服务端为辅”。

我当时的处理方式是这样的:设备状态类提示,比如电量、存储、权限状态,由前端通过原生模块直接读取;运营策略类提示,比如“新版本上线了”“有个新功能可以试试”,由服务端接口下发。两者在进入组件之前统一转换为CareTip结构,合并成一个数组交给UI层。

这种分工带来的好处是,前端不祈祷网络通畅才能展示最关键的设备风险提示,运营策略又可以随时下发,不需要发版。合并逻辑我放在一个工具函数里,它要做的事情包括:按expireAt过滤过期项、按level做优先级排序、对重复的id做去重。

2. 状态管理与列表渲染,这是组件的地基

2.1 为什么状态管理我选了useReducer而不是useState

CareTipsScreen不是一个单纯的展示页,它需要维护一组不断变化的交互状态。除了基本的数据列表和加载状态,还有已读/未读标记、用户点了“稍后提醒”之后的重新排序、下拉刷新带来的整体替换。

如果用一堆useState管理这些,代码很快会乱成散沙。我这里推荐用useReducer,原因是这个页面的状态更新逻辑不是“覆盖”而是“合并”,多条分支状态互有牵连,集中到一个reducer里反而让流程清晰。当时定义的状态和动作是这个样子:

type CareTipState = { tips: CareTip[]; loading: boolean; refreshing: boolean; // 已经被用户延后处理的提示,记录延后到的时间戳 snoozedTips: Record<string, number>; }; type CareTipAction = | { type: 'LOAD_TIPS'; tips: CareTip[] } | { type: 'MARK_DONE'; id: string } | { type: 'SNOOZE'; id: string } | { type: 'REFRESH_START' } | { type: 'REFRESH_END'; tips: CareTip[] };

reducer的核心逻辑很简单,但“稍后提醒”这个动作有一点要特别注意。它不是简单地从数组里把某条删除,而是要把这条的expireAt重新设置为当前时间加24小时,再按新时间戳插回正确位置。如果直接在原state上用splice或者sort,可能会触发意料之外的引用变更。所以我选择先基于现有数组计算出新的tips数组,然后整体返回:

function careTipReducer(state: CareTipState, action: CareTipAction): CareTipState { switch (action.type) { case 'LOAD_TIPS': return { ...state, tips: action.tips, loading: false }; case 'REFRESH_START': return { ...state, refreshing: true }; case 'REFRESH_END': return { ...state, tips: action.tips, refreshing: false }; case 'MARK_DONE': return { ...state, tips: state.tips.filter((tip) => tip.id !== action.id), }; case 'SNOOZE': { const now = Date.now(); const nextExpire = now + 24 * 60 * 60 * 1000; const remaining = state.tips.filter((tip) => tip.id !== action.id); const snoozedTip = state.tips.find((tip) => tip.id === action.id); if (!snoozedTip) { return state; } const updatedTip = { ...snoozedTip, expireAt: nextExpire }; const nextTips = [...remaining, updatedTip].sort( (a, b) => b.level.localeCompare(a.level) || b.createdAt - a.createdAt ); return { ...state, tips: nextTips, snoozedTips: { ...state.snoozedTips, [action.id]: nextExpire }, }; } default: return state; } }

这里的localeCompare不是特别可靠的排序方式,因为它按照字符串顺序而不是我们自定义的级别顺序。真实项目里我建议写一个levelWeight映射,比如const levelWeight = { critical: 3, warning: 2, info: 1 },然后按数字排。这样更稳。

2.2 FlatList参数调优:同样的代码,鸿蒙上更容易卡

列表组件我直接用React Native自带的FlatList,没有额外引入第三方库。它在鸿蒙端的基础能力没问题,但渲染性能需要主动调参数。

几个最关键的参数:

  • initialNumToRender控制首屏渲染条数。默认值是10,对提示页来说偏多,我压到6,首屏速度提升明显。
  • maxToRenderPerBatch控制每批渲染的条数,默认10,我保持10,不调太低,否则快速滑动时会频繁产生空白。
  • windowSize控制渲染窗口的倍数,默认21,对短列表影响不大,但配合下面的removeClippedSubviews会有收益。
  • removeClippedSubviews在Android上默认开启,可以让屏幕外组件直接卸载。鸿蒙端我实测发现开启后滚动更省内存,但前提是列表项不能有复杂的阴影或圆角裁剪,否则会出现白块。

另外,renderItem函数一定不要写在JSX内联位置,否则每次父组件更新都会重新创建函数引用,导致所有列表项重新渲染。优先把它抽成组件外部函数,配合React.memo包裹的卡片组件使用。

3. 核心实现拆解:从页面骨架到卡片交互

3.1 页面总骨架:SafeAreaView + FlatList组合

页面的最外层骨架不算复杂,但有一层细节要处理到位。我直接用SafeAreaView包裹,内部是顶部标题栏和列表主体。标题栏左侧显示“关怀提示”,右侧显示未读数,这个未读数可以直接从tips.length派生出来,不需要额外维护一个count状态。

列表主体根据状态分三种展示形态:加载中显示骨架屏、空数据显示带插画的空态、正常情况显示提示卡片列表。骨架屏我用自己的轻量实现避免引入过重依赖,就是用几个灰色的Animated.View做透明度呼吸。

3.2 卡片组件:一个组件应对三种级别

每张卡片是一个独立组件CareTipCard,外部用React.memo包裹。卡片视觉结构分三块:左侧是一个圆底图标容器,中间是标题和描述文字,右侧是操作区。

这里有个交互细节值得说:操作区不是直接铺开两个按钮,而是默认只展示一个箭头按钮。如果用户点击卡片主体,根据actionType执行跳转;如果用户长按卡片,弹出底部半屏操作面板,里面有“标记完成”和“稍后提醒”两个按钮。

为什么这么设计?因为提示页的卡片空间有限,两个按钮会挤压描述文本的展示区域,而且会拖慢视觉扫描速度。把次要操作藏到长按手势里,卡片一屏能多放一条提示,主操作路径依然清晰。

卡片颜色的核心逻辑我封装成一个函数:

function getLevelPreset(level: CareTip['level']) { switch (level) { case 'critical': return { iconBackground: '#FFF0F0', iconColor: '#E5484D', borderColor: '#F3C2C2', }; case 'warning': return { iconBackground: '#FFF8E6', iconColor: '#D9822B', borderColor: '#F0D9A8', }; default: return { iconBackground: '#F2F4F7', iconColor: '#5E6C84', borderColor: '#E3E6EC', }; } }

用预设对象而不是散落各处的三元表达式,是为了深色模式适配的时候可以一次性替换色板。后面适配阶段我会改成动态函数返回不同主题的色值。

3.3 卡片进场与退出的动画处理

列表里删除一条提示时,如果瞬间消失,视觉上会很突兀,用户甚至不知道自己做了什么。我给卡片进场和退出都加了动画。

进场用Animated.timing,把容器透明度从0弹到1,同时带一个轻微的translateY位移,动画时长控制在200毫秒左右,使用useNativeDriver: true。

退出稍微麻烦一点,FlatList在删除数据项时组件会立刻卸载,没有给动画留时间窗口。我的处理方案是:先不直接派发MARK_DONE动作,而是先在卡片内部把高度和透明度动画做完,再用InteractionManager延迟几十毫秒再派发删除动作。这样视觉上卡片有一个收缩消失的过程,列表数据才真正移除。

function deleteWithAnimation(cardRef: number) { Animated.parallel([ Animated.timing(opacityAnim, { toValue: 0, duration: 180, useNativeDriver: true, }), Animated.timing(heightAnim, { toValue: 0, duration: 200, useNativeDriver: false, }), ]).start(() => { dispatch({ type: 'MARK_DONE', id: cardRef }); }); }

注意heightAnim不能使用useNativeDriver: true,因为布局属性的动画必须走JavaScript线程,这是React Native本身的限制。

3.4 接入鸿蒙原生能力:电量、存储、权限状态获取

CareTipsScreen最核心的数据来源是设备状态。这部分必须引入鸿蒙原生模块的桥接能力,React Native自身没有读电量、查存储空间的通用API。

我们的做法是在鸿蒙原生侧暴露一个DeviceInfoModule,提供三个方法:getBatteryLevel()、getStorageUsage()、checkPermissionStatus()。前端在页面加载时通过NativeModules调用:

import { NativeModules } from 'react-native'; const { DeviceInfoModule } = NativeModules; async function fetchDeviceTips() { const [battery, storage, permissions] = await Promise.all([ DeviceInfoModule.getBatteryLevel(), DeviceInfoModule.getStorageUsage(), DeviceInfoModule.checkPermissionStatus(), ]); const tips: CareTip[] = []; if (battery.level < 0.2) { tips.push({ id: 'battery_low', title: '电量低于20%', description: '当前剩余电量偏低,建议尽快充电', level: 'warning', actionType: 'navigate', actionPayload: { screen: 'BatterySettings' }, expireAt: Date.now() + 3600_000, createdAt: Date.now(), }); } // 存储、权限模块同理 return tips; }

实际接入时你会发现,Promise桥接比callback方式好用很多,尤其在Promise.all里并行请求多个状态时,代码会清爽不少。如果鸿蒙侧的Module是Sync类型的方法,也可以走同步调用,但我不建议在列表页主线程做过多同步查询,容易卡掉帧。

4. 鸿蒙适配阶段,我踩过的几个具体坑

4.1 安全区:折叠屏内外屏差异

页面刚上真机测试时,第一版是拿标准直板机调的,一切正常。换上某款折叠屏之后,发现列表底部被导航条挡住了一截,标题栏也被状态栏浸入了。原因是鸿蒙折叠屏在外屏和内屏两种形态下,安全区数值差异很大,而且系统状态栏的高度是会动态变化的。

直接使用React Native自带的SafeAreaView在某些鸿蒙版本上并不可靠,它对折叠屏展开状态的处理存在偏差。我最后的方案是封装一个HarmonySafeArea组件,底层读取鸿蒙原生模块提供的getSafeAreaInsets(),在页面级别做一次归一化,把上下左右的安全距离作为样式注入到容器里。

这个组件内部还要监听onDisplayModeChange事件,当设备从折叠状态展开或合起时,自动更新insets。列表的contentContainerStyle也要预留底部安全距离,不能只靠外层容器撑开。

4.2 深色模式:useColorScheme偶发返回null

鸿蒙系统启动深色模式之后,RN层面通过useColorScheme()应该返回'dark',但我实测在个别版本上会出现首次返回null、切换主题时不刷新的问题。这个坑单独看很诡异,排查了好一阵才发现是原生主题事件没有及时同步到JS侧。

解决方案是不要只依赖useColorScheme,而是自己做一层主题管理:在原生侧把当前系统的颜色模式通过模块方法主动上报,同时在页面启动时先取一次缓存值,再监听系统的主题变更事件。这样即使RN的hook偶发失灵,页面也能拿到正确主题。

色板这块,我把上面提到的getLevelPreset改成了接收isDark参数,深色模式下把浅色背景换成深灰底、警示色换高饱和版本。做提示页尤其要注意:critical级别的红色在深色背景下如果还是原来的浅粉底,对比度会不够,文字都看不清。

4.3 中文字体加粗:部分机型渲染发虚

鸿蒙系统内置了中文字体,但React Native里直接设置fontWeight: 'bold',在某些中低端鸿蒙机型上会渲染发虚,特别是描述文字字号只有13号的时候,加粗后边缘毛刺明显。

后来我把标题的加粗样式改成了fontWeight: '700',效果比'bold'好一些;描述性文字干脆去掉加粗,用深一级的颜色来区分层级,视觉上更干净。如果你要在鸿蒙设备上做大量中文文本渲染,建议准备一套自定义字体,用fontFamily指定鸿蒙系统字体名,避免RN默认字体在部分机型上出现基线偏移。

4.4 下拉刷新与列表滚动性能差异

RefreshControl在鸿蒙端表现基本可用,但有一个体验问题:下拉手势触发的临界值比Android原生要敏感,稍微下拉一点就开始刷新,页面会频繁闪动。我通过把RefreshControl的progressViewOffset和refreshing状态配合调整,给刷新触发加了约10像素的缓冲区间。

滚动性能方面,我发现鸿蒙设备上FlatList的滑动帧率比Android原生略低,尤其在列表项带阴影的情况下。排查后确认是阴影渲染开销过大。把卡片的阴影改成用细边框和浅色背景区分层级,滚动帧率立刻回到流畅水平。这就是为什么我上面卡片预设里用的是borderColor而不是shadow。

4.5 原生模块注册方式:TurboModule路径的处理

鸿蒙生态对React Native的原生模块支持,目前有旧版bridge和新版TurboModule两种路径。我们工程里是用TurboModule模式运行的,所以自定义的DeviceInfoModule不能只在原生侧注册一个普通模块,还需要在.ets文件里实现对应的Spec接口,再通过codegen生成绑定代码。

这个工作量不大,但确实容易忽略。如果你从Android工程迁移过来,第一反应可能是照抄原来的Module写法,到了鸿蒙端就会发现反射拿不到模块。建议一开始就确认好工程用的是哪条桥接路径,再去查对应的原生模块编写规范。跑通一次之后,后面接电量、存储、权限这些都是复制粘贴的事。

5. 性能调优与最终实测:从“能跑”到“好用”

5.1 首屏渲染时长优化

CareTipsScreen依赖的数据分为两块:本地设备状态查询本地就有结果,服务端运营配置需要网络请求。首屏如果等网络回来再渲染,会白屏很久。我当时的优化方案是:先加载本地设备状态生成的提示,立刻展示;运营配置回来后再通过dispatch({ type: 'LOAD_TIPS' })合并进列表。这样首屏不会空转,用户看到的内容也不会闪跳。

首屏的图片资源也要处理。卡片的图标我全部用iconfont方案,本质上就是一套自定义字体,不涉及位图解码。相比png或webp图标,内存占用少一个量级,渲染也不会因为解码而产生掉帧。

5.2 列表快速滑动时的内存表现

短列表页通常不用太担心内存,但CareTipsScreen在真机上有一个特殊场景:折叠屏展开后列表可视区域变大,滑动更快,如果每张卡片内部持有大量图片资源,内存抖动很容易看出来。我除了用iconfont之外,还给图标容器加了明确的宽高和borderRadius,避免运行时动态布局计算带来的额外开销。

removeClippedSubviews在鸿蒙端实测有效,但要配合稳定的keyExtractor。我直接返回tip.id,没有做字符串拼接,这样React的diff算法能稳定复用组件实例,不会因为key变化导致组件重建。

5.3 真机验证:三台设备的性能对比

最终验证阶段,我在三台不同定位的设备上做了对比:一台旗舰级直板机、一台中端机型、一台折叠屏。数据如下:

设备定位首屏渲染(毫秒)删除卡片动画帧率列表滑动帧率内存峰值(MB)
旗舰直板机260606038
中端机型380555845
折叠屏(展开态)340555552

中端机型和折叠屏在删除动画期间会出现掉帧,原因是heightAnim走了JavaScript线程。后来我把高度动画改成直接用LayoutAnimation配合UIManager处理,掉帧缓解了不少。但LayoutAnimation在鸿蒙端需要确认是否默认开启,没有的话要先在应用启动阶段执行一次启用逻辑。

5.4 后续可以继续完善的扩展点

目前的CareTipsScreen已经能覆盖设备风险提示和运营活动展示,但还有几个方向值得继续做。比如接入鸿蒙的推送服务,把critical级别的提示从后台直接推送到通知栏,用户点击通知直达对应卡片;再比如给不同提示增加智能排序权重,根据用户历史点击率动态调整优先级,而不是固定按level排序。

另外,多设备协同场景下,同一用户可能在手机、平板、折叠屏上看到不同的提示列表,这一版的数据模型还没有处理设备维度,后续若要做“某个设备上已读,另外一个设备同步已读”,需要在CareTip模型里加一个deviceScoped字段,或者引入统一的同步状态位。这块如果做,会连带修改reducer的合并策略,不是换接口那么简单。


最后说点个人体会。这套CareTipsScreen做下来,我最深的感受是:提示类组件的难点从来不在UI有多华丽,而在“克制”二字。每加一个弹窗、每多一条提示,都是在消耗用户的耐心。把信息聚合清楚、把操作入口设计明确、把性能做到不掉帧,这比堆砌任何花哨的视觉都重要。如果你正在做类似的多端适配页面,建议先花半天把数据模型和状态边界理清楚,再动手写代码。我在实际开发中踩过的所有坑,几乎都是前期设计偷懒留下的债,而不是React Native本身有多难。后面再做同类页面时,把这篇里的坑提前绕开,开发速度能快不少。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进