rn_for_openharmony_空状态与加载状态:别让用户对着白屏发呆

案例项目开源地址: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 的属性,用于在列表底部显示内容。

loadingtrue 时,显示一个 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 的 ListEmptyComponentListFooterComponent,逻辑稍有不同,但思路是一样的。

骨架屏的进阶方案

加载指示器是最简单的方案,更好的方案是骨架屏(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

相关推荐
SameX2 小时前
鸿蒙应用的“任意门”:Deep Linking 与 App Linking 的相爱相杀
harmonyos
AlbertZein2 小时前
HarmonyOS一杯冰美式的时间 -- @Watch 到 @Monitor
harmonyos
城东米粉儿2 小时前
JobScheduler 相关笔记
android
程序员Agions2 小时前
别再只会 console.log 了!这 15 个 Console 调试技巧,让你的 Debug 效率翻倍
前端·javascript
城东米粉儿2 小时前
android 耗电优化 笔记
android
张小潇2 小时前
AOSP15的Zygote启动流程源码分析
android
我的div丢了肿么办2 小时前
vue使用h函数封装dialog组件,以命令的形式使用dialog组件
前端·javascript·vue.js
UIUV2 小时前
Git 提交规范与全栈AI驱动开发实战:从基础到高级应用
前端·javascript·后端
毕设源码-钟学长2 小时前
【开题答辩全过程】以 基于安卓的医疗健康查询系统为例,包含答辩的问题和答案
android