
案例项目开源地址:https://atomgit.com/nutpi/wanandroid_rn_openharmony
用户打开 App,看到一片空白。是加载中?还是没有数据?还是出错了?
如果 App 不告诉用户,用户只能猜。猜错了就会觉得 App 有问题,然后关掉。
这就是为什么空状态和加载状态很重要。它们告诉用户"现在是什么情况",让用户知道该等待还是该操作。
今天来聊聊 WanAndroid 项目里怎么处理这两种状态。
加载状态的实现
tsx
const [loading, setLoading] = useState(false);
// 加载数据时
const loadData = async () => {
setLoading(true);
try {
const res = await homeApi.getArticles(0);
if (res.errorCode === 0) {
setArticles(res.data.datas);
}
} catch (e) {
console.error('加载失败', e);
}
setLoading(false);
};
loading 是一个布尔状态,表示是否正在加载。
加载开始时设为 true,加载结束时设为 false。不管成功还是失败,都要设为 false,否则会一直显示加载中。
为什么用 try-catch
网络请求可能失败,比如断网、服务器错误、超时等。如果不用 try-catch,异常会导致后面的代码不执行,setLoading(false) 就不会被调用。
用 try-catch 包裹,即使请求失败,也能执行 setLoading(false)。
finally 的写法
更好的写法是用 finally:
tsx
const loadData = async () => {
setLoading(true);
try {
const res = await homeApi.getArticles(0);
if (res.errorCode === 0) {
setArticles(res.data.datas);
}
} catch (e) {
console.error('加载失败', e);
} finally {
setLoading(false);
}
};
finally 里的代码不管成功还是失败都会执行。这样 setLoading(false) 只写一次,更清晰。
我们项目里没用 finally,是因为代码简单,两种写法差别不大。如果逻辑复杂,建议用 finally。
加载指示器的显示
tsx
<FlatList
data={articles}
ListFooterComponent={
loading ? (
<ActivityIndicator color={theme.accent} style={{padding: 20}} />
) : null
}
/>
ListFooterComponent 是 FlatList 的属性,用于在列表底部显示内容。
当 loading 为 true 时,显示一个 ActivityIndicator(加载指示器);为 false 时,显示 null(什么都不显示)。
ActivityIndicator 组件
ActivityIndicator 是 React Native 提供的加载指示器组件,显示一个旋转的圆圈。
color={theme.accent} 设置颜色,和主题保持一致。
style={``{padding: 20}} 加一些内边距,让指示器不会贴着列表内容。
为什么放在 ListFooterComponent
加载指示器放在列表底部,是因为我们的加载场景是"上拉加载更多"。用户滑到底部,触发加载,指示器出现在底部,告诉用户"正在加载更多内容"。
如果是首次加载(列表为空),指示器放在底部就不太合适了,因为用户看不到。这时候应该放在页面中间。
我们的实现比较简单,没有区分这两种情况。更完善的做法是:
tsx
// 首次加载,显示全屏加载
if (loading && articles.length === 0) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={theme.accent} />
<Text style={{color: theme.subText, marginTop: 12}}>加载中...</Text>
</View>
);
}
// 加载更多,显示底部加载
<FlatList
ListFooterComponent={loading ? <ActivityIndicator /> : null}
/>
下拉刷新的加载状态
tsx
const [refreshing, setRefreshing] = useState(false);
const loadData = async (refresh = false) => {
if (refresh) {
setRefreshing(true);
} else {
setLoading(true);
}
// 加载数据...
setLoading(false);
setRefreshing(false);
};
<FlatList
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => loadData(true)}
tintColor={theme.accent}
/>
}
/>
下拉刷新用单独的 refreshing 状态,和上拉加载的 loading 分开。
为什么要分开?因为它们的 UI 不同。下拉刷新时,顶部会出现一个下拉指示器;上拉加载时,底部会出现加载指示器。如果用同一个状态,两个指示器会同时出现,很奇怪。
RefreshControl 组件
RefreshControl 是 React Native 提供的下拉刷新组件,配合 FlatList 或 ScrollView 使用。
refreshing={refreshing} 控制刷新指示器的显示。为 true 时显示,为 false 时隐藏。
onRefresh={() => loadData(true)} 用户下拉时触发的回调。我们调用 loadData(true),参数 true 表示是刷新操作。
tintColor={theme.accent} 设置指示器颜色。这个属性只在 iOS 上生效,Android 用 colors 属性。
跨平台的颜色设置
tsx
<RefreshControl
refreshing={refreshing}
onRefresh={() => loadData(true)}
tintColor={theme.accent} // iOS
colors={[theme.accent]} // Android
/>
iOS 用 tintColor,Android 用 colors(是一个数组,可以设置多个颜色,指示器会循环使用)。
为了跨平台一致,两个属性都设置。
空状态的实现
tsx
<FlatList
data={articles}
ListEmptyComponent={
!loading ? (
<View style={styles.empty}>
<Text style={styles.emptyIcon}>📰</Text>
<Text style={[styles.emptyText, {color: theme.subText}]}>暂无文章</Text>
</View>
) : null
}
/>
ListEmptyComponent 是 FlatList 的属性,当列表数据为空时显示。
条件判断的逻辑
!loading ? ... : null 只有在不加载时才显示空状态。
为什么要这个判断?因为加载中时,数据也是空的。如果不判断,用户会先看到"暂无文章",然后数据加载完又显示文章列表,体验很差。
加上 !loading 判断,加载中时不显示空状态,加载完成后如果数据还是空的,才显示"暂无文章"。
空状态的设计
tsx
empty: {
alignItems: 'center',
paddingVertical: 60
},
emptyIcon: {
fontSize: 64,
marginBottom: 16
},
emptyText: {
fontSize: 16
},
空状态居中显示,上下留出 60 像素的空间。
用一个大号 emoji 作为图标,比纯文字更吸引眼球。emoji 的好处是不需要引入图标库,跨平台都能显示。
文字简洁明了,"暂无文章"四个字就够了。不需要长篇大论解释为什么没有文章。
更丰富的空状态
如果想要更丰富的空状态,可以加上操作按钮:
tsx
<View style={styles.empty}>
<Text style={styles.emptyIcon}>📰</Text>
<Text style={styles.emptyText}>暂无文章</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => loadData(true)}
>
<Text style={styles.retryText}>点击刷新</Text>
</TouchableOpacity>
</View>
给用户一个操作入口,比干等着好。
错误状态的处理
tsx
const [error, setError] = useState<string | null>(null);
const loadData = async () => {
setLoading(true);
setError(null);
try {
const res = await homeApi.getArticles(0);
if (res.errorCode === 0) {
setArticles(res.data.datas);
} else {
setError(res.errorMsg || '加载失败');
}
} catch (e) {
setError('网络错误,请检查网络连接');
}
setLoading(false);
};
除了加载状态和空状态,还有错误状态。
error 存储错误信息,null 表示没有错误。
加载开始时清空错误(setError(null)),因为用户可能是在重试。
加载失败时设置错误信息。区分两种情况:API 返回错误(res.errorCode !== 0)和网络异常(catch 到的错误)。
错误状态的显示
tsx
{error && (
<View style={styles.error}>
<Text style={styles.errorIcon}>😵</Text>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity onPress={() => loadData(true)}>
<Text style={styles.retryText}>重试</Text>
</TouchableOpacity>
</View>
)}
有错误时显示错误信息和重试按钮。
错误信息要具体,让用户知道是什么问题。"网络错误,请检查网络连接"比"加载失败"更有帮助。
重试按钮很重要,给用户一个解决问题的途径。
状态的优先级
当多个状态同时存在时,显示哪个?
tsx
// 优先级:加载 > 错误 > 空 > 正常
if (loading && articles.length === 0) {
return <LoadingView />;
}
if (error) {
return <ErrorView error={error} onRetry={loadData} />;
}
if (articles.length === 0) {
return <EmptyView />;
}
return <ArticleList articles={articles} />;
加载状态优先级最高,因为用户需要知道"正在加载"。
错误状态次之,因为用户需要知道"出问题了"。
空状态再次,因为这是正常情况,只是没有数据。
有数据时正常显示列表。
我们的实现用 FlatList 的 ListEmptyComponent 和 ListFooterComponent,逻辑稍有不同,但思路是一样的。
骨架屏的进阶方案
加载指示器是最简单的方案,更好的方案是骨架屏(Skeleton)。
骨架屏是一种占位 UI,模拟内容的布局,用灰色块代替文字和图片。用户看到骨架屏,就知道内容大概长什么样,心理上更有准备。
tsx
const SkeletonCard = () => (
<View style={styles.skeletonCard}>
<View style={styles.skeletonTitle} />
<View style={styles.skeletonLine} />
<View style={styles.skeletonLine} />
</View>
);
const SkeletonList = () => (
<View>
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</View>
);
// 使用
{loading && articles.length === 0 ? <SkeletonList /> : <ArticleList />}
骨架屏的样式要和真实内容接近,让用户有"内容正在填充"的感觉。
我们的项目没有用骨架屏,是因为实现起来比较麻烦,而且加载速度还可以,用简单的加载指示器就够了。
完整的状态处理代码
tsx
import React, {useState, useEffect} from 'react';
import {
View,
Text,
FlatList,
RefreshControl,
ActivityIndicator,
StyleSheet
} from 'react-native';
import {Article} from '../types';
import {homeApi} from '../services/api';
import {useTheme} from '../context/ThemeContext';
import {ArticleCard} from '../components/ArticleCard';
export const HomePage = () => {
const {theme} = useTheme();
const [articles, setArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
loadData(true);
}, []);
const loadData = async (refresh = false) => {
if (refresh) {
setRefreshing(true);
} else {
setLoading(true);
}
try {
const res = await homeApi.getArticles(0);
if (res.errorCode === 0) {
setArticles(res.data.datas);
}
} catch (e) {
console.error('加载失败', e);
}
setLoading(false);
setRefreshing(false);
};
return (
<FlatList
data={articles}
keyExtractor={(item, index) => `${item.id}-${index}`}
renderItem={({item}) => <ArticleCard item={item} />}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => loadData(true)}
tintColor={theme.accent}
/>
}
ListFooterComponent={
loading ? (
<ActivityIndicator
color={theme.accent}
style={{padding: 20}}
/>
) : null
}
ListEmptyComponent={
!loading ? (
<View style={styles.empty}>
<Text style={styles.emptyIcon}>📰</Text>
<Text style={[styles.emptyText, {color: theme.subText}]}>
暂无文章
</Text>
</View>
) : null
}
/>
);
};
const styles = StyleSheet.create({
list: {
paddingBottom: 100
},
empty: {
alignItems: 'center',
paddingVertical: 60
},
emptyIcon: {
fontSize: 64,
marginBottom: 16
},
emptyText: {
fontSize: 16
},
});
空状态和加载状态看起来是小事,但对用户体验影响很大。
用户不喜欢不确定性。告诉他们"正在加载",他们会耐心等待;告诉他们"没有数据",他们会去找其他内容;什么都不告诉他们,他们会觉得 App 坏了。
好的 App 在每个状态下都有清晰的反馈,让用户始终知道发生了什么。这就是细节决定体验。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net