美文网首页
便签SpannableString崩溃问题分析

便签SpannableString崩溃问题分析

作者: whtaxiesai | 来源:发表于2019-10-20 20:14 被阅读0次

    便签SpannableString崩溃问题分析

    1. 问题描述

    自研的便签在加入多样式文本后出现偶现崩溃问题,代码提示如下,可以看到是在SpannableString对象初始化出现的越界错误,出错的代码很快就定位到,但是发现并不好分析,原因在于

    (1)出错的代码在源码中,代码中只是对象初始化,按道理来说不会出现问题(后面证实该想法存在问题,初始化过程中会触发其他的代码)

    (2)由于该问题是偶现问题,通过moneky测试发现的,没有办法找到必现路径

    (3)在网上没有搜索出同样的问题,其实从这里就可以推断是自己代码中的问题

        java.lang.IndexOutOfBoundsException: setSpan (0 ... -1) has end before start
            at android.text.SpannableStringInternal.checkRange(SpannableStringInternal.java:428)
            at android.text.SpannableStringInternal.setSpan(SpannableStringInternal.java:163)
            at android.text.SpannableStringInternal.copySpans(SpannableStringInternal.java:68)
            at android.text.SpannableStringInternal.<init>(SpannableStringInternal.java:43)
            at android.text.SpannableString.<init>(SpannableString.java:30)
    

    2.问题分析

    (1)尝试复现问题

    该功能是在加入富文本样式中后出现的,所以推断是跟样式存在联系,而且代码出错的触发时机是在打开页面或者页面重复滚动。测试时通过多次输入样式文本打开和滚动页面,最后发现当输入样式文本第一个字体为带样式文本时,上下滚动页面后会出现崩溃。

    (2)源码分析

    找到必现路径后,跟踪崩溃的源码,发现在SpannableStringInternal中会调用CopySpans方法,当调用到i=6时,发现st和en获取都为-1,设置setSpan的参数时就会出现开头所说的越界错误。

        private void copySpans(Spanned src, int start, int end, boolean ignoreNoCopySpan) {
            Object[] spans = src.getSpans(start, end, Object.class);
    
            for(int i = 0; i < spans.length; ++i) {
                if (!ignoreNoCopySpan || !(spans[i] instanceof NoCopySpan)) {
                    int st = src.getSpanStart(spans[i]);
                    int en = src.getSpanEnd(spans[i]);
                    int fl = src.getSpanFlags(spans[i]);
                    if (st < start) {
                        st = start;
                    }
    
                    if (en > end) {
                        en = end;
                    }
    
                    this.setSpan(spans[i], st - start, en - start, fl, false);
                }
            }
        }
    

    查看getSpanStart方法代码(SpannableStringBuilder类中),getSpanStart值与mIndexOfSpan值有关,mIndexOfSpan是IdentityHashMap类型对象,IdentityHashMap是以对象作为key值保存,具体使用可参考官方文档,这里我们可以当hashMap进行理解。

    查看以下代码,我们发现getSpanStart返回-1的情况只有两种:

    • mIndexOfSpan为空,由于i=6时才会触发该条件,说明mIndexOfSpan不可能为空
    • mIndextOfSpan在获取i为spans[6]时对应的object为null,所以这里推断在SpannableString初始化时,在代码的其他地方设置了该object的值为null
       private IdentityHashMap<Object, Integer> mIndexOfSpan;
       public int getSpanStart(Object what) {
            if (this.mIndexOfSpan == null) {
                return -1;
            } else {
                Integer i = (Integer)this.mIndexOfSpan.get(what);
                return i == null ? -1 : this.resolveGap(this.mSpanStarts[i]);
            }
        } 
    

    SpannableStringBuilder类中查看mIndexOfSpan的引用,发现在removeSpan方法中会移处指定的标记对象。

    public void removeSpan(Object what, int flags) {
            if (mIndexOfSpan == null) return;
            Integer i = mIndexOfSpan.remove(what);
            if (i != null) {
                removeSpan(i.intValue(), flags);
            }
        }
    

    在removeSpan方法中设置断点,调试跟踪代码,调用栈如下。在SpannableString初始化时会调用onSelectionChange方法中,在onSelectionChanged方法中会判断selStart和selEnd参数进行span的设置,当满足设定条件时,会调用removeSpan方法移除设置的富文本样式(该方法为自己调用代码,不属于源码)。

    问题的原因就很简单,在SpannableString调用了removeSpan方法,移除相应的对象引用,导致在copySpans中调用时,遍历mIndexOfSpan时获取该值为空,导致越界错误。

    但这里存在两个疑惑,

    1. SpannableString初始化中为什么调用onSelectionChange

    2. 在调用CopySpans方法时,首先会遍历源对象的span对象,怎么确保出错的span不在onSelectionChange方法前调用,也就是onSelectionChange调用的时机

    这里我们先分析第一个疑惑:从以上调用栈,可以获取函数调用为

    SpannableStringInterna.copy --> SpanSpannableStringInternal.setSpan --> SpannableStringInternal.sendSpanAdded --> ChangeWatcher.onSpanChanged --> TextView.spanChange

    在设置span时,会触发onSpanAdd方法,最后会调用到TextView的onSelectionChange方法,这里的关键在于Span数组中包含了TextView的ChangeWatcher对象。

    SpannableStringInternal类的SetSpan方法

    private void setSpan(Object what, int start, int end, int flags, boolean enforceParagraph) {
            this.checkRange("setSpan", start, end);
            ...
            if (this instanceof Spannable) {
                this.sendSpanAdded(what, start, end);
            }
        }
    

    SpannableStringInternal类的sendSpanAdded方法

    private void sendSpanAdded(Object what, int start, int end) {
            SpanWatcher[] recip = getSpans(start, end, SpanWatcher.class);
            int n = recip.length;
    
            for (int i = 0; i < n; i++) {
                recip[i].onSpanAdded((Spannable) this, what, start, end);
            }
        }
    

    ChangeWatcher的onSpanChanged

    public void onSpanChanged(Spannable buf, Object what, int s, int e, int st, int en) {
        TextView.this.spanChange(buf, what, s, st, e, en);
    }
    

    TextView的spanChange方法

    void spanChange(Spanned buf, Object what, int oldStart, int newStart, int oldEnd, int newEnd) {
        if (selChanged) {
            ...
            if ((buf.getSpanFlags(what) & Spanned.SPAN_INTERMEDIATE) == 0) {
                ...
                onSelectionChanged(newSelStart, newSelEnd);
            }
        }
    }
    

    那么这个changeWather是在何时增加到Span数组中呢,查看TextView的源码,在setText中可以看到,在设置文本时会自动添加TextWatcher。

    出错的span为我们自己设置的StyleSpan(富文本样式),是在setText之后设置的span,所以也就确保出错的span是在onSelectionChange方法之后,为了验证这个问题,断点查看了span的顺序,如下所示,如猜想所示,这里也就解决了第二个疑惑。

    private void setText(CharSequence text, BufferType type,
                             boolean notifyBefore, int oldlen) {
    
            if (text instanceof Spannable && !mAllowTransformationLengthChange) {
                if (mChangeWatcher == null) mChangeWatcher = new ChangeWatcher();
    
                sp.setSpan(mChangeWatcher, 0, textLength, Spanned.SPAN_INCLUSIVE_INCLUSIVE
                        | (CHANGE_WATCHER_PRIORITY << Spanned.SPAN_PRIORITY_SHIFT));
            }
        }
    

    3.解决方案

    最后整理下总过程,在进行new SpannableString时会时会调用CopySpan方法,会遍历对象的span,当遍历到Span为ChangeWatcher时,会触发onSelectionChange函数,会主动调用removeSpan方法移除我们设定的样式span,同时移除mIndexOfSpan中对应的对象,导致在copySpan获取下次StyleSpan对象为空,出现崩溃问题。

    可以看到,问题的关键在于onSelectionChange方法的调用,在问题验证过程中,发现将SpannableString修改为SpanStringBuilder时不会出现崩溃问题。

    查看源码发现SpannableStringBuilder在setSpan时会设置标志位send为false,在SpannableStringBuilder的setSpan方法中会根据该值触发sendSpanAdded方法,所以也就不会触发onSelectionChange方法,最终的解决方案也很简单,将原来调用的修改为SpannableStringBuilder即可。

    public SpannableStringBuilder(CharSequence text, int start, int end) {
            if (text instanceof Spanned) {
                Spanned sp = (Spanned) text;
                Object[] spans = sp.getSpans(start, end, Object.class);
    
                for (int i = 0; i < spans.length; i++) {
                    ...
                    setSpan(false, spans[i], st, en, fl, false/*enforceParagraph*/);
                    ...
                }
            }
        }
    
    private void setSpan(boolean send, Object what, int start, int end, int flags,
                boolean enforceParagraph) {
            checkRange("setSpan", start, end);
            ...
            if (send) {
                restoreInvariants();
                sendSpanAdded(what, nstart, nend);
            }
            ...
        }
    
    

    参考文档:

    Spannable 源码分析

    相关文章

      网友评论

          本文标题:便签SpannableString崩溃问题分析

          本文链接:https://www.haomeiwen.com/subject/xvpfrqtx.html