5、CPU负载优化:主线程轻量化、计算密集型任务迁移、WorkManager与协程调度

CPU负载优化,说白了就是别让手机处理器累死。尤其是主线程,它一旦卡住,用户立刻就能感觉到——掉帧、卡顿、ANR。我这些年排查过的性能问题,十有八九都跟主线程干不该它干的活有关。

你想想看,Android的主线程要处理布局、绘制、事件分发,还要响应生命周期回调。它本来就很忙了。如果我们再把图片解码、数据解析、文件读写这些重活塞给它,那它不卡谁卡?

主线程轻量化:能不做就别做

我个人习惯,写代码时会时刻问自己一个问题:这件事真的必须在主线程做吗?

举个例子,很多人在onCreate里直接解析JSON、加载大图。其实这些完全可以放到子线程。我在项目中遇到过一位同事,他在onCreate里做了三次SharedPreferences的读取,每次读取还带字符串拼接。结果就是启动时白屏时间多了200ms。

核心原则:主线程只做UI操作和轻量级逻辑。任何超过1ms的操作,都值得怀疑。

常见的“主线程杀手”包括:

  • Bitmap解码(尤其是大图)
  • JSON/XML解析
  • 数据库查询(哪怕只有几十条记录)
  • 文件I/O(读写SharedPreferences也算)
  • 正则匹配(这个特别容易被忽略)
  • 对象序列化/反序列化

嗯,这里要注意:不是所有操作都能简单丢到子线程。比如View的创建和测量必须在主线程,但View的数据准备可以提前在子线程做好。这就是所谓的“预加载”思路。

计算密集型任务迁移:别让主线程算数学题

什么叫计算密集型?就是那种CPU占用高、执行时间长的任务。比如视频编解码、音频重采样、图像滤镜处理、机器学习推理。这些任务如果放在主线程,那画面简直不敢想。

我记得有一次做视频编辑功能,用户裁剪视频后需要生成缩略图。最初实现是在主线程逐帧解码,结果手机烫得能煎鸡蛋。后来我把解码逻辑迁移到子线程,配合Surface的异步渲染,CPU占用直接降了40%。

迁移策略其实很简单:

  1. 识别热点:用Systrace或Profiler找出CPU占用高的方法
  2. 评估可迁移性:是否依赖UI?是否线程安全?
  3. 选择调度工具:协程、线程池、WorkManager,各有适用场景
  4. 验证结果:迁移后主线程帧率是否稳定?ANR是否减少?

小技巧:对于视频处理这类任务,可以考虑使用RenderScript或Vulkan Compute Shader,把计算压力从CPU转移到GPU。我在项目中试过,处理1080p视频的滤镜效果,GPU方案比CPU方案快了将近3倍。

WorkManager与协程调度:选对工具干对活

说到任务调度,很多人的第一反应是开个线程或者用AsyncTask。但AsyncTask已经被标记为废弃了,而且它处理生命周期真的很糟糕。我个人现在主要用两种方案:协程WorkManager

协程:轻量级任务的首选

协程的优势在于轻量和可控。它不像线程那样占用大量资源,一个线程可以跑成千上万个协程。而且协程的取消机制非常优雅,配合viewModelScopelifecycleScope,可以自动跟随生命周期取消。

// 协程处理音视频解码的典型写法
viewModelScope.launch(Dispatchers.IO) {
    val decodedData = withContext(Dispatchers.Default) {
        // 计算密集型解码任务
        decoder.decodeFrame(rawData)
    }
    withContext(Dispatchers.Main) {
        // 回到主线程更新UI
        imageView.setImageBitmap(decodedData)
    }
}

这里有个坑:Dispatchers.Default和Dispatchers.IO的区别。Default用于CPU密集型任务,IO用于网络和文件操作。用错了反而会影响性能。我曾经在项目里把大量IO操作丢到Default线程池,结果导致CPU线程池被阻塞,其他计算任务排队等待。

WorkManager:后台任务的可靠选择

WorkManager适合那些需要保证执行、可以延迟、甚至需要设备重启后继续的任务。比如视频上传、音频转码、缓存清理。

它的好处是:

  • 自动处理生命周期和电源管理
  • 支持链式任务和并行任务
  • 可以设置约束条件(如网络可用、充电状态)
  • 兼容Doze模式和低电量模式
// 定义一个视频转码的Worker
class VideoTranscodeWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        return try {
            val inputUri = inputData.getString("input_uri")
            // 执行转码任务
            transcodeVideo(inputUri)
            Result.success()
        } catch (e: Exception) {
            // 重试策略
            if (runAttemptCount < 3) Result.retry() else Result.failure()
        }
    }
}

注意:WorkManager的doWork方法默认运行在后台线程,但如果你用CoroutineWorker,它会在协程中执行。记得不要在里面做UI操作。另外,WorkManager的最小执行间隔是15分钟,不适合需要立即执行的任务。

知识体系总览

下面这张图总结了CPU负载优化的核心思路:从识别问题到选择工具,再到落地执行。

CPU负载优化知识体系 问题识别 主线程轻量化 计算密集型迁移 任务调度工具 具体手段 • 避免主线程I/O • 延迟非必要初始化 迁移策略 • 识别热点函数 • 选择合适线程池 工具选择 • 协程:轻量任务 • WorkManager:后台任务 验证:Systrace / Profiler / 帧率监控 从问题识别到工具选择,再到效果验证,形成闭环

避坑指南

最后分享几个我踩过的坑:

  • 协程滥用:我曾经在一个页面里启动了十几个协程,结果内存泄漏了。后来养成了习惯,所有协程都绑定到viewModelScopelifecycleScope,并且用SupervisorJob隔离异常。
  • WorkManager重试陷阱:默认的重试策略是退避算法,但如果你不设置BackoffPolicy,重试间隔会越来越长。对于需要快速重试的任务,记得设置setBackoffCriteria
  • 线程池大小:很多人直接用Executors.newCachedThreadPool(),结果线程数暴涨导致CPU过载。我建议根据设备核心数来设置,一般核心数+1是比较安全的。
  • 主线程检测:可以用StrictMode在开发阶段检测主线程的磁盘读写和网络访问。我把它加到了Debug构建的Application里,效果很好。

总结一下:CPU负载优化的核心就三句话——主线程只做UI,重活交给子线程,选对工具省力气。做到这三点,你的App离流畅就不远了。

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