4、纹理与内存优化:让车载图形不再“卡顿”

好,我们进入第四章。这一章聊的是纹理和内存,说白了就是怎么让GPU吃得少、跑得快。我在车载项目里见过太多因为纹理爆炸导致系统卡死的案例,嗯,今天就把这些坑一个个填上。

4.1 纹理压缩格式选择:ASTC vs ETC2

纹理压缩,不是简单的“压缩图片”。它决定了你的显存占用、带宽消耗,甚至渲染质量。我习惯把纹理压缩比作“给GPU吃的压缩饼干”——既要管饱,又不能硌牙。

4.1.1 ETC2:老牌选手,兼容性之王

ETC2是OpenGL ES 3.0的标配。几乎所有Android设备都支持。它的压缩比固定为4:1(每个像素4比特)。

  • 优点:兼容性极好,老设备也能跑
  • 缺点:不支持透明通道的独立压缩(Alpha通道质量一般)
  • 适用场景:非透明纹理、UI背景、普通贴图
我的经验:在车载仪表盘项目中,ETC2用于背景纹理和按钮贴图,效果稳定。但遇到半透明图标时,我果断换成了ASTC。

4.1.2 ASTC:新一代王者,灵活度拉满

ASTC是ARM推出的格式,支持从4x4到12x12的块大小。压缩比可以从2:1到8:1自由调节。说白了,你可以根据纹理重要性“按需分配”显存。

块大小 压缩比 质量 推荐场景
4x4 2:1 极高 UI图标、重要纹理
6x6 3:1 普通贴图
8x8 4:1 中等 背景纹理
12x12 8:1 模糊纹理、法线贴图
注意:ASTC在部分老款高通芯片上存在解码延迟。我曾经在骁龙625上踩过坑——ASTC 4x4的纹理加载时间比ETC2多了30%。所以,建议在运行时检测GPU型号,动态选择格式。

4.1.3 我的选择策略

我个人习惯这样选:

  1. UI纹理:ASTC 4x4(质量优先)
  2. 3D模型贴图:ASTC 6x6(平衡)
  3. 背景/远景:ASTC 8x8或ETC2(省显存)
  4. 兼容性兜底:ETC2(老设备回退)

4.2 纹理图集(Texture Atlas)技术

纹理图集,就是把一堆小纹理拼成一张大图。为什么要这么做?因为GPU切换纹理的开销很大。你想想看,每切换一次纹理,GPU就要重新绑定、刷新缓存,这中间的时间足够渲染好几个三角形了。

4.2.1 图集的核心优势

  • 减少Draw Call:一张图集对应一个纹理绑定,多个UI元素可以一次绘制
  • 提高缓存命中率:GPU的纹理缓存更喜欢连续访问同一张大图
  • 降低显存碎片:小纹理分散存储容易产生碎片,图集统一管理

4.2.2 图集生成的注意事项

我在项目中遇到过一个问题:图集里的纹理边缘出现“出血”现象。说白了,就是相邻纹理的颜色互相渗透。原因很简单——纹理采样时,GPU会读取相邻像素。

解决方案:在每张小纹理周围留2-4像素的padding,并填充边缘像素颜色。或者使用TexturePacker这类工具自动处理。
// 图集UV计算示例
// 假设图集大小为1024x1024,小纹理位于(100, 200),尺寸为64x64
float atlasWidth = 1024.0f;
float atlasHeight = 1024.0f;
float texWidth = 64.0f;
float texHeight = 64.0f;

// 计算UV坐标(带padding)
float padding = 2.0f;
float u0 = (100.0f + padding) / atlasWidth;
float v0 = (200.0f + padding) / atlasHeight;
float u1 = (100.0f + texWidth - padding) / atlasWidth;
float v1 = (200.0f + texHeight - padding) / atlasHeight;

4.3 内存抖动(Memory Jank)的避免

内存抖动,就是内存频繁分配和释放导致的卡顿。在车载系统里,这简直是“隐形杀手”。你开着导航,突然界面卡了一下——很可能就是内存抖动在作祟。

4.3.1 抖动的根源

  • 频繁创建Bitmap:每次加载图片都new一个Bitmap对象
  • 临时纹理对象:渲染过程中不断创建临时纹理
  • GC频繁触发:Java层的垃圾回收会暂停所有线程
我曾经在一个车载项目中,发现仪表盘的指针动画每帧都创建新的Bitmap。结果GC每2秒触发一次,动画直接变成“幻灯片”。后来改用预分配+复用,问题彻底解决。

4.3.2 避免抖动的实战技巧

  1. 预分配内存池:在初始化阶段分配好所有纹理内存
  2. 对象池模式:复用Bitmap和纹理对象,避免频繁new/delete
  3. 使用inBitmap:Android的Bitmap复用机制,允许重用已分配的内存
  4. 避免在渲染循环中分配内存:所有内存分配放在初始化或后台线程

4.4 Bitmap复用与缓存策略

Bitmap是Android图形系统里最吃内存的对象之一。一张1920x1080的ARGB_8888图片,就要吃掉8MB内存。你想想看,如果同时加载10张这样的图片,80MB就没了。

4.4.1 Bitmap复用:inBitmap的妙用

Android 3.0+提供了inBitmap选项,允许你复用已有的Bitmap内存。说白了,就是告诉系统:“别给我分配新内存了,用这块旧的就行。”

// Bitmap复用示例
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;  // 必须设置为可变
options.inBitmap = reusableBitmap;  // 复用已有的Bitmap

// 注意:新图片的尺寸必须小于等于reusableBitmap的尺寸
// 且格式必须兼容(建议都用ARGB_8888)
Bitmap newBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.new_image, options);
我的习惯:在车载项目中,我会维护一个Bitmap池,按尺寸分类存储。加载图片时,先从池中找尺寸匹配的Bitmap复用。找不到再创建新的。这样内存分配次数减少了80%以上。

4.4.2 三级缓存策略

我推荐使用“内存-磁盘-网络”三级缓存。但车载场景下,网络缓存用得少,主要是内存和磁盘。

缓存层级 存储位置 容量 淘汰策略
L1:内存缓存 LruCache 应用可用内存的1/8 LRU(最近最少使用)
L2:磁盘缓存 DiskLruCache 50-100MB LRU + 文件大小限制
// 内存缓存示例(使用LruCache)
int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
int cacheSize = maxMemory / 8;  // 1/8的内存用于缓存

LruCache<String, Bitmap> memoryCache = new LruCache<String, Bitmap>(cacheSize) {
    @Override
    protected int sizeOf(String key, Bitmap bitmap) {
        return bitmap.getByteCount() / 1024;  // 以KB为单位
    }
};

// 加载图片时先查缓存
Bitmap bitmap = memoryCache.get(key);
if (bitmap == null) {
    // 从磁盘或资源加载
    bitmap = loadBitmapFromDisk(key);
    memoryCache.put(key, bitmap);
}

4.4.3 缓存更新的时机

嗯,这里要注意:缓存不是“存进去就不管了”。在车载系统中,UI主题切换、分辨率变化、甚至白天/黑夜模式切换,都可能需要刷新缓存。

  • 主题切换:清空内存缓存,重新加载
  • 内存紧张:收到onTrimMemory回调时,释放部分缓存
  • 应用切到后台:保留磁盘缓存,释放内存缓存
总结一下:纹理和内存优化,核心就是“少分配、多复用、按需加载”。ASTC/ETC2选对格式,图集减少切换,内存池避免抖动,Bitmap复用加缓存。这几招用好了,车载系统的图形性能至少提升30%。

下一章我们会聊渲染管线的优化,包括如何减少Overdraw、使用硬件层加速等。到时候见。