You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Expo ImagePicker对接Rails API上传图片触发Errno::ENOENT报错

问题现象

通过Expo的ImagePicker组件上传图片时,Rails服务端返回500错误,错误日志如下:

Completed 500 Internal Server Error in 1ms (ActiveRecord: 2.8ms | Allocations: 637)
  
Errno::ENOENT (No such file or directory @ rb_sysopen - file:///data/user/0/host.exp.exponent/cache/ExperienceData/%2540zilasino%252Frestaurant-management/ImagePicker/5f29f45c-4467-4360-bcc8-2651146837b3.jpg):

使用Insomnia调试上传接口可正常完成上传,仅在Android模拟器运行Expo应用发起请求时触发上述报错。请求参数可正常传递到Rails服务端,但服务端无法定位到对应文件。

现有相关代码

CreateMenuItem.js

const addMenuItem = async () => {
    const menuItem = {
      name,
      price,
      image: selectedImage.uri,
      is_drink: isDrink ? true : false,
      notes,
    };
   const resp = await createAccessory("menu_items", menuItem);
    if(resp.type == 'success'){
      fetchMenuItemData();
      clearFields()
    }
    showMessage({
      message: `${resp.msg}`,
      position: "center",
      duration: 2500,
      style: { backgroundColor: resp.type == 'success' ? colors.PRIMARY : colors.ERROR },
      titleStyle: { fontSize: 20 },
    });
  };


const createAccessory = async (accessory, payload) => {
    return await axios.post(baseUrl + '/' + accessory, payload, {headers})
    .then(resp => {
        if (resp.data){
            return { msg: i18n.t('saved'), type: 'success' }
        } })
        .catch(error => {
            if (error.response) {
            return { msg: error.response.data.name }
        }
      }
    )
}

ImageUpload.js

const ImageUpload = ({ parentCallback, selectedImage }) => {
  let openImagePickerAsync = async () => {
    let permissionResult =
      await ImagePicker.requestMediaLibraryPermissionsAsync();

    if (permissionResult.granted === false) {
      alert("Permission to access camera roll is required!");
      return;
    }

    let pickerResult = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ImagePicker.MediaTypeOptions.Images,
      allowsEditing: true,
      aspect: [1, 1],
      quality: 1
    });

    if (pickerResult.cancelled === true) {
      return;
    }
    parentCallback(pickerResult);
  };

  if (selectedImage != null) {
    return (
      <>
        <Image
          source={{ uri: selectedImage.uri }}
          style={styles.thumbnail}
          resizeMode="contain"
        />
        <Button
          title="X"
          color={colors.PRIMARY}
          onPress={() => parentCallback(null)}
        />
      </>
    );
  } else {
    return (
      <>
        <TouchableOpacity onPress={openImagePickerAsync} style={styles.button}>
          <Text style={styles.buttonText}>{i18n.t("pickAnImage")}</Text>
        </TouchableOpacity>
      </>
    );
  }
};

controller.rb

class MenuItemsController < ApplicationController
 ...
  def create
    image = Cloudinary::Uploader.upload(params[:image])
    menu_item = MenuItem.new(
      image: image['url'],
    )
  end 
 ...
end
根因分析
  • 前端错误地将图片的本地缓存URI字符串作为image参数传给后端,没有上传真实的图片二进制数据
  • 服务端Cloudinary上传方法接收到字符串参数时,会默认该字符串是服务器本地的文件路径,直接尝试读取服务器对应路径下的文件。但file:///开头的路径是Android模拟器内的本地缓存路径,Rails服务器上不存在该路径,因此抛出找不到文件的ENOENT错误
  • Insomnia调试正常是因为工具发起请求时使用multipart/form-data格式上传了真实的文件二进制流,和前端当前传参逻辑完全不同
修复方案

前端修改

不要直接传JSON格式的URI字符串,改用FormData构造 multipart 格式请求,上传真实文件对象:

  1. 修改addMenuItem方法,构造FormData对象填充参数,将图片按照文件格式append到请求体中
  2. 修改axios请求配置,不要手动指定JSON格式的Content-Type,让客户端自动生成带boundary标识的multipart请求头,否则后端无法正确解析文件

修改后的CreateMenuItem.js核心代码:

const addMenuItem = async () => {
  const formData = new FormData();
  // 填充普通字段
  formData.append('name', name);
  formData.append('price', price);
  formData.append('is_drink', isDrink ? true : false);
  formData.append('notes', notes);
  // 处理图片文件
  const uriSegments = selectedImage.uri.split('.');
  const fileExt = uriSegments[uriSegments.length - 1];
  formData.append('image', {
    uri: selectedImage.uri,
    name: `menu_item.${fileExt}`,
    type: `image/${fileExt}`
  });

  const resp = await createAccessory("menu_items", formData);
  if(resp.type == 'success'){
    fetchMenuItemData();
    clearFields()
  }
  showMessage({
    message: `${resp.msg}`,
    position: "center",
    duration: 2500,
    style: { backgroundColor: resp.type == 'success' ? colors.PRIMARY : colors.ERROR },
    titleStyle: { fontSize: 20 },
  });
};


const createAccessory = async (accessory, payload) => {
  return await axios.post(baseUrl + '/' + accessory, payload, {
    headers: {
      Accept: 'application/json',
      // 不要手动设置Content-Type,让axios自动识别FormData生成合法的multipart头
      ...headers
    }
  })
  .then(resp => {
    if (resp.data){
        return { msg: i18n.t('saved'), type: 'success' }
    } 
  })
  .catch(error => {
    if (error.response) {
      return { msg: error.response.data.name }
    }
  })
}

ImageUpload组件逻辑不需要修改。

后端验证

现有后端逻辑不需要调整,前端修改完成后,params[:image]会自动被Rails解析为ActionDispatch::Http::UploadedFile类型的文件对象,Cloudinary的上传方法原生支持该类型参数,可以直接读取文件流完成上传。
可在controller中加一行日志验证参数类型:

def create
  # 打印参数类型,若为ActionDispatch::Http::UploadedFile则传参正确
  Rails.logger.info params[:image].class
  image = Cloudinary::Uploader.upload(params[:image])
  menu_item = MenuItem.new(
    image: image['url'],
  )
  # 后续保存逻辑不变
end 
注意避坑
  • 不要手动给FormData请求设置Content-Type: multipart/form-data,缺少boundary标识的请求头会导致后端无法解析文件
  • Android端返回的图片URI带file://前缀属于正常情况,不需要手动截断,React Native网络层会自动处理该格式
  • 上述FormData逻辑同时兼容iOS和Android双端,不需要写平台判断代码

内容的提问来源于stack exchange,提问作者Zilton

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.30 00:01:04