rn_for_openharmony狗狗之家app实战-热门图片实现
本文介绍了如何开发一个热门狗狗图片推荐页面,重点分析了数据驱动的内容推荐机制和代码实现要点。主要内容包括: 页面功能设计:基于用户投票数据展示热门图片,形成"投票→统计→推荐"闭环 代码结构分析:展示TopImagesPage的初始实现,使用Header和Empty组件占位 状态管理方案:对比GalleryPage与热门页面的状态设计差异,新增排序和时间范围参数 API接口扩展
案例开源地址:https://atomgit.com/nutpi/rn_openharmony_dogimg
数据驱动的内容推荐
上一篇讲了投票功能,用户的每一次投票都是宝贵的数据。热门图片页面就是这些数据的呈现,展示得票最高的狗狗图片。
这种"用户投票 → 数据统计 → 热门推荐"的闭环,是很多内容平台的核心逻辑。用户既是内容的消费者,也是内容的筛选者。
当前页面的代码
TopImagesPage 目前是个占位:
import React from 'react';
import {View, StyleSheet} from 'react-native';
import {Header, Empty} from '../../components';
导入了 React、RN 基础组件,以及我们封装的 Header 和 Empty。
这三行导入在项目里出现频率很高,几乎每个页面都有。可以考虑在某个地方统一导出,减少重复代码。但为了保持每个文件的独立性,目前还是分开写。
export function TopImagesPage() {
return (
<View style={s.container}>
<Header title="热门图片" />
<Empty icon="🔥" title="最受欢迎的狗狗图片" desc="功能开发中..." />
</View>
);
}
🔥 这个图标选得很贴切,"热门"嘛,火热的意思。
标题"最受欢迎的狗狗图片"比单纯的"暂无数据"更有信息量,告诉用户这个页面将来会展示什么。
const s = StyleSheet.create({
container: {flex: 1, backgroundColor: '#f5f5f5'}
});
灰色背景,和其他页面保持一致。
复用 GalleryPage 的模式
热门图片和普通图库的展示方式很像,都是图片网格。看看 GalleryPage 的实现:
import React, {useState, useEffect} from 'react';
import {View, ScrollView, StyleSheet, RefreshControl} from 'react-native';
import {Header, Loading, Empty, ImageCard} from '../../components';
import {useNavigation, useStore} from '../../hooks';
import {api} from '../../api';
import type {DogImage} from '../../types';
导入分成几组:
React 相关:useState、useEffect 是最常用的两个 Hook。
RN 组件:View 是容器,ScrollView 是滚动容器,RefreshControl 是下拉刷新。
业务组件:Header、Loading、Empty、ImageCard 都是我们封装的。
自定义 Hook:useNavigation 处理导航,useStore 处理全局状态。
API 和类型:api 是接口封装,DogImage 是类型定义。
这种分组方式让导入部分更清晰,一眼就能看出依赖了哪些模块。
状态设计的对比
GalleryPage 的状态:
const {navigate} = useNavigation();
const {favoriteImages, toggleImage} = useStore();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [images, setImages] = useState<DogImage[]>([]);
热门图片页面可以复用这套状态,再加上排序相关的:
const [sortBy, setSortBy] = useState<'votes' | 'recent'>('votes');
const [timeRange, setTimeRange] = useState<'day' | 'week' | 'month'>('week');
sortBy 控制排序方式:按投票数还是按时间。
timeRange 控制时间范围:今日热门、本周热门、本月热门。
这两个筛选条件能让用户看到不同维度的热门内容。
数据加载的差异
GalleryPage 加载随机图片:
useEffect(() => { loadData(); }, []);
const loadData = async () => {
try {
const res = await api.getImages(20);
setImages(res);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
setRefreshing(false);
}
};
api.getImages(20) 获取 20 张随机图片。
热门图片需要不同的接口,按投票数排序:
const loadTopImages = async () => {
try {
const res = await api.getTopImages({
limit: 20,
order: sortBy,
range: timeRange,
});
setImages(res);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
setRefreshing(false);
}
};
参数里带上排序方式和时间范围,服务端返回对应的数据。
API 接口的扩展
当前的 api.ts 只有三个接口:
export const api = {
getBreeds: (limit?: number) => http.get<Breed[]>('/breeds', {limit}),
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',
}),
};
需要加一个获取热门图片的接口:
getTopImages: (params: {limit?: number; order?: string; range?: string}) =>
http.get<DogImage[]>('/images', {
...params,
order: 'DESC',
sort: 'votes',
}),
The Dog API 的 /images 接口支持按投票数排序,需要 API Key 才能访问。
ImageCard 组件的复用
GalleryPage 用 ImageCard 展示每张图片:
{images.map(img => (
<ImageCard
key={img.id}
image={img}
liked={favoriteImages.includes(img.id)}
onLike={() => toggleImage(img.id)}
onPress={() => navigate('ImageView', {image: img})}
/>
))}
四个属性:
key:React 列表渲染必须的唯一标识。
image:图片数据,包含 id 和 url。
liked:是否已收藏,从全局状态判断。
onLike:点击收藏按钮的回调。
onPress:点击卡片的回调,跳转到详情页。
热门图片页面可以直接复用,不需要改动。
ImageCard 的实现细节
const W = (Dimensions.get('window').width - 48) / 2;
这行代码计算卡片宽度。屏幕宽度减去 48(左右各 16 的 padding,中间 16 的间距),再除以 2,得到两列布局下每张卡片的宽度。
为什么在组件外部计算?因为 Dimensions.get('window') 的结果在 App 运行期间不会变(除非屏幕旋转),放在外部只计算一次,避免每次渲染都重复计算。
interface Props {
image: DogImage;
liked?: boolean;
onPress?: () => void;
onLike?: () => void;
}
Props 接口定义了组件接受的属性。
image 是必传的,其他都是可选的。这种设计让组件在不同场景下都能用:
- 只展示图片:
<ImageCard image={img} /> - 带收藏功能:
<ImageCard image={img} liked={true} onLike={handleLike} /> - 带点击跳转:
<ImageCard image={img} onPress={handlePress} />
export function ImageCard({image, liked, onPress, onLike}: Props) {
return (
<TouchableOpacity style={s.card} onPress={onPress} activeOpacity={0.9}>
<Image source={{uri: image.url}} style={s.img} />
{onLike && (
<TouchableOpacity style={s.like} onPress={onLike}>
<Text style={s.heart}>{liked ? '❤️' : '🤍'}</Text>
</TouchableOpacity>
)}
</TouchableOpacity>
);
}
外层 TouchableOpacity 让整个卡片可点击。activeOpacity={0.9} 让点击时的透明度变化不那么明显,视觉上更舒服。
收藏按钮用条件渲染 {onLike && ...},只有传了 onLike 才显示。这样在不需要收藏功能的场景,按钮就不会出现。
ImageCard 的样式
card: {
width: W,
height: W * 1.2,
borderRadius: 12,
overflow: 'hidden',
marginBottom: 12
},
宽度是计算出来的 W,高度是宽度的 1.2 倍,形成竖向的卡片比例。
overflow: 'hidden' 配合 borderRadius 实现圆角裁剪。如果不加这个,图片会超出圆角区域。
img: {
width: '100%',
height: '100%',
backgroundColor: '#f0f0f0'
},
图片填满整个卡片。backgroundColor 在图片加载前显示,避免空白。
like: {
position: 'absolute',
bottom: 8,
right: 8,
backgroundColor: 'rgba(0,0,0,0.3)',
borderRadius: 16,
padding: 6
},
heart: {fontSize: 18},
收藏按钮用绝对定位浮在图片右下角。
半透明黑色背景 rgba(0,0,0,0.3) 让按钮在任何颜色的图片上都清晰可见。
网格布局的实现
GalleryPage 的网格样式:
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
padding: 16,
justifyContent: 'space-between'
},
flexDirection: ‘row’ 让子元素横向排列。
flexWrap: ‘wrap’ 允许换行,一行放不下就换到下一行。
justifyContent: ‘space-between’ 让两列之间自动分配间距。
这三个属性组合起来,就实现了两列的网格布局,不需要额外的布局库。
热门图片的排名展示
热门图片应该显示排名,让用户知道这是第几名:
{images.map((img, index) => (
<View key={img.id} style={s.cardWrapper}>
<ImageCard
image={img}
liked={favoriteImages.includes(img.id)}
onLike={() => toggleImage(img.id)}
onPress={() => navigate('ImageView', {image: img})}
/>
<View style={s.rank}>
<Text style={s.rankText}>{index + 1}</Text>
</View>
</View>
))}
用 View 包裹 ImageCard,在左上角加一个排名标签。
index + 1 因为数组索引从 0 开始,排名从 1 开始。
cardWrapper: {
position: 'relative',
},
rank: {
position: 'absolute',
top: 8,
left: 8,
backgroundColor: '#D2691E',
borderRadius: 12,
paddingHorizontal: 8,
paddingVertical: 4,
},
rankText: {
color: '#fff',
fontSize: 12,
fontWeight: '600',
},
排名标签用主题色背景,白色文字,圆角胶囊形状。
放在左上角,和右下角的收藏按钮形成对角呼应。
投票数的展示
除了排名,还可以显示具体的投票数:
interface TopImage extends DogImage {
votes: number;
}
扩展 DogImage 类型,加上 votes 字段。
<View style={s.voteInfo}>
<Text style={s.voteIcon}>👍</Text>
<Text style={s.voteCount}>{img.votes}</Text>
</View>
在卡片底部显示投票数。
voteInfo: {
position: 'absolute',
bottom: 8,
left: 8,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 10,
paddingHorizontal: 8,
paddingVertical: 4,
},
voteIcon: {fontSize: 12, marginRight: 4},
voteCount: {color: '#fff', fontSize: 12},
和收藏按钮类似的样式,放在左下角。
筛选栏的设计
页面顶部加一个筛选栏,让用户切换时间范围:
<View style={s.filters}>
<TouchableOpacity
style={[s.filterBtn, timeRange === 'day' && s.filterBtnActive]}
onPress={() => setTimeRange('day')}
>
<Text style={[s.filterText, timeRange === 'day' && s.filterTextActive]}>
今日
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[s.filterBtn, timeRange === 'week' && s.filterBtnActive]}
onPress={() => setTimeRange('week')}
>
<Text style={[s.filterText, timeRange === 'week' && s.filterTextActive]}>
本周
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[s.filterBtn, timeRange === 'month' && s.filterBtnActive]}
onPress={() => setTimeRange('month')}
>
<Text style={[s.filterText, timeRange === 'month' && s.filterTextActive]}>
本月
</Text>
</TouchableOpacity>
</View>
三个按钮,选中的那个高亮显示。
style={[s.filterBtn, timeRange === 'day' && s.filterBtnActive]} 用数组合并样式,条件为真时才加上 active 样式。
筛选栏样式
filters: {
flexDirection: 'row',
backgroundColor: '#fff',
paddingVertical: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
filterBtn: {
paddingHorizontal: 16,
paddingVertical: 6,
borderRadius: 16,
marginRight: 12,
},
filterBtnActive: {
backgroundColor: '#FFF3E0',
},
filterText: {
fontSize: 14,
color: '#666',
},
filterTextActive: {
color: '#D2691E',
fontWeight: '600',
},
筛选栏白色背景,底部有条细线分隔。
按钮默认是透明的,选中后变成浅橙色背景。
文字默认灰色,选中后变成主题色并加粗。
切换筛选时重新加载
useEffect(() => {
setLoading(true);
loadTopImages();
}, [timeRange, sortBy]);
当 timeRange 或 sortBy 变化时,重新加载数据。
setLoading(true) 先显示 Loading,避免用户看到旧数据。
下拉刷新
<ScrollView
style={s.content}
contentContainerStyle={s.grid}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={loadData} />
}
>
RefreshControl 是 RN 内置的下拉刷新组件。
refreshing 控制刷新指示器的显示。
onRefresh 是下拉触发的回调。
用户下拉时会看到一个转圈的指示器,松手后触发 onRefresh,加载完成后指示器消失。
空状态处理
{images.length > 0 ? images.map(...) : <Empty icon="📷" title="暂无图片" />}
如果图片列表为空,显示 Empty 组件。
热门图片页面可以定制一下空状态:
<Empty
icon="🔥"
title="暂无热门图片"
desc="快去投票,让你喜欢的狗狗上榜吧!"
btnText="去投票"
onPress={() => navigate('Vote')}
/>
加上引导按钮,把用户带到投票页面。这样空状态也能产生价值,而不是死胡同。
DogImage 类型的结构
export interface DogImage {
id: string;
url: string;
breeds?: Breed[];
}
id 是字符串类型,The Dog API 返回的图片 ID 是字符串。
url 是图片地址,直接用于 Image 组件的 source。
breeds 是可选的品种数组,有些图片会关联品种信息。
为什么 breeds 是数组?因为一张图片可能包含多只不同品种的狗。虽然大多数情况只有一个品种,但接口设计要考虑边界情况。
加载更多的实现
当前用 ScrollView,一次加载所有图片。如果图片很多,可以改成分页加载:
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const loadMore = async () => {
if (!hasMore || loading) return;
setLoading(true);
try {
const res = await api.getTopImages({
limit: 20,
page: page + 1,
range: timeRange,
});
if (res.length < 20) {
setHasMore(false);
}
setImages([...images, ...res]);
setPage(page + 1);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
page 记录当前页码。
hasMore 标记是否还有更多数据。
返回的数据少于请求的数量,说明没有更多了。
setImages([...images, ...res]) 把新数据追加到现有数据后面。
滚动到底部触发加载
<ScrollView
onScroll={({nativeEvent}) => {
const {layoutMeasurement, contentOffset, contentSize} = nativeEvent;
const isBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - 20;
if (isBottom) {
loadMore();
}
}}
scrollEventThrottle={400}
>
layoutMeasurement.height 是可视区域高度。
contentOffset.y 是滚动距离。
contentSize.height 是内容总高度。
当可视区域高度 + 滚动距离 >= 内容总高度 - 20 时,说明快到底了,触发加载。
scrollEventThrottle={400} 限制 onScroll 的触发频率,避免性能问题。
小结
热门图片页面的实现要点:
- 复用现有组件:ImageCard、Empty 等组件可以直接用
- 扩展数据展示:加上排名、投票数等信息
- 筛选功能:让用户切换不同的时间范围
- 空状态引导:把用户带到投票页面,形成闭环
- 分页加载:数据量大时按需加载,优化性能
热门图片是投票数据的呈现,两个功能相互配合,形成完整的用户参与体系。
下一篇进入领养模块,讲领养列表页面的实现。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐




所有评论(0)