rn_for_openharmony狗狗之家app实战-图片上传实现
本文介绍了狗狗图片社区应用中图片上传功能的设计与实现思路。首先强调了让用户成为内容生产者的核心理念,分析了当前上传页面的基本框架结构。文章详细阐述了上传功能需要考虑的6个关键问题:图片来源、预览展示、附加信息、服务器传输、进度反馈和错误处理。在技术实现方面,重点讲解了状态管理设计、图片选择接口抽象、UI交互实现等核心环节,包括如何通过条件渲染显示不同状态、使用虚线边框提升用户体验、处理多平台差异等
案例开源地址:https://atomgit.com/nutpi/rn_openharmony_dogimg
让用户参与进来
做社区类 App 有个核心理念:用户不只是内容的消费者,也应该是内容的生产者。狗狗之家如果只有官方提供的图片,时间久了用户会觉得没意思。让用户上传自己家狗狗的照片,才能形成真正的社区氛围。
想象一下,用户拍了一张自家金毛的照片,上传到 App 里,其他用户看到后点赞、评论,这种互动带来的成就感是单纯浏览无法比拟的。
这篇文章讲讲图片上传功能的设计思路和实现方案。
当前页面的状态
先看看现在的 UploadPage 长什么样:
import React from 'react';
import {View, StyleSheet} from 'react-native';
import {Header, Empty} from '../../components';
导入部分很简单,就用到了 React 基础模块、View 和 StyleSheet,再加上我们自己封装的 Header 和 Empty 组件。
Empty 组件在前面的文章里讲过,是一个通用的空状态展示组件,支持自定义图标、标题和描述文字。
export function UploadPage() {
return (
<View style={s.container}>
<Header title="上传图片" />
<Empty icon="📤" title="上传你的狗狗照片" desc="功能开发中..." />
</View>
);
}
页面结构非常简单,一个 Header 加一个 Empty 占位。这种先搭框架后填内容的开发方式很常见,能让整个 App 的页面结构先跑通,后续再逐个完善功能。
图标用了 📤 这个 emoji,直观地表达"上传"的含义。
const s = StyleSheet.create({
container: {flex: 1, backgroundColor: '#f5f5f5'}
});
样式就一个容器,flex: 1 撑满屏幕,灰色背景和其他页面保持统一。
上传功能要解决什么问题
在动手写代码之前,先想清楚上传功能需要处理哪些事情:
图片从哪来? 用户可以从相册选择已有的照片,也可以现场拍一张。这两种方式都需要调用系统能力。
图片怎么展示? 选完图片后要给用户看一眼预览,确认没选错。
附加信息要不要? 可以让用户填写描述、选择品种,让上传的图片更有价值。
怎么发到服务器? 图片是二进制数据,不能像普通 JSON 那样直接发送,需要用 FormData。
上传过程怎么反馈? 图片文件比较大,上传需要时间,要让用户知道进度。
失败了怎么办? 网络不好、文件太大、格式不对,各种情况都要处理。
想清楚这些问题,实现起来就有方向了。
状态设计的思考
根据上面的分析,页面需要管理这些状态:
const [selectedImage, setSelectedImage] = useState<string | null>(null);
selectedImage 存储用户选中的图片路径。类型是 string | null,null 表示还没选图片。
为什么用 string 而不是更复杂的对象?因为 React Native 的 Image 组件只需要 uri 就能显示图片,其他信息可以在需要时再获取。
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
这两个状态配合使用。uploading 是布尔值,控制 UI 的整体状态切换;progress 是 0-100 的数字,显示具体进度。
分开管理而不是合成一个对象,是因为它们的更新频率不同。uploading 只在开始和结束时变化,progress 在上传过程中频繁更新。
const [description, setDescription] = useState('');
const [selectedBreed, setSelectedBreed] = useState<number | null>(null);
这两个是可选的附加信息。描述是字符串,品种 ID 是数字或 null。
设为可选是为了降低用户的操作成本。如果每次上传都要填一堆信息,用户会觉得麻烦。让用户快速上传,想填就填,不想填也行。
图片选择的接口设计
React Native 本身不提供图片选择功能,需要借助原生能力。先定义好接口:
interface ImagePickerResult {
uri: string;
width: number;
height: number;
type: string;
fileSize: number;
}
这个接口描述了选择图片后返回的数据结构:
- uri:图片的本地路径,用于显示和上传
- width/height:图片尺寸,可以用来判断是否需要压缩
- type:MIME 类型,如
image/jpeg - fileSize:文件大小,单位是字节
有了这些信息,就能做各种判断和处理了。
const pickImage = async (): Promise<ImagePickerResult | null> => {
// 这里调用原生模块
// 鸿蒙系统需要用 @ohos.multimedia.image
return null;
};
函数返回 Promise,因为选择图片是异步操作。返回 null 表示用户取消了选择。
不同平台的实现方式不同:
- iOS 用 UIImagePickerController
- Android 用 Intent.ACTION_PICK
- 鸿蒙 用 @ohos.multimedia.image 模块
这就是为什么要抽象成统一接口,上层代码不用关心平台差异。
选择区域的 UI 实现
用户点击选择图片的区域,需要根据状态显示不同内容:
<TouchableOpacity style={s.pickBtn} onPress={handlePick}>
{selectedImage ? (
<Image source={{uri: selectedImage}} style={s.preview} />
) : (
<View style={s.placeholder}>
<Text style={s.pickIcon}>📷</Text>
<Text style={s.pickText}>点击选择图片</Text>
</View>
)}
</TouchableOpacity>
这里用了条件渲染。selectedImage 有值时显示图片预览,没值时显示占位提示。
整个区域用 TouchableOpacity 包裹,点击任何位置都能触发选择。这比放一个小按钮体验好,用户不用精确点击。
占位区域用了相机 emoji 📷 和文字提示,让用户一眼就知道这里是干嘛的。
选择区域的样式
pickBtn: {
width: '100%',
aspectRatio: 1,
borderRadius: 16,
backgroundColor: '#fff',
overflow: 'hidden',
},
aspectRatio: 1 是个很实用的属性,让元素保持正方形。不管屏幕多宽,高度都等于宽度。
overflow: 'hidden' 配合 borderRadius 实现圆角裁剪。如果不加这个,图片会超出圆角区域。
placeholder: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: '#ddd',
borderStyle: 'dashed',
borderRadius: 16,
},
占位区域用了虚线边框,这是上传区域的经典设计。虚线给人一种"这里可以放东西"的暗示,比实线更有引导性。
flex: 1 让占位区域填满父容器,justifyContent 和 alignItems 让内容居中。
preview: {
width: '100%',
height: '100%',
},
预览图片填满整个选择区域,让用户看清楚选了什么。
处理图片选择
const handlePick = async () => {
const result = await pickImage();
if (result) {
setSelectedImage(result.uri);
}
};
逻辑很简单:调用选择器,有结果就更新状态。
if (result) 这个判断很重要。用户可能打开相册后又点了取消,这时 result 是 null,不应该更新状态。
描述输入框
让用户给图片加点描述:
<TextInput
style={s.input}
placeholder="添加描述(可选)"
value={description}
onChangeText={setDescription}
multiline
maxLength={200}
/>
placeholder 里写了"可选",降低用户心理压力。
multiline 允许换行,用户可以写长一点的描述。
maxLength 限制 200 字,防止有人写小作文。这个限制也要在服务端做,前端限制只是优化体验。
input: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
fontSize: 15,
minHeight: 100,
textAlignVertical: 'top',
},
minHeight: 100 给输入框一个最小高度,看起来像个文本域而不是单行输入框。
textAlignVertical: 'top' 让光标从顶部开始,这是多行输入框的常见设置。Android 上默认是居中的,不加这个会很奇怪。
品种选择器
const [breeds, setBreeds] = useState<Breed[]>([]);
const [showPicker, setShowPicker] = useState(false);
useEffect(() => {
api.getBreeds().then(setBreeds);
}, []);
页面加载时获取品种列表,存到状态里供选择器使用。
api.getBreeds() 是之前封装好的接口,返回所有狗狗品种。
<TouchableOpacity style={s.breedPicker} onPress={() => setShowPicker(true)}>
<Text style={s.breedLabel}>
{selectedBreed
? breeds.find(b => b.id === selectedBreed)?.name
: '选择品种(可选)'}
</Text>
<Text style={s.arrow}>▼</Text>
</TouchableOpacity>
选择器的触发按钮。点击后设置 showPicker 为 true,打开选择弹窗。
显示文字的逻辑:如果已选择品种,从 breeds 数组里找到对应的名字显示;否则显示提示文字。
breeds.find(b => b.id === selectedBreed)?.name 用了可选链,防止找不到时报错。
上传按钮的状态处理
<TouchableOpacity
style={[s.uploadBtn, !selectedImage && s.uploadBtnDisabled]}
onPress={handleUpload}
disabled={!selectedImage || uploading}
>
<Text style={s.uploadBtnText}>
{uploading ? `上传中 ${progress}%` : '上传'}
</Text>
</TouchableOpacity>
按钮有三种状态:
- 可点击:选了图片,没在上传
- 禁用(没选图片):样式变灰,点击无效
- 上传中:显示进度,点击无效
style={[s.uploadBtn, !selectedImage && s.uploadBtnDisabled]} 用数组合并样式。第二个元素是条件表达式,没选图片时才加上禁用样式。
disabled={!selectedImage || uploading} 两种情况都禁用点击。
上传按钮样式
uploadBtn: {
backgroundColor: '#D2691E',
paddingVertical: 16,
borderRadius: 12,
alignItems: 'center',
marginTop: 20,
},
用 App 的主题色 #D2691E(巧克力棕),和整体风格统一。
paddingVertical: 16 让按钮有足够的点击区域,手指粗的用户也能轻松点到。
uploadBtnDisabled: {
backgroundColor: '#ccc',
},
禁用状态变成灰色,视觉上告诉用户"现在不能点"。
uploadBtnText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
白色文字在深色背景上清晰可见。fontWeight: '600' 是半粗体,比普通文字更醒目。
构建上传数据
图片上传不能用普通的 JSON,要用 FormData:
const formData = new FormData();
formData.append('file', {
uri: selectedImage,
type: 'image/jpeg',
name: 'upload.jpg',
} as any);
FormData 是浏览器的 API,React Native 也支持。它能把文件和普通字段打包在一起发送。
append 的第二个参数是个对象,包含文件的 uri、类型和文件名。as any 是为了绑过 TypeScript 的类型检查,因为 RN 的 FormData 类型定义不太完善。
if (description) {
formData.append('sub_id', description);
}
if (selectedBreed) {
formData.append('breed_ids', selectedBreed.toString());
}
可选字段只在有值时才添加。selectedBreed 是数字,要转成字符串。
The Dog API 用 sub_id 字段存储用户自定义的标识,我们用它来存描述。
发送上传请求
const response = await fetch('https://api.thedogapi.com/v1/images/upload', {
method: 'POST',
headers: {
'x-api-key': 'YOUR_API_KEY',
'Content-Type': 'multipart/form-data',
},
body: formData,
});
上传接口需要 API Key,免费用户也能用,只是有频率限制。
Content-Type 设为 multipart/form-data,告诉服务器这是文件上传请求。
实际上 fetch 会自动设置这个 header,而且会加上 boundary 参数。手动设置可能反而出问题,这里写出来是为了说明原理。
处理上传结果
if (response.ok) {
Alert.alert('成功', '图片上传成功!', [
{text: '继续上传', onPress: resetForm},
{text: '返回', onPress: () => goBack()},
]);
} else {
const error = await response.json();
throw new Error(error.message || '上传失败');
}
response.ok 是 true 表示状态码在 200-299 之间,即请求成功。
成功后弹窗让用户选择:继续上传还是返回。这比直接返回更友好,用户可能想连续上传多张。
失败时尝试解析错误信息。服务端通常会返回具体的错误原因,比如"文件太大"、"格式不支持"等。
重置表单
const resetForm = () => {
setSelectedImage(null);
setDescription('');
setSelectedBreed(null);
setProgress(0);
};
上传成功后清空所有输入,让用户可以上传下一张。
每个状态都要重置,漏掉一个都会导致问题。比如忘了重置 selectedImage,下次打开页面还会显示上次的图片。
上传进度的实现
fetch API 不支持上传进度,要用 XMLHttpRequest:
const uploadWithProgress = (formData: FormData): Promise<any> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
setProgress(percent);
}
};
xhr.upload.onprogress 是上传进度的回调,会在上传过程中多次触发。
event.loaded 是已上传的字节数,event.total 是总字节数。除一下再乘 100 就是百分比。
event.lengthComputable 判断总大小是否已知,有些情况下服务器不返回这个信息。
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`HTTP ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error('网络错误'));
xhr.ontimeout = () => reject(new Error('请求超时'));
onload 在请求完成时触发,不管成功还是失败。要根据状态码判断。
onerror 在网络层面出错时触发,比如断网。
ontimeout 在超时时触发,需要配合 xhr.timeout 设置。
xhr.open('POST', 'https://api.thedogapi.com/v1/images/upload');
xhr.setRequestHeader('x-api-key', 'YOUR_API_KEY');
xhr.timeout = 60000; // 60秒超时
xhr.send(formData);
});
};
xhr.timeout = 60000 设置 60 秒超时。图片上传可能比较慢,给足够的时间。
图片压缩的必要性
用户手机拍的照片动辄好几 MB,直接上传太慢了。压缩一下能大幅提升体验:
const compressImage = async (uri: string): Promise<string> => {
// 获取图片信息
const info = await getImageInfo(uri);
// 计算目标尺寸,最大边不超过 1080
const maxSize = 1080;
let targetWidth = info.width;
let targetHeight = info.height;
if (info.width > maxSize || info.height > maxSize) {
const ratio = Math.min(maxSize / info.width, maxSize / info.height);
targetWidth = Math.round(info.width * ratio);
targetHeight = Math.round(info.height * ratio);
}
// 调用原生压缩模块
return await nativeCompress(uri, targetWidth, targetHeight, 0.8);
};
压缩策略:
- 尺寸限制:最大边不超过 1080 像素,够用了
- 质量参数:0.8 表示 80% 质量,肉眼几乎看不出差别
- 保持比例:等比缩放,不会变形
一张 4MB 的照片压缩后可能只有 200KB,上传速度提升 20 倍。
权限申请
访问相册需要用户授权:
const checkPermission = async (): Promise<boolean> => {
// 检查当前权限状态
const status = await getPermissionStatus('photo');
if (status === 'granted') {
return true;
}
if (status === 'denied') {
// 用户之前拒绝过,引导去设置页
Alert.alert(
'需要相册权限',
'请在设置中允许访问相册',
[{text: '去设置', onPress: openSettings}, {text: '取消'}]
);
return false;
}
// 首次请求
const result = await requestPermission('photo');
return result === 'granted';
};
权限有三种状态:
- granted:已授权,可以直接用
- denied:已拒绝,需要引导用户去设置页手动开启
- undetermined:未决定,可以弹窗请求
不同平台的权限名称不同:
- iOS:
NSPhotoLibraryUsageDescription - Android:
READ_EXTERNAL_STORAGE或READ_MEDIA_IMAGES(Android 13+) - 鸿蒙:
ohos.permission.READ_MEDIA
错误处理的完善
上传可能遇到各种问题,要给用户明确的提示:
const handleUploadError = (error: Error) => {
let title = '上传失败';
let message = '请稍后重试';
if (error.message.includes('network') || error.message.includes('网络')) {
title = '网络错误';
message = '请检查网络连接后重试';
} else if (error.message.includes('timeout') || error.message.includes('超时')) {
title = '上传超时';
message = '网络较慢,请稍后重试';
} else if (error.message.includes('too large') || error.message.includes('过大')) {
title = '文件过大';
message = '请选择小于 10MB 的图片';
} else if (error.message.includes('format') || error.message.includes('格式')) {
title = '格式不支持';
message = '请选择 JPG 或 PNG 格式的图片';
}
Alert.alert(title, message);
};
根据错误信息匹配不同的提示。虽然不够精确,但比统一显示"上传失败"好多了。
更好的做法是服务端返回错误码,前端根据错误码显示对应文案。
小结
图片上传看起来简单,实际涉及不少东西:
- 原生能力调用:图片选择、相机、权限
- 文件处理:压缩、格式转换
- 网络请求:FormData、进度监听
- 用户体验:状态反馈、错误提示
当前页面是占位实现,完整功能需要配合原生模块。但设计思路和接口定义都在这了,后续开发有据可依。
下一篇讲投票功能,让用户给狗狗图片打分,是另一种有趣的互动方式。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐




所有评论(0)