做 React Native for Harmony(RNOH)项目的朋友应该都有同感:把一套 RN 代码跑上鸿蒙设备,最怕的不是复杂功能做不出来,而是“简单效果莫名其妙不对”。任务表这个场景我刚好完整走过一遍,「删除 / 完成任务 - 划线置灰效果」看起来是个练习级需求,真正在 RNOH 上落地时却牵出了一串问题:文本装饰样式映射、长按和左滑的取舍、FlatList 刷新策略、还有用户打开 App 时的启动白屏。我干脆把自己的实现方案和踩坑过程完整写下来,给正在搭任务表或待办应用的同学做个参考。
1. 一个划线置灰效果,为什么在鸿蒙上值得单独写一篇
先说个反直觉的结论:在 Web 端,给已完成任务加删除线和中灰色,就是text-decoration: line-through加一个color的事。同一段代码放到 React Native for Harmony 上,你可能会遇到完全不生效、删除线粗细不一致、颜色值被忽略、甚至整个列表刷新卡顿这些独立问题。这不是 RN 语法变了,而是 RNOH 的渲染链路和 Android/iOS 有本质区别。
RNOH 的架构是把 RN 的视图树映射到鸿蒙的 ArkUI 组件上。你在 JS 侧写的Text组件,最终要翻译成 ArkUI 的Text/Span属性。问题就出在“翻译”这一步:RN 标准样式和 ArkUI 原生样式不是一一对应的,有些属性支持得早,有些支持得晚,还有些在不同系统版本上表现不完全一致。任务表的划线置灰恰好同时踩中了文本样式、状态更新、列表刷新、交互手势这几条线,所以它不是一个孤立的小样式,而是检验 RNOH 环境成熟度的一个标准样例。
另外,任务表作为鸿蒙 App 的高频业务场景,几乎每个团队都会做。它看起来简单,但涉及的数据结构(任务对象)、操作类型(完成/恢复/删除)、视觉反馈(划线、变色、动画)都很典型。你在桌面端和移动端都遇到过同样需求,如果能在 RNOH 上把这一套链路走通,后续的待办清单、日程管理、审批流看板都能直接复用这套方法论。这也是我写这篇的原因:与其等同事掉进同一个坑,不如把可复现的步骤和结论摆出来。
2. 任务数据模型与完成态切换的正确姿势
2.1 任务对象的基本结构
在设计任务表之前,先把数据模型定清楚。我的做法是尽量减少字段,够用就好:
type Task = { id: string; title: string; completed: boolean; createdAt: number; };id用字符串而不是数字索引,因为列表项需要稳定的唯一标识,数字索引在删除和排序后会变,容易导致 React 复用时出错。completed是整个划线置灰逻辑的唯一开关,不要额外加一个status字段来表达完成态,那是过度设计。createdAt是为了后续做排序或者展示创建时间,现在用不上也可以先留着。
有同学会问,为什么不在每个任务里存deleted标记,而是直接把任务从数组里删除?从产品角度,任务表一般不希望保留已删除的数据,真要做撤销,可以单独维护一个“最近删除”的临时变量,比全量保留deleted字段更省心。从状态管理角度,删除一个数组元素用filter就够了,保留deleted标记意味着每个渲染周期都要再过滤一次,徒增复杂度。
2.2 完成切换和删除都走不可变更新
React 的状态更新要求不可变性。我维护任务列表用的是最简单的useState:
const [tasks, setTasks] = useState<Task[]>([]); const toggleTask = useCallback((id: string) => { setTasks(prev => prev.map(task => task.id === id ? { ...task, completed: !task.completed } : task )); }, []); const removeTask = useCallback((id: string) => { setTasks(prev => prev.filter(task => task.id !== id)); }, []);这里有两个细节值得展开。
第一,toggleTask用map返回的是一个新的数组引用。即使只有一个任务状态发生变化,整个数组也是新的对象,这对 React 来说是必需的信号。如果你图省事直接task.completed = true再setTasks(tasks),React 会认为引用没变,UI 不会刷新,这是新手最容易踩的坑。
第二,useCallback保证toggleTask和removeTask的函数引用在依赖不变时保持稳定。后面如果把TaskItem封装成React.memo组件,稳定的函数引用能让 React 跳过大部分无谓渲染。任务表数据量不大,但这属于“顺手就把性能做对”的做法。
2.3 状态放组件内部还是放全局仓库
任务表这种规模的业务,我强烈不建议为了它引入 Redux 或 MobX,除非你要跨页面共享任务数据。一个页面级的任务列表,组件内部一个useState足够。如果后续要加多端同步、本地存储,再考虑封装一个useTaskStore自定义 Hook,把初始化、增删改查、持久化统一收口:
function useTaskStore(): TaskStore { const [tasks, setTasks] = useState<Task[]>([]); // ... 内部封装 toggle/remove/add return { tasks, addTask, toggleTask, removeTask }; }这样做的好处是页面的业务代码只关心“我要调用什么操作”,不需要知道状态是怎么改的。而且等你要接本地数据库或者服务端 API 时,只需要改这个 Hook 的内部实现,不再需要改 UI 层。
3. textDecorationLine 在 RNOH 上的样式映射细节
3.1 划线置灰的标准写法
完成任务的视觉反馈,核心是两件事:文字加删除线,文字变灰。RN 里最直接的写法是这样:
const styles = StyleSheet.create({ taskText: { fontSize: 16, color: '#333333', }, taskTextCompleted: { textDecorationLine: 'line-through', textDecorationColor: '#8A8A8A', color: '#8A8A8A', }, }); // 组件内 <Text style={[styles.taskText, task.completed && styles.taskTextCompleted]}> {task.title} </Text>数组叠加样式时,后面对象的属性会覆盖前面的同名属性,所以taskTextCompleted里的color会稳定地把文字变成灰色。这里把textDecorationColor也设置成灰色,是为了让删除线和文字颜色保持一致,视觉上更协调。
3.2 实测中哪些属性可靠
在 RNOH 上跑了一遍真机验证,几个相关属性的表现我放在表格里:
| 样式属性 | RN 标准定义 | RNOH 实测 | 说明 |
|---|---|---|---|
| textDecorationLine: line-through | 支持 | 支持 | 基础能力,可靠 |
| textDecorationColor | 支持 | 部分版本可能忽略 | 建议直接用 color 统一控制 |
| textDecorationStyle: double | 支持 | 效果不稳定 | 渲染结果和 RN 原生差异较大 |
| color | 支持 | 支持 | 控制文字颜色,可靠 |
| opacity | 支持 | 支持 | 会让整行内容透明,慎用 |
我实际遇到的情况是:line-through一切正常,但textDecorationColor在某个系统版本上没生效,删除线还是默认的黑色,跟灰色文字放一起非常突兀。解决办法不是去查 RNOH 的源码,而是把删除线颜色和文字颜色解耦:如果textDecorationColor不靠谱,那就接受删除线跟文字同样使用文字颜色,或者干脆删除线也设为灰色,用color控制主视觉。
这里想分享一个排查技巧:在项目里专门建一个样式验证页,把任务表可能用到的预置样式全部列出来,每条样式配一个开关,运行后逐项截图对比。这样不只是划线置灰,后续任何样式映射问题都能在一分钟内区分出“是标准 RN 的问题”还是“RNOH 的兼容问题”。
3.3 置灰不一定要靠透明度
很多同学做“置灰”时第一反应是opacity: 0.5,就是把整行文字变半透明。这在视觉上确实灰了,但有三个问题:一是激活的Text如果有背景色或图标,透明度会把背景也变淡,效果非常脏;二是删除线加在半透明文字上,视觉优先级不够,完成态反馈不清晰;三是在鸿蒙上如果Text不是原生字体渲染,透明度叠加可能导致个别字符出现渲染瑕疵。
所以我的建议是:优先改color,把文字颜色调整为中性灰(比如#8A8A8A),删除线跟字色保持一致。如果产品经理还想要“更弱的视觉权重”,可以再叠加一个opacity: 0.8这种轻量程度,而不是一刀切 0.5。划线置灰的核心是“让用户一眼知道这个任务完成了”,不是“让用户看不清这个任务写了什么”。
4. 完成/删除的交互选型:三方手势库、自研手势还是按钮兜底
4.1 点击切换完成态是最基础的操作
任务表最常见的交互是“点击任务文字 -> 切换完成状态”。RN 里用Pressable或者TouchableOpacity包一下就行:
<Pressable onPress={() => onToggle(task.id)}> <Text style={[styles.taskText, task.completed && styles.taskTextCompleted]}> {task.title} </Text> </Pressable>这个交互必须放在划线置灰之前实现。因为只有“完成”和“未完成”两种状态能被清晰区分,划线置灰才有效果。在封装上,我建议把TaskItem独立成一个组件,用memo包裹:
const TaskItem = memo(function TaskItem({ task, onToggle }: TaskItemProps) { // ... });memo会让 FlatList 在刷新时判断 props 是否真的变化,completed是布尔基本类型,浅比较就能正确跳过未变化的任务项。这在任务列表达到上百条时能明显降低渲染压力。
4.2 删除交互的选择没那么简单
删除任务有三种常见形态:
| 交互形态 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 右侧删除按钮 / 长按弹菜单 | 实现简单,兼容性好 | 操作路径长 | 任务数量少,容错要求高的场景 |
| 左滑删除 | 手势自然,效率高 | 依赖手势库兼容性 | 移动端成熟产品常见 |
| 编辑模式批量删除 | 可批量操作 | 需要多一个模式状态 | 管理端、批量整理场景 |
我在 RNOH 上先把“长按删除”做了兜底,再考虑左滑删除。原因很现实:左滑删除依赖react-native-gesture-handler,而 RNOH 社区虽然维护了对应移植包,但版本、初始化方式和重新渲染的表现都和标准 RN 不完全一样,不能直接套用网上 Android 的配置。
4.3 左滑删除在 RNOH 上的兼容实践
如果你坚持要做左滑删除,目前相对成熟的路径是:安装@react-native-oh-tpl/react-native-gesture-handler,然后在根组件包一层GestureHandlerRootView:
import { GestureHandlerRootView } from 'react-native-gesture-handler'; function App() { return ( <GestureHandlerRootView style={{ flex: 1 }}> <TaskListScreen /> </GestureHandlerRootView> ); }滑动行组件可以用ReanimatedSwipeable。新版 gesture-handler 把Swipeable迁移到了 Reanimated 版本,用法大致是这样:
import ReanimatedSwipeable from 'react-native-gesture-handler/ReanimatedSwipeable'; const renderRightActions = () => ( <Pressable onPress={() => onRemove(task.id)} style={styles.deleteAction}> <Text style={styles.deleteText}>删除</Text> </Pressable> ); <ReanimatedSwipeable renderRightActions={renderRightActions}> <TaskContent task={task} /> </ReanimatedSwipeable>实际测试中,左滑弹出“删除”按钮是没问题的,真正容易出问题的其实是滑开之后的动画收尾。比如先左滑打开删除按钮,然后点击空白区域关闭,再滑动下一条时,上一条的动画状态可能没有完全复位,导致两条都处于半开状态。这个问题在 Android 上也有,但在 RNOH 上更明显,因为手势动画用的是 Reanimated,而 Reanimated 在鸿蒙上的帧回调依赖系统垂直同步,个别版本存在延迟。
所以我的建议是:版本锁定以仓库发布的兼容版本为准,不要随手升级小版本;上线前在真机上完整走一遍“快速连续左滑 — 关闭 — 再左滑”的操作序列,看动画是否复位干净。如果动画问题无法短期解决,就采用按钮删除兜底,不要为了动画把删除功能卡死。
4.4 删除时的平滑动画:LayoutAnimation 够用
删除任务不一定要上 Reanimated。如果一个任务被删除后列表其余项目要往上补位,RN 的LayoutAnimation就能做到平滑收缩:
import { LayoutAnimation, UIManager, Platform } from 'react-native'; if (Platform.OS === 'android') { UIManager.setLayoutAnimationEnabledExperimental?.(true); } function removeTask(id: string) { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); setTasks(prev => prev.filter(task => task.id !== id)); }RNOH 对LayoutAnimation.configureNext也有支持,但同样需要先开启实验开关。这个功能适合任务项高度不固定、不需要精细控制动画曲线的情况。如果你追求更复杂的删除动画(比如行高度从 60 平滑压缩到 0),再考虑切换到 Reanimated 的LinearTransition,但那样就要把前面手势库那套依赖全部升级到位,复杂度会明显上升。
5. FlatList 刷新策略与完成/删除后的状态同步
5.1 用 FlatList 而不是 ScrollView
任务表数据超过十几条后,我建议直接用FlatList而不是ScrollView+map。FlatList 天然支持列表项复用、滚动优化和可控的渲染窗口,在鸿蒙上的表现也相对稳定。
<FlatList data={tasks} keyExtractor={item => item.id} renderItem={({ item }) => ( <TaskItem task={item} onToggle={toggleTask} onRemove={removeTask} /> )} />keyExtractor必须返回任务的id。千万不能用数组下标,否则删除和排序后 React 复用组件时会拿错误的数据渲染,划线置灰会错乱到下一行去。这个坑在任务表这种“频繁删除”的场景里尤其容易被触发。
5.2 data 引用变化与 extraData 的关系
很多 RN 教程会说“FlatList 需要传 extraData 才会更新”,这里有个容易混淆的地方:如果你的data本身每次都是新数组(map、filter都会产生新引用),那 FlatList 收到新的data自然会触发更新,不需要extraData。extraData是给那种“数据引用没变、但依赖的 Props 变了”的场景用的,比如点击高亮时传extraData={selectedId}。
在任务表里,toggleTask每次都会生成新数组,所以老老实实传data={tasks}就够。如果你把tasks拆成“进行中列表”和“已完成列表”两个派生数组,那更要保证这两个数组是通过useMemo或者每次filter生成的新引用,否则切 tab 时界面可能不刷新。
5.3 分开展示进行中/已完成时的过滤器
产品上常见的做法是提供“全部 / 进行中 / 已完成”三个筛选 tab。这里有个性能细节:不要在 renderItem 里写判断,而是先在数据层过滤好,再传给 FlatList:
const activeTasks = useMemo(() => tasks.filter(t => !t.completed), [tasks]); const completedTasks = useMemo(() => tasks.filter(t => t.completed), [tasks]);useMemo会在tasks变化时重新计算过滤结果。分段渲染的意义在于,已完成任务通常不会频繁操作,把它跟活跃任务放在同一个列表里滚动,每次完成操作都会导致整个列表重新计算可视范围,体验不如分开两个列表来得清爽。当然,如果任务量只有二三十条,一个列表加筛选条件也完全够用,不需要过度设计。
5.4 固定行高时用 getItemLayout 提升滚动性能
任务表行结构如果比较统一,强烈建议给 FlatList 配getItemLayout:
const ROW_HEIGHT = 64; <FlatList data={tasks} keyExtractor={item => item.id} getItemLayout={(_, index) => ({ length: ROW_HEIGHT, offset: ROW_HEIGHT * index, index, })} // ... />getItemLayout告诉 FlatList 每一项的高度是固定的,这样滚动跳转时不需要动态测量每一项的真实高度,能减少滚动时的计算量。这个优化在 Android 上效果明显,在 RNOH 上同样有效。但前提是你的任务行高度必须稳定,如果文字超长换行导致行高变化,固定高度反而会引发滚动跳动,这个时候就不要加这个配置。
6. 任务表首页白屏:RNOH 启动阶段的排查清单
这个标题的热搜词“react native 启动白屏”我特别理解。用户点开任务表 App,从看到启动图标到列表真正渲染出来,中间如果有一段白屏,第一反应就是“应用卡死了”。RNOH 项目的白屏,基本集中在原生启动图结束之后、RN 首帧渲染完成之前。下面是我的排查顺序。
6.1 先分清白屏发生在哪个阶段
RNOH 的启动链路大致分两段:第一段是鸿蒙原生侧加载启动图和初始化窗口,第二段是加载 RN bundle 并渲染第一篇 UI。排查时必须先确认白屏是哪一段造成的,否则会对着错误的方向改半天。
常见现象:
| 现象 | 大概率原因 | 优先操作 |
|---|---|---|
| 启动后一直白屏,无界面 | bundle 加载失败或 Metro 连不上 | 看 hilog 日志 |
| 白屏持续 2-3 秒后突然出现列表 | bundle 体积大或 Hermes 未启用 | 优化 bundle,开启字节码 |
| Debug 模式正常,Release 白屏 | 正式包没有把 bundle 打进去 | 检查打包脚本 |
| 进入任务表页后短暂白屏 | 首页组件异步加载时无占位 | 加 loading 状态 |
6.2 Debug 模式白屏:先检查 Metro 和 bundle 加载
Debug 模式下,RNOH 应用通过 Metro 加载 JS bundle。如果模拟器或真机访问不到开发机的 Metro 服务,首页就会一直白屏,控制台会打印类似Unable to load script的错误。排查步骤:
- 确认 Metro 已经启动,并且监听端口和项目配置一致。
- 确认真机和开发机在同一个局域网,鸿蒙模拟器则检查模拟器的网络通道。
- 查看 hilog 里有没有 bundle 下载失败的日志。
RNOH 的日志 tag 一般是项目的包名或者RNOH前缀,用hilog过滤能快速定位。不要上来改代码,先看日志,避免做无用功。
6.3 Release 模式白屏:检查 bundle 和 Hermes 字节码
Release 包白屏,十有八九是 bundle 没有正确打包进应用。RNOH 工程的打包流程和标准 RN 不太一样,需要走@react-native-oh-tpl/cli提供的 bundle 命令,或者查看项目里的hvigor配置。一个常见失误是只改了 JS 代码,没有重新生成 bundle 包,导致 Release 包内还是旧 bundle。
另一个优化点是 Hermes 字节码。RNOH 支持 Hermes,开启后能把 JS 编译成字节码,bundle 体积更小、启动解析更快。如果你的项目还没开 Hermes,Release 包启动白屏时间偏长是正常的,可以把它作为下一步优化方向。
6.4 用原生启动图和背景色填充白屏窗口
就算 bundle 加载再快,从窗口创建到首帧渲染之间也必然有毫秒级空白。更工程化的做法是:原生侧的启动图(startWindowIcon)和窗口背景色跟上 RN 侧主界面的背景色保持一致,这样用户感知不到“白屏”那个突然跳变。
在鸿蒙工程里,可以修改module.json5中的startWindowIcon和startWindowBackground:
{ "module": { "abilities": [ { "name": "EntryAbility", "startWindowIcon": "$media:startIcon", "startWindowBackground": "$color:startWindowBackground" } ] } }同时,RN 侧根组件也把背景色设置成同样的色值:
<View style={{ flex: 1, backgroundColor: '#F5F5F5' }}> {/* 任务表内容 */} </View>两边颜色统一之后,即使启动阶段存在短暂空窗,视觉上也是从浅灰到浅灰,而不是突然闪一下白色再进内容,体验会自然很多。
6.5 任务表页面的数据加载占位
还有一种“假白屏”:页面已经渲染出来了,但因为任务数据是异步加载的,tasks此时还是空数组,FlatList 一片空白,看起来就像白屏。这种情况不是启动问题,而是加载态的缺失。
我的处理方式很直接,任务数据加载期间给列表外显一个居中提示:
{loading ? ( <View style={styles.tipBox}> <Text style={styles.tipText}>任务加载中...</Text> </View> ) : tasks.length === 0 ? ( <View style={styles.tipBox}> <Text style={styles.tipText}>还没有任务,去创建一个吧</Text> </View> ) : ( <FlatList data={tasks} /* ... */ /> )}空列表也有界面,总比一片白底让用户猜“到底加载成功没有”要好。这个细节看似和划线置灰无关,但它是任务表这个产品体验完整性的关键一环。
6.6 白屏问题排查的最后提醒
如果你把上面这些点都查完了还是白屏,建议回归最小复现:新建一个只有Text的空白 RNOH 页面,确认它能正常渲染,再逐步把任务表代码贴回去。这样做的好处是把问题域缩小到“某个组件导致白屏”还是“整个环境配置不对”。
我最后想说的是,在 RNOH 上做任务表这种“看起来简单”的功能,最忌讳的就是在 Android 上跑通了就不管了。鸿蒙的版本迭代节奏很快,同样的代码在某个 API 版本上可能一切正常,换个版本就可能出现样式失效或启动异常。建议每次改动后都在真机上回归一遍划线置灰和删除流程,同时记录下环境和版本号,这对后续维护是非常宝贵的资料。