Android必知必会——Drawable
Drawable概览
如果需要在应用内显示静态图片,可以使用 Drawable 类及其子类绘制形状和图片。Drawable 是可绘制对象的常规抽象。不同的子类可用于特定的图片场景,可以对其进行扩展以定义行为方式独特的可绘制对象。
Drawable的定义和实例化
可以通过如下三种方式定义和实例化Drawable:
-
构造函数
使用现有的Drawable子类,如ShapeDrawable,用来绘制基本的物理图形;ColorDrawable,用来绘制特定的颜色;BitmapDrawable,用来绘制特定的位图等。
当然还可以直接继承Drawable,自定义绘制行为:
//此示例是一个用来绘制区域最大圆形的Drawable class MyDrawable : Drawable() { private val redPaint: Paint = Paint().apply { setARGB(255, 255, 0, 0) } override fun draw(canvas: Canvas) { // 获取可绘制区域的宽高,得到可绘制最大圆的半径 val width: Int = bounds.width() val height: Int = bounds.height() val radius: Float = Math.min(width, height).toFloat() / 2f // 由中心画一个圆 canvas.drawCircle((width / 2).toFloat(), (height / 2).toFloat(), radius, redPaint) } override fun setAlpha(alpha: Int) { // 必须重写的方法,处理透明度 } override fun setColorFilter(colorFilter: ColorFilter?) { // 必须重写的方法,处理颜色过滤器 } override fun getOpacity(): Int = // 必须重写的方法,返回此Drawable的不透明度/透明度 //返回值必须是如下几个值: //PixelFormat.UNKNOWN //PixelFormat.TRANSLUCENT 只有绘制的地方才覆盖底下的内容 //PixelFormat.TRANSPARENT 透明,完全不显示任何东西 //PixelFormat.OPAQUE 完全不透明,遮盖在它下面的所有内容 PixelFormat.OPAQUE }
-
通过资源图片创建可绘制对象
最直接的方式,就在资源目录下存放特定类型的图片文件如PNG、JPG、GIF等。
值得注意的一点是,res/drawable/目录下的图片资源可由aapt工具在构建过程中自动完成无损图片压缩优化。但是在res/raw/文件夹下的图片,appt不会对其进行修改。
在通过Resources获取图片资源文件得到Drawable对象时,如果同一个资源实例化了多个Drawable对象,并更改其中一个对象的属性(如透明度),则其他对象也会受到影响。
val myImage1: Drawable = ResourcesCompat.getDrawable(context.resources, R.drawable.my_image, null)
val myImage2: Drawable = ResourcesCompat.getDrawable(context.resources, R.drawable.my_image, null)
myImage1.setAlpha(1)//myImage2也会受到影响
-
通过XML资源创建可绘制对象
例如TransitionDrawable:
<transition xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/image_expand">
<item android:drawable="@drawable/image_collapse">
</transition>
VectorDrawable
VectorDrawable 是一种矢量图形,在 XML 文件中定义为一组点、线条和曲线及其相关颜色信息。使用矢量可绘制对象的主要优势在于图片可缩放。可以在不降低显示质量的情况下缩放图片,也就是说,可以针对不同的屏幕密度调整同一文件的大小,而不会降低图片质量。这不仅能缩减 APK 文件大小,还能减少开发者维护工作。还可以对动画使用矢量图片,具体方法是针对各种显示屏分辨率使用多个 XML 文件,而不是多张图片。
VectorDrawable 定义静态可绘制对象。与 SVG 格式类似,每个矢量图形定义为树状层次结构,由 path 和 group 对象构成。每个 path 都包含对象轮廓的几何图形,而 group 包含转换的详细信息。所有路径都是按照其在 XML 文件中显示的顺序绘制的。
借助 Vector Asset Studio 工具,可轻松地将矢量图形作为 XML 文件添加到项目中。
对于矢量图形,还有另外一个类AnimatedVectorDrawable,它 可以为矢量图形的属性添加动画。
Android5.0开始支持使用矢量图形,如果要在更低版本使用,那么可以通过VectorDrawableCompat 和 AnimatedVectorDrawableCompat来进行兼容。在控件中,如ImageView,可以使用srcCompat属性,来引用矢量图形。
Bitmap
Bitmap是一种独立于显示器的位图数字图像文件格式。BMP文件通常是不压缩的,所以它们通常比同一幅图像的压缩图像文件格式要大很多。
Bitmap存储的核心,在于图像的信息。即宽度上有多少像素点,高度上有多少像素点,然后每个像素点的具体信息。
而针对每个像素点,通常保存的颜色深度有2(1位)、16(4位)、256(8位)、65536(16位)和1670万(24位)种颜色。
那么当Bitmap中的像素越多,每个点可表达的颜色越多,那么这个图片就越清晰、颜色越丰富。
Android中实例化Bitmap时可选择的质量类型:
- ALPHA_8 (8 / 8 = 1)字节/像素
只保存透明度信息,没有颜色信息
- RGB_565 (红绿蓝 5+6+5=16 16 / 8 = 2)字节/像素
保存红绿蓝信息,没有透明度
- ARGB_4444 (透明度、红绿蓝 4+4+4+4=16 16 / 8 = 2)字节/像素
透明度和红绿蓝都有,但是能使用的色彩数少
- ARGB_8888 (透明度、红绿蓝 8+8+8+8=32 32 / 8 = 4)字节/像素
透明度和红绿蓝都有,但是能使用的色彩数较多
- RGBA_F16 (透明度、红绿蓝 16+16+16+16=48 48 / 8 = 6)字节/像素
透明度和红绿蓝都有,但是能使用的色彩数多
Bitmap使用内存的计算
计算公式(针对内存中的Bitmap):
使用内存 = 横向像素数 * 竖向像素数 * 每个像素字节数
例如,对于像素数为1024 * 1024、质量为ARGB_8888的Bitmap来说,要将其加载到内存中,需要的内存为:
1024 * 1024 * 4 = 4MB
在Android应用中加载Bitmap比较复杂,原因有多种:
-
位图很容易就会耗尽应用的内存预算。
-
在界面线程中加载位图会降低应用的性能,导致响应速度变慢,甚至会导致系统显示 ANR 消息。因此,在使用位图时,必须正确地管理线程处理。
-
如果应用将多个位图加载到内存中,需要娴熟地管理内存和磁盘缓存。否则,应用界面的响应速度和流畅性可能会受到影响。
高效加载大图
既然Bitmap是实打实的图片数据,占用内存巨大,那么在某些场景必须加载大图时(如加载相册中的高清大图),应该如何处理呢?
可以按照如下几个步骤,高效的加载大图:
-
inJustDecodeBounds
通过BitmapFactory加载Bitmap时,传入Config参数,并将其inJustDecodeBounds设置为true,那么在加载过程中,BitmapFactory不会为其自动申请内存,而是进读取位图的尺寸和类型。
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeResource(resources, R.id.myimage, options)
val imageHeight: Int = options.outHeight
val imageWidth: Int = options.outWidth
val imageType: String = options.outMimeType
-
inSampleSize
通过inJustDecodeBounds,可以获知位图的尺寸,这之后就可以在其他几个维度确定是否要降低图片的采样率:
1.在内存中加载完整图片的估计内存使用量。
2.根据应用的任何其他内存要求,可分配用于加载此图片的内存量。
3.图片要载入到的目标 ImageView 或界面组件的尺寸。 例如,如果 1024x768 像素的图片最终会在 ImageView 中显示为 128x96 像素缩略图,则不值得将其加载到内存中。
4.当前设备的屏幕大小和密度。
例如,分辨率为 2048*1536 且以 4 作为 inSampleSize 进行解码的图片会生成大约 (2048/4)512 *(1536/4)384 的位图。将此图片加载到内存中需使用 0.75MB,而不是完整图片所需的 12MB(假设位图配置为 ARGB_8888)。
inSampleSize的数值,应为2的幂。即1、2、4、8...
那么,接下来要做的就是根据位图实际尺寸和实际需要尺寸,来计算实际的采用率:
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
// 图像的原始高度和宽度
val (height: Int, width: Int) = options.run { outHeight to outWidth }
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight: Int = height / 2
val halfWidth: Int = width / 2
// 计算最大的inSampleSize值,该值为2的幂,并且使height和width都大于请求的height和width。
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
综上,加载大图时,整体流程是这样的:
fun decodeSampledBitmapFromResource(
res: Resources,
resId: Int,
reqWidth: Int,
reqHeight: Int
): Bitmap {
// 先获取图片尺寸信息
return BitmapFactory.Options().run {
inJustDecodeBounds = true
BitmapFactory.decodeResource(res, resId, this)
// 计算 inSampleSize
inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
// 根据计算的采样率,最终加载实际的位图
inJustDecodeBounds = false
BitmapFactory.decodeResource(res, resId, this)
}
}
缓存位图
将单个位图加载到界面中非常简单,但如果需要同时加载较多的图片,情况就会变得复杂。在很多情况下(比如ListView、GridView或ViewPager等),屏幕上的图片与可能很快会滚动到屏幕上的图片加起来,数量是无限的。
对于这类组件,系统会通过循环利用移出屏幕的子视图来限制其对内存的占用。垃圾回收器也会释放已加载的位图,但当用户又滑回之前被回收的条目时,可以通过内存和磁盘缓存,让组件可以快速重新加载经过处理的图片。
- 内存缓存——LruCache
配置LruCahce初始化相关参数
private lateinit var memoryCache: LruCache<String, Bitmap>
override fun onCreate(savedInstanceState: Bundle?) {
...
// 获取最大可用VM内存(KB单位),超过此数量将抛出OutOfMemory异常。
val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
// 将可用内存的1/8用作此内存缓存。
val cacheSize = maxMemory / 8
memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
override fun sizeOf(key: String, bitmap: Bitmap): Int {
// 缓存大小单位是KB
return bitmap.byteCount / 1024
}
}
...
}
使用内存缓存:
fun loadBitmap(resId: Int, imageView: ImageView) {
val imageKey: String = resId.toString()
//内存缓存中有,那么直接用
val bitmap: Bitmap? = getBitmapFromMemCache(imageKey)?.also {
mImageView.setImageBitmap(it)
} ?: run {
//内存缓存中没有,那么异步加载
mImageView.setImageResource(R.drawable.image_placeholder)
val task = BitmapWorkerTask()
task.execute(resId)
null
}
}
异步加载图片时,需要及时的将位图存入内存缓存:
private inner class BitmapWorkerTask : AsyncTask<Int, Unit, Bitmap>() {
...
// 异步加载位图,加载成功后,及时将位图放入内存缓存
override fun doInBackground(vararg params: Int?): Bitmap? {
return params[0]?.let { imageId ->
decodeSampledBitmapFromResource(resources, imageId, 100, 100)?.also { bitmap ->
addBitmapToMemoryCache(imageId.toString(), bitmap)
}
}
}
...
}
- 磁盘缓存
内存缓存有助于加快对最近查看过的位图的访问,但不能依赖于此缓存中保留的图片。GridView 这样拥有较大数据集的组件很容易将内存缓存填满。应用可能被其他任务(如电话)中断,而在后台时,应用可能会被终止,而内存缓存则会销毁。
在这些情况下,可以使用磁盘缓存来保存经过处理的位图,并在图片已不在内存缓存中时帮助减少加载时间。当然,从磁盘获取图片比从内存中加载缓慢,而且应该在后台线程中完成,因为磁盘读取时间不可预测。
完整的图片存取方式:
private const val DISK_CACHE_SIZE = 1024 * 1024 * 10 // 10MB的磁盘缓存空间
private const val DISK_CACHE_SUBDIR = "thumbnails"
...
private var diskLruCache: DiskLruCache? = null
private val diskCacheLock = ReentrantLock()
//即使是初始化磁盘缓存也需要执行磁盘操作,因此不应在主线程上执行。
//不过,这也意味着可能会在初始化之前访问该缓存。
//为了解决此问题,利用了一个 lock 对象来确保应用在磁盘缓存初始化之前不会从该缓存中读取数据。
private val diskCacheLockCondition: Condition = diskCacheLock.newCondition()
private var diskCacheStarting = true
override fun onCreate(savedInstanceState: Bundle?) {
...
// 初始化内存缓存
...
// 异步初始化磁盘缓存
val cacheDir = getDiskCacheDir(this, DISK_CACHE_SUBDIR)
InitDiskCacheTask().execute(cacheDir)
...
}
internal inner class InitDiskCacheTask : AsyncTask<File, Void, Void>() {
override fun doInBackground(vararg params: File): Void? {
diskCacheLock.withLock {
val cacheDir = params[0]
diskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE)
diskCacheStarting = false // 完成初始化
diskCacheLockCondition.signalAll() // 唤醒等待的线程
}
return null
}
}
internal inner class BitmapWorkerTask : AsyncTask<Int, Unit, Bitmap>() {
...
// 异步解码图像
override fun doInBackground(vararg params: Int?): Bitmap? {
val imageKey = params[0].toString()
// 异步检查硬盘缓存
return getBitmapFromDiskCache(imageKey) ?:
// 硬盘缓存中未找到
decodeSampledBitmapFromResource(resources, params[0], 100, 100)
?.also {
// 将最终位图添加到缓存
addBitmapToCache(imageKey, it)
}
}
}
fun addBitmapToCache(key: String, bitmap: Bitmap) {
// 校验内存缓存中是否有可用缓存,没有则放入内存缓存
if (getBitmapFromMemCache(key) == null) {
memoryCache.put(key, bitmap)
}
// 同样放入磁盘缓存
synchronized(diskCacheLock) {
diskLruCache?.apply {
if (!containsKey(key)) {
put(key, bitmap)
}
}
}
}
fun getBitmapFromDiskCache(key: String): Bitmap? =
diskCacheLock.withLock {
while (diskCacheStarting) {
try {
diskCacheLockCondition.await()
} catch (e: InterruptedException) {
}
}
return diskLruCache?.get(key)
}
// 创建指定的应用程序缓存目录的唯一子目录。尝试在外部使用,但如果未安装,则退回到内部存储。
fun getDiskCacheDir(context: Context, uniqueName: String): File {
// 检查是否安装了介质或内置了存储,如果是,尝试使用外部缓存目录,否则使用内部缓存目录
val cachePath =
if (Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()
|| !isExternalStorageRemovable()) {
context.externalCacheDir.path
} else {
context.cacheDir.path
}
return File(cachePath + File.separator + uniqueName)
}
管理Bitmap内存
对于不同的Android版本,位图内存管理发生了如下的变更:
-
在 Android Android 2.2(API 级别 8)及更低版本上,当发生垃圾回收时,应用的线程会停止。这会导致延迟,从而降低性能。Android 2.3 添加了并发垃圾回收功能,这意味着系统不再引用位图后,很快就会回收内存。
-
在 Android 2.3.3(API 级别 10)及更低版本上,位图的像素数据存储在native内存中。它与存储在 Dalvik 堆中的位图本身是分开的。native内存中的像素数据并不以可预测的方式释放,可能会导致应用短暂超出其内存限制并崩溃。
-
从 Android 3.0(API 级别 11)到 Android 7.1(API 级别 25),像素数据会与关联的位图一起存储在 Dalvik 堆上。
-
在 Android 8.0(API 级别 26)及更高版本中,位图像素数据存储在原生堆中。
因此在不同的Android版本中,应采用不同的管理方案:
-
在 Android 2.3.3(API 级别 10)及更低版本上,建议使用 recycle(),可以尽快回收内存。
-
Android 3.0(API 级别 11)引入了 BitmapFactory.Options.inBitmap 字段。如果设置了此选项,那么采用 Options 对象的解码方法会在加载内容时尝试重复使用现有位图。这意味着位图的内存得到了重复使用,从而提高了性能,同时移除了内存分配和取消分配。不过,inBitmap 的使用方式存在限制,要求需要重用的位图是可变的。特别是在 Android 4.4(API 级别19)之前,系统仅支持大小相同(像素数相同且采样率为1)的位图。
关于BitmapFactory.Options
这个参数的作用非常大,它可以设置Bitmap的采样率,通过改变图片的宽度、高度、缩放比例等,以达到减少图片的像素的目的。总的来说,通过设置这个值,可以更好地控制、显示,使用位图。
以下是其个属性及其含义:
属性 | 类型 | 含义 |
---|---|---|
inJustDecodeBounds | boolean | 是否只解析图片信息 |
inSampleSize | int | 采样率(每隔多少个样本采样一次作为结果,比如4,代表没4个像素取1个作为结果返回,宽高都变为原来的1/4,总体为原来的1/16) |
inScaled | boolean | 在需要缩放时,是否对当前文件进行缩放。false则不进行缩放;true或不设置,则会根据文件夹分辨率和屏幕分辨率动态缩放 |
inDensity | int | 设置文件所在资源文件夹的屏幕分辨率 |
inTargetDensity | int | 表示真实显示的屏幕分辨率,缩放比 = inTargetDensity/inDensity |
inScreenDensity | int | 正在使用的实际屏幕的像素密度,目前没什么用 |
inPreferredConfig | enum | 设置像素的存储格式。RGB_565,ARGB_8888等 |
inMutable | boolean | 如果设置true,则解码方法将始终返回可变(可以修改像素信息)的位图,而不是不变(不可修改)的位图。 |
inBitmap | Bitmap | 重用此Bitmap,需要此Bitmap是可修改 |
outConfig | Config | 如果知道,解码位图将具有的配置 |
outHeight | int | 位图的最终高度 |
outWidth | int | 位图的最终宽度 |
inDither | boolean | 是否抖动,如果设置true,解码器将尝试抖动解码图像。例如图片原本是100px200px,而实际需要150px300px,设置此参数后,会将原来的100像素平铺,多出来的空白利用相邻两个颜色生成“中间色”来过渡。 |
关于Bitmap的可变和不可变
对于可变的Bitmap来说,通过setPixel(int x,int y,int color)等函数可以设置其中的像素值,而不可变的Bitmap使用这些方法就会报错。
那么什么情况下生成的Bitmap可变,什么时候不可变呢?答案就是:通过BitmapFactory加载的Bitmap都是不可变的;只有Bitmap中的几个函数创建的Bitmap才是像素可变的。这几个函数是:
1.copy(Config config, boolean isMutable)//isMutable传入true
2.createBitmap(@Nullable DisplayMetrics display, int width, int height,
@NonNull Config config, boolean hasAlpha, @NonNull ColorSpace colorSpace)
3.createScaledBitmap(@NonNull Bitmap src, int dstWidth, int dstHeight,
boolean filter)