先把结论放在前面:Canvas 是"命令式绘制接口",Bitmap 是"像素内存容器",两者的性能问题根因完全不同。 Canvas 慢的根因在每帧的绘制指令数与状态切换(重绘、离屏图层、save/restore 滥用);Bitmap 慢的根因在像素数据量与采样策略(未采样、格式、缓存命中)。把这两类慢混在一起讲"加缓存",是这题最常见的失分点。
面试官问"Canvas 与 Bitmap 为什么慢",真正想听的是一整套排查逻辑:现象是什么、用什么指标度量、瓶颈在哪一层、改完怎么证明有效。 这套动作平时没做过,现场编不出来。所以这篇的重点不在 API,而在指标与归因。
像素账:先把内存算清楚
Bitmap 的内存占用有一个可以直接算的公式:
占用字节 ≈ 宽度 × 高度 × 每像素字节数
每像素字节数 由 Config 决定:
ARGB_8888= 4 字节(默认,带 alpha,内存最大)RGB_565= 2 字节(无 alpha,内存减半)ARGB_4444= 2 字节(质量已废弃,不建议)RGBA_F16= 8 字节(HDR 场景)Hardware= 不占常规堆内存,存于图形内存
用这个公式可以直接判断问题:一张 1080×2400 的相机原图按 ARGB_8888 是 1080 × 2400 × 4 ≈ 9.9 MB。一个列表页放 20 张这样的图就是 200 MB,OOM 近乎难以避免。 这就是"未采样直接加载原图"为什么会崩——很多人在这里只说"太大了",说不清"大多少、怎么算出来的"。
Bitmap 还有一个常被忽略的账:rowBytes 与内存回收。 实际上 Bitmap 的内存是 rowBytes × height,而 rowBytes 可能因为对齐(通常 4 字节,某些 GPU 要求更多)而大于 width × 4。所以实际占用略大于公式值,大图上这个差值可观。
硬件位图(Config.Hardware)是个特殊存在:它把像素放在图形内存里,CPU 无法直接读。这带来一个明确的限制——对硬件位图调用 getPixels()、copyPixelsFromBitmap 等需要 CPU 访问的 API 会抛 IllegalStateException。所以"硬件位图更省内存"与"能对位图做像素级处理"是互斥的,缓存策略必须区分类型,这是这题里少有人答对的细节。
Canvas 慢的三个真实根因
第一,重绘而不是重绘区域。 invalidate() 让整个 View 重走 measure-layout-draw(requestLayout 更糟)。修正手段是 invalidate(left, top, right, bottom) 或 postInvalidateOnAnimation()。这一条是"View 动画掉帧"最常见的根因。
第二,saveLayer 与离屏缓冲滥用。 saveLayer 会分配一张离屏位图并做一次额外的合成,在 onDraw 里每帧调用它就是每帧新建位图。同理,setShadowLayer 在硬件加速下会触发额外的离屏操作。
第三,Paint 状态频繁切换。 每次 drawText 换一次 color、换一次 textSize、换一次 typeface,都会让渲染管线重新准备状态。正确做法是"按状态分组绘制"——把相同 Paint 属性的绘制放在一起。这是一个真实存在、但几乎没人主动讲的优化点。
最常见的坑是
第一层坑:未采样直接加载相机原图。
BitmapFactory.decodeStream / decodeFile 不传 inSampleSize,解码的是原始像素。规范是先用 inJustDecodeBounds = true 只读尺寸,算出目标宽高比,再按 2 的幂次采样:
fun decodeSampled(path: String, reqW: Int, reqH: Int): Bitmap? {
val bounds = BitmapFactory.Options().apply {
inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
val (w, h) = bounds.outWidth to bounds.outHeight
// 关键:算出 2 的幂次采样系数
var sample = 1
while (w / (sample * 2) >= reqW && h / (sample * 2) >= reqH) {
sample *= 2
}
return BitmapFactory.decodeFile(path, BitmapFactory.Options().apply {
inSampleSize = sample
inPreferredConfig = Bitmap.Config.RGB_565 // 无透明需求时省一半
})
}
这段代码的关键是 inJustDecodeBounds 这一步——不解码像素、只读图片头就能拿到宽高,这是"先算后解"的标准姿势。"两遍解码"听起来浪费,实际远优于解一张 10 MB 的图。
第二层坑:对硬件位图做像素级操作。
如前所述,Config.Hardware 的位图 CPU 不可读。如果代码里有 bitmap.getPixels(...)、把它画到 Canvas 上再 readPixels、或者做逐像素滤镜,那么一旦这个位图来自硬件加速路径就会崩。 规范是像素级处理前先 copy(ARGB_8888, false) 转一份软件位图,并明确区分缓存里哪些是硬件位图、哪些是软件位图。
第三层坑:把位图缓存做成"只增不减"。
很多自研缓存只 put 不 evict,结果缓存本身成了泄漏源。规范是用 LruCache 且 sizeOf 返回字节数:
object BitmapCache {
// 缓存上限取可用内存的 1/8,这是经验值不是标准
private val cache = object : LruCache<String, Bitmap>(
((Runtime.getRuntime().maxMemory() / 1024) / 8).toInt()
) {
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount / 1024
}
fun get(key: String): Bitmap? {
cache.get(key)?.also {
// 规范:命中后重新 put 维持 LRU 顺序
cache.put(key, it)
}
return null
}
fun put(key: String, bmp: Bitmap) = cache.put(key, bmp)
fun trim() = cache.evictAll()
}
这段代码里 sizeOf 必须返回 byteCount / 1024——如果错误地返回 count++,LruCache 就失去了按内存控制的意义,缓存一个 10 MB 的图和一个 1 KB 的图"权重"相同。这是自研图片缓存最经典的 bug。
还有一个更隐蔽的坑:Bitmap.recycle() 用错位置。 在 API 26+ 上,Bitmap 的内存由像素引用计数管理,recycle() 之后若该位图仍被绘制,会画出一片灰("Software bitmap cannot be recycled"那类崩溃)。规范是:除非确定位图已不再被任何 View 持有且不再绘制,否则不要调 recycle();让 GC 自然回收,或在 onTrimMemory 里整体清理缓存。
代码里见真章
先看一个把"指标 → 归因 → 验证"三步走完的排查实现:
class DrawProfiler {
private val frameTimes = ArrayDeque<Long>()
private var lastFrameNs = 0L
fun onFrameStart() {
val now = System.nanoTime()
if (lastFrameNs != 0L) {
val delta = now - lastFrameNs
frameTimes.addLast(delta)
if (frameTimes.size > 120) frameTimes.removeFirst()
}
lastFrameNs = now
}
fun report(): String {
if (frameTimes.isEmpty()) return "no samples"
val sorted = frameTimes.sorted()
val p50 = sorted[sorted.size / 2] / 1_000_000.0
val p95 = sorted[(sorted.size * 95) / 100] / 1_000_000.0
val over = frameTimes.count {
it / 1_000_000 > 16.6 }
// 指标:P50 看常态,P95 看抖动,over 看掉帧比例
return "p50=%.2fms p95=%.2fms jank=%d/%d".format(p50, p95, over, frameTimes.size)
}
fun reset() {
frameTimes.clear(); lastFrameNs = 0L }
}
// 在 View 里接 Choreographer 而不是 postInvalidateOnAnimation,
// 这样能拿到每一帧的真实起点时间
class ProfilingView(context: Context) : View(context), Choreographer.FrameCallback {
private val profiler = DrawProfiler()
override fun doFrame(frameTimeNanos: Long) {
profiler.onFrameStart()
doActualDraw()
Choreographer.getInstance().postFrameCallback(this)
}
private fun doActualDraw() {
/* 实际绘制逻辑 */ }
}
这段代码值得盯三处:第一处,用 Choreographer.FrameCallback 而不是 invalidate 后自测时间,因为前者拿的是真实 vsync 帧起点;第二处,统计 P50 / P95 / jank 比例三个指标而不是平均值——平均值会掩盖长尾,而用户感知到的卡顿恰恰来自长尾;第三处,环形队列只保留 120 帧,避免长期运行内存增长。
再看不重绘整屏的局部刷新写法:
class ProgressRingView : View {
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 8f
strokeCap = Paint.Cap.ROUND
color = 0xFFE0E0E0.toInt()
}
private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 8f
strokeCap = Paint.Cap.ROUND
color = 0xFF2196F3.toInt()
}
private val arcBounds = RectF()
private val arcPath = Path()
private var sweepAngle = 0f
private var lastInvalidateRect: Rect? = null
fun setProgress(fraction: Float) {
val newSweep = 360f * fraction.coerceIn(0f, 1f)
if (newSweep == sweepAngle) return // 幂等:值没变不重绘
sweepAngle = newSweep
// 关键:只 invalidate 变化的那段弧所在的矩形,而不是整屏
invalidateDirtyRegion()
}
private fun invalidateDirtyRegion() {
val radius = arcBounds.width() / 2
if (radius <= 0f) {
invalidate(); return }
// 依据新旧 sweep 的角度范围计算脏矩形
val dir = if (sweepAngle >= (lastInvalidateRect?.let {
0f } ?: 0f)) 1 else -1
val startRad = Math.toRadians(-90.0)
val endRad = Math.toRadians(-90.0 + sweepAngle)
val left = arcBounds.centerX() - radius - 16f
val top = arcBounds.centerY() - radius - 16f
val right = arcBounds.centerX() + radius + 16f
val bottom = arcBounds.centerY() + radius + 16f
// 方向未使用但保留参考:dir 表示增量方向
invalidate(left, top, right, bottom)
lastInvalidateRect = RectF(left, top, right, bottom).let {
Rect() }
}
override fun onSizeChanged(w: Int, h: Int, ow: Int, oh: Int) {
// 尺寸变化时统一更新几何对象,不在 onDraw 里计算
val pad = progressPaint.strokeWidth / 2f
arcBounds.set(pad, pad, w - pad, h - pad)
}
override fun onDraw(canvas: Canvas) {
// 关键:零分配,不 new Paint/RectF/Path
canvas.drawArc(arcBounds, -90f, sweepAngle, false, progressPaint)
canvas.drawArc(arcBounds, -90f, 360f - sweepAngle, false, trackPaint)
// startRad / endRad 已计算但此处直接用常量更清晰,保留变量说明来源
}
}
这段代码的核心是 setProgress 里的幂等判断 + 局部 invalidate——值没变就不重绘,是自定义 View 最容易被忽略也最容易见效的一条优化。
关于 Canvas 状态切换的优化,用一个"按状态分组"的例子说明:
override fun onDraw(canvas: Canvas) {
// 反模式:color 在 text 间来回切换,每次切换都会让渲染管线重新准备状态
// repeat(1000) {
// textPaint.color = if (it % 2 == 0) RED else BLUE
// canvas.drawText("item $it", x, y, textPaint)
// }
// 正模式:按属性分组,同一属性只设置一次
val groups = items.groupBy {
it.color }
for ((color, group) in groups) {
textPaint.color = color
for (item in group) canvas.drawText(item.text, item.x, item.y, textPaint)
}
}
这段代码的价值在于点出一个真实存在的优化维度——"按状态分组"在文本量大的场景能带来可观提升,这是 Canvas 绘制里最容易被忽略的一类优化。
面试中的经典考点
问:怎么排查 Canvas 绘制慢?
答:四步,顺序不能乱。① 定位是"绘制慢"还是"布局慢"——onDraw 耗时长是绘制问题,频繁 requestLayout 是测量布局问题,两者处理方式不同;② 用 Trace 或自埋 Choreographer 测 onDraw 耗时,看 P95 而不是平均值;③ 逐项排除——是否每帧 new 对象(触发 GC)、是否用了 saveLayer、是否调了 setShadowLayer、是否频繁切 Paint 状态、是否整屏 invalidate 而非局部;④ 改完复测同一指标,用数据证明有效。"加缓存"不是排查结论,而是排除完重绘与状态切换之后的可能选项。
问:Bitmap 内存怎么算?为什么有的框架会自动缩放?
答:公式是 width × height × 每像素字节数。多数图片加载框架在缓存里存的是采样后的图(按目标 View 尺寸算 inSampleSize),而不是原图——所以"同一个 URL 加载两次尺寸不同会得到两个缓存条目"是正确行为,不是 bug。如果框架只按 URL 做 key 而不把目标尺寸纳入,就会出现"详情页用大图、列表页用小图互相覆盖"的问题。
问:硬件位图和软件位图的区别?什么时候不能用硬件位图?
答:硬件位图像素在图形内存,GPU 可直接采样,绘制快、不占常规堆;代价是 CPU 读不到。凡是需要 CPU 访问像素的场景都不能用——getPixels、copyPixelsFromBitmap、像素滤镜、BitmapDrawable 之外的逐像素操作、某些 Canvas 混合模式。规范是缩略图、列表图可以用硬件位图;需要处理或需要读像素的场景显式 copy(ARGB_8888, false) 转软件位图。
问:Bitmap.recycle() 什么时候该用?
答:现代 Android(API 26+)上基本不该用。Bitmap 内存由 native 侧的引用计数管理,GC 会回收;手动 recycle 之后若仍被绘制会崩溃或画空白。仍需要手动干预的场景是"大图批量解码后立即释放"的极端内存压力场景,此时必须在解码后立刻用完并 recycle,且确保没有任何引用。面试能主动说明"这个 API 在现代 Android 上基本不该用",是加分的。
问:为什么 Canvas 硬件加速下 drawBitmap 快,但某些操作会变慢?
答:硬件加速把绘制转成 GPU 命令,位图上传纹理后绘制几乎零 CPU 成本;但某些操作会触发"软件回退"——Canvas.clipPath 的非矩形裁剪、drawBitmapMesh、某些 PorterDuff 组合、读取像素,这些会让渲染管线回落到 CPU 路径,反而比不开硬件加速更慢。规范是热点路径避免非矩形 clipPath,用 saveLayer 或改用预处理的位图。
落到项目里怎么做
一条能写进规范的红线:所有进入缓存与界面展示的位图,必须经过采样,且采样目标尺寸与展示 View 的尺寸一致。 这条直接决定"列表滑动是否 OOM"。
配套实践三条:① 自研图片层统一实现"三段缓存 + 采样 + 内存上限 + onTrimMemory 联动",不要让业务直接 BitmapFactory 解码;② 自定义 View 的 onDraw 遵守"零分配"并用 StrictMode/自定义 lint 卡住,对象在 onSizeChanged 里建好;③ 性能验证固化为可重复的动作——同一个长列表在低端机上滚动 60 秒,记录 P95 帧耗时与 jank 比例,改动前后对比。没有基线数据,优化就只能靠感觉。
给正在准备面试的你
这题的答法要落到"两类问题分开归因 + 指标驱动排查"。推荐这条线:先算像素账(w × h × 每像素字节数,ARGB_8888 vs RGB_565 差一倍)并给出具体数字 → 讲 Canvas 慢的三个根因(整屏重绘、saveLayer 滥用、Paint 状态切换)→ 讲 Bitmap 慢的根因(未采样、key 漏参数、缓存无上限)→ 落到"采样 + LruCache 按字节计量 + 局部 invalidate"这套组合。能被追问到"硬件位图为什么不能 getPixels"并答出图形内存与 CPU 可达性,说明真处理过这类问题;能主动给出 P95 与 jank 比例这类指标,说明真的做过性能优化而不是只读过文档。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:属性动画与估值器:插值曲线如何驱动画面
下一篇预告:图片加载原理:三级缓存与解码采样
有任何问题欢迎在评论区留言交流。