请添加图片描述

案例开源地址:https://atomgit.com/nutpi/rn_openharmony_dogimg

进入核心功能

前面几篇讲的都是相对简单的页面,从这篇开始进入狗狗之家的核心功能——品种模块。品种列表是用户浏览狗狗信息的主入口,数据来自 The Dog API,涉及到网络请求、列表渲染、本地筛选、下拉刷新等功能。

这个页面的代码量不大,但知识点挺密集的。我会从 HTTP 工具封装讲起,一直讲到页面的完整实现。

HTTP 工具的封装

网络请求是 App 开发的基础设施,先看看 http.ts 是怎么封装的:

const API_BASE_URL = 'https://api.thedogapi.com/v1';

基础 URL 定义成常量,所有请求都基于这个地址。The Dog API 是免费的狗狗数据接口,不需要 API Key 也能用(有限制)。

HttpClient 类的定义:

class HttpClient {
  private baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

用类来封装,baseUrl 通过构造函数传入。这样设计的好处是,如果以后要对接其他 API,再 new 一个实例就行。

URL 构建方法

  private buildUrl(endpoint: string, params?: Record<string, any>): string {
    let url = `${this.baseUrl}${endpoint}`;

buildUrl 是私有方法,负责拼接完整的请求 URL。先把 baseUrl 和 endpoint 拼起来。

处理查询参数:

    if (params) {
      const query = Object.entries(params)
        .filter(([_, v]) => v !== undefined)
        .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
        .join('&');
      if (query) url += `?${query}`;
    }
    return url;
  }

这段代码做了几件事:

  • Object.entries 把对象转成 [key, value] 数组
  • filter 过滤掉值为 undefined 的参数
  • map 把每对键值转成 key=value 格式,用 encodeURIComponent 编码
  • join('&')& 连接所有参数

最后如果有参数,就加上 ? 拼到 URL 后面。

GET 请求方法

  async get<T>(endpoint: string, params?: Record<string, any>): Promise<T> {
    const url = this.buildUrl(endpoint, params);
    const res = await fetch(url);

get 方法是泛型函数,T 是返回数据的类型。调用时指定类型,返回值就有类型提示了。

用原生 fetch 发请求,没有用 axios 这类库。原因还是兼容性,自己用 fetch 写更可控。

错误处理和返回:

    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  }
}

res.ok 是 fetch 的属性,状态码在 200-299 范围内为 true。不 ok 就抛错,调用方用 try-catch 捕获。

res.json() 解析 JSON 响应体,返回 Promise。

导出实例:

export const http = new HttpClient(API_BASE_URL);

创建一个实例导出,整个 App 共用。

API 接口定义

有了 http 工具,再封装具体的接口:

import {http} from './utils/http';
import type {Breed, DogImage} from './types';

export const api = {
  getBreeds: (limit?: number) => http.get<Breed[]>('/breeds', {limit}),

getBreeds 获取品种列表,limit 参数可选,不传就返回全部(大概 170 多个品种)。

返回类型是 Breed[],品种数组。

其他接口:

  searchBreeds: (q: string) => http.get<Breed[]>('/breeds/search', {q}),
  getImages: (limit = 10, breedId?: number) =>
    http.get<DogImage[]>('/images/search', {
      limit,
      breed_ids: breedId,
      order: 'RANDOM',
    }),
};
  • searchBreeds 搜索品种,传关键词
  • getImages 获取图片,可以按品种筛选,默认随机排序

接口定义集中在一个文件里,方便管理和维护。

Breed 类型定义

看看品种数据的类型:

export interface Breed {
  id: number;
  name: string;
  bred_for?: string;
  breed_group?: string;
  life_span: string;
  • idname 是必有的
  • bred_for 是培育目的,如 “Hunting” “Guarding”
  • breed_group 是品种分组,如 “Working” “Sporting”
  • life_span 是寿命范围,如 “10 - 12 years”

继续:

  temperament?: string;
  origin?: string;
  weight: { metric: string };
  height: { metric: string };
  reference_image_id?: string;
  image?: { url: string };
}
  • temperament 是性格特点,逗号分隔的字符串
  • origin 是原产地
  • weightheight 是体重身高,嵌套对象
  • image 是图片信息,有的品种没有

? 的是可选字段,使用时要注意判空。

品种列表页的引入

import React, {useState, useEffect} from 'react';
import {View, ScrollView, StyleSheet, RefreshControl} from 'react-native';
import {Header, Loading, SearchBar, BreedCard} from '../../components';
import {useNavigation} from '../../hooks';
import {api} from '../../api';
import type {Breed} from '../../types';

引入比较多,分类看:

  • ReactuseState 管理状态,useEffect 处理副作用
  • RN 组件RefreshControl 是下拉刷新控件
  • 自定义组件:Header、Loading、SearchBar、BreedCard
  • 工具:useNavigation、api、Breed 类型

状态定义

export function BreedsPage() {
  const {navigate} = useNavigation();
  const [loading, setLoading] = useState(true);
  const [refreshing, setRefreshing] = useState(false);
  const [breeds, setBreeds] = useState<Breed[]>([]);
  const [search, setSearch] = useState('');

四个状态:

  • loading:首次加载状态,初始 true
  • refreshing:下拉刷新状态,初始 false
  • breeds:品种数据数组
  • search:搜索关键词

loadingrefreshing 分开管理,因为它们的 UI 表现不同。首次加载显示全屏 Loading,下拉刷新只在顶部显示转圈。

数据加载

  useEffect(() => { loadData(); }, []);

  const loadData = async () => {
    try {
      const res = await api.getBreeds();
      setBreeds(res);
    } catch (e) {
      console.error(e);
    } finally {
      setLoading(false);
      setRefreshing(false);
    }
  };

useEffect 依赖数组为空,只在挂载时执行一次。

loadData 函数:

  • 调用 api.getBreeds() 获取数据
  • 成功后 setBreeds 更新状态
  • 失败打印错误(实际项目应该给用户提示)
  • finally 里关闭两个加载状态

为什么 finally 里同时设置 loadingrefreshing?因为这个函数既用于首次加载,也用于下拉刷新。不管哪种情况,结束后都要关闭对应的状态。

本地筛选

  const filtered = search 
    ? breeds.filter(b => b.name.toLowerCase().includes(search.toLowerCase())) 
    : breeds;

这行代码实现了本地搜索功能。

如果 search 有值,就过滤 breeds 数组,只保留名称包含关键词的品种。toLowerCase() 让搜索不区分大小写。

如果 search 为空,直接返回全部数据。

为什么用本地筛选而不是调接口?因为品种数据量不大(170 多条),一次性加载完,本地筛选响应更快,用户体验更好。

页面渲染结构

  return (
    <View style={s.container}>
      <Header title="🐕 品种大全" showBack={false} />
      <SearchBar value={search} onChange={setSearch} placeholder="搜索品种..." />

Header 不显示返回按钮,因为这是 Tab 页面。

SearchBar 绑定 search 状态,输入时实时更新,列表自动过滤。

条件渲染列表:

      {loading ? <Loading full /> : (
        <ScrollView 
          style={s.list} 
          refreshControl={
            <RefreshControl refreshing={refreshing} onRefresh={loadData} />
          }
        >
          {filtered.map(b => (
            <BreedCard 
              key={b.id} 
              breed={b} 
              onPress={() => navigate('BreedDetail', {breed: b})} 
            />
          ))}
        </ScrollView>
      )}
    </View>
  );
}

loading 为 true 显示全屏 Loading,否则显示列表。

下拉刷新的实现

<RefreshControl refreshing={refreshing} onRefresh={loadData} />

RefreshControl 是 RN 内置的下拉刷新组件,作为 ScrollViewrefreshControl 属性传入。

两个关键属性:

  • refreshing:是否正在刷新,控制转圈动画
  • onRefresh:下拉触发的回调,这里复用 loadData 函数

用户下拉时,onRefresh 被调用,loadData 里会设置 setRefreshing(true)… 等等,代码里没有这行?

确实没有。这是个小问题,应该在 loadData 开头加上:

const loadData = async () => {
  // 应该加这行,但下拉刷新时 refreshing 已经被 RefreshControl 自动设为 true
  try {

实际上 RefreshControl 在触发 onRefresh 时会自动把 refreshing 设为 true,所以不加也能工作。但为了代码清晰,最好还是显式设置。

Loading 组件

export function Loading({full = false}: {full?: boolean}) {
  return (
    <View style={[s.box, full && s.full]}>
      <ActivityIndicator size="large" color="#D2691E" />
      <Text style={s.text}>加载中...</Text>
    </View>
  );
}

full 参数控制是否全屏显示。full && s.full 是条件样式,full 为 true 时才应用 s.full

ActivityIndicator 是 RN 内置的转圈组件,color 设为主题色。

样式:

const s = StyleSheet.create({
  box: {padding: 20, alignItems: 'center'},
  full: {flex: 1, justifyContent: 'center'},
  text: {marginTop: 10, fontSize: 14, color: '#999'},
});
  • 默认只有 padding 和居中
  • full 模式加上 flex: 1 和垂直居中,撑满整个容器

列表项的渲染

{filtered.map(b => (
  <BreedCard 
    key={b.id} 
    breed={b} 
    onPress={() => navigate('BreedDetail', {breed: b})} 
  />
))}

map 遍历过滤后的数组,每个品种渲染一个 BreedCard

key 用品种 ID,保证唯一性。

点击时跳转到详情页,把整个 breed 对象传过去。这样详情页不用再请求一次,直接用传过来的数据。

页面样式

const s = StyleSheet.create({
  container: {flex: 1, backgroundColor: '#f5f5f5'},
  list: {flex: 1},
});

样式很简单,容器撑满屏幕,列表也撑满。背景色浅灰。

大部分样式在 BreedCard 组件里,列表页只负责布局。

为什么不用 FlatList

你可能注意到了,这里用的是 ScrollView + map,而不是 FlatList

FlatList 的优势是虚拟列表,只渲染可见区域的元素,适合长列表。

但这个页面:

  • 数据量不大,170 多条
  • 每个卡片高度固定
  • 没有复杂的动画

ScrollView 完全够用,代码也更简单。

如果列表有几千条数据,或者卡片内容很复杂,就应该换成 FlatList 了。

搜索的用户体验

当前的搜索是实时过滤,用户每输入一个字符,列表就更新一次。

好处是响应快,用户能立即看到结果。

潜在问题是,如果数据量大或者过滤逻辑复杂,可能会卡顿。解决办法是加防抖

// 伪代码,实际没实现
const debouncedSearch = useMemo(
  () => debounce((text) => setSearch(text), 300),
  []
);

用户停止输入 300ms 后才执行过滤,减少不必要的计算。

当前数据量小,不加防抖也很流畅。

错误处理的改进空间

当前的错误处理只是 console.error,用户看不到任何提示。

更好的做法:

const [error, setError] = useState<string | null>(null);

const loadData = async () => {
  try {
    const res = await api.getBreeds();
    setBreeds(res);
    setError(null);
  } catch (e) {
    setError('加载失败,请检查网络');
  }
  // ...
};

然后在 UI 里显示错误信息,并提供重试按钮。

这个后续可以优化,先保证核心功能跑通。

小结

品种列表页涉及的知识点:

  • HTTP 工具封装:基于 fetch,支持 URL 参数拼接
  • API 接口定义:集中管理,类型安全
  • 状态管理:loading、refreshing、数据、搜索词
  • 本地筛选:不区分大小写的模糊匹配
  • 下拉刷新:RefreshControl 组件的使用
  • Loading 组件:支持全屏和局部两种模式

下一篇讲品种详情页,会展示更丰富的品种信息,还有收藏功能的实现。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐