跨平台这个口号喊了好多年,我现在听到的第一反应已经不是兴奋,而是翻需求文档。原因很简单:框架能不能跑,和业务能不能顺畅地跑,是两码事。只要项目涉及多平台,尤其是国内环境里绕不开的鸿蒙,你就会发现“一套代码处处跑”背后其实铺着一层又一层的适配细节。这篇文章写的是我最近完成的一个阅读类应用的实现方案,核心模块包括书籍展示、分类筛选、搜索排序。整个方案里最让我花心思的不是列表长什么样,而是状态管理怎么做,数据筛选和排序怎么组织,才能在功能叠加之后依然保持清晰。
如果你也要在 React Native 项目里接鸿蒙,或者正被筛选、排序这类“看起来简单、做起来容易乱”的功能折磨,这篇文章应该能帮你少走点弯路。我会从整体方案设计、状态管理底层逻辑、核心模块实现,到鸿蒙适配与排坑实录,逐步拆开讲。代码都是可以直接参考的那种,不是概念演示。
1. 项目背景与整体设计方案
1.1 为什么是 React Native,而不是再写一套原生
阅读类应用这个品类,业务上有个非常典型的特点:核心交互是“列表浏览 + 详情阅读”,页面结构高度统一,但运营侧又希望快速调整分类入口、排序规则、推荐位这类元素。这种场景下,跨平台框架的收益比做工具类、系统类应用要大得多。
最初也认真考虑过 Flutter。Flutter 在渲染一致性和动画表现上确实强,尤其自绘引擎保证了在鸿蒙、安卓、iOS 上几乎是像素级统一。但问题是团队手里已经沉淀了不少 React Native 的业务组件和 JS 工具库,全部用 Dart 重写一遍,成本不是一个月两个月能消化的。相比之下,React Native 的 JS 层代码在鸿蒙适配这条路上,复用率要高很多。
还有一个现实考量:阅读应用里很多页面本质上是“数据展示页”,对原生能力的要求集中在网络请求、本地缓存、图片加载这些基础能力上,不需要频繁调用底层硬件。React Native 桥接层把这些能力封得比较完整,业务代码几乎不感知平台差异。所以结论不是 React Native 比 Flutter 好,而是对于“已有 RN 代码资产 + 阅读业务场景”的组合,它更合适。
1.2 需求拆解与模块划分
这个项目功能上不复杂,但模块边界如果不划清楚,后期改需求时会非常痛苦。我按职责拆成了四块:
- 书籍展示:负责封面、书名、作者、简介、字数、评分的展示,以及列表分页加载。它是纯展示层,不关心数据从哪来,只接收一个
books数组。 - 分类筛选:负责按分类、状态(连载中/已完结)、评分、字数区间过滤书籍。这一层只产出“筛选条件”,本身不直接改列表数据。
- 搜索排序:负责搜索关键词、排序策略(默认/最新/热门/评分)和升降序。搜索词和排序方式本质上也是“筛选条件”的一部分。
- 状态管理层:把原始书籍数据、筛选条件、搜索结果统一收敛到一个 store 里,对外暴露“可见书籍列表”这个派生结果。
这里最关键的设计决策是:列表页里看到的数据,永远不是一个被业务组件随手改过的数组,而是“原始数据 + 筛选条件”经过一个纯函数计算后得到的派生数据。后面所有功能都是围绕这个思路展开的。
1.3 技术栈选型与依赖清单
状态管理最终选了 Zustand,没用 Redux Toolkit,也没用 Context。原因很直白:这个项目的筛选、排序状态更新频率高,但状态结构并不复杂,Redux 那套 action/reducer 样板代码在这里有点杀鸡用牛刀。Zustand 对 hooks 的支持非常自然,组件可以通过 selector 只订阅自己关心的状态片段,避免无关组件被连带重渲染。这个特性在搜索场景下尤其值钱。
基础依赖大概是这么一套:
- React Native:0.72 以上版本(具体小版本要看鸿蒙适配层支持的镜像版本)
- 状态管理:Zustand + persist 中间件
- 网络请求:axios(主要看重拦截器方便统一处理 token 和错误码)
- 本地存储:AsyncStorage(持久化搜索历史和筛选偏好)
- 列表组件:FlatList + 自定义 BookCard
- 导航:React Navigation(底部 Tab + 普通 Stack)
这里要提醒一句:RN 接鸿蒙时,不能直接把依赖往 package.json 里一贴就完事。每个第三方库都要确认有没有鸿蒙原生实现,很多安卓/iOS 上能跑的库在鸿蒙上需要换掉或者走条件编译分支。这部分细节我会在第四节展开。
2. 状态管理:数据流设计是项目的命门
2.1 状态管理选型的两个核心原则
我在这个项目里最想强调的一点:状态管理的选型,不在于用哪个库,而在于“状态从哪来、往哪去、谁有资格改它”。
阅读 App 的状态可以分成两大类。一类是服务端数据,比如书籍列表、书籍详情,这类数据的特点是“有权威来源”,客户端不能随便改,只能通过接口刷新;另一类是 UI 状态,比如当前选中的分类、搜索框里的关键词、排序方式,这类状态的特点是谁都可以改,但改完之后所有依赖它的界面都要同步更新。
这个项目选择 Zustand,是因为它能把这两类状态摆得很扁平。我把原始书籍列表、筛选条件、排序条件、搜索词全部放在同一个 store 里,所有组件都不再维护自己的“局部副本”。比如分类 Tab 组件点了一个分类,它做的唯一事情是setFilters({ category: '玄幻' }),至于列表怎么刷新、要不要重置分页,都是 store 层统一处理的。组件只管表达意图,不管数据处理,这是整个状态设计里最重要的一条。
2.2 数据模型定义:Book 与 FilterState
好的开始是先定义好类型。TypeScript 在这里不是可选项,而是必需品,尤其是筛选逻辑涉及多个条件组合,类型不清晰时,改一个字段就可能把数据流搅乱。
书目数据主要面向展示和筛选,所以字段设计要同时满足“列表渲染”和“筛选判断”两个目的:
interface Book { id: string; title: string; author: string; category: string; coverUrl?: string; description: string; rating: number; // 评分,保留一位小数 wordsNum: number; // 字数,用于筛选 readCount: number; // 阅读量,用于热门排序 status: 'serializing' | 'finished'; // 连载中 / 已完结 createdAt: string; // 上架时间,ISO 格式 updatedAt: string; // 最后更新时间,用于“最新”排序 latestChapter: string; // 最新章节名 }筛选条件单独抽成一个类型,因为这个对象就是要放进 store 里的单一状态源:
interface FilterState { category: string[]; // 支持单选,用数组是为了之后扩展多选方便 status: 'all' | 'serializing' | 'finished'; minRating: number; // 0 表示不限 minWords: number; // 0 表示不限 searchText: string; // 搜索框内容经过防抖后的值 sortBy: 'default' | 'newest' | 'hot' | 'rating'; sortOrder: 'asc' | 'desc'; }需要注意,搜索关键词我单独放在 FilterState 里,而不是让搜索框组件自己存。很多人写搜索喜欢让输入框内部维护一个keywordstate,提交时才丢给上层。这在单页面没问题,但这个项目里搜索结果可能会被书架页、历史记录页复用,如果关键词散落在组件内部,跨页面同步就非常头疼。统一放到 store 里,任何页面都能读取当前搜索结果对应的搜索条件。
2.3 核心设计:store 里不存“筛选后的列表”
这是整个状态管理方案最反直觉、也最值得讲清楚的一点。
直觉告诉我们:用户选了分类,我就把当前列表 filter 一遍,然后把过滤结果 setState 存起来,列表直接展示这个结果。这个做法在功能少时看起来没问题,但一旦筛选条件多起来,就会出现各种“同步问题”:用户搜索关键词后,之前选中的分类状态还在吗?排序变了之后,还要不要重新过滤?筛选面板重置时,要不要把搜索结果也一起清掉?
所有这些问题的根源,都是“筛选后的列表”被当成了独立状态来维护。它本质上是“原始数据”和“筛选条件”的派生值,不应该被单独存储和修改。这个项目的做法是:store 里只存books(原始数据)和filters(筛选条件),任何组件要取展示列表时,统一走一个派生 hook:
const useVisibleBooks = () => { const books = useBookStore(s => s.books); const filters = useBookStore(s => s.filters); return useMemo(() => applyFilters(books, filters), [books, filters]); };useMemo确保只有 books 或 filters 发生变化时才重新计算。所以搜索框每敲一个字,防抖生效后 filters 更新,列表自动刷新;分类点选后 filters 更新,列表自动刷新;排序切换后同样只是 filters 更新。由于所有条件都集中在 filters 这一个对象里,组合逻辑天然就是“叠加”的,不存在各改各的、互相覆盖的问题。
这种设计还能白得一个好处:撤销和重置非常好做。重置按钮只需要setFilters(initialFilters),不需要手动把分类、搜索、排序一个个恢复。因为原始数据没有被动过,所以无论之前叠加了多少条件,重置都只发生在 filters 这一个点上。
2.4 筛选与排序合并管线:先过滤,后排序
筛选和排序的组合逻辑我抽成了一个纯函数,放在utils/filter.ts里。之所以强调纯函数,是因为它可以单测、可以复现、不依赖组件生命周期。整个处理流程是固定管线:先过滤,再排序。
先过滤后排序的顺序不能颠倒。如果先排序再过滤,每次筛选条件变化都要对整个全集排序,大数据量下白白浪费性能;而且先排序会让“默认排序”失去意义——原始列表顺序被排序打乱后,再过滤得到的顺序就不自然了。正确做法是先把不符合条件的书剔除,再在结果集上做排序。
export function applyFilters(books: Book[], filters: FilterState): Book[] { const kw = filters.searchText.trim().toLowerCase(); const filtered = books.filter(book => { if (filters.category.length > 0 && !filters.category.includes(book.category)) { return false; } if (filters.status !== 'all' && book.status !== filters.status) { return false; } if (filters.minRating > 0 && book.rating < filters.minRating) { return false; } if (filters.minWords > 0 && book.wordsNum < filters.minWords) { return false; } if (kw && !book.title.toLowerCase().includes(kw) && !book.author.toLowerCase().includes(kw)) { return false; } return true; }); const sorted = sortBooks(filtered, filters.sortBy, filters.sortOrder); return sorted; }排序函数按策略映射来实现,每种策略对应一个比较器:
const sortStrategies = { default: (a: Book, b: Book) => a.updatedAt.localeCompare(b.updatedAt), newest: (a: Book, b: Book) => b.updatedAt.localeCompare(a.updatedAt), hot: (a: Book, b: Book) => b.readCount - a.readCount, rating: (a: Book, b: Book) => b.rating - a.rating, }; export function sortBooks( books: Book[], sortBy: FilterState['sortBy'], sortOrder: FilterState['sortOrder'] ): Book[] { const comparator = sortStrategies[sortBy]; const sorted = [...books].sort(comparator); if (sortOrder === 'asc') { // 对于字符串日期,reverse 需要反转比较方向,所以这里直接返回倒序 sorted.reverse(); } return sorted; }对同一个字段做升降序时,我选择“先按策略排好,再整体 reverse”,逻辑简单且不容易出错。只有一点要注意:reverse 之后相同值的元素顺序也会反转,如果你需要在升降序切换时保持同值元素的相对位置,应该在比较函数里同时处理升降序,而不是事后 reverse。阅读列表对这一点不敏感,所以用 reverse 完全够用。
另外一个隐藏细节是默认排序策略。很多人以为默认排序就是“不加排序”,直接返回原数组。但在这个项目里,默认排序其实隐含了后台上架顺序的意义,所以我直接用updatedAt倒序来做默认。这样新书会自动出现在最前面,运营不需要额外配置推荐位。如果哪天上架时间字段有问题,这个策略的错误会非常显眼,反而容易暴露数据问题。
2.5 用 Zustand selector 时的一个隐形坑
Zustand 用起来简洁,但也容易踩坑。最常见的是在 selector 里返回新对象:
// 错误写法:每次都会返回一个全新的对象计数,导致死循环或频繁重渲染 const filters = useBookStore(s => ({ ...s.filters }));正确做法是分别订阅需要的字段,或者用 Zustand 提供的useShallow做浅比较:
import { useShallow } from 'zustand/react/shallow'; const filters = useBookStore( useShallow(s => ({ category: s.filters.category, status: s.filters.status, minRating: s.filters.minRating, })) );不处理引用相等性,页面会在渲染期间出现无限循环警告。这个问题在原生 Redux 时代不太常见,因为 connect 有默认的浅比较逻辑,Zustand 把控制权交给你之后,反而多了一道警惕。
3. 核心功能模块实现细节
3.1 书籍展示:BookCard 与 FlatList 的配合
书籍列表是阅读应用的门面,BookCard 既是信息展示单元,也是性能优化的基本单位。它的渲染频率最高,所以组件一旦创建,就不应该因为列表状态变化而频繁重建。
我第一个经验是:BookCard 必须用React.memo包一层,并且所有回调都要用useCallback稳定引用。原因很直接——FlatList 在滚动时会反复调用 renderItem,如果卡片组件没有 memo,父组件重新渲染时(比如筛选条件变化导致列表刷新)所有卡片都会跟着重渲染,即使它们对应的书籍数据完全没变。数据量一上来,帧率就崩了。
interface BookCardProps { book: Book; onPress: (book: Book) => void; } const BookCard = React.memo(({ book, onPress }: BookCardProps) => { return ( <Pressable onPress={() => onPress(book)} style={({ pressed }) => [styles.card, pressed && styles.cardPressed]} > <Image source={{ uri: book.coverUrl }} style={styles.cover} /> <View style={styles.info}> <Text style={styles.title} numberOfLines={1}>{book.title}</Text> <Text style={styles.author}>{book.author}</Text> <View style={styles.metaRow}> <Text style={styles.category}>{book.category}</Text> <Text style={styles.meta}> {book.wordsNum / 10000}万字</Text> </View> <Text style={styles.desc} numberOfLines={2}>{book.description}</Text> </View> </Pressable> ); });封面图的加载在列表里是最费性能的环节。我在 Image 组件上加了defaultSource占位图,加载完成前先显示一个统一底色配文字的封面骨架。真正踩过的坑是封面 URL 失效后 Image 一直保留空白占位,用户看起来像网络坏了。后来在图片加载失败时强制换成本地默认封面图片,体验好了很多。这个逻辑单独写了个BookCover组件,避免每个卡片里都复制一遍。
列表容器直接用 FlatList。keyExtractor用 book.id,不要用数组索引,否则筛选和排序导致数据位置变化后,组件复用会错乱。initialNumToRender我调到 10,windowSize保持默认,这两个参数在鸿蒙上的表现和安卓有些差异,后面排坑时会再说。
3.2 分类筛选:点击分类只是更新状态
分类筛选有两种常见形态:顶部横向滚动的分类 Tab,和底部弹出的筛选面板。这个项目两种都用到了,但实现思路完全一致:任何筛选交互最终都只是调用 store 里的 setter,不直接操作列表。
顶部分类 Tab 用横滑 ScrollView 实现,支持“全部、玄幻、都市、历史、科幻、军事”这几个分类。点击分类时更新filters.category。这里我保留了数组类型,但业务上目前是单选逻辑:点击分类就替换数组为[category],点击“全部”则置空数组。为什么要保留数组?因为从产品规划看,后续很可能支持“类型 + 状态”或者“多类型”的交叉筛选,数据结构提前做成数组,后面不用改模型。
底部筛选面板提供状态和评分两个条件:
- 状态:单选(全部/连载中/已完结)
- 评分:一组预设阈值(不限/8.5+ / 8.0+ / 7.5+)
筛选面板里我做了两个细节。第一个是“结果计数”,面板打开时实时显示“筛选出 N 本书”,这样用户每点一个条件都能立刻感知到结果变化,减少“选完还得退出才知道有没有书”的焦虑。实现很简单,筛选面板组件内部直接调用useVisibleBooks().length,听到的感觉有点反直觉,但这个派生值本来就是实时计算的,性能开销很小。
第二个细节是“重置”按钮。重置时直接在 store 里对筛选条件做局部重置,不涉及历史搜索词。搜索关键词也算筛选条件的一部分,但重置面板时不应该清空搜索框,否则用户体验会很突兀。所以我把resetFilters设计成可配置的,面板重置只恢复分类、状态、评分这些 UI 筛选项。
3.3 搜索:防抖之后才进状态管理
搜索框是这个项目里最容易写出性能问题的模块。如果每次按键都立刻更新 store,那列表组件会反复做过滤计算和大列表重渲染,哪怕有 useMemo 也很难扛住快速输入时的连续触发。
实际方案是:输入框内部维护一个inputText本地状态,用于回显用户输入;真正进入状态管理的searchText经过 300ms 防抖后才更新。这样用户在快速敲字时,只有最后一次停顿之后才会触发筛选逻辑。
function useSearchInput() { const setSearchText = useBookStore(s => s.setSearchText); const [inputText, setInputText] = useState(''); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const onChangeText = useCallback((text: string) => { setInputText(text); if (timerRef.current) { clearTimeout(timerRef.current); } timerRef.current = setTimeout(() => { setSearchText(text.trim()); }, 300); }, [setSearchText]); useEffect(() => { return () => { if (timerRef.current) clearTimeout(timerRef.current); }; }, []); return { inputText, onChangeText }; }搜索匹配逻辑上,标题匹配和作者匹配是同时进行的,例子里的applyFilters函数用的是includes做子串匹配。有人会建议用模糊搜索或正则,但阅读应用场景里用户搜索书籍通常就是几个字,子串匹配已经能覆盖绝大多数需求。真正要注意的是中文大小写问题——中文没有大小写,但用户可能搜英文作者名,所以匹配前统一toLowerCase()是必须的。
搜索历史我通过 Zustand 的 persist 中间件直接持久化。每次setSearchText成功触发搜索后,把搜索词追加进历史数组,去重后最多保留 10 条。搜索历史页和搜索框共用同一个 store 状态,从历史点击某个词时,只要把inputText和searchText同时设置一次即可。
还有一个容易忽略的边界:空态展示。搜索无结果时,FlatList 的ListEmptyComponent要区分“没有筛选结果”和“当前分类下本来就没有书”两种情况。我判断条件很简单,如果searchText非空且结果数为 0,显示“未找到与 ‘xx’ 相关的书籍”;如果searchText为空但其他筛选条件导致结果为 0,显示“该分类下暂无书籍”。这个区分对用户来说体验差别很大,单独用一行代码判断即可实现。
3.4 排序:策略模式与排序指示状态
排序用的是四个 Tab:默认、最新、热门、评分。设计上不搞复杂的下拉菜单,因为阅读用户对排序的诉求非常直接,横向 Tab 点起来最顺手。
排序的核心逻辑在sortStrategies里已经给了。项目里唯一要额外处理的点:热门排序依据的readCount不是每本书都有,后台偶尔漏传,空值会导致排序结果出现 undefined 比较的异常。所以比较前做了兜底:(b.readCount || 0) - (a.readCount || 0)。评分和字数同理,字段缺失时先归一化为默认值。
排序状态的 UI 表达我用了两种方式。Tab 头显示当前的排序策略,如果当前不是默认排序,Tab 头旁边会出现一个“排序中”的小圆点,并且列表底部显示“已按热度排序,共 N 本”。这个设计是产品同学建议的,目的就是让用户知道当前列表不是默认顺序,避免误以为列表有问题。
另外,切换排序时要把列表滚动位置滚到顶部。否则用户在第一屏看完,切到“热门”排序后还停在原滚动位置,会感觉 “数据没刷新”。我用 FlatList 的ref调用scrollToOffset({ offset: 0 }),并把列表 key 拼上排序策略,强制在排序切换时重建列表:
<FlatList key={`${filters.sortBy}-${filters.sortOrder}`} ref={listRef} data={visibleBooks} renderItem={renderItem} ListEmptyComponent={EmptyView} onScrollBeginDrag={...} />给 FlatList 换 key 看起来粗暴,但它能彻底避免滚动位置和内容不匹配的脏状态,代价是重新渲染整个列表。对几百本书的规模来说,这个代价完全值得。
4. 鸿蒙适配与跨平台兼容实战
4.1 理解 React Native 在鸿蒙上的运行方式
在适配之前,先搞清楚底层关系能省不少排查时间。React Native 的架构本身是“JS 层 + 原生层分离”的:业务代码跑在 JS 引擎里,界面和原生能力通过桥接层交给原生端渲染。安卓端有安卓的桥接实现,iOS 端有 iOS 的桥接实现,鸿蒙要接进来,本质上是需要一套面向鸿蒙系统的桥接实现,把 RN 的 JS 代码映射到鸿蒙的原生组件和模块上。
对业务开发来说,这个架构带来的直接结论是:纯 JS 业务代码(比如状态管理、筛选逻辑、字符串处理、日期格式化)在鸿蒙上不需要任何改动;凡是依赖原生能力的第三方库(比如图片选择、权限申请、设备信息获取)都要检查是否有鸿蒙的原生实现。
这个项目里网络请求用的是 axios,底层依赖 XMLHttpRequest,鸿蒙适配层对网络这部分支持是完整的。AsyncStorage 官方适配层也有对应实现。真正出问题的是一些 UI 增强库,比如某个阴影组件在鸿蒙上没有原生实现,最后只能用 View 的 boxShadow 或者换方案。这类问题没法预判,只能在实际构建时逐个看报错。
4.2 构建配置与依赖改造
鸿蒙端的接入我是在一个独立分支里做的,不直接动主干的 package.json。构建环境上需要单独配置鸿蒙工程目录,RN 的 JS 打包产物通过适配层的构建工具注入到鸿蒙工程里。整体做法和接“安卓的 RN 工程”类似,只是目标平台换成了鸿蒙。
依赖改造时有一条原则:能少改就少改。package.json 里不强行加入鸿蒙专用依赖,只用条件编译去隔离平台差异。React Navigation 的导航栈在鸿蒙上需要验证手势返回是否顺畅,如果有问题,可以在鸿蒙端改用系统返回键监听,而不是依赖边缘滑动手势。这里强烈建议把“平台差异清单”维护成一个独立文档,每验证一个库就记录一条,后面接更多平台时照着翻。
一套代码同时跑安卓、iOS、鸿蒙时,Platform 判断是最常用的手段:
import { Platform } from 'react-native'; const isHarmony = Platform.OS === 'harmony'; const isAndroid = Platform.OS === 'android'; if (isHarmony) { // 走鸿蒙的返回监听 } else { // 走 RN 默认的 BackHandler }RN 社区的文件约定还支持平台后缀,比如SearchBar.harmony.tsx、SearchBar.android.tsx、SearchBar.ios.tsx,构建时会自动选择对应文件。我倾向于把差异大的组件拆成独立文件,而不是在一个文件里写满if (isHarmony),后者在维护时容易漏改且阅读成本高。
4.3 样式与交互差异:安全区、字体与加载体验
鸿蒙的系统 UI 和安卓/iOS 有差别,适配时最常碰到的是安全区处理。鸿蒙设备也有刘海屏和挖孔屏,页面顶部如果不做安全区避让,状态栏会直接挡住书名栏。RN 里用SafeAreaView在 iOS 上表现最好,在鸿蒙上则更依赖StatusBar.currentHeight加 padding。我的做法是在根组件里做一个平台判断,统一计算顶部安全区高度,避免每个页面各写一遍。
字体加载是另一个差异点。鸿蒙系统默认字体和安卓、iOS 不同,数字和中文的渲染比例会有一点偏移,最直观的问题是固定宽度的标签文字在鸿蒙上可能出现截断。排查时不要只调字号,先看是不是字体渲染差异导致的宽度变化。列表卡片里字数标签我改成了flexShrink: 1加numberOfLines={1},保证任何平台都不截断。
图片加载在鸿蒙上的行为也要验证。封面图是网络图,鸿蒙适配层对图片缓存的策略和安卓有差异,我在使用中发现反复滚动时图片偶发闪烁,最终处理是给封面图组件加了一个fadeDuration={200}的属性,让图片淡入而不是直接替换。这个属性安卓上本来就支持,鸿蒙适配后也继承了行为,问题解决得很顺利。
4.4 跨平台差异速查表
我在项目里维护了一张横向对比表,遇到问题先查表,比每次重新踩坑高效得多:
| 能力点 | 安卓 | iOS | 鸿蒙 |
|---|---|---|---|
| 网络请求 | 正常 | 正常 | 正常,需注意权限声明 |
| 图片缓存 | 三级缓存正常 | 正常 | 部分版本缓存策略不同,建议显式设置淡入 |
| 状态栏高度 | StatusBar.currentHeight | SafeAreaView | StatusBar.currentHeight 可用 |
| 返回手势 | 物理返回键 | 边缘滑动 | 手势识别差异大,建议监听系统返回 |
| 本地存储 | AsyncStorage 正常 | 正常 | 注意适配层版本,别依赖旧数据格式 |
| 表单键盘避让 | KeyboardAvoidingView | 需要 behavior='padding' | 鸿蒙表现接近安卓,仍需真机验证 |
这张表不是静态的,每在一个平台上踩出新坑,都会补一行。跨平台适配最大的敌人不是某个平台的坑,而是“假设其他平台都一样”的侥幸心理。
5. 常见问题与排查技巧实录
5.1 搜索输入卡顿:重复渲染的元凶
项目联调阶段遇到一个典型问题:搜索框每敲一个字符,整个列表会肉眼可见地卡一下。最初怀疑是过滤计算耗时,但数据量只有几百本书,计算量根本不可能拖垮 UI。
后来用 React DevTools 的 Profiler 录了一段,发现输入框每次 setState 之后,整个页面组件都重渲染了,列表里几百个 BookCard 全部参与 diff。原因有两个:一是输入框组件和列表组件在同一个页面组件下,输入框的本地状态更新导致父组件重渲染,列表所有子组件跟着遭殃;二是 BookCard 的 onPress 回调没有用 useCallback 包住,每次父组件渲染都会生成新函数,memo 判断失效。
修复分三步。第一步把搜索框抽成独立组件,让它内部的 inputText 状态只影响自己;第二步给列表的 renderItem 回调套 useCallback,稳定传给 FlatList;第三步给 BookCard 的 onPress 也用 useCallback。这三步做完,输入卡顿立刻消失。经验就是:memo 不是银弹,它只在子组件 props 稳定时才生效,而 props 稳定的前提是回调引用不变化。
5.2 搜索结果“闪一下旧数据”
搜索时另一个诡异现象:输入新词后,列表会先显示一瞬间的上一次结果,再跳转到新结果。原因是搜索文本是防抖后才更新的,但列表的数据源visibleBooks依赖searchText,在防抖等待期间输入变了,筛选条件没变,列表自然还显示旧数据。
解决方案是给搜索框组件记录一个isTyping状态,只要用户正在输入,列表区域就显示一个轻量加载态而不是旧列表。具体实现是搜索框onChangeText时置isTyping为 true,防抖回调触发新一轮筛选后置 false。这个改动也顺便提升了感知速度——反正新结果还没出现,与其让用户盯着旧数据,不如明确告诉他 “在搜了”。
5.3 FlatList 在鸿蒙上的白屏与加载闪烁
鸿蒙真机上,FlatList 在快速滑动时出现过底部白屏,往下滑突然没有内容。刚开始以为数据没加载完,后来发现是虚拟列表的渲染窗口问题。鸿蒙适配层对windowSize的计算和安卓不完全一致,默认窗口在部分机型上偏小,内容还没预渲染就被回收了。
调整措施是:把windowSize从默认的 21 调大到 31,maxToRenderPerBatch从默认值 10 调到 16。同时确认initialNumToRender不小于首屏可见卡片数。改完后白屏出现的频率明显降低,而且没有明显增加内存开销。记住这个参数是为“滚动顺畅”服务的,不是越大越好,自己在目标机型上试几次才能找到平衡点。
5.4 中文排序与混合内容排序
项目里有一个书籍分类页需要按“书名”排序,开始直接写a.title > b.title ? 1 : -1,结果发现中文书名排序完全不符合直觉,返回的结果在字符层面是乱序的。原因是 JavaScript 默认字符串比较是基于 UTF-16 码点,对中文来说基本等于随机序。
正确做法是用localeCompare,并且带上中文区域参数:
const comparator = (a: Book, b: Book) => a.title.localeCompare(b.title, 'zh-Hans-CN');作者名排序同理。这个坑很隐蔽,因为列表量小的时候看起来每个书名都很“正常”,但一旦形成规律排序就露馅。测试用例里也应该带上中文书名、中文作者、英文书名混排的场景。
5.5 问题速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 输入关键词后列表卡顿 | 输入组件导致父组件整体重渲染 | 拆分组件、useCallback 稳定回调 |
| 搜索结果先显示旧数据再跳新数据 | 防抖期间列表仍展示旧结果 | 引入 isTyping 状态 |
| 鸿蒙快速滑动出现白屏 | 虚拟列表渲染窗口偏小 | 调大 windowSize 和 maxToRenderPerBatch |
| 中文书名排序乱 | 使用默认字符码点比较 | 改用 localeCompare 并指定区域 |
| 封面图加载失败留白 | 未处理 Image error 事件 | 加载失败切换默认封面 |
| 切换排序后滚动位置错乱 | 列表内容变化但位置没重置 | scrollToOffset + 强制 key 更新 |
这张表是从项目真实排障记录里抽出来的,每一条都对应线上或测试环境的真实反馈。遇到类似现象,先照着表格排除,再深入查代码,能省不少时间。
6. 一点测试与强维护性心得
筛选和排序是纯函数逻辑,非常适合单元测试,这也是我把applyFilters独立出来的重要原因。测试用例不用多,覆盖关键分支即可:
- 分类单选过滤:只传 category,其他条件空,验证结果只包含该分类
- 多条件叠加:category + minRating + searchText 同时生效,验证交集
- 搜索不区分大小写:搜索 “abc” 能匹配 “ABC”
- 默认排序:验证按 updatedAt 倒序
- 热门排序:验证 readCount 降序且空值兜底
- 升降序切换:验证 reverse 后顺序正确
这些用例跑起来非常快,每次重构状态管理代码后先跑一遍,能拦住大部分低级错误。我在项目里甚至把几组固定数据写成了测试夹具,筛选条件的各种组合都能秒级回归。
状态管理这块我还有一个习惯:所有修改 store 的方法都集中放在一个useBookStore文件里,不让业务组件直接写setState拼新对象。组件层只调用 store 暴露的setCategory、setSortBy、setSearchText这些语义化方法。这样即使后续把数据改成接口请求时,改动也只会集中在 store 内部,业务组件一行都不用动。
最后再分享一个小技巧。我在 store 的 storage 持久化里,把用户上次的排序方式和分类选择存了下来。下次打开应用,用户看到的是他上次设置的视图,而不是每次重新打开都回到默认排序。这个细节对阅读应用这种高频浏览场景很重要,而且实现成本极低,只要在 persist 配置里加上白名单就行。不过要注意筛选条件里不要持久化搜索词,否则用户每次打开应用都会莫名被限制在旧搜索词的结果里,会怀疑是自己的书架出了问题。这个坑我在测试时踩过一次,后来特意在持久化字段列表里把 searchText 排除掉,再也没出现过类似的诡异反馈。