上周把一套业务页面往 OpenHarmony 上搬,最后卡住我的不是状态管理,也不是路由跳转,而是一张卡片。同一个Card组件,在 iOS 和 Android 上渲染得好好的,到了 OpenHarmony 上,阴影消失了,圆角边缘露出了毛刺,点上去也一点反馈都没有。那会我才意识到,React Native for OpenHarmony(下面统一简称 RNOH)虽然把 React Native 的开发范式带了过来,但底层真正画像素的是另一套渲染引擎,任何依赖“平台默认行为”的组件,都得重新审一遍。
这篇就聊怎么从零写一个能跨端复用的Card 卡片容器组件。它不是把几个 View 套起来那么简单——一个能进业务代码库的 Card,至少要同时解决容器布局、圆角裁剪、阴影降级、点击反馈、主题跟随、无障碍标注这六件事,而每一件在 RNOH 上都可能和 iOS/Android 的表现不一致。内容适合已经会用 React Native 写业务、正准备把组件库往 OpenHarmony 迁移的同学,也适合刚接手跨端组件维护、想知道“为什么要这么写”的读者。整套代码我按可直接落地的形式给出来,中间的取舍和踩过的坑都会讲透。
1. 先想清楚:OpenHarmony 上的 Card 和别处哪里不一样
1.1 从业务需求倒推组件契约
动手写之前我做的第一件事,是把现有项目里所有“看起来像卡片”的地方翻了一遍,统计它们到底在用什么。结果比我预想的收敛:90% 的卡片只有四种形态——纯内容卡片、带标题栏的卡片、带封面图的卡片、可点击跳转的卡片。剩下的 10% 是营销活动页的异形卡片,那种本来就该用自定义布局,不该硬塞进通用组件里。
这个统计直接决定了组件的边界。通用 Card 只解决“容器 + 分区”的问题,具体放什么内容交给调用方。所以我把组件拆成了四个插槽:title/extra构成头部区,cover是顶部整宽内容(通常是图),children是主体,footer是底部操作区。任何一个插槽不传,对应的容器节点就不渲染——这点很重要,空 View 在跨端布局里是会吃掉 margin 的,尤其在 flex 布局下容易产生“多出几像素”的诡异间距。
另一件事是默认样式。我在第一版里给了很多默认值,结果接入方全部在覆盖,组件反而变成了负担。后来改成“克制的默认值 + 显式的变体”:默认只有 12 的圆角、16 的内边距、浅色背景和一个极轻的边框,想要阴影就用variant="elevated",想要纯色块就用variant="filled"。默认值越少,跨端不一致的面积就越小,这个道理在 RNOH 上被放大了好几倍。
1.2 RNOH 渲染链路上必须知道的三个事实
写这个组件之前,我把 RNOH 的渲染链路捋了一遍,有三个事实直接影响了后面所有的实现选择,值得单独说。
第一,样式最终是被翻译成另一套 UI 框架的属性来执行的。React Native 的ViewStyle不是直接操作原生视图的,它经过 JS 侧的样式解析、C++ 侧的桥接,最后落到 OpenHarmony 的声明式 UI 组件上。这意味着任何 RN 样式属性是否有等效实现,取决于适配层做了多少映射。backgroundColor、borderRadius、padding这类基础属性映射得很完整,但elevation这种明显带 Android 基因的属性,在 RNOH 上就没有对应的语义——它不会报错,只是静默失效。这是阴影必须改用shadow*系列的根本原因。
第二,组件的生命周期和原生视图的创建时机不是严格一一对应的。我在调试点击反馈时遇到过:onPressIn触发后立刻改 state 换背景色,视觉上会有一帧的延迟。这不是 RN 本身的问题,而是跨语言调用链路多了几层,状态更新到视图刷新的路径变长了。处理办法是把高频的视觉反馈交给样式状态机(用Pressable的style回调),而不是自己维护 state 再触发渲染。少一次 setState,就少一次跨线程的往返。
第三,图片、文本这类“内容型”组件在 RNOH 上有自己的默认行为。比如文本默认跟随系统字体缩放,图片默认不裁剪。这些默认值在别的平台上也一样,但 RNOH 上系统级的字体缩放档位更粗(常见的几档跨度更大),所以在卡片标题这种固定高度区域里,过大的字体缩放会直接把布局撑破。后面我在 Card 里对标题做了numberOfLines兜底,并允许调用方通过titleNumberOfLines覆盖。
这三条不是文档里会写的“知识点”,但它们解释了为什么后面每一个实现细节都要那样处理。
1.3 目录结构与分层设计
组件的文件组织我按“token 层 / 原子层 / 容器层”三层来分,避免所有逻辑挤在一个文件里:
src/components/Card/ ├── index.ts // 统一导出 ├── Card.tsx // 组件主体 ├── tokens.ts // 圆角、间距、阴影、色板 ├── styles.ts // StyleSheet 定义 └── types.ts // 对外类型这么分层的好处是,主题相关的魔法数字只出现在 tokens.ts 里。后来做深浅色适配的时候,我只改了一个文件,Card.tsx一行没动。如果一开始就把borderRadius: 12写死在样式里,改造时就得全局搜索替换,还容易漏掉。
index.ts里我把Card和CardProps都导出去,同时在包里做了一个小的CardSection子组件——不是每个卡片都需要分区,但一旦需要,让调用方自己写<View style={{marginTop: 12}}>拼出来的间距,迟早会不统一。
2. Card 的 API 设计:把“能改”和“不该改”分开
2.1 属性清单与默认值
属性设计上我踩过一个典型的坑:第一版把style直接透传给了最内层的内容容器,结果调用方传backgroundColor进去,背景色被内层盖住了外层圆角,出现了“方形色块 + 圆角边框”的错位。后来我把style明确约定为作用于最外层容器,内层的样式则通过bodyStyle/headerStyle/footerStyle单独开放。约定清楚比功能多更重要。
下面是最终定下来的属性表,每一项都写清楚了为什么存在:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
title | ReactNode | 无 | 头部主标题,传字符串时自动套用标题样式 |
extra | ReactNode | 无 | 头部右侧操作区,仅在存在title时渲染 |
cover | ReactNode | 无 | 顶部整宽内容,自动被圆角裁剪 |
footer | ReactNode | 无 | 底部区,自带一条分隔线 |
children | ReactNode | 无 | 主体内容 |
variant | 'outlined' | 'elevated' | 'filled' | 'outlined' | 决定边框、阴影、背景的组合 |
size | 'sm' | 'md' | 'lg' | 'md' | 控制内边距与圆角档位 |
radius | number | 由size推导 | 显式覆盖圆角 |
padding | number | 由size推导 | 显式覆盖内边距 |
pressable | boolean | 自动判断 | 是否启用点击态 |
onPress | function | 无 | 点击回调,存在时自动开启pressable |
onLongPress | function | 无 | 长按回调 |
disabled | boolean | false | 禁用态,降低不透明度并屏蔽回调 |
style | ViewStyle | 无 | 作用于最外层容器 |
bodyStyle | ViewStyle | 无 | 作用于内容区 |
titleNumberOfLines | number | 1 | 标题截断行数 |
accessibilityLabel | string | 无 | 无障碍朗读文本,未传时回退到字符串标题 |
testID | string | 无 | 测试标识,跨端自动化都靠它 |
pressable的“自动判断”是一个可以省掉大量重复代码的小设计:只要传了onPress或onLongPress,组件内部就自动切换到可点击包装,调用方不需要写两遍pressable={true}。反过来,如果显式传pressable={false}但又有onPress,则以显式值优先——这种冲突场景虽然少见,但关系到组件行为的确定性,不能含糊。
2.2 用 token 统一圆角、间距、阴影
size这层抽象的意义在于收敛视觉节奏。我见过太多项目,卡片圆角有 8 的、10 的、12 的、16 的,最后视觉走查时被一次打回。用三档sm/md/lg强制统一,短期看是限制,长期看是省事。
// tokens.ts export const radiusTokens = { sm: 8, md: 12, lg: 16 } as const; export const paddingTokens = { sm: 12, md: 16, lg: 20 } as const; export const shadowTokens = { none: {}, sm: { shadowColor: '#000000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.08, shadowRadius: 2, }, md: { shadowColor: '#000000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.12, shadowRadius: 6, }, lg: { shadowColor: '#000000', shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.16, shadowRadius: 16, }, } as const;这里有个细节值得说:shadowRadius的取值和shadowOffset.height是配套的。半径越大,偏移要跟着加大,否则阴影会“压在”卡片底部形成一条脏边。我按 1/2、2/6、6/16 的比例来配,是从 Material 的 elevation 曲线里借的思路——高度越高,光源方向越偏上,投影越长越淡。
as const不是装饰,它让 TypeScript 把radiusTokens.md推导成字面量12而不是number,后面做映射类型的时候能少写一堆keyof typeof。
2.3 variant 与可点击态的设计逻辑
三个变体对应三种信息层级,这个映射关系必须在组件层面固化下来,否则每个调用方都会自己拍脑袋:
outlined(默认):浅色背景 + 1px 边框,无阴影。用于信息密度高的列表流,卡片之间靠边框区分,视觉噪声最小。这是唯一一个在 RNOH 上完全不会有降级风险的变体。elevated:阴影为主,边框极淡或不加。用于需要“浮起来”的场景,比如弹层里的卡片、需要强调的推荐位。这个变体在 RNOH 上要做降级处理,后面详细讲。filled:无边框无阴影,靠背景色差区分。用于弱分组,比如设置页里的分组容器。它在深浅色模式下依赖背景色 token 的对比度,需要单独校验。
可点击态的设计我改了三版。第一版用的是TouchableOpacity,整体透明度变化,在深色背景上几乎看不出反馈。第二版换成Pressable+android_ripple,结果 RNOH 上波纹完全没出来——这个属性是 Android 原生特性的透传,适配层没做映射。第三版才落到最终方案:Pressable的style回调 + 自绘覆盖层。
<Pressable onPress={handlePress} onLongPress={onLongPress} disabled={disabled} style={({ pressed }) => [ styles.pressable, pressed && { backgroundColor: pressOverlayColor }, ]} >style回调的好处是整个过程不经过组件的 state,pressed的变化由框架内部的轻量状态驱动,避免了我前面提到的那一帧延迟。代价是覆盖色必须用一个独立的层来实现,不能直接改卡片背景——因为卡片背景可能来自variant,直接覆盖会丢失语义。所以我用了一个绝对定位的覆盖层,只在pressed为真时渲染,并且设置pointerEvents="none"防止它拦截手势。
3. 三个最容易翻车的实现细节
3.1 阴影:认准 shadow 系列,别碰 elevation
前面提过elevation在 RNOH 上静默失效。更麻烦的是它不会报错也不会告警,只是不显示,所以如果你只在 OpenHarmony 上跑,很容易以为是主题色的问题,排查半天。
正确的做法是用shadowColor/shadowOffset/shadowOpacity/shadowRadius这四件套,RNOH 对它们的映射是完整的。但还有一个隐藏问题:这组属性在不同平台上的坐标系和模糊算法不完全一致,同样的参数在 iOS 上偏柔和,在 OpenHarmony 上会显得更“硬”一些。我的处理是给 OpenHarmony 单独留一个 0.7 的透明度系数:
const shadowScale = Platform.OS === 'openharmony' ? 0.7 : 1; const shadowStyle = { ...baseShadow, shadowOpacity: baseShadow.shadowOpacity * shadowScale, };Platform.OS在 RNOH 上返回的正是'openharmony',这个值可以在运行时打日志确认,不同版本的适配层可能有细微差异,上线前务必实机验证一遍。
还有一个必须处理的问题:深色模式下阴影几乎不可见。黑色阴影压在深灰背景上,视觉上就是没有。所以elevated变体在深色模式下我做了双保险——保留阴影的同时补一条极淡的边框,用边框承担“轮廓”职责,阴影只是氛围。这也是为什么variant不能简单等同于一个固定的样式对象,它需要在主题切换时重新计算。
注意:
shadow*系列属性在 RNOH 上对borderRadius有依赖,圆角为 0 时阴影是矩形的,这没问题;但如果你给外层容器设了overflow: 'hidden',阴影会被一起裁掉。这是后面 3.2 要解决的核心矛盾。
3.2 圆角裁剪:内外两层容器是唯一解
“圆角 + 内部内容被裁剪”这个需求,在 React Native 上的标准做法是给容器设borderRadius和overflow: 'hidden'。但在 RNOH 上,一旦这个容器同时带阴影,你立刻会陷入两难:
- 设了
overflow: 'hidden',阴影被裁,卡片看起来是平的; - 不设
overflow: 'hidden',内部图片的直角会戳出圆角,出现四个尖角。
我试过三种绕法。第一种是用borderRadius直接作用在Image上,这个在 RNOH 上对单张图有效,但cover插槽里放的可能是任意节点(渐变、视频封面、组合布局),管不住。第二种是给图片加resizeMode配合overflow的子容器,本质上还是绕不开上面那个矛盾。第三种才是最终方案:拆成外层和内层两个容器。
外层只负责三件事:位置、尺寸、阴影。它不能有overflow: 'hidden',但需要设borderRadius——因为阴影的形状是由外层的圆角决定的,不设的话阴影是方角,会从卡片圆角外面透出来。
内层负责背景色、边框、圆角裁剪。它设borderRadius和overflow: 'hidden',把内部内容兜住。
<View style={[styles.shadowLayer, { borderRadius: r }, shadowStyle, style]}> <View style={[styles.clipLayer, { borderRadius: r, backgroundColor: bg, borderWidth }]}> {/* 内容 */} </View> </View>这两层的borderRadius必须完全一致,差 1px 就会出现内层圆角比外层小、边缘露出一条背景色缝的情况。所以我把半径统一从一个变量r算出来,绝不写两遍。
实测下来这套结构在 RNOH 上很稳,唯一的代价是多了一层 View。在长列表里这一层会不会有性能影响?我用 200 个卡片的列表压过,滚动帧率没有可测量的差异。真正影响滚动性能的是每个卡片里重复创建的 StyleSheet 对象和匿名函数,那是第 5 章要说的。
3.3 点击反馈的降级策略
Pressable在 RNOH 上是可用的,基础的按下、抬起、长按事件都能正常触发。但有三个坑要提前知道。
坑一是android_ripple无效,前面说过了,不再展开。坑二是hitSlop的行为边界,我在卡片上给了一个负向外扩的hitSlop来扩大点击区域,结果在 RNOH 上外扩区域会和相邻卡片的点击区域重叠,触发顺序取决于视图树的层级而不是坐标。稳妥的做法是只在卡片内部使用hitSlop的正向扩展,也就是向内收缩可点击区,而不是向外。
坑三是禁用态的语义。Pressable的disabled只屏蔽事件,不改变视觉。如果调用方忘了处理,用户点一个禁用卡片毫无反馈,会以为卡死了。所以我在组件内部做了兜底:
const interactive = (!!onPress || !!onLongPress) && !disabled; const opacity = disabled ? 0.5 : 1;同时给禁用态加上accessibilityState={{ disabled: true }},读屏软件能正确播报。这个属性在 RNOH 的无障碍通道里是生效的,我在实机上用系统读屏验证过。
另外,长按和点击共存的场景要处理事件冲突。标准做法是靠Pressable内部的时间阈值区分,但 RNOH 上长按的默认阈值和 iOS 不完全一致。如果你的业务对长按时长敏感(比如 400ms 就触发),别指望默认值,显式传delayLongPress。
3.4 主题跟随:别只信 useColorScheme
深浅色适配看起来简单,实际上在 RNOH 上有一个特性要注意:useColorScheme()返回的是系统级设置,而某些应用内还提供“跟随系统 / 强制浅色 / 强制深色”的开关。如果组件直接读useColorScheme(),应用内的强制模式就对它无效了。
我的做法是组件内部优先读一个可选的 Context,没读到才回退到系统值:
const ThemeContext = React.createContext<'light' | 'dark' | null>(null); function useCardScheme() { const ctx = React.useContext(ThemeContext); const sys = useColorScheme(); return ctx ?? (sys === 'dark' ? 'dark' : 'light'); }Context 默认值是null而不是'light',这个细节很重要——如果默认给'light',你就没法区分“上层明确指定浅色”和“上层没指定”,回退逻辑会失效。
色板我用的是语义化命名(surface/border/text/textSecondary),而不是gray100这种色阶命名。原因是语义名在深浅色切换时天然成对,而色阶名会让你在写深色版本时不断思考“这个场景该用哪一档”。下面是实际用的两套值:
export const palette = { light: { surface: '#FFFFFF', surfaceAlt: '#F5F6F8', border: '#E6E8EB', text: '#1A1C1E', textSecondary: '#6B7280', pressedOverlay: 'rgba(0, 0, 0, 0.04)', }, dark: { surface: '#1E1F22', surfaceAlt: '#26282B', border: '#34363A', text: '#F2F3F5', textSecondary: '#9AA0A6', pressedOverlay: 'rgba(255, 255, 255, 0.06)', }, };深色下的按压覆盖色必须是白色系的低透明度,而不是黑色。用黑色在深色背景上不但看不出变化,还容易在圆角边缘出现脏边。这个细节我在两个项目里都栽过。
4. 完整实现:从 token 到组件源码
4.1 类型定义与样式分离
先把对外类型定死,写清楚哪些是必填、哪些是联合类型,让调用方在编辑器里就能看到约束:
// types.ts import type { ReactNode } from 'react'; import type { StyleProp, ViewStyle } from 'react-native'; export type CardVariant = 'outlined' | 'elevated' | 'filled'; export type CardSize = 'sm' | 'md' | 'lg'; export interface CardProps { title?: ReactNode; extra?: ReactNode; cover?: ReactNode; footer?: ReactNode; children?: ReactNode; variant?: CardVariant; size?: CardSize; radius?: number; padding?: number; pressable?: boolean; onPress?: () => void; onLongPress?: () => void; delayLongPress?: number; disabled?: boolean; titleNumberOfLines?: number; accessibilityLabel?: string; testID?: string; style?: StyleProp<ViewStyle>; headerStyle?: StyleProp<ViewStyle>; bodyStyle?: StyleProp<ViewStyle>; footerStyle?: StyleProp<ViewStyle>; }StyleProp<ViewStyle>而不是ViewStyle,是为了让调用方可以传数组和条件样式([a, b && c]),这是 RN 生态里的通行约定,别在这上面标新立异。
4.2 组件主体实现
下面是完整实现,我加了必要注释,重点看shadowStyle和interactive两处:
// Card.tsx import React, { useCallback, useMemo } from 'react'; import { Platform, Pressable, StyleSheet, Text, View, useColorScheme, } from 'react-native'; import { palette, radiusTokens, paddingTokens, shadowTokens } from './tokens'; import type { CardProps } from './types'; const Card: React.FC<CardProps> = ({ title, extra, cover, footer, children, variant = 'outlined', size = 'md', radius, padding, pressable, onPress, onLongPress, delayLongPress = 500, disabled = false, titleNumberOfLines = 1, accessibilityLabel, testID, style, headerStyle, bodyStyle, footerStyle, }) => { const scheme = useColorScheme() === 'dark' ? 'dark' : 'light'; const colors = palette[scheme]; const r = radius ?? radiusTokens[size]; const p = padding ?? paddingTokens[size]; const interactive = pressable ?? (!!onPress || !!onLongPress); const enabled = interactive && !disabled; const shadowStyle = useMemo(() => { if (variant !== 'elevated') return null; const base = shadowTokens.md; const scale = Platform.OS === 'openharmony' ? 0.7 : 1; return { ...base, shadowOpacity: base.shadowOpacity * scale, } as const; }, [variant]); const backgroundColor = variant === 'filled' ? colors.surfaceAlt : colors.surface; const borderVisible = variant === 'outlined' || variant === 'elevated'; const handlePress = useCallback(() => { if (!enabled) return; onPress?.(); }, [enabled, onPress]); const handleLongPress = useCallback(() => { if (!enabled) return; onLongPress?.(); }, [enabled, onLongPress]); const body = ( <View style={[styles.clip, { borderRadius: r, backgroundColor }]}> {borderVisible ? ( <View pointerEvents="none" style={[ StyleSheet.absoluteFill, { borderRadius: r, borderWidth: StyleSheet.hairlineWidth * 2, borderColor: colors.border, }, ]} /> ) : null} {cover ? <View style={styles.cover}>{cover}</View> : null} {title || extra ? ( <View style={[styles.header, { paddingHorizontal: p, paddingTop: p }, headerStyle]}> <View style={styles.headerMain}> {typeof title === 'string' ? ( <Text numberOfLines={titleNumberOfLines} style={[styles.title, { color: colors.text }]} > {title} </Text> ) : ( title )} </View> {extra ? <View style={styles.headerExtra}>{extra}</View> : null} </View> ) : null} {children ? ( <View style={[ styles.body, { paddingHorizontal: p, paddingTop: title || extra ? p * 0.5 : p, paddingBottom: footer ? p * 0.5 : p, }, bodyStyle, ]} > {children} </View> ) : null} {footer ? ( <View style={[ styles.footer, { paddingHorizontal: p, paddingVertical: p * 0.75, borderTopColor: colors.border, }, footerStyle, ]} > {footer} </View> ) : null} </View> ); const content = enabled ? ( <Pressable testID={testID} onPress={handlePress} onLongPress={onLongPress ? handleLongPress : undefined} delayLongPress={delayLongPress} disabled={!enabled} accessibilityRole="button" accessibilityLabel={ accessibilityLabel ?? (typeof title === 'string' ? title : undefined) } accessibilityState={{ disabled }} style={({ pressed }) => [ styles.shadowLayer, { borderRadius: r }, shadowStyle, pressed && { backgroundColor: colors.pressedOverlay }, ]} > {body} </Pressable> ) : ( <View testID={testID} accessibilityLabel={ accessibilityLabel ?? (typeof title === 'string' ? title : undefined) } style={[styles.shadowLayer, { borderRadius: r }, shadowStyle]} > {body} </View> ); return ( <View style={[styles.outer, disabled && styles.disabled, style]}> {content} </View> ); }; const styles = StyleSheet.create({ outer: { width: '100%' }, disabled: { opacity: 0.5 }, shadowLayer: { backgroundColor: 'transparent' }, clip: { overflow: 'hidden' }, cover: { width: '100%' }, header: { flexDirection: 'row', alignItems: 'center' }, headerMain: { flex: 1 }, headerExtra: { marginLeft: 8 }, title: { fontSize: 16, fontWeight: '600', lineHeight: 22 }, body: {}, footer: { borderTopWidth: StyleSheet.hairlineWidth * 2 }, }); export default React.memo(Card);4.3 用备忘录把“嵌套重渲染”挡在门外
React.memo放在导出位置而不是定义位置,是有讲究的。如果写成const Card = React.memo((props) => ...),组件在 DevTools 里会显示成Memo(Card),堆栈里不好定位;先定义再包一层,组件名保留,同时拿到了浅比较的性能收益。
但memo只能挡住 props 引用没变的场景。如果调用方每次渲染都传一个新的style={{ marginTop: 12 }},浅比较照样失败。所以我在文档里明确要求:样式对象必须在组件外部用StyleSheet.create定义或用useMemo缓存。这条约束看着啰嗦,但在长列表里能省下大量无意义的 diff。
还有一个容易被忽略的点:children是 ReactNode,每次渲染必然是新的引用。所以在列表项里用 Card 时,memo基本不起作用,真正的性能收益来自FlatList的keyExtractor和getItemLayout。我把这层关系在文档里写清楚了,免得有人以为加了memo就万事大吉。
4.4 调用示例:三种典型用法
最简单的内容卡片,只需要给children:
<Card> <Text>这里放任意内容,卡片会自动处理内边距和圆角裁剪。</Text> </Card>带标题、额外操作和底部分隔的完整形态:
<Card title="本月用量" extra={<Text style={{ color: '#6B7280' }}>查看详情</Text>} variant="elevated" footer={ <Text style={{ fontSize: 12, color: '#6B7280' }}> 数据每小时更新一次 </Text> } > <Text style={{ fontSize: 24, fontWeight: '700' }}>12.4 GB</Text> </Card>带封面图的可点击卡片,注意图片高度要显式给出,别指望自适应:
<Card cover={ <Image source={{ uri: coverUrl }} style={{ width: '100%', height: 160 }} resizeMode="cover" /> } title="封面卡片" onPress={() => navigate('detail')} size="lg" />这里有个细节:cover里的图片高度必须写死或用宽高比算。RNOH 上图片的固有尺寸获取时机和 iOS 不完全一致,如果只写width: '100%'不给高度,布局阶段会先按 0 高度占位,图片加载完再撑开,产生一次明显跳动。显式给高度是最省事的解法。
5. 跨端一致性与性能的实测方法
5.1 视觉走查怎么做才有效
跨端组件最怕的不是“明显坏掉”,而是“看起来差不多但差一点”。我的做法是准备一个固定的走查页,把三种variant× 三种size× 深浅两套主题,一共 18 种组合全排在一屏里,然后在各端截图,用同一张截图工具做叠加对比。
对比时重点看四个位置:圆角边缘有没有锯齿或毛刺、阴影的扩散范围和柔和度、边框在圆角处有没有断线、文字基线在头部的垂直居中。前三个是渲染层差异,第四个是布局层差异,排查思路完全不同。
圆角毛刺通常是内层overflow: 'hidden'和外层圆角半径不严格相等导致的,把两个半径统一成一个变量基本能解决。边框断线多见于边框画在带overflow: 'hidden'的容器上,改用绝对定位的覆盖层来画边框就稳了——这正是我在 4.2 里把边框单独抽成一个absoluteFill层的原因。至于文字基线,lineHeight必须给,不给的话各端默认行高算法不一样,alignItems: 'center'居中的结果就会差 1 到 2 像素。
5.2 性能压在长列表里才看得出来
单个卡片的性能无所谓,问题都出在列表里。我的压测方式是用一个渲染 200 项、每项都带阴影和封面的列表,滚动 30 秒,记录丢帧情况。
第一次跑的时候掉帧明显,排查下来是两个原因:一是阴影在 RNOH 上是比较贵的绘制,200 个卡片同时带阴影,滚动时每帧都要重新合成;二是封面图没有缓存,滚动反复解码。
对应两个处理。阴影方面,我给 Card 加了一个elevation语义上的替代方案——滚动中降级。做法是列表容器监听onScrollBeginDrag和onMomentumScrollEnd,滚动期间把variant从上层的 Context 传下去,Card 内部读到就把阴影换成边框。滚动停止再恢复。这个改动把丢帧率压下去了,代价是用户滚动时会看到阴影一闪,所以只适合信息流这种快速滚动的场景,不适合静态页面。
封面图方面,统一的Image缓存策略比组件本身更重要。我在文档里给出的建议是:封面图尺寸不要超过卡片宽度的 2 倍像素密度,超出部分纯属浪费内存。
5.3 测试用例该覆盖什么
单元测试我写了三组。第一组是渲染分支:只传children、传title但没extra、传footer但没children,各跑一遍快照,确认没有多渲染空节点。第二组是交互:传onPress后触发点击回调、disabled时点击不触发、长按触发onLongPress。第三组是无障碍属性:accessibilityRole、accessibilityLabel的回退逻辑是否正确。
it('未传 accessibilityLabel 时回退到字符串标题', () => { const { getByTestId } = render(<Card title="订单详情" testID="card" />); expect(getByTestId('card').props.accessibilityLabel).toBe('订单详情'); });快照测试在跨端项目里要克制。同一个组件在不同平台上的快照可能不同(比如Platform.OS影响了阴影透明度),如果每条用例都拍快照,平台相关的用例会全部失败。我的做法是只对结构拍快照,把平台相关的样式断言写成显式判断,比如断言shadowOpacity的数值是否等于基础值乘以 0.7。
6. 踩坑记录与速查表
6.1 常见问题对照
这张表是半年里真实遇到过的问题汇总,按现象索引:
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 阴影完全不显示 | 用了elevation,或外层被overflow: hidden裁掉 | 改用shadow*四件套;拆内外两层容器 |
| 卡片四角露出内容直角 | 内层容器没设overflow: 'hidden' | 内层补上,并确认与外层半径一致 |
| 圆角边缘有毛刺或锯齿 | 内外两层半径差 1px 以上 | 半径从一个变量算,不手写两次 |
| 点击无任何视觉反馈 | 依赖了android_ripple | 改用Pressable的style回调 + 覆盖色 |
| 按下后反馈慢半拍 | 用 setState 驱动视觉状态 | 视觉状态交给style回调,不经过 state |
| 深色下卡片“糊”在一起 | 阴影在深底上不可见 | 保留阴影同时补极淡边框 |
| 长按不触发或提前触发 | 各端默认阈值不同 | 显式传delayLongPress |
| 标题被系统字体放大撑破 | 未限制行数 | 设numberOfLines并允许外部覆盖 |
| 封面图加载时布局跳动 | 图片高度未定 | 显式给 height 或用宽高比计算 |
| 相邻卡片点击区域互相抢占 | hitSlop向外扩展过多 | 只做向内收缩,不做向外扩张 |
| 禁用卡片看起来还能点 | disabled只屏蔽事件不改视觉 | 组件内部兜底降透明度 + 无障碍状态 |
6.2 几条不会写在文档里的经验
第一,StyleSheet.hairlineWidth在 RNOH 上要乘 2。我按 iOS 的习惯用hairlineWidth画 1 物理像素的边框,结果在 OpenHarmony 的高密度屏上细到几乎看不见。后来发现不同适配层对这个值的换算基准不一致,稳妥的做法是用hairlineWidth * 2作为“细边框”的基准,视觉上接近 1dp。
第二,绝对定位的边框层一定要加pointerEvents="none"。我漏过一次,结果边框层把整张卡片的点击都吃掉了,而且因为它是透明的,排查了快半小时才发现问题。任何纯装饰的覆盖层,这个属性都应该当成默认动作。
第三,不要在 Card 内部做数据请求或者读取全局状态。这条听起来是常识,但我在早期版本里为了让extra区域能自动显示未读数,在组件里接了一个 store 的 hook。结果这个 Card 在长列表里变成了几百个订阅者,任何一个未读数变化都会触发全列表重渲染。容器组件的职责就该是容器,数据从外面传进来。
提示:如果你要把这套 Card 复用到更多场景,别急着加属性。先看看能不能通过组合
children解决。一个每两周就要加一个新 prop 的通用组件,通常说明它的抽象层级没找准——要么太底层,要么承担了不属于它的业务逻辑。
第四,testID一定要透传到最外层可交互节点上。自动化测试和 UI 走查工具都靠它定位。我一开始把testID放在最外层的View上,结果点击类用例全部失败,因为它只是容器,不接收手势。后来统一约定:有交互时放在Pressable上,无交互时放在最外层的View上。
第五,深浅色切换要测“运行中切换”,不只是启动时。启动时读一次主题是最容易通过的路径,真正出问题的是应用运行中用户在系统设置里切了深色模式。这时候如果有组件缓存了色值到useRef或者模块级变量里,就不会跟着变。我在 Card 里所有色值都是每次渲染从palette[scheme]里取,不做任何跨渲染缓存,看起来很“浪费”,但这才是最稳的写法。useMemo的依赖里一定要带上scheme或colors,漏了就会出现“切了主题但阴影透明度还是旧值”这种隐蔽问题。
这套组件现在跑在三个平台的业务里,改动的频率已经降到几个月一次。我个人的体会是,跨端组件真正的成本不在写第一版,而在第一版之后每一次“这里好像有点不对”的排查。把每个平台差异背后的原因搞清楚,写进注释和速查表里,比多写几个功能属性有价值得多。后面如果还要扩展,我倾向于往“组合”方向走——比如拆出一个CardMedia和CardActions的子组件,让调用方自己拼装,而不是往Card身上继续堆 props。