一、日志上传与清理任务:从痛点说起

做 Android 开发这么多年,我遇到过最头疼的问题之一,就是日志管理。

你想想看,用户手机上的日志文件越积越多,最后把存储空间占满了。用户跑来骂你,说你的 App 是「存储杀手」。更麻烦的是,线上出了问题,你想看日志定位 bug,结果发现日志早就被系统清理掉了。

嗯,这就是我们今天要解决的问题。

用 WorkManager 来做日志上传和清理,说白了就是三个核心动作:压缩、上传、清理。再加上一些约束条件,让这些任务在合适的时机执行。

核心思路:日志文件 → 压缩 → 定时上传 → 清理过期日志 → 受约束执行

二、整体架构设计

先看一张图,你就明白整个流程了。

日志上传与清理任务架构图 日志收集 日志压缩(ZIP) 定时上传(PeriodicWork) 清理过期日志(7天) 约束条件 🔌 网络连接(WiFi) 🔋 电量充足(>20%) 💾 存储空间充足 📱 设备空闲状态 🔐 充电状态 WorkManager 自动调度,无需前台服务

这张图把整个流程串起来了。从日志收集开始,到压缩、上传、清理,每一步都有约束条件把关。我个人习惯把这种设计叫做「闭环日志管理」——日志从生到死,都在掌控之中。

三、日志压缩:别让大文件卡住上传

日志文件如果不压缩,上传起来很慢。尤其是在弱网环境下,一个 10MB 的日志文件可能传半天。

我建议用 ZipOutputStream 来做压缩。为什么不用 Gzip?因为 Zip 可以打包多个文件,而且 Android 原生支持,不需要额外引入库。

class LogCompressor {
    
    fun compressLogs(
        logDir: File,
        outputZip: File
    ): Result<File> {
        return try {
            ZipOutputStream(BufferedOutputStream(
                FileOutputStream(outputZip)
            )).use { zos ->
                logDir.listFiles()
                    ?.filter { it.extension == "log" }
                    ?.forEach { logFile ->
                        val entry = ZipEntry(logFile.name)
                        entry.size = logFile.length()
                        zos.putNextEntry(entry)
                        
                        logFile.inputStream().use { input ->
                            input.copyTo(zos, bufferSize = 8192)
                        }
                        zos.closeEntry()
                    }
            }
            Result.success(outputZip)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}
小技巧:压缩时设置 bufferSize 为 8192(8KB),这是经过我多次测试后得出的最优值。太小了频繁 IO,太大了浪费内存。

我曾经遇到过一个问题:压缩到一半,用户把 App 杀了。结果 zip 文件损坏,上传后服务器解压失败。后来我加了一个临时文件机制——先压缩到 .tmp 文件,压缩完成后再重命名为 .zip。这样即使中断,也不会影响已有的 zip 文件。

四、定时上传:用 PeriodicWorkRequest 搞定

上传日志不能太频繁,也不能太稀疏。我个人习惯是每天上传一次。

WorkManager 的 PeriodicWorkRequest 正好干这个事。注意,它的最小间隔是 15 分钟,但实际执行时间取决于系统调度。

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

    override suspend fun doWork(): Result {
        return try {
            // 1. 获取日志目录
            val logDir = File(
                applicationContext.filesDir, "logs"
            )
            
            // 2. 压缩日志
            val zipFile = File(
                applicationContext.cacheDir, 
                "logs_${System.currentTimeMillis()}.zip"
            )
            val compressResult = LogCompressor()
                .compressLogs(logDir, zipFile)
            
            if (compressResult.isFailure) {
                return Result.retry()
            }
            
            // 3. 上传到服务器
            val uploadResult = uploadToServer(zipFile)
            
            if (uploadResult.isSuccess) {
                // 4. 上传成功,清理本地压缩包
                zipFile.delete()
                Result.success()
            } else {
                Result.retry()
            }
        } catch (e: Exception) {
            Result.retry()
        }
    }
    
    private suspend fun uploadToServer(file: File): Result<Unit> {
        // 这里实现你的上传逻辑
        // 可以用 OkHttp + MultipartBody
        return Result.success(Unit)
    }
}
注意:doWork() 方法中不要做耗时太长的操作。WorkManager 给每个 Worker 默认 10 分钟的执行时间。如果压缩大文件超时了,记得调用 Result.retry() 让系统重试。

定时任务的调度代码也很简单:

val uploadRequest = PeriodicWorkRequestBuilder<LogUploadWorker>(
    24, TimeUnit.HOURS
).setConstraints(
    Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .setRequiresBatteryNotLow(true)
        .build()
).build()

WorkManager.getInstance(context)
    .enqueueUniquePeriodicWork(
        "log_upload",
        ExistingPeriodicWorkPolicy.KEEP,
        uploadRequest
    )

五、清理过期日志:别让日志变成垃圾

日志上传成功后,本地日志要不要删?我的答案是:要删,但不能全删。

为什么?因为如果上传失败,本地还有备份。我一般保留最近 7 天的日志,超过 7 天的直接清理。

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

    override suspend fun doWork(): Result {
        val logDir = File(
            applicationContext.filesDir, "logs"
        )
        
        if (!logDir.exists()) return Result.success()
        
        val cutoffTime = System.currentTimeMillis() 
            - 7 * 24 * 60 * 60 * 1000L
        
        var deletedCount = 0
        
        logDir.listFiles()?.forEach { file ->
            if (file.lastModified() < cutoffTime) {
                if (file.delete()) {
                    deletedCount++
                }
            }
        }
        
        Log.d("LogCleanup", "清理了 $deletedCount 个过期日志文件")
        return Result.success()
    }
}
关键点:清理任务和上传任务要分开。上传失败时,日志文件还在,可以下次重试。清理任务只清理那些「已经上传成功且过期」的日志。

我记得有一次,有个同事把清理和上传写在一个 Worker 里。结果上传失败,日志也被删了。线上问题查了三天,最后发现是日志没了。从那以后,我坚持把这两个任务拆开。

六、约束条件:让任务在合适的时间执行

约束条件是 WorkManager 最强大的功能之一。说白了,就是告诉系统:「这个任务只能在某些条件下执行」。

对于日志上传,我一般设置这些约束:

约束条件 说明 为什么需要
NetworkType.CONNECTED 网络已连接 没网传个啥?
RequiresBatteryNotLow 电量不低于 20% 避免上传日志把电耗光
RequiresStorageNotLow 存储空间充足 压缩需要临时空间
RequiresDeviceIdle 设备空闲 用户正在用手机时别抢资源
val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .setRequiresBatteryNotLow(true)
    .setRequiresStorageNotLow(true)
    .setRequiresDeviceIdle(true)
    .build()

val cleanupRequest = OneTimeWorkRequestBuilder<LogCleanupWorker>()
    .setConstraints(constraints)
    .build()

WorkManager.getInstance(context)
    .enqueue(cleanupRequest)
我的经验:约束条件不要设置得太苛刻。比如同时要求「充电中 + WiFi + 空闲状态」,那可能一周都执行不了一次。找到平衡点很重要。

七、完整调度流程

最后,把上面所有东西串起来。我一般这样设计调度流程:

  1. App 启动时:注册定时上传任务(每天一次)
  2. 定时任务触发:压缩日志 → 上传 → 成功后通知清理
  3. 清理任务:删除 7 天前的日志文件
  4. 约束条件:所有任务都受网络、电量、存储约束

你可能会问:为什么不把压缩和上传放在一个任务里?嗯,我试过。但压缩失败和上传失败的原因不同——压缩失败通常是磁盘问题,上传失败通常是网络问题。分开处理,重试策略可以更精准。

举个例子:压缩失败,我重试 3 次,每次间隔 5 分钟。上传失败,我重试 5 次,每次间隔 30 分钟。这种精细化的控制,只有拆开任务才能做到。

总结一句话:日志管理不是「写完就扔」的功能。压缩、上传、清理、约束,每一步都要精心设计。用 WorkManager 来做,省心又省力。

公众号:蓝海资料掘金营,微信deep3321