请添加图片描述

案例开源地址: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 让占位区域填满父容器,justifyContentalignItems 让内容居中。

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>

按钮有三种状态:

  1. 可点击:选了图片,没在上传
  2. 禁用(没选图片):样式变灰,点击无效
  3. 上传中:显示进度,点击无效

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_STORAGEREAD_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

Logo

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

更多推荐