rn_for_openharmony狗狗之家app实战-品种列表实现
本文介绍了狗狗之家App的品种模块实现,重点包括HTTP工具封装、API接口设计和品种列表页面开发。首先通过HttpClient类封装了基础的GET请求方法,支持参数拼接和错误处理。然后定义了获取品种列表、搜索品种等API接口,并详细说明了Breed数据类型的结构。在品种列表页面中,使用useState管理加载状态和数据,实现首次加载和下拉刷新功能,并通过本地筛选处理搜索逻辑。代码展示了React
案例开源地址: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;
id和name是必有的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是原产地weight和height是体重身高,嵌套对象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';
引入比较多,分类看:
- React:
useState管理状态,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:搜索关键词
loading 和 refreshing 分开管理,因为它们的 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 里同时设置 loading 和 refreshing?因为这个函数既用于首次加载,也用于下拉刷新。不管哪种情况,结束后都要关闭对应的状态。
本地筛选
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 内置的下拉刷新组件,作为 ScrollView 的 refreshControl 属性传入。
两个关键属性:
- 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
更多推荐




所有评论(0)