最近在做React Native的鸿蒙跨平台开发,手头的第一个实战任务就是积分商城页面。功能看起来直白——积分商品列表、兑换、记录——但一旦要把React Native跑在鸿蒙设备上,页面只是表象,背后是环境搭建、鸿蒙适配、状态同步、真机调试这一连串不太高调但足够磨人的细节。这篇就完整记录一下我是怎么把这个页面从0到1做出来的,包括踩过的启动白屏、请求失败、列表卡顿这些问题。不管你是刚接触RN还是已经在Android/iOS上写过多端,只要想上鸿蒙,都会遇到同样的问题:原来的组件能不能跑、网络层需不需要改、打包产物怎么接进鸿蒙工程。积分商城是一个特别典型的中等复杂度页面,不大不小,刚好能把列表渲染、状态管理、路由跳转、本地持久化这些内容串起来。这篇文章不打算堆砌框架源码,而是以“积分商城页面实现”为线索,把React Native鸿蒙开发中真正需要知道的东西掰开讲清楚。
1. 整体设计与需求拆解
1.1 积分商城的页面功能与边界
积分商城这种模块,在各家App里长得都很像:顶部是用户当前积分余额,中间是一串能用积分兑换的商品卡片,点击兑换后弹出确认框,确认后扣积分、减库存,同时生成一条兑换记录。记录页也是一个常见的列表页面,展示兑换时间、商品名称、消耗积分数。说白了,这是一个标准的信息展示加业务操作的组合,适合用来练手,也适合作为整个App的“第一块试验田”。
从产品角度拆需求,积分商城页面至少要满足这几点:
| 功能模块 | 子功能 | 说明 |
|---|---|---|
| 顶部积分模块 | 展示当前积分余额 | 积分来源可能是签到、消费返积分等,页面只做消耗 |
| 商品列表 | 展示积分商品信息 | 每个商品包含名称、封面、所需积分、剩余库存 |
| 兑换操作 | 二次确认、余额库存校验 | 防止误触,明确提示消耗多少积分 |
| 兑换反馈 | 成功/失败提示 | 积分不足、库存不足等异常需明确告知用户 |
| 兑换记录 | 历史记录列表 | 按时间倒序展示兑换明细 |
边界也要提前划定:这个页面不处理积分充值、不处理订单物流、不接入支付。因为作为React Native鸿蒙跨平台开发的基础入门项目,重点不在业务复杂度,而在于把RN在一个新平台上的完整链路跑通。把边界划清楚,后面写代码才不会越写越散。很多新手在做页面时喜欢“顺手把能做的都做上”,结果首页还没跑通,已经引入了一堆用不上的依赖。这个项目里我刻意保持克制,只做列表、兑换、记录三件核心事。
1.2 为什么用React Native而不是写两套原生
如果只做鸿蒙,当然可以用ArkTS直接写原生应用,但跨平台开发的场景通常是:团队里已经有一套React Native代码需要复用,或者希望同一套业务代码同时覆盖iOS、Android和鸿蒙。积分商城页面的业务逻辑都在JS层,原生依赖极其有限,正好可以避开鸿蒙适配的深水区,又能验证RN组件在鸿蒙上的表现。
对比一下常见跨平台方案在鸿蒙上的切入点:
| 方案 | 跨端覆盖 | 学习成本 | 鸿蒙适配现状 |
|---|---|---|---|
| Flutter | iOS/Android/Web/鸿蒙 | 需要学Dart | 社区有适配分支,插件仍在补全 |
| uni-app | 小程序/App多端 | 偏低,偏Vue | 官方节奏在推进,但要等框架适配 |
| React Native | iOS/Android/鸿蒙 | 中,前端可迁移 | 社区活跃,常用组件已经有鸿蒙版本 |
积分商城这种页面选React Native还有一个好处:图片、列表、弹窗、本地存储这些能力都有成熟方案,不用接触太多鸿蒙原生代码。其实“跨平台”三个字并不神秘,本质上是用一个JavaScript引擎把业务逻辑和UI描述跑起来,再由原生容器渲染到不同平台。鸿蒙的出现,只是给这个容器又多了一个目标平台。只要RN适配层做到位,业务代码可以不动或者小改。
1.3 工程初始化与运行环境准备
真正动手前,环境必须先准备好。我的开发机配置大致是:Node.js 18+、npm/yarn、DevEco Studio、JDK 17、以及一个React Native项目。你还需要针对鸿蒙安装对应的RN适配依赖,不同RN版本对应不同适配分支,安装时要以官方适配文档为准。以社区常用的鸿蒙化RN工程为例,初始化命令大致是这样:
npx react-native init PointMall --version 0.72.7 npm install @react-native-oh/react-native-harmony --save初始化完成后,工程目录会包含多个平台的壳工程:
PointMall/ ├── android/ # Android 壳工程 ├── ios/ # iOS 壳工程 ├── harmony/ # 鸿蒙壳工程 ├── src/ │ ├── pages/ │ │ ├── MallScreen.tsx │ │ └── RecordsScreen.tsx │ ├── components/ │ │ └── PointCard.tsx │ ├── store/ │ │ └── useMallStore.ts │ ├── types.ts │ └── api/ ├── index.js └── package.json重点理解这个结构:src下面的React Native代码是跨平台共享的,harmony目录里的内容才是鸿蒙原生壳。开发时,先在终端启动Metro,再通过DevEco Studio打开harmony目录,构建出hap包后部署到鸿蒙模拟器或真机上。这个过程第一跑往往会遇到白屏、连不上Metro等问题,我会在第四章专门讲排查方法,这里先有个心理准备就行。
2. 商品列表与积分展示模块实现
2.1 数据模型与Mock数据初始化
写页面之前,先把数据模型定下来。积分商品的核心字段就那么几个:商品ID、名称、封面、所需积分、库存。在src/types.ts里定义:
import { ImageSourcePropType } from 'react-native'; export interface PointProduct { id: string; name: string; cover: ImageSourcePropType; points: number; stock: number; }这里把cover定义成ImageSourcePropType,是因为RN中本地图片用require()引入时返回的是一个数字资源ID,网络图片是URL字符串。用这个类型可以两种都兼容。Mock数据可以用本地图片文件,避免开发阶段因为网络加载失败导致图片区域空白:
export const mockProducts: PointProduct[] = [ { id: 'p001', name: '便携蓝牙音箱', cover: require('./assets/speaker.png'), points: 1500, stock: 8, }, { id: 'p002', name: '保温杯', cover: require('./assets/cup.png'), points: 650, stock: 20, }, { id: 'p003', name: '视频会员月卡', cover: require('./assets/vip.png'), points: 900, stock: 50, }, ];为什么要先写Mock数据?因为在真实项目里,后端接口往往比前端晚一步,尤其积分商城这种业务,商品上下架、积分价格调整都可能拖到最后。Mock数据能让页面开发不被接口阻塞,等后端就绪后,只需要把mockProducts替换成一个请求函数,页面其余部分不用大改。这里的关键是控制依赖:不要在业务组件里到处直接引用mockProducts,而是让所有数据都从入口函数获取,以后切换真实接口时只改一处。
2.2 积分余额状态管理与商品卡片组件
页面顶部需要展示当前积分余额。在入门阶段直接用useState管理就够,比如默认给一个3200分:
const [userPoints, setUserPoints] = useState(3200); const [products, setProducts] = useState<PointProduct[]>(mockProducts);页面结构上,Header区域放一个积分余额条,下面用FlatList渲染商品列表。之所以用FlatList而不是ScrollView + map,是因为积分商城的商品数量通常不会太少,可能有几十个甚至上百个。FlatList是虚拟化列表,只渲染当前屏幕可见区附近的item,滚动性能比一次性渲染所有item的ScrollView好很多。这一点对长列表是决定性的,尤其鸿蒙设备上如果一次性渲染过多图片,首屏白屏时间会肉眼可见地变长。
商品卡片是一个独立子组件,方便复用和维护:
import React from 'react'; import { View, Text, Image, TouchableOpacity, StyleSheet } from 'react-native'; import type { PointProduct } from '../types'; interface Props { product: PointProduct; userPoints: number; onExchange: (product: PointProduct) => void; } const PointCard = React.memo(({ product, userPoints, onExchange }: Props) => { const canAfford = userPoints >= product.points; const outOfStock = product.stock <= 0; return ( <View style={styles.card}> <Image source={product.cover} style={styles.cover} /> <Text style={styles.name}>{product.name}</Text> <Text style={styles.points}>{product.points} 积分</Text> <Text style={styles.stock}>剩余 {product.stock} 件</Text> <TouchableOpacity style={[styles.button, (!canAfford || outOfStock) && styles.buttonDisabled]} disabled={!canAfford || outOfStock} onPress={() => onExchange(product)} > <Text style={styles.buttonText}> {outOfStock ? '已抢光' : canAfford ? '立即兑换' : '积分不足'} </Text> </TouchableOpacity> </View> ); });按钮状态分三种:积分不足、已抢光、可兑换。一定要把按钮置灰并改变文案,否则用户不知道为什么会兑换失败。这里用React.memo包了一下卡片组件,能减少一些无意义的重复渲染。积分变化时,所有卡片其实都需要重新检查按钮状态,所以memo带来的提升没有想象中那么大,但它能让列表在商品数据局部刷新时少渲染不相关的卡片,这个习惯还是好的。
2.3 列表性能优化与空态处理
FlatList有几个参数建议从一开始就配上。keyExtractor必须用商品唯一ID,千万不要用数组index,否则商品删除或排序变化时,列表状态会错乱。initialNumToRender控制首屏一次渲染几个item,我的经验是设置成和首屏可视数量相近就行,比如6。windowSize控制列表预渲染窗口,默认是21,如果列表项多且图片多,可以适当降低到10,但太小会导致滚动时出现空白,需要根据真机表现调整。
<FlatList data={products} renderItem={renderItem} keyExtractor={(item) => item.id} contentContainerStyle={styles.listContent} ListEmptyComponent={<EmptyView />} initialNumToRender={6} windowSize={10} />空态是一个很容易被忽视但很影响体验的点。当后端返回空数组,或者本地筛选后没有商品时,页面不能只显示一个空白列表。用ListEmptyComponent渲染一个“暂时没有积分商品”的提示,再配一个刷新按钮,用户至少知道当前状态不是Bug。
商品卡片的封面还有一个细节:图片加载前高度不确定,可能会从0突然变成实际高度,导致整排卡片上下跳动。我惯用的做法是给封面设置固定宽高比:
cover: { width: '100%', aspectRatio: 16 / 9, borderRadius: 8, }这样图片还没加载完,布局已经给它预留了正确的高度,加载完成后也不会跳。对于积分商城这种图片密集型页面,这个小习惯能明显提升观感。
3. 兑换流程与记录模块实现
3.1 兑换规则校验与二次确认弹窗
兑换功能是整个页面的核心操作,规则并不复杂:先判断积分够不够,再判断库存有没有,然后弹一个确认框,用户确认后才真正执行兑换。代码大致如下:
const handleExchange = (product: PointProduct) => { if (userPoints < product.points) { Alert.alert('积分不足', `还差 ${product.points - userPoints} 积分`); return; } if (product.stock <= 0) { Alert.alert('库存不足', '该商品已经被兑换完了'); return; } Alert.alert('确认兑换', `确定要花费 ${product.points} 积分兑换 ${product.name} 吗?`, [ { text: '取消', style: 'cancel' }, { text: '兑换', onPress: () => doExchange(product) }, ]); }; const doExchange = (product: PointProduct) => { setUserPoints((prev) => prev - product.points); setProducts((prev) => prev.map((p) => (p.id === product.id ? { ...p, stock: p.stock - 1 } : p)) ); addRecord(product); Alert.alert('兑换成功', '可在兑换记录中查看详情'); };为什么需要二次确认?积分兑换属于消耗性操作,用户一旦误触,积分就没了。虽然技术上加个确认框很简单,但在产品体验上是刚需。另外,二次确认也为以后接真实后端接口留了一个天然的入口:确认弹窗的“兑换”按钮里发起接口请求,正好符合用户的操作节奏。
注意状态更新使用了函数式写法setUserPoints(prev => prev - product.points)。这不是炫技,而是防止极端情况下拿到旧值。如果用户在积分变动前快速触发两次操作,函数式更新能保证基于最新状态计算,比直接读userPoints更可靠。虽然我们的二次弹窗会有一定防抖作用,但代码层面还是要稳健一点。
3.2 兑换记录的数据持久化
积分兑换完成后,需要生成一条兑换记录。如果只是临时存在组件state里,App一重启就没了,显然不满足“记录”的要求。正常做法是把记录存在本地,或者提交到后端。这个项目没有后端,所以我用RN自带的AsyncStorage做本地持久化。
先定义记录类型:
export interface ExchangeRecord { id: string; productId: string; productName: string; points: number; exchangedAt: number; status: 'success'; }再封装读写工具:
import AsyncStorage from '@react-native-async-storage/async-storage'; const STORAGE_KEY = 'point_mall_records'; export async function getRecords(): Promise<ExchangeRecord[]> { const raw = await AsyncStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : []; } export async function saveRecord(record: ExchangeRecord): Promise<void> { const records = await getRecords(); records.unshift(record); await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(records)); }为什么把读写单独封装成工具函数?因为记录数据的结构后续大概率会变,比如增加状态字段、兑换单号等,集中在src/utils/recordStorage.ts里维护,比散落在各个页面代码里好改得多。
在页面里,记录用state维护,初始化时读取一次:
const [records, setRecords] = useState<ExchangeRecord[]>([]); useEffect(() => { getRecords().then(setRecords); }, []); const addRecord = async (product: PointProduct) => { const newRecord: ExchangeRecord = { id: Date.now().toString(), productId: product.id, productName: product.name, points: product.points, exchangedAt: Date.now(), status: 'success', }; setRecords((prev) => [newRecord, ...prev]); await saveRecord(newRecord); };用Date.now().toString()作为记录ID,在入门项目里够用了。如果以后有更严谨的防重需求,可以考虑生成一个UUID,不过那是另一层话题了。
3.3 商城页与记录页之间的状态同步
积分商城页面和兑换记录页面是两个独立页面,但共享同一份“兑换记录”和“当前积分”状态。如果通过路由参数把记录从商城页传到记录页,会出现返回再进入后数据不是最新、重复跳转状态不同步的问题。正确做法是把共享状态抽到一个全局Store里。
我推荐用Zustand,写法比Redux轻量很多,也比React Context少写一堆Provider嵌套。先安装:
npm install zustand然后建一个Store:
import { create } from 'zustand'; interface MallState { userPoints: number; records: ExchangeRecord[]; setUserPoints: (points: number) => void; addRecord: (record: ExchangeRecord) => void; } export const useMallStore = create<MallState>((set) => ({ userPoints: 3200, records: [], setUserPoints: (points) => set({ userPoints: points }), addRecord: (record) => set((state) => ({ records: [record, ...state.records] })), }));商城页和记录页都从同一个Store读取状态,任何一页修改后,另一页自动更新。使用起来非常直接:
const userPoints = useMallStore((state) => state.userPoints); const records = useMallStore((state) => state.records); const addRecord = useMallStore((state) => state.addRecord);这里要理解一个关键点:兑换成功后,商城页调用的addRecord更新的是Store里的records,记录页监听的也是同一个records,所以两个页面天然同步,完全不需要手动刷新。如果将来积分余额也要在记录页展示,也只需要在Store里读同一个userPoints。这个模式对于中大型App同样适用,团队里大家用一致的全局数据源,比各页面自己维护一份再互相通知要省心太多。
记录页面本身就是一个FlatList,每一行展示商品名、消耗积分、兑换时间。时间格式化可以用new Date(record.exchangedAt).toLocaleString(),但要注意鸿蒙设备上系统时间格式可能和Android不同,最好在工具函数里统一格式化,不要直接拼字符串。
4. 鸿蒙真机与模拟器调试踩坑实录
4.1 React Native启动白屏的排查步骤
虽然积分商城页面本身不复杂,但第一次把App装到鸿蒙模拟器上时,我还是遇到了经典问题:启动后一片白屏,等了半天也没有内容。社区里搜“react native 启动白屏”,能找到一堆帖子,但大部分是Android场景,鸿蒙场景下的排查路径要稍微调整一下。
白屏的第一反应不是怀疑代码,而是确认Metro服务有没有启动。RN开发模式需要Metro提供JS Bundle,没有Metro或手机连不上Metro,App启动后自然拿不到可渲染的JS代码。
排查步骤我整理成这样:
- 在终端执行
npx react-native start,看到Metro启动成功且等待连接,再打开App。 - 如果Metro终端没有任何请求日志,说明App没有成功请求Bundle,检查鸿蒙壳工程里配置的Bundle地址对不对。
- 查看DevEco Studio的Log窗口,搜
ReactNativeJS或SoLoader,看有没有红色报错。 - 检查鸿蒙工程里是否声明了网络权限,缺少
ohos.permission.INTERNET时,从Metro加载Bundle会被拒绝,表现出来就是白屏。 - 如果是安装正式包后白屏,很可能是没有把JS Bundle打进包里。
正式包不像开发包可以连Metro,需要提前把Bundle打包好放到资源目录。命令大致是:
npx react-native bundle --platform harmony --dev false --entry-file index.js --bundle-output harmoney/entry/src/main/resources/rawfile/index.bundle这里--platform harmony是鸿蒙适配层的约定,不同适配分支可能有差异,具体以你的适配文档为准。打包成功后重新构建hap,白屏问题基本就解决了。
4.2 真机与模拟器的调试区别
积分商城页面用模拟器调试其实足够,UI布局和交互逻辑都能验证。但如果你想测试真实网络接口,我还是建议用真机跑一轮。原因很简单:模拟器和真机网络环境有差异,有些局域网请求在模拟器上通,真机上却会失败。
模拟器调试时,Metro的地址通常可以填localhost,因为模拟器和开发机共享网络。真机就不行,手机走的是自己的网络,填localhost只会访问手机自身。需要把Metro地址改成开发机在局域网里的IP,类似:
const DEV_HOST = '192.168.1.20:8081';然后在鸿蒙壳工程或者启动配置里,把加载Bundle的地址改成http://${DEV_HOST}/index.bundle。如果修改后还是连不上,检查开发机防火墙是否放行了8081端口。
真机调试还需要在鸿蒙设备上开启开发者模式,并通过DevEco Studio连接。鸿蒙4.0之后的设备支持无线调试,但首次连接还是建议用USB线减少变量。积分商城这种轻量页面,不需要用到指纹、NFC等硬件能力,真机验证的重点是网络请求、页面切换流畅度和列表滚动性能。
4.3 鸿蒙适配的几个细节坑
有几个坑虽然不致命,但碰到了会卡住半天。
第一个是Platform.OS的判断。在鸿蒙适配层里,Platform.OS的返回值有的分支是harmony,有的分支出于兼容考虑仍返回android。这意味着你代码里写if (Platform.OS === 'android')时,在鸿蒙上不一定能命中预期分支。建议把所有平台判断收敛到一个工具函数里,后面适配不同平台只需要改这一处。
第二个是状态栏安全区。鸿蒙的状态栏高度和Android的挖孔屏、iOS的刘海都不一样,如果页面顶部直接顶到状态栏底下,积分余额文字会被遮挡。用react-native-safe-area-context包一层SafeAreaView,比手动加paddingTop稳妥得多,它能根据不同设备动态计算安全区。
第三个是字体。不要在JS里写死某个平台的字体名,比如fontFamily: 'PingFang SC',鸿蒙设备上很可能没有这个字体,会导致系统回落成默认字体,页面观感差异很大。积分商城里的数字和大标题如果要统一风格,最好是加载通用字体文件,或者干脆用系统默认字体。
第四个是网络请求失败。鸿蒙端请求报错时,优先检查ohos.permission.INTERNET权限和baseURL是否写成了localhost。在本地联调时,baseURL要填开发机的局域网IP,否则真机请求必然失败。
5. 后续扩展方向与个人心得
5.1 从Demo到可上线应用还要补什么
积分商城页面跑通之后,“能兑换、有记录”只是最小闭环。如果要做成真正可上线的功能,下面这些内容早晚要补上。
第一,接入真实用户体系。积分不能写死在Store里,必须从服务端用户积分接口拉取,兑换成功后再和服务端对账。第二,商品分页。当前FlatList的data一次全量渲染,真实场景商品数量可能是几百个,接口需要做分页,FlatList的onEndReached配合onEndReachedThreshold做无限滚动,底部加上“加载中”和“没有更多了”的状态。第三,接口幂等。兑换请求需要带一个客户端生成的兑换单号,服务端用这个单号做防重,否则用户连续点击两次兑换,可能产生两笔扣除记录。第四,埋点与监控。兑换按钮点击率、兑换成功率、接口耗时这些数据,一定要从第一天就开始记录,不然上线后出了问题只能干瞪眼。
还有一点经常被忽略:多端回归。React Native跨平台开发不能只在鸿蒙上报好消息,Android和iOS的构建也要同步跑,因为第三方原生库在不同平台上的兼容性总有差异。我的习惯是每次改动核心逻辑后,三端各跑一遍主流程,时间成本不算高,但能避免发布前才突然发现某个平台不兼容的尴尬。
5.2 我建议的落地顺序和心得
这个项目给我最大的体会是:先跑通功能闭环,再回来优化UI和性能。一开始我也想做一套精美的商品卡片,圆角、阴影、渐变一个不少,结果卡在Bundle加载问题上折腾了两天,样式代码反而成了调试时的噪音。后来我把卡片样式全部简化成纯色背景加普通文字,先把“列表→兑换→记录”跑通,再回头加阴影和空态插图,效率高很多。
另一个实用技巧是把Mock数据封装成一个独立函数,切换真实接口时只改一处:
// api/pointApi.ts export const fetchProducts = async (): Promise<PointProduct[]> => { // 本地调试期 // return mockProducts; // 联调期 // return request.get('/products'); };这样页面开发时可以真实返回Mock数据,后端接口通了以后,把注释换一行就切换完成了。整个过程用户无感知,也不需要大范围改代码。
按这个思路,积分商城页面从需求到跑通,一个熟练的RN开发大概两三天就能完成。新手第一次接触鸿蒙适配的话,多预留一点时间给环境搭建和白屏排查,只要把“跨平台代码归业务、原生壳归适配”这个边界想清楚,后面会顺利很多。
跑完这个积分商城,我对鸿蒙跨平台开发的信心足了不少。跨平台从来不是什么银弹,但当你把组件层、状态层和平台壳分开以后,新平台带来的冲击确实能被消化在很小一个范围内。最后留一个小建议:做新页面时,先把所有“状态变化”用一张表列出来,比如积分余额、商品库存、兑换记录,再动手写UI。我在积分商城这个项目里试了,越到后面越省心。