
如果有人丢给你一个需求做一款口腔护理应用首页就叫“今日任务”用户每天早上起床后打开它看到刷牙、用牙线、漱口这一串动作完成一项勾一项晚上睡前再来一轮。听上去很简单可当“React Native”“鸿蒙”“跨平台”这三个词凑在一起时事情就开始变得有意思了——因为鸿蒙NEXT之后应用不能再靠Android兼容包直接跑你手里那套RN代码能不能继续用取决于适配层和工程方案怎么选。这篇文章把我自己从需求拆解到鸿蒙真机调试的完整过程写出来重点放在“今日任务”的跨天重置逻辑、本地存储设计以及RN接入鸿蒙壳工程时最容易踩的坑。适合正在做健康打卡类应用、或者想把现有RN产品移植到鸿蒙生态的团队参考。1. 先把“今日任务”的需求边界画清楚1.1 口腔护理动作的最小集少而准我见过不少同类产品最后都死在“任务太多”上。口腔护理的日常动作真正每天需要提醒的其实就是那几项早起刷牙。睡前刷牙。牙线或冲牙器清洁牙缝。含氟漱口水漱口。舌苔清洁。每项任务后面要配一句引导文案比如“含氟牙膏刷够2分钟”“牙线不要来回拉要贴住牙缝上下刮”。不要一开始就把“定期更换牙刷”“每半年洗牙”这类低频事件塞进今日任务它们更适合放在设置页或独立的历史记录里不然用户打开首页看到八行待办第一反应不是去完成而是焦虑。关键词里带着“口腔护理”很多开发容易把注意力放到“护理知识库”上做一堆口腔百科文章。但用户真正高频使用的入口永远是“今天还差哪几项”。这个判断直接决定了首页的交互密度一个清爽的任务列表比一个资讯瀑布流有价值得多。1.2 “今日”的本质是每一天重置普通待办事项是一个长期列表勾掉一项就永远完成。但“今日任务”不一样它的生命周期最长24小时到了第二天必须回到零状态昨天的完成情况要归档成历史。这个区别直接决定数据模型怎么设计。我建议的状态结构是数据说明示例dateKey日期字符串精确到天2025-06-11completed任务key到完成状态的映射{ brushMorning: true, floss: false }updatedAt最后一次更新的时间戳1718100000000以日期为隔离单元而不是用一个全局布尔数组。好处是今天勾了哪些、昨天是否全勤、前天晚刷有没有漏掉后续都能追溯。就算第一版不做历史页这个结构也给以后留了后路避免到时候再写数据迁移脚本。1.3 MVP阶段最该守住的体验口腔护理应用的使用场景很短用户可能刚睡醒、飞快打卡。我给这个产品定的体验原则是三秒内能判断出“今天还剩几项”。主界面只需要一句“今天还剩3项”加一个可勾选列表不要登录、不要社交、不要积分商城。打卡成功就一个轻微的选中反馈全完成时给一段柔和的庆祝文案。说白了这个产品拼的是稳定和顺滑不是花哨。任务状态切换要跟手存储写入要快跨天切换不能出错。想清楚这三点再谈其他的。2. 为什么是React Native鸿蒙跨平台的现实选择2.1 HarmonyOS NEXT之后RN到底还能不能跑在HarmonyOS 4及以前不少App靠Android APK兼容方式在鸿蒙设备上跑大家默认“能装就行”。鸿蒙NEXT这条路被彻底堵上应用必须分发鸿蒙原生包。这时候如果项目组手里已经有一套React Native代码摆在面前的选择就很现实要么用ArkTS重新写一遍要么找一套能让RN跑在鸿蒙上的适配方案。目前行业里成熟的方向是OpenHarmony社区维护的React Native适配层它把RN运行时移植到OpenHarmony环境里让RN业务代码最终可以编译进一个鸿蒙应用包。注意这不是让你“拿鸿蒙当Android继续跑”而是真正的鸿蒙原生应用只不过业务层复用RN。这个方案的价值在于React和TypeScript这部分代码在Android、iOS、鸿蒙三端是同一套。对于口腔护理这种偏业务、偏数据、偏界面的应用复用率非常高。2.2 三种方案的真实对比我给自己列了一张对比表开发前最好先看一遍关键比较点ArkTS原生Flutter鸿蒙分支React Native鸿蒙适配层业务代码复用无完全重写中Dart逻辑可复用高React/TS可复用原生体验最好较好可接受团队维护成本高需要双套人马中中第三方库生态原生库丰富部分需要适配核心JS组件可用重原生库需确认合适场景系统级、强交互中重度UI轻量到中重度业务应用对“今日任务”这个场景来说核心能力是基础列表组件、本地存储、日期计算和系统通知。这恰好是RN生态最稳定的部分。我的判断是如果项目里需要地图、音视频编解码、自绘渲染、蓝牙协议栈这一类重原生能力就不要死磕RN走鸿蒙如果只是表单、列表、状态流转这类业务逻辑RN适配层完全够用。2.3 选型前先查一遍依赖清单在决定用RN做鸿蒙之前把现有项目的package.json打开把所有依赖分成两类纯JS/TS可替代的和必须有原生代码支撑的。前者放心用RN后者要提前确认在鸿蒙侧有没有实现或替代方案。举一个实际例子想做一个带水波纹按压效果的卡片RN原生库可能依赖Android的RippleDrawable鸿蒙未必支持。这时候要么换成一个用View和Pressable自绘的组件要么在鸿蒙壳工程里用ArkTS包一个自定义侧组件桥接给RN。提前做这份清单能避免开发到中后期才发现某个核心功能在鸿蒙上根本没法实现。3. 搭建工程RN代码如何进入鸿蒙壳工程3.1 初始化步骤和版本选择我按最小步骤走不引入复杂的自动化脚本初始化RN项目执行npx react-native-community/cli init OralCareDaily选TypeScript模板。业务代码先按标准RN写不用任何平台专属语法。准备鸿蒙壳工程在DevEco Studio里新建一个HarmonyOS NEXT空工程把entry模块作为容器。在鸿蒙工程中集成RN适配层依赖并配置API 12以上的SDK。构建时把RN bundle输出到鸿蒙工程的rawfile资源目录再跟ArkTS代码一起打包成HAP。有一点必须强调RN基础版本不要自己乱升。鸿蒙适配层通常对应一个RN版本区间比如某些版本对应RN 0.72、0.73的快照。版本不匹配时编译阶段会出现类似module XYZ not found的问题或者是运行时方法找不到的诡异报错。排查起来很耗时间不如一开始就按适配层文档把版本钉死。3.2 启动白屏到底怎么来的“react native 启动白屏”是很多人搜索时遇到的场景在鸿蒙上更常见。根因并不神秘RN的bundle加载需要时间原生启动窗口先渲染出来而RN根视图还没挂载完成中间这一段就是白屏。我在项目里的处理思路是保留鸿蒙原生启动页作为背景不启动时立刻隐藏。bundle必须本地化打包进HAP避免从调试服务器加载。等RN根视图的首帧内容渲染完成之后再手动隐藏启动页。如果适配层支持Hermes引擎可以开启来缩短JS解析时间但先确认支持否则反而可能黑屏。启动页切换时机是这类问题的关键。不是RN卡死而是原生启动页和RN视图之间的“交接”没做好。3.3 rawfile资源路径的一个细节鸿蒙工程的资源目录跟Android的raw目录不一样。bundle放在resources/rawfile/下时加载路径必须写对。如果路径错表现是启动白屏数秒然后直接崩溃或者加载不到bundle。我在替换bundle之后习惯在代码里打印bundleURL先确认加载的是哪一个路径再排查别的问题。这一步虽然简单但能排除掉一个最常见的变量。4. 今日任务状态核心从数据结构到跨天重置4.1 数据模型先定义任务清单我把任务定义成一张常量表而不是硬编码在UI里。这样后续加一个“夜间漱口”任务只需要改数据不用动组件逻辑。export type CareItemKey | brushMorning | brushEvening | floss | mouthwash | tongue; export interface CareItem { key: CareItemKey; title: string; guide: string; period: morning | evening | anytime; } export const CARE_ITEMS: CareItem[] [ { key: brushMorning, title: 早起刷牙, guide: 含氟牙膏刷满2分钟, period: morning }, { key: floss, title: 清洁牙缝, guide: 使用牙线或冲牙器温柔清理, period: anytime }, { key: mouthwash, title: 含氟漱口, guide: 漱口30秒后吐出, period: anytime }, { key: tongue, title: 清洁舌苔, guide: 从舌根向舌尖轻刷, period: anytime }, { key: brushEvening, title: 睡前刷牙, guide: 一天最后一次清洁别偷懒, period: evening }, ]; export interface DailyRecord { dateKey: string; completed: PartialRecordCareItemKey, boolean; updatedAt: number; }注意我把“早起刷牙”放在第一位“睡前刷牙”放在最后一个中间是全天项。因为用户在早晨和晚上打开App时视线正好对应一天的时间线不需要来回滚动。4.2 存储层做一个自己的KV封装我习惯用AsyncStorage做持久化但不会在业务代码里直接调用它而是再包一层。原因是鸿蒙适配层对AsyncStorage这类含原生代码的库支持情况要单独确认。万一它没有可用适配我可以把内部实现替换成鸿蒙Preferences或文件读写外部接口不变业务层完全不受影响。const PREFIX oralcare.daily.; export async function getDaily(dateKey: string): PromiseDailyRecord | null { const raw await Storage.get(PREFIX dateKey); if (!raw) return null; try { return JSON.parse(raw) as DailyRecord; } catch { return null; } } export async function setDaily(record: DailyRecord) { await Storage.set(PREFIX record.dateKey, JSON.stringify(record)); }这里的Storage可以理解成一个最小接口内部实现可以是AsyncStorage也可以是鸿蒙原生模块甚至是文件系统。接口隔离这个习惯在做跨端应用时是保命技能。4.3 useTodayTasks跨天重置的核心逻辑我封装了一个Hook负责三件事读取今天的记录、监听跨天、切换任务状态。import { useCallback, useEffect, useMemo, useRef, useState } from react; import { AppState } from react-native; function getTodayKey(d new Date()) { const y d.getFullYear(); const m String(d.getMonth() 1).padStart(2, 0); const day String(d.getDate()).padStart(2, 0); return ${y}-${m}-${day}; } function getMsUntilTomorrow() { const now new Date(); const nextMidnight new Date( now.getFullYear(), now.getMonth(), now.getDate() 1, 0, 0, 0, 0, ); return nextMidnight.getTime() - now.getTime(); } export function useTodayTasks() { const [todayKey, setTodayKey] useState(getTodayKey()); const [record, setRecord] useStateDailyRecord({ dateKey: todayKey, completed: {}, updatedAt: Date.now(), }); const latestTodayKey useRef(todayKey); latestTodayKey.current todayKey; useEffect(() { let timer: ReturnTypetypeof setTimeout; const loadAndSync () { const nextKey getTodayKey(); if (nextKey ! latestTodayKey.current) { setTodayKey(nextKey); } const targetKey latestTodayKey.current nextKey ? nextKey : latestTodayKey.current; getDaily(targetKey).then((saved) { if (saved) { setRecord(saved); } else { setRecord({ dateKey: targetKey, completed: {}, updatedAt: Date.now() }); } }); }; loadAndSync(); const appStateSub AppState.addEventListener(change, (state) { if (state active) loadAndSync(); }); timer setTimeout(loadAndSync, getMsUntilTomorrow() 1000); return () { appStateSub.remove(); clearTimeout(timer); }; }, []); const toggleTask useCallback(async (key: CareItemKey) { setRecord((prev) { if (prev.dateKey ! latestTodayKey.current) { const updated { dateKey: latestTodayKey.current, completed: { [key]: true } as PartialRecordCareItemKey, boolean, updatedAt: Date.now(), }; void setDaily(updated); return updated; } const completed { ...prev.completed, [key]: !prev.completed[key] }; const updated { ...prev, completed, updatedAt: Date.now() }; void setDaily(updated); return updated; }); }, []); const total CARE_ITEMS.length; const doneCount CARE_ITEMS.filter((item) record.completed[item.key]).length; const progress total 0 ? 0 : Math.round((doneCount / total) * 100); return { todayKey, record, toggleTask, progress, doneCount, total }; }有几个细节值得展开。第一闭包陷阱。我最初只写了todayKey在useEffect里直接引用结果App从后台切回前台时回调里拿到的还是旧值导致永远都在读前一天的数据。引入latestTodayKey这个ref之后回调里读到的永远是最新日期。这个坑在“今日任务”类需求里极其典型。第二跨天不能只靠定时器。移动端App随时可能被系统挂起JS定时器在后台会被冻结。所以定时器负责“App在前台时跨零点自动切换”AppState监听负责“App从后台回到前台时主动检查日期”。两者互为兜底。第三保存状态时先判断prev.dateKey是否等于当前日期。如果用户前天打开App没有退出后台一直挂着第二天直接操作列表prev里存的还是昨天的数据。这时候再用{...prev, completed: ...}去覆盖就会把昨天记录里今天已经完成任务的部分丢掉。所以我在toggleTask里先做了一个跨天判断一旦发现日期不一致就抛弃旧记录、创建新记录。4.4 完成度和任务列表UI进度计算部分const allKeys CARE_ITEMS.map((item) item.key); const doneCount allKeys.filter((key) record.completed[key]).length; const progress Math.round((doneCount / allKeys.length) * 100);列表渲染我直接用FlatList把进度条放在ListHeaderComponent里FlatList data{CARE_ITEMS} keyExtractor{(item) item.key} ListHeaderComponent{ View style{styles.header} Text style{styles.remainText} 今天还剩 {total - doneCount} 项 /Text View style{styles.progressTrack} View style{[styles.progressFill, { width: ${progress}% }]} / /View /View } renderItem{({ item }) ( Pressable style{[styles.card, record.completed[item.key] styles.cardDone]} onPress{() toggleTask(item.key)} View style{[ styles.checkbox, record.completed[item.key] styles.checkboxDone, ]} / View style{styles.cardBody} Text style{record.completed[item.key] ? styles.titleDone : styles.title} {item.title} /Text Text style{styles.guide}{item.guide}/Text /View /Pressable )} /整张卡片是按压区而不是只让右边一个小勾可点。这个交互细节后面实测时效果明显用户早上半睡半醒的时候根本没有耐心去对准一个小圆圈整卡可点大幅降低操作成本。4.5 早晚分组的扩展思路如果你希望界面按“早晨任务”和“晚间任务”分区可以这样渲染const morningTasks CARE_ITEMS.filter((item) item.period morning); const anytimeTasks CARE_ITEMS.filter((item) item.period anytime); const eveningTasks CARE_ITEMS.filter((item) item.period evening);然后用SectionList代替FlatList每个分组处理一个标题。这个改动不影响数据模型和存储结构只是渲染层面的分组。所以我的建议是第一版先别分组等用户反馈确实需要按时间段展示再花半小时加上去。5. 提醒能力让今日任务主动找用户5.1 MVP阶段先做App内提醒“今日任务”背后最大的产品价值其实是提醒因为口腔护理这件事靠自觉很难坚持。我第一版没有直接上系统通知而是先做了一个纯JS的App内提醒用户打开App时如果当前时间已经超过晚上20点但“睡前刷牙”还没完成就在页面顶部显示一条轻提示。这条逻辑完全不需要原生能力界面也简单适合先把主流程跑通。5.2 系统通知需要鸿蒙原生模块补位如果要做真正的系统级通知RN里的第三方通知库在鸿蒙上未必开箱即用。我的做法是写一个鸿蒙原生模块暴露给RN调用。业务侧只需要这样调用import { NativeModules } from react-native; const { OralCareNotify } NativeModules; export function scheduleDailyReminder(options: { id: string; hour: number; minute: number; title: string; body: string; }) { OralCareNotify?.scheduleDaily?.(options); }鸿蒙侧实现这个模块时本质是调用系统通知能力在首次请求时还需要申请通知权限。这一步不可避免最好在App启动后的某个业务节点用引导文案解释为什么要通知权限而不是一上来就弹系统授权框。把“用原生能力补弱项”这件事当作一个常规思路RN才会在鸿蒙上走得更顺。5.3 桌面卡片值得预留如果产品后续要做桌面卡片让用户在桌面直接看到“今日口腔任务完成度”建议放在第二迭代。卡片本质上需要频繁读取任务状态所以第一版把数据结构和存储做对以后做卡片只是加一个薄壳不需要重构核心逻辑。6. 实测中遇到的几个坑和我的排错路径6.1 跨天不刷新不能只靠定时器我第一版确实只写了一个setTimeout测试时发现App在后台被系统回收后第二天打开完全没有刷新显示的仍是前一天的数据。根因是JS上下文被冻结定时器失效了。我的排查路径是这样在AppState回调里加日志发现从background切回active确实触发了事件。但回调里读到的todayKey是旧值因为useEffect闭包没有更新。引入useRef保存最新日期让回调永远能读取最新值。在回调里重新执行getTodayKey()而不是拿缓存的日期直接比较。前台跨零点时保留定时器兜底确保App没离开前台也能自动切换。这个经验可以通用凡是有“今天/明天”概念的打卡类、待办类、签到类应用都应该做“双保险”日期刷新不能只依赖某一种机制。6.2 鸿蒙构建时第三方原生库编译失败我做任务卡片图标时选了一个RN的矢量图标库结果在鸿蒙侧编译失败提示缺少某个原生实现。排查思路很简单先在纯RN环境里确认图标组件本身没问题。打开鸿蒙工程的构建日志看具体是哪个原生模块缺失。如果该库没有鸿蒙适配版果断换方案。最后我用普通Text组件加Unicode字符和简单SVG替代效果完全够用。“今日任务”页面需要的是清晰可识别的状态而不是重设计感的图标。为了一个图标引入一个没有适配的原生库性价比太低。6.3 首屏假死bundle路径错误还有一次启动后白屏超过十秒控制台没有任何JS异常看起来像卡死。最后发现是bundle放在rawfile下的路径写错了加载失败后应用没有回退逻辑一直停在等待状态。这个问题跟我前面提到的“启动白屏”是同一类只是更隐蔽。排查时我做的第一件事是打印实际加载的bundleURL跟鸿蒙工程资源目录里的文件路径做比对。改对目录层级之后启动时间从十几秒白屏降到一秒内显示首页。写在这里想提醒大家白屏不一定是性能问题先确认资源加载路径再考虑引擎优化。6.4 热更新先别碰调研时总有人问鸿蒙能不能套CodePush那套我试过之后给的建议是现阶段不要碰。原因不复杂CodePush这类热更新服务是基于特定平台的能力鸿蒙侧没有成熟的对等机制。与其把发布链路做复杂不如在RN业务层做一个远程配置接口启动时拉取一次任务文案或提醒话术失败时用本地缓存兜底。对“今日任务”这种产品来说文案更新的优先级远高于代码热更新没必要为此背上额外的技术风险。最后分享一个实际体会第一版做完后我把完成按钮从右侧小勾改成整卡可点的大面积按压区数据显示用户完成率明显提高。这个改动很小但背后是真实的使用场景——用户早上迷迷糊糊的时候根本不会去找一个5像素的小圆圈。做完这个项目我更深的感受是像“口腔护理今日任务”这类应用技术难度不高真正拉开差距的是数据模型是否稳定、跨天重置是否可靠以及交互是否贴合用户在最疲惫时刻的使用状态。希望这篇内容能帮你少走几步弯路。