1. 为什么选React Native做鸿蒙端:剧本杀组队App的选型复盘
1.1 先说说这个项目的实际场景
我在做的这个剧本杀组队App,核心玩法是玩家在线组队、约局、开本。用户打开首页刷剧本、对桌游感兴趣、点进个人中心看自己的信息——这个个人中心页面看起来简单,实际做起来比想象中麻烦。它不只是展示一个头像和昵称,还牵扯到称号系统、历史战绩、组队记录这些动态数据,并且要在一堆不同屏幕尺寸的设备上保持一致的体验。
项目立项的时候,客户端团队只有四个人,要同时覆盖Android、iOS,现在还要加一个鸿蒙。如果三个平台各写一套原生代码,别说维护,光是排期就崩溃。所以我当时拍板,走跨平台方案,最终选了React Native。
有人可能会问,为什么不是Flutter,或者uni-app、Taro?我后面会详细讲,这里先给结论:因为我们团队的技能栈就是React,而且RN的生态里能找到大量现成的组件库,鸿蒙这边也有社区移植的运行时支撑。只要不碰特别偏门的功能,RN在鸿蒙上完全可以跑出可接受的体验。
1.2 对比纯鸿蒙原生和跨平台框架的取舍
先列一下我当时考虑过的几个方案,分别说清楚优劣势。
- 纯ArkTS原生开发:体验最好,对鸿蒙新特性的支持最及时,但需要单独组建鸿蒙开发团队,代码无法和Android端复用。我们组没人写过ArkTS,从零学起来至少按月算。
- Flutter + OpenHarmony适配:Flutter自身渲染引擎在鸿蒙上的移植已经有社区版本,但还没到官方维护的成熟度;而且我们团队没人写过Dart,风险高。
- uni-app / Taro:写起来像Vue或小程序,H5和各家小程序是强项,但原生能力弱的场景要靠插件堆,个人中心这种需要频繁刷新、动画切换的页面做起来很别扭。
- React Native + 鸿蒙适配:我们团队最熟,React组件模型天然适合个人中心这种信息分层明显的页面;社区里rn的库基本上能直接拉过来用。
对比下来,RN是成本最低、风险最可控的一条路。这里多说一句,React Native跑鸿蒙,很多人以为只有社区野路子方案,其实官方生态已经跟进了。OpenHarmony这边有对应的RN框架支持,虽然还达不到Android/iOS那种无缝程度,但个人中心这种中规中矩的页面,完全够用。
1.3 RN在鸿蒙生态的现状与适配边界
很多人一听到"React Native鸿蒙"就担心踩坑,我实测下来的感受是:常用组件像View、Text、ScrollView、FlatList、Pressable都跑得通,核心的布局系统也兼容,但有几个边界得提前知道。
- 第三方原生模块(比如地图SDK、支付SDK)要针对鸿蒙单独做桥接,不能直接复用Android的aar或iOS的framework。
- 动画性能比iOS端弱一些,复杂交互动画要克制。
- 首帧加载比Android端慢,启动白屏问题需要单独优化(后面讲)。
- 鸿蒙模拟器的JSVM目前只支持ARM64架构,如果你用的是x86电脑,模拟器跑不起来,必须用真机。
搞清楚这些边界之后,个人中心这个页面就非常适合拿来验证方案可行性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 页面需求拆解与函数式组件设计思路
2.1 个人中心到底要展示什么
剧本杀组队的个人中心,和普通社交App的个人中心有相似之处,也有自己的业务特点。功能上分为三大块:
- 个人信息展示:头像、昵称、个性签名、性别、常驻地区、年龄区间。这里比较关键的一点是,用户需要被快速识别,所以头像和称号要有辨识度。
- 称号管理:用户通过打本、组队、签到获得不同称号,比如"高级推理师""金牌车头""灵魂戏精"。不同的称号会展示在个人资料卡上。称号除了好看,还直接决定用户能否进某些高级本的房间,这个在业务上有实际价值。
- 历史战绩:包括总场次、胜率、MVP次数、参过的本目录、最近组队记录。战绩是用户炫耀的资本,也是组队时别人判断你水平的重要依据。
这三块内容数据形态差异挺大:个人信息是单条对象,称号是有限集合且需要当前选中项,战绩是列表且要不断加载。但页面又必须在一个屏幕上和谐地展示出来,这就非常考验组件拆分和状态管理能力。
2.2 组件树怎么拆
我用的是函数式组件,整体拆成四层结构:
tsx复制PersonalCenter (容器组件,负责数据拉取和状态总控)
├── UserInfoCard (个人信息展示,纯展示组件)
├── TitleManager (称号管理,负责称号列表和切换)
│ └── TitleItem (单个称号卡片)
└── BattleRecordList (历史战绩,负责列表展示和加载更多)
└── RecordItem (单条战绩)
这样拆的依据是"一个组件只干一件事"。PersonalCenter负责向上对接网络层,向下分发数据;UserInfoCard只接收user对象,不关心数据从哪来;TitleManager维护自己的称号列表和当前选中态;BattleRecordList接收战绩数组和加载状态。
这种设计的最大好处是,以后如果想加一个"编辑个人资料"的弹窗,只需要扩展UserInfoCard或者新增一个组件,不会影响页面其他部分。我见过太多人把所有代码堆在一个大组件里,几千行下来,改一个字段都要翻半天。
2.3 useState够用,为什么不用Redux
很多人一上来就要上Redux或者Zustand,其实没必要。个人中心这个页面没有跨页面的复杂状态共享问题,用户信息、称号、战绩都是这个页面自己的数据,组件树层级也不深,用props逐层传递完全够。强行引入全局状态管理,反而把数据流搞复杂了。
我的原则是:状态范围在一个页面内的,先考虑useState;状态需要跨页面共享的,再考虑全局状态库。个人中心页面完全符合前一种情况。
而且useState配合useCallback、useEffect,可以写出很干净的业务逻辑。比如称号切换后要同步更新个人信息卡上的称号展示,这个数据流在组件内部就能完成,不需要绕道全局store。
3. useState在三大业务模块中的实战细节
3.1 个人信息展示:异步加载与状态初始化
用户信息从接口返回,不可避免要处理加载中、成功、失败三种状态。这是我个人中心里最基础的useState用法:
tsx复制const [user, setUser] = useState<UserInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
fetchUserInfo()
.then((data) => {
if (mounted) {
setUser(data);
setError(null);
}
})
.catch((err) => {
if (mounted) setError(err.message || '加载失败');
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => {
mounted = false;
};
}, []);
这里有个容易踩的坑:组件卸载后调用setState,React会报warning,在鸿蒙上还可能导致内存泄漏。用mounted标记在cleanup里置false,是常规解法。
还有一个细节,user对象里如果包含多个字段,更新时不要逐字段set,而是整体替换对象,否则会触发多次渲染。React的setState是浅比较,你更新对象里的一个属性,但如果引用变了,整个对象都会被判定为变更,所以保持state粒度适中很重要。我是把user作为一个整体对象放在state里,更新头像、昵称时用setUser({ ...prev, nickname: newName })。
3.2 称号管理:列表渲染与切换逻辑
称号模块是个人中心里最有趣的部分。用户拥有一个称号集合,每次只能启用一个作为"当前身材"展示。在页面上,当前称号显示在个人信息卡中,称号管理区域展示所有称号,用户可以点击切换。
状态设计:
tsx复制const [titleList, setTitleList] = useState<TitleItem[]>([]);
const [currentTitleId, setCurrentTitleId] = useState<string>('');
const [switchLoading, setSwitchLoading] = useState(false);
切换逻辑:
tsx复制const handleSwitchTitle = useCallback(async (titleId: string) => {
if (titleId === currentTitleId || switchLoading) return;
setSwitchLoading(true);
try {
await requestSwitchTitle(titleId);
setCurrentTitleId(titleId);
// 同步更新个人信息卡中的称号展示
setUser((prev) => prev ? { ...prev, activeTitleId: titleId } : prev);
} catch (e) {
// 这里要处理错误提示,不要让用户以为切换成功了
Toast.show('切换失败,请重试');
} finally {
setSwitchLoading(false);
}
}, [currentTitleId, switchLoading]);
这里有两个容易被忽略的点。
第一个是防重复点击。switchLoading在请求期间是true,用户连续点击不同称号时,不会并发发请求。真实业务里这个操作如果没有防抖,用户在鸿蒙上由于触控延迟会比Android更容易产生重复触发。
第二个是称号列表的渲染。如果称号数量多(超过30个),建议不要一次渲染全部,用横向ScrollView包一层,配合removeClippedSubviews,避免一次性创建太多原生视图。鸿蒙上对频繁创建和销毁View的开销比Android更敏感,这个点后面性能优化那块再细讲。
另外,如果称号带解锁条件,比如"集齐20个不同剧本即可解锁",UI上需要展示锁定态。我把锁定和可用状态的判断放在TitleItem组件内部,根据props里的unlocked字段渲染不同样式,不在state里维护一堆冗余的样式字段,这样更干净。
3.3 历史战绩:下拉刷新与分页状态
战绩列表是个人中心数据量最大的模块,必须考虑分页。我用的状态结构是:
tsx复制const [records, setRecords] = useState<BattleRecord[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
战绩列表用FlatList来渲染,核心配置如下:
tsx复制<FlatList
data={records}
keyExtractor={(item) => `${item.id}_${item.sessionId}`}
renderItem={renderRecordItem}
onRefresh={handleRefresh}
refreshing={refreshing}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.3}
ListFooterComponent={renderFooter}
initialNumToRender={10}
windowSize={7}
maxToRenderPerBatch={5}
removeClippedSubviews={Platform.OS === 'android'}
/>
这里最关键的几个参数:
keyExtractor必须唯一。电影票、剧本局的记录ID可能重复,所以我把id和sessionId拼起来用。如果key重复,FlatList在鸿蒙上会出现渲染错乱。onEndReached会在用户滑到底部时触发,不要在FlatList外面再包ScrollView,也不要包裹同方向的ScrollView,这样会导致onEndReached无法触发。onEndReachedThreshold是0到1之间的比例,0.3表示距离底部还有可视区域高度的30%时触发加载。别设太大,否则用户一到页面底部就开始加载,体验反而差。
分页状态更新的写法:
tsx复制const handleLoadMore = useCallback(async () => {
if (loadingMore || refreshing || !hasMore) return;
setLoadingMore(true);
try {
const nextPage = page + 1;
const res = await fetchBattleRecords(nextPage);
setRecords((prev) => [...prev, ...res.list]);
setHasMore(res.list.length > 0);
setPage(nextPage);
} finally {
setLoadingMore(false);
}
}, [loadingMore, refreshing, hasMore, page]);
这里有一个我踩过坑的地方:不要用records.length来判断是否还有更多,因为有的接口返回的list长度正好是分页大小,但已经没数据了。必须让后端返回一个hasMore字段,或者你拿返回条数去判断——如果返回条数小于请求的pageSize,说明没有更多了。
下拉刷新的逻辑类似,区别是不追加数据,直接替换:
tsx复制const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
const res = await fetchBattleRecords(1);
setRecords(res.list);
setPage(1);
setHasMore(true);
} finally {
setRefreshing(false);
}
}, []);
这样下来,个人中心三个核心模块的状态管理就清晰了。没有引入任何额外的状态管理库,全部用useState实现,代码量可控,可读性也高。
4. 鸿蒙适配实录:从双端到三端的兼容差异
4.1 布局差异:Flexbox在鸿蒙上的表现
理论上RN的布局引擎在不同平台上应该表现一致,但实测下来还是有一些细微差异,主要集中在三个方面。
第一个是SafeArea的处理。鸿蒙的挖孔屏、全面屏状态栏高度和Android不一样,如果直接使用SafeAreaView,在鸿蒙上可能不够用。我的做法是用StatusBar.currentHeight拿状态栏高度,然后给页面顶部留出padding:
tsx复制const statusBarHeight = Platform.OS === 'harmony'
? 状态栏高度
: StatusBar.currentHeight || 0;
鸿蒙上这个值从哪来?可以通过Dimensions.get('window')和系统API配合获取,或者直接用react-native-status-bar-height这类库,在鸿蒙上测试下来也能正常工作。
第二个是百分比布局。RN支持百分比宽度,但鸿蒙上如果父组件高度不明确,height: '100%'有时表现和Android不一致。我在个人中心外层容器上固定设置了flex: 1,并且确保每一层都不丢失这个属性,基本能规避。
第三个是zIndex层级问题。当称号管理区域有浮层按钮时,鸿蒙上zIndex的表现比Android更"敏感",不能只靠zIndex,还要配合elevation属性。比如称号切换成功后的Toast提示,在鸿蒙上容易被其他View遮挡,给Toast容器设置elevation={10}才能保证显示在最上面。
4.2 组件兼容检查清单
我在个人中心页面用到的RN核心组件,逐个在鸿蒙真机上跑了一遍,结论如下:
| 组件 | 鸿蒙兼容性 | 注意事项 |
|---|---|---|
| View / Text | 兼容 | Text的numberOfLines在鸿蒙上表现正常 |
| ScrollView | 兼容 | 横向滚动时建议设置showsHorizontalScrollIndicator={false} |
| FlatList | 兼容 | 避免嵌套同方向滚动,性能参数要调整 |
| Pressable | 兼容 | ripple效果和Android有差异,自绘反馈样式更稳 |
| Image | 兼容 | 需要处理内存缓存,见下文 |
| Modal | 兼容 | 背景色差异,需要自己调 |
| StatusBar | 部分兼容 | 主题切换时状态栏文字颜色要用setBarStyle,鸿蒙上要延迟执行 |
重点说一下Image。个人中心的头像是网络图,用户战绩里还有剧本封面缩略图。鸿蒙上如果直接用<Image source={{ uri }} />,大图内存占用高,滚动列表时会卡。我的做法是:
tsx复制<Image
source={{ uri: item.coverUrl }}
style={styles.cover}
resizeMode="cover"
fadeDuration={0}
/>
并且后端给图时应该直接给三档尺寸的缩略图,头像用?imageView2/1/w/200这种裁剪参数,列表封面用/w/400,不要在客户端用完整大图去缩小。这一招在任何RN平台上都通用,在鸿蒙上收益尤其明显。
4.3 环境配置与构建流程要点
如果你要在鸿蒙上跑这套RN代码,环境上需要注意:
- 鸿蒙官方提供的是React Native的OpenHarmony版本,需要安装对应的脚手架工具,不是直接用
npx react-native init。 - 工程初始化后,要把
react-native-harmony相关的依赖装好,并且鸿蒙侧需要通过DevEco Studio打开并同步。 - 跑包的时候,JS bundle要单独生成再加载到鸿蒙工程中,不能用Metro直接连接调试。前期开发时调试模式可以在DevEco里配置,但发布必须把bundle打包进应用,减少白屏时间。
- 鸿蒙模拟器的JSVM目前只支持ARM64平台,x86电脑上是跑不起来的。我第一次就是被这个卡住,折腾了两小时才发现是模拟器架构问题,换成真机后立马解决。
5. 实测中最头疼的两个问题:启动白屏与列表卡顿
5.1 启动白屏的根因排查
鸿蒙上RN应用启动白屏,是我在联调阶段遇到的第一道坎。现象是冷启动后白屏2到3秒,然后页面才一次性渲染出来,体验很差。
排查链路是这样的:
先怀疑是bundle加载慢。RN在鸿蒙上没法像iOS那样直接从本地文件系统快速读取bundle,需要先解压再执行。如果bundle文件大,这个时间不可忽略。看一下构建产物,发现bundle之后4MB多,虽然不至于离谱,但减包裹至少能让首屏更快。
然后怀疑是入口组件渲染慢。我的入口组件写法上有一个问题:PersonalCenter里的useEffect在组件挂载后才发请求拿数据,而在这期间页面是空白状态。改进方案是做一个启动页底图,在bundle执行期间先展示一张和页面结构相似的静态图,等真实数据渲染后再替换。
再排查发现,部分页面白屏是因为入口注册时机不对。RN组件需要等到原生侧把环境初始化完成后再注册,否则会出现渲染但看不到内容的情况。解决办法是在鸿蒙工程的入口页面里,通过事件通知确认RN环境ready后再执行AppRegistry.registerComponent。这个顺序不正确,白屏问题就会反反复复。
最后是首帧渲染性能:将页面里最重的ListFooterComponent(加载更多转圈组件)改为懒加载,不要在首屏就创建;同时将initialNumToRender从默认10降到6,减少首屏渲染的组件数量。这样冷启动那段白屏时间降到了1秒以内,基本可以接受。
5.2 战绩列表卡顿的优化过程
另一个问题出现在战绩列表滚动时,鸿蒙真机上帧数明显下降,滑快了掉帧严重。
第一步,排查是否有重复渲染。战绩列表的renderItem里,RecordItem是一个函数组件,如果不做memo,任何父组件state变化都会导致整列表重渲染。我给RecordItem包了React.memo:
tsx复制const RecordItem = memo(({ item, onPress }: RecordItemProps) => {
// ...
});
同时确保传给RecordItem的onPress是稳定的引用,用useCallback包好,否则memo的效果会被每次生成新函数抵消。
第二步,检查FlatList的windowSize和maxToRenderPerBatch。鸿蒙上默认值往往不够,我把windowSize从默认21调成7,把maxToRenderPerBatch从默认10调成5,减少一次渲染的组件数量。
第三步,检查item的样式复杂度。RecordItem有阴影、圆角、多个文字的嵌套布局,阴影在鸿蒙上很消耗GPU。我把阴影改成了描边+背景色,视觉上差不多,但渲染性能好很多。
第四步,图片懒加载。战绩封面图用Image组件会在render时立刻加载,在列表中这会阻塞滚动。我在FlatList的ListHeaderComponent里预加载了前几项的封面图,滚动到后面时再按需加载,配合fadeDuration={0},卡顿明显缓解。
优化后,列表滚动帧率从肉眼可见的卡顿恢复到流畅,虽然还有一些小的掉帧,但60帧流畅度已经接近Android端的90%。
6. 更多值得留意的细节与后续扩展
6.1 状态更新的性能细节
个人中心用到了多个useState,实际运行中,频繁的setState会带来一系列渲染开销。两个建议:
第一,把"关联状态"合并成一个state对象。比如个人信息的loading、error、data这三个状态,其实可以合成一个:
tsx复制const [userState, setUserState] = useState({
loading: true,
error: null,
data: null,
});
这样一次性更新,避免loading和data分开更新时产生两次渲染。
第二,稳定的回调引用。所有传给子组件的函数,尽量用useCallback包起来。不要小看这个优化,在函数式组件架构下,如果父组件渲染,子组件拿到的函数引用变了,即使子组件包了memo也会被迫重渲染。个人中心有三个子模块,互相之间的状态变化很容易造成联动渲染,用useCallback把这个链条切断,收益非常明显。
6.2 测试时容易忽略的边界场景
经验告诉我,个人中心这类页面最容易出问题的是状态切换时的边界场景:
- 弱网下拉刷新:刷新按钮按了之后,接口迟迟不返回,用户又下拉了一次。这里要通过
refreshing和loadingMore的互斥判断来避免并发。 - 称号切换后并发请求:用户点称号A,没等返回,又点了称号B。虽然我加了
satisfiedLoading判断,但极端情况下还是可能出现竞态。简单的解决办法是给切换逻辑加一个timestamp,只处理最后一次请求的结果。
这些边界在鸿蒙上更容易暴露,因为鸿蒙的触控和系统调度差异,导致这类并发事件的概率比Android高。调试时最好用真机反复测。
6.3 个人中心还能怎么扩展
当前版本的个人中心是三块功能,后续如果要迭代,有几个方向:
- 战力图谱:根据历史战绩生成玩家的角色偏好雷达图,需要引入图表库,要提前验证图表库在鸿蒙上的兼容性。
- 称号解锁进度:用环形进度条展示称号收集进度,需要SVG或Canvas支持。
- 战绩分享海报:生成一张包含战绩数据的图片分享到社交平台,涉及原生模块的对接,鸿蒙上要做桥接。
这些功能都建立在当前函数式组件架构之上,组件拆好了,加功能就是新增一个组件、接一个接口的事,不需要推倒重做。
回到React Native在鸿蒙上的实际体验,我的判断是:如果团队有RN经验,个人中心这类中等复杂度的页面,完全可以用这套方案落地。白屏和列表性能问题都有明确的解法,真正要小心的反而是那些不起眼的差异——状态栏高度、模拟器架构、Shadow性能。把这些问题在开发前就排查清楚,后面会顺畅很多。
