
做鸿蒙适配做到第七个页面CareTipsScreen算是我这轮项目里最头疼、也最有收获的一个。先说清楚它是什么这是一款健康管理应用里的“每日护理建议”聚合页用户每天打开应用能看到系统按时间段推荐的护理建议比如下午三点的补水提醒、晚上的皮肤护理步骤同时还可以按分类订阅自己关心的内容并把高优先级的建议一键交给系统提醒中心。页面的核心价值不在UI多好看而在于它把React Native的跨平台页面和鸿蒙原生系统能力真正拧在了一起。如果你正在做RN项目的鸿蒙化改造这篇应该能给你省不少时间。它会涉及几个层面页面本身的组件拆分、状态与数据流设计、RN层如何调用鸿蒙的提醒服务以及最后在真机上踩过的那些适配坑。适合已经完成RN基础环境搭建、准备接触系统能力桥接的团队。1. CareTipsScreen的定位与需求拆解1.1 业务场景为什么需要这样一个页面CareTipsScreen的原始需求其实很简单给用户每天推送几条护理建议。但落到产品层就变复杂了。第一建议要有时间段属性不同时间的建议内容完全不同早晨是防晒准备中午是补水晚上是修复页面需要按当前时间段自动切换主推内容。第二用户对建议的接受度差异很大有人只看皮肤护理有人关注作息和用药提醒所以必须支持分类订阅。第三也是最关键的护理建议的核心价值在于“提醒”用户不会天天主动打开App看需要借助系统级提醒中心把重要事项推送到通知栏甚至手表上。这三层需求决定了CareTipsScreen不是普通的展示型页面而是一个“内容聚合用户订阅系统调度”三者结合的复合页面。从技术上拆解它至少包含四块能力内容列表的加载、筛选和展示。分类订阅状态的本地持久化。用户点击“加入提醒”后把提醒内容、时间、重复规则传给系统。页面从后台恢复后需要刷新当天的建议状态。每一个能力都不难但组合在一起并且在鸿蒙环境下实现就需要重新考量很多细节。比如“订阅状态持久化”在iOS和Android上可以直接用AsyncStorage在鸿蒙上虽然也能用但提醒调度就必须走鸿蒙自己的ReminderAgentManager接口这一块RN框架不会替你封装只能自己写桥接层。1.2 为什么在RN中要“单独开发鸿组件”很多团队在做鸿蒙适配时有个误区以为把RN的JS Bundle跑起来就完事了。实际上鸿蒙NEXT的应用生态和Android/iOS完全不同RN框架在鸿蒙上能跑是因为底层有一个适配层把JS组件树映射成ArkUI组件树。基础组件没问题但系统级能力比如提醒调度、系统日历写入、分布式流转都不在映射范围内。所以“在React Native中开发鸿组件”这句话准确理解应该是UI层继续用RN写系统能力层用ArkTS写原生模块再通过TurboModule暴露给JS调用。CareTipsScreen就是这种混合开发模式的典型案例。页面本身是RN组件但所有涉及提醒调度的逻辑都必须落到一个自定义的鸿蒙原生模块上。1.3 这个页面给团队沉淀的三样东西做完这个页面我们团队内部做了个小结有三点值得复用定义了RN侧“屏级组件”接入鸿蒙原生能力层的协作规范后续的日历页面、消息推送页面都沿用这套方式。把提醒调度桥接跑通了一条完整链路包括权限申请、参数组装、成功回调和异常处理。沉淀了一套卡片式交互模板包括优先级标识、标签系统、左滑操作后续很多信息流页面直接复用。2. 技术方案选型与架构分层2.1 版本与依赖选型这次因为是从零适配鸿蒙版本选型上我们比较保守以稳定为主。具体组合如下依赖版本选型理由React Native0.72.x团队此前已在生产环境使用组件生态成熟第三方库兼容性经过验证鸿蒙SDKAPI 12当前能稳定支持RN适配层的版本提醒服务接口齐全react-native-harmony适配层匹配RN 0.72的版本官方维护的RN到ArkUI映射层升级节奏能跟上RN主版本Zustand4.x轻量、无冗余样板代码跨端心智一致性好这里专门说一下React Native版本的选择。不要在鸿蒙适配初期追最新RN大版本适配层往往滞后半拍。0.72.x虽然不是最新但适配层对它支持最稳定特别是TurboModule接口在0.72上已经非常成熟足够支撑自研桥接模块。后续可以再规划整体升级而不是在适配初期给自己增加变量。2.2 页面纵向分层CareTipsScreen整体分成四层每层职责单一互相之间通过接口通信调试时能快速定位问题View层CareTipsScreen主页面、CareTipCard卡片、分类TabBar、右下角提醒入口悬浮按钮。全部用RN/TS实现不掺杂任何ArkTS代码。State层Zustand管理的tipsStore负责分类状态、当前卡片列表、订阅集合、提醒调度结果反馈。Data层CareTipService负责从服务端拉取建议列表写入本地缓存并提供按分类过滤的查询接口。Native能力层通过自定义TurboModule暴露的CareTipReminder模块封装权限申请、提醒创建、提醒取消三组方法。这样的分层带来的直接好处是真机调试时如果发现页面卡顿可以直接定位到View层如果是提醒没触发只需要查原生的CareTipReminder实现不需要在JS里翻找逻辑。2.3 状态管理为什么选了Zustand刚立项时团队里有同事提议用Redux Toolkit理由是社区案例多、团队熟悉。最终我们还是选了Zustand核心考量有三个对比项ZustandRedux Toolkit样板代码极少一个create就是完整Store需要Slice、Reducer、Action三件套异步处理Action里直接await天然友好需要额外引入createAsyncThunk鸿蒙桥接场景Store更新不依赖额外中间件原生回调返回后直接setState中间件链路长原生回调场景下多一层心智负担实际开发下来Zustand在配合原生模块回调时确实顺手很多。比如提醒调度成功后需要在卡片上把按钮状态从“去提醒”切到“已提醒”原生模块的Promise resolve之后直接在调用处更新store即可不需要动任何Action或Reducer。对CareTipsScreen这种中等复杂度的页面这是个明显优势。3. 核心实现过程全记录3.1 项目目录与代码组织页面不是孤立的我把和它相关的文件集中在一个目录下后续维护时一目了然src/features/careTips/ ├── CareTipsScreen.tsx # 页面主组件 ├── components/ │ ├── CareTipCard.tsx # 单条建议卡片 │ ├── CareTipTabBar.tsx # 分类筛选栏 │ └── ReminderFAB.tsx # 悬浮提醒按钮 ├── models/ │ └── careTip.ts # 类型定义与常量 ├── services/ │ └── careTipService.ts # 数据拉取与本地缓存 ├── store/ │ └── tipsStore.ts # Zustand store └── native/ └── NativeCareTipReminder.ts # TurboModule定义对应的原生工程目录单独维护放鸿蒙侧的TurboModule实现RN侧只保留接口定义文件。这个分离原则很重要否则混合在一起很容易出现“JS侧改着改着把原生文件也动了”的问题。3.2 页面基础骨架与卡片组件实现CareTipsScreen本身的结构并不复杂核心就是一个带Header的FlatList加上顶部分类Tabs和一个悬浮按钮。但有几个细节值得注意。// CareTipsScreen.tsx import React, { useCallback, useEffect, useMemo } from react; import { FlatList, SafeAreaView, View, Text, StyleSheet } from react-native; import { useTipsStore } from ./store/tipsStore; import { CareTipCard } from ./components/CareTipCard; import { CareTipTabBar } from ./components/CareTipTabBar; import { ReminderFAB } from ./components/ReminderFAB; export function CareTipsScreen() { const { tips, activeCategory, loading, loadTips, setCategory } useTipsStore(); useEffect(() { loadTips(); }, [loadTips]); const visibleTips useMemo(() { if (activeCategory all) return tips; return tips.filter((item) item.category activeCategory); }, [tips, activeCategory]); const renderItem useCallback(({ item }: { item: CareTip }) { return CareTipCard tip{item} /; }, []); const ListHeader useMemo(() { return ( View style{styles.headerContainer} View style{styles.headerTextBlock} Text style{styles.headerTitle}今日护理建议/Text Text style{styles.headerSubTitle}按时间段为你推荐点击卡片可加入提醒/Text /View CareTipTabBar activeCategory{activeCategory} onChange{setCategory} / /View ); }, [activeCategory, setCategory]); return ( SafeAreaView style{styles.container} FlatList data{visibleTips} renderItem{renderItem} ListHeaderComponent{ListHeader} keyExtractor{(item) item.id} getItemLayout{(_, index) ({ length: CARD_HEIGHT, offset: CARD_HEIGHT * index, index, })} initialNumToRender{6} contentContainerStyle{styles.listContent} showsVerticalScrollIndicator{false} / ReminderFAB / /SafeAreaView ); }这段代码里getItemLayout算是我强行的优化点。CareTipCard高度是固定的所以直接告诉FlatList每个卡片的高度避免它动态测量。在鸿蒙的RN适配层上动态测量成本比iOS和Android高一些列表项一旦超过20条没有getItemLayout会明显感觉到滑动掉帧。卡片组件的核心是优先级标识和提醒按钮// CareTipCard.tsx import React, { memo } from react; import { View, Text, Pressable, StyleSheet } from react-native; export const CareTipCard memo(function CareTipCard({ tip }: { tip: CareTip }) { const priorityColor priorityMap[tip.priority]; return ( View style{[styles.card, { borderLeftColor: priorityColor }]} View style{styles.tagRow} Text style{[styles.tag, { backgroundColor: priorityColor }]} {tip.categoryLabel} /Text Text style{styles.timeText}{tip.startTime}/Text /View Text style{styles.title}{tip.title}/Text Text style{styles.content}{tip.content}/Text Pressable style{styles.remindButton} onPress{() handleAddReminder(tip)} Text style{styles.remindButtonText} {tip.reminderScheduled ? 已加入提醒 : 加入提醒} /Text /Pressable /View ); });这里用memo包一层是因为列表项在切换分类时其实只有少部分卡片需要重渲染不加memo的话父组件任何state变化都会带动全列表刷新在鸿蒙上的代价很明显。3.3 数据层与状态层封装CareTip的类型定义是后面所有逻辑的基础字段设计直接影响桥接层的参数组装。// models/careTip.ts export type CareTipCategory hydration | rest | skincare | medicine; export interface CareTip { id: string; category: CareTipCategory; categoryLabel: string; title: string; content: string; source: string; priority: low | medium | high; startTime: string; // HH:mm endTime: string; // HH:mm repeatDays: number[]; // 0-6每周重复的天 reminderScheduled: boolean; }repeatDays这个字段是为原生提醒服务的重复规则准备的比如提醒每天下午三点执行对应的就是repeatDays: [0,1,2,3,4,5,6]。如果不提前在数据模型里设计清楚到了桥接层还要再做一次映射很容易出问题。Zustand store的实现也走最简模式// store/tipsStore.ts import { create } from zustand; import { fetchCareTips, cacheCareTips } from ../services/careTipService; interface TipsState { tips: CareTip[]; activeCategory: CareTipCategory | all; loading: boolean; loadTips: () Promisevoid; setCategory: (category: CareTipCategory | all) void; markReminderScheduled: (tipId: string, scheduled: boolean) void; } export const useTipsStore createTipsState((set, get) ({ tips: [], activeCategory: all, loading: false, loadTips: async () { set({ loading: true }); try { const remoteTips await fetchCareTips(); set({ tips: remoteTips, loading: false }); } catch (error) { const cachedTips await getCachedCareTips(); if (cachedTips.length 0) { set({ tips: cachedTips, loading: false }); } else { set({ loading: false }); // 这里可以挂一个全局错误提示 } } }, setCategory: (category) set({ activeCategory: category }), markReminderScheduled: (tipId, scheduled) { set((state) ({ tips: state.tips.map((tip) tip.id tipId ? { ...tip, reminderScheduled: scheduled } : tip ), })); }, }));loadTips里做了个兜底逻辑服务端拉取失败时读本地缓存。护理建议这类内容对时效性要求没那么高缓存兜底能避免用户打开页面直接白屏。这个模式在弱网环境下体验提升非常明显。3.4 RN调用鸿蒙提醒服务的桥接实现这是CareTipsScreen真正区别于普通RN页面的地方。RN侧先定义接口// native/NativeCareTipReminder.ts import { TurboModule, TurboModuleRegistry } from react-native; export interface ReminderOptions { title: string; content: string; triggerTime: string; // HH:mm repeatDays: number[]; } export interface Spec extends TurboModule { requestPermission(): Promiseboolean; scheduleReminder(options: ReminderOptions): Promisenumber; cancelReminder(reminderId: number): Promisevoid; } export default TurboModuleRegistry.getEnforcingSpec(CareTipReminder);鸿蒙侧的ArkTS实现核心是构造ReminderRequest对象并调用reminderAgentManager发布// CareTipReminder.ets import { TurboModule } from rnoh/react-native-harmony; import { reminderAgentManager, reminderAgent } from kit.ReminderAgentKit; import { BusinessError } from kit.BasicServicesKit; export class CareTipReminder extends TurboModule { async requestPermission(): Promiseboolean { try { const res await reminderAgentManager.requestSystemNotificationEnabled(); return res true; } catch (e) { return false; } } async scheduleReminder(options: ReminderOptions): Promisenumber { const parts options.triggerTime.split(:); const hour Number(parts[0]); const minute Number(parts[1]); const request: reminderAgent.ReminderRequestAlarm { reminderType: reminderAgent.ReminderType.REMINDER_TYPE_ALARM, hour, minute, days: options.repeatDays, // 每周重复空数组表示仅一次 title: options.title, content: options.content, notificationContent: { title: options.title, content: options.content, }, wantAgent: undefined, maxSupported: 5, operationButton: undefined, }; try { const reminderId await reminderAgentManager.publishReminder(request); return reminderId; } catch (error) { const e error as BusinessError; // 记录错误码方便排查 return -1; } } async cancelReminder(reminderId: number): Promisevoid { await reminderAgentManager.cancelReminder(reminderId); } }这里有几个必须注意的地方权限请求要在首次触发“加入提醒”时调用不要放在页面加载时。很多用户不需要提醒功能主动申请通知权限会导致授权率下降。hour和minute必须是数字来自RN侧的字符串要做转换后再传入。days字段传空数组时表示单次提醒传[0,1,2,3,4,5,6]则表示每日重复。这个语义要和产品确认清楚否则很容易出现“用户选了周一提醒结果每天都被提醒”的问题。桥接方法返回-1时RN侧要给出明确提示不要把失败吞掉。我们页面上会弹一个小气泡“提醒创建失败请检查通知权限”这个反馈闭环很关键。TurboModule注册好之后RN侧调用是干净的Promise风格import NativeCareTipReminder from ./native/NativeCareTipReminder; async function handleAddReminder(tip: CareTip) { const hasPermission await NativeCareTipReminder.requestPermission(); if (!hasPermission) { Toast.show(需要通知权限才能创建提醒); return; } const reminderId await NativeCareTipReminder.scheduleReminder({ title: tip.title, content: tip.content, triggerTime: tip.startTime, repeatDays: tip.repeatDays, }); if (reminderId 0) { useTipsStore.getState().markReminderScheduled(tip.id, true); Toast.show(已加入系统提醒); } }这条链路跑通之后CareTipsScreen从单纯的“看内容”变成了“内容行动”。用户看到一条建议点一下就能落入系统提醒中心到点直接弹通知这对护理类业务来说是核心体验。4. 鸿蒙适配中的关键细节与坑4.1 样式与单位换算vp不是px很多RN开发者在做鸿蒙适配时第一个踩的坑就是单位。鸿蒙的ArkUI使用vp作为逻辑像素单位RN适配层会把RN的逻辑像素做映射但字体和阴影的映射并不完全严格。我在CareTipsScreen里至少遇到三个具体问题字体大小在部分真机上偏小尤其中文场景。适配层默认的字体缩放策略没有完全对齐RN的allowFontScaling解决方式是页面根部设置allowFontScaling{false}并统一用固定字号。boxShadow样式在鸿蒙上只支持有限的参数组合。最初卡片阴影用了boxShadow: 0 4px 12px rgba(0,0,0,0.08)在部分真机上不生效后来改用elevation配合shadowColor才稳定。安全区高度处理。鸿蒙的挖孔屏和胶囊键区域SafeAreaView的适配在个别机型和API版本上有偏差最保险的是用useSafeAreaInsets手动为顶部和底部添加padding不要完全依赖SafeAreaView的默认行为。4.2 手势冲突滑动卡片和侧滑返回打架CareTipCard上做了一个左滑操作区域用来快速预览“加入提醒结果”或“删除建议”。在鸿蒙真机上左滑手势和系统侧滑返回手势会发生冲突表现为卡片滑到一半整个页面被系统接管后退出了体验非常割裂。解决思路不是禁用系统手势而是通过手势优先级协商。我在卡片容器上做了处理在根组件外层包裹GestureHandlerRootView。卡片的水平滑动Pan响应器设置activeOffsetX为[-10, 10]确保手指水平位移明显后才判定为目标手势而不是垂直位移就触发。对于系统侧滑返回通过onBackPress做一个判断如果当前卡片处于半展开状态优先收起卡片而不是退出页面。这个问题的根因在于RN适配层对手势事件的处理和原生系统手势之间存在竞争不是单纯代码问题必须真机反复调。我在模拟器上完全复现不出来换了真机才暴露。4.3 生命周期与前后台恢复CareTipsScreen的页面内容是“按当天时间段推荐”的用户可能在早上打开一次挂起后台下午再回前台这时候理论上主推内容应该切换。RN侧监听AppState的变化但实测在鸿蒙上有个坑AppState从active切到background再切回active的时机有时候会比系统真实恢复时间延迟几百毫秒到一秒。如果只在AppState回调里刷新数据用户会短暂看到旧时段的内容然后被刷新体验很怪。我的做法是双保险监听AppState的变化触发数据刷新。同时在页面组件上用useFocusEffect风格的焦点回调当页面重新获得焦点时也做一次轻量刷新。因为CareTipsScreen是应用首页之一焦点回调的触发时机比AppState更贴近用户感知。5. 性能优化与问题排查实录5.1 长列表与渲染优化CareTipsScreen的Tips数量平时在20到30条数据量不大但真机上仍出现过滑动卡顿。原因排查下来是三个问题的叠加FlatList没有设置getItemLayout每次滚动都要动态测量卡片高度。卡片的提醒按钮状态更新时没有memo包裹导致整列卡片重渲染。切换分类时visibleTips每次生成新数组引用FlatList无法复用旧的cell。针对这三个问题对应做了三个调整固定卡片高度并配置getItemLayout用memo包裹卡片组件对筛选结果用useMemo缓存。调整之后滑动帧率在真机上从肉眼可见的掉帧恢复到稳定60帧。还有一个细节是图片缓存。护理建议卡片里会有一些配图URL如果每次加载都重新请求滚动时会频繁出现占位图闪烁。这部分我用通用的图片缓存库做了磁盘缓存同时在图片组件上固定宽高比避免布局抖动。5.2 常见问题速查表把这一轮开发中遇到的典型问题整理成一张表后续团队接手时可以直接参考现象原因分析解决方案点击“加入提醒”后一直无反应通知权限未申请桥接层静默失败桥接方法返回失败原因RN侧Toast提示并引导打开权限设置提醒在真机上不弹通知重复规则参数days传错语义确认空数组表示单次提醒避免和每日重复混淆卡片阴影在部分鸿蒙版本上消失boxShadow兼容性不足改用elevation提升层级视觉效果接近且更稳定左滑卡片和系统返回手势冲突手势事件竞争设置手势activeOffsetX并在半展开状态拦截返回切换分类后卡片顺序闪烁列表未设置稳定key状态复用混乱keyExtractor用tip.id并配合getItemLayout从后台回前台内容不刷新AppState在鸿蒙上响应延迟增加焦点回调节点执行轻量数据刷新5.3 两个值得单独展开的坑第一个坑是提醒重复规则。最初产品定义“每周一、三、五提醒”数据模型里repeatDays传的是[1,3,5]但鸿蒙的ReminderAgent要求days字段里的0代表周日1到6代表周一到周六。如果团队里有人按“周一1”的直觉去实现就会导致用户设置的工作日提醒在周末触发。这个映射关系必须在一开始就写清楚在数据模型注释里用最醒目的方式标注。第二个坑是页面数据缓存和刷新策略。提示类内容如果每次进页面都从服务端拉弱网环境下体验很差但如果完全依赖缓存用户看到的内容可能过期。我最终还是决定做一个轻量策略进入页面先秒开本地缓存3秒内发请求更新服务端成功后再更新列表。这个“先显缓存再静默更新”的模式在此类内容页上表现很好既有速度又有准确性。6. 一轮适配结束后的个人体会CareTipsScreen做完之后我对RN鸿蒙化的理解发生了很大变化。最开始以为核心工作量在页面UI和样式适配实际做下来才发现真正拉开工作量的是“RN页面和鸿蒙系统能力之间的桥接层”。一个页面如果只是展示数据适配层可以帮你搞定大部分问题但只要涉及系统级能力从权限到参数再到生命周期每一步都需要自己定义规范、封装接口、测试边界。如果后面你的项目也要接类似的提醒或调度功能建议在方案设计评审阶段就把三件事定下来通知权限的触发时机、重复规则的字段语义、后台恢复时的刷新策略。这三个问题我在开发中分别踩了一遍全都是在设计阶段多想五分钟就能避开的问题。最后分享一个小的操作习惯真机调试鸿蒙原生模块时不要只盯着RN侧的日志鸿蒙侧的日志信息往往记录了更底层的错误原因。我习惯同时在两端打印关键节点的上下文比如“RN侧开始调度提醒”“原生侧已收到参数”“原生侧publish返回”。这样两边一对问题定位基本就是分钟级的事情。CareTipsScreen的坑踩完了但把这些经验沉淀成文档和代码规范才是它留给我们团队最大的价值。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。