好玩系列:听说你的ImageSpan没能动起来?

Posted leobert_lan

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了好玩系列:听说你的ImageSpan没能动起来?相关的知识,希望对你有一定的参考价值。

前言

前不久,我写过一篇文章:迟来的续集–Drawable+Animator,将优雅进行到底 , 并在其中留下一个思考题:"
用 动画Drawable 是否可以让 ImageSpan 直接动起来"

相信大家也进行了尝试,并且不出意外地出现了意外!即便使用可以动起来的Drawable构建ImageSpan,也没有让他动起来!

今天我们将在一个愉快的氛围下,让ImageSpan动起来,并进行一些更深层次的探索,不出意外,这将是Drawable相关文章的终结篇。

另外,有几篇相关文章可以作为扩展阅读:

是否用得上

“学而时习之,不亦说乎” – 《论语-学而》

Span是android中实现富文本的一种方式,读者诸君请注意,是一种方式而不是唯一方式!
除却Span机制,依旧有其他形式展现富文本。

但不可否认:Span是非常轻量的一种方式,虽然这种 脱离展示容器的轻量 使得它的设计并不简单,
导致了简单使用它时很方便,重度使用它时 难以如指臂使 ,并会遭遇性能瓶颈。

以Juejin为例,只要我持续创作读者感兴趣的内容,我相信终有一天会解锁Lv10级作者,那么:

“在APP上给我颁发一个会闪烁的徽章,并追加在昵称后,以显示身份”,包括不限于:我的主页、文章作者栏、评论区、文章中 @我 的地方

这个功能似乎很合情合理😆。

用ImageSpan方案实现这一需求也很合理,并且这一解析、展示方案可以多处复用,并不需要四处精心维护布局。虽然juejin并未这样做😂

简单盘算后,今天的知识一定有使用的前景,稳赚不亏!

制造一个翻车现场

还是借助先前的项目:DrawableWorkShop ,先制作一个翻车现场。

在上一篇文章中,我们已经完成了动画Drawable,正好利用它生成ImageSpan。我们再增加DrawableStart 用作对比。

关键代码如下:

val tvSpan = findViewById<TextView>(R.id.tv_span)

val drawable = createADrawable()
val imgSpan = ImageSpan(drawable)

val ss = SpannableString("ImageSpan *")
ss.setSpan(imgSpan, 10, 11, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
tvSpan.text = ss

val drawableStart = createADrawable()
tvSpan.setCompoundDrawables(drawableStart, null, null, null)

tvSpan.setOnClickListener 
    drawable.start()
    drawableStart.start()


fun createADrawable(): AnimLetterDrawable2 
    val drawable = AnimLetterDrawable2()
    drawable.textSize = 20f
    drawable.letters = "span"
    drawable.setBounds(0, 0, 100, 100)
    return drawable

点击TextView开启动画,好,果然翻车了!ImageSpan并未动起来,而DrawableStart动起来了。

温故而知新 – 原因分析

在前两篇相关文章中,我们已经窥探到动画的原理:

按照特定的时间序列绘制对应帧,利用视觉暂留形成动画效果

无论是控件的属性动画、还是Drawable动画,其本质均为此。在前两篇文章中,我们分别用了两种方式驱动Drawable形成动画效果,稍作复习:

  • 基于 Drawable#scheduleSelf API,向宿主View Post 一个延迟执行的 Runnable 业务逻辑为重新绘制。在Handler消息机制的驱动下, Choreographer 实现了动画基本原理
  • 基于 ValueAnimator,按照时序执行回调,业务逻辑为重新绘制。依旧是借助Handler消息机制的驱动, Choreographer 实现动画基本原理。

此时,可以做出大胆的假设:问题本质是没有正确重新绘制

在前文中,我们已经知道,Drawable重新绘制的核心是 invalidateSelf()

class Drawable 
    public void invalidateSelf() 
        final Callback callback = getCallback();
        if (callback != null) 
            callback.invalidateDrawable(this);
        
    

而该API借助了 Drawable.Callback 委托实现。

Debug之后可以发现,配合ImageSpan使用时,Drawable并未持有Callback实例

对比参考TextView设置DrawableStart的相关核心代码,忽略掉无关细节,其中调用了 Drawable#setCallback(this)

class TextView 
    private void setRelativeDrawablesIfNeeded(Drawable start, Drawable end) 
        boolean hasRelativeDrawables = (start != null) || (end != null);
        if (hasRelativeDrawables) 
            //ignore 
            if (start != null) 
                //ignore

                //重点
                start.setCallback(this);

                //ignore
             else 
                dr.mDrawableSizeStart = dr.mDrawableHeightStart = 0;
            
            if (end != null) 
                //ignore
                end.setCallback(this);

                //ignore
             else 
                dr.mDrawableSizeEnd = dr.mDrawableHeightEnd = 0;
            
            //ignore
        
    

至此,推测得到验证。

直面问题 – 以最简单的代码让ImageSpan动起来

才思敏捷的读者可能已经想到,给ImageSpan内部的Drawable设置Callback不就可以了吗?就像这样:

 tvSpan.setOnClickListener 
    //设置Callback
    drawable.callback = it

    drawable.start()
    //屏蔽掉DrawableStart的干扰
    // drawableStart.start()

当你自信满满的尝试了一下,哎呀,又TM翻车了!!!

翻车原因

让我们再复习一下:

public class View 
    public void invalidateDrawable(@NonNull Drawable drawable) 
        //看这里的校验
        if (verifyDrawable(drawable)) 
            final Rect dirty = drawable.getDirtyBounds();
            final int scrollX = mScrollX;
            final int scrollY = mScrollY;

            //这里是刷新
            invalidate(dirty.left + scrollX, dirty.top + scrollY,
                    dirty.right + scrollX, dirty.bottom + scrollY);
            rebuildOutline();
        
    

    protected boolean verifyDrawable(@NonNull Drawable who) 
        // Avoid verifying the scroll bar drawable so that we don't end up in
        // an invalidation loop. This effectively prevents the scroll bar
        // drawable from triggering invalidations and scheduling runnables.
        return who == mBackground || (mForegroundInfo != null && mForegroundInfo.mDrawable == who)
                || (mDefaultFocusHighlight == who);
    

很显然,ImageSpan内包含的Drawable和TextView之间并无直接关联!

注意,TextView的判断逻辑存在重载,仅背景、聚焦效果、DrawableStart、DrawableTop、DrawableEnd、DrawableBottom 是有关联的:

class TextView 
    protected boolean verifyDrawable(@NonNull Drawable who) 
        final boolean verified = super.verifyDrawable(who);
        if (!verified && mDrawables != null) 
            for (Drawable dr : mDrawables.mShowing) 
                if (who == dr) 
                    return true;
                
            
        
        return verified;
    

暗度陈仓,绕过校验

才思敏捷的读者朋友一定想到了:“既然是校验出的问题,那我绕过校验不就好了”,很快掏出了代码V2:

tvSpan.setOnClickListener 
//            drawable.callback = it //这种方式无效,Drawable和TextView之间无关联
    drawable.callback = object : Drawable.Callback 
        override fun invalidateDrawable(who: Drawable) 
            //直接刷,绕过校验
            it.invalidate()
        

        override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) 
            it.scheduleDrawable(who, what, `when`)
        

        override fun unscheduleDrawable(who: Drawable, what: Runnable) 
            it.unscheduleDrawable(who, what)
        

    
    drawable.start()
//            drawableStart.start()

果然,它动起来了!

作者按:让ImageSpan动起来的核心知识,到此已经结束,但知识的探索还未结束,下面将打开Span世界的大门

优雅,永恒的追求

我一直追求编程中的一种优雅:

  • 复杂度按必要程度分层展开,避免 没有必要的详细导致难以理解的复杂
  • 井然有序,当代码不得不做出改变时,不因代码间没必要的耦合,加大变化难度

而我们目前面对的问题,恰恰就是一个好机会,可以顺势研究源码,汲取知识,在此基础上,封装代码,更优雅地解决问题;并且在研究的过程中,可以摸索到其它知识模块。

这恰好可以通往第三境 对于 人生追求三境 ,我会在下一个杂篇和读者们交流一下心得

直接使用会带来的问题

1.虽然有效,但耦合过重,业务代码中不得不暴露过多无关代码

我们已经使ImageSpan动起来了,那我多搞几个动态徽章没有问题吧。

将核心代码复刻,很快就得到了以下代码

val tvSpan2 = findViewById<TextView>(R.id.tv_span2)
val infoBuilder = SpannableStringBuilder().append("Leobert")

val madels = arrayListOf<String>("Lv.10", "持续创造", "笔耕不追", "夜以继日")
val drawables: List<AnimLetterDrawable2> = madels.map  madel ->
    appendMadel(infoBuilder, madel).let  drawable ->
        drawable.callback = object : Drawable.Callback 
            override fun invalidateDrawable(who: Drawable) 
                tvSpan2.invalidate()
            
            //ignore
        
        drawable
    


tvSpan2.text = infoBuilder
tvSpan2.setOnClickListener 
    drawables.forEach 
        it.start()
    


fun appendMadel(builder: SpannableStringBuilder, madel: String): AnimLetterDrawable2 
    val drawable = AnimLetterDrawable2()
    //ignore

    val imgSpan = ImageSpan(drawable)
    val ss = SpannableString(" *")
    ss.setSpan(imgSpan, 1, 2, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
    builder.append(ss)

    //ignore,追加了一处ClickSpan,可以直观观测点击触发

    return drawable

您已经发现,为了让功能有效,我们不得不小心的设置回调,一旦有所疏漏,就会带来Bug。

务必注意,仅用于显示时,我们尚可以说服自己不负责任地忽略 “不移除回调” 带来的负面影响,诸如无效刷新、内存泄露。

而在编辑时(如EditText中使用,并删除图片)、以及RecycleView中TextView复用(推广到可替换呈现内容的情况)
则不得不移除回调。否则轻则造成性能损耗和内存泄露,重则立现 UI bug。

可以想象,这样的代码太过于丑陋,过多的无关代码暴露在业务实现中

2.影响复用

Callback的唯一性导致ImageSpan不能被有效复用,还需要进行一定的改造。

虽然在理论上已经推断出这种做法会影响Drawable复用(以及Span的复用、进一步推广到Spannable的复用),仍旧以代码验证一下推论:

val tvSpan3 = findViewById<TextView>(R.id.tv_span3)
// 沿用上文中构建的Spannable,它已经通过Callback和tvSpan2高度耦合,
// 我们希望这样的代码就可以完成目标,但显然目前无法完成
tvSpan3.text = infoBuilder
tvSpan3.setOnClickListener 
    drawables.forEach 
        it.start()
    

tvSpan3.movementMethod = LinkMovementMethod.getInstance()

读者诸君可使用WorkShop自行尝试,不出意外的翻车了吧。

1.解决Callback对复用的限制

显然,我们无法修改SDK的内容,但可以使用组合模式。

先定义一个回调接口,依赖抽象以解耦。

interface OnRefreshListener 
    /**
     * will be called when a inner drawable of the span want to invalidate.
     *
     * @return true if the listener want to be called in future.
     * false otherwise
     */
    fun onRefresh(): Boolean

定义组合,仍旧以接口定义,以提升灵活度:

interface OnRefreshListeners : OnRefreshListener 
    fun addRefreshListener(callback: OnRefreshListener)
    fun removeRefreshListener(callback: OnRefreshListener)

我们可以顺其自然的定义一个组合Callback实现如下:

class DrawableCallbackComposer : OnRefreshListeners, Drawable.Callback 
    private val mRefreshListeners: MutableCollection<OnRefreshListener> = mutableListOf()

    override fun addRefreshListener(callback: OnRefreshListener) 
        mRefreshListeners.add(callback)
    

    override fun removeRefreshListener(callback: OnRefreshListener) 
        mRefreshListeners.remove(callback)
    

    override fun onRefresh(): Boolean 
        val stillActivatedAfterRefresh = mRefreshListeners.filter 
            it.onRefresh()
        
        mRefreshListeners.clear()
        mRefreshListeners.addAll(stillActivatedAfterRefresh)
        return true
    

    override fun unscheduleDrawable(who: Drawable, what: Runnable) 
    

    override fun invalidateDrawable(who: Drawable) 
        onRefresh()
    

    override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) 
    

接下来就可以使用 DrawableCallbackComposer 来扩展复用度,但显然,我们并不愿意到处裸露 DrawableCallbackComposer 的操作,继续考虑封装和隐藏。

2.李代桃僵,利用代理

如果我们拥有一个代理层,它帮助 Drawable 处理 DrawableCallbackComposer子节点 OnRefreshListener 注册与解注册,并且最终表现为一个 Drawable,那么 裸露的控制代码
将替换为简单的 依赖注入中的实例提供

基于接口的灵活性,我们可以顺其自然的定义如下 DrawableProxy 类。

它能够直接代理原Drawable关于绘制的全部内容,又追加了 使用 DrawableCallbackComposer 和 回调注册解注册的必要控制逻辑

class DrawableProxy
@JvmOverloads constructor(
    proxy: Drawable? = null,
    private val drawableCallbackComposer: DrawableCallbackComposer = DrawableCallbackComposer()
) : Drawable(),
    ResizeDrawable,
    Drawable.Callback by drawableCallbackComposer,
    OnRefreshListeners by drawableCallbackComposer 

    private var proxySafety: Drawable = proxy?.also  it.callback = drawableCallbackComposer  ?: this
        set(drawable) 
            field.callback = null
            drawable.callback = drawableCallbackComposer
            field = drawable
            needResize = true
            invalidateDrawable(this)
        

    fun setProxy(drawable: Drawable) 
        this.proxySafety = drawable
    

    fun clearProxy() 
        this.proxySafety = this
    

    override var needResize: Boolean = false

    /*以下是代理实现,无需过度关心*/

    override fun getIntrinsicWidth(): Int 
        return proxySafety.intrinsicWidth
    

    override fun getIntrinsicHeight(): Int 
        return proxySafety.intrinsicHeight
    

    override fun draw(canvas: Canvas) 
        proxySafety.draw(canvas)
    

    override fun setAlpha(alpha: Int) 
        proxySafety.alpha = alpha
    

    override fun setColorFilter(cf: ColorFilter?) 
        proxySafety.colorFilter = cf
    

    override fun getOpacity(): Int 
        return proxySafety.opacity
    

    override fun onBoundsChange(bounds: Rect) 
        super.onBoundsChange(bounds)
        needResize = false
    

    override fun setBounds(bounds: Rect) 
        super.setBounds(bounds)
        proxySafety.bounds = bounds
    

    override fun setBounds(left: Int, top: Int, right: Int, bottom: Int) 
        super.setBounds(left, top, right, bottom)
        proxySafety.setBounds(left, top, right, bottom)
    


至此,我们将使用DrawableProxy 替代原先的 AnimDrawable,用以构建Span实例。

接下来可以将注意力转移到:“实现、添加 OnRefreshListener”,通过调用宿主TextView的刷新,显示动画帧。

继续使用SpanWatcher解放双手

行至此处,您一定不甘心再在业务代码中裸露此类控制代码:

drawable?.addRefreshListener(object : OnRefreshListener 
    override fun onRefresh(): Boolean 
        //ignore 诸如生命周期断定 。。。
        view.invalidate()
        return true
    
)

哪怕您将它封装成一个静态API以供业务代码中调用,也难以达到您对 追求"优雅" 的要求底线了。

而SDK中已经确定了这位天选打工人 SpanWatcher,看一下它的定义:

/**
 * When an object of this type is attached to a Spannable,
 * its methods will be called to notify it that other 
 * markup objects have been added, changed, or removed.
 * */

翻译如下:

SpanWatcher 类型的实例被添加到 Spannable 之后,一旦发生 (Span)标记被 添加、改变、移除时,
实例相应的 API方法 会被调用,起到通知效果。

藉此,我们可以顺其自然地简化处理回调的注册与解注册

读者诸君,此时还请再想一想,它能完美的解决问题吗?

class AnimImageSpanWatcher(view: View) : SpanWatcher, OnRefreshListener 
    private var mLastRefreshStamp: Long = 0
    private val mViewWeakReference: WeakReference<View> = WeakReference(view)

    override fun onSpanAdded(text: Spannable, what: Any, start: Int, end: Int) 
        if (what is RefreshSpan) 
            val drawable = what.getInvalidateDrawable()
            drawable?.addRefreshListener(this)
        
    

    override fun onSpanRemoved(text: Spannable, what: Any, start: Int, end: Int) 
        if (what is RefreshSpan) 
            val drawable = what.getInvalidateDrawable()
            drawable?.removeRefreshListener(this)
        
    

    override fun onSpanChanged(text: Spannable, what: Any, ostart: Int, oend: Int, nstart: Int, nend: Int) 

    

    override fun onRefresh(): Boolean 
        val view = mViewWeakReference.get() ?: return false

        //ignore 生命周期有效性判断

        val currentTime = System.currentTimeMillis()
        //加一层过滤,避免刷新过于频繁
        if (currentTime - mLastRefreshStamp > REFRESH_INTERVAL) 
            mLastRefreshStamp = currentTime
            view.invalidate()
        
        return true
    

    companion object 
        private const val REFRESH_INTERVAL = 60
    

行至此处,还剩下关键的一步:借助TextView自身的机制,让这些类正常工作!

使用 Spannable.Factory 梦幻联动

不清楚读者诸君是否 精心研读TextView 的源码,当然,本篇并不打算展开分析,TextView 中有一个API:

class TextView 
    /**
     * Sets the Factory used to create new Spannable
     */
    public final void setSpannableFactory以上是关于好玩系列:听说你的ImageSpan没能动起来?的主要内容,如果未能解决你的问题,请参考以下文章

Pygame实战俄罗斯方块 | 太好玩了~停不下来,这种版本(Turtle彩版)你肯定没玩过……(经典怀旧:无人不知的俄罗斯方块)

好玩的Linux命令,将礼品包装在盒子中

Linux好玩儿的命令(RHEL/CentOS上实现)

网宿科技股份有限公司工资待遇怎么样?

iPhone 14 系列来了!能动的“药丸屏”,Plus 型号回归,最高售价 13499 元

测试平台系列如何停止测试任务执行