1. 为什么偏偏是 Skeleton:RN for OpenHarmony 里的首屏体验问题
1.1 骨架屏是“假页面”,也是“真体验”
先交代一下背景。我在 OpenHarmony 设备上做 React Native 应用时,第一件让我头疼的事不是状态管理,也不是路由,而是页面加载时那一片白屏。RN 应用在 OpenHarmony 上启动时,JavaScript 引擎要初始化、Bundle 要加载解析、业务数据要等网络返回,这个过程在低性能设备上尤其明显。rk3568、rk3588 这类开发板比手机弱不少,白屏时间一拉长,用户的第一反应就是“这应用是不是死了”。
骨架屏解决的就是这个窗口期的问题。它不是在等数据的时候放一个转圈 Loading,而是按照页面真实布局渲染出灰色的占位块,让用户感觉页面“已经出来了,只是内容还没填上”。从体验上讲,骨架屏比 Loading 更接近真实页面,用户等待的心理压力会小很多。从工程上讲,骨架屏的难度也不高,只要组件设计合理,一套代码在 Android、iOS、OpenHarmony 上都能跑出差不多的效果。
这篇文章我就拿“rn_for_openharmony 常用组件_Skeleton 骨架屏”这个主题展开,聊聊我在真实项目里怎么设计骨架屏组件,怎么把它跑在 OpenHarmony 设备上,以及那些文档里不会写、代码里才会遇到的坑。适合正在做 RN for OpenHarmony 应用的开发者参考,也适合那些刚把 RN 工程跑通、准备开始打磨体验细节的团队。
1.2 现成库不一定能跑:组件适配的三个现实约束
很多人在 RN 里做骨架屏,第一反应是去 npm 上找个现成库,比如 react-native-skeleton-placeholder。但放到 OpenHarmony 上,这条路往往走不通,原因有三个。
第一,现成骨架屏库大多依赖原生 View 的阴影、渐变、透明度合成等能力。RN for OpenHarmony 是社区和厂商一起推进的适配层,它把 React Native 的渲染映射到 ArkUI 的组件体系上,但并不是所有原生能力都做了完整桥接。很多在 Android 上正常的样式属性,在 OpenHarmony 上可能静默失效,或者表现不一致。
第二,部分骨架屏库用了 react-native-linear-gradient、react-native-reanimated 这类三方原生模块。这些模块在 OpenHarmony 上不一定有对应的原生实现。即使有,版本对齐和编译集成也是一堆麻烦事。
第三,也是最重要的,骨架屏本身不复杂,自己写一个纯 JS 的组件成本很低,而且完全可控。与其在 OpenHarmony 上跟三方库的兼容性问题死磕,不如自己实现,把动画、布局、显隐逻辑都掌握在自己手里。这样后续如果要针对 OpenHarmony 做性能优化,也容易下手。
所以我的建议很直接:在 RN for OpenHarmony 项目里,骨架屏组件优先自研,而且优先用纯 JS + RN 基础 API 实现。下面我会把完整的设计和实现过程讲清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 自研 Skeleton 组件的核心设计:动画、布局与 Props
2.1 Props 设计:让调用方只关心 loading 状态
组件设计第一件事是确定对外 API。骨架屏的使用场景很固定:数据没回来时显示占位,数据回来后显示真实内容。所以组件的核心 Prop 就一个:loading。
我最初设计的接口长这样:
tsx复制interface SkeletonProps {
loading: boolean;
children?: React.ReactNode;
style?: StyleProp<ViewStyle>;
animationType?: 'pulse' | 'shimmer' | 'none';
duration?: number;
backgroundColor?: string;
highlightColor?: string;
layout?: SkeletonLayoutItem[];
}
这个 API 的目标是让业务方只关心一件事:当前数据在不在加载中。其他所有细节,比如每个占位块的位置、大小、动画效果,都由组件内部或配置项处理。duration 控制动画周期,默认 1200ms,animationType 控制动画样式,pulse 是整体透明度脉冲,shimmer 是扫光效果,none 是完全静止的占位。
layout 是给高阶用法准备的。你可以不写 layout,而是把 children 传进来,组件内部通过遍历 children 把普通 View 替换成占位块。但我更推荐 layout 这种配置驱动的方式,因为它的表达更精确:一个文本行占多宽、一个图片块占多高、圆角是多少,都能用 JSON 描述清楚,后面做模板化、自动化也方便。
实际调用时,业务代码大概是这样的:
tsx复制<Skeleton loading={loading} animationType="shimmer">
<ProductList data={list} />
</Skeleton>
2.2 脉冲动画与扫光效果:NativeDriver 的取舍
动画是骨架屏的核心。没有动画的骨架屏看起来像页面渲染出错,有了轻微的闪烁或扫光效果,用户才会觉得“这是正常加载中的状态”。
最简单、兼容性最好的动画方式是脉冲。实现思路是让整个骨架屏的透明度在 0.4 到 1 之间循环变化。RN 的 Animated 模块可以直接完成,不需要任何原生模块。关键在于用 Animated.Value 驱动,而不是用 setState。
tsx复制const opacity = useRef(new Animated.Value(0.4)).current;
useEffect(() => {
if (loading && animationType === 'pulse') {
const loop = Animated.loop(
Animated.sequence([
Animated.timing(opacity, {
toValue: 1,
duration: duration / 2,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 0.4,
duration: duration / 2,
useNativeDriver: true,
}),
])
);
loop.start();
return () => loop.stop();
}
}, [loading, animationType, duration, opacity]);
这里有一个关键取舍:useNativeDriver 在 Android/iOS 上可以提高动画性能,但在 OpenHarmony 上不一定完全生效。实测下来,RN for OpenHarmony 对 useNativeDriver 的支持是逐步完善的,如果遇到动画不生效的情况,把它改成 false 会走 JS 驱动,兼容性更好,代价是动画调度占一点 JS 线程。在一个页面只有一个骨架屏动画、且数据加载只有几秒钟的情况下,性能开销完全可以接受。所以我的建议是保留 useNativeDriver: true,但在真机上验证动画是否流畅,如果有问题就降级为 false。
扫光效果则要复杂一点。扫光的本质是一个高亮的渐变条从左往右移动。在 OpenHarmony 的 ArkUI 体系里,LinearGradient 的适配在不同版本上表现有差异,所以我的方案是用一个绝对定位的半透明 View,加上 X 轴位移动画,覆盖在骨架屏上层。
tsx复制const translateX = useRef(new Animated.Value(-200)).current;
useEffect(() => {
if (loading && animationType === 'shimmer') {
const loop = Animated.loop(
Animated.timing(translateX, {
toValue: 400,
duration,
useNativeDriver: true,
})
);
loop.start();
return () => loop.stop();
}
}, [loading, animationType, duration, translateX]);
View 的宽度设为骨架屏宽度的 60%,高度铺满,背景用半透明白色,通过 borderRadius 和 transform rotate 让它看起来像一道斜光。这个方案的优点是只依赖 View 的 transform 和背景色,几乎没有兼容性风险,OpenHarmony 上也能正常显示。
2.3 用百分比和固定比例撑起布局,避免机型跳变
骨架屏最怕的是页面跳变:加载时占位块是 100 高度,数据回来后真实内容是 120 高度,页面突然抖一下,用户体验直接从“可以接受”变成“很难受”。要避免这个问题,核心思路是让骨架屏的布局尽可能接近真实内容的布局。
我的做法是定义几种基础占位块类型,每种类型都有明确的尺寸规则:
第一类是文本行。真实文本一般是一行或多行文字,所以骨架屏里用高度 14 到 16 的圆角矩形模拟。宽度用百分比,比如标题行 80%,内容行 100%,最后一行 60%,这样看起来更像自然文本。
第二类是图片块。图片区域一般是正方形或固定宽高比,比如头像用 40x40 的圆,封面图用 16:9 的矩形。骨架屏里直接用一个宽 100%、高度由宽高比计算出来的 View 就行。
第三类是按钮或标签。高度 32 到 40,宽度用固定值或百分比,圆角可以大一点,模拟按钮的形状。
一个典型的列表页骨架屏配置可能是这样的:
tsx复制const listLayout = [
{ type: 'circle', size: 48, marginBottom: 12 },
{ type: 'rect', height: 16, width: '80%', borderRadius: 4, marginBottom: 8 },
{ type: 'rect', height: 16, width: '100%', borderRadius: 4, marginBottom: 8 },
{ type: 'rect', height: 16, width: '60%', borderRadius: 4, marginBottom: 20 },
{ type: 'rect', height: 200, width: '100%', borderRadius: 8, marginBottom: 0 },
];
在渲染时,宽度如果是字符串百分比,就用父容器宽度乘以百分比;如果是数字,直接作为固定宽度。高度只允许数字,这样布局计算简单,也避免在 ArkUI 上出现宽度高度单位不一致的问题。
还有一点要注意:骨架屏容器本身必须保持和真实内容容器一致的 padding 和 margin,否则加载完成后内容一换,页面照样会跳。这个看起来是细节,实际上是最影响体验的一环。
3. 在 rk3568 / rk3588 开发板上完整跑通
3.1 环境准备:从 hdc 连接设备到确认 OpenHarmony 版本
在写业务代码之前,得先保证开发环境是通的。OpenHarmony 设备调试和 Android 很像,Android 用 adb,OpenHarmony 用 hdc。第一次连接开发板时,建议按这个顺序排查:
先看设备有没有被识别:
bash复制hdc list targets
如果 list 出来是空的,检查 USB 驱动和开发者模式,确认开发板的 DEVOCON 调试开关已经打开。如果 list 到设备,下一步确认系统版本,方便对照 API 兼容范围:
bash复制hdc shell param get const.product.name
hdc shell param get const.ohos.version
我手上这块 rk3568 开发板跑的是 OpenHarmony 4.0 左右的版本,基本够用。如果你用的是 rk3588,性能会好一些,但调试命令是一样的。拿到设备之后,建议先用 hilog 确认日志输出是通的,因为后面排查 RN Bundle 加载和动画问题都要靠它:
bash复制hdc shell hilog | grep ReactNative
这一步看似基础,却能省掉后面大量“不知道为什么不行”的排查时间。
3.2 创建 RN for OpenHarmony 工程并接入 Skeleton
工程初始化这部分,我默认你已经有一套 RN for OpenHarmony 的工程模板。如果你是从零开始,可以按社区给的脚手架初始化,生成一个同时包含 OpenHarmony 原生工程和 RN 前端代码的项目。工程结构上会有一个 entry 目录对应 OpenHarmony 应用壳,一个 src 目录对应 RN 代码。
接入 Skeleton 组件不需要任何原生改动,因为整个组件是纯 JS 实现的,所以只需要把 Skeleton.tsx 放到项目的 components 目录下,然后在页面里引入就行。如果要在多个页面复用,建议封装成公共组件,并在组件里做默认导出。
我把 Skeleton 组件做成一个通用容器,核心逻辑分三层:
第一层是容器。它负责接收 loading 和 children,当 loading 为 true 时渲染占位内容,为 false 时渲染真实内容。
第二层是占位布局。它根据 layout 配置或 children 结构生成对应的灰色 View 数组。为了性能,这些 View 应该是纯静态的,不要在每个动画帧里重新计算。
第三层是动画层。它是绝对定位的一个或多个半透明 View,通过 Animated 驱动透明度或位移。为了保证点击事件不穿透到底下的真实内容,动画层要设置 pointerEvents="none"。
组件写好后,在页面里使用就是前面说的那样。这里给一个完整的、在真实项目里跑过的列表页示例:
tsx复制import Skeleton from './components/Skeleton';
const layout = [/* 上面的 listLayout */];
function ProductListPage() {
const [loading, setLoading] = useState(true);
const [list, setList] = useState([]);
useEffect(() => {
fetchList().then(data => {
setList(data);
setLoading(false);
});
}, []);
return (
<View style={{ flex: 1 }}>
<Skeleton loading={loading} layout={layout} animationType="shimmer">
<FlatList
data={list}
renderItem={renderItem}
keyExtractor={item => item.id}
/>
</Skeleton>
</View>
);
}
注意上面代码里的 loading 状态:数据请求成功后先 setList,再 setLoading(false)。这两个 setState 在 React 18 的自动批处理下会合并成一次渲染,所以不会出现“内容已经更新但骨架屏还闪了一下”的问题。
3.3 业务页面接入:列表加载、详情跳转、电话拨打场景
只做一个列表页还不够,实际项目里骨架屏要覆盖的页面类型很多。这里说三个我在 OpenHarmony 设备上真实做过的场景。
第一个是列表页,上面已经演示过了。核心就是一个 FlatList 加一个 Skeleton 容器,布局用配置驱动,动画用 shimmer 或 pulse 都行。列表页的骨架屏配置要尽量模拟真实列表项的间距和高度,避免数据回来后跳动。
第二个是详情页。详情页特点是内容区块多,图片、文本、按钮混合。我的做法是把详情页划分成几个区块,每个区块对应一个 layout 片段,比如顶部大图、标题、描述、操作按钮。骨架屏加载时按区块渲染,数据返回后整体切换。这里如果页面结构比较复杂,可以拆成多个 Skeleton 实例分别控制,但要注意多个实例同时开动画可能造成性能压力,建议共用同一个动画驱动,或者只在最外层的 Skeleton 上开动画。
第三个场景比较特殊:列表项里有点击拨打电话的入口。在 OpenHarmony 上通过 RN 调起系统电话,一般要借助原生模块或者 Intent 能力,这里不展开讲原生实现。我提这个场景是想说:骨架屏加载完之后,这些可交互入口会立即暴露给用户,所以骨架屏的容器层绝对不能挡住点击。如果骨架屏在数据回来之前拦住了用户点击,用户连续点了几次没反应,之后的体验就会打折扣。
为此我专门在 Skeleton 容器上加了一个可配置项,默认情况下骨架屏渲染时会拦截所有 PointerEvent,但一旦 loading 切换为 false,立即释放。如果想让骨架屏可穿透,直接设置 pointerEvents="none"。
4. 真实项目里最容易踩的四个坑
4.1 骨架屏遮住点击事件:pointerEvents 才是答案
第一个坑几乎每个接骨架屏的团队都会踩:骨架屏显示的时候,用户点击页面,点击事件被骨架屏容器拦截了。轻则没反应,重则手指松开时触发了一次真实的点击,跳到不该跳的页面。
问题出在骨架屏容器本身是一个普通的 View。在 RN 里,View 默认会拦截触摸事件,即使它是“空的”。当骨架屏作为 children 的替换物渲染时,它占据了整个页面区域,所有点击都会落在它身上。
解决办法有两个层面。
第一层是设置容器为 pointerEvents="none",这样当前的 View 不会成为触摸目标,事件会穿透到兄弟姐妹节点或底层组件。但要注意,如果骨架屏容器底下没有任何可点击区域,这个设置其实没有意义。
第二层更重要:骨架屏不应该单独渲染一层覆盖在页面上面,而是应该和真实内容共享同一层级。也就是说,同一时刻页面渲染的要么是骨架屏,要么是真实内容,而不是两者同时存在并叠放。这样就不会出现遮盖问题。我的组件里强制了这一点:
tsx复制if (loading) {
return <SkeletonLayout />;
}
return children;
这个写法从根本上避免了点击拦截。如果你遇到点击事件被吞的情况,先检查是不是用了“覆盖”而不是“替换”的方案。
4.2 动画一直停不下来:组件卸载与 Animated.loop 的清理
第二个坑和动画生命周期有关。很多人的骨架屏在加载完成后没问题,但页面销毁时报错,或者在页面切换后动画还在跑,导致内存泄漏。
原因很简单:Animated.loop 是一个无限循环动画,如果组件卸载时没有调用 stop,动画会继续持有组件实例,造成泄漏。在 RN 的 Animated 体系里,动画循环不是自动清理的,必须手动处理。
我的做法是在 useEffect 里启动动画,并在清理函数里停止动画。参考第 2.2 节的代码:useEffect 的 return 里调用了 loop.stop(),这一步是必须的。如果你在多个地方启动动画,建议用一个 ref 保存动画实例:
tsx复制const animRef = useRef<Animated.CompositeAnimation | null>(null);
useEffect(() => {
if (!loading) return;
const loop = Animated.loop(/* ... */);
animRef.current = loop;
loop.start();
return () => {
animRef.current?.stop();
animRef.current = null;
};
}, [loading]);
还有一个细节:当 loading 从 true 变成 false 时,动画也应该停。所以 useEffect 的依赖数组里必须包含 loading,这样 loading 变化时会先执行清理函数,再执行新的 effect。如果依赖数组漏了 loading,动画就会一直跑下去,直到组件卸载。
4.3 加载完成后页面跳动、骨架屏闪烁:opacity 切换法
第三个坑是页面跳动和骨架屏闪烁,这两者是相关的。
先说跳动。骨架屏布局和真实内容布局不一致,数据返回后高度发生变化,页面就会上下跳。根治方法是让骨架屏布局无限接近真实布局。如果一个页面里的真实列表项高度是不确定的,比如图片加载完之前高度未知,可以在图片容器上设置固定 height,或者用 aspectRatio 固定宽高比。这样骨架屏和真实内容的高度就能保持一致。
再说闪烁。闪烁通常是因为 loading 状态在一次渲染里先变成了 true,然后很快又变成了 false,骨架屏刚显示就消失。典型场景是请求被缓存了,数据在 100ms 内返回,骨架屏一闪而过,看起来像页面抖了一下。
我的处理方式是做一个最小显示时长控制。比如 Skeleton 组件内部记录 loading 为 true 的起始时间,当 loading 变为 false 时,如果距离显示时间不足 300ms,就延迟到 300ms 再切换。这个延迟可以用一个定时器实现:
tsx复制const startTime = useRef(Date.now());
useEffect(() => {
if (loading) {
startTime.current = Date.now();
} else {
const elapsed = Date.now() - startTime.current;
const remaining = MIN_DISPLAY_TIME - elapsed;
if (remaining > 0) {
const timer = setTimeout(() => setVisible(false), remaining);
return () => clearTimeout(timer);
}
setVisible(false);
}
}, [loading]);
这样后端的极快响应不会导致页面闪烁,反而会让整个加载过程看起来更稳定。当然,这个延迟也会让页面渲染真实内容的时间往后推一点点,但 300ms 内的延迟用户感知不明显,换来的是视觉上的平滑。
4.4 AppState 与来电/后台切换:骨架屏收到错误事件
第四个坑比较隐蔽:应用切换到后台再回来时,骨架屏的动画状态可能错乱。比如 A 页面正在加载,用户接了个电话或者切到后台,过一会儿回来,发现骨架屏动画停住了,或者动画位置跑偏了。
原因是 Animated 动画在应用进入后台时会被暂停,从后台恢复时需要重新启动。处理方式是监听 AppState 变化,在 active 状态重新启动动画。
tsx复制useEffect(() => {
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active' && loading) {
restartAnimation();
}
});
return () => sub.remove();
}, [loading]);
这个监听要和动画的启停逻辑配合好,避免重复启动。建议把启动动画的逻辑抽成一个函数,在 effect 和 AppState 回调里都调用它,但要用同一个动画实例,防止多个 loop 叠加导致的动画速度翻倍。
另外还有一个类似的问题:如果页面用了 react-navigation 之类的导航库,页面从 A 切到 B 再切回来,A 页面的骨架屏也会遇到类似问题。这时候最好的方式其实是让页面的请求继续跑,loading 状态根据请求结果来定,不因页面切换而重置。骨架屏动画则跟随 loading 状态和组件挂载状态自动启停。
5. 工程化沉淀:把 Skeleton 做成团队资产
5.1 配置驱动:一份 JSON 描述所有骨架布局
骨架屏组件做出来之后,下一步就是把它做成能复用的工程资产。我做得最有效的一件事,是把骨架屏布局全部配置化,用一份 JSON 描述页面结构,而不是在代码里手写 JSX。
配置项我设计成数组,每一项描述一个块:
json复制[
{ "type": "avatar", "size": 48, "marginBottom": 12 },
{ "type": "line", "width": "80%", "height": 16, "marginBottom": 8, "radius": 4 },
{ "type": "line", "width": "100%", "height": 16, "marginBottom": 8, "radius": 4 },
{ "type": "line", "width": "60%", "height": 16, "marginBottom": 20, "radius": 4 },
{ "type": "image", "height": 200, "width": "100%", "radius": 8 }
]
这样做的直接好处是:产品和 UI 可以直接看 JSON 就知道骨架屏长什么样,开发调整布局时不用去翻组件代码。还有一层好处是,后续可以做骨架屏模板库,把常见的列表、详情、卡片、个人中心等页面骨架模板沉淀下来,新页面直接引用。
模板库的粒度我建议控制在页面区块级别,不要一整个页面一整个模板,因为不同页面的区块组合差异很大。拆成区块模板后,页面级骨架屏就是区块模板的排列组合,灵活性高很多。
5.2 HOC 封装 withSkeleton:业务代码只留两行
配置化之后,我再把组件封装成高阶组件,业务侧的使用成本降到了两行。
tsx复制const ProductListWithSkeleton = withSkeleton(ProductList, listLayout);
function ProductPage() {
const [loading, setLoading] = useState(true);
const [data, setData] = useState(null);
useEffect(() => {
loadData().then(d => {
setData(d);
setLoading(false);
});
}, []);
return <ProductListWithSkeleton loading={loading} data={data} />;
}
withSkeleton 的实现逻辑是:把原始组件包一层,在 loading 为 true 时渲染 Skeleton,为 false 时渲染原始组件。这样业务组件的内部代码完全不用感知骨架屏的存在,也方便在不同页面统一骨架屏的交互规则。
这里有一个细节:withSkeleton 生成的新组件,props 类型要透传原始组件的 props,避免 TypeScript 类型检查报错。同时要给新组件设置 displayName,方便在日志和调试工具里定位。如果项目里有多个页面都要接骨架屏,HOC 方式是性价比最高的方案。
5.3 构建产物瘦身与日志定位:编译后哪些文件可以删
骨架屏本身不产生原生代码,但整个 RN for OpenHarmony 工程在反复编译后,磁盘空间的占用会越来越夸张。我统计过一次,一个包含 OpenHarmony 原生壳和 RN 代码的工程,编译后目录总大小随随便便超过 10GB。里面很多是中间产物,是可以安全清理的。
常见可删除的构建产物主要有这么几类:
第一是 HarmonyOS 工程下的 build、.cxx、.hvigor 目录,这些是原生编译的中间产物。执行 hvigor clean 或者直接删除都可以,下次编译会重新生成。.hvigor 是 hvigor 的本地缓存,删掉不会影响源码。
第二是 oh_modules 目录,这是 OpenHarmony 侧依赖的安装目录。它在执行 hvigor sync 或 IDE 自动 sync 时会重新拉取,如果暂时不需要编译原生部分,删掉能省出不少空间。
第三是 RN 侧的 node_modules 和缓存目录。node_modules 删除后通过 npm install 或 yarn 重新安装。RN 构建相关的缓存目录也要看情况清理,比如 Metro 缓存,在你改了包名或调试脚本时,有时候缓存不刷新会导致页面一直加载旧的逻辑,这时候清理 Metro 缓存反而能解决问题。
删除构建产物的通用原则是:源码、配置文件、package.json、oh-package.json 这些保留,其余 build 产物和缓存都可以删。如果你不确定某个目录是源码还是产物,最简单的判断方式是看它有没有对应的配置文件,以及删掉之后重新编译能不能恢复。能恢复的,基本都可以放心清理。
6. 最后分享一个小技巧
骨架屏本身不复杂,但它在 OpenHarmony 上的动画性能,直接决定了这个页面“看起来专不专业”。我在 rk3568 上实测时发现,shimmer 扫光动画在低端设备上偶尔会有掉帧,尤其是页面本身还有 FlatList 在滚动的时候。后来我把扫光 View 的阴影、圆角、半透明叠加层尽量简化,又把动画驱动的对象从整个容器缩小到那一条扫光 View 本身,掉帧问题就明显改善了。
如果你的骨架屏动画在 OpenHarmony 上不够顺滑,先别急着上 reanimated 或改原生代码,优先检查两点:一是动画是不是驱动了太多节点,二是渲染的占位 View 数量是不是太多了。一个页面 50 个灰色 View 和 200 个灰色 View,性能差距是肉眼可见的。控制在合理数量内,再用 useMemo 把布局配置缓存下来,比任何优化库都管用。
另外,骨架屏的降级策略也值得想一下。如果用户开启了“减少动态效果”的系统偏好,或者设备性能确实太差,可以自动把动画关掉,只保留静态占位块。这个判断逻辑不复杂,却能照顾到真实的低配置用户,属于性价比很高的体验优化。
