Android Kotlin使用MultipartBody上传图片至服务器失败排查
问题背景
尝试将用户存储中的图片发送至服务器,但图片上传失败,服务器端接收的图片为null。其他数据可正常接收。具体流程是从Fragment页面获取图片Uri并传入ViewModel,将其转换为真实路径并创建文件,按要求将图片调整为1:1比例后转为MultipartBody.Part格式,但服务器端无法接收到图片,不清楚问题所在。
相关代码
ViewModel代码
class AddProductViewModel(application: Application) : AndroidViewModel(application) { var _apiLiveData = MutableLiveData<Resource<AddProductResponse>>() val apiLiveData: MutableLiveData<Resource<AddProductResponse>> get() = _apiLiveData fun addProduct( productName: String, productType: String, price: String, tax: String, imageUri: Uri? = null ) { _apiLiveData.postValue(Resource.Loading) viewModelScope.launch { val imagePart = if (imageUri != null) { val imageFilePath = context.getRealPathFromURI(imageUri) fileToMultipartBodyPart(imageFilePath!!) } else { null } val apiInterface = RetrofitInstance.getRetrofitInstance().create(ApiInterface::class.java) apiInterface.addProductData( toRequestBody(productName), toRequestBody(productType), toRequestBody(price), toRequestBody(tax), listOfNotNull(imagePart) ) .enqueue( object : Callback<AddProductResponse> { override fun onResponse( call: Call<AddProductResponse>, response: Response<AddProductResponse> ) { if (response.isSuccessful) { if (response.body()!!.message == "Product added Successfully!") { _apiLiveData.postValue(Resource.Success(response.body()!!)) } else _apiLiveData.postValue( Resource.Failure( false, 0, response.body()!!.message ) ) } } override fun onFailure(call: Call<AddProductResponse>, t: Throwable) { _apiLiveData.postValue(Resource.Failure(true, 0, "Network Error")) } } ) } } private val context: Context get() = getApplication<Application>() private fun toRequestBody(value: String): RequestBody { return RequestBody.create("text/plain".toMediaTypeOrNull(), value) } private fun Context.getRealPathFromURI(uri: Uri): String? { val projection = arrayOf(MediaStore.Images.Media.DATA) val cursor: Cursor? = contentResolver.query(uri, projection, null, null, null) return cursor?.use { val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA) it.moveToFirst() it.getString(columnIndex) } } private fun fileToMultipartBodyPart(filePath: String): MultipartBody.Part { // Load the original image file val originalFile = File(filePath) // Calculate the desired width and height for the 1:1 aspect ratio val desiredWidth = 500 // Set your desired width val desiredHeight = 500 // Set your desired height // Resize the image val resizedBitmap = Bitmap.createScaledBitmap( BitmapFactory.decodeFile(originalFile.absolutePath), desiredWidth, desiredHeight, false ) // Save the resized bitmap to a new file val resizedFile = File(context.cacheDir, "resized_image.jpg") val fileOutputStream = FileOutputStream(resizedFile) resizedBitmap.compress(Bitmap.CompressFormat.JPEG, 90, fileOutputStream) fileOutputStream.close() // Create a RequestBody from the resized file val requestFile = RequestBody.create("multipart/form-data".toMediaTypeOrNull(), resizedFile) // Create MultipartBody.Part return MultipartBody.Part.createFormData("files", resizedFile.name, requestFile) } }
ApiInterface代码
interface ApiInterface { @GET("get") fun getProductData(): Call<List<ProductResponse>> @Multipart @POST("add") fun addProductData( @Part("product_name") productName: RequestBody, @Part("product_type") productType: RequestBody, @Part("price") price: RequestBody, @Part("tax") tax: RequestBody, @Part files: List<MultipartBody.Part>? = null ): Call<AddProductResponse> }
Fragment代码片段
uploadImage.setOnClickListener { pickImageFromGallery() } submitBtn.setOnClickListener { if (editTextProductName.text!!.isEmpty() || editTextProductTax.text!!.isEmpty() || editTextProductSellingPrice.text!!.isEmpty() ) { Toast.makeText(context, "One or more fields empty!!", Toast.LENGTH_LONG).show() } else { if (selectedImageUri != null) { MultipartBody.Builder().setType(MultipartBody.FORM).apply { addFormDataPart("product_name", editTextProductName.text.toString()) addFormDataPart("product_type", productTypeSpinner.selectedItem.toString()) addFormDataPart("price", editTextProductSellingPrice.text.toString()) addFormDataPart("tax", editTextProductTax.text.toString()) }.build() viewModel.addProduct( editTextProductName.text.toString(), productTypeSpinner.selectedItem.toString(), editTextProductSellingPrice.text.toString(), editTextProductTax.text.toString(), selectedImageUri// Pass the MultipartBody parts ) } } } private fun pickImageFromGallery() { val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) startActivityForResult(intent, PICK_IMAGE_REQUEST) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == PICK_IMAGE_REQUEST && resultCode == Activity.RESULT_OK && data != null) { selectedImageUri = data.data Toast.makeText(requireActivity(), selectedImageUri.toString(), Toast.LENGTH_LONG).show() } }
问题排查与修复方案
1. 废弃的真实路径获取方法
MediaStore.Images.Media.DATA 在Android 10及以上已被废弃,且无法兼容所有Uri类型(比如第三方应用返回的内容Uri),会导致获取路径失败,后续文件处理为空。
修复:直接通过ContentResolver读取Uri对应的文件流,跳过真实路径获取:
private fun uriToMultipartBodyPart(uri: Uri): MultipartBody.Part? { return try { val inputStream = context.contentResolver.openInputStream(uri) val originalBitmap = BitmapFactory.decodeStream(inputStream) inputStream?.close() // 调整为1:1比例 val desiredSize = 500 val resizedBitmap = Bitmap.createScaledBitmap(originalBitmap, desiredSize, desiredSize, true) // 保存到缓存文件(加时间戳避免重名覆盖) val resizedFile = File(context.cacheDir, "resized_image_${System.currentTimeMillis()}.jpg") val outputStream = FileOutputStream(resizedFile) resizedBitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream) outputStream.close() // 使用正确的图片MIME类型 val requestFile = RequestBody.create("image/jpeg".toMediaTypeOrNull(), resizedFile) MultipartBody.Part.createFormData("files", resizedFile.name, requestFile) } catch (e: Exception) { e.printStackTrace() null } }
在addProduct方法中替换原逻辑:
val imagePart = imageUri?.let { uriToMultipartBodyPart(it) }
2. RequestBody媒体类型错误
当前代码用multipart/form-data作为图片RequestBody的类型,正确的应该是图片对应的MIME类型(如image/jpeg),否则服务器无法识别文件,导致解析为null。
修复:如上面的代码所示,将图片RequestBody的类型改为image/jpeg。
3. Retrofit接口List参数兼容性问题
部分服务器对List<MultipartBody.Part>的解析支持不佳,如果仅上传单张图片,建议改为单个MultipartBody.Part参数:
修改ApiInterface:
@Multipart @POST("add") fun addProductData( @Part("product_name") productName: RequestBody, @Part("product_type") productType: RequestBody, @Part("price") price: RequestBody, @Part("tax") tax: RequestBody, @Part files: MultipartBody.Part? = null ): Call<AddProductResponse>
调用时直接传imagePart,而非listOfNotNull(imagePart)。
4. Fragment冗余代码清理
Fragment中创建的MultipartBody.Builder未实际使用,属于冗余代码,直接删除即可。
5. 权限检查
Android 13及以上版本需要申请READ_MEDIA_IMAGES权限,确保Manifest中已声明,并在运行时动态申请,否则无法读取图片文件。
验证步骤
- 打印调整后的图片文件大小,确认文件已正确生成(大小不为0)。
- 用OkHttp拦截器或Charles抓包,查看Multipart请求体中是否包含图片字段。
- 核对服务器端接收参数的字段名,确保和客户端
createFormData("files", ...)中的名称完全一致。
内容的提问来源于stack exchange,提问作者Thomas

