第148篇Canvas 与 Bitmap:绘制资源的内存账

简介: Canvas是命令式绘制接口,慢因在于重绘区域过大、saveLayer滥用及Paint状态频繁切换;Bitmap是像素内存容器,慢因在于未采样加载、格式不当(如ARGB_8888)及缓存未按字节计量。二者性能根因迥异,混谈“加缓存”是常见误区。核心是指标驱动归因:用P95帧耗时、jank率定位Canvas瓶颈;用width×height×bytesPerPixel精算Bitmap内存。

先把结论放在前面: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软件开发面试·从入门到精通」连载系列

上一篇:属性动画与估值器:插值曲线如何驱动画面

下一篇预告:图片加载原理:三级缓存与解码采样

有任何问题欢迎在评论区留言交流。

相关文章
|
20天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
8863 26
|
19天前
|
人工智能 并行计算 PyTorch
秋叶 ComfyUI 2026 整合包 v3.2 完整部署教程:Python 3.13 + Torch 2.13 全栈升级
秋叶aaaki ComfyUI 2026年8月整合包v3.2正式发布!全面升级Python 3.13.11、PyTorch 2.13.0+cu130及ComfyUI v0.30.2,原生支持MiniMax H3、Wan 2.2、Qwen-Image-2.1等2026主流音视频/图像模型,解压即用,无需环境配置。
3757 16
|
18天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
2237 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
5天前
|
人工智能 JSON 自然语言处理
2026 年 Jev 决策模型深度拆解:原理解读、实战测评与保姆级落地教程
有一款特殊AI模型在开发者圈子刷屏,它摒弃传统大模型擅长的对话聊天能力,专注做高速结构化决策,它就是TypeSafe AI推出的Jev模型。该模型由ChatGPT共同发明人Diogo Almeida主导研发,定位为**System One Model(系统一模型)**,对标人类大脑快速直觉判断的思维模式,在响应延迟、调用成本、结构化输出稳定性上相比传统生成式大模型有着巨大差异。本文会完整拆解Jev底层原理、三大核心原语能力、适用业务场景,同时提供可直接运行的curl、Python代码示例,并且结合多组实测数据,客观分析模型优势与能力边界,帮助普通开发者和AI应用从业者快速上手落地。
405 1
|
13天前
|
人工智能 Linux 开发者
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
Codex是OpenAI推出的AI编程智能体,可读取本地项目、理解需求并自动修改代码。支持桌面GUI、命令行(CLI)及VS Code/Cursor插件三种形态,覆盖可视化操作、终端高效开发与编辑器无缝集成场景,助开发者用自然语言驱动编码全流程。(239字)
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
|
6天前
|
存储 人工智能 并行计算
大模型本地部署终端选型方法论:以 Qwen3.8-27B 为例的四档分层完整流程
本文提出一套大模型本地部署终端选型方法论:定约束、定档位、定框架、定参数四步决策法,配合入门、主力、质量、无损四档分层模型。以 Qwen3.8-27B 实测数据为例,逐环节解读显存、带宽、存储、散热、系统、预算等要素,给出面向不同预算的优选方案、决策自查清单与市场观察框架。文末前瞻 AI 笔记本的 CPU+GPU 与统一内存两条路线,论证四步决策法在新品类上的延续性。
|
7天前
|
人工智能 Linux Windows
千问办公(QwenWork)官网入口:其实有2个,一个是网页端千问办公,一个是介绍指南页面
千问办公(QwenWork)是阿里云推出的AI智能办公平台,支持网页端直接使用及Windows/Mac/Linux客户端下载。提供PPT生成、财报分析、网页搭建等AI功能,个人版免费,企业版198元/席/月。详情见官网qwenwork.cn或阿里云产品页。
931 0
千问办公(QwenWork)官网入口:其实有2个,一个是网页端千问办公,一个是介绍指南页面
|
19天前
|
云安全 人工智能 安全

热门文章

最新文章