在 Android 开发中,向服务器上传文件及表单数据是常见的需求,通常使用 multipart/form-data 编码格式。本文将对比使用原生 HttpURLConnection 和现代库 OkHttp4 的实现方式,指出原始代码中的问题,并提供现代化改进建议。

博主博客

第一部分:使用 HttpURLConnection 实现

以下是一个使用 HttpURLConnection 发送 Multipart 请求的示例,该示例将图片和表单数据一并上传。

public class HandleAccountData extends AsyncTask<String, Void, Void> {

    protected Void doInBackground(String... params) {

        HttpURLConnection conn = null;
        DataOutputStream dos = null;
        String lineEnd = "\r\n";
        String twoHyphens = "--";
        String boundary = "*****";
        int bytesRead, bytesAvailable, bufferSize;
        byte[] buffer;
        int maxBufferSize = 1 * 1024 * 1024;
        int serverResponseCode = 0;

        // 参数
        String urldisplay = params[0];
        String email = params[1];
        String personName = params[2];
        String loginMethod = params[3];
        Bitmap mIcon11 = null;

        try {

            System.setProperty("http.keepAlive", "false");

            /////////////////////////////////////////////
            //// 上传图片和数据

            // 从网络下载图片
            InputStream in = new URL(urldisplay).openStream();
            mIcon11 = BitmapFactory.decodeStream(in);

            // 将图片编码为字节数组
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            mIcon11.compress(Bitmap.CompressFormat.JPEG, 100, baos);
            byte[] imageBytes = baos.toByteArray();

            // 打开到 Servlet 的 URL 连接
            ByteArrayInputStream fileInputStream = new ByteArrayInputStream(imageBytes);
            URL url = new URL(url_upload_profile_picture);

            // 打开 HTTP 连接
            conn = (HttpURLConnection) url.openConnection();
            conn.setDoInput(true); // 允许输入
            conn.setDoOutput(true); // 允许输出
            conn.setUseCaches(false); // 不使用缓存
            conn.setRequestMethod("POST");
            conn.setRequestProperty("Accept-Encoding", "");
            // conn.setRequestProperty("Connection", "Keep-Alive");
            conn.setRequestProperty("ENCTYPE", "multipart/form-data");
            conn.setRequestProperty("Content-Type", "multipart/form-data;boundary=" + boundary);
            conn.setRequestProperty("uploaded_file", "profile_picture");
            conn.setRequestProperty("email", email);
            conn.setRequestProperty("full_name", personName);
            conn.setRequestProperty("password", loginMethod);

            dos = new DataOutputStream(conn.getOutputStream());

            // 第一个参数 - email
            dos.writeBytes(twoHyphens + boundary + lineEnd);
            dos.writeBytes("Content-Disposition: form-data; name=\"email\"" + lineEnd + lineEnd
                    + email + lineEnd);

            // 第二个参数 - userName
            // 修正:此处原始代码将字节数组直接 toString,是错误的,应直接写入字符串
            dos.writeBytes(twoHyphens + boundary + lineEnd);
            dos.writeBytes("Content-Disposition: form-data; name=\"full_name\"" + lineEnd + lineEnd
                    + personName + lineEnd);

            // 第三个参数 - password
            dos.writeBytes(twoHyphens + boundary + lineEnd);
            dos.writeBytes("Content-Disposition: form-data; name=\"password\"" + lineEnd + lineEnd
                    + loginMethod + lineEnd);

            // 第四个参数 - 文件名
            dos.writeBytes(twoHyphens + boundary + lineEnd);
            dos.writeBytes("Content-Disposition: form-data; name=\"uploaded_file\";filename=\""
                    + "profile_picture" + "\"" + lineEnd);
            dos.writeBytes(lineEnd);

            // 创建最大大小的缓冲区
            bytesAvailable = fileInputStream.available();

            bufferSize = Math.min(bytesAvailable, maxBufferSize);
            buffer = new byte[bufferSize];

            // 读取文件并写入表单
            bytesRead = fileInputStream.read(buffer, 0, bufferSize);

            while (bytesRead > 0) {
                dos.write(buffer, 0, bufferSize);
                bytesAvailable = fileInputStream.available();
                bufferSize = Math.min(bytesAvailable, maxBufferSize);
                bytesRead = fileInputStream.read(buffer, 0, bufferSize);
            }

            // 发送文件数据后的必要分隔符
            dos.writeBytes(lineEnd);
            dos.writeBytes(twoHyphens + boundary + twoHyphens + lineEnd);

            // 获取服务器响应(状态码和信息)
            serverResponseCode = conn.getResponseCode();
            String serverResponseMessage = conn.getResponseMessage();

            Log.i("uploadFile", "HTTP Response is : "
                    + serverResponseMessage + ": " + serverResponseCode);

            // 关闭流
            fileInputStream.close();
            dos.flush();
            dos.close();
            conn.disconnect();

            ///////////////////////////////////////////////////////////
            /////////// 将数据存储到 SharedPreferences

            // 创建小尺寸图片链接(假设原链接以特定格式结尾)
            urldisplay = urldisplay.substring(0, urldisplay.length() - 3) + "70";

            // 创建 SharedPreferences 变量来存储用户信息
            // 'mysettings' 是文件名
            SharedPreferences mSettings = getSharedPreferences("mysetting", Context.MODE_PRIVATE);
            Editor editor = mSettings.edit();
            editor.putString("login_method", loginMethod);
            editor.putString("email", email);
            editor.putString("name", personName);
            editor.putString("pic_uri", urldisplay);
            editor.apply();

            ////////////////////////////////////////////////////////////
            ////// 启动主界面
            Intent i = new Intent(getApplicationContext(), MainPage.class);
            startActivity(i);
            finish();

        } catch (MalformedURLException ex) {
            Log.e("Upload file to server", "error: " + ex.getMessage(), ex);
        } catch (Exception e) {
            String err = (e.getMessage() == null) ? "SD Card failed" : e.getMessage();
            Log.e("The caught exception is: ", err);
        }
        return null;
    }
}

HttpURLConnection 实现的问题与说明

  1. 字符编码问题:原始代码中 personName.getBytes("UTF-8").toString() 是错误的,它不会返回原始字符串,而是字节数组的对象地址。已修正为直接使用字符串。
  2. 硬编码边界符:边界符 boundary 应为随机生成,以避免与内容冲突。
  3. 主线程操作:在 doInBackground 中直接启动 Activity 和操作 UI 是不安全的,应通过 onPostExecute 或在主线程中执行。
  4. 已过时 APIAsyncTask 在较新版本的 Android 中已弃用,建议使用 Kotlin 协程RxJavaWorkManager 等现代异步处理方式。
  5. 资源管理:需要确保所有流都被正确关闭,即使在发生异常的情况下。

第二部分:使用 OkHttp4 实现(现代方案)

以下是使用 OkHttp4 发送相同请求的示例。OkHttp4 提供了更简洁、更安全的 API,并支持 Kotlin 协程。

Kotlin + 协程实现

class ProfileUploader(private val context: Context) {
    
    private val okHttpClient = OkHttpClient.Builder()
        .connectTimeout(30, TimeUnit.SECONDS)
        .writeTimeout(30, TimeUnit.SECONDS)
        .readTimeout(30, TimeUnit.SECONDS)
        .build()
    
    suspend fun uploadProfileData(
        imageUrl: String,
        email: String,
        name: String,
        password: String,
        uploadUrl: String
    ): Result<Unit> = withContext(Dispatchers.IO) {
        return@withContext try {
            // 1. 下载图片
            val bitmap = downloadImage(imageUrl)
            
            // 2. 上传数据
            val response = uploadToServer(bitmap, email, name, password, uploadUrl)
            
            if (response.isSuccessful) {
                // 3. 保存到 SharedPreferences
                saveToPreferences(imageUrl, email, name, password)
                
                // 4. 返回成功(UI操作应在调用处处理)
                Result.success(Unit)
            } else {
                Result.failure(Exception("上传失败: ${response.code} ${response.message}"))
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
    
    private suspend fun downloadImage(imageUrl: String): Bitmap = withContext(Dispatchers.IO) {
        val request = Request.Builder()
            .url(imageUrl)
            .build()
        
        okHttpClient.newCall(request).execute().use { response ->
            if (!response.isSuccessful) throw IOException("下载图片失败: ${response.code}")
            
            val body = response.body ?: throw IOException("响应体为空")
            val inputStream = body.byteStream()
            BitmapFactory.decodeStream(inputStream) ?: throw IOException("图片解码失败")
        }
    }
    
    private suspend fun uploadToServer(
        bitmap: Bitmap,
        email: String,
        name: String,
        password: String,
        uploadUrl: String
    ): Response = withContext(Dispatchers.IO) {
        // 将 Bitmap 转换为字节数组
        val byteArrayOutputStream = ByteArrayOutputStream()
        bitmap.compress(Bitmap.CompressFormat.JPEG, 90, byteArrayOutputStream)
        val imageBytes = byteArrayOutputStream.toByteArray()
        
        // 构建 Multipart 请求体
        val requestBody = MultipartBody.Builder()
            .setType(MultipartBody.FORM)
            .addFormDataPart("email", email)
            .addFormDataPart("full_name", name)
            .addFormDataPart("password", password)
            .addFormDataPart(
                "uploaded_file",
                "profile_picture.jpg",
                imageBytes.toRequestBody("image/jpeg".toMediaType())
            )
            .build()
        
        val request = Request.Builder()
            .url(uploadUrl)
            .post(requestBody)
            .build()
        
        return@withContext okHttpClient.newCall(request).execute()
    }
    
    private fun saveToPreferences(
        imageUrl: String,
        email: String,
        name: String,
        password: String
    ) {
        // 生成缩略图 URL(假设特定格式)
        val thumbnailUrl = imageUrl.substring(0, imageUrl.length - 3) + "70"
        
        context.getSharedPreferences("mysetting", Context.MODE_PRIVATE).edit {
            putString("login_method", password)
            putString("email", email)
            putString("name", name)
            putString("pic_uri", thumbnailUrl)
        }
    }
}

// 使用示例(在 ViewModel 或 Activity/Fragment 中)
class ProfileViewModel : ViewModel() {
    private val profileUploader = ProfileUploader(applicationContext)
    private val _uploadState = MutableStateFlow<UploadState>(UploadState.Idle)
    val uploadState: StateFlow<UploadState> = _uploadState
    
    fun uploadProfile(
        imageUrl: String,
        email: String,
        name: String,
        password: String,
        uploadUrl: String
    ) {
        viewModelScope.launch {
            _uploadState.value = UploadState.Loading
            val result = profileUploader.uploadProfileData(
                imageUrl, email, name, password, uploadUrl
            )
            _uploadState.value = when (result) {
                is Result.Success -> UploadState.Success
                is Result.Failure -> UploadState.Error(result.exception.message ?: "未知错误")
            }
        }
    }
    
    sealed class UploadState {
        object Idle : UploadState()
        object Loading : UploadState()
        object Success : UploadState()
        data class Error(val message: String) : UploadState()
    }
}

Java 兼容版本(OkHttp4)

public class OkHttp4Uploader {
    private final OkHttpClient client;
    private final Context context;
    
    public OkHttp4Uploader(Context context) {
        this.context = context.getApplicationContext();
        this.client = new OkHttpClient.Builder()
                .connectTimeout(30, TimeUnit.SECONDS)
                .writeTimeout(30, TimeUnit.SECONDS)
                .readTimeout(30, TimeUnit.SECONDS)
                .build();
    }
    
    public interface UploadCallback {
        void onSuccess();
        void onError(String error);
    }
    
    public void uploadProfile(
            String imageUrl,
            String email,
            String name,
            String password,
            String uploadUrl,
            UploadCallback callback
    ) {
        new Thread(() -> {
            try {
                // 1. 下载图片
                Bitmap bitmap = downloadImage(imageUrl);
                
                // 2. 上传到服务器
                boolean success = uploadToServer(bitmap, email, name, password, uploadUrl);
                
                if (success) {
                    // 3. 保存到 SharedPreferences
                    saveToPreferences(imageUrl, email, name, password);
                    
                    // 4. 回调成功(切换到主线程)
                    new Handler(Looper.getMainLooper()).post(callback::onSuccess);
                } else {
                    new Handler(Looper.getMainLooper()).post(() -> 
                        callback.onError("上传失败"));
                }
            } catch (Exception e) {
                new Handler(Looper.getMainLooper()).post(() -> 
                    callback.onError(e.getMessage()));
            }
        }).start();
    }
    
    private Bitmap downloadImage(String imageUrl) throws IOException {
        Request request = new Request.Builder()
                .url(imageUrl)
                .build();
        
        try (Response response = client.newCall(request).execute()) {
            if (!response.isSuccessful()) {
                throw new IOException("下载失败: " + response.code());
            }
            
            ResponseBody body = response.body();
            if (body == null) {
                throw new IOException("响应体为空");
            }
            
            InputStream inputStream = body.byteStream();
            Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
            if (bitmap == null) {
                throw new IOException("图片解码失败");
            }
            return bitmap;
        }
    }
    
    private boolean uploadToServer(
            Bitmap bitmap,
            String email,
            String name,
            String password,
            String uploadUrl
    ) throws IOException {
        // 压缩图片
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        bitmap.compress(Bitmap.CompressFormat.JPEG, 90, baos);
        byte[] imageBytes = baos.toByteArray();
        
        // 构建 Multipart 请求体
        RequestBody requestBody = new MultipartBody.Builder()
                .setType(MultipartBody.FORM)
                .addFormDataPart("email", email)
                .addFormDataPart("full_name", name)
                .addFormDataPart("password", password)
                .addFormDataPart(
                        "uploaded_file",
                        "profile_picture.jpg",
                        RequestBody.create(imageBytes, MediaType.parse("image/jpeg"))
                )
                .build();
        
        Request request = new Request.Builder()
                .url(uploadUrl)
                .post(requestBody)
                .build();
        
        try (Response response = client.newCall(request).execute()) {
            return response.isSuccessful();
        }
    }
    
    private void saveToPreferences(
            String imageUrl,
            String email,
            String name,
            String password
    ) {
        String thumbnailUrl = imageUrl.substring(0, imageUrl.length() - 3) + "70";
        
        SharedPreferences preferences = context.getSharedPreferences(
                "mysetting", Context.MODE_PRIVATE);
        SharedPreferences.Editor editor = preferences.edit();
        editor.putString("login_method", password);
        editor.putString("email", email);
        editor.putString("name", name);
        editor.putString("pic_uri", thumbnailUrl);
        editor.apply();
    }
}

OkHttp4 实现的优势

  1. 简洁的 API:MultipartBody.Builder 提供了直观的构建方式
  2. 自动管理:支持 try-with-resources(Java)或 use 函数(Kotlin)自动关闭资源
  3. 超时配置:可以方便地设置连接、读取和写入超时
  4. 拦截器支持:便于添加日志、认证等通用功能
  5. 协程友好:在 Kotlin 中与协程完美结合

现代化最佳实践

1. 使用 Kotlin 协程处理异步

// 在 ViewModel 中使用
viewModelScope.launch {
    try {
        val result = uploader.uploadData(...)
        // 更新 UI 状态
    } catch (e: Exception) {
        // 处理错误
    }
}

2. 添加进度监听

// 自定义 RequestBody 以跟踪上传进度
class ProgressRequestBody(
    private val requestBody: RequestBody,
    private val onProgress: (percentage: Int) -> Unit
) : RequestBody() {
    
    override fun contentType(): MediaType? = requestBody.contentType()
    
    override fun contentLength(): Long = requestBody.contentLength()
    
    override fun writeTo(sink: BufferedSink) {
        val countingSink = countingSink(sink) { bytesWritten ->
            val percentage = (bytesWritten * 100 / contentLength()).toInt()
            onProgress(percentage)
        }
        val bufferedSink = countingSink.buffer()
        requestBody.writeTo(bufferedSink)
        bufferedSink.flush()
    }
    
    private fun countingSink(
        sink: Sink,
        onProgress: (bytesWritten: Long) -> Unit
    ): ForwardingSink {
        return object : ForwardingSink(sink) {
            private var bytesWritten = 0L
            
            override fun write(source: Buffer, byteCount: Long) {
                super.write(source, byteCount)
                bytesWritten += byteCount
                onProgress(bytesWritten)
            }
        }
    }
}

3. 使用 Hilt/Dagger 依赖注入

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    
    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = HttpLoggingInterceptor.Level.BODY
            })
            .addInterceptor { chain ->
                val request = chain.request().newBuilder()
                    .addHeader("User-Agent", "MyApp/1.0")
                    .build()
                chain.proceed(request)
            }
            .build()
    }
    
    @Provides
    fun provideUploader(client: OkHttpClient): ProfileUploader {
        return ProfileUploader(client)
    }
}

4. 错误处理与重试机制

suspend fun <T> retryWithBackoff(
    times: Int = 3,
    initialDelay: Long = 1000,
    maxDelay: Long = 10000,
    factor: Double = 2.0,
    block: suspend () -> T
): T {
    var currentDelay = initialDelay
    repeat(times - 1) { attempt ->
        try {
            return block()
        } catch (e: Exception) {
            if (attempt == times - 1) throw e
            delay(currentDelay)
            currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay)
        }
    }
    return block() // 最后一次尝试
}

对比总结

特性 HttpURLConnection OkHttp4
API 简洁性 冗长,需要手动构造 multipart 格式 简洁,Builder 模式
性能 基础实现,性能一般 连接池、缓存优化,性能更好
功能特性 基础功能 拦截器、自动重试、HTTP/2 支持等
异步支持 需自行封装 原生支持 Callback,协程友好
社区维护 Android 标准库,更新缓慢 活跃社区,持续更新
适用场景 简单请求,避免额外依赖 生产环境,需要高级功能

迁移建议

  1. 新项目:直接使用 OkHttp4 + Kotlin 协程组合
  2. 老项目迁移:逐步替换,优先在新增功能中使用 OkHttp
  3. 保持兼容:对于简单请求,仍可使用 HttpURLConnection,但建议统一技术栈
  4. 测试保障:迁移过程中确保充分的单元测试和集成测试

结论

在 Android 开发中,虽然 HttpURLConnection 仍然可用,但 OkHttp4 提供了更现代、更强大的网络请求能力。结合 Kotlin 协程,可以编写出更简洁、更安全的异步代码。对于 multipart/form-data 请求,OkHttp 的 MultipartBody.Builder 极大地简化了构建过程,避免了手动拼接格式的错误。

建议开发者根据项目需求选择技术方案:对于简单应用或学习目的,HttpURLConnection 足够使用;对于生产环境或复杂应用,OkHttp 是更优选择。无论选择哪种方式,都要注意正确处理异步操作、资源管理和错误处理,以确保应用的稳定性和用户体验。