WorkManager实战:图片上传任务

图片上传,几乎是每个App都会遇到的需求。但说实话,很多开发者对它的处理方式,就是简单丢个OkHttp请求完事。用户切到后台,上传断了;网络波动,上传失败;App被杀,一切重来……

我在项目中就踩过这个坑。当时做一个社交App,用户拍完照片,后台一压,上传队列全崩了。产品经理追着我问:「为什么用户发个图这么难?」嗯,从那以后,我开始认真研究WorkManager来做图片上传。

创建图片上传Worker

先看最基础的部分——如何定义一个图片上传的Worker。我习惯把Worker设计成带状态的,而不是一股脑往里塞逻辑。

class ImageUploadWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        // 1. 获取输入数据
        val imageUri = inputData.getString("image_uri") ?: return Result.failure()
        val imageId = inputData.getString("image_id") ?: return Result.failure()

        // 2. 读取文件
        val file = try {
            UriUtils.getFileFromUri(applicationContext, Uri.parse(imageUri))
        } catch (e: Exception) {
            return Result.retry()
        }

        // 3. 执行上传
        return uploadImage(imageId, file)
    }

    private suspend fun uploadImage(imageId: String, file: File): Result {
        return withContext(Dispatchers.IO) {
            try {
                val response = ApiService.upload(imageId, file)
                if (response.isSuccessful) {
                    Result.success()
                } else {
                    Result.retry()
                }
            } catch (e: IOException) {
                Result.retry()
            }
        }
    }
}

这里有个细节:CoroutineWorker 默认跑在 Dispatchers.Default 上。但网络请求我建议切到 Dispatchers.IO,毕竟IO密集型操作,别占着CPU线程不放。

处理网络请求

网络请求这块,我推荐用Retrofit + OkHttp。但要注意,WorkManager的Worker生命周期和Activity不同,你不能指望用户一直在前台。

核心原则:Worker里的网络请求,必须能独立运行,不依赖任何UI组件。

object ApiService {
    private val client = OkHttpClient.Builder()
        .connectTimeout(30, TimeUnit.SECONDS)
        .readTimeout(60, TimeUnit.SECONDS)
        .writeTimeout(60, TimeUnit.SECONDS)
        .retryOnConnectionFailure(true)
        .build()

    private val retrofit = Retrofit.Builder()
        .baseUrl("https://api.example.com/")
        .client(client)
        .build()

    private val api = retrofit.create(UploadApi::class.java)

    suspend fun upload(imageId: String, file: File): Response<UploadResponse> {
        val requestBody = file.asRequestBody("image/jpeg".toMediaType())
        val part = MultipartBody.Part.createFormData("image", file.name, requestBody)
        return api.uploadImage(imageId, part)
    }
}

interface UploadApi {
    @Multipart
    @POST("upload/image")
    suspend fun uploadImage(
        @Part("image_id") imageId: RequestBody,
        @Part image: MultipartBody.Part
    ): Response<UploadResponse>
}

我曾经犯过一个错——把超时时间设得太短。用户用4G网络上传大图,动不动就超时重试。后来我把writeTimeout调到60秒,配合分片上传,问题就解决了。

上传进度反馈

用户最关心什么?进度。但WorkManager本身不提供进度回调给UI。怎么办?用 setProgress() 方法。

class ImageUploadWorker(...) : CoroutineWorker(...) {

    override suspend fun doWork(): Result {
        // ... 省略前面的代码

        // 创建进度监听器
        val progressListener = object : ProgressRequestBody.Listener {
            override fun onProgress(bytesWritten: Long, contentLength: Long) {
                val progress = (bytesWritten * 100 / contentLength).toInt()
                // 更新进度
                setProgress(workDataOf("progress" to progress))
            }
        }

        // 包装RequestBody
        val requestBody = file.asRequestBody("image/jpeg".toMediaType())
        val progressBody = ProgressRequestBody(requestBody, progressListener)

        // ... 上传逻辑
    }
}

UI层怎么获取进度?通过 WorkManager.getWorkInfoByIdLiveData() 观察即可。

WorkManager.getInstance(context)
    .getWorkInfoByIdLiveData(workId)
    .observe(viewLifecycleOwner) { workInfo ->
        if (workInfo != null && workInfo.progress != null) {
            val progress = workInfo.progress.getInt("progress", 0)
            binding.progressBar.progress = progress
        }
    }

小技巧:进度更新不要太频繁。我一般控制在每5%更新一次,或者每秒最多更新10次。否则频繁的跨进程通信反而会影响性能。

失败重试策略

上传失败是常态。网络波动、服务器限流、Token过期……你想想看,哪个没遇到过?

WorkManager提供了非常灵活的重试策略。我一般这样配置:

val uploadRequest = OneTimeWorkRequestBuilder<ImageUploadWorker>()
    .setInputData(inputData)
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
    )
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL,
        30, TimeUnit.SECONDS
    )
    .addTag("image_upload")
    .build()

这里我用了指数退避策略。第一次失败等30秒,第二次等60秒,第三次等120秒……最大延迟由系统控制。为什么要用指数退避?说白了,如果服务器正在重启,你每秒重试一次,只会加重负担。

注意:重试次数不是无限的。默认情况下,WorkManager会重试3次(加上首次尝试,共4次)。如果需要更多,可以在Worker里手动返回 Result.retry(),但建议不要超过10次。

断点续传实现

这才是真正的硬骨头。大图上传,断了重传整个文件?太浪费了。我分享一个我在生产环境用过的方案。

核心思路:把文件切成固定大小的分片,记录每个分片的上传状态。

class ChunkedImageUploadWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    private val chunkSize = 512 * 1024 // 512KB每片

    override suspend fun doWork(): Result {
        val imageUri = inputData.getString("image_uri") ?: return Result.failure()
        val imageId = inputData.getString("image_id") ?: return Result.failure()

        val file = UriUtils.getFileFromUri(applicationContext, Uri.parse(imageUri))
        val totalChunks = (file.length() + chunkSize - 1) / chunkSize

        // 获取已上传的分片列表
        val uploadedChunks = getUploadedChunks(imageId)

        // 遍历所有分片
        for (chunkIndex in 0 until totalChunks) {
            if (chunkIndex in uploadedChunks) continue // 已上传的跳过

            val start = chunkIndex * chunkSize
            val end = minOf(start + chunkSize, file.length())
            val chunkData = file.readBytes().copyOfRange(start.toInt(), end.toInt())

            // 上传单个分片
            val result = uploadChunk(imageId, chunkIndex, totalChunks, chunkData)
            if (!result) {
                // 记录当前进度,下次从断点继续
                saveUploadProgress(imageId, chunkIndex)
                return Result.retry()
            }
        }

        // 所有分片上传完成,通知服务端合并
        return mergeChunks(imageId, totalChunks)
    }

    private suspend fun uploadChunk(
        imageId: String,
        chunkIndex: Long,
        totalChunks: Long,
        data: ByteArray
    ): Boolean {
        return withContext(Dispatchers.IO) {
            try {
                val response = ApiService.uploadChunk(
                    imageId, chunkIndex, totalChunks, data
                )
                response.isSuccessful
            } catch (e: Exception) {
                false
            }
        }
    }

    private fun getUploadedChunks(imageId: String): Set<Long> {
        // 从本地数据库或SharedPreferences读取已上传的分片索引
        val prefs = applicationContext.getSharedPreferences("upload_progress", 0)
        val uploaded = prefs.getStringSet(imageId, emptySet()) ?: emptySet()
        return uploaded.map { it.toLong() }.toSet()
    }

    private fun saveUploadProgress(imageId: String, chunkIndex: Long) {
        val prefs = applicationContext.getSharedPreferences("upload_progress", 0)
        val uploaded = prefs.getStringSet(imageId, mutableSetOf()) ?: mutableSetOf()
        uploaded.add(chunkIndex.toString())
        prefs.edit().putStringSet(imageId, uploaded).apply()
    }
}

关键点:断点续传的核心是「记录已上传的分片」。我用SharedPreferences做演示,生产环境建议用Room数据库,更可靠。

为什么不用文件来记录?我曾经试过,结果App被系统杀掉时,文件写入可能不完整,导致记录损坏。数据库有事务保证,安全得多。

整体架构图

下面这张图,是我整理出来的图片上传任务整体流程。你看一眼就能明白各个模块怎么配合。

图片上传任务整体架构 UI层 Activity / Fragment 观察 WorkInfo,显示进度条 WorkManager调度层 OneTimeWorkRequest + Constraints + BackoffCriteria Worker执行层 分片上传 + 进度反馈 + 失败重试 + 断点续传 网络层 Retrofit + OkHttp + 分片上传API 用户可见 后台执行

从这张图你能看到,整个流程分四层。UI层只管观察进度,不参与实际上传。WorkManager负责调度和约束条件。Worker层是核心,处理分片、进度、重试。网络层只做一件事——发请求。

这种分层设计的好处是:每一层都可以独立测试、独立修改。我在重构旧项目时,就把原来耦合在Activity里的上传逻辑,按这个架构拆开,代码清晰了很多。


好了,图片上传Worker的核心内容就这些。从创建Worker、处理网络请求,到进度反馈、失败重试,再到断点续传,每一步都有坑,但也都有解法。你动手试试,遇到问题再回来翻翻这篇文章。

最后提醒一句:生产环境别忘了处理权限问题。Android 11以上,读取图片需要 READ_MEDIA_IMAGES 权限,WorkManager不会帮你处理权限申请,你得在调度任务前确保权限已授予。