Android Span详解

引子

Android中的Span之前用的很少,接触多了以后,发现Span还是相当有趣的。
Span的命名即使不是最差劲的,也是最差劲的之一吧,第一眼看去完全不知道这个类是干嘛的😆。Span字面的意思是“跨度”、“区间”、“范围”,这完全词不达意,一脸懵😳。
在Android中,Span用来定义文本的样式。通过Span可以改变几个文字的颜色,让它们可点击,缩放文字大小甚至绘制自定义的项目符号点。
Span的价值是,可以将这些样式作用在字符级别或者段落级别。
那现在反过来,如果我来写一个这种功能的类,有没有更好的命名呢?呃~ 呃~ 呃~,好像google 大佬的命名还挺香~~~

本文主要分4部分介绍、总结下Span(大部分直接翻译了google文档😶),(1)Span的使用哲学,(2)Framework中提供的Span武器库,明晰有哪些样式可以直接使用,(3)如果系统未提供样式,如何自定义Span,(4)使用Span的最佳实践。

Span的使用哲学

Span是专门用来增强TextView样式的,Span通过改变TextPaint属性,在Canvas上绘制,甚至是改变文本的布局和影响像行高这样的元素,来改变文本样式。它可以被应用到部分或整段的文本中。

TextView有样式属性,为什么还需要Span?

通过XML属性或者代码设置就可以改变文本样式,但是效果必须作用于整个文本,如果要在部分文本上使用特殊样式就无能无力了,例如像下面这种:


image.png

Span就是解决这种需求的,Span样式可以作用于字符或者段落级别的文本。

通常使用的套路是样式属性和Span组合使用,可以考虑将设置给TextView的样式属性作为一种“基本”样式,而 Span样式是应用在基本样式“之上”并且会覆盖基本样式的样式。例如,当给一个 TextView 设置了 textColor=”@color.blue” 属性且设置开头4个字符应用了 ForegroundColorSpan(Color.PINK),则开头4个字符会使用 span 设置的粉色,而其他文本使用 TextView 属性设置的颜色。具体API使用,自行google,或者查看github的Span Sample

<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textColor="@color/blue"/>
SpannableString spannable = new SpannableString(“Text styling”);
spannable.setSpan(
     new ForegroundColorSpan(Color.PINK), 
     0, 4, 
     Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
myTextView.setText(spannable);
image.png

如何创建使用Span

当使用 span 时,需要使用SpannedString, SpannableString 或 SpannableStringBuilder之一。 它们之间的区别在于text内容或markup是可改变的还是不可改变的,以及它们使用的内部结构:SpannedString 和 SpannableString 使用线性数组记录已添加的 span,而 SpannableStringBuilder 使用 区间树。


image.png

如何决定使用哪一个类:

  • 创建后 文本 和 span 不可变 –> SpannedString
  • 创建后文本不可变,仅需设置 少量的 span (<~ 10)? –> SpannableString
  • 创建后需设置 文本 和 span –> SpannableStringBuilder
  • 创建后需设置 大量的 span (>~ 10)? –> SpannableStringBuilder

比较难理解的是SpanedString,查看其api,可以看到其只能通过SpannableString来创建,复制其Span属性来使用,这是我的理解不知道对不对,有了解的可以指导下。SpanedString使用场景也比较少吧,一直没用过。
对于SpannableString和SpannableStringBuilder,多个 span 可以被组合且同时附加到同一段文本上。如下面的红色和粗体叠加:


image.png

Framework中Span样式总结

Android framework在android.text.style包提供了20+的Span样式,通过2个维度可以对Span进行分类:

  • 基于Span是否改变text的外形还是改变text的尺寸或布局
  • 基于Span的作用范围是字符级别还是或段落级别


    image.png

Span的实现原理是,Android framework定义了几个接口和抽象类,这些接口和抽象类有允许Span访问TextPaint或Canvas对象的方法,它们会在测量和渲染时被检查,达到改变文本样式的效果。

影响text外观的Span

这些Span可以影响text外观:文本或背景颜色、下划线、删除线等等,如下UML类图所示,类名所见即所得。


image.png

这些Span会触发文本重新绘制,而不会触发重新布局。这些 span 实现了 UpdateAppearance 且继承自 CharacterStyle。CharacterStyle的子类通过提供更新 TextPaint 的访问方法,定义了怎样绘制文本。

影响text尺寸或布局的Span

这些Span可以影响text的尺寸和布局,如文本绝对尺寸、相对尺寸、插入图片、上标、下标、字体、字体风格等,如下UML类图所示,类名所见即所得。这些Span都继承自MetricAffectingSpan。


image.png

影响文本字体大小的Span可能会使得text字符宽高变化,甚至多出来一行,其实现是通过监听,触发重新测量、进而重新计算布局,进而重新绘制。这写Span继承自MetricAffectingSpan类,这个抽象类通过提供对 TextPaint的访问,来影响文本测量,而 MetricAffectingSpan 继承自CharacterSpan,其子类在字符级别影响文本的外形。

字符级Span

抽象类CharacterStyle对文本产生的影响在字符级别,更新元素,如背景颜色、样式或大小,上面的影响text外观、影响text尺寸或布局的Span都是字符级的Span。
CharacterStyle主要就是一个抽象方法updateDrawState,影响绘制属性,总结下来就是,一支画笔走天下,什么效果都能渲染。
MetricAffectingSpan主要就是一个抽象方法updateMeasureState,影响测量,进而重新布局。


image.png

段落级Span

段落级别Span都实现了接口ParagraphStyle(空接口),这些Span可以更改整个文本块的对齐方式或者边距。继承自ParagraphStyle的Span必须作用于text整体,从第一个字符附加到单个段落的最后一个字符,否则Span不会被显示。
在 Android 中,段落是基于换行符 (\n) 定义的。


image.png

Framework中段落级的Span,如下UML类图所示,类名所见即所得。可以看到很多接口没有实现,系统是预留了很多能力的,方便自定义。


image.png

自定义Span

系统提供的Span样式虽多,但是未必有一款合你心意,自定义Span总是在所难免。在实现你自己的Span时,需要确定你的Span是会影响字符级别还是影响段落级别的文本,以及它是影响文本的布局还是影响文本的外观,据此选择需要扩展的基类和实现的接口。相应选择如下:


image.png

举个例子,你需要Span样式可以改变文本的大小和颜色。你可以扩展RelativeSizeSpan,由于 RelativeSizeSpan已经提供了updateDrawState和updateMeasureState回调,我们可以复写绘制状态回调并设置 TextPaint 的颜色。这只是一个自定义Span的例子而已,同样的效果你可以通过组合使用RelativeSizeSpan和ForegroundColorSpan来达成。

public class RelativeSizeColorSpan extends RelativeSizeSpan {
    private int color;
    public RelativeSizeColorSpan(float spanSize, int spanColor) {
        super(spanSize);
        color = spanColor;
    }
    @Override
    public void updateDrawState(TextPaint textPaint) {
        super.updateDrawState(textPaint);
        textPaint.setColor(color);
    }
}

Span使用最佳实践

基于使用场景,TextView#setText()方法有几种优化内存的方式。原理是,setText方法会copy一份text实例,在某些场景可以规避创建copy text实例。

text不变增加或移除Span

TextView#setText()因处理不同的Span有多个重载,例如,设置一个Spannable text:

textView.setText(spannableObject);

当调用setText()方法,TextView会copy Spannable作为SpannableString,并在内存中以CharSequence形态保存。这意味着text和Span是不可变的,当需要更新text和Span时,需要创建新的Spannable,并且调用setText()。
如果Span是可变的,使用setText(CharSequence text, TextView.BufferType type)更佳, 如下:

textView.setText(spannable, BufferType.SPANNABLE);
Spannable spannableText = (Spannable) textView.getText();
spannableText.setSpan(
     new ForegroundColorSpan(color),
     8, spannableText.getLength(),
     SPAN_INCLUSIVE_INCLUSIVE);

上例中,由于BufferType.SPANNABLE参数,setText方法创建了SpannableString(可变markup,不可变文本),再次更新Span时,可以获取TextView中的Spannable引用,而非再次创建新的Spannable实例,优化内存使用。
需要注意的是,此时需要主动调用invalidate() 或者requestLayout(),根据更新的Span是影响外观的,还是影响尺寸和布局的而定。

TextView多次设置text

一些场景,比如RecyclerView.ViewHolder,存在TextView复用,导致多次设置text。
通常不使用BufferType参数的情况下,每次设置文本,TextView都会copy一份实例,以CharSequence的形态存在内存中。也就是,每次设置新的文本,TextView都会创建新的实例。
通过实现自己的Spannable#Factory并重写newSpannable()可以控制这个过程,并避免多余实例的创建。范例如下:

Spannable.Factory spannableFactory = new Spannable.Factory(){
    @Override
    public Spannable newSpannable(CharSequence source) {
        return (Spannable) source;
    }
};

需要注意的是,必须使用textView.setText(spannableObject, BufferType.SPANNABLE)这种方式设置文本,否则就会抛出ClassCastException。
需要告诉TextView使用自定义的Spannable#Factory,如下:

textView.setSpannableFactory(spannableFactory);

在获得TextView引用之后需要立刻设置,如果在使用RecyclerView,应该在view第一次被inflate出来之后立刻设置Factory,避免绑定数据时TextView#setText()出现多余的实例创建。

改变Span属性

如果需要改变一个可变Span的内部属性,比如改变BulletSpan的颜色,避免多次重头调用setText()方法,最佳实现方式是,保存Span的引用,再需要更新Span属性时,通过引用改变属性,然后调用invalidate() 或者 requestLayout()方法。
BulletSpan颜色改变的范例如下:

public class MainActivity extends AppCompatActivity {

    private BulletPointSpan bulletSpan = new BulletPointSpan(Color.RED);

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        SpannableString spannable = new SpannableString("Text is spantastic");
        // setting the span to the bulletSpan field
        spannable.setSpan(bulletSpan, 0, 4, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
        styledText.setText(spannable);
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                // change the color of our mutable span
                bulletSpan.setColor(Color.GRAY);
                // color won’t be changed until invalidate is called
                styledText.invalidate();
            }
        });
    }
}

使用Android KTX扩展

Android KTX扩展包括了很多更方便使用Span的方法,具体可以参考androidx.core.text

参考文档

  1. Android developer span
  2. 探索Android中的Span
  3. github 例子
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267

推荐阅读更多精彩内容