一次 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 内部纳秒时间。
更稳妥的方式是:
- 先从
slice表中找到目标sys_ioctl的真实ts和dur。 - 再用这个时间窗口统计
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_futex 和 sys_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 的价值就在于帮助我们把这条链路完整串起来。