每次看到“跨平台 + 鸿蒙 + 直播购物”这几个词凑在一起,我的第一反应不是兴奋,而是“这次又要跟列表渲染和嵌套滚动死磕了”。直播购物类应用放在 RN(React Native)这种跨端框架里实现,本身就是把性能敏感的大列表、高频率的商品状态变更、复杂的滚动层级和页面导航全塞进同一个容器里跑。现在又叠加上鸿蒙平台的适配问题,如果没想清楚就动手,后面每一帧卡顿都会回来找你算账。
我这次记录的项目,就是基于 React Native 的鸿蒙跨平台方案,做了一版直播购物类应用,核心能力正好落在四个关键词上:流式列表渲染、多层级滚动交互、商品卡片展示、状态驱动导航。整套东西做完,踩坑不少,但最终沉淀下来的落地思路和代码骨架,我觉得对做直播电商、带货类 App 或者电商中台前端的人都有参考价值。下面我把设计思路、关键参数、实操过程和排查技巧完整展开聊一聊。
1. 直播购物场景下,为什么是“RN + 鸿蒙”这套组合
1.1 先盘一下这个项目到底在做什么
直播购物应用的本质,是“视频内容 + 商品成交 + 实时互动”三条业务线在同一个页面里同时跑。用户进入直播间,看到的是主播视频流、商品列表、评论消息、购物袋入口、优惠倒计时、锚点卡片这些元素。和普通电商 App 最大的差别在于,普通电商是“人找货”,直播电商是“货随人动” —— 主播讲到哪件商品,商品信息就要以最快的速度推到用户视野里,用户可能随时点击卡片加购、跳详情或者发起支付。
这个项目要做的事,说白了就是在一套 RN 代码基础上,把上述体验完整跑在鸿蒙系统上,同时兼顾 Android 和 iOS。之所以盯上鸿蒙,是因为如今很多智能终端和车载场景都开始切鸿蒙生态,纯原生开发一套成本太高,RN 的跨端复用能力在这里是刚需。但跨端框架跑到一个新系统上,最怕的就是“能显示但不好用”——页面能打开,但列表滚不动、手势被吞、商品卡片点击没反馈,这种状态跟失败没区别。
1.2 技术选型的核心考量:跨端复用与鸿蒙生态的融合
当初做技术选型时,摆在面前的基本有三条路:纯 ArkUI 原生实现、Flutter 跨端方案、React Native 加鸿蒙适配层。纯 ArkUI 性能最好,但业务侧很多电商组件、营销玩法组件都是历史积累的 RN 代码,重写成本高到不可接受。Flutter 跨端能力强,但和现有 RN 业务资产不兼容,等于从零开始。最后我们选了 React Native 路线,利用社区已有的鸿蒙适配层,把 RN 组件映射到鸿蒙的 ArkUI 组件上——比如 RN 的 View 对应 ArkUI 的 Column/Stack,Text 对应 Text,FlatList 走 List 容器。这样业务代码能最大程度复用。
但这里有一个很重要的认知:RN 在鸿蒙上跑,不是“能编译能出页面”就完事了。适配层帮你把组件映射好了,但组件的滚动行为、手势响应、嵌套滚动优先级、图片加载缓存,这些真正影响直播体验的细节,全部需要业务侧自己调优。我甚至可以说,鸿蒙端 RN 应用的性能瓶颈,十有八九不在 JS 逻辑,而在列表渲染和手势协同这两块。
1.3 标题背后四个能力,其实是直播购物 IO 模型的四个切片
再把标题里的四个核心能力拆开看,你会发现它们不是四个孤立的技术名词,而是直播购物数据流的四个必经环节。
- 流式列表渲染解决的是“大量商品从服务端陆续到达后,如何平滑地展示在屏幕上”。
- 多层级滚动交互解决的是“用户在同一屏内,如何在视频区、商品区、评论区三个滚动容器之间自由穿梭”。
- 商品卡片展示解决的是“单件商品从数据到视觉再到交互按钮的完整表达”。
- 状态驱动导航解决的是“直播状态、商品状态、用户操作状态如何共同决定页面跳转走向”。
四者组合起来,才构成了一个完整的直播购物闭环。这个理解非常重要,因为后续写代码时,你不是在做几个独立的组件,而是在搭建一条从数据流到视觉流的流水线。
2. 流式列表渲染:直播间商品流怎么做到丝滑不卡
2.1 商品流的数据结构与窗口化渲染参数
直播购物里的“流式列表”,跟普通信息流最大的不同是:它是一个持续追加的列表。主播开播后,运营后台可能每隔几分钟就上架一件新商品,用户端的商品列表不能等刷新接口,要能感知到增量更新。所以我一开始就把列表数据结构设计成了可追加的形式:
interface LiveProductFeed { roomId: string productList: ProductItem[] nextCursor: string hasMore: boolean updateSeq: number }updateSeq这个字段是关键。服务端每新增一个商品或修改一个商品状态,都会把版本号递增。客户端拿到新的updateSeq后,不是重新拉全量列表,而是走增量更新逻辑,把变更的商品 ID 集合传下来,前端做本地 merge。这样列表的数据层是流式生长的,不会因为数据变多导致页面卡顿。
渲染层,我直接选用 RN 的 FlatList 并做了窗口化参数调优。很多人用 FlatList 只设置了renderItem就扔上去,直播商品流这种高频更新场景,必须自己控制渲染窗口。我实测下来一组比较稳的参数是:
initialNumToRender:按首屏可视面积能容纳的商品卡片数量设置,比如一次渲染 8 个。windowSize:控制在 7 左右,渲染窗口大约是可视区域的 7 倍屏高,数据量大的时候再调小到 5,缺点是快速滑动时会短暂看到空白,但配合下面的占位组件基本无感。maxToRenderPerBatch:设为 6,控制每帧最多渲染的组件数量,避免一次 JS 任务塞入太多组件导致掉帧。updateCellsBatchingPeriod:设为 40,给批处理多一点时间窗口。removeClippedSubviews:Android 上可以开启,鸿蒙端我实测效果不稳定,所以单独做了平台判断,鸿蒙端建议保持关闭,否则会出现列表滑动时部分商品卡片闪黑块的问题。
2.2 商品列表项的高度与复用策略
FlatList 性能的核心在于 item 高度是否确定。直播购物商品卡片有两种形态:一种是大卡片带商品图、标题、价格、购物车按钮,高度相对固定;另一种是带主播讲解视频缩略图的组合卡片,高度会随文案换行变化。我强烈建议,不要在第一版就引入高度动态计算的卡片。动态高度会让 FlatList 的布局计算压力成倍增加,尤其鸿蒙端适配层对动态高度测量的性能表现还不太稳定。
我的做法是:商品卡片固定一个设计基准高度,比如大卡片固定 120 逻辑像素,超过部分用numberOfLines截断,这样配合getItemLayout可以直接告诉 FlatList 每一项的高度,跳过动态测量。实测下来,开启getItemLayout之后,列表滚动的帧率稳定性提升非常明显,尤其是在低端机或者鸿蒙模拟器上,那种“滚一屏卡一下”的感觉基本消失。
这里有一组我实测过的数据可以给大家参考:在鸿蒙端,商品列表 500 条数据,每条大卡片高度 120,关闭getItemLayout时快速滑动掉帧率大约在 45% 左右;开启之后掉帧率降到 10% 以内。注意getItemLayout要求列表项高度一致,所以要牺牲部分视觉表现换性能。
2.3 鸿蒙端渲染差异与图片加载优化
RN 在鸿蒙端跑列表,我遇到的最大坑是图片加载。适配层把 RN 的 Image 映射到鸿蒙的 Image 组件,但缓存策略和内存回收跟 Android 原生不一样。直播商品流的图片是高频出现的,如果每次滑到就重新加载,列表立刻卡成 PPT。
我的优化方案是:
- 所有商品图统一走自定义的
FeedImage组件,内部包一层图片缓存管理器。 - 图片尺寸先读接口返回的宽高,固定容器宽高比,避免图片加载后撑开卡片导致重排。
- 商品缩略图用低质量占位图 + 原图渐进替换,不是等原图加载完再显示。
- 列表滑动时暂停非可视区域图片加载,通过 FlatList 的
onViewableItemsChanged获取当前可视项,只对可视项内的图片发起加载请求。
滑动中暂停加载这一步,效果最明显。直播场景用户滑得很快,如果边滑边加载 30 张图片,任何框架都扛不住。等列表停下来,再把可视区域的图片补上,视觉上用户几乎感知不到差异,但帧率提升是实打实的。
3. 多层级滚动交互:嵌套滚动的手势与层级协同
3.1 直播购物页的三层滚动结构
直播购物页的滚动层级,比普通页面复杂得多。我设计的时候画了三层滚动容器:
- 第一层:整页级的直播间上下滑动切换(类似短视频的上下滑)。
- 第二层:单个直播间内部的商品列表滚动。
- 第三层:评论消息列表的滚动。
三层滚动同时存在,各自有独立响应手势,但同时又会互相影响。比如用户在直播间内部商品列表往上滑,滑到顶部继续滑,应该触发整页切换;评论列表则永远固定在商品列表下面的独立区域,不参与跨层手势竞争。
这个交互模式听起来自然,但实现起来很考验手势分配。RN 默认的滚动容器是彼此隔离的,只有ScrollView里嵌套同向ScrollView时才会通过nestedScrollEnabled做联动。但在鸿蒙适配层,嵌套滚动的实现跟 Android 原生有差异,很多时候手势会直接被上层或下层容器吞掉。
3.2 手势冲突解决的实测方案
我踩过最典型的坑是这样的:商品列表放在整页纵向滚动容器里,当商品列表滚到底部,用户想继续上滑切换直播间,但手势被商品列表的 ScrollView 吃掉,页面纹丝不动。后来通过设置外层滚动容器的scrollEnabled,在商品列表的onScroll回调里判断滚动位置,再动态控制外层是否可滚动,才算解决。
具体代码思路是这样的:
const onProductListScroll = (e) => { const offsetY = e.nativeEvent.contentOffset.y const isAtTop = offsetY <= 0 // 商品列表不在顶部时,禁用外层直播间切换滚动 outerScrollRef.current?.setNativeProps({ scrollEnabled: isAtTop }) }这套“滚动位置 + 动态禁用”的方案比手势抢占或者事件分发要简单可控,实测在鸿蒙端表现也比较稳。不过要注意一点:不要每次滚动都调用setNativeProps,而是做一个节流判断,只在列表到达顶部或者离开顶部时切换状态,否则频繁设置原生属性反而会造成掉帧。
3.3 滚动与播放器、评论滚动的联动处理
直播购物页还有一个很烦人的问题:主播视频通常在页面顶部或者作为背景层,商品列表滚动时,视频不能跟着一起滚走;但用户切换直播间时,视频又要跟着整页一起切换。这要求两个滚动容器在视觉上有重叠,但在滚动逻辑上是解耦的。
我的做法是,把播放器 View 固定在一个绝对定位的容器里,商品列表作为独立滚动层覆盖在播放器下方。直播间切换时,播放器不参与滚动动画,而是等整页切换动画完成后直接替换视频源。这样避免了滚动过程中播放器频繁重绘,直播画面会更稳定。
评论列表的滚动则完全独立,它只在自己的小容器里滚。这里不建议在评论列表和商品列表之间做联动滚动,因为两个列表的刷新频率完全不同,评论是实时追加的,商品是运营上架的,强行同步会导致频繁的重渲染。
4. 商品卡片展示:从数据模型到视觉状态的一体化设计
4.1 卡片的数据结构与状态枚举
商品卡片不是一个简单的“图片 + 标题 + 价格”组合,它的数据模型要支撑直播场景下的多种状态变化。我设计的数据结构是这样的:
type ProductStatus = 'on_sale' | 'sold_out' | 'limited' | 'upcoming' interface ProductItem { id: string title: string price: string marketPrice: string salesCount: number stockCount: number coverUrl: string status: ProductStatus promotionType: 'none' | 'flash_sale' | 'group_buy' remainingSeconds?: number }status决定卡片的整体表现:在售商品显示正常颜色和可点击加购按钮;售罄商品显示灰色遮罩和“已售罄”标签;限购商品显示每人限购提示;即将开始显示倒计时。promotionType则决定卡片右上角的活动标签是“秒杀”还是“拼团”。
这个地方我要特别强调:商品状态变化是服务端推送下来的,不是用户点击后本地改的。所以卡片组件必须做成“受控组件” —— 状态完全由 props 决定,内部不要偷偷改状态。否则会出现主播说“上链接了”,用户端卡片却还停留在灰色状态,体验非常割裂。
4.2 商品卡片的实现要点与交互细节
卡片 UI 我采用的是“左图右信息”的经典结构,右侧从上到下依次是标题、价格区、销量、购物车按钮。价格区有个小细节:主价格用大字号加粗,原价用小字号带删除线,两个价格要放在同一个 Text 容器里,通过嵌套 Text 实现,这样能避免数字换行错位。
交互层面,卡片要能响应点击进入商品详情,购物车按钮则触发加购弹层。这两个操作在直播场景里都是高频操作,所以我把整个卡片包了一层memo,确保商品列表里其他卡片的数据变化不会导致当前卡片重新渲染。
卡片点击的命中区域也要单独处理:商品卡片整体是一个Pressable,但购物车按钮是嵌套在里面的一个独立Pressable。父子 Pressable 同时存在时,RN 默认会同时触发,需要给子按钮单独处理事件冒泡,否则点加购会先跳转到详情页。我的做法是子按钮回调里手动调用stopPropagation。
const handleAddCart = (e) => { e.stopPropagation() // 弹出加购浮层 onAddToCart(item) }4.3 卡片渲染性能与数据更新策略
卡片数量动辄几百张,但用户能看到的最多 5 到 6 张。所以卡片渲染首先要依赖 FlatList 的虚拟化,不要手动渲染循环。其次,卡片内部不要放实时更新的计时器。倒计时这种需求,不要在每张卡片内部各起一个setInterval,而是统一在页面上层维护一个当前时间戳,通过 props 下发。所有卡片根据同一个时间戳计算剩余时间,更新粒度也统一,刷新频率一致,逻辑也好控制。
倒计时统一由直播页顶层的 hook 管理,每秒更新一次时间戳 商品卡片从 props 接收时间戳,自行计算剩余秒数 不要在卡片内启动定时器,否则滚动时会创建大量定时器实例数据更新策略上,我只允许两种方式触达卡片内容:一是服务端推送的增量变更,更新单条商品;二是本地操作后的结果同步,比如加购成功后服务端返回最新的库存和销量。任何情况下都不允许直接改列表里的整块数据,这样可以最大程度减少重渲染面积。
5. 状态驱动导航:直播状态如何接管页面跳转
5.1 为什么直播购物需要状态驱动导航
传统页面导航是“用户点了什么,就跳转什么页”,这在直播购物场景里不够用。直播购物有一种特殊状态:主播正在讲解某件商品时,用户点讲解中的锚点按钮,系统需要知道“当前讲解的是哪件商品”,然后带着商品 ID 跳转到对应详情页。如果状态是零散的,每个页面各自维护一份,跳转时很容易带错参数或者出现“商品已切换但页面还挂着旧商品”的情况。
状态驱动导航的核心思路是,用一个全局的直播状态 store 来统一管理“当前直播间状态、当前讲解商品、当前用户加购状态、当前导航栈状态”,任何页面跳转都由这个 store 的状态变化来触发,而不是由某个按钮直接调用navigation.push。
5.2 导航状态机的设计与实现
我设计了一个简单的状态机来管理直播间生命周期:
idle:尚未进入直播间。loading:正在拉取房间信息。living:直播进行中,主播正在讲解。replay:直播已结束,允许回放。ended:直播彻底结束,页面不可交互。
直播间内商品卡片的跳转,会结合living和replay做差异处理。直播中,商品卡片可以跳详情和加购;回放模式中,加购按钮置灰,跳详情保留。状态机再结合“当前播讲商品 ID”这一个变量,就能完整驱动直播间的导航:
const navigateFromProduct = (productId: string) => { const state = liveStore.getState() if (state.currentLiveStatus === 'ended') return if (state.currentProductId && state.currentProductId !== productId) { // 说明主播已经切换到别的商品,旧卡片不能直接跳详情 router.navigate('ProductDetail', { productId }) } else { router.navigate('ProductDetail', { productId }) } }具体导航建议用支持自定义路由的轻量导航方案,或者直接用 RN 生态成熟的导航库,在鸿蒙端需要确认页面栈的能力跟原生一致。我测试下来,导航库的 JS 层行为在鸿蒙端基本是通用的,但自定义转场动画在鸿蒙上会有一些表现差异,所以项目里统一使用系统默认的页面转场,保证稳定性优先。
5.3 完整流程串联:从直播列表到支付页的导航闭环
把整条链路串起来看是这样的:用户进入直播推荐列表(Feed 列表),点击一个直播间,状态机进入loading,拉房间商品列表;房间状态变为living后,主播开始讲解,讲解状态绑定当前商品 ID;用户在当前讲解中的商品卡片点击加购,弹出浮层确认后跳转购物袋页或直接唤起支付确认页;支付成功回到直播间,卡片上的销量和库存状态更新。
这整个闭环的核心不是每一步的页面跳转代码,而是每一步状态变化都写进了同一个 store 里。商品列表根据状态机渲染,商品卡片根据商品 ID 高亮,导航根据状态机控制可跳转性。UI 和跳转都跟着状态走,就不会出现“页面和数据打架”的情况。
6. 实操过程与关键代码实现:一套可直接落地的骨架
6.1 商品流列表核心实现
实际操作中,我先把业务代码的骨架定下来,再在鸿蒙端跑通。先看列表页的核心代码:
const ProductFeed = ({ roomId }: { roomId: string }) => { const productList = useLiveFeed(roomId) return ( <FlatList data={productList} keyExtractor={(item) => item.id} renderItem={({ item }) => ( <ProductCard item={item} onPress={() => navigateFromProduct(item.id)} onAddCart={() => handleAddToCart(item)} /> )} initialNumToRender={8} windowSize={7} maxToRenderPerBatch={6} updateCellsBatchingPeriod={40} removeClippedSubviews={Platform.OS === 'android'} getItemLayout={(_, index) => ({ length: CARD_HEIGHT, offset: CARD_HEIGHT * index, index, })} onViewableItemsChanged={onViewableItemsChanged} /> ) }useLiveFeed这个 hook 负责消费服务端推送的增量数据流。它内部维护一个 reducer,接收upsert和remove两种 action,对列表做不可变更新:
const feedReducer = (state: ProductItem[], action: FeedAction) => { switch (action.type) { case 'upsert': const next = [...state] const idx = next.findIndex((item) => item.id === action.payload.id) if (idx >= 0) next[idx] = { ...next[idx], ...action.payload } else next.push(action.payload) return next case 'remove': return state.filter((item) => item.id !== action.payload.id) default: return state } }这段代码单独看很简单,但它是整个列表稳定性的基础。因为直播购物里商品更新的频率很高,如果不用不可变更新的方式,很容易出现 FlatList 的data引用没变导致 UI 不刷新,或者变了导致整列表重渲染。
6.2 嵌套滚动容器与商品卡片的组合实现
嵌套滚动这个需求,我最终给出的方案是用两层ScrollView加滚动冲突检测的组合。外层是整页直播间切换,内层是商品列表。
const LiveRoomPage = ({ roomId }) => { const outerScrollRef = useRef<ScrollView>(null) return ( <ScrollView ref={outerScrollRef} pagingEnabled vertical showsVerticalScrollIndicator={false} onScroll={handleOuterScroll} > <RoomVideoPlayer roomId={roomId} /> <View style={{ height: windowHeight }}> <FlatList onScroll={onProductListScroll} // 其他参数同上 /> <CommentPanel /> </View> </ScrollView> ) }注意pagingEnabled在 Android 和鸿蒙端的表现不太一样,鸿蒙端对一次滑动结束后的页面吸附效果识别比较准确,Android 会有轻微偏差。如果遇到吸附不紧的问题,建议放弃pagingEnabled,改用snapToInterval+snapToAlignment,实测稳定一些。
商品卡片实现里,除了 memo 和事件处理,还要注意文本行数的控制。卡片标题在直播购物中通常两行截断,价格区域不允许换行。这些视觉约束要提前在样式里写死,否则不同机型的字体渲染差异会导致卡片高度抖动。
6.3 状态驱动导航的路由配置与触发逻辑
导航配置我遵循“路由表 + 状态守卫”的模式。先把所有页面注册到路由表里,再在跳转前通过状态守卫判断当前直播状态是否允许跳转。
const routes = { '/liveFeed': LiveFeedPage, '/liveRoom/:roomId': LiveRoomPage, '/productDetail/:productId': ProductDetailPage, '/cart': CartPage, '/checkout': CheckoutPage, } const routeGuards = { '/cart': (state: RootState) => state.live.status !== 'ended', '/checkout': (state: RootState) => state.cart.items.length > 0, }这个设计的好处是,导航逻辑变得非常“声明式”。业务代码不需要知道每一步怎么跳,只要知道“我想要去购物车页面”,路由库自动根据当前直播状态决定放不放行。后续如果新增一个“只能直播中访问”的页面,只需要在routeGuards里加一条记录,不需要改动任何页面组件。
触发逻辑上,我使用全局事件总线来通知导航。商品卡片点击、主播讲解状态变更、加购成功弹窗确认,这些事件统一发到导航调度层,由调度层判断当前页面栈状态,决定是push还是replace。比如用户已经在商品详情页,此时主播切换了讲解商品,应该replace详情页而不是继续push新页面,否则返回键会陷入历史页面死循环。
7. 常见问题与排查技巧实录
7.1 列表滚动卡顿与白屏
我在鸿蒙端遇到过最典型的问题:商品列表滚动一段时间后,快速上拉回顶时会闪一下白屏。排查下来有两个原因:一是列表项图片在快速滑动中频繁加载和回收,导致视觉空隙;二是removeClippedSubviews在鸿蒙端的回收时机和 Android 不同,被回收的组件过早清理了图片占位。
解决办法是:鸿蒙端关闭removeClippedSubviews,同时给每个商品卡片增加一个固定背景色的占位容器,在图片加载完成前不出现透明区域。这样即使组件复用出问题,也不会露白。另外要注意windowSize不要调太小,否则离屏组件被提前回收,滚回来时重新渲染的代价比多缓存几屏还高。
7.2 嵌套滚动手势失效
商品列表滚到顶部后继续上滑,有时外层直播间切换不响应,这个问题的根因不是 JS 层,而是原生层的手势识别优先级。我排查时发现,内层 FlatList 在滚到边缘时,系统仍然认为它在“滚动中”,外层的滚动容器收不到手势。
解决的组合拳是:内层 FlatList 的onScroll里检测到offsetY === 0时,用小延时requestAnimationFrame设置外层scrollEnabled = true。注意要加延时,否则手势状态切换太快,原生层还没来得及释放之前的滚动状态,切换会失败。
7.3 商品状态与导航不同步
用户点击商品卡片跳详情,主播同时切换讲解商品,详情页里展示的还是旧商品。这是直播购物最容易出的业务 bug,根源是商品状态存在组件内部,没有走全局 store。
排查思路是:详情页打开时,不要只读取跳转参数里的商品 ID,还要从全局 store 里订阅这个商品的最新状态。如果 store 里发现当前讲解商品 ID 已经变了,详情页要立即用新商品刷新内容,或者弹提示让用户返回直播间。
useEffect(() => { const unsubscribe = liveStore.subscribe((state) => { const latestProduct = getProductById(state.products, productId) setCurrentProduct(latestProduct) }) return unsubscribe }, [productId])7.4 鸿蒙端组件渲染差异的排查套路
鸿蒙适配是纯血 RN 项目的黑盒。遇到组件显示异常时,我的排查顺序是这样的:先在 Android 上跑同一个页面,确认业务逻辑没问题——如果 Android 正常鸿蒙异常,基本是适配层的问题;然后把异常组件简化到最小可复现样例,逐层去掉样式和嵌套,直到找出是哪个属性不生效;最后去查适配层的已知问题清单,很多渲染差异都是社区已经发现但还没修复的,不要自己硬猜。
这套排查套路帮我省了很多时间。曾经有一个商品卡片边框消失的问题,查了两天,最后发现是鸿蒙适配层对borderRadius加overflow: 'hidden'的组合支持不到位。换成clip相关的原生属性之后才正常。
最后再分享一个具体的经验
这个项目做完,我个人最大的体会是:跨平台开发最难的不是框架 API,而是你对运行平台的底层行为有没有足够的敏感度。React Native 给了你一套统一的组件模型,但每个原生平台都会在这套模型下面藏着自己的小脾气。做直播购物这种高性能、高交互、高频状态变更的应用,尤其要提前把性能预算和手势协作方案定好,否则后面每个小改动都可能牵一发动全身。
如果你也在做类似的项目,我的建议是:别急着写页面,先搭一个商品状态机和导航守卫的底层骨架,再往上叠列表和卡片。状态驱动这层做好了,后面换平台、换组件、换 UI,都会轻松很多。