写RNOH项目的时候,我最怕看到的就是网络请求还没回来,页面先白屏几秒钟。尤其是列表页和详情页,数据一到手就是一片空白,用户还以为App卡死了。后来我习惯性地把以前在普通React Native里用的Skeleton骨架屏方案搬到了RN for OpenHarmony(后面都叫RNOH)项目里,效果立竿见影。这篇文章就把我在RNOH下实现Skeleton骨架屏的完整过程拆开讲一遍,包括组件设计、动画实现、页面接入和真机调试时踩过的坑,适合正在做OpenHarmony应用、又在用React Native技术栈的团队参考。
1. 项目概述与核心需求拆解
1.1 为什么要在RNOH项目里做Skeleton骨架屏
骨架屏不是普通的Loading转圈,也不是简单的占位图片,而是一种在数据到达前,用灰色块模拟页面最终布局的加载反馈。用户一进入页面,看到的是头像框、标题条、内容行的轮廓,视觉上会觉得“页面已经渲染好了,只是内容还在读取”,这种心理体验比转圈动画好很多。
在RNOH项目里,这个需求更加明显。OpenHarmony设备形态很多,有rk3568、rk3588这些开发板,也有手机和智能平板,性能差异很大。低性能设备上JS加载、网络请求、原生组件挂载都更慢,白屏时间被拉长。如果不用骨架屏,用户只能干等,还容易误触点击。
我最初的需求很简单:列表页请求数据时要显示6到8个模拟卡片,详情页要显示顶图、标题、文本行的骨架;等数据回来,骨架自动消失,真实内容替换上去。这个需求听起来不复杂,但落到RNOH环境里,要考虑的事情比普通RN项目多一点。
1.2 骨架屏和Loading、Placeholder的区别
团队里经常有人把骨架屏和Loading混为一谈,其实两者解决的问题完全不同。
Loading的核心语义是“正在加载中”,它不关心最终页面长什么样。转圈、进度条、菊花都是通用反馈,放在任何页面都能用。缺点是用户对即将出现的内容没有任何预期,在网络慢的时候容易焦虑。
Placeholder是静态占位,比如图片加载失败时显示的灰色图标、文字提示。它强调的是“当前区域没有内容”的状态,并不承载“加载完成后将变成什么”的暗示。
Skeleton介于两者之间:它用与真实布局高度相似的块状结构,提前把页面框架画给用户看。它既有Loading的过程性反馈,又比静态占位更接近最终结果。我个人的判断标准是:凡是页面结构相对固定的地方,优先用骨架屏;结构完全不可预期的,才用通用Loading兜底。
表格对比一下:
| 类型 | 视觉反馈 | 是否模拟真实布局 | 适用场景 | 实现成本 |
|---|---|---|---|---|
| Loading转圈 | 周期性往复动画 | 否 | 全屏请求、操作等待 | 最低 |
| Placeholder | 静态图标/文案 | 否 | 图片失败、空态 | 低 |
| Skeleton | 闪烁/呼吸的灰色块 | 是 | 列表、详情、卡片 | 中高 |
在RNOH这种框架支援还不算特别丰富的环境里,Skeleton是完全用原生RN组件和Animated就能实现的,不需要额外依赖,这一点非常关键。
1.3 适用场景与预期收益
我实际落地时选了三个场景做第一版:
列表页是最典型的场景。进入页面就请求第一页数据,在数据到达前,用SkeletonList渲染6个卡片,每个卡片包含一行头像加两行文本。数据到位后替换成真实列表项。
详情页不是整页骨架,而是只给内容区做骨架。页面顶部的操作栏是固定不变的,内容区用SkeletonText模拟几行文本,避免整页闪烁。
图片墙场景更简单,每个格子是一个圆角矩形骨架,等图片加载完成后渐显替换。这个场景最接近“渐进式图片”方案,但实现上比原生Web容易。
收益方面,最直观的变化是界面不再白屏。低性能开发板上,冷启动进入页面时,骨架屏能在一秒内渲染出来,给用户持续的“页面已经准备好”的暗示。第二个收益是调试方便,骨架屏本身是纯View布局,复用了相同的样式结构,很多布局问题在骨架阶段就能提前暴露。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术方案选型与实现原理
2.1 常见的骨架屏实现思路
在React Native生态里,骨架屏大致有四种实现方式:
第一种是用WebView加载一个HTML骨架页。这种方案在原生RN里见过,但放在RNOH里很别扭。RNOH的WebView组件能力本身就是通过原生模块桥接的,多一层WebView就多一层性能开销,还增加包体积,我直接pass掉了。
第二种是直接用View和StyleSheet搭建骨架块。这是最朴素也最可控的方案,用灰色背景的View模拟头像、文本、图片区域,再叠加一层透明度动画模拟呼吸感。RNOH对View、StyleSheet、Animated这些基础组件的支持已经相当完善,所以这种方案属于“开箱即用”。
第三种是用SVG绘制骨架块。SVG能画任意形状,圆角、弧线都能精确控制,但RNOH环境里SVG依赖需要额外引入库,而且动画性能不一定比得上原生View,性价比低。
第四种是渐进式图片占位,用base64小图或模糊缩略图做底图,图片加载完成后再替换。这个通常只用于图片类组件,不能覆盖列表和文本区场景,所以只能作为补充。
我最终选择了第二种:纯React Native组件实现,不引入任何第三方依赖。理由很简单:RNOH的第三方组件生态还在持续完善,很多在原生RN里跑得很好的库未必能直接编译到OpenHarmony上。骨架屏本质上不需要特殊能力,用官方组件就够了,何必冒依赖不兼容的风险。
2.2 RNOH环境下的方案取舍
选型时有个容易被忽略的点:RNOH目前对React Native的核心组件支持已经比较齐全,比如View、Text、Image、ScrollView、TextInput、TouchableOpacity、Animated都可以正常使用。但不是所有原生RN组件都完成了100%对齐,比如某些Android/iOS特有的属性可能暂时不在OHOS原生层实现,或者表现不一致。
所以骨架屏的实现要尽量只用“跨端通用属性”。我给自己定了三条规则:
第一,不用platform专用样式。比如Android的elevation、iOS的shadowColor等,这些在RNOH上可能没有对应实现。骨架屏里的阴影效果直接用borderWidth加borderColor模拟,或者干脆不做阴影。
第二,动画只依赖Animated,不要用LayoutAnimation。LayoutAnimation在RNOH上的支持还不够稳定,尤其批量更新布局时会有闪烁。骨架屏的闪烁动画只需要opacity变化,Animated.loop循环就够用了。
第三,布局测量优先用flex和百分比。RNOH的onLayout回调机制与原生RN基本一致,但某些低端设备上回调时序可能不稳定,所以骨架屏里能用flex布局解决的位置就不依赖绝对定位测量。
这套规则后来帮我避开了很多兼容问题。比如在普通RN里很常用的“定位paddingTop撑起骨架高度”的做法,在RNOH上容易在软键盘弹起或旋转时错位,不如直接用固定高度加flex排列。
2.3 动画原理:Animated.loop与opacity
骨架屏的动效一般有两种:呼吸渐变和滑动扫光。滑动扫光需要位移加裁剪,实现复杂,在低端OpenHarmony设备上容易掉帧。我选择了更稳妥的呼吸渐变:让骨架块的透明度在0.3到0.8之间往复变化,形成类似“亮起来又暗下去”的呼吸感。
原理其实就一句话:Animated.loop可以循环执行一个Animated.sequence,Animated.sequence里再串一个Animated.timing。核心代码如下:
jsx复制const pulseAnim = useRef(new Animated.Value(0.4)).current;
useEffect(() => {
const loop = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 0.8,
duration: 700,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 0.4,
duration: 700,
useNativeDriver: true,
}),
])
);
loop.start();
return () => loop.stop();
}, [pulseAnim]);
这里有一个和普通RN不太一样的地方:useNativeDriver在RNOH上的实现已经支持opacity和transform原生驱动。在低端设备上,原生驱动动画基本不占用JS线程,对列表滚动性能影响很小。如果发现动画不生效,可以改成false试一下,但会占用JS线程,性能会差一些。
3. 实操:从零写一个可复用的Skeleton组件
3.1 项目准备与目录约定
动手之前,先确认RNOH项目能正常跑起来。我习惯在OpenHarmony开发板上用hdc连接设备做真机调试,也经常用模拟器快速验证。查看系统版本和产品名是第一步,命令很有用:
bash复制hdc shell param get const.product.name
hdc shell param get const.ohos.fullname
这两个命令可以确认设备的系统版本和产品型号,因为RNOH的不同版本对API Level有要求,版本不匹配会出现组件渲染异常或者编译失败。
项目结构上,我建议把Skeleton放到独立目录里,不要和业务页面混在一起。我一般这样组织:
text复制src/components/Skeleton/
index.tsx
SkeletonList.tsx
SkeletonText.tsx
SkeletonBox.tsx
styles.ts
types.ts
目录按组件拆分,方便业务页面只引入需要的子组件。SkeletonBox是最底层的灰色块,SkeletonList和SkeletonText是上层组合。
3.2 基础骨架单元:SkeletonBox
SkeletonBox是整个骨架屏的最小单元,一个圆角灰色View。它接收width、height、borderRadius、style等参数,默认使用一个统一的灰底色,方便主题管理。代码如下:
tsx复制import React from 'react';
import { View, StyleSheet, ViewStyle } from 'react-native';
interface SkeletonBoxProps {
width?: number | `${number}%`;
height?: number;
borderRadius?: number;
style?: ViewStyle;
}
const SkeletonBox: React.FC<SkeletonBoxProps> = ({
width = '100%',
height = 20,
borderRadius = 6,
style,
}) => {
return (
<View
style={[
styles.base,
{ width, height, borderRadius },
style,
]}
/>
);
};
const styles = StyleSheet.create({
base: {
backgroundColor: '#E8E8E8',
},
});
export default SkeletonBox;
这里有几个细节值得注意。
宽高参数支持数字和百分比。布局时经常需要“宽度等于父容器一定比例”的骨架,比如头像用40px固定,文本行用100%宽度,短标题用80%宽度。我默认是String类型时走百分比,是Number类型时走dp,满足绝大多数场景。
backgroundColor不要写死在文件里,建议通过props传入或者放到主题变量里,后面做深色模式会方便很多。我一开始图省事直接写死,后面适配深色模式时改了一堆文件,教训很深刻。
borderRadius默认给6,是一个比较通用的圆角值。头像的圆角一般设为圆半径(正方形宽度的一半),图片块的圆角更小,文本块的圆角更小。具体场景通过props覆盖即可。
3.3 组装常用骨架形态
有了SkeletonBox,就能搭出各种常见结构。我封装了两个高频组件:SkeletonList和SkeletonText。
SkeletonList用于列表类页面,结构是一个横向头像区加右侧两行文本区,重复渲染count次。代码如下:
tsx复制import React from 'react';
import { View, StyleSheet } from 'react-native';
import SkeletonBox from './SkeletonBox';
interface SkeletonListProps {
count?: number;
}
const SkeletonList: React.FC<SkeletonListProps> = ({ count = 6 }) => {
return (
<View style={styles.container}>
{Array.from({ length: count }).map((_, index) => (
<View key={`skeleton-list-${index}`} style={styles.row}>
<SkeletonBox width={44} height={44} borderRadius={22} />
<View style={styles.rowContent}>
<SkeletonBox width="60%" height={16} />
<SkeletonBox width="100%" height={14} style={styles.secondLine} />
</View>
</View>
))}
</View>
);
};
const styles = StyleSheet.create({
container: {
paddingHorizontal: 16,
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#F0F0F0',
},
rowContent: {
flex: 1,
marginLeft: 12,
},
secondLine: {
marginTop: 8,
},
});
export default SkeletonList;
SkeletonText用于详情文本区,模拟标题加若干正文行。这里用了一个小技巧:正文行越多,最后一行的宽度越短,模拟真实文本的换行视觉。我通过props传入lines数量,最后一行的宽度按行号递减。
3.4 把呼吸动画注入骨架容器
SkeletonBox只是静态灰色块,要让整个骨架屏动起来,不能每个块都单独开动画,那样创建太多Animated.Value,性能会很差。正确的做法是在骨架容器的根节点上只开一个opacity动画,让所有子块作为一个整体呼吸。
我封装了一个SkeletonContainer组件:
tsx复制import React, { useEffect, useRef } from 'react';
import { Animated } from 'react-native';
const SkeletonContainer: React.FC = ({ children }) => {
const pulseAnim = useRef(new Animated.Value(0.4)).current;
useEffect(() => {
const loop = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 0.8,
duration: 700,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 0.4,
duration: 700,
useNativeDriver: true,
}),
])
);
loop.start();
return () => loop.stop();
}, [pulseAnim]);
return (
<Animated.View style={{ opacity: pulseAnim, flex: 1 }}>
{children}
</Animated.View>
);
};
export default SkeletonContainer;
这里有个很小的优化点:把Animated.Value挂到useRef里,避免组件重复渲染时动画值被重置。useEffect里返回loop.stop()做清理,防止组件卸载后动画还在后台跑。
在RNOH的真机调试中,我发现OpenHarmony设备上Animated.loop有极小概率会出现循环停不下来或者退到后台又恢复后动画继续执行的场景。组件卸载时主动stop()基本能解决,但如果你遇到stop不生效,可以再给根节点加一个if判断,在卸载后不再渲染Animated.View。
3.5 对外API与加载状态切换
骨架屏组件最终要接进业务页面,所以对外API一定要简单。我做了两个关键设计:
第一个是SkeletonWrapper,负责骨架和真实内容的切换。它接收visible参数,visible为true时渲染骨架,false时渲染children。代码如下:
tsx复制import React from 'react';
import SkeletonContainer from './SkeletonContainer';
interface SkeletonWrapperProps {
visible: boolean;
skeleton: React.ReactElement;
children: React.ReactElement;
}
const SkeletonWrapper: React.FC<SkeletonWrapperProps> = ({
visible,
skeleton,
children,
}) => {
if (visible) {
return <SkeletonContainer>{skeleton}</SkeletonContainer>;
}
return children;
};
export default SkeletonWrapper;
这个组件只做一件事:开关切换。业务页面自己维护loading状态,数据请求成功后将loading置为false,真实内容自动替换骨架。切换过程如果希望更平滑,可以在children外层加一个简单的Animated.View淡入,但第一版不必过度设计。
第二个设计是默认导出SkeletonBox、SkeletonList、SkeletonText和SkeletonWrapper,让调用方按需引入。我更推荐显式命名导入,比如:
tsx复制import { SkeletonList, SkeletonWrapper } from '@/components/Skeleton';
避免默认导出造成的命名混乱,同时方便代码静态分析。
3.6 在页面中接入:列表页与详情页案例
列表页接入的代码长这样:
tsx复制const [loading, setLoading] = useState(true);
const [list, setList] = useState<Item[]>([]);
useEffect(() => {
fetchList()
.then((data) => {
setList(data);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
return (
<SkeletonWrapper
visible={loading}
skeleton={<SkeletonList count={6} />}
>
<FlatList
data={list}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <ListItem item={item} />}
/>
</SkeletonWrapper>
);
这个写法可以把加载状态的复杂度从页面里剥离出去。页面只关心“加载中显示什么”和“加载完显示什么”,不关心动画细节。
详情页接入类似,但骨架内容更细。我会把详情页分成几段:顶部图区域用SkeletonBox高度200,标题用SkeletonText中的第一行宽80%,正文用3到4行SkeletonBox堆叠。不同段落之间用margin分隔,模拟真实排版节奏。骨架结构尽量和真实页面对齐,差距越大,切换时的跳动感越明显。
4. 常见问题与排查技巧实录
4.1 OHOS上动画卡顿或循环停止
我在rk3568开发板上遇到过两次动画卡顿。第一次是因为动画值绑到了ScrollView内部的每个列表项上,页面滚动时每一帧都在更新几十个Animated.Value,导致明显掉帧。解决办法就是我在3.4里说的:统一到容器根节点上,一个动画值驱动整片骨架。
第二次循环停止出现在应用进入后台再恢复后,骨架屏不再呼吸。排查发现是RNOH的动画状态在应用退后台时被系统挂起,恢复后没有自动重新start。我的处理是在AppState监听resume事件,回到前台时重新启动动画循环,同时清理旧的loop。这个方案实测下来比较稳定。
4.2 深浅色模式下骨架颜色生硬
如果用固定灰底,切到深色模式后会出现大面积亮灰色块,非常刺眼。处理方式是让骨架底色跟随主题变量变化。我建议把骨架颜色放到一个独立的ThemeContext里,通过useTheme拿到当前模式,动态生成颜色。
浅色模式下推荐灰底#E8E8E8,深色模式下推荐灰底#2A2A2A。透明度动画的范围也要调整,深色模式下0.5到0.85的区间观感更好。
注意别用纯黑纯白做底色,纯白会在深色模式下亮到刺眼,纯黑在浅色模式下显得太脏。灰色系的中间调才最耐看。
4.3 百分比宽度和onLayout失效
RNOH早期版本对百分比宽度支持得还可以,但onLayout在部分场景下不触发,比如组件在不可见区域或display:none状态下。这会导致依赖onLayout动态计算的骨架高度失效。
我的规避办法是:骨架屏布局尽量不依赖onLayout。如果确实需要动态高度,比如图片墙的每列高度由宽高比计算,那就在数据加载前先固定一个估算的高度,等真实数据来了再替换。骨架屏本身是“占位”属性,不需要精确到像素,接近真实布局即可,这也给布局实现留了弹性空间。
4.4 切换页面后骨架屏不消失
这个坑出现在用React Navigation的Tab切换场景。TabB保持缓存后,从TabA切到TabB初次加载时,骨架屏会正常消失。但如果TabB被切走再切回,且组件没有重新挂载,数据请求不会重新执行,loading状态却可能因为状态被外部重置而变成true,导致页面一直显示骨架。
解决方法是把loading状态放到数据请求的同一个生命周期里,或者用useFocusEffect在页面重新聚焦时刷新loading。核心思路是:骨架屏的开关状态必须与数据源绑定,不能单独靠组件内部状态驱动。我在实际项目中就把loading放到了请求模块返回的Promise链上,请求完成自动关闭,而不是监听其他事件的副作用。
4.5 用hdc快速确认RNOH运行环境
如果你在真机上遇到组件渲染异常,先别急着怀疑骨架屏代码。RNOH的运行环境与设备系统版本强相关,我经常用几个hdc命令快速定位。
bash复制hdc shell param get const.product.name
hdc shell param get const.ohos.fullname
hdc shell param get const.ohos.apiversion
product.name能看出是rk3568还是rk3588或者其他设备;fullname能看出系统版本;apiversion能判断RNOH是否支持当前API Level。这三条命令基本能排除环境兼容问题。
另外,真机调试时还需要注意设备serial和devudid的区分。hdc list targets可以查看当前连接的设备列表,开发时多设备连接时很容易串台,建议在hdc连接后先用命令确认目标设备是你要部署的那一台,不然经常出现“代码改了但设备上没反应”的假象。
4.6 其他常见问题速查
| 问题 | 可能原因 | 解决方向 |
|---|---|---|
| 骨架宽度不占满 | 父容器没有alignSelf: stretch | 检查flex和width设置 |
| 动画不生效 | useNativeDriver不兼容 | 先改成false验证 |
| 骨架和真实内容高度跳动 | 骨架结构与真实布局差异太大 | 逐块对齐宽高比例 |
| 深色模式白块发亮 | 颜色固定硬编码 | 改为主题变量 |
| Tab来回切换骨架残留 | loading状态与请求生命周期脱节 | 用请求状态驱动 |
| 低端设备掉帧 | 动画Value数量太多 | 集中到容器根节点 |
这张表是我在实际开发过程中根据团队反馈总结的,基本覆盖了新手最容易踩的几个点。
5. 性能优化与工程化落地
5.1 用memo和useMemo减少重复计算
骨架屏本身渲染的资源并不大,但在大列表场景下,如果骨架屏组件被放在FlatList的renderItem里,每次渲染都可能创建新的React元素,导致子组件重复计算。我的做法是把SkeletonList用React.memo包裹,props不变时直接跳过渲染:
tsx复制const SkeletonList = React.memo(({ count = 6 }) => {
// ...
});
同样,SkeletonBox虽然是一个简单View,但如果同一个页面有几十个实例,建议也加上memo。RNOH的React调度器和原生RN一致,memo能有效减少调和阶段的diff开销。
业务页面里,骨架屏的skeleton节点可以用useMemo缓存,避免父组件每次渲染都生成新的React Element:
tsx复制const skeleton = useMemo(() => <SkeletonList count={6} />, []);
这样即使父组件因为其他状态发生重渲染,骨架节点也不会重建。
5.2 骨架屏的降级策略
骨架屏不是所有场景都必须开。网络极快时,骨架屏一闪而过,反而增加视觉噪点;低端设备上动画再轻量也有功耗。我采用的策略是:请求开始后延迟150ms再显示骨架屏,如果150ms内请求已经完成,就不显示骨架。这样既避免快速加载时的闪烁,又能在慢网下给用户反馈。
具体实现是加一个delayShow状态,请求开始时启动一个setTimeout,请求完成时清理它。超过150ms还没有返回数据,就把visible置为true。这个小策略在视觉体验上提升非常明显。
5.3 组件库化:把Skeleton收进公共包
如果团队里有多个App或模块都要用骨架屏,建议把Skeleton抽成公共组件库。我在项目里会单独建一个packages/ui包,里面放Skeleton、Button、EmptyState这些通用组件。每个组件自带types和styles,通过入口文件统一导出。
公共组件库的收益不只是复用。后续如果RNOH官方新增了更高效的动画API,或者团队要统一设计规范,只需要改一个包,所有业务模块同步生效。骨架屏这种高频组件非常适合做组件化沉淀,值得多花一点工程成本。
组件库的版本迭代要跟上RNOH的版本升级。RNOH本身还在快速演进,某些API行为和属性支持范围可能变化,组件库最好锁一个兼容的RNOH版本,并在README里写清楚支持范围,避免下游团队升级RNOH后莫名其妙的样式问题。
写在最后
实际上手之后,你会发现Skeleton骨架屏在RNOH里并不是一个高不可攀的复杂组件,它本质上就是“灰色块 + 透明度动画 + 条件渲染”的组合。真正花时间的不是写组件,而是把加载状态、布局结构、动画性能和主题适配这些细节打磨好。我在实际项目中,第一版骨架屏只花了一个下午,但后续适配深色模式、低性能板子和页面切换场景,前前后后用了快一周。骨架屏的取舍原则我一直记着:布局要对齐真实页面,动画要克制,状态必须跟数据生命周期绑定。最后再分享一个小技巧:把SkeletonBox的hex色值提成常量,所有骨架块统一引用,后续调主题深浅色,只改一个值就够了。
