原来Span可以这样加载网络图(下)

3,633 阅读4分钟

前言

上一篇文章原来Span可以这样加载网络图(上)讲了Span加载网络图的方法。为了简化使用和拓展,接下来尝试封装一下。

先来看看之前的做法

val ss = SpannableStringBuilder("<img>To be or not to be, that is the question(生存还是毁灭,这是一个值得考虑的问题)")
val placeholderSpan = ImageSpan(context, R.mipmap.placeholder)
ss.setSpan(placeholderSpan, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
textView.setText(ss, TextView.BufferType.SPANNABLE) // 必需设置
val spannable = textView.text as? Spannable ?: return
Glide.with(textView)
    .load("https://sf6-ttcdn-tos.pstatp.com/img/user-avatar/d8111dfb52a63f3f12739194cf367754~100x100.png")
    .into(object : CustomTarget<Drawable>() {
        override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
            val start = spannable.getSpanStart(placeholderSpan)
            val end = spannable.getSpanEnd(placeholderSpan)
            if (start != -1 && end != -1) {// 替换Span 
                resource.setBounds(0, 0, resource.intrinsicWidth, resource.intrinsicHeight)
                spannable.removeSpan(placeholderSpan)
                spannable.setSpan(ImageSpan(resource), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
            }
        }

        override fun onLoadCleared(placeholder: Drawable?) {}
    })

先是设置占位ImageSpan,然后调用了加载图片的方法,图片加载后用新的ImageSpan替换占位ImageSpan。

对于使用者来说,这太过于繁琐了。最好能做到只创建一个Span,设置几个参数就能自己加载图片。使用起来大概是这样

val ss = SpannableStringBuilder("<img>To be or not to be, that is the question(生存还是毁灭,这是一个值得考虑的问题)")
val urlImageSpan = new ImageSpanBuilder()
    ...
    .build()         
ss.setSpan(urlImageSpan, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
textView.setText(ss, TextView.BufferType.SPANNABLE) // 必需设置

设置占位ImageSpan,这一步是必不可少的。那么如何将加载图片还有替换占位ImageSpan的步骤"藏"起来?这就需要看下ImageSpan的源码了。

ImageSpan继承自DynamicDrawableSpan,实现了getDrawable()方法。

// ImageSpan,API-30
    public Drawable getDrawable() {
        Drawable drawable = null;

        if (mDrawable != null) {
            drawable = mDrawable;
        } else if (mContentUri != null) {
            Bitmap bitmap = null;
            try {
                InputStream is = mContext.getContentResolver().openInputStream(
                        mContentUri);
                bitmap = BitmapFactory.decodeStream(is);
                drawable = new BitmapDrawable(mContext.getResources(), bitmap);
                drawable.setBounds(0, 0, drawable.getIntrinsicWidth(),
                        drawable.getIntrinsicHeight());
                is.close();
            } catch (Exception e) {
                Log.e("ImageSpan", "Failed to loaded content " + mContentUri, e);
            }
        } else {
            try {
                drawable = mContext.getDrawable(mResourceId);
                drawable.setBounds(0, 0, drawable.getIntrinsicWidth(),
                        drawable.getIntrinsicHeight());
            } catch (Exception e) {
                Log.e("ImageSpan", "Unable to find resource: " + mResourceId);
            }
        }

        return drawable;
    }

看了源码后我们知道,该方法返回了需要展示的Drawable。那么我们可以仿照ImageSpan,自定义一个DynamicDrawableSpan,在getDrawable()方法返回指定的占位图,并且在这个方法内调用加载图片的逻辑。这样就可以简化使用了。

封装

理论上没什么问题了,开始实践。先来明确下需求

  • 可替换图片加载框架
  • 可设置占位图、错误图、图像大小等
  • 使用简单

首先我们定义一个请求类,用来记录占位图、错误图、图像大小,占位Span等信息。

class URLImageSpanRequest(
    textView: TextView,
    val url: String?,
    val placeholderDrawable: Drawable?,
    val errorPlaceholder: Drawable?,
    val desiredWidth: Int,
    val desiredHeight: Int,
    val verticalAlignment: Int
) {
    private val viewRef = WeakReference(textView)
    val view get():TextView? = viewRef.get()
    var span: Any? = null
}

这里对textView使用弱引用,避免内存泄漏。然后定义一个图片加载接口,用来处理这个请求。

interface DrawableProvider {
    fun get(request: URLImageSpanRequest): Drawable
}

这里我用了Glide来加载图片。

class GlideDrawableProvider : DrawableProvider {
    override fun get(request: URLImageSpanRequest): Drawable {
        val drawable = if (request.url.isNullOrEmpty()) {
            request.placeholderDrawable ?: request.errorPlaceholder
        } else {
            execute(request)
            request.placeholderDrawable
        }
        return drawable ?: ColorDrawable()/*Can't be null*/
    }

    fun execute(request: URLImageSpanRequest) {
        val view = request.view ?: return
        val span = request.span
        Glide.with(view)
            .load(request.url)
            .error(request.errorPlaceholder)
            .override(request.desiredWidth, request.desiredHeight)
            .into(object : CustomTarget<Drawable>() {
                override fun onResourceReady(
                    resource: Drawable,
                    transition: Transition<in Drawable>?
                ) {
                    resource.setBounds(0, 0, resource.intrinsicWidth, resource.intrinsicHeight)
                    onResponse(request, resource)
                }

                override fun onLoadFailed(errorDrawable: Drawable?) {
                    if (errorDrawable != null) {
                        onResponse(request, errorDrawable)
                    }
                }

                override fun onLoadCleared(placeholder: Drawable?) {
                }

                private fun onResponse(request: URLImageSpanRequest, drawable: Drawable) {
                    val spannable = request.view?.text as? Spannable ?: return
                    spannable.replaceSpan(
                        span,
                        ImageSpan(drawable, request.verticalAlignment),
                        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
                    )
                }
            })
    }

    fun Spannable.replaceSpan(oldSpan: Any?, newSpan: Any?, flags: Int): Boolean {
        if (oldSpan == null || newSpan == null) {
            return false
        }
        val start = getSpanStart(oldSpan)
        val end = getSpanEnd(oldSpan)
        if (start == -1 || end == -1) {
            return false
        }
        removeSpan(oldSpan)
        setSpan(newSpan, start, end, flags)
        return true
    }
}

如果url是空的话就没必要开启图片加载了。get()方法的返回值是占位图。图片加载后替换占位Span。

最后用Builder模式将上面的内容组合一下

class URLImageSpan {
    open class Builder(private val provider: DrawableProvider = GlideDrawableProvider()) {
        private var url: String? = null
        private var placeholderDrawable: Drawable? = null
        private var placeholderId = 0
        private var useInstinctPlaceholderSize = true
        private var errorPlaceholder: Drawable? = null
        private var errorId = 0
        private var useInstinctErrorPlaceholderSize = true
        private var verticalAlignment = DynamicDrawableSpan.ALIGN_BOTTOM
        private var desiredWidth = -1
        private var desiredHeight = -1

        fun override(width: Int, height: Int): Builder {
            this.desiredWidth = width
            this.desiredHeight = height
            return this
        }

        fun url(url: String?): Builder {
            this.url = url
            return this
        }

        fun placeholder(drawable: Drawable?): Builder {
            this.placeholderDrawable = drawable
            this.placeholderId = 0
            this.useInstinctPlaceholderSize = true
            return this
        }

        fun error(drawable: Drawable?): Builder {
            this.errorPlaceholder = drawable
            this.errorId = 0
            this.useInstinctErrorPlaceholderSize = true
            return this
        }
	...
        
        fun buildRequest(textView: TextView): URLImageSpanRequest {
            val context = textView.context
            return URLImageSpanRequest(
                textView = textView,
                url = url,
                placeholderDrawable = getPlaceholderDrawable(context),
                errorPlaceholder = getErrorDrawable(context),
                verticalAlignment = verticalAlignment,
                desiredWidth = desiredWidth,
                desiredHeight = desiredHeight
            )
        }

        fun build(textView: TextView): DynamicDrawableSpan {
            val request = buildRequest(textView)
            return object : DynamicDrawableSpan() {
                override fun getDrawable(): Drawable {
                    request.span = this
                    return provider.get(request)
                }
            }
        }
    }
}

注意记得将占位span赋值。见上面的build()方法

封装后的使用方式

val ss =
    SpannableString("<img>To be or not to be, that is the question(生存还是毁灭,这是一个值得考虑的问题)")
val urlImageSpan = URLImageSpan.Builder()
    .url("https://sf6-ttcdn-tos.pstatp.com/img/user-avatar/d8111dfb52a63f3f12739194cf367754~500x500.png")
    .override(100.dp, 100.dp)
    .build(textView)
ss.setSpan(urlImageSpan, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
textView.setText(ss, TextView.BufferType.SPANNABLE) // 必需设置

与之前的用法对比,明显简单了不少

val ss = SpannableStringBuilder("<img>To be or not to be, that is the question(生存还是毁灭,这是一个值得考虑的问题)")
val placeholderSpan = ImageSpan(context, R.mipmap.placeholder)
ss.setSpan(placeholderSpan, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
textView.setText(ss, TextView.BufferType.SPANNABLE) // 必需设置
val spannable = textView.text as? Spannable ?: return
Glide.with(textView)
    .load("https://sf6-ttcdn-tos.pstatp.com/img/user-avatar/d8111dfb52a63f3f12739194cf367754~100x100.png")
    .into(object : CustomTarget<Drawable>() {
        override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
            val start = spannable.getSpanStart(placeholderSpan)
            val end = spannable.getSpanEnd(placeholderSpan)
            if (start != -1 && end != -1) {// 替换Span 
                resource.setBounds(0, 0, resource.intrinsicWidth, resource.intrinsicHeight)
                spannable.removeSpan(placeholderSpan)
                spannable.setSpan(ImageSpan(resource), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
            }
        }

        override fun onLoadCleared(placeholder: Drawable?) {}
    })

总结

Span加载网络图的原理很简单,但是使用起来并不简单。在原来做法的基础上抽象封装了一下,简化使用,方便以后的拓展和修改。

最后附上Github链接