ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

鸿蒙NEXT上React Native聊天列表:消息置顶与FlatList优化

鸿蒙NEXT上React Native聊天列表:消息置顶与FlatList优化 做鸿蒙应用开发的同行最近应该明显感受到一个变化HarmonyOS NEXT 上跑 React Native 这件事已经从“能不能跑”的验证阶段进入到“怎么跑得稳”的落地阶段。RN 的鸿蒙适配层落地之后不少双端复用的团队开始把聊天、IM 这类高频场景往鸿蒙上搬。我前段时间正好把一个聊天列表页面完整跑通了核心需求只有一个——最新消息自动置顶。听起来好像就是 sort 一下的事但真正落下去数据模型、列表性能、更新时机、滚动位置这些点全部要一起考虑不然随时会翻车。这篇文章把完整方案和踩坑过程梳理出来给准备在鸿蒙上用 RN 做列表类页面的同学做个参考。不管你是刚接触鸿蒙开发还是已经有 RN 基础、想了解鸿蒙侧适配的差异应该都能从这里拿到一些可以直接用的东西。1. 方案选型与整体设计1.1 为什么在鸿蒙上选择 React Native先说结论如果你手头已经有一套成熟的 RN 双端业务代码鸿蒙这里继续用 RN性价比是最高的。以往鸿蒙上做应用只有两条路用 ArkTS 重写一遍业务或者等 WebView 壳方案跑通。重写的成本不用多说业务逻辑、组件库、埋点上报、状态管理全都要从零来一遍WebView 壳虽然便宜但性能和体验在聊天这类高频交互页面上不太行——列表滚动掉帧、输入法弹起时机错位、图片加载缓存不可控这些都是硬伤。React Native 的鸿蒙适配走的是社区驱动的路线核心思路是把 RN 的渲染层映射到 ArkUI 的组件树上同时把 JS 引擎跑在鸿蒙系统上。相比 ArkTS 原生RN 的优势在于业务代码安卓、iOS、鸿蒙三端共享只要各自平台侧做好桥接适配UI 和逻辑几乎不用动。相比 WebViewRN 渲染的是原生组件列表滚动、触摸响应、动画都在原生层执行体验至少能摸到原生的边。聊天列表恰好是那种“对滚动性能和更新帧率敏感但逻辑又相对固定”的页面非常适合拿来做迁移试点。1.2 聊天列表的需求拆解与数据模型先把需求边界说清楚。这里说的“聊天列表页面”指的是 IM 里的会话列表页——每行是一个会话显示对方的头像、昵称、最后一条消息摘要、最后消息时间以及未读数。所谓“最新消息置顶”就是当一个会话收到了新消息它要自动浮到列表顶部同时用户手动置顶的会话还要始终排在最上面不受新消息时间影响。这是两个层面的事不能混为一谈。数据模型我用 TypeScript 定义了一个ConversationItemexport interface MessageContent { type: text | image | voice | file; content: string; timestamp: number; fromMe: boolean; status: sending | sent | failed; } export interface ConversationItem { id: string; peerName: string; avatar: string; lastMessage: MessageContent; unreadCount: number; pinned: boolean; muted: boolean; }字段看起来不复杂但有几个地方要提前想清楚。lastMessage别只存一个字符串要把消息类型和状态一起带进去这样列表里才能区分“图片消息”和“语音消息”的摘要显示。pinned是手动置顶标记它和新消息置顶是两个独立条件排序时要一起参与运算。unreadCount在鸿蒙上要配合角标权限使用后面会细说。整体方案就是用 FlatList 渲染会话列表用 zustand 做全局状态管理收到的消息通过 store 的 action 更新对应会话然后按“手动置顶优先、最新消息时间次之”的规则重排列表。这套结构在安卓和 iOS 上都很常见搬到鸿蒙上只替换了平台适配层业务代码一行没改。2. 开发环境与工程初始化2.1 鸿蒙侧工程需要改的配置项在鸿蒙工程里跑 RN最核心的事情就是替换 RN 的 Android 平台适配层。工程上使用的库是react-native-ohos/react-native-harmony它把 RN 的 AppRegistry、UIManager、DeviceInfo 等模块映射到了鸿蒙的 HarmonyOS SDK 之上。实际开发时建议直接用它的脚手架初始化工程然后手动确认下面几个配置。首先是entry/src/main/module.json5。这个文件相当于安卓的 AndroidManifest需要手动确认网络权限{ module: { requestPermissions: [ { name: ohos.permission.INTERNET }, { name: ohos.permission.GET_NETWORK_INFO } ] } }没有 INTERNET 权限Metro 的 bundle 加载会直接失败表现就是页面白屏加控制台报 network 错误。这个是我最开始踩的第一个坑一度以为是 React Native 鸿蒙版的 bug后来发现只是权限没加。其次是build-profile.json5里的 SDK 版本。鸿蒙适配层对 API 版本有要求我这边用的是 HarmonyOS NEXT 的 API 12 及以上。低于这个版本适配库里的 API 可能对不上编译会报一堆 undefined 错误。检查的时候直接看compatibleSdkVersion是不是 12 或更高就行。还有一个容易被忽略的配置Metro 的 host 地址。鸿蒙模拟器里跑开发调试Metro 默认跑在电脑的 8081 端口上模拟器想访问宿主机需要走adb reverse。鸿蒙调试工具里可以直接执行hdc reverse tcp:8081 tcp:8081执行完模拟器里访问localhost:8081就能透传到电脑上的 Metro 服务了。这一步不做首屏永远白屏因为 JS bundle 根本拉不下来。2.2 依赖安装与 Metro 启动细节工程初始化完成之后依赖安装和 RN 三端开发没有本质区别。进入工程根目录npm install npm run startnpm run start会启动 Metro默认监听在 8081 端口。这里要注意Metro 的缓存问题在鸿蒙上一样存在。改完原生配置或者新装依赖之后如果启动报错或者加载的代码还是旧的先清缓存再跑npm run start -- --reset-cacheRN 版鸿蒙适配这边还有一个特殊性由于要同时维护 JS 层和 ArkTS 层两层代码工程里会看到entry/src/main/ets/目录下面有一些原生侧的桥接代码比如EntryAbility.ets、RNInstance的初始化逻辑。刚开始会觉得结构比安卓工程复杂但其实那些文件除了首次配置后续很少需要动真正的业务开发还是在src/目录下的 TS/TSX 文件里。还有一点属于“经验之谈”鸿蒙上直接跑 debug 模式首屏渲染会比 release 模式明显慢一截因为在加载 Metro bundle 而不是本地 bundle。如果只是验证页面结构和布局先用 release 模式把 bundle 打包到本地能省去很多等待时间等真正需要调试 JS 时再切回 debug。我自己的习惯是UI 开发用 release逻辑调试用 debug两边各取所长。3. 聊天列表页面核心实现3.1 FlatList 渲染与基础组件整个页面的骨架我用一个ConversationListPage组件来承载内部就是一个 FlatList。RN 的 FlatList 是虚拟列表不会一次性渲染所有行这是聊天列表性能的根基也是我在鸿蒙上继续用 RN 而不是改用 ScrollView 的原因。逐行遍历渲染几千个会话ScrollView 会直接把内存打爆FlatList 只渲染可视区附近的行表现完全不同。先看组件骨架import React, { useCallback, useMemo } from react; import { FlatList, View, Text, Pressable, StyleSheet } from react-native; import { SafeAreaView } from react-native-safe-area-context; import { useChatStore } from /stores/chatStore; import { ConversationItem } from /types/conversation; export function ConversationListPage() { const conversations useChatStore(state state.conversations); const renderItem useCallback(({ item }: { item: ConversationItem }) { return ConversationRow item{item} /; }, []); const keyExtractor useCallback((item: ConversationItem) item.id, []); return ( SafeAreaView style{styles.container} FlatList data{conversations} renderItem{renderItem} keyExtractor{keyExtractor} initialNumToRender{12} windowSize{7} maxToRenderPerBatch{10} updateCellsBatchingPeriod{50} removeClippedSubviews / /SafeAreaView ); }这里有几个参数说明一下。initialNumToRender控制首次渲染的行数默认是 10聊天列表里有头像和摘要信息第一屏往往塞不下 10 行我放到 12 只是为了减少首屏补充渲染的批次。windowSize决定保留渲染范围的高度倍率值越大越流畅、占内存也越多聊列表场景 7 是我调试下来比较稳的折中。removeClippedSubviews会裁剪移出可视区的视图能省内存但前提是行内不用绝对定位的浮层否则会出现内容偶发闪现的情况鸿蒙上尤其明显后面排查章节再展开。3.2 时间格式化与未读角标处理聊天列表页里最“琐碎但影响观感”的部分是每条会话的时间显示和未读角标。时间不能直接抛时间戳上去要按照 IM 行业的通用规则做格式化今天显示HH:mm昨天显示“昨天”7 天内显示星期更早显示具体日期。我封装了一个formatTimefunction formatTime(timestamp: number): string { const date new Date(timestamp); const now new Date(); const startOfToday new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const ONE_DAY 24 * 60 * 60 * 1000; if (timestamp startOfToday) { const hours String(date.getHours()).padStart(2, 0); const minutes String(date.getMinutes()).padStart(2, 0); return ${hours}:${minutes}; } if (timestamp startOfToday - ONE_DAY) { return 昨天; } if (timestamp startOfToday - 7 * ONE_DAY) { const week [周日, 周一, 周二, 周三, 周四, 周五, 周六]; return week[date.getDay()]; } const y date.getFullYear(); const m String(date.getMonth() 1).padStart(2, 0); const d String(date.getDate()).padStart(2, 0); return ${y}-${m}-${d}; }这里要注意startOfToday的计算方式。直接用new Date().setHours(0, 0, 0, 0)也行但跨时区场景下建议用new Date(now.getFullYear(), now.getMonth(), now.getDate())这种本地时区写法避免 UTC 偏移导致的“今天”判断错位。未读角标也有几个细节。超过 99 条就别显示 99 了直接显示99免打扰会话可以不显示数字只显示一个小红点。这些逻辑都不复杂但多个条件叠加在一起时建议写一个纯函数来输出展示文本和背景样式export function getUnreadDisplay(unreadCount: number, muted: boolean) { if (muted) { return unreadCount 0 ? { showDot: true, text: } : { showDot: false, text: }; } if (unreadCount 0) return { showDot: false, text: }; return { showDot: false, text: unreadCount 99 ? 99 : String(unreadCount) }; }未读角标的样式在鸿蒙上有一个特殊点角标如果是从右上角悬浮出来的不要用绝对定位的position: absolute然后去精确调top和right不同系统字体渲染宽度不一样很容易跑偏。我自己的方案是让角标作为行内元素排在头像区域右上角用 flex 布局而不是绝对定位这样无论字体怎么变化都不会叠到头像上。摘要文本也要按消息类型区分。图片、语音、文件消息不能直接显示原始 content 字段否则会露出一串图片 URL 或者文件名。统一处理function getMessageSummary(msg: MessageContent): string { switch (msg.type) { case image: return [图片]; case voice: return [语音]; case file: return [文件]; default: return msg.content; } }如果是一条发送失败的消息前缀要加红色感叹号和“发送失败”。这类细节在聊天列表里非常影响信任感漏掉了用户会觉得消息丢了。4. 最新消息置顶的排序与刷新机制4.1 置顶排序规则先 pin 后时间这应该是整篇文章最核心的部分。聊天列表的排序规则在设计上有一个优先级手动置顶优先于最新消息时间。也就是说用户手动置顶的会话不管有没有新消息都要排在普通会话上面普通会话之间再按最后一条消息的时间倒序排列。排序函数我是这样写的export function sortConversations(list: ConversationItem[]): ConversationItem[] { return [...list].sort((a, b) { const pinA a.pinned ? 1 : 0; const pinB b.pinned ? 1 : 0; if (pinA ! pinB) return pinB - pinA; return b.lastMessage.timestamp - a.lastMessage.timestamp; }); }两个细节值得多说。第一[...list]是先拷贝再排序不要直接在原数组上 sort。RN 的数据驱动渲染依赖引用变化如果直接改原数组FlatList 拿到的引用没变setState 根本不会触发重新渲染。第二pinned排序用了把布尔值转成 0/1 的技巧代码更紧凑。如果在 ArkTS 侧写也是一样的逻辑可以用Number(a.pinned)替代。这里要区分“新消息置顶”和“手动置顶会话不动”这两个需求。用户手动 pin 的会话如果收到了新消息它的位置本来就该在上面所以规则一已经天然覆盖了但如果用户 pin 了三个会话其中两个有新消息pin 会话之间的排序就该按消息时间倒了这就是第一层判断之后再比较 timestamp 的原因。有的团队会在会话模型上单独存一个lastActiveTime来代表“会话活跃时间”和最后一条消息的 timestamp 解耦。这样做的目的是支持“撤回消息不改变活跃时间”或者“用户已读后会话依然保持在顶部一段时间”。如果产品对这个有要求就在lastMessage.timestamp之外再加一个sortTime字段参与排序。具体业务具体分析但通用规则一定是排序字段和展示字段分离别把一个字段两用。4.2 新消息到达时的更新策略消息到达时页面的更新路径是WebSocket/长连接收到消息推送进 zustand 的 storestore 里更新目标会话的lastMessage和unreadCount然后调用排序函数重排。完整逻辑如下interface ChatState { conversations: ConversationItem[]; upsertMessage: (msg: MessageContent { conversationId: string }) void; } export const useChatStore createChatState((set, get) ({ conversations: [], upsertMessage: (msg) { const { conversations } get(); let found false; const next conversations.map(item { if (item.id ! msg.conversationId) return item; found true; return { ...item, lastMessage: { type: msg.type, content: msg.content, timestamp: msg.timestamp, fromMe: msg.fromMe, status: msg.status, }, unreadCount: msg.fromMe ? item.unreadCount : item.unreadCount 1, }; }); const finalList found ? next : [{ /* 构造新会话 */ } as ConversationItem, ...next]; set({ conversations: sortConversations(finalList) }); }, }));这段逻辑有几个容易写错的地方。map里返回新对象时一定要展开原来的字段只替换要改的lastMessage和unreadCount。如果直接item.lastMessage msg然后返回 item对象引用没变列表不会刷新就算刷新了也可能因为浅比较跳到错误的渲染分支。msg.fromMe的判断要放在未读数累加之前。自己发送的消息不该增加未读数这是基本规则。但如果用户自己发的消息发生在“对方已读”之后未读数还要归零这个场景需要后端或消息回执配合前端单独处理不了。第三个坑是“会话不存在时”。如果这是一条全新会话的第一条消息map不会命中任何现有会话需要手动构造一个会话对象放到列表头部。这里有个细节新会话的 unreadCount 如果不是自己发的初始值应该是 1。我见过有人在这里漏掉初始化导致新会话角标一直为 0。更新时用set而不是set(state ...)有什么差别在 zustand 里两者都能用但set直接传对象时zustand 会做浅合并。因为整个conversations数组是全新的引用FlatList 能正确感知数据变化如果写成set(state ({ ...state, conversations: ... }))也没有问题只是代码更啰嗦。我个人习惯用函数式因为后续要加多个 action 时逻辑更统一可以防止 this 指向问题。4.3 滚动位置与列表稳定性最新消息置顶带来一个隐性问题列表顶部数据的顺序会跟着消息时间变化。如果一个会话在用户滚动浏览到列表中间时有新消息进来它的位置突然跳到顶部这时候用户正在看的内容会从视线里消失。聊天列表产品上一般能接受这个行为但代码层面要尽量减少列表“跳动”的次数。我的做法是给消息更新加一层节流防抖。WebSocket 推送消息往往是批量到达的比如用户离线时积攒了 20 条消息服务器一次性推过来。如果每一条都触发一次sortConversations和set列表重组会连续发生好几次视觉上卡片会跳来跳去性能也白白浪费。解决方案是合并批量更新function flushMessageBuffer() { const buffer messageBuffer; messageBuffer []; const state useChatStore.getState(); const updated buffer.reduce( (list, msg) upsertConversation(list, msg), state.conversations ); useChatStore.setState({ conversations: sortConversations(updated) }); }然后用一个 100~200ms 的定时器批量冲刷。这个方案在安卓和 iOS 上用都没问题鸿蒙上更能感受到收益因为鸿蒙 RN 适配层的 JS 调原生渲染依然有桥接开销减少重复更新就是减少桥接次数。还有一个稳定性问题是 FlatList 的滚动位置保持。如果页面上有“加载更早消息”的功能往上翻到历史记录时新数据会插入列表头部这时候用户视觉位置会被顶下去。解决办法是记录当前滚动偏移在新数据渲染完成后用scrollToOffset恢复。这个过程在 RN 里需要用onContentSizeChange等一次内容区变化不能立刻恢复偏移否则新数据还没渲染出来偏移量恢复不准。5. 常见问题与性能排查实录5.1 启动白屏与首屏加载失败聊到鸿蒙上的 RN绕不开“启动白屏”这个热门话题。白屏的原因集中在三块Metro 没连上、字体资源加载失败、入口初始化顺序不对。Metro 没连上是最常见的。检查顺序是先看电脑上 Metro 有没有输出 bundle 请求日志再看模拟器里localhost:8081能不能访问最后确认hdc reverse tcp:8081 tcp:8081有没有执行。我把这三步写进了一个启动检查脚本里每次模拟器白屏跑一遍脚本就能定位 90% 的问题。字体加载失败是鸿蒙特有的坑。RN 在安卓上默认用系统 Roboto在鸿蒙上如果字体配置指向了一个不存在的 fontFamily渲染层会静默降级但某些版本的适配层会出现整页空白。解决办法是在全局样式里显式设置中文字体为HarmonyOS Sans或者直接不设置 fontFamily让系统走默认字体。不建议在单个 Text 组件上反复设置字体容易漏掉页面里某个角落导致渲染异常。入口初始化顺序的问题出在EntryAbility和 RN 的AppRegistry注册时机上。AppRegistry.registerComponent必须在 UI 显示前完成如果入口代码里先启动了某个原生页面RN 的 Surface 再挂载就会出现页面框架渲染但 JS 内容迟迟不出现的情况。排查时直接看 DevEco Studio 的控制台有没有SoLoader或者RNInstance相关报错通常比盲试更快。5.2 FlatList 长列表性能调优参数聊天列表滚动卡顿、内存飙升大多是 FlatList 参数没有针对大列表调优。我整理了一个速查表方便对照改参数表现现象关联参数调优方向首次进入卡顿明显initialNumToRender减小到 10 或 8减少首屏渲染行数快速滑动掉帧windowSize从默认 21 降到 5~9减少保留渲染范围滑动时出现空白块maxToRenderPerBatch适当增大到 15~20加快补充渲染频率不同行内容重叠/错位getItemLayout固定行高时配置getItemLayout让渲染定位更准确内存占用过高removeClippedSubviews开启后裁剪不可见区域但注意浮层组件会受影响getItemLayout是最值得配置的一项。聊天列表每行高度固定比如 72 或 80配置之后 FlatList 可以直接计算渲染位置不需要动态测量每一行的高度。行高的测量在鸿蒙适配层上比安卓贵因为要跨 JS 和 ArkUI 两层通信。所以固定高度列表一定加上getItemLayout{(_, index) ({ length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index, index, })}还有一点会话列表的行内容不建议用复杂的阴影和毛玻璃。鸿蒙 ArkUI 对阴影渲染的开销比安卓大列表滚动时会明显感觉到帧率下降。真要提升质感可以用一张很浅的分隔线代替卡片阴影视觉差距不大性能差距明显。5.3 置顶不生效与数据引用纠缠列表页最气人的问题莫过于“明明调了 sort置顶就是不生效”。这类问题九成出在数据引用上。第一个原因是 FlatList 的extraData。如果renderItem依赖了pinned、unreadCount这些字段之外的组件状态数据更新时 FlatList 可能因为默认浅比较而忽略变化。最简单的做法就是在 FlatList 上加一句FlatList data{conversations} extraData{conversations} ... /虽然data本身已经传了新引用加上extraData等于明确告诉 FlatList“这里的所有依赖都随 data 变化”。这在有些旧版本 RN 里是必踩的坑。第二个原因是排序时没有生成新数组。直接在原数组上调Array.prototype.sort原数组引用不变React 的 diff 检测不到变化。排序函数里那行[...list].sort(...)看起来不起眼实际上它决定了一切。如果团队里有人手滑把[...list]写成list这个 bug 会潜伏到上线。排查时不要只盯 FlatList。我在鸿蒙上还遇到过一种情况状态管理库正常更新了但列表 UI 因为PureComponent的浅比较一直拿旧 props。会话列表组件如果用了memo或React.PureComponent父组件每次传入的新item对象引用已经变了按理说不会命中缓存但鸿蒙适配层的组件对比有时会出现延迟更新。遇到这种诡异现象先在renderItem里打一行日志确认组件有没有真正收到新数据再决定是不是适配层的问题。实际项目里我最后是通过给会话行组件换key解决的——key变化会强制重挂载牺牲一点性能换逻辑确定性排查阶段很有效。6. 实操体会与后续扩展6.1 我在实际项目里的几条经验跑完整个页面最想跟大家分享的是三个体会。第一鸿蒙版 RN 的适配层还在快速演进遇到文档没覆盖的问题去 GitHub 的 issue 区搜关键词往往比查官方文档更有效。比如滚动列表的nativeEvent里多了一个鸿蒙特有的字段官方文档没有及时更新但 issue 区早有人讨论过。第二在鸿蒙上写 RN要把“跨端一致性”的预期调低一点。RN 本身已经做了一层抹平但鸿蒙的 ArkUI 在某些控件细节上回归不到安卓/iOS 完全一致的体验。比如进度圈样式、开关状态的过渡动画就不可能做到三端一像素不差。我的处理原则是功能逻辑完整、交互行为一致即可视觉细节以鸿蒙系统规范为准。第三把数据层和视图层的边界划清楚。聊天列表这种“收到消息后自动置顶”的需求最稳的架构是数据层zustand store持有会话列表的原始顺序只在对外暴露时做排序。这样列表 UI 永远只消费“已经排好序”的数据不需要关心消息什么时候到、怎么更新逻辑清晰排查问题也快。最后给一个小建议开发调试阶段尽量在发布模式下多跑几轮。鸿蒙上的 RN 发布模式会走 AOT 编译部分代码执行顺序和 debug 模式有差异尤其是 Metro 缓存、异步初始化、字体加载这几个环节。我之前有一个列表偶发白屏的问题在 debug 模式下怎么都复现不了切到 release 之后马上稳定复现后续排查效率高了很多。6.2 后续可以扩展的方向聊天列表这个页面做完之后很多东西都可以顺着往下做。左滑删除会话、长按置顶/取消置顶、会话分组免打扰分组、手动置顶分组都是在这个列表基础上加交互层。再进一步可以做搜索页对会话名称和消息摘要做本地索引做草稿箱角标在会话行上显示未发送的草稿内容。如果是做真正的 IM 应用后续还要考虑离线消息回执、消息状态多端同步、会话漫游这些服务端能力的配合。RN 鸿蒙版在这上面的接入方式和安卓/iOS 没有本质区别重点依然是桥接层的稳定性和消息轮询/推送通道的可靠性。这个项目跑下来我最大的感受是鸿蒙生态发展到现在跨平台方案已经不是一个“备用选择”而是可以认真作为生产方案来对待的。聊天列表页面只是个开始后面值得做的还有更多。如果你也在鸿蒙上用 RN 写列表类页面欢迎来交流踩过的坑一起分享能少走很多弯路。
返回列表