☰
React Native for Harmony 任务表划线置灰实现与踩坑指南
2026/10/10 10:00:31 网站建设 项目流程

做 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的错误。排查步骤:

  1. 确认 Metro 已经启动,并且监听端口和项目配置一致。
  2. 确认真机和开发机在同一个局域网,鸿蒙模拟器则检查模拟器的网络通道。
  3. 查看 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 版本上可能一切正常,换个版本就可能出现样式失效或启动异常。建议每次改动后都在真机上回归一遍划线置灰和删除流程,同时记录下环境和版本号,这对后续维护是非常宝贵的资料。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询