一次 Android 拍照后卡顿的 Perfetto 定位与优化实践

一次 Android 拍照后卡顿的 Perfetto 定位与优化实践

背景

在一个拍照页面中,用户点击拍照后,页面会从相机预览状态切换到照片预览状态。实际体验中,拍照完成后页面存在明显卡顿。

使用 Perfetto 抓取 trace 后,发现拍照后的某一帧耗时异常长,初始阶段最长卡顿接近数百毫秒。

从优化结果看,经过几轮定位和代码调整后,关键耗时大致从:

text 复制代码
400ms+  ->  约 245ms  ->  约 44ms

卡顿体感有明显改善。


一、初始现象:主线程显示 sys_futex

Perfetto 中首先看到的是 App 主线程在一帧中耗时很长,调用链大致类似:

text 复制代码
MainThread
  Choreographer#doFrame
    traversal
      draw
        postAndWait
          sys_futex

乍一看,sys_futex 像是系统层调用,容易误以为问题不在应用侧。

但这里需要特别注意:

sys_futex 并不表示系统在执行大量计算,它通常表示当前线程正在等待某个同步对象,例如锁、条件变量、另一个线程、渲染线程或其他系统组件。

结合父节点 postAndWait,这类调用通常意味着:

text 复制代码
主线程把绘制任务交给 RenderThread 后,在等待 RenderThread 完成。

因此,排查重点不能只停留在主线程,而需要继续看同一时间段的 RenderThread。


二、继续追踪:RenderThread 卡在图形提交路径

同一时间段,RenderThread 上可以看到类似调用链:

text 复制代码
RenderThread
  DrawFrames
    Drawing
      flush layers
        QueueSubmit
          sys_ioctl

初始阶段,sys_ioctl 片段非常长,约数百毫秒,并且大部分时间处于 Sleeping 状态。

这说明 RenderThread 不是在 CPU 上跑了数百毫秒,而是在内核、图形驱动、GPU、Surface 或 fence 等同步点上等待。

此时可以把等待链理解为:

text 复制代码
MainThread
  等 RenderThread

RenderThread
  等图形驱动 / GPU / Surface / fence

所以这不是一个简单的"主线程业务代码执行太慢"的问题,而是拍照完成后触发了图形链路上的重操作或同步等待。


三、结合代码:拍照后直接显示原始图片

进一步检查拍照页面代码后,发现原始逻辑大致如下。

拍照成功回调中直接保存图片文件和图片 URI:

kotlin 复制代码
onImageSaved = { imageUri, imageFile ->
    currentImageFile = imageFile
    previewImageUri = imageUri
    isCaptureInProgress = false
}

UI 根据是否存在 previewImageUri 在相机预览和图片预览之间切换:

kotlin 复制代码
if (previewImageUri == null) {
    CameraPreview(...)
} else {
    CapturedImagePreview(uri = previewImageUri)
}

图片预览中直接加载拍照得到的 URI:

kotlin 复制代码
Image(
    painter = rememberAsyncImagePainter(previewImageUri),
    contentDescription = null,
)

这意味着拍照得到的原始大图可能直接进入 UI 预览链路。

如果拍照图片分辨率较高,而页面中实际显示区域远小于原图尺寸,就可能造成:

text 复制代码
读取图片文件
  -> 解码大图 Bitmap
  -> 创建 GPU texture
  -> RenderThread flush layers
  -> QueueSubmit
  -> 图形驱动 / GPU 同步等待

这与 Perfetto 中看到的 QueueSubmit -> sys_ioctl 长耗时高度吻合。


四、第一轮优化:预览图按显示尺寸降采样

问题

UI 预览区域实际只需要一张较小尺寸的图片,但原逻辑可能让图片库按原图尺寸或较大尺寸解码。

这会增加:

  • Bitmap 解码成本
  • Java/native heap 压力
  • GPU 纹理上传成本
  • RenderThread 绘制提交压力

优化思路

对于 UI 预览,不需要加载原始大图,而应该按目标显示区域加载一张缩小后的预览图。

示例代码如下:

kotlin 复制代码
@Composable
fun CapturedImagePreview(
    uri: Uri,
    modifier: Modifier = Modifier,
) {
    val context = LocalContext.current
    val density = LocalDensity.current

    val previewWidthDp = 400.dp
    val previewHeightDp = 300.dp

    val previewWidthPx = with(density) { previewWidthDp.roundToPx() }
    val previewHeightPx = with(density) { previewHeightDp.roundToPx() }

    val request = remember(uri, previewWidthPx, previewHeightPx) {
        ImageRequest.Builder(context)
            .data(uri)
            .size(previewWidthPx, previewHeightPx)
            .precision(Precision.INEXACT)
            .build()
    }

    Image(
        painter = rememberAsyncImagePainter(request),
        contentDescription = null,
        modifier = modifier.size(previewWidthDp, previewHeightDp),
        contentScale = ContentScale.FillWidth,
    )
}

这里的关键点是:

kotlin 复制代码
.size(previewWidthPx, previewHeightPx)
.precision(Precision.INEXACT)

它告诉图片库:

text 复制代码
只需要一张接近 UI 预览尺寸的图片,不需要按原图完整尺寸解码。

优化效果

这一轮后,长耗时从约 400ms+ 降到约 245ms

说明"原始图片直接上屏"确实是卡顿的重要来源之一。


五、第二轮定位:Runnable Preempted 说明线程被抢占

第一轮优化后,再看 Perfetto,RenderThread 的长 sys_ioctl 仍然存在,但线程状态发生了变化。

之前大部分时间是:

text 复制代码
Sleeping

优化后变成大量:

text 复制代码
Runnable (Preempted)

两者含义不同:

状态 含义
Running 线程正在 CPU 上执行
Sleeping 线程在等待事件、锁、IO、fence 或驱动返回
Runnable / Preempted 线程想运行,但没有拿到 CPU

这说明优化后剩余问题不再主要是"等待 GPU/driver 返回",而更像是:

text 复制代码
RenderThread 想继续运行,但同一时间系统中有其他线程占用了 CPU。

六、用 Perfetto SQL 找出谁在抢 CPU

当看到 Runnable Preempted 占比很高时,下一步需要查对应时间窗口内 CPU 实际在运行哪些线程。

需要注意的是,Perfetto UI 上显示的相对时间不能直接拿来当 SQL 中的 ts 使用。Perfetto SQL 中的时间戳通常是 trace 内部纳秒时间。

更稳妥的方式是:

  1. 先从 slice 表中找到目标 sys_ioctl 的真实 tsdur
  2. 再用这个时间窗口统计 sched 表中实际运行的线程。

下面是一段脱敏后的 SQL 示例:

sql 复制代码
WITH target AS (
  SELECT
    s.ts AS start_ts,
    s.ts + s.dur AS end_ts
  FROM slice s
  JOIN thread_track tt ON s.track_id = tt.id
  JOIN thread t USING (utid)
  LEFT JOIN process p USING (upid)
  WHERE s.name = 'sys_ioctl'
    AND t.name = 'RenderThread'
    AND p.name LIKE '%your.app.package%'
  ORDER BY s.dur DESC
  LIMIT 1
),
overlap AS (
  SELECT
    p.name AS process_name,
    t.name AS thread_name,
    CASE
      WHEN sched.ts > target.start_ts THEN sched.ts
      ELSE target.start_ts
    END AS overlap_start,
    CASE
      WHEN sched.ts + sched.dur < target.end_ts THEN sched.ts + sched.dur
      ELSE target.end_ts
    END AS overlap_end
  FROM sched
  JOIN target
  LEFT JOIN thread t USING (utid)
  LEFT JOIN process p USING (upid)
  WHERE sched.ts < target.end_ts
    AND sched.ts + sched.dur > target.start_ts
)
SELECT
  COALESCE(process_name, '[kernel]') AS process_name,
  COALESCE(thread_name, '[unknown]') AS thread_name,
  ROUND(SUM(overlap_end - overlap_start) / 1000000.0, 3) AS running_ms
FROM overlap
GROUP BY process_name, thread_name
ORDER BY running_ms DESC
LIMIT 30;

查询结果显示,在长 sys_ioctl 窗口中,除了 App 的 RenderThread,还有以下类型线程占用了较多 CPU:

text 复制代码
CameraX / camera related thread
SurfaceFlinger
RenderEngine
Graphics composer service
Kernel memory reclaim / compaction threads
logcat / logd / trace probe related threads

这说明拍照后卡顿并不是单一线程导致,而是多个因素叠加:

text 复制代码
相机处理
+ 图片预览上屏
+ 图形合成
+ GPU/驱动资源分配
+ 内存整理
+ trace/log 采集开销

七、第二轮优化:避免同一帧移除相机预览 Surface

问题

原 UI 逻辑是互斥切换:

kotlin 复制代码
if (previewImageUri == null) {
    CameraPreview(...)
} else {
    CapturedImagePreview(uri = previewImageUri)
}

这意味着拍照成功设置 previewImageUri 的那一帧,会同时发生:

text 复制代码
CameraPreview 从 Compose 树中移除
PreviewView / Surface / TextureView 开始释放
CapturedImagePreview 加入
图片首次解码和上屏
GPU texture 创建
SurfaceFlinger / RenderEngine 重新合成

这些操作叠在同一帧,很容易造成 RenderThread 和图形链路抖动。

优化思路

不要在图片首次显示的同一帧移除相机预览,而是短暂保留相机预览,让图片预览覆盖在上层。

示例代码:

kotlin 复制代码
Box(modifier = Modifier.fillMaxSize()) {
    if (keepCameraPreviewVisible) {
        CameraPreview(
            onImageCaptureReady = { imageCapture = it },
            onError = { /* handle error */ },
        )
    }

    if (previewImageUri != null) {
        CapturedImagePreview(uri = previewImageUri)
    }

    PreviewMask(...)
}

这一步的核心目的不是永久保留相机预览,而是避免:

text 复制代码
Surface teardown 和图片首次上屏发生在同一帧。

优化效果

这一轮后,原先单个 200ms+ 的 QueueSubmit -> sys_ioctl 被明显打散,说明相机预览 Surface 的同帧释放确实是重要影响因素。


八、第三轮优化:分帧显示图片预览

问题

即使相机预览不再同帧移除,拍照成功回调中仍然可能一次性更新多个 Compose 状态:

kotlin 复制代码
currentImageFile = imageFile
previewImageUri = imageUri
isCaptureInProgress = false

这些状态变化可能触发:

  • 按钮状态变化
  • 图片预览进入 Composition
  • 图片请求创建
  • Compose recompose
  • 首帧图片绘制

如果都压在同一帧,仍然容易造成 jank。

优化思路

拍照保存成功后,先更新轻量状态,然后等待 1~2 帧,再设置图片 URI,让图片预览进入 UI。

示例代码:

kotlin 复制代码
onImageSaved = { uri, file ->
    currentImageFile = file
    isCaptureInProgress = false

    coroutineScope.launch {
        withFrameNanos { }
        withFrameNanos { }

        if (currentImageFile == file) {
            previewImageUri = uri
        }
    }
}

这里使用 withFrameNanos 而不是固定 delay(32),是为了更贴近 Compose 帧调度。

防止过期回调

由于图片显示被延迟了两帧,需要避免用户快速重拍或退出时显示旧图片。

因此增加了类似校验:

kotlin 复制代码
if (currentImageFile == file) {
    previewImageUri = uri
}

只有当前文件仍然是这次拍照得到的文件时,才显示对应图片。


九、第四轮优化:短暂保留后释放相机预览

问题

如果相机预览一直保留在图片下面,会带来新的问题:

  • CameraX 仍然在工作
  • Preview surface 仍然存在
  • SurfaceFlinger / RenderEngine 仍然可能持续合成
  • 多帧持续负载增加

因此,保留相机预览只能作为过渡手段,不能永久保留。

优化思路

拍照成功后:

text 复制代码
先保留 CameraPreview
等待两帧后显示图片预览
再延迟一小段时间
最后释放 CameraPreview

示例代码:

kotlin 复制代码
private const val CAMERA_PREVIEW_RELEASE_DELAY_MS = 300L
kotlin 复制代码
onImageSaved = { uri, file ->
    currentImageFile = file
    isCaptureInProgress = false

    coroutineScope.launch {
        withFrameNanos { }
        withFrameNanos { }

        if (currentImageFile == file) {
            previewImageUri = uri
        }

        delay(CAMERA_PREVIEW_RELEASE_DELAY_MS)

        if (currentImageFile == file && previewImageUri == uri) {
            keepCameraPreviewVisible = false
            imageCapture = null
        }
    }
}

重拍时重新恢复相机预览:

kotlin 复制代码
onRetakeClick = {
    keepCameraPreviewVisible = true
    previewImageUri = null
    currentImageFile = null
    imageCapture = null
}

主动解绑相机用例

CameraPreview 离开 Composition 时,主动解绑 CameraX use cases:

kotlin 复制代码
DisposableEffect(Unit) {
    onDispose {
        val providerFuture = ProcessCameraProvider.getInstance(context)
        providerFuture.addListener({
            try {
                providerFuture.get().unbindAll()
            } catch (e: Exception) {
                // log or ignore
            }
        }, ContextCompat.getMainExecutor(context))
    }
}

这样可以避免只是移除了 PreviewView,但 CameraX 仍然绑定在 lifecycle 上继续工作。

优化效果

最终关键长耗时降低到约 44ms

相比最初的数百毫秒级长卡顿,已经有明显改善。


十、为什么不是简单的"系统问题"

这次排查中有一个容易误判的点:

  • 主线程看到 sys_futex
  • RenderThread 看到 sys_ioctl
  • GPU/driver 相关 slice 耗时很长

这些看起来都像系统调用,但它们并不意味着"应用无能为力"。

应用层行为会影响系统图形链路,例如:

  • 是否直接显示原始大图
  • 是否在同一帧释放相机 Surface
  • 是否在同一帧触发多处 Compose 状态变化
  • 是否让图片首帧上屏和 Camera teardown 叠加
  • 是否让 CameraX 释放延迟到用户不敏感的时机

因此,这类问题更准确的理解是:

text 复制代码
系统调用是表现形式,应用层触发的资源变化才是重要诱因。

十一、最终优化策略总结

本次优化采用的是组合策略:

1. 降低图片上屏成本

text 复制代码
原始大图直接显示
-> 按 UI 预览尺寸降采样显示

2. 避免同帧释放 Camera Preview Surface

text 复制代码
CameraPreview 和 ImagePreview 互斥切换
-> 短暂保留 CameraPreview,ImagePreview 覆盖显示

3. 拆分状态更新到多个帧

text 复制代码
拍照回调中立即设置所有状态
-> 先更新轻量状态,等待两帧后再显示图片

4. 延迟释放相机预览

text 复制代码
图片显示同帧释放 CameraPreview
-> 图片稳定后延迟释放 CameraPreview

5. 主动解绑 CameraX

text 复制代码
PreviewView 移除后 CameraX 可能仍绑定
-> CameraPreview onDispose 时主动 unbindAll

6. 减少性能测试干扰

text 复制代码
移除频繁 recomposition log
减少 trace/log 对测试结果的影响

十二、Perfetto 分析经验总结

1. 先看父子调用链,不要只看 syscall 名字

sys_futexsys_ioctl 是结果,不是根因。

需要结合上下文判断:

text 复制代码
postAndWait + sys_futex
=> 主线程在等 RenderThread

QueueSubmit + sys_ioctl
=> RenderThread 在图形提交路径中

2. 线程状态比函数名更重要

同样是 sys_ioctl

  • Sleeping 为主:更像在等 GPU/driver/fence。
  • Runnable Preempted 为主:更像线程想跑但被其他线程抢占。

3. Runnable Preempted 要用 SQL 查 CPU 使用者

当线程长期 Preempted,不要只盯着当前线程,应查同一时间窗口中 CPU 上实际跑了哪些线程。

4. 用小实验验证假设

不要一次性大改。推荐按假设逐步验证:

假设 实验
原图上屏太重 图片预览按目标尺寸降采样
Surface 同帧 teardown 太重 保留 CameraPreview,图片叠加显示
状态更新挤在同一帧 延迟两帧设置图片 URI
CameraPreview 持续占资源 短暂保留后释放并主动解绑

5. 关注 trace 本身带来的干扰

如果开启过多数据源,例如 logcat、raw syscall、过大的 buffer,trace 本身也会增加 CPU 和 IO 压力。

在问题方向已经明确后,可以适当减少采集项,让数据更接近真实运行表现。


十三、后续可继续尝试的方向

如果还需要进一步压缩剩余 40ms 左右的短时波动,可以继续做以下实验。

1. 延长 CameraPreview 释放延迟

例如从:

kotlin 复制代码
private const val CAMERA_PREVIEW_RELEASE_DELAY_MS = 300L

调整为:

kotlin 复制代码
private const val CAMERA_PREVIEW_RELEASE_DELAY_MS = 500L

目的不是减少释放成本,而是把释放波动移动到用户更不敏感的时间点。

2. 尝试相机预览的性能模式

如果当前使用的是兼容模式,可以实验使用性能模式:

kotlin 复制代码
previewView.implementationMode = PreviewView.ImplementationMode.PERFORMANCE

但需要验证 SurfaceView 与 Compose 遮罩、覆盖层的层级兼容性。

3. 评估拍照输出分辨率

如果业务不需要最高质量原图,可以评估降低拍照输出尺寸或使用低延迟拍照模式。

但这可能影响后续业务效果,应结合产品需求评估。


总结

这次卡顿优化的关键不是找到某一个"慢函数",而是通过 Perfetto 还原出完整等待链:

text 复制代码
MainThread 等 RenderThread
RenderThread 等图形提交 / GPU / driver
图形链路又受到图片上屏、Camera surface 切换、内存分配和 CPU 调度竞争影响

最终通过降低图片上屏成本、拆分状态更新、避免同帧释放相机预览 Surface、延迟释放并主动解绑 CameraX,将关键长耗时从数百毫秒降低到几十毫秒。

这类问题的经验是:

Android UI 卡顿不一定发生在应用主线程的业务代码里。很多时候,应用层的资源切换和 UI 状态变化会放大到 RenderThread、SurfaceFlinger、GPU driver 和内核调度层面。Perfetto 的价值就在于帮助我们把这条链路完整串起来。

相关推荐
OpenFDE开源桌面1 小时前
实操:如何将安卓 App与Linux系统应用级融合(附代码)
android·linux
xiangxiongfly9151 小时前
Android VideoView总结
android·videoview
杉氧1 小时前
从 Modifier 到 Flexbox:React Native 布局与样式设计哲学
android·前端·react native
Kapaseker1 小时前
我常用的 5 个 Kotlin 优化小技巧
android·kotlin
恋猫de小郭2 小时前
Flutter 多窗口支持类型和 API 介绍
android·前端·flutter
张风捷特烈2 小时前
当 AI 遇见 Flutter | 打造 500+ Widget 专属Logo
android·前端·flutter
mengge.cloud2 小时前
云计算&服务器基础小白教程
android·linux·运维·服务器·网络·云计算
PHP实战开发录3 小时前
PHP后台大文件导出内存溢出排查记录
性能优化·系统架构·php·开发
天空之城--3 小时前
Android Flutter行业最新动态与实用参考(2026年8月第3周)
android·人工智能·flutter·ai编程