自己实现一个Android网络图片加载器

2,229 阅读8分钟

在Android开发中,我们经常会用到各种各样的图片加载框架来帮助我们加载网络图片,那有没有想过自己实现一个呢

本文记录了实现一个图片框架的整个流程,以及对代码的优化整理过程,文章比较长,如果只对其中的一部分感兴趣直接跳转到相应部分即可

提问环节

把大象装进冰箱分几步?是不是感觉在这初秋时节更加凉爽了呢

好吧,下面开始提问

  • 大多数图片都是网络获取的,如何加载网络图片到本地imageview
  • 图片有可能很大,怎么在加载前进行压缩
  • 每次加载同一个url的图片都要请求网络,可以做一个缓存来防止过度请求吗
  • 头像想显示圆角,怎么通过加载器显示圆角图片
  • 在图片没加载出来之前,会显示placeholder图,加载完成之后切换会闪烁一下,很不美观,怎么处理
  • 写好了代码,但是用起来很麻烦,想用设计模式优化一下,让它用起来像glide一样,减少学习成本,该怎么做

如果只想看其中某一部分,可以直接跳转到相应段落,完整的代码放到了我github上,点击这里可以直达

那么,女士们先生们,下面就开始我们的旅程吧

如何加载网络图片到本地imageview

这个功能很简单,怎么实现我不管,明天上线

这个问题确实很简单,开启一个线程,从网络请求中获取图片流,再组装成bitmap,加载到imageview里面去就行了,看代码

  private fun getNetImg(imageView: ImageView): Bitmap? {
        var bitmap: Bitmap? = null
        val url = URL(params.imageURL)
        val connection = url.openConnection() as HttpURLConnection
        connection.requestMethod = "GET"
        connection.connectTimeout = 10000
        val code = connection.responseCode
        if (code == 200) {
            val inputStream = connection.inputStream
            bitmap = bitmapCompressor.getCompressBitmap(inputStream, imageView)//获取到亚索(压缩,谐音梗扣钱)图片
            inputStream.close()
        } else {
            Log.e("NetImageView", "server error")
        }
        return bitmap//获取到bitmap了,返回,直接扔给imageview用就行
    }

怎么在加载前进行压缩

网络图片有时候会很大,我们的ImageView就那么小一点,图片很大浪费了我们宝贵的内存资源,怎么办呢?我们勤劳的劳动人民有很多办法,没错,就是图片压缩,在加载前我们先获取到图片的宽高以及ImageView的宽高,根据比例来压缩图片,再加载,就没问题啦。

关键利用了

  • val options = BitmapFactory.Options()这个Options里有个方法,叫inSampleSize,设置比例

  • 还有一个参数,叫inJustDecodeBounds,只返回图片尺寸

这俩配合,就能让我们压缩图片了

关键代码:

	val options = BitmapFactory.Options()
  	options.inJustDecodeBounds = true
        BitmapFactory.decodeStream(sizeInputStream, null, options)
        options.inSampleSize = getInSampleSize(options, imageView)//getInSampleSize就是算比理的函数
        options.inJustDecodeBounds = false
        
        -----getInSampleSize的代码在下面-----
        
     private fun getInSampleSize(options: BitmapFactory.Options, imageView: ImageView): Int {
        var inSampleSize = 1
        val (viewWidth, viewHeight) = getImageViewSize(imageView)//这个函数是什么?别急,它的代码就在下面,算ImageView尺寸的

        val outWidth = options.outWidth
        val outHeight = options.outHeight

        if (outWidth > viewWidth || outHeight > viewHeight) {
            val widthRadio = (outWidth / viewWidth).toDouble().roundToInt()
            val heightRadio = (outHeight / viewHeight).toDouble().roundToInt()

            inSampleSize = if (widthRadio > heightRadio) widthRadio else heightRadio
        }

        return inSampleSize
    }

	//计算ImageView尺寸的函数
   private fun getImageViewSize(imageView: ImageView): Pair<Int, Int> {
        val displayMetrics = context.resources?.displayMetrics
        val layoutParams = imageView.layoutParams
        var viewWidth = imageView.width
        var viewHeight = imageView.height
        if (viewWidth <= 0) {
            viewWidth = layoutParams.width
        }
        if (viewWidth <= 0) {
            viewWidth = imageView.maxWidth
        }
        if (viewWidth <= 0) {
            viewWidth = displayMetrics?.widthPixels!!
        }
        if (viewHeight <= 0) {
            viewHeight = layoutParams.height
        }
        if (viewHeight <= 0) {
            viewHeight = imageView.maxHeight
        }
        if (viewHeight <= 0) {
            viewHeight = displayMetrics?.heightPixels!!
        }
        return Pair(viewWidth, viewHeight)//返回一个pair,可以同时返回算好的宽和高
    }

看完了关键代码,是不是很简单呢,我们来看看完整的代码

//图片压缩类
class BitmapCompressor(private val context:Context) {
//计算imageview尺寸
    private fun getImageViewSize(imageView: ImageView): Pair<Int, Int> {
        val displayMetrics = context.resources?.displayMetrics
        val layoutParams = imageView.layoutParams
        var viewWidth = imageView.width
        var viewHeight = imageView.height
        if (viewWidth <= 0) {
            viewWidth = layoutParams.width
        }
        if (viewWidth <= 0) {
            viewWidth = imageView.maxWidth
        }
        if (viewWidth <= 0) {
            viewWidth = displayMetrics?.widthPixels!!
        }
        if (viewHeight <= 0) {
            viewHeight = layoutParams.height
        }
        if (viewHeight <= 0) {
            viewHeight = imageView.maxHeight
        }
        if (viewHeight <= 0) {
            viewHeight = displayMetrics?.heightPixels!!
        }
        return Pair(viewWidth, viewHeight)
    }
//计算压缩比例
    private fun getInSampleSize(options: BitmapFactory.Options, imageView: ImageView): Int {
        var inSampleSize = 1
        val (viewWidth, viewHeight) = getImageViewSize(imageView)

        val outWidth = options.outWidth
        val outHeight = options.outHeight

        if (outWidth > viewWidth || outHeight > viewHeight) {
            val widthRadio = (outWidth / viewWidth).toDouble().roundToInt()
            val heightRadio = (outHeight / viewHeight).toDouble().roundToInt()

            inSampleSize = if (widthRadio > heightRadio) widthRadio else heightRadio
        }

        return inSampleSize
    }

//获取压缩后的bitmap
    fun getCompressBitmap(input: InputStream, imageView: ImageView): Bitmap? {
        val stream = ByteArrayOutputStream()
        val bufferSize = 1024
        try {
            val buffer = ByteArray(bufferSize)
            var len: Int
            while (input.read(buffer).also { len = it } > -1) {
                stream.write(buffer, 0, len)
            }
            stream.flush()
        } catch (e: IOException) {
            e.printStackTrace()
        }

        val sizeInputStream: InputStream = ByteArrayInputStream(stream.toByteArray())
        val bitmapInputStream: InputStream = ByteArrayInputStream(stream.toByteArray())

        val options = BitmapFactory.Options()
        options.inJustDecodeBounds = true
        BitmapFactory.decodeStream(sizeInputStream, null, options)

        options.inSampleSize = getInSampleSize(options, imageView)
        options.inJustDecodeBounds = false
        return BitmapFactory.decodeStream(bitmapInputStream, null, options)

    }
}

这个功能稍微复杂一点点,不过也挺简单,相信大家都表示问题不大

可以做一个缓存来防止过度请求吗

当然可以,这个功能也非常简单,我们用文件来进行缓存,怎么确保缓存唯一呢——使用图片url来当文件名即可

这个功能太简单了,直接看代码吧

//先来一个接口,为了以后可能的更多缓存形式做准备
interface ImageCache{
    fun cacheImg(bitmap: Bitmap?,name:String);
    fun getCacheImage(name:String): Bitmap?
}

//文件缓存类
class FileImageCache(private val context: Context): ImageCache {
//储存缓存文件
   override fun cacheImg(bitmap: Bitmap?,name:String) {
       if (bitmap != null)
        try {
            val file = File(context.cacheDir, name)
            val out = FileOutputStream(file)
            bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
            out.flush()
            out.close()
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }
    //根据名字获取文件
    override fun getCacheImage(name:String): Bitmap? {
        val file = File(context.cacheDir, name)
        var bitmap: Bitmap? = null
        if (file.length() > 0) {
            val inputStream: InputStream = FileInputStream(file)
             bitmap = BitmapFactory.decodeStream(inputStream)
        }
        return bitmap
    }

}

这个就不用多解释了吧,只是一个文件存取而已

怎么通过加载器显示圆角图片

现在我们的图片加载器以及初具雏形了,但是好多头像都有圆角,我们怎么给这个加载器添加这个功能呢?

这个稍微复杂一点,需要用到canvas的绘制功能,大体思路是这样的

  • 绘制一个圆角的rect
  • 设置xfermode为PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
  •   绘制bitmap
    

上代码

object BitmapRounder{
     fun getRoundedCornerBitmap(bitmap: Bitmap, round: Float): Bitmap? {
        return try {
            val output = Bitmap.createBitmap(
                bitmap.width,
                bitmap.height, Bitmap.Config.ARGB_8888
            )
            val canvas = Canvas(output)
            val paint = Paint()
            val rect = Rect(
                0, 0, bitmap.width,
                bitmap.height
            )
            val rectF = RectF(
                Rect(
                    0, 0, bitmap.width,
                    bitmap.height
                )
            )
            paint.isAntiAlias = true
            canvas.drawARGB(0, 0, 0, 0)
            paint.color = Color.BLACK
            canvas.drawRoundRect(rectF, round, round, paint)//重点,绘制一个圆角的矩形
            paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)//也是重点,设置xfermode为src_in
            val src = Rect(
                0, 0, bitmap.width,
                bitmap.height
            )
            canvas.drawBitmap(bitmap, src, rect, paint)//绘制bitmap
            output
        } catch (e: Exception) {
            bitmap
        }
    }
}

圆角功能也就完成了

在图片没加载出来之前,会显示placeholder图,加载完成之后切换会闪烁一下,很不美观,怎么处理

一句话,用动画

怎么用动画,这里就要引入一个类,drawable,想必大家都不陌生,我们直接自定义一个drawable,在里面执行显示内容的切换动画即可

看代码


class MyAnimationDrawable(private val bitmap: Bitmap, private val placeholder: Bitmap) : Drawable(),Animatable {
    private var mValueAnimator = ValueAnimator()
    var placeholderAlpha = 250
    var paint:Paint
    var isFinish = false
    init {
        mValueAnimator = ObjectAnimator.ofInt(this,"placeholderAlpha",0)
        mValueAnimator.duration = 1200
        mValueAnimator.startDelay = 1000
        paint = Paint()
        mValueAnimator.addUpdateListener { // 监听属性动画并进行重绘
            invalidateSelf()
        }
    }
    override fun draw(canvas: Canvas){
        if (!isFinish) {
            val rectF = RectF(
                0f,
                0f,
                bounds.width().toFloat(),
                bounds.height().toFloat()
            ) //w和h分别是屏幕的宽和高,也就是你想让图片显示的宽和高
            paint.reset()
            canvas.drawBitmap(bitmap, null, rectF, paint)
            paint.alpha = placeholderAlpha
            canvas.drawBitmap(placeholder, null, rectF, paint)
            paint.reset()
        }
        if (placeholderAlpha == 0)isFinish = true
    }

    override fun setAlpha(p0: Int) {
    }

    @SuppressLint("WrongConstant")
    override fun getOpacity(): Int {
        return  1
    }

    override fun setColorFilter(p0: ColorFilter?) {
    }

    override fun isRunning(): Boolean {
        return mValueAnimator.isRunning
    }

    override fun start() {
        mValueAnimator.start()
    }

    override fun stop() {
    }

}

用这个类替代bitmap,即可实现从一个图片到另一个图片的动画效果切换,是不是很简单呢

写好了代码,但是用起来很麻烦,想用设计模式优化一下,让它用起来像glide一样,减少学习成本,该怎么做

核心,使用builder模式

关于这个问题,我们就要去看看glide是怎么做的了

经过对glide的分析,我们发现它是用了builder模式,我们也整一个,我们还发现它的builder不直接设置加载器,而是设置了一个param类,最后加载的时候再传入参数,所以我们也这么玩

下面是我们的builder代码

class RequestBuilder {
    data class ImageParams(
         var roundPx: Float = 0f,
         val emptyPlaceHolderId: Int = -1,
         var placeHolder: Int = emptyPlaceHolderId,
         var imageURL: String = "",
         var imageMaxSideSize: Float = -1f,
         var useCache: Boolean = true,
         var context: Context? = null)
     private var params = ImageParams()
    fun withContext(context: Context): RequestBuilder {
        params.context = context
        return this
    }

    fun useCache(useCache: Boolean): RequestBuilder {
        params.useCache = useCache
        return this
    }

    fun placeholder(placeholder: Int): RequestBuilder {
        params.placeHolder = placeholder
        return this
    }

    fun load(url: String): RequestBuilder {
        params.imageURL = url
        return this
    }
    fun round(round:Float): RequestBuilder {
        params.roundPx = round
        return this
    }
    fun into(imageView: ImageView) {
        val realLoader = RealImageLoader(params)
        realLoader.loadImage(imageView)
    }
    fun adjustImageScale(imageMaxSideSize:Float): RequestBuilder {
        params.imageMaxSideSize = imageMaxSideSize
        return this
    }

}

是不是so easy呢

我们来看看具体使用的时候长什么样

   ImageLoader.withContext(this)
            .placeholder(R.drawable.holder)
            .load("https://www.baidu.com/img/flexible/logo/pc/result.png")
            .useCache(true)
            .into(image)

是不是感觉和glide一模一样呢

最后

前面说了那么多,可能有人想说,你的加载图片代码呢,不急,这里就放出全部的代码

class RealImageLoader(private val params: RequestBuilder.ImageParams) {
    private var fileCache = FileImageCache(params.context!!)//缓存类
    private var bitmapCompressor = BitmapCompressor(params.context!!)//压缩类
//加载图片的方法
    fun loadImage(imageView: ImageView) {
        if (params.context == null) {
            Log.e("ImageLoader", "Empty context")
            return
        }//参数为空,直接返回
        var bitmap: Bitmap? = null
        loadPlaceHolderImg(imageView)//加载placeholder
        GlobalScope.launch(Dispatchers.IO) {//开启一个协程,用来处理图片
            try {
                if (params.useCache) {
                    bitmap = fileCache.getCacheImage(getCacheFileName())//从缓存查找
                }
                if (bitmap == null) {
                    bitmap = getNetImg(imageView)//缓存没有,从网络获取
                }
                if (bitmap != null)
                    bitmap = roundBitmap(bitmap)//根据设置,进行圆角处理
            } catch (e: IOException) {
                e.printStackTrace()
                Log.e("NetImageView", "Load image error")
            }
            withContext(Dispatchers.Main) {//切换到主线程,进行图片加载
                if (bitmap != null) {
                   imageView.setImageBitmap(bitmap)
/*                          val placeholder = BitmapFactory.decodeResource(
                              params.context?.resources,
                              params.placeHolder
                          )
                          val drawable = MyAnimationDrawable(bitmap!!, placeholder!!)
                          imageView.setImageDrawable(drawable)
                          drawable.start()*/
                }
            }
        }
    }

    private fun loadPlaceHolderImg(imageView: ImageView) {
        if (params.placeHolder != params.emptyPlaceHolderId) {
            var bitmap = BitmapFactory.decodeResource(params.context?.resources, params.placeHolder)
            bitmap = roundBitmap(bitmap)
            imageView.setImageBitmap(bitmap)
        }
    }

    private fun roundBitmap(bitmap: Bitmap?): Bitmap? {
        var roundBitmap = bitmap
        if (params.roundPx != 0f) {
            if (roundBitmap != null)
                roundBitmap = BitmapRounder.getRoundedCornerBitmap(roundBitmap, params.roundPx)
        }
        return roundBitmap
    }

    private fun getNetImg(imageView: ImageView): Bitmap? {
        var bitmap: Bitmap? = null
        val url = URL(params.imageURL)
        val connection = url.openConnection() as HttpURLConnection
        connection.requestMethod = "GET"
        connection.connectTimeout = 10000
        val code = connection.responseCode
        if (code == 200) {
            val inputStream = connection.inputStream
            bitmap = bitmapCompressor.getCompressBitmap(inputStream, imageView)
            if (bitmap != null)
                bitmap = changeScale(bitmap)
            if (params.useCache)
                fileCache.cacheImg(bitmap, getCacheFileName())
            inputStream.close()
        } else {
            Log.e("NetImageView", "server error")
        }
        return bitmap
    }

    private fun getCacheFileName(): String {
        var name = ""
        val strings = params.imageURL.split("/")
        for (s in strings) {
            name += s
        }
        return name
    }

    private fun changeScale(bitmap: Bitmap): Bitmap {
        var mBitmap = bitmap
        if (params.imageMaxSideSize > 0) {
            var height = bitmap.height
            var width = bitmap.width
            if (width >= height) {
                width = params.imageMaxSideSize.toInt()
                height =
                    (params.imageMaxSideSize * (bitmap.height.toFloat() / bitmap.width)).toInt()
            } else {
                height = params.imageMaxSideSize.toInt()
                width = (params.imageMaxSideSize * (bitmap.width.toFloat() / bitmap.height)).toInt()
            }
            mBitmap = zoomImg(bitmap, width, height)
        }
        return mBitmap
    }

    private fun zoomImg(bm: Bitmap, newWidth: Int, newHeight: Int): Bitmap {
        val width = bm.width
        val height = bm.height
        val scaleWidth = newWidth.toFloat() / width
        val scaleHeight = newHeight.toFloat() / height
        val matrix = Matrix()
        matrix.postScale(scaleWidth, scaleHeight)
        return Bitmap.createBitmap(bm, 0, 0, width, height, matrix, true)
    }

}

至此,我们的图片加载工具已经做好啦,大家可以根据自己的需求愉快的使用了,希望这篇文章能对你有帮助,我们下次再见