康复系统做到第二周,我开始把注意力从“跑通脚手架”挪到一屏一屏的界面搭建上。这三天基本没碰复杂的原生交互逻辑,专注做一件事:把 React Native 在鸿蒙设备上的内置组件挨个用熟,用它们把康复训练记录、打卡表单、进度列表这些页面真正撑起来。如果你也在做 React Native 鸿蒙方向的适配,尤其是像我们这样要先交付一套可用的业务页面,而不是先啃底层能力,那么这四天里踩过的一些组件边界问题,应该能帮你少走不少弯路。
这里先交代一下背景:我们做的康复系统分患者端和康复师端。患者端最核心的页面是每日训练计划、训练打卡和进度查看;康复师端则是排期、记录和基础数据维护。最开始我很担心,鸿蒙的内置组件会不会跟 iOS/Android 上表现不一致,导致整套 UI 都要重写。实际做完 Day4~6 之后,我的结论是:常用内置组件的覆盖面已经够用,只要先把边界摸清楚、按照鸿蒙的运行习惯做一些细节调整,业务页面基本不需要依赖第三方 UI 库。
1. 项目背景:康复系统从“能跑”到“组件都用对”
1.1 这一阶段的起点与目标
先说说为什么到了 Day4 才开始认真研究内置组件。前面几天我们主要是在解决工程侧的问题:React Native 工程怎么在鸿蒙设备上初始化、原生工程和 JS 侧怎么联动、打包产物怎么塞进应用。等这些链路通了,页面打开不再是白屏,才有条件进入真正写业务界面的阶段。
Day4~6 的目标非常明确:
- 把训练计划页的长列表做出来,要求能按日期分组、支持下拉刷新;
- 把打卡页的表单做出来,包括训练项目选择、训练时长数字输入、备注文本输入;
- 把训练详情弹层和操作反馈做出来,尽量接近原生的交互手感;
- 顺手把启动阶段的体验问题处理一下,别让用户每次冷启动都盯着白屏。
这一阶段我们没有引入任何额外的 UI 组件库。原因很直接:康复系统的界面结构不算复杂,核心就是列表、卡片、表单、弹层。这些用 React Native 内置组件完全能实现。在鸿蒙这种还在快速迭代的运行环境里,第三方库的适配往往滞后,与其去调试一个不兼容的三方日期选择器,不如先用内置组件搭一套稳定的界面骨架。
1.2 为什么只盯着内置组件
很多做 RN 开发的朋友习惯了“缺什么就 npm 装什么”,但到了鸿蒙侧,这个习惯得改一改。新架构、新原生能力、新的发布节奏,导致社区里许多组件的原生代码还没有完整编译进鸿蒙的包体。最稳妥的做法是先把内置组件的能力边界吃透,用组合的方式解决问题。
比如日期选择,我没有直接去找一个日历库,而是用 Modal 加自定义的滚轮列表来实现。表面上多写了一些代码,但实际上避开了“第三方原生组件在鸿蒙上未编译、接口不存在”这一类最棘手的问题。内置组件还有一个好处是 API 心智模型稳定。你在 iOS 上怎么写 FlatList,鸿蒙上基本还是同样一套写法,团队协作时不用额外记一套差异。
所以后面这几天的记录,本质上就是一份“用内置组件搭康复系统业务页面”的实战笔记。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 内置组件兼容性摸底:把不确定项提前找出来
2.1 组件可用性清单
开始写页面之前,我做了一张简单的组件盘点表。不是说每个组件都要列一遍,而是把我预期会用的组件拿出来,在鸿蒙设备上逐一跑最小示例。
| 内置组件 | 预期用途 | Day4~6 实测结论 |
|---|---|---|
| View | 布局容器、卡片背景 | 基础能力稳定,关注圆角与阴影表现 |
| Text | 标题、正文、状态标签 | 稳定,注意字体缩放适配 |
| TextInput | 打卡表单、搜索框 | 基础输入没问题,多行输入与键盘避让需要处理 |
| ScrollView | 页面滚动、表单滚到底 | 稳定,适合子元素较少的长页面 |
| FlatList | 训练计划列表、历史记录 | 稳定,长列表性能需要显式配置 |
| Pressable | 卡片点击、按钮反馈 | 比 Touchable 系列更好用,压感状态清晰 |
| Modal | 训练详情弹层、确认弹窗 | 基础能力可用,透明遮罩类的样式要小心 |
| RefreshControl | 列表下拉刷新 | 通过 FlatList 的 refreshing 属性接入可用 |
| Image | 用户头像、康复示意图 | 本地与网络图片基本都能显示 |
这张表看起来平平无奇,但对后面的排期很有用。前端排期最怕的就是某个组件写到一半发现运行时和预期不一致,被迫返工。提前跑一遍最小示例,相当于把风险前置了。
2.2 摸底过程中容易误判的两个点
第一点是 Text 的字体对齐。鸿蒙设备上部分中文字体在行高、垂直对齐上跟 Android 略有差别,同样的 lineHeight 写出来,视觉效果会比 iOS 上偏上或偏下。后来我们在全局样式中统一给 Text 设了一个基准行高,遇到多行文本优先用 textAlignVertical 配合,避免文字贴边框。
第二点是 View 的阴影。iOS 上用 shadowColor、shadowOpacity、shadowRadius,Android 上经常需要 elevation,鸿蒙的渲染引擎对这两套属性的支持并不完全一致。实测下来,卡片阴影最稳妥的方式还是用带边框的浅色 View 去模拟层次感,或者把阴影值调大一点。别在阴影细节上花太多时间,优先保证界面层次清晰即可。
这种摸底要记录到团队的组件使用文档里,不然过两周换个人来写,又会在同样的问题上卡一遍。
3. Day 4 复盘:用 FlatList 搭训练计划长列表
3.1 列表要承担的分组和状态
Day4 的核心任务是训练计划页。页面结构不复杂:顶部是当天计划的摘要卡,下面是一整串训练任务,每项任务包含动作名称、预计时长、完成状态,还有一条“进入记录”的跳转入口。
最初的方案是用 ScrollView 把摘要卡和列表平铺在一起。但数据量起来之后,任务项可能到几十条,加上历史记录、备注,继续用 ScrollView 就会开始卡。于是改成了 FlatList 方案:
jsx复制// 简化的分组思路:把训练计划按日期分成多个 section
const groupByDate = useCallback((plans) => {
const map = new Map();
plans.forEach((plan) => {
const key = plan.date;
const list = map.get(key) || [];
list.push(plan);
map.set(key, list);
});
return Array.from(map.entries()).map(([key, items]) => ({
date: key,
data: items,
}));
}, []);
const sections = groupByDate(planData);
用 FlatList 的 renderItem 去渲染每个分组时,可以直接把整个 section 作为一条数据输出,也可以在 ListHeaderComponent 里放日期标题。我最终选择的是后者,因为这样每一行任务还是独立的单元格,点击、上下滑动时的回收机制更自然。
3.2 FlatList 的配置细节与坑
FlatList 在鸿蒙上的表现比我预想中稳,但前提是几个关键配置要写对。下面是我们在训练计划页最终使用的配置:
jsx复制<FlatList
ref={listRef}
data={flatTaskList}
renderItem={renderTaskItem}
keyExtractor={(item, index) => `${item.id}-${index}`}
ListHeaderComponent={SummaryCard}
ListEmptyComponent={<EmptyState text="今日暂无训练安排" />}
initialNumToRender={10}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={40}
windowSize={7}
removeClippedSubviews
refreshing={refreshing}
onRefresh={handleRefresh}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.2}
/>
这里有几个值得展开的点:
keyExtractor一定不能只用下标,否则删除任务或更新状态时容易出现渲染错乱。我们统一用任务 id 加下标做兜底。removeClippedSubviews能在可视区外回收视图,对长列表收益明显。但注意不要在需要做滚动截图的页面上开启,否则截图内容会残缺。initialNumToRender不要贪大。设成 10 基本能保证首屏快速出内容,设成 50 反而会拖慢首次渲染。尤其鸿蒙的 JS 引擎还在持续优化,首屏一次性渲染太多原生节点容易拉长白屏时间。- 数据源最好不要在
renderItem里做 map 或 filter,应该提前在 useMemo 里处理好,否则每渲染一帧都会重复计算。
3.3 单条任务卡的交互反馈
任务卡我用了 Pressable 而不是 TouchableOpacity。原因很简单:Pressable 能直接拿到 pressed 状态,方便自定义按压背景色,同时能设置 android_ripple 或 style 回调,适配起来比 Touchable 系列更直观。
jsx复制const renderTaskItem = useCallback(({ item }) => {
return (
<Pressable
onPress={() => onTaskPress(item)}
style={({ pressed }) => [
styles.taskCard,
pressed && styles.taskCardPressed,
]}
>
<View style={styles.taskInfo}>
<Text style={styles.taskName}>{item.name}</Text>
<Text style={styles.taskMeta}>
{item.duration} 分钟 · {item.status === 'done' ? '已完成' : '待完成'}
</Text>
</View>
<View style={[styles.statusTag, item.status === 'done' && styles.statusTagDone]}>
<Text style={styles.statusText}>
{item.status === 'done' ? 'OK' : 'GO'}
</Text>
</View>
</Pressable>
);
}, [onTaskPress]);
这段代码的要点在于卡片内部的按钮层级不要嵌套过深。Pressable 里如果再放 Pressable,点击事件在鸿蒙上偶尔会出现穿透。如果确实需要在卡片里放一个独立操作按钮,建议把整卡 Pressable 和按钮 Pressable 平级摆放,不要做父子嵌套。
4. Day 5 复盘:TextInput 处理康复打卡表单
4.1 备注输入框与键盘“避让”问题
Day5 主要做康复打卡表单。界面里有训练项目选择、训练时长输入和备注输入。前两项相对简单,真正花时间的是备注输入。
社交聊天软件里输入框被键盘挡住,用户还能大概猜到文字在下面;但在打卡表单里,如果备注输入框被键盘完全盖住,用户会非常焦虑,尤其是一些康复者可能年纪偏大,对误操作容忍度更低。
React Native 常用的解法是 KeyboardAvoidingView,但它在鸿蒙上的表现需要手动验证。我们最终采用的是更可控的方案:外层用 ScrollView,输入框聚焦时通过 onFocus 事件计算位置并滚动到可视区域。
jsx复制const inputYRef = useRef(0);
const scrollRef = useRef(null);
const handleRemarkFocus = () => {
setTimeout(() => {
const targetY = inputYRef.current - 120;
scrollRef.current?.scrollTo({ y: targetY, animated: true });
}, 120);
};
// 输入框外层 View 在 onLayout 里记录 Y 坐标
const handleRemarkLayout = (e) => {
inputYRef.current = e.nativeEvent.layout.y;
};
<ScrollView ref={scrollRef} keyboardShouldPersistTaps="handled">
{/* 表单字段 */}
<TextInput
style={styles.remarkInput}
multiline
maxLength={100}
placeholder="补充训练感受或身体状态"
onFocus={handleRemarkFocus}
onLayout={handleRemarkLayout}
/>
</ScrollView>
keyboardShouldPersistTaps="handled" 很关键。这样用户正在输入时点击外部完成按钮,第一次点击会先收起键盘,第二次点击才会触发按钮事件,避免误触提交不完整的表单。
4.2 数值输入的过滤写法
训练时长的输入框应该只允许数字。有人会在 onChangeText 里写好长一串正则表达式,其实用一条简单的 replace 就够了:
jsx复制const [duration, setDuration] = useState('');
const handleDurationChange = (text) => {
// 只保留数字,不处理小数,训练时长按整分钟计
const cleaned = text.replace(/[^0-9]/g, '');
if (cleaned.length <= 3) {
setDuration(cleaned);
}
};
<TextInput
value={duration}
onChangeText={handleDurationChange}
keyboardType="number-pad"
placeholder="请输入训练分钟数"
/>
这里有个经常被忽略的点:keyboardType="number-pad" 只是让原生键盘偏向数字输入,并不能拦截粘贴进来的非数字字符。所以过滤逻辑必须写在 onChangeText 里,不能只依赖键盘类型。
时长上限我控制在 3 位数字,也就是最多 999 分钟。这个上限本身不是性能问题,而是为了减少输入框取值后再校验的边界情况,数据层越早约束,后面的逻辑越简单。
4.3 表单校验的交互细节
表单页面最容易出现的问题是:用户没填必填项就直接点提交,然后页面弹了个报错 Toast,但用户根本看不清是哪个字段没填。Day5 下午我特地把校验的交互方式改成了字段级提示。
具体做法是给每个需要校验的输入区下方预留一个提示行:
jsx复制const [touched, setTouched] = useState({ duration: false, planId: false });
const validate = () => {
const errors = {};
if (!duration || Number(duration) <= 0) {
errors.duration = '请输入有效的训练时长';
}
if (!selectedPlan) {
errors.planId = '请先选择训练项目';
}
return errors;
};
const handleSubmit = () => {
const errors = validate();
if (Object.keys(errors).length > 0) {
setErrors(errors);
setTouched({ duration: true, planId: true });
return;
}
// 提交打卡数据
};
默认情况下提示行是空的,不占用额外视觉层级;当用户点过提交且字段确实有误时,再把对应错误文本渲染出来。这样做比全局弹窗友好得多,也符合康复系统面向高龄用户使用的操作习惯。
5. Day 6 复盘:Modal、Pressable、RefreshControl 的真实体验
5.1 Moda l做训练详情弹层
Day6 的重点是把几个交互组件补全,包括训练详情弹层、下拉刷新和按钮反馈。先说 Modal。
训练详情弹层的结构是底部弹出的半屏卡片,里面显示训练动作、组数、频率,还有一条“开始训练”的按钮。实现上我用的是 RN 内置 Modal 加透明背景:
jsx复制<Modal
visible={detailVisible}
transparent
animationType="slide"
onRequestClose={closeDetail}
>
<Pressable style={styles.modalMask} onPress={closeDetail}>
<Pressable
style={styles.detailSheet}
onPress={(e) => e.stopPropagation()}
>
<Text style={styles.detailTitle}>{detailData?.name}</Text>
<Text style={styles.detailDesc}>{detailData?.description}</Text>
<ActionButton
text="开始训练"
onPress={handleStartTrain}
/>
</Pressable>
</Pressable>
</Modal>
这里有个常见技巧:外层遮罩是一个 Pressable,点击遮罩关闭弹层;内层详情卡片也是一个 Pressable,点击卡片本身要阻止事件冒泡,否则点击卡片内部也会触发关闭。React Native 里处理事件冒泡没有 Web 端那么自由,用 Pressable 包裹是最简单的做法。
5.2 RefreshControl 的下拉刷新
列表页如果只是本地静态数据,下拉刷新并不复杂。但康复系统的训练计划往往来自接口,下拉刷新要处理“刷新中状态”“请求失败状态”“数据更新后的列表重绘”三个环节。
FlatList 内置了 refreshing 和 onRefresh 属性,底层就是 RefreshControl。在鸿蒙上的写法与其他平台没有差别:
jsx复制const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
const latest = await fetchTrainPlan();
setPlanData(latest);
setRefreshError(false);
} catch (err) {
setRefreshError(true);
// 给用户一个轻提示,但不清空旧数据
Snackbar.show('刷新失败,请稍后再试');
} finally {
setRefreshing(false);
}
}, []);
强调一个原则:下拉刷新失败时,不要把页面上的旧数据清空。用户看到旧数据至少还能用,如果一刷新失败列表就空了,体验会非常差。这也是我们在后续所有接口请求里统一遵守的规则。
5.3 交互细节让页面更像原生
康复系统面向的使用场景里,很多用户对移动操作的精细度没那么高,所以“按钮反馈是否明确”直接影响信心。内置组件里适合做按钮反馈的主要是 Pressable。
我们可以通过 style 回调实现不同的按压态效果:
jsx复制const ActionButton = ({ text, onPress, disabled }) => (
<Pressable
onPress={onPress}
disabled={disabled}
style={({ pressed }) => [
styles.actionButton,
pressed && !disabled && styles.actionButtonPressed,
disabled && styles.actionButtonDisabled,
]}
>
<Text style={styles.actionButtonText}>{text}</Text>
</Pressable>
);
按压时把背景色变深、透明度降到 0.9,松开后恢复。这个效果看着简单,但对鸿蒙上这类没有系统级 button 组件的界面来说,是提升真实感最有效的手段。
另外,卡片按钮的点击范围最好不小于 44×44 的逻辑像素。我们在设计稿里特意把操作按钮高度做到了 48,避免用户点了几次都感觉“没反应”。
6. 启动白屏与构建链路:这4天顺手解决的干扰项
6.1 白屏问题的定位顺序
开发过程中几乎绕不开“react native 启动白屏”这个话题。我们遇到的问题也很有意思:第一次打开应用很正常,切到后台再切回来也正常,但应用被系统杀掉后重新冷启动,偶尔会出现 2 到 4 秒的白屏。
最开始我以为是代码崩溃,抓了日志又没看到明显异常。后来整理了一下定位顺序,发现所谓的白屏可以拆成几类:
- JS Bundle 还没加载完成,屏幕自然是一片空白;
- 原生容器已经创建,但 rootView 还没挂载业务页面;
- 网络请求白屏,页面代码没问题,但首屏数据为空且没有写 Loading 状态;
- 某个组件渲染异常导致全局捕获到错误,界面被兜底清空。
前两种属于框架初始化阶段,第三种属于业务代码问题,第四种需要用错误边界去兜住。
6.2 优化启动阶段的实际做法
针对初始化阶段的白屏,我们在原生入口侧做了一层启动占位背景。在 React Native 页面还没有挂载完成时,先展示一个纯色背景或品牌图。具体实现方式不复杂:原生容器 View 的背景色先设置成品牌主色,等 RN 的 rootView 加载完成后自动覆盖上去。
同时可以在 JS 侧做一次首屏内容的预构建。比如把训练计划列表的数据请求提前到根部组件挂载前的 useEffect 里,而不是等首页组件渲染完成后再发请求。这样虽然不能减少 JS Bundle 本身的加载时间,但可以减少“首帧渲染之后还在等接口”的第二段白屏。
还有一种常见原因出在打包环节。如果打包时没有把 JS Bundle 正确放置到应用的资源目录里,运行时就会一直处于加载资源状态。遇到这类问题,我会先看应用沙盒里是否存在对应的 bundle 文件,如果文件缺失,优先检查打包脚本的资源复制路径,而不是反复清缓存重试。
6.3 调试时的小习惯
这三天调试组件的过程中,我养成一个习惯:每次改完布局,先看原生日志有没有大段红黄报错,再快速翻一遍页面上的实际元素位置。很多 style 上的偏差,比如 Text 不居中、View 溢出,其实在原生日志里根本不会暴露,最终还是要靠真机上的直观反馈。
另外要养成写错误边界的习惯。因为鸿蒙运行环境还在迭代,个别组件在低版本上可能仍有兼容缺口。用 ErrorBoundary 包住页面级组件,至少能保证某个模块异常时用户看到的是可恢复的提示页,而不是整个应用白屏。
7. 关于内置组件边界的一点体会
7.1 哪些场景可以放心交给内置组件
经过 Day4~6 的实践,我现在可以放心地把这些场景交给内置组件:
- 结构固定的信息展示页,比如训练详情、康复师介绍;
- 中长列表页,比如训练计划、历史记录;
- 表单录入页,比如打卡、信息登记;
- 状态切换和轻量弹层,比如提交确认、训练提醒。
甚至可以说,如果产品需求里没有特别复杂的图表、富文本、视频播放,纯粹靠内置组件把一套管理系统搭起来是可行的。图表类需求可以优先考虑用 Canvas 或 WebView 承载,而不是依赖原生图表库;富文本如果只是简单的加粗、换行,Text 的多段嵌套也能应付。
7.2 内置组件写多了之后的几句话
要说这三天最大的收获,其实是“克制”。在鸿蒙生态还没完全成熟时,少用一个第三方组件,就少一个潜在的不稳定源。内置组件的功能虽然朴素,但它的兼容性是被 React Native 与鸿蒙桥接层重点保障的。把页面结构做得朴素一点、稳一点,比界面花哨但动不动白屏重要得多。
我们在规划下一阶段时,把目标从“页面能打开”变成了“页面能在几秒内稳定打开、流畅滚动、正确录入”。后面要做的自定义原生组件,也只会围绕真正的核心需求去开发。内置组件能解决的,就不额外造轮子。
最后分享一个对我自己很管用的习惯:每写完一个页面,就把这个页面里用到的内置组件、特殊配置、遇到的坑记在项目文档里。别高估记忆力,两周后的你会感谢现在的这一条笔记。
