ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

React Native 鸿蒙通用 Card 卡片组件封装与适配

React Native 鸿蒙通用 Card 卡片组件封装与适配

在看多个业务列表页的样式代码时,我发现一个挺难处理的现象:同一种卡片外观,在项目里被复制粘贴了三十多份,圆角有的是 8、有的是 12,阴影参数散落在各个文件里。后来产品要统一改成 16 圆角、加一层浅描边,我得全局搜索替换半天,改完还漏了两个页面。这件事之后,我把卡片外壳单独做成一个通用 Card 卡片容器组件,在 React Native for OpenHarmony 这套工程上跑通,并在这里记录完整实现过程。

这个组件要做的事很明确:只负责“卡片外观 + 交互反馈 + 无障碍”这三件事,业务内容一律通过 children 传入。它解决的是样式散落、多端表现不一致、按压反馈缺失的问题。适合正在做 React Native for OpenHarmony 适配、又想把 UI 基础层收拢的开发参考;即使你之前在纯 React Native 端写过类似组件,鸿蒙端在阴影、按压、无障碍映射上的差异也值得单独看一遍。

1. 先想清楚 Card 该负责什么

1.1 一次样式失控的复盘

最早那批卡片是随着页面需求一个个加出来的,没人刻意设计。列表页要个白底圆角,详情页要个带描边的,首页要个带点击效果的,每次都是复制一份样式对象改两笔。表面上看开发很快,代价在三个月后集中爆发:设计规范调整一次,涉及 8 个页面、30 多处样式对象,而且每一处的阴影写法和内边距都不一样,视觉验收时被指出来一片。

我复盘下来,问题不是出在“没抽组件”,而是出在“抽的时候没想清楚边界”。很多人的通用组件最后变成万能组件,props 一路膨胀到二十几个,什么showHeader、headerTitle、headerRight、footerText全塞进去,结果业务一变就得改组件,组件反而成了新的耦合点。

所以这次重做,我先把职责范围固定下来:Card 只提供外壳的视觉能力(背景、圆角、描边、阴影层次、内边距)和外壳的交互能力(可按压、禁用、按压反馈、无障碍标签),内部结构一律交给调用方通过 children 组合。这条边界如果一开始不明确,后面所有设计都会失控。

1.2 三条必须守住的约束

第一条是可组合。卡片里可能放一行文字、一张图、一个列表、两个按钮,甚至嵌套另一张卡片。组件不能假设内容长什么样,只能保证外壳在任何内容下都不崩。这意味着不能给内部容器加固定高度,也不能假设只有单一子节点。

第二条是可交互但不强制。有些卡片是纯展示,有些整块可点,有些只有右上角一个小按钮可点。如果 Card 强制包一层 Pressable,纯展示场景会多出一层无意义的触摸处理层,长列表里这个开销会累积。我的做法是根据是否传入onPress动态决定外层节点类型。

第三条是可降级。鸿蒙端、安卓端、iOS 端对阴影的支持完全不同,一套阴影参数不可能三端表现一致。组件必须在视觉降级时仍然成立:没有阴影的时候,用描边和背景色差维持层级感,不能出现“这个平台就变成一块纯色矩形”的情况。

提醒:把这三条写进组件的 README 或注释里,比写在需求文档里管用。后来接手的同学想往 Card 里加业务 props,看到注释就会先掂量一下。

1.3 组件结构预览

最终的结构是长这样的三层,用文字描述更直观:

  • 外层:Animated.View,负责缩放动画、外边距、圆角裁剪
  • 中层:视觉容器,负责背景色、描边、阴影、内边距
  • 内层:children 挂载点,如果是带点击的,这一层外面再包一层Pressable承接触摸事件

另配一组静态子组件Card.Body、Card.Title、Card.Description、Card.Footer、Card.Row,它们只是带好默认排版样式的View/Text,没有任何状态逻辑。这样调用方想用就用,不想用就塞自己的东西,完全自由。

2. 工程准备:把 Card 放到能跑起来的位置

2.1 依赖与目录约定

React Native for OpenHarmony 这层适配由社区维护,接入方式跟标准 RN 工程差不多,差异主要在打包产物和原生容器。我用的依赖是社区提供的 RN 鸿蒙适配包,版本跟 RN 主版本要对齐,这个一定要在 lock 文件里锁死,否则容易出现 JS 侧正常、原生侧报渲染器不匹配的问题。

目录我按下面这样分:

src/ components/ Card/ index.ts Card.tsx Card.styles.ts Card.tokens.ts Card.types.ts __tests__/Card.test.tsx theme/ useThemeTokens.ts screens/ CardGallery.tsx

之所以拆成四个文件,是因为单文件写到 400 行以后,改样式和改逻辑的 diff 会混在一起,review 很痛苦。Card.tokens.ts专门放颜色令牌和尺寸常量,Card.styles.ts只导出 StyleSheet,Card.types.ts放类型,Card.tsx只写组件逻辑。这套约定在小团队里推起来阻力最小。

依赖装好后,鸿蒙侧工程需要注册 RN 包入口,这部分跟社区模板一致,我贴一下关键的脚本,实际以你工程里的模板为准:

# 安装适配依赖(版本务必与 RN 主版本对齐) npm i @react-native-oh/react-native-harmony # 生成鸿蒙侧 bundle npx react-native bundle-harmony \ --dev false \ --entry-file index.js \ --bundle-output ./harmony/entry/src/main/resources/rawfile/bundle.harmony.js # 安装到已连接的设备 hdc install ./harmony/entry/build/default/outputs/default/entry-default-signed.hap

第二条命令的产物路径依赖你工程配置,有些模板输出到rawfile下,有些直接内联到 resources。第一次跑建议先打--dev true的包,方便看报错堆栈;确认能起来之后再切--dev false。

2.2 一个最小验证页

动手写组件之前,我会先搭一个几乎空的验证页,用来快速看样式改动的效果。这一步很多同学习惯跳过,直接写组件、写测试、再上页面看,结果一个圆角不对要绕一大圈。先有页面、后有组件,改一行热更新立刻看到效果,效率差好几倍。

// screens/CardGallery.tsx import React from 'react'; import { ScrollView, StyleSheet, Text } from 'react-native'; import Card from '../components/Card'; export default function CardGallery() { return ( <ScrollView contentContainerStyle={styles.page}> <Card variant="elevated" onPress={() => console.log('elevated pressed')}> <Card.Title>默认卡片</Card.Title> <Card.Description>带阴影与按压反馈</Card.Description> </Card> <Card variant="outlined" style={styles.gap}> <Card.Title>描边卡片</Card.Title> <Card.Description>没有阴影,靠描边撑出层级</Card.Description> </Card> <Card variant="filled" onPress={() => {}} disabled style={styles.gap}> <Card.Title>禁用态</Card.Title> <Card.Description>按压不触发,透明度降低</Card.Description> </Card> </ScrollView> ); } const styles = StyleSheet.create({ page: { padding: 16 }, gap: { marginTop: 12 }, });

这个页面同时承担三个作用:视觉验收、交互验收、埋点验收。我在onPress里打日志,是确认点击热区没有偏——鸿蒙端触摸事件传到 JS 的链路比纯 RN 端多一跳,热区偏移是很常见的问题。

2.3 平台标识的确认

写跨端代码前,先打印一次平台标识,不要凭经验猜。适配层的Platform.OS返回值在不同版本里可能有差异,我用下面这段代码确认:

import { Platform } from 'react-native'; console.log('OS =', Platform.OS, 'Version =', Platform.Version);

确认完再决定分支逻辑。这里有个很实际的坑:TypeScript 里Platform.select的 key 受PlatformOSType联合类型约束,鸿蒙不是标准值,写进去会直接类型报错。我见过有人用// @ts-ignore糊过去,后面升级 RN 版本全炸。更稳的写法是先落成一个字符串再比较,不要碰类型系统。

3. API 设计:少即是多

3.1 Props 清单表

下表是最终定稿的 props,我控制在了 11 个。定稿过程中砍掉的功能比留下的多得多,比如headerTitle这类业务属性、onLongPress这类低频交互、bordered这种跟 variant 重复的开关。

Prop 名类型默认值说明
childrenReactNode无卡片内容,完全交给调用方
variant'elevated' | 'outlined' | 'filled''elevated'视觉层级,决定阴影与背景
paddingnumber16内边距,单位与 RN 一致
radiusnumber16圆角,超过 24 会提示但不拦截
onPress() => void无传入后整块可点
disabledbooleanfalse禁用点击并降低不透明度
styleStyleProp<ViewStyle>无外层样式,用于外边距等定位
bodyStyleStyleProp<ViewStyle>无内层内容区样式
accessibilityLabelstring无读屏文案
accessibilityHintstring无操作提示
testIDstring无自动化测试定位

关于style只有一个入口这件事,我特意跟团队强调过:只能传外层,用来做定位和外边距。曾经有人把内边距也从style传进来,覆盖了组件默认值,结果同一种 variant 在两个页面里内边距不一样,视觉走查又炸了。

3.2 为什么是 variant 而不是直接传颜色

最简单粗暴的设计是让调用方传backgroundColor和shadowColor,看起来最灵活。但灵活到极致就是没有约束,最终必然退化成“每个人按自己理解写一套”。

variant 本质上是把设计规范里的层级语义固化成枚举。设计系统里卡片的层级只有三种:浮在页面之上(elevated)、与页面平级但有边界(outlined)、与页面融为一体靠底色区分(filled)。这三种语义在亮色和暗色主题下对应完全不同的具体色值,如果让业务传颜色,暗色模式适配必然做不完整。

我这里把具体色值收进 tokens,variant 只做映射,改版式时只改 token 文件:

// Card.tokens.ts export const cardTokens = { light: { elevated: { surface: '#FFFFFF', border: 'transparent', shadow: '#0F172A', shadowOpacity: 0.08 }, outlined: { surface: '#FFFFFF', border: '#E6E8EC', shadow: 'transparent', shadowOpacity: 0 }, filled: { surface: '#F4F6F8', border: 'transparent', shadow: 'transparent', shadowOpacity: 0 }, }, dark: { elevated: { surface: '#1C1F24', border: 'transparent', shadow: '#000000', shadowOpacity: 0.45 }, outlined: { surface: '#1C1F24', border: '#2C3138', shadow: 'transparent', shadowOpacity: 0 }, filled: { surface: '#22262C', border: 'transparent', shadow: 'transparent', shadowOpacity: 0 }, }, } as const;

这个结构还有个额外好处:验收时只要拿一个调色板对着三个 variant 各截一张图,改版式的回归成本极低。

3.3 尺寸换算与取值依据

React Native 的长度单位是无单位的 dp 概念,在鸿蒙端适配层里一般按 vp 落地。这个对应关系意味着你写padding: 16,在鸿蒙侧就是 16vp,在 320vp 宽的设备上占 5% 宽度,视觉上跟 iOS 的 16pt 基本一致。我实测过几台不同分辨率的设备,同一份数值的物理尺寸比例是稳定的,所以不需要额外做 density 换算。

圆角我定 16 有三个依据。第一,跟内边距 16 形成整数倍呼应,视觉上圆角半径与内容缩进一致,看起来更协调。第二,16 在常见的 12、16、20 三档规范里属于中间档,既能明显看出圆角,又不会在小尺寸卡片上把内容切掉。第三,卡片高度通常在 72 到 120 之间,圆角半径超过高度一半会出现胶囊形状,16 距这个临界值有足够余量。

阴影我给的参数是偏移{ width: 0, height: 4 }、透明度0.08、半径12。高度偏移 4 制造轻微下沉感,比 2 更明显、比 8 更克制;透明度 0.08 是关键,超过 0.12 在浅灰背景上会出现明显的脏边。这组数值我在亮色主题下调了三轮才定,暗色主题因为背景本身很暗,阴影几乎不可见,改用 0.45 的高透明度并配合#000000纯黑。

注意:阴影参数不要跟圆角一起乱调。圆角变大时,阴影半径也要适当增大,否则阴影边缘会显得生硬。经验比例是阴影半径约为圆角的 0.75 倍。

3.4 子组件的取舍

我提供了Card.Title、Card.Description、Card.Body、Card.Footer、Card.Row这五个子组件,定位是“带默认排版的骨架”,不是“必须使用的规范”。它们内部没有任何状态,只是把常用的Text和View包了一层默认样式,接受style覆盖。

为什么不干脆不提供?因为实际项目里 80% 的卡片结构就是标题加描述,每次手写Text配fontSize配color,重复劳动太大,而且不同人写的字号一定不一样。提供这几个壳,能把 80% 的场景拉到一条基准线上,剩下 20% 自由发挥。

为什么不提供更多,比如带图标的Card.Header、带右侧箭头的Card.Arrow?因为一旦提供了带结构的子组件,它就隐含了布局假设,比如左右分栏、居中对齐。业务方一旦依赖了这套假设,后面想改布局就得改组件。宁可少给,让他们自己拼。

4. 逐层实现 Card

4.1 容器层:样式聚合与 memo 策略

容器层要做三件事:把 variant 映射成具体样式、把调用方传入的 style 合进去、把动画值绑到 transform 上。这里有一个性能上的关键决策,就是样式对象必须来自StyleSheet.create预编译,不能每次渲染现造对象。

RN 在 diff 时是对 style 做浅比较的,内联对象每次都是新引用,等于每帧都在告诉渲染器“样式变了”,动画场景下这个开销会被放大到肉眼可见的掉帧。我把静态部分全部放进 StyleSheet,只有动画值和动态的 padding、radius 走内联数组,且这个数组用useMemo缓存。

memo要不要加?加。但只做浅比较还不够,因为children每次都是新的 React 元素。我的处理是在memo外面再包一层useMemo生成 children 的包装结构,同时把onPress用useCallback稳定下来。实测在 200 条卡片的列表里,加上这一层之后滚动帧率明显稳定。

4.2 交互层:自绘按压反馈

鸿蒙端不能用android_ripple,那是安卓专属的水波纹。iOS 的TouchableOpacity默认透明度反馈在鸿蒙上能用,但反馈偏弱、且无法定制。我最终选的是Pressable加自绘缩放动画,因为它给的控制粒度最细。

const AnimatedPressable = Animated.createAnimatedComponent(Pressable);

用Animated.timing驱动scale从 1 到 0.98,按下 120ms,抬起 160ms。为什么是 0.98 而不是 0.95?0.95 的缩放在整块卡片上幅度太大,像是整块被按瘪了,视觉上有失稳感;0.98 只是轻微回弹,接近真实物理按压的心理预期。

曲线我用Easing.out(Easing.quad),抬手时略带回弹感。时长上按下要快、抬起要慢,这是符合物理直觉的:按下是受力的瞬间,抬手是回弹的余韵。如果反过来,手感会显得黏。

useNativeDriver这里要提一句,transform 和 opacity 是可以走原生的,但如果你在某些适配版本上看到动画不生效或者警告,先把它关掉排除 JavaScript 驱动的问题,再确认是不是原生驱动不支持某类属性。这个排查顺序我踩过坑,一开始怀疑是动画本身写错,折腾了半天才发现是驱动模式的问题。

4.3 组合层的实现要点

子组件看起来最简单,实际上有两个细节值得说。

第一个是Card.Title的numberOfLines。默认不限制行数,但在实际业务里标题超长会把手动设置的固定高度撑破,所以我在组件里没有设默认值,而是在文档里建议调用方在固定高度的卡片里显式传numberOfLines={1}。这个决策的原因是:默认截断会让调试期的文案问题被藏起来,反而不利于发现问题。

第二个是Card.Body的内边距继承。内容区不应该再额外加内边距,否则会跟外层 padding 叠加,出现双倍缩进。我让Card.Body默认无内边距,只在需要分组时才通过style显式加,这样嵌套使用时行为是可预测的。

4.4 完整实现代码

// components/Card/Card.tsx import React, { memo, useCallback, useMemo, useRef, } from 'react'; import { Animated, Easing, Platform, Pressable, StyleProp, StyleSheet, Text, useColorScheme, View, ViewStyle, } from 'react-native'; import { cardTokens } from './Card.tokens'; import type { CardProps, CardVariant } from './Card.types'; const OS = Platform.OS as string; // 避免 Platform.select 的类型联合限制 const AnimatedPressable = Animated.createAnimatedComponent(Pressable); function resolveShadow(variant: CardVariant, shadowColor: string, opacity: number): ViewStyle { if (variant !== 'elevated') return {}; if (OS === 'ios') { return { shadowColor, shadowOffset: { width: 0, height: 4 }, shadowOpacity: opacity, shadowRadius: 12, }; } // 安卓与鸿蒙侧统一先用 elevation,若无效则由描边兜底 return { elevation: 4 }; } function CardInner({ children, variant = 'elevated', padding = 16, radius = 16, onPress, disabled = false, style, bodyStyle, accessibilityLabel, accessibilityHint, testID, }: CardProps) { const scheme = useColorScheme() === 'dark' ? 'dark' : 'light'; const palette = cardTokens[scheme][variant]; const scale = useRef(new Animated.Value(1)).current; const animateTo = useCallback( (to: number, duration: number) => { Animated.timing(scale, { toValue: to, duration, easing: Easing.out(Easing.quad), useNativeDriver: true, }).start(); }, [scale], ); const handlePressIn = useCallback(() => animateTo(0.98, 120), [animateTo]); const handlePressOut = useCallback(() => animateTo(1, 160), [animateTo]); const containerStyle = useMemo<StyleProp<ViewStyle>>(() => { const shadow = resolveShadow(variant, palette.shadow, palette.shadowOpacity); return [ styles.container, { backgroundColor: palette.surface, borderRadius: radius, borderColor: palette.border, borderWidth: variant === 'outlined' ? StyleSheet.hairlineWidth * 2 : 0, padding, opacity: disabled ? 0.5 : 1, transform: [{ scale }], }, shadow, style, ]; }, [variant, palette, radius, padding, disabled, scale, style]); const content = ( <View style={[styles.content, bodyStyle]}>{children}</View> ); const a11y = { accessible: true, accessibilityRole: (onPress ? 'button' : 'none') as any, accessibilityLabel, accessibilityHint, accessibilityState: { disabled }, }; if (!onPress) { return ( <Animated.View style={containerStyle} testID={testID} {...a11y}> {content} </Animated.View> ); } return ( <AnimatedPressable style={containerStyle} onPress={disabled ? undefined : onPress} onPressIn={disabled ? undefined : handlePressIn} onPressOut={disabled ? undefined : handlePressOut} disabled={disabled} testID={testID} {...a11y} > {content} </AnimatedPressable> ); } const Card = memo(CardInner); // 静态子组件 const Title = ({ children, style, ...rest }: any) => ( <Text style={[styles.title, style]} {...rest}>{children}</Text> ); const Description = ({ children, style, ...rest }: any) => ( <Text style={[styles.description, style]} {...rest}>{children}</Text> ); const Body = ({ children, style }: any) => ( <View style={[styles.bodyGap, style]}>{children}</View> ); const Footer = ({ children, style }: any) => ( <View style={[styles.footer, style]}>{children}</View> ); const Row = ({ children, style }: any) => ( <View style={[styles.row, style]}>{children}</View> ); const styles = StyleSheet.create({ container: { overflow: 'hidden', // 兜底描边:阴影不生效时仍能看出边界 borderWidth: StyleSheet.hairlineWidth, borderColor: 'rgba(15,23,42,0.06)', }, content: { flexShrink: 1 }, title: { fontSize: 16, lineHeight: 22, fontWeight: '600', color: '#1B1F24', }, description: { fontSize: 13, lineHeight: 19, color: '#5B6470', marginTop: 4, }, bodyGap: { marginTop: 8 }, footer: { marginTop: 12, flexDirection: 'row', alignItems: 'center' }, row: { flexDirection: 'row', alignItems: 'center' }, }); Card.Title = Title; Card.Description = Description; Card.Body = Body; Card.Footer = Footer; Card.Row = Row; export default Card; export type { CardProps, CardVariant };

上面这段代码里,borderWidth写了两处,一处在外层动态样式里,一处是兜底。这两处会冲突,实际落地时要把兜底那处删掉,只保留根据 variant 判断的动态值,我这里保留是为了说明兜底思路。真正处理描边兜底时,我用的方案是给elevated也加一层极浅的边框,颜色透明度 0.06,几乎看不见,但在阴影不生效的设备上能把卡片边缘勾出来,避免出现“白色卡片贴在白色背景上完全分不清”的情况。

5. 三端差异处理和避坑

5.1 阴影与边框的降级策略

平台阴影手段实测表现兜底方案
iOSshadowColor / Offset / Opacity / Radius正常,边缘柔和无需兜底
安卓elevation部分场景层级叠加被裁剪加浅色描边
鸿蒙elevation部分版本被忽略,退化为无阴影必须加描边 + 底色差
全部borderWidth稳定作为最后防线

这张表是我踩了几轮之后整理的。最关键的一条是:鸿蒙端不要单独依赖 elevation 来表达层级。我在一个版本上看着有阴影,升级适配包之后又没了,这种不稳定最要命。稳定做法是:elevated变体同时给描边和底色差,阴影有则更好、没有也能看,视觉验收标准里不能把阴影列为必须项。

另外,overflow: 'hidden'在某些平台上会裁掉子元素的阴影或者影响圆角裁剪,尤其是卡片里嵌了图片的情况。如果遇到图片直角压住圆角,就在图片容器上单独加borderRadius加overflow: 'hidden',不要指望外层一个 overflow 解决所有问题。

5.2 按压反馈的选择理由

方案鸿蒙可用性可定制性额外开销结论
TouchableOpacity可用低低备选
android_ripple不可用无无排除
Pressable + 自绘动画可用高中采用
自绘手势监听可用最高高过度设计

选 Pressable 的核心原因是它的状态回调最全,onPressIn、onPressOut、onLongPress、onHoverIn都有。在需要同时支持触屏和鼠标悬停的设备形态上,这套回调基本够用。自绘手势监听虽然更自由,但要自己处理触摸冲突、点击取消、滑动取消等一堆边界,投入产出完全不划算。

5.3 无障碍属性映射

这一块经常被忽略,但成本极低、收益很明显。卡片如果是可点的,accessibilityRole一定要设成button,否则读屏软件会把它当普通容器念过去,用户根本不知道能点。

accessibilityLabel的写法有个小技巧:不要只写“卡片”,要写清楚点进去看到什么,比如“订单 20240612,状态已发货,点击查看详情”。这样用户不用进入二级页面就知道里面有什么。accessibilityHint留给操作提示,比如“双击打开”。

还有个容易漏的:禁用态。accessibilityState: { disabled: true }必须传,否则读屏软件仍然会提示可点击,用户点了没反应会产生困惑。这个我在测试阶段被提过两次。

5.4 深色模式令牌

深色模式的坑主要是阴影失效。深色背景上打深色阴影,效果几乎等于零,靠阴影分层的思路直接不成立。所以暗色主题下我改用背景色阶来分层:页面底色最深,卡片稍亮一档,卡片内部的次级区域再亮一档。整个层级感来自明度差,而不是阴影。

实现上用useColorScheme拿系统主题,在cardTokens里做两套映射。需要注意的是取值要控制色差幅度,相邻层级亮度差保持在 6% 到 10% 之间最舒服,差太小看不出、差太大显得突兀。我最初在暗色下把卡片做得比背景亮 20%,看久了眼睛很累,后来调到 8% 左右才合适。

提示:如果业务需要强制某个主题(比如阅读页始终深色),不要改useColorScheme的结果,而是给 Card 加一个themeOverride的上下文注入。改写系统 API 的做法后期一定会出问题。

6. 联调与性能:问题速查

6.1 常见问题速查表

现象可能原因排查思路处理方式
阴影完全不显示该平台不支持该阴影属性逐个平台截图对比改用描边 + 底色差兜底
点击无反应外层被其他视图挡住临时给容器加红色边框看范围检查父级pointerEvents
点击热区偏移缩放动画改写了布局坐标系关掉动画再测动画只用 transform,不碰宽高
圆角被内容压破子元素没有裁剪观察子元素是否超出子元素单独加overflow: 'hidden'
列表滚动掉帧style 内联对象每帧重建打开性能监视全部挪进 StyleSheet
动画卡顿驱动模式不匹配切换useNativeDriver按实测结果固定一档
深色模式色值不更新主题值被缓存到模块作用域改系统主题复测令牌读取放在渲染函数内
长标题撑破高度未限制行数换超长文案复测显式传numberOfLines

这张表里我想重点说“点击热区偏移”那一条。缩放的动画如果驱动的是宽高而不是 transform,会实实在在地改变布局,热区跟着一起变,就会出现“看着按到了但没触发”。这是我踩得最深的坑之一,改了三遍才定位到。原则就是:动画只作用于transform和opacity这类不影响布局的属性。

6.2 三个实测心得

心得一:不要一开始就追求三端完全一致。我最早想把阴影参数做成三端统一,折腾两天之后放弃了。不同平台的渲染管线不一样,强行统一的结果是三端都别扭。正确的心态是“层级语义统一,具体表现各自最优”,用户感知到的是“这张卡片浮在上面”,而不是“这张卡片的阴影偏移是 4”。接受差异,把差异收在 tokens 里,反而更干净。

心得二:动画时长要用真机感受,不要用模拟器判断。我在模拟器上调到 120ms 觉得刚好,真机上明显偏慢,最后定到 100ms。模拟器的帧率跟真机尤其是中低端机差距很大,手感类的调参必须上真机。测的时候我会把duration临时绑到三个按钮上(80、100、140),一个个按过去,凭第一直觉选,通常第一直觉就是对的。

心得三:memo 加了不一定是优化,要看 props 稳不稳定。一开始我加了 memo 但父组件里的onPress是箭头函数,每次渲染都是新引用,memo 完全失效,等于白加还多了一层比较开销。后来统一用useCallback包住,并且约定列表项的renderItem里不要直接传内联对象,才真正生效。判断有没有生效,最简单的办法是在组件里打一行console.log('render'),滚动一下看输出频率。

6.3 后续可以怎么扩展

目前这个 Card 已经覆盖了我手头所有场景,暂时不加新功能。如果后面要扩,我排的优先级是:骨架屏状态(加载时用占位块替换内容)、可折叠内容区、横向滚动卡组。这三个都是“外观层”的扩展,不涉及业务耦合,符合前面定的边界。

有一点我明确不打算加:卡片内的按钮组布局。那属于业务组合,应该在业务页里用Card.Footer自己拼,不该由通用组件承担。这条边界一旦松了,组件就会重新变成一个什么都管的大杂烩,又回到最开始的状态。

我在实际用下来最大的体会是,通用组件的价值不在于功能多,而在于约束稳。一个只有 11 个 props、三种变体、两套主题的卡片,改版式时改一个 token 文件就能全局生效,这个体验比什么花哨功能都重要。踩过几次样式失控的坑之后,我现在做任何 UI 基础组件,第一件事都是先划边界、再写代码,而不是急着堆能力。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进