Day4 到 Day6,我把康复系统最关键的三个页面从「能跑」推到了「能用」:首页看板、训练计划列表、每日打卡表单。这三个页面全部基于 React Native 内置组件实现,跑在鸿蒙环境的模拟器和真机上。文章会直接分享我这三天的踩坑记录,包括 React Native 鸿蒙启动白屏怎么排查、内置组件在鸿蒙上的差异怎么处理,以及康复系统里典型的列表、表单、弹窗交互怎么落地。如果你正在折腾 react native for openharmony,或者打算用 RN 快速搭一个鸿蒙端工具类应用,这份内容应该能帮你节省至少一个晚上的调试时间。
1. 项目复盘:Day4~6完成了多少康复系统
1.1 康复系统的功能边界和选型逻辑
康复系统面向的是有居家康复需求的患者,加上康复师远程查看进度的轻管理场景。我不打算做成大而全的医疗平台,只聚焦三个高频动作:查看今天要做什么、按计划完成训练、提交每次训练的反馈。对应到界面上,就是首页看板、训练计划列表和每日打卡表单。
Day4~6 的目标很明确:先把这三条主链路跑通,让数据能展示、能点击、能输入。首页看板负责展示今日训练总览、本周完成率;训练计划列表用卡片形式展示动作名称、组数、预计时长;每日打卡表单包含疼痛等级、完成状态和备注,用户填完点提交,前端把数据交给后端接口。
这个范围是在项目启动时反复压缩过的。原因很简单:康复系统涉及的权限、数据模型、接口文档都还在频繁调整,如果一上来就铺开做用户管理、图表报表,很容易被需求返工拖垮。先做最小闭环,再慢慢迭代,是这个阶段最合适的方式。技术上我也提前做了取舍:优先保证页面能跑通,交互细节放在第二步优化。
技术选型上,我直接锁定 React Native 而不是原生 ArkTS。原因有两个:第一,RN 生态里现成的组件和业务逻辑可以复用,团队里前端同学也能直接上手;第二,当前阶段业务验证比极限性能更重要。虽然鸿蒙原生的 ArkTS 能力更强,但 RN 的开发效率对这个体量的项目来说优势太明显了。
1.2 为什么坚持用内置组件而不是第三方UI库
项目初期有人建议直接用第三方 UI 组件库,比如一套现成的移动端组件库,可以少写很多样式。我最终没有走这条路,原因有两个。
第一,RN for OpenHarmony 的适配进度还在快速迭代中,第三方 UI 库底层可能使用了很多自定义原生 View 或特殊动画,这些在鸿蒙上不一定有对应实现。与其在第三方组件报错时去翻它的原生代码,不如用框架自带组件,至少这些组件是 RNOH 框架重点保证兼容性的部分。我在社区里看到过有人用第三方库后出现触摸失效、弹窗错位的问题,排查成本很高。
第二,康复系统的界面相对工具化,不需要特别炫的视觉。内置组件完全可以撑起目前的业务:View 做布局、Text 做文本、ScrollView 做滚动容器、FlatList 做列表、TextInput 做输入、Modal 做弹窗。这套组合在标准 RN 里已经非常成熟,在鸿蒙上也有比较明确的适配路径。我先把结论放前面:如果你的业务界面也是这种表单+列表+详情页的形态,在鸿蒙上优先用内置组件,遇到问题容易搜索、容易定位,不会因为 UI 库本身引入新的不确定因素。
1.3 数据模型与状态管理的轻量方案
Day4~6 期间,后端接口并没有全部就绪。为了保证开发不阻塞,我先在项目里定义了前端侧的 TypeScript 类型,用固定 mock 数据驱动页面。核心数据实体有患者信息、训练计划、训练记录、打卡记录四类。训练计划包含动作名称、组数、次数、预计时长;打卡记录包含训练完成状态、疼痛等级、备注文本。
状态管理方面我没有急着上 Redux。这个阶段页面少,患者信息可以从入口参数传递,打卡表单状态保存在组件内部,跨页面共享的数据用一个简单的 Context 就够用了。等后续加入多角色登录、历史记录、趋势图之后,再考虑引入 zustand 或 Redux Toolkit。过早引入全局状态管理,只会让每次改接口字段时同步改一堆 action 和 reducer,反而拖慢进度。
2. 环境准备:鸿蒙下的工程初始化与启动白屏
2.1 从初始化到看到首页的过程
Day4 一开始其实没写业务代码,因为工程环境就花了大半天。我的做法是先用 React Native 官方 CLI 创建一个标准的 TypeScript 工程,再对照 RNOH 社区的集成文档,把鸿蒙壳工程配置进项目。具体来说,是在工程根目录下增加harmony目录,里面有完整的 DevEco Studio 工程,通过hvigor构建脚本把 JS Bundle 和 RN 运行时打包成 hap 包。
这里最关键的一步是确认react-native包版本和 RNOH 适配库版本要匹配。不同小版本之间,组件的原生映射表可能有差异,最直接的表现就是某几个组件在鸿蒙上白屏、不渲染,或者是点击没有响应。我当时固定了版本号,没有使用latest,然后用npm ci把依赖锁死,避免第二天拉新版本后行为变化。现在的 RN 版本迭代非常快,锁版本是第一天必须养成的习惯。
构建顺序上,先执行 Metro 启动 JS 服务,再用 DevEco Studio 运行鸿蒙工程。注意 Metro 的端口默认是 8081,鸿蒙侧的 EntryAbility 在启动时会去加载这个服务地址。如果改了端口,需要同步修改壳工程里的配置。我第一次调试时就是端口没对上,导致应用一直卡在启动页。后来养成一个习惯:启动应用前先在浏览器里访问一下http://localhost:8081/status,确认 Metro 正常再运行鸿蒙工程。
2.2 模拟器兼容性:arm64 与 jsvm 的限制
这三天里我遇到的一个环境大坑,是鸿蒙模拟器的架构限制。官方模拟器目前对架构有要求,这个信息在社区里很热:运行设备不兼容鸿蒙模拟器目前只能在 arm64 平台运行 jsvm。也就是说,如果开发机是 x86_64 架构,直接用官方模拟器跑 RN 应用,很可能在启动阶段报“设备不兼容”或者 JS 引擎无法初始化。
这个限制的原因不难理解:RN 在鸿蒙上依赖 JavaScript 虚拟机(jsvm)执行 JS 代码,而 jsvm 的鸿蒙运行版本主要提供 arm64 产物。虽然也可以用其它内核跑,但 RN 当前集成路径默认走的是这个引擎。所以最省事的方式,是找一台 arm64 开发机,或者在支持 arm64 镜像的模拟器上运行,再不然就直接用真机验证。
我后来在 Mac mini(M 系列芯片)上成功启动了模拟器,业务页面才正常渲染。如果你只有 X86 的 Windows 电脑,建议直接准备一台鸿蒙真机,通过 USB 连接后开启开发者模式调试,体验会比折腾模拟器顺畅很多。模拟器适合日常 UI 快速验证,真机适合做完整流程调试,两边配合使用最高效。
2.3 启动白屏排查:从日志到权限逐层定位
启动白屏是 React Native 鸿蒙上被问得最多的现象,也是我这三天踩得最深的坑。所谓白屏,就是应用进程起来后窗口显示了,但页面内容一直没有出现。我遇到过三次白屏,原因各不相同。
第一次是 Metro 没有启动,应用去加载 JS bundle 失败,窗口起不来内容。这种通过 Metro 的终端日志就能看到有没有请求进来。如果没请求,检查壳工程里配置的 bundle 地址是否指向了正确的 Metro 端口。第二次是网络权限问题。鸿蒙应用默认的网络访问权限是需要显式声明的,如果module.json5里没有加ohos.permission.INTERNET,JS bundle 就无法从 Metro 下载,页面自然空白。这个问题报错不一定明显,我是在看 hdc 日志时才发现有网络请求被拦截。
第三次是组件渲染异常。RN 代码里如果用到了鸿蒙还没有实现的原生模块,或者某个内置组件的某个属性不兼容,也可能整页白屏。这种最好处理,打开 Metro 的日志,能看到具体的 JS 异常堆栈,定位到是哪个文件哪一行。我把排查顺序整理成了表格,后面第四章会再展开。
| 白屏场景 | 可能原因 | 定位方式 | 快速处理 |
|---|---|---|---|
| 应用启动后一直空白 | Metro 服务未启动或端口不对 | 查看 Metro 终端日志,确认是否有 bundle 请求 | 启动 Metro,核对 EntryAbility 的端口配置 |
| 网络请求被阻断 | 缺少 ohos.permission.INTERNET 权限 | 查看 hdc/Hilog 日志 | 在 module.json5 中声明权限 |
| 有 JS 报错但不显示 | 组件属性或原生模块不支持 | 打开 Metro 日志查看异常堆栈 | 替换为兼容的内置组件或属性 |
| 真机调试正常,模拟器白屏 | 架构或模拟器引擎不兼容 | 检查模拟器是否为 arm64 镜像 | 换 arm64 镜像或真机调试 |
2.4 真机调试与日志分析小技巧
真机调试是这三天里最稳定的方案。鸿蒙真机连接电脑后,先通过hdc list targets确认设备被识别,再用 DevEco Studio 自动签名安装应用。如果签名证书没配置好,应用会安装失败,在 DevEco Studio 的日志面板里能看到签名错误,需要到项目签名配置里补充证书信息。
日志分析方面,我平时会同时开两个窗口:一个跑 Metro,一个跑hdc shell hilog。过滤关键字RNJS或ReactNativeJS可以快速看到 JS 层的 console 输出。鸿蒙原生层的异常日志则用hilog | grep -i react过滤。这样 JS 报错和原生报错能分开看,定位问题会快很多。真机上跑 RN 应用还有一个好处:能直接看到网络请求是否真的到达了后端,比在模拟器上更容易排查接口联调问题。
3. 内置组件在康复系统里的落地场景
3.1 用 View / Text / ScrollView 搭建首页看板
先说首页看板。这个页面结构是顶部问候语、今日训练卡片、本周完成率进度条、底部操作按钮。标准 RN 的做法是用 View 做分层容器,Text 做所有文案,ScrollView 包住整个可滚动区域。代码大致是这个样子:
import React from 'react'; import { View, Text, ScrollView, SafeAreaView, StyleSheet, } from 'react-native'; function HomeBoard() { return ( <SafeAreaView style={styles.container}> <ScrollView contentContainerStyle={styles.content}> <View style={styles.header}> <Text style={styles.greeting}>下午好,张阿姨</Text> <Text style={styles.subGreeting}>今日康复计划已更新</Text> </View> <View style={styles.todayCard}> <Text style={styles.cardTitle}>今日训练</Text> <Text style={styles.cardCount}>3 组动作 · 预计 25 分钟</Text> </View> <View style={styles.progressCard}> <Text style={styles.cardTitle}>本周完成率</Text> <Text style={styles.progressText}>68%</Text> </View> </ScrollView> </SafeAreaView> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#F5F7FA' }, content: { padding: 16 }, header: { marginBottom: 16 }, greeting: { fontSize: 24, fontWeight: '600', color: '#1F2937' }, subGreeting: { fontSize: 14, color: '#6B7280', marginTop: 4 }, todayCard: { backgroundColor: '#FFFFFF', borderRadius: 12, padding: 16, marginBottom: 12 }, cardTitle: { fontSize: 16, fontWeight: '500', color: '#374151' }, cardCount: { fontSize: 14, color: '#9CA3AF', marginTop: 8 }, progressCard: { backgroundColor: '#FFFFFF', borderRadius: 12, padding: 16 }, progressText: { fontSize: 28, fontWeight: '700', color: '#2563EB' }, }); export default HomeBoard;这里有个细节:在鸿蒙上SafeAreaView的兼容性比普通 View 更复杂,不建议把安全区逻辑全部交给它处理。我在实际项目中是给根容器加了paddingTop兜底,同时保留SafeAreaView,两边一起作用,避免在个别机型上出现刘海屏遮挡。另外,ScrollView的使用原则很简单:只适合内容量可控的页面。首页看板的内容固定,最多增加几条公告,用 ScrollView 没问题。如果未来要把历史记录全部塞进来,就要换成 FlatList 或者分段刷新,避免一次性渲染大量节点导致滚动掉帧。
3.2 FlatList 训练计划列表:性能与交互兼顾
训练计划列表是康复系统里数据量最大的页面,我用 FlatList 而不是 ScrollView 来渲染。原因是 FlatList 会做窗口化渲染,只绘制当前屏幕附近的数据项,在几十条计划卡片的情况下性能依然稳定。先看核心代码:
import React, { useState } from 'react'; import { FlatList, Text, View, TouchableOpacity, StyleSheet, } from 'react-native'; const plans = [ { id: '1', name: '膝关节屈伸训练', groups: 4, reps: 15, time: 8 }, { id: '2', name: '踝泵训练', groups: 3, reps: 20, time: 5 }, { id: '3', name: '靠墙静蹲', groups: 3, reps: 45, time: 10 }, // 后续接口返回更多数据 ]; function PlanList() { const [refreshing, setRefreshing] = useState(false); const renderItem = ({ item }) => ( <TouchableOpacity style={styles.card} key={item.id}> <View style={styles.info}> <Text style={styles.name}>{item.name}</Text> <Text style={styles.desc}> {item.groups} 组 × {item.reps} 次 · 约 {item.time} 分钟 </Text> </View> <Text style={styles.arrow}>开始</Text> </TouchableOpacity> ); return ( <FlatList data={plans} renderItem={renderItem} keyExtractor={(item) => item.id} refreshing={refreshing} onRefresh={() => { setRefreshing(true); // 模拟请求结束后的状态 setTimeout(() => setRefreshing(false), 800); }} contentContainerStyle={styles.listContent} /> ); }FlatList 在鸿蒙上的表现比我预期好,但有几个点必须注意。第一,renderItem不要写成内联箭头函数。否则每次父组件刷新,FlatList 都认为 item 渲染函数变了,会导致列表项重新渲染,卡顿感会很明显。我上面把renderItem提到了组件内,但如果进一步优化,应该用useCallback包裹。第二,如果列表特别长,建议实现getItemLayout。这样 FlatList 不需要动态计算每一项的高度,滚动时能减少计算量。适合像训练计划这种卡片高度基本一致的场景。
第三,空数据的处理。列表接口没返回数据时,不要只显示一个白屏。我增加了ListEmptyComponent,用一张插图和文案提示用户暂无训练计划,同时提供一个“刷新”按钮。这个交互在康复场景里很重要,患者看到空白页面会以为是 App 坏了。第四,分页加载可以靠onEndReached实现,但要注意onEndReachedThreshold在鸿蒙上的表现,我设置成0.3时体验比较正常。如果阈值太小,可能会在快到底部时才触发请求,用户会明显感觉到等待。
3.3 TextInput / Switch / TouchableOpacity 实现打卡表单
每日打卡表单是患者和康复师最关注的交互点。患者需要填报本次训练的感受:疼痛等级、是否完成、备注。实现上我用 TextInput 做文本输入,Switch 做完成状态切换,TouchableOpacity 做提交按钮。代码片段如下:
import React, { useState } from 'react'; import { View, Text, TextInput, Switch, TouchableOpacity, KeyboardAvoidingView, Platform, } from 'react-native'; function CheckinForm() { const [painLevel, setPainLevel] = useState(''); const [finished, setFinished] = useState(false); const [comment, setComment] = useState(''); const submit = () => { // TODO: 把表单数据交给后端接口 console.log({ painLevel, finished, comment }); }; return ( <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }}> <View style={{ padding: 16 }}> <Text>疼痛等级(0-10)</Text> <TextInput value={painLevel} onChangeText={setPainLevel} keyboardType="numeric" placeholder="请输入 0 到 10 之间的数字" style={styles.input} /> <Text>本次训练是否完成</Text> <Switch value={finished} onValueChange={setFinished} /> <Text>备注</Text> <TextInput value={comment} onChangeText={setComment} multiline placeholder="记录一下训练感受" style={[styles.input, styles.multiline]} /> <TouchableOpacity onPress={submit} style={styles.submitButton}> <Text style={styles.submitText}>提交记录</Text> </TouchableOpacity> </View> </KeyboardAvoidingView> ); }真实场景里,我这个页面在鸿蒙上遇到两个问题。第一个是软键盘遮住输入框。在标准 RN 里可以依赖KeyboardAvoidingView,但在鸿蒙上behavior的表现和 iOS 不完全一致。我的解决办法是给根布局加了一个onKeyboardDidShow监听,动态调整底部按钮的marginBottom,让提交按钮始终露出键盘上方。第二个是 Switch 组件的视觉效果。系统默认 Switch 在鸿蒙上的轨道颜色可能和设计稿有差异,需要显式设置trackColor和thumbColor,否则颜色会很奇怪。这也是内置组件跨平台常见的适配点,样式属性和标准 RN 对齐,但渲染效果还需要逐个验证。
表单提交我并没有直接连后端,Day6 结束时只做了本地状态维护和 Mock 请求。等后端接口稳定后,再把提交逻辑替换成真实请求。这样前端开发不被接口阻塞,是目前比较高效的做法。表单校验也做了最基础的一层:疼痛等级必须是 0 到 10 的数字,如果超出范围就给出红色提示,不调用提交逻辑。
3.4 Modal 与 ActivityIndicator 的补充场景
康复系统里还有一个很常见的交互:训练完成后的弹窗确认。我用 Modal 实现,展示本次训练时长、完成动作数,引导患者去打卡。在鸿蒙上,Modal 的transparent属性和动画比较基础,所以我没有做复杂的弹出动画,而是用半透明遮罩加居中的圆角卡片,既简单又够用。
代码上就是visible控制显示,animationType="fade"做淡入淡出,里面放一个 View 容器承载内容。需要注意 Modal 内的背景需要手动设置opacity和颜色,否则默认是黑色半透明,遮罩效果会和设计稿偏差很大。我一般会用一个遮罩 View 盖住全屏,再在它的子层放卡片,这样层级关系最清晰。
提交按钮点击后,我会用ActivityIndicator显示加载态。这个组件在鸿蒙上同样支持,但要注意不要把它放在TouchableOpacity内部导致触摸事件被拦截。我一般是在按钮外部创建一个绝对定位的遮罩层,统一处理 loading 状态,这样避免用户重复点击。等接口返回后再关闭遮罩,同时根据结果弹出成功或失败的提示。加载态颜色我设置了跟主题色一致的color="#2563EB",在浅色背景下看着很清楚。
4. 常见问题与调试实录
4.1 高频报错速查表
这三天我把遇到的错误都记在了项目 Issue 里,整理成了一张速查表,按“现象 → 原因 → 处理”的方式记录,供自己再遇到时快速定位,也分享给你:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 应用启动白屏 | Metro 未启动 / bundle 地址错误 / 网络权限缺失 | 先看 Metro 日志,再查 EntryAbility 配置和module.json5权限 |
| 组件没有渲染 | 使用了鸿蒙未实现的属性或原生模块 | 打开 Metro 错误堆栈,替换为兼容写法 |
| FlatList 滚动卡顿 | renderItem 内联函数 / 缺少 keyExtractor | 抽离 renderItem、用 useCallback、保证唯一 key |
| 软键盘遮挡输入框 | KeyboardAvoidingView 行为不完全兼容 | 监听键盘事件,动态调整页面偏移 |
| Switch 颜色不对 | 未设置 trackColor / thumbColor | 显式设置主题色 |
| 模拟器提示不兼容 | 当前模拟器架构不是 arm64 | 换 arm64 镜像或使用真机 |
这张表我会持续补充。Day4~6 只是项目早期阶段,后面遇到更高阶的问题,比如网络层封装、图片上传、权限弹窗等,我还会继续更新。另外提醒一句:遇到报错先别急着改代码,把完整的报错日志复制到本地文件,再对照版本号搜索。社区里的 issue 很活跃,很多问题其实已经有了解决方案,只是需要多翻几条才能找到适配你版本的回答。
4.2 渲染性能优化:让列表滚动不卡顿
康复系统对性能的容忍度其实不高,患者如果对着一个卡顿的列表做训练,体验会非常糟糕。所以我从 Day5 开始就关注渲染性能,主要做了三件事。
第一,减少不必要的setState。页面上状态更新会触发整个组件函数重新执行,如果有子组件没有用React.memo包裹,就会连带刷新。我尽量把状态收敛在最小范围内,比如打卡表单的输入状态就放在表单组件内部,不提升到父页面。第二,FlatList 的 key 必须稳定。我之前遇到过一个问题:列表数据排序后 key 变了,导致 FlatList 认为所有 item 都是新的,全部重新渲染,滑动时明显掉帧。修复方式是始终使用业务主键id,不要用 index。
第三,样式对象不要内联创建。style={{ padding: 16 }}这种写法在每次渲染时都会生成新的对象,虽然 React 能处理,但累积起来对长列表不友好。我使用StyleSheet.create固定样式表,组件层通过样式文件名引用,整体可读性和复现性也更好。再加上React.memo包裹列表项组件,整个列表滚动时的帧率比优化前明显稳定。
4.3 鸿蒙组件兼容性细节与避坑
内置组件在鸿蒙上不是 100% 和 Android/iOS 一致,我挑了三个最典型的差异。
第一个是阴影属性。标准 RN 里用shadowColor、shadowOffset、shadowOpacity这套,在鸿蒙上经常不生效。更推荐使用elevation属性,或者用边框加背景色做视觉上的卡片层次。我在训练计划卡片上直接用borderWidth: 1加浅色边框,避免依赖阴影。第二个是文本截断。numberOfLines={1}和ellipsizeMode="tail"在部分鸿蒙版本上表现不稳定,长文件名偶尔会换行而不是省略号。我的做法是在文本外层限制宽度,同时设置flexShrink: 1,再配合numberOfLines,降低出问题的概率。
第三个是触摸反馈。TouchableOpacity在鸿蒙上有效果,但按压透明度有时不明显。如果希望有更明确的点击状态,可以用Pressable,通过style函数根据pressed状态动态改变背景色,交互反馈更清晰。这些细节看着小,但对患者的操作体验影响很大。康复系统里的用户很多是年长者,如果按钮按压没有反馈,他们很容易重复点击,反而造成误操作。
4.4 Metro 与 hdc 日志配合的调试工具链
这三天最实用的调试工具组合,是 Metro 终端日志加 hdc 的系统日志。Metro 终端会显示 JS 层所有请求和报错,但原生层的崩溃信息不一定出现在这里。所以我同时开一个 hdc 日志窗口,用hdc shell hilog | grep ReactNativeJS过滤 JS 日志,再用hdc shell hilog | grep -i react看原生层信息。
如果遇到页面白屏但 Metro 没有明显输出,可以先看 hdc 日志里是不是有Cannot find module或so文件加载失败的错误。曾经有一次就是原生库没有正确打进 hap 包,导致应用起不来,这个只有通过 hdc 日志才能发现。整体调试节奏是:先跑通一次完整启动流程,确认日志没有红色错误,再开始写业务代码。这样后续出的问题基本都能定位到业务层,不会跟环境问题混在一起。
最后分享一点我自己的体会。Day4~6 从结果看只完成了三个页面,但对我来说最大的收获不是页面的数量,而是把 React Native 鸿蒙内置组件的运行逻辑摸清了。