先说一个背景:我们团队在把一套 React Native 双端应用往鸿蒙上迁移时,最先遇到的不是网络层也不是存储层,而是一个看起来简单得不能再简单的 UI 需求——Accordion 手风琴的互斥展开。这个组件在 iOS 和 Android 上随便找个库就能用,但到了鸿蒙的 React Native 生态里,你会发现一个很现实的问题:能直接跑通的现成组件少得可怜,手风琴这种带状态联动和动画的交互组件,基本只能自己动手写。
我这篇文章就把手风琴互斥展开在鸿蒙 RN 环境下的整套实现思路、踩坑过程、代码细节都摊开讲。内容面向正在做鸿蒙化改造的 RN 开发者、以及打算用 React Native 开发鸿蒙应用但被 UI 组件卡住的同学。重点不只是给你一套能跑的代码,而是帮你搞清楚"为什么互斥展开在鸿蒙上会失效""动画为什么总是崩""状态应该放在哪一层"这些关键问题。
1. 为什么手风琴这种组件在鸿蒙上必须自己写
1.1 鸿蒙 RN 生态的组件现状
先泼一盆冷水:React Native 跑上鸿蒙,靠的是 react-native-harmony 这一层适配,它把 RN 的基础组件映射到了 ArkUI 的宿主组件上。View、Text、ScrollView、FlatList 这些基础组件已经能正常工作,但社区里大量"半依赖原生"的三方库,在鸿蒙上的兼容性就要打个问号了。
拿手风琴来说,社区里常用的 react-native-collapsible、react-native-accordion、以及不少 UI 库内置的 Accordion,实现方式通常分两类:
一类是纯 JS 层控制展开收起,内部依赖 LayoutAnimation 或者 Animated 做高度动画。这类组件在 iOS/Android 上很稳定,但 LayoutAnimation 在鸿蒙适配层上的支持程度并不完整,不少场景下动画直接不生效或者白屏闪一下。
另一类则嵌入了原生代码,比如 iOS 上用了原生 UITableView 的 section 展开收起,Android 上用了 RecyclerView 的动画辅助类。这种组件在鸿蒙上根本没有对应的原生实现,运行时会直接报"找不到原生模块"或者静默降级。
所以结论很直接:在鸿蒙 RN 项目里,如果你不想被三方库的兼容性问题牵着鼻子走,手风琴这种交互组件就得按鸿蒙的实际情况重新实现一遍。这也符合我们项目组当时定的原则——凡是涉及状态联动和动画的组件,一律自研,不赌社区兼容性。
1.2 互斥展开真正的技术难点不在"展开"而在"收起"
很多人觉得手风琴就是"点一个展开一个",哪有什么难点。但你把逻辑掰开看,真正的难点有三个:
第一个是互斥状态的管理。多个面板如果各自维护 isOpen 状态,点开 A 再点开 B 时,A 怎么知道要被收起?这就必须有统一的"当前展开面板"状态,而且这个状态要放在所有面板的公共父级上。
第二个是高度动画。RN 里 View 的高度不能直接动画到 auto,你必须在内容渲染出来后拿到实际高度,再用 Animated 驱动 height 从一个数值过渡到另一个数值。内容里有图片、接口数据、动态字体时,高度测量时机会变得很难控制。
第三个是收起时的联动清理。收起面板如果里面有 Timer、地图实例、视频播放器等资源,在折叠后必须及时清理,否则会出现内容还在播放或者 CPU 持续占用的问题。
这三个点单独拿出来都不复杂,但放在鸿蒙 RN 的适配环境下,每一个都有坑。后面我会逐个展开讲。
1.3 我们项目里的实际场景
需要说明的是,我这里写的手风琴不是一个 Demo,而是我们知识库 App 里真实在用的组件:一级分类列表,展开后显示下面的子分类和文档条目,子分类里可能还有标签组。数据是接口异步返回的,面板内容高度不固定,并且展开面板时要做平滑动画。
因为内容高度不固定、数据异步加载,所以不能简单用"预设高度"的动画方案;因为在鸿蒙真机上跑,所以动画方案必须能在 ArkUI 的渲染体系下稳定工作;因为整个页面放在 ScrollView 里,所以还要考虑长列表下的性能。这些约束叠加在一起,基本排除了所有"简化版手风琴"的做法。
2. 鸿蒙化改造前,环境里这些细节不搞定你根本没法安心写代码
开始写手风琴之前,你得先有一个能在鸿蒙上跑起来的 RN 工程。这段我没法跳过,因为我在这一步就耗了将近一周。下面列的是最容易卡住人的几个环境细节,和你在 iOS/Android 上开发时那些顺手的流程不一样。
2.1 RN 鸿蒙化的整体路径
现在官方推荐的路线是通过 HarmonyOS NEXT 的脚手架创建 RN 工程,再安装 @react-native-oh/react-native-harmono 相关依赖。整体上是把现有的 RN 代码库接入鸿蒙的 IDE 工程,通过 hap 包构建产物安装到鸿蒙设备上运行。
实际操作中,最省力的方式是先用脚手架生成一个干净工程,验证基础渲染没问题后,再把你的业务代码逐步合入。别直接拿一个大项目往鸿蒙里塞,否则你很难区分报错是来自你自己的代码还是来自鸿蒙适配层,排错成本极高。
2.2 跑通第一个 Demo 时最容易遇到的白屏问题
"react native 启动白屏"这个热搜词,我在鸿蒙上算是亲身体验了。第一次启动应用,白屏卡了十几秒,然后才出内容。排查链路大概是这样的:
先确认 JS Bundle 是否加载成功。鸿蒙 RN 的启动流程里,入口页面通过 windowStage.loadContent 加载主页面,之后 RN 引擎初始化、JS Bundle 加载、首帧渲染是依次串起来的。只要其中一个环节慢,白屏时间就会特别长。
我当时最终定位到两个原因:一是 debug 模式连着开发服务器,局域网访问变慢导致 bundle 下载耗时;二是页面入口在引擎未 ready 时就执行了业务初始化,阻塞了渲染线程。
解决方式是在 Native 入口做一个启动页,等 RN 引擎初始化完成后再替换内容;同时把登录态检查、配置拉取这些异步操作放到首帧渲染完成之后,不要阻塞 JS 执行。
2.3 真机调试与模拟器的选择
鸿蒙模拟器我试过,适合做快速 UI 验证,但涉及网络请求、系统弹窗、回退手势这类交互,还是真机更接近最终效果。开发阶段建议用无线调试配合鸿蒙开发工具连接真机,改完代码直接同步到设备上。
如果你是拿 Android 的调试习惯来操作,有几点不同:
- HAP 包的安装方式和 APK 不一样,通过 IDE 一键部署最省心;
- 无线调试需要先在系统设置里打开对应开关,每次重新连接时设备授权弹窗要手动确认;
- 手机和电脑要在同一网络环境下,否则同步速度会让你怀疑人生。
2.4 网络层调试的隐患:Android 正常鸿蒙报 2300056
手风琴面板里的内容大多要请求接口,所以网络调不通,你连数据都渲染不出来。这个坑必须提前说:同样的接口代码,Android 上请求正常,鸿蒙上报错 2300056。
我当时的完整排查链路是这样的:先用 Charles 抓包确认请求是否发出。这里要注意,鸿蒙真机上的抓包配置和 Android 有差异,你得在系统 CA 证书里安装 Charles 的证书,否则 HTTPS 解不了包。抓包后发现请求其实已经发出去了,服务端也返回了,但客户端侧报错。
接着检查网络安全配置,鸿蒙上默认的安全策略比 Android 更严格,明文 HTTP 请求和某些证书校验场景会被直接拦掉。定向排查后,确认是接口域名证书链不完整导致的校验失败。这个问题的隐蔽点在于:它只在鸿蒙的底层网络栈上暴露出来,你在 Android 上压跟测不出来。
所以给手风琴面板做接口联调时,第一件事就是确认鸿蒙的网络策略和证书校验差异,别等数据渲染不出来才回头找网络问题。
3. 手风琴互斥展开的方案选型:三种做法里为什么我选了受控状态
标题既然是"互斥展开",方案选型是整个文章的核心决策点。我把三种主流做法在鸿蒙 RN 环境下的表现对比了一遍,直接说结论。
| 方案 | 实现方式 | 鸿蒙 RN 下的表现 | 适用场景 |
|---|---|---|---|
| 多面板自管理状态 | 每个面板内部 useState 管 isOpen,点击时自己翻转 | 互斥逻辑需要面板间相互通知,容易乱;动画各自为战,体验割裂 | 单面板独立折叠,不要求互斥 |
| 受控组件 + 父级 activeIndex | 父组件持有 activeIndex,面板只根据 props 展示展开/收起状态 | 状态流清晰,互斥天然成立,动画可由父级统一驱动 | 标准手风琴互斥场景,最推荐 |
| 原生 Accordion 封装 | 通过原生模块接入 ArkUI 的折叠组件 | 性能最好,但需要写原生代码,开发成本高 | 面板数量极大、性能要求苛刻的场景 |
3.1 先看为什么"自管理状态"在互斥场景里必然失控
每个面板自己管 isOpen,用户点开 B 时,A 不知道 B 被点开了,A 怎么收起?方案只有两个:要么在点击回调里手动通知 A 关闭,要么用一个全局事件广播。前者的互斥逻辑分散在各面板内部,面板一多就会出现漏关、误关;后者的全局事件越多,项目越难维护。
鸿蒙 RN 环境下面板内容通常还要异步加载,自管理方案里你还会面临一个衍生问题:A 面板还在加载内容,用户就点了 B,A 的加载回调回来后把状态又改了,界面出现两个面板同时半展开的诡异状态。所以自管理状态只适合"每个折叠块完全独立"的场景,一旦要求互斥,就不要再考虑。
3.2 原生 Accordion 为什么"性能最好但成本高"
ArkUI 其实是提供折叠类组件的,如果直接用原生开发,性能当然是最好的。但在 RN 里,你要通过自定义原生模块把 ArkUI 组件桥接给 JS 层,这意味着你要写 ArkTS 端的组件封装、处理事件回传、管理生命周期,还要兼容 RN 的 Shadow Tree,工程量大很多。
我当时的判断是:手风琴面板数量最多几十个,还没有到"非原生不可"的性能边界;而团队里对 ArkTS 熟悉的人有限,维护成本撑不住。所以直接放弃原生封装这条路。
3.3 受控组件方案的关键逻辑
最终我选了受控组件 + 父级 activeIndex 的方案。核心逻辑很简单:
父组件维护一个 activeIndex 状态,number 类型,值为 null 时表示所有面板都收起。每个面板根据 activeIndex === index 判断自己是否展开。点击某个面板的头部时,执行"如果 activeIndex === index 就收起到 null,否则把这个 index 设为展开"。
这个方案的优点在于:互斥不是靠面板之间的通知实现的,而是由"单一数据源"自然推导出来的。你永远不会出现两个面板同时展开的状态,因为界面上只有一个 activeIndex,它不可能同时等于两个不同的 index。想调整成"允许同时展开多个"时,把 number 改成数组就够了,几乎不需要动面板内部逻辑。
另外,受控方案对动画非常友好。展开和收起的状态切换统一发生在父级,父级可以在状态变化时统一触发动画逻辑,动画过程不会出现多个面板各自为战、视觉错乱的情况。
4. 手风琴互斥展开的完整实现:从状态设计到动画落地
方案定了,接下来就是把代码写扎实。这一节是全文的核心,我按组件结构、状态设计、动画实现、完整代码、鸿蒙实测表现五个部分来讲。
4.1 组件结构设计
先把组件拆成两层:
<Accordion>:容器组件,管理 activeIndex,渲染子面板列表;<AccordionItem>:单个面板,包含头部和内容区。头部接收 title 和展开状态图标;内容区负责渲染具体内容,并对外暴露测量逻辑。
这样拆分的好处是,业务方使用时不需要关心互斥逻辑,只需要把数据源和渲染函数传进来即可:
<Accordion data={categories} activeIndex={activeIndex} onChange={setActiveIndex} renderHeader={(item, index) => <Text>{item.title}</Text>} renderContent={(item, index) => <View>{/* 面板内容 */}</View>} />4.2 状态提升与互斥逻辑的关键代码
在父组件里维护 activeIndex:
const [activeIndex, setActiveIndex] = useState<number | null>(null); const handleHeaderPress = (index: number) => { setActiveIndex(prev => (prev === index ? null : index)); };在 AccordionItem 里通过 props 拿到"是否展开":
interface AccordionItemProps { index: number; isExpanded: boolean; onHeaderPress: (index: number) => void; headerComponent: React.ReactNode; contentComponent: React.ReactNode; }每次点击头部时,只通知父级"我第 index 个被点了",做出决策的是父级。这样你在任何时刻检查 UI 渲染出来的状态,activeIndex 都是唯一的,两个面板同时展开的情况在逻辑上就不可能发生。
实际业务中还会遇到一个常见变形:默认展开第一项。做法就是初始值直接设置成 0 即可。还有一个需求——"展开某个面板时自动滚动到它在 ScrollView 中的位置",这个需要用 ScrollView 的 ref 和面板的 onLayout 数据来计算坐标,后面在踩坑部分我会细说。
4.3 高度动画:为什么"直接量高度"在鸿蒙上会碰壁
手风琴动画的核心难题,是让内容区的高度从 0 平滑过渡到内容实际高度。RN 里 height 不能直接设置成 auto 再动画,你必须在渲染后拿到内容高度。
很多新手会直接这么做:在 content 的 onLayout 里 e.nativeEvent.layout.height 存到一个 state 里。但在鸿蒙上的实测表现是:内容区域如果有图片、异步数据,onLayout 可能会触发一次甚至多次,而且初次渲染时拿到的 height 往往是一个临时值,等图片加载完成后高度又变了。
我的方案是"内容区实时测量 + 动画值固定":
- 内容区始终渲染在视图树中,但外层用一个高度为 0 的容器裁剪隐藏;
- 内容区通过 onLayout 把最新高度上报给父级;
- 父级把测量到的高度作为 Animated.Value 的终点值,驱动高度从 0 变化到 contentHeight。
这样做的好处是:内容区在动画之外始终保持真实渲染,高度测量由内容的实际布局驱动,不会出现"动画结束后内容被裁剪"的问题。
具体实现里,我会让内容区包裹在绝对定位的容器中,避免内容区被折叠时影响布局:
const animatedHeight = useRef(new Animated.Value(0)).current; const [contentHeight, setContentHeight] = useState(0); {isExpanded && ( <View style={{ height: 0, overflow: 'hidden' }} onLayout={e => setContentHeight(e.nativeEvent.layout.height)} > <View style={{ position: 'absolute', left: 0, right: 0 }}> {contentComponent} </View> </View> )}这里有个关键细节:onLayout所在的外层 View 高度是 0,那么里面 absolute 定位的子 View 会不会高度也变成 0?不会。position: 'absolute'的子 View 脱离了父容器的约束,高度由其自身内容决定,这样onLayout就能拿到真实内容高度。
动画触发时,用 Animated.timing 驱动高度从 0 到 contentHeight:
Animated.timing(animatedHeight, { toValue: contentHeight, duration: 260, easing: Easing.out(Easing.cubic), useNativeDriver: false, }).start();收起时驱动到 0:
Animated.timing(animatedHeight, { toValue: 0, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: false, }).start();注意这里 mock 了一个关键点:useNativeDriver在鸿蒙 RN 上必须设成 false。鸿蒙适配层对原生驱动的动画支持有限,如果设成 true,动画可能完全不执行或者崩溃。这也是为什么很多从 iOS/Android 搬过来的手风琴库在鸿蒙上"尸体陈列"的原因之一。
4.4 完整核心代码
把完整的核心代码放在这里,你可以直接落地的参考:
// Accordion.tsx import React, { useRef, useState, useEffect } from 'react'; import { View, Text, TouchableOpacity, Animated, Easing, StyleSheet } from 'react-native'; export interface AccordionData { title: string; content: React.ReactNode; } interface AccordionProps { data: AccordionData[]; activeIndex?: number | null; defaultIndex?: number | null; onChange?: (index: number | null) => void; } const Accordion: React.FC<AccordionProps> = ({ data, activeIndex: controlledIndex, defaultIndex = null, onChange, }) => { const [internalIndex, setInternalIndex] = useState<number | null>(defaultIndex); const isControlled = controlledIndex !== undefined; const activeIndex = isControlled ? controlledIndex : internalIndex; const handleHeaderPress = (index: number) => { const next = activeIndex === index ? null : index; if (isControlled) { onChange?.(next); } else { setInternalIndex(next); } }; return ( <View style={styles.container}> {data.map((item, index) => { const isExpanded = activeIndex === index; return ( <AccordionItem key={index} index={index} isExpanded={isExpanded} onHeaderPress={handleHeaderPress} header={<Text style={styles.headerText}>{item.title}</Text>} content={item.content} /> ); })} </View> ); }; interface AccordionItemProps { index: number; isExpanded: boolean; onHeaderPress: (index: number) => void; header: React.ReactNode; content: React.ReactNode; } const AccordionItem: React.FC<AccordionItemProps> = ({ index, isExpanded, onHeaderPress, header, content, }) => { const animatedHeight = useRef(new Animated.Value(0)).current; const [contentHeight, setContentHeight] = useState(0); useEffect(() => { if (isExpanded && contentHeight > 0) { Animated.timing(animatedHeight, { toValue: contentHeight, duration: 260, easing: Easing.out(Easing.cubic), useNativeDriver: false, }).start(); } else { Animated.timing(animatedHeight, { toValue: 0, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: false, }).start(); } }, [isExpanded, contentHeight]); return ( <View style={styles.item}> <TouchableOpacity style={styles.header} onPress={() => onHeaderPress(index)} activeOpacity={0.7} > {header} <Text style={styles.arrow}>{isExpanded ? '收起' : '展开'}</Text> </TouchableOpacity> <Animated.View style={[styles.contentWrapper, { height: animatedHeight }]}> <View style={styles.measure} onLayout={e => setContentHeight(e.nativeEvent.layout.height)} > {content} </View> </Animated.View> </View> ); }; const styles = StyleSheet.create({ container: { backgroundColor: '#fff' }, item: { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#ddd' }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 14, paddingHorizontal: 16, }, headerText: { fontSize: 16, fontWeight: '600' }, arrow: { fontSize: 13, color: '#999' }, contentWrapper: { overflow: 'hidden' }, measure: { position: 'absolute', left: 0, right: 0, paddingHorizontal: 16, paddingBottom: 16, }, }); export default Accordion;几个说明:
isControlled用于区分受控和非受控模式,受控模式下父组件完全接管 index 状态,适合需要和其他组件联动(比如搜索列表命中后自动展开的某项)的场景;measure容器必须 absolute 定位且在动画过程中始终渲染,这样高度测量不会因为父容器高度为 0 而失败;- 收起时不能把
content卸载掉,否则下次展开时onLayout又要重新测量。这就是为什么我在结构上让 measure 始终在视图树中,只是被外层overflow: 'hidden'隐藏。
4.5 在鸿蒙真机上的实测表现
这个组件在鸿蒙真机上跑起来的实测结果是:展开收起动画流畅,260ms 的时长在高刷新率屏幕上看起来不会卡顿;快速点击多个头部时,由于状态是派生自单一 activeIndex,不会出现同时展开两个面板的异常情况。
唯一不足的是,动画过程中如果内容里有列表或地图,帧率会有轻微波动,这是因为useNativeDriver: false时动画跑在 JS 线程,内容复杂的面板渲染压力会叠加在 JS 线程上。这个属于预期内的问题,优化方向是减少动画期间的 JS 负载,比如把非必要组件用 React.memo 包裹。
5. 鸿蒙适配过程中踩过的那些坑
写手风琴那段时间,我几乎每天都能碰到几个"只有鸿蒙才有"的怪问题。这里挑四个影响最大的展开讲,每一个都是我完整排查过的。
5.1 动画不生效与"闪白屏"
第一次在鸿蒙上运行手风琴时,点击头部后动画完全没有过渡,内容"啪"地一下就展开了。一开始以为是 Easing 的兼容问题,后来排查到是高度动画的起点值问题。
在 Android 上,Animated.Value(0)起始值为 0,动画自然从 0 开始。但在鸿蒙上,如果动画值从 0 开始,高度为 0 的容器在某些渲染条件下会被 ArkUI 视为"未布局节点",展开时没有任何中间帧,直接跳到目标高度。解决方式是让动画从很小的非零值起步,比如Math.max(0.01, animatedHeight),或者在建初始值时用new Animated.Value(0.01)。这个不是官方文档会写的内容,是黑屏闪屏试出来的经验。
另外还有一个"闪白屏"的问题:如果面板内部有背景颜色,动画过程中会出现整块白色闪烁。原因是Animated.View在 JS 线程动画过程中,原生图层重建时背景色没有及时应用。解决方式是在面板外层设置不透明的背景色,并在动画期间尽量不做图层级的样式切换。
5.2 Android 请求正常、鸿蒙请求 2300056 的完整排查链路
这个坑在前面环境部分提过,因为手风琴面板的数据会走接口,所以值得再完整说一遍排查过程:
- 现象:同一段 fetch 代码,Android 上正常返回数据,鸿蒙上报错 2300056;
- 第一步用 Charles 抓包,确认请求是否到达服务端。鸿蒙真机开启抓包后,发现请求实际发出了,后端也回包了;
- 第二步看回包是否被客户端拦截。鸿蒙的网络栈有自己的证书校验策略,中间代理安装的证书如果不在系统信任链里,就会导致请求失败;
- 第三步检查代码里的 baseUrl,是否用了 https。如果服务端证书链不完整或者域名是自签证书,鸿蒙会直接拒绝连接;
- 最终定位是接口域名证书链的中间证书缺失,Android 的证书校验比较宽容所以没暴露,鸿蒙严格校验后报错 2300056。
这是鸿蒙网络层和 Android 差异的一个典型案例。项目里如果出现 Android 正常、鸿蒙报错的情况,先别急着怀疑 RN 适配层,优先查网络证书和网络安全策略。
5.3 长列表复用与展开状态丢失
手风琴放在 ScrollView 里滚动时,如果面板数量较多,内容区用了 FlatList 渲染内部列表,会出现滚动过程中展开状态丢失、动画重放的怪现象。
这个问题的根因是 FlatList 在鸿蒙上的回收复用机制和 Android 不同,某些情况下会把不可见的单元格整个卸载,重新滚回来时触发重挂载,导致isExpanded这个 prop 虽然没变,但内部动画状态被重置了。
解决方式有两个,我最终用的是第二个:
- 给内容区的列表组件加
removeClippedSubviews={false},让内容区即使在滚动中也不释放; - 用 useMemo 把展开状态和内容缓存下来,内容不随列表回收而重建。
不过要注意,removeClippedSubviews设 false 会提高内存占用,如果面板数量极大,建议用虚拟列表方案,而不是一味保守。
5.4 启动白屏:windowStage.loadContent 与首帧渲染的配合
热词里那句 "鸿蒙windowstage: window.windowstage loadcontent",恰好是我排查启动白屏时反复看到的日志。鸿蒙的窗口加载方式和 Android 不同,入口页面是你通过windowStage.loadContent指定的,RN 引擎的启动和渲染都要在这个窗口建立之后进行。
我的处理方式是在原生入口先加载一个纯 ArkUI 的启动页,页面是一个居中的 loading 动画,不依赖 RN。然后在 RN 引擎初始化完成的回调里,再通知原生层替换内容层。这样用户看到的是"启动页秒开",而不是"白屏等待十几秒"。
对应到 JS 层,启动时要避免在主线程同步做大量初始化。尤其不要在主组件里同步拉配置、算加密、建数据库连接。把这些全部挪到首帧渲染之后异步执行,能大幅缩短白屏时间。
5.5 鸿蒙上打开支付宝/微信这类外部 App 的适配提醒
手风琴面板里如果有"去下单""去支付"按钮,点击后需要跳转到支付宝这类外部 App 时,鸿蒙的跳转方式和 Android 的 intent scheme 不一样。RN 的Linking.openURL在鸿蒙上需要配合鸿蒙的 openLink 或者 ability 跳转机制。
我碰到的情况是:面板里跳转支付宝,Android 上Linking.openURL('alipays://...')能直接拉起,鸿蒙上没反应。原因是对外跳转需要额外校验 scheme 的可用性,并且要声明对应的查询参数。
建议在鸿蒙上做外部 App 跳转时,先看一眼是否能通过Linking.canOpenURL检查对方 App 是否安装,不能检查的提示用户手动打开对应 App。这个细节在手风琴面板里非常容易被忽略,等测试在真机上点按钮没反应才发现就晚了。
6. 从"能跑到能用":把这套手风琴沉淀成公共组件
功能跑通只是一个开始。项目里多个页面都要用手风琴,如果每个页面复制一份代码,后续改动画时长、改样式、改互斥策略就要改十几个地方。我花了一些时间把它沉淀成了一个相对干净的公共组件,下面说说封装思路和优化方向。
6.1 对外 API 设计
公共组件最重要的是把"用的人需要什么"和"用的人不需要关心什么"分清楚。我的 API 长这样:
interface AccordionProps<T> { data: T[]; keyExtractor: (item: T, index: number) => string; renderHeader: (item: T, index: number, expanded: boolean) => React.ReactNode; renderContent: (item: T, index: number, expanded: boolean) => React.ReactNode; activeIndex?: number | null; onActiveIndexChange?: (index: number | null) => void; defaultIndex?: number | null; expandMultiple?: boolean; animated?: boolean; duration?: number; }需要额外解释的是expandMultiple。有些页面需要"手风琴"模式,有些页面需要"可以同时展开多个"的折叠面板。互斥模式用 number 就够了,但允许多展开时,内部状态要改成数组。把这个能力直接在公共组件里支持掉,比让业务方在外层再套一层容器去管理要舒服得多。
6.2 性能优化的几个方向
手风琴在鸿蒙 RN 里的性能瓶颈集中在动画和内容复用上。我做过的有效优化有这几个:
一个是用 React.memo 包裹 AccordionItem。因为父组件每次点击头部都会重新渲染,如果没有 memo,所有面板的内容区都会跟着重新渲染一次。内容区如果包含地图、富文本、视频,这个开销是不能接受的。
另一个是延迟渲染面板内容。手风琴刚打开时,同时渲染所有面板的内容,应用冷启动会明显变慢。我改成"面板首次展开时才渲染内容,展开过后缓存渲染结果",这样初始渲染节点数大幅减少,滚动流畅度也上来了。
还有一个是动画时机上的微调。快速点开和收起面板时,动画容易互相打断。我在动画开始前调用了animatedHeight.stopAnimation(),确保每次动画都是从上一次动画的剩余位置开始的,不会产生跳变。
6.3 后续可以扩展的方向
手风琴互斥展开这个需求看似是一个小功能,但在鸿蒙 RN 项目里,它其实代表了一类"基础组件鸿蒙化自研"的工作。我把未来可以扩展的方向列一下:
- 面板内容的懒加载:配合网络请求做"展开时才加载数据,加载完成展示内容"的完整链路;
- 手风琴嵌套:一级面板展开后,二级面板也支持互斥展开,此时状态管理需要扩展成一个嵌套结构;
- 联动搜索:外部传入一个搜索关键字,命中某个面板时自动展开并高亮命中内容,这也是受控模式最典型的应用场景;
- 动画插值改进:当前是简单的 height 动画,后续可以加入 opacity 和 translateY 的组合动画,视觉层次会更好。
我把这套手风琴在项目里落了地,坦白说,它不算一个特别炫酷的技术作品,但在鸿蒙 RN 生态还不算成熟的阶段,它非常实用。如果你也在做 RN 的鸿蒙化改造,我建议你从这种小组件入手,把环境流程全部趟一遍,再逐步啃复杂业务。等你把启动白屏、网络调试、动画适配这些坑都踩过一次,后面的鸿蒙开发路会顺很多。