Android必知必会——Drawable

2,214 阅读14分钟

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)