Android TextView富文本深入探索

来源:互联网 发布:中级程序员面试题 编辑:程序博客网 时间:2024/05/17 02:07

一、怎么用?

先介绍TextView富文本的使用方法,TextView富文本显示主要有两种方式,一个是使用SpannableString类,另一种是直接将富文本写成HTML形式。

SpannableString

SpannableString是Android内置的专门处理富文本的类,基本涵盖了你能想到的所有富文本表示,字体、颜色、图片、点击事件…功能非常强大。话不多说,直接上代码:

示例

//设置Hello World前三个字符为红色,背景为蓝色SpannableString textSpanned1 = new SpannableString("Hello World");textSpanned1.setSpan(new ForegroundColorSpan(Color.RED),        0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);textSpanned1.setSpan(new BackgroundColorSpan(Color.BLUE),        0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);text1.setText(textSpanned1);//设置Hello World前三个字符字体为斜体SpannableString textSpanned2 = new SpannableString("Hello World");textSpanned2.setSpan(new StyleSpan(Typeface.ITALIC),        0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);text2.setText(textSpanned2);//设置Hello World前三个字符有下划线SpannableString textSpanned3 = new SpannableString("Hello World");textSpanned3.setSpan(new UnderlineSpan(),        0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);text3.setText(textSpanned3);//设置Hello World前三个字符有点击事件SpannableStringBuilder textSpanned4 = new SpannableStringBuilder("Hello World");ClickableSpan clickableSpan = new ClickableSpan() {    @Override    public void onClick(View view) {        Toast.makeText(MainActivity.this, "Hello World", Toast.LENGTH_SHORT).show();    }};textSpanned4.setSpan(clickableSpan,        0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);//注意:此时必须加这一句,不然点击事件不会生效text4.setMovementMethod(LinkMovementMethod.getInstance());text4.setText(textSpanned4);

Spannable演示

setSpan()

void setSpan (Object what, int start, int end, int flags)
参数 说明 what 样式 start 样式开始的字符索引 end 样式结束的字符索引 flags 新插入字符的设置

flags:

取值 说明 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE 前后都不包括 Spanned.SPAN_EXCLUSIVE_INCLUSIVE 前面不包括,后面包括 Spanned.SPAN_INCLUSIVE_EXCLUSIVE 前面包括,后面不包括 Spanned.SPAN_INCLUSIVE_INCLUSIVE 前后都包括

这个flags可能有人不懂,它表示了这个样式是否作用在本字符串之前或之后插入的其他字符串上,举个例子:

SpannableStringBuilder textSpannedBuilder1 = new SpannableStringBuilder();SpannableString textSpanned11 = new SpannableString("Hello");textSpanned11.setSpan(new BackgroundColorSpan(Color.BLUE), 0, textSpanned11.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);SpannableString textSpanned12 = new SpannableString("World");text1.setText(textSpannedBuilder1.append(textSpanned11).append(textSpanned12));SpannableStringBuilder textSpannedBuilder2 = new SpannableStringBuilder();SpannableString textSpanned21 = new SpannableString("Hello");textSpanned21.setSpan(new BackgroundColorSpan(Color.BLUE), 0, textSpanned21.length(), Spanned.SPAN_EXCLUSIVE_INCLUSIVE);SpannableString textSpanned22 = new SpannableString("World");text2.setText(textSpannedBuilder2.append(textSpanned21).append(textSpanned22));SpannableStringBuilder textSpannedBuilder3 = new SpannableStringBuilder();SpannableString textSpanned31 = new SpannableString("Hello");textSpanned31.setSpan(new BackgroundColorSpan(Color.BLUE), 0, textSpanned21.length(), Spanned.SPAN_EXCLUSIVE_INCLUSIVE);SpannableString textSpanned32 = new SpannableString("World");textSpanned32.setSpan(new BackgroundColorSpan(Color.GREEN), 0, 3, Spanned.SPAN_EXCLUSIVE_INCLUSIVE);text3.setText(textSpannedBuilder3.append(textSpanned31).append(textSpanned32));

Flags演示

  • text1里,”Hello”的flags是SPAN_EXCLUSIVE_EXCLUSIVE,在它之后插入的”World”显示正常,无背景。

  • text2里,”Hello”的flags是SPAN_EXCLUSIVE_INCLUSIVE,它之后插入的”World”的背景变为蓝色。

  • 需要注意的是text3,这里的”Hello”与text2相同,而”World”的一部分字符设置为绿色,显然这部分字符显示的是绿色,这说明虽然设置了SPAN_EXCLUSIVE_INCLUSIVE属性,但只要后面的字符串设置了同类的样式,还是覆盖掉flags属性。

SpannableString和SpannableStringBuilder

在上面的例子中我们用到了SpannableStringBuilder类,那这个类和SpannableString有什么不同呢?其实大家只要想想String和StringBuilder区别就行了,SpannableString在创建的时候就需要指定好字符串,之后就不能更改了,而SpannableStringBuilder可以使用append()方法,在已有的富文本后添加新的富文本。

HTML

接下来介绍HTML的用法,其实HTML使用起来要比SpannableString简洁,我们只需要按照平时写HTML的习惯,将需要显示的富文本加上各种标签,就可以显示在TextView上了,下面我们看一下例子:

示例

String htmlText1 = "<b>Hello World</b>";text1.setText(Html.fromHtml(htmlText1));String htmlText2 = "<font color='#ff0000'>Hello World</font>";text2.setText(Html.fromHtml(htmlText2));String htmlText3 = "<i><a href='https://gavinli369.github.io/'>我的博客</a></i>";text3.setMovementMethod(LinkMovementMethod.getInstance());text3.setText(Html.fromHtml(htmlText3));

HTML演示

是不是感觉比SpannableString简单多了,其实Html类内部还是在使用Spannable处理,我们会在后文看到它的实现方式。

TextView支持的HTML标签

标签 说明 font 设置字体和颜色 big 大号字体 small 小号字体 i 斜体 b 粗体 tt 等宽字体 br 换行(行与行之间没有空行) p 换行(行与行之间有空行) a 链接 img 图像

其实TextView支持HTML标签不止这些,后文会带大家一起看的HTML类的源码,里面有TextView支持的所有HTML标签。还有一点需要注意的是,不同的标签是有可能会出现相同效果的,例如strong标签和b标签的效果都是字体加粗,这些在大家看到HTML类源码的时候,就知道会原因。

二、深入的探索

熟悉了用法,我们就要向更深一层探索了。接下来就让我来带大家深入TextView源码,一起揭开TextView富文本显示的神秘面纱……

Spannable的表示

首先,要想知道TextView的富文本是怎么实现的,我们得先搞明白Android内部是怎么表示富文本的,这是Spannable相关类的继承体系:

Spannable继承体系

我们之前用的SpannableString类和SpannableStringBuilder类都实现了Spannable接口,setSpan()方法就是在这里声明的。再看左边的SpannableString类,它继承自一个虚类SpannableStringInternal,而我们要找的富文本实现方法就隐藏在这个类中,下面我们就来一探究竟。

void setSpan(Object what, int start, int end, int flags) {    //省略了一些无关代码    mSpans[mSpanCount] = what;    mSpanData[mSpanCount * COLUMNS + START] = start;    mSpanData[mSpanCount * COLUMNS + END] = end;    mSpanData[mSpanCount * COLUMNS + FLAGS] = flags;    mSpanCount++;}

还是先进到我们最熟悉的setSpan()方法内看一下,我们发现setSpan方法主要更改了三个全局变量的值mSpans, mSpanData和mSpanCount,我们找到这几个变量的声明:

private String mText;private Object[] mSpans;private int[] mSpanData;private int mSpanCount;private static final int START = 0;private static final int END = 1;private static final int FLAGS = 2;private static final int COLUMNS = 3;

其实SpannableStringInternal内部存在两个数组,一个mSpanData表示样式的首尾索引和flags,另一个mSpans表示对应的样式。
这个mSpanData的表示比较有意思,它是将三个变量打包存在一起的,取得时候只需要取变量对应的偏移地址的值,可以看一下这个mSpanData数组的表示图:

mSpanData构成

而SpannableStringBuilder的就显得简单多了,直接将这四个变量分别存放在了四个数组里,这里就不对它做过多介绍了,有兴趣的同学可以自己探索一下。

富文本的绘制

知道了富文本是怎么表示的,接下来我们就是富文本的绘制了,我们先看一下TextView的onDraw()方法。

protected void onDraw(Canvas canvas) {    //...    if (mLayout == null) {        assumeLayout();    }    Layout layout = mLayout;    //省咯了大量代码    final int cursorOffsetVertical = voffsetCursor - voffsetText;    Path highlight = getUpdatedHighlightPath();    if (mEditor != null) {        mEditor.onDraw(canvas, layout, highlight, mHighlightPaint, cursorOffsetVertical);    } else {        layout.draw(canvas, highlight, mHighlightPaint, cursorOffsetVertical);    }}

这里就牵扯到了TextView类的构成,大家在看TextView源码的时候会发现有一万多行的代码,其实这是Android为了方便TextView的扩展,将很多本不该属于TextView的代码也写在了这里。大家可以看一下EditText的源码,总共就一百多行,大部分逻辑都是直接交给TextView处理的。而这个mEditor就是用来处理可编辑的TextView的,我们不管它直接看下面,TextView将绘制的细节都交给了这个mLayout来做,那这个mLayout又是什么呢?

protected Layout makeSingleLayout(int wantWidth, BoringLayout.Metrics boring, int ellipsisWidth,        Layout.Alignment alignment, boolean shouldEllipsize, TruncateAt effectiveEllipsize,        boolean useSaved) {    Layout result = null;    if (mText instanceof Spannable) {        result = new DynamicLayout(mText, mTransformed, mTextPaint, wantWidth,                alignment, mTextDir, mSpacingMult, mSpacingAdd, mIncludePad,                mBreakStrategy, mHyphenationFrequency,                getKeyListener() == null ? effectiveEllipsize : null, ellipsisWidth);    }    //省略一大段代码,其中包括另外两个Layout类的实例化,BoringLayout和StaticLayout    return result;}

在我一番寻找过后,发现mLayout就是在这里被创建的,我省略了另外两个Layout子类创建的代码,BoringLayout和StaticLayout,其实这三个类都是直接调用了它们父类Layout的draw()方法,而draw()类又调用了drawText()方法进行文本绘制,所以,我们还是直接进drawText()方法吧:

Layout:段落格式计算

public void drawText(Canvas canvas, int firstLine, int lastLine) {    TextLine tl = TextLine.obtain();    // Draw the lines, one at a time.    // The baseline is the top of the following line minus the current line's descent.    for (int lineNum = firstLine; lineNum <= lastLine; lineNum++) {        //这里省略了一些段落格式的计算,AlignmentSpan,LeadingMarginSpan都是在这里        Alignment align = paraAlign;        if (align == Alignment.ALIGN_LEFT) {            align = (dir == DIR_LEFT_TO_RIGHT) ?                Alignment.ALIGN_NORMAL : Alignment.ALIGN_OPPOSITE;        } else if (align == Alignment.ALIGN_RIGHT) {            align = (dir == DIR_LEFT_TO_RIGHT) ?                Alignment.ALIGN_OPPOSITE : Alignment.ALIGN_NORMAL;        }        int x;        if (align == Alignment.ALIGN_NORMAL) {            if (dir == DIR_LEFT_TO_RIGHT) {                x = left + getIndentAdjust(lineNum, Alignment.ALIGN_LEFT);            } else {                x = right + getIndentAdjust(lineNum, Alignment.ALIGN_RIGHT);            }        } else {            int max = (int)getLineExtent(lineNum, tabStops, false);            if (align == Alignment.ALIGN_OPPOSITE) {                if (dir == DIR_LEFT_TO_RIGHT) {                    x = right - max + getIndentAdjust(lineNum, Alignment.ALIGN_RIGHT);                } else {                    x = left - max + getIndentAdjust(lineNum, Alignment.ALIGN_LEFT);                }            } else { // Alignment.ALIGN_CENTER                max = max & ~1;                x = ((right + left - max) >> 1) +                        getIndentAdjust(lineNum, Alignment.ALIGN_CENTER);            }        }        paint.setHyphenEdit(getHyphen(lineNum));        Directions directions = getLineDirections(lineNum);        if (directions == DIRS_ALL_LEFT_TO_RIGHT && !mSpannedText && !hasTab) {            // XXX: assumes there's nothing additional to be done            canvas.drawText(buf, start, end, x, lbaseline, paint);        } else {            tl.set(paint, buf, start, end, dir, directions, hasTab, tabStops);            tl.draw(canvas, x, ltop, lbaseline, lbottom);        }        paint.setHyphenEdit(0);    }}

在这里Layout就已经计算好了每一行的段落格式,前面空多少、居中还是靠右,而具体的文字显示样式则交给了TextLine类来处理。

TextLine:文本绘制

private float handleText(TextPaint wp, int start, int end,        int contextStart, int contextEnd, boolean runIsRtl,        Canvas c, float x, int top, int y, int bottom,        FontMetricsInt fmi, boolean needWidth, int offset) {    //...    if (c != null) {        //...        //文字背景        if (wp.bgColor != 0) {            int previousColor = wp.getColor();            Paint.Style previousStyle = wp.getStyle();            wp.setColor(wp.bgColor);            wp.setStyle(Paint.Style.FILL);            c.drawRect(x, top, x + ret, bottom, wp);            wp.setStyle(previousStyle);            wp.setColor(previousColor);        }        if (wp.underlineColor != 0) {            // kStdUnderline_Offset = 1/9, defined in SkTextFormatParams.h            float underlineTop = y + wp.baselineShift + (1.0f / 9.0f) * wp.getTextSize();            int previousColor = wp.getColor();            Paint.Style previousStyle = wp.getStyle();            boolean previousAntiAlias = wp.isAntiAlias();            wp.setStyle(Paint.Style.FILL);            wp.setAntiAlias(true);            wp.setColor(wp.underlineColor);            c.drawRect(x, underlineTop, x + ret, underlineTop + wp.underlineThickness, wp);            wp.setStyle(previousStyle);            wp.setColor(previousColor);            wp.setAntiAlias(previousAntiAlias);        }        //文字绘制,内部直接调用canvas的drawTextRun()        drawTextRun(c, wp, start, end, contextStart, contextEnd, runIsRtl,                x, y + wp.baselineShift);    }    return runIsRtl ? -ret : ret;}

就在这里TextView把将要显示的文本绘制到了canvas上,可能又有细心的同学发现了,这TextPaint的各项参数又是怎么来的呢?这我们就要回到调用它的方法handleRun()寻找答案了:

private float handleRun(int start, int measureLimit,        int limit, boolean runIsRtl, Canvas c, float x, int top, int y,        int bottom, FontMetricsInt fmi, boolean needWidth) {    //...    for (int i = start, inext; i < measureLimit; i = inext) {        for (int j = i, jnext; j < mlimit; j = jnext) {            //...            wp.set(mPaint);            for (int k = 0; k < mCharacterStyleSpanSet.numberOfSpans; k++) {                //...                CharacterStyle span = mCharacterStyleSpanSet.spans[k];                //关键是这句,调用对应Style的updateDrawState()方法,直接设置TextPaint属性                span.updateDrawState(wp);            }            x += handleText(wp, j, jnext, i, inext, runIsRtl, c, x,                    top, y, bottom, fmi, needWidth || jnext < measureLimit, offset);        }    }    return x - originalX;}

是不是有种拨开云雾见青天的感觉,至此TextView使用SpannableString绘制富文本的整个流程就呈现在大家眼前了。

使用Html绘制富文本

有同学就要说了,不是说还有Html类的解析吗?其实Html内部还是将文本转换成了Spannable,原理都是相同的,我这里摘一段大家看一下:

if (tag.equalsIgnoreCase("strong")) {    end(mSpannableStringBuilder, Bold.class, new StyleSpan(Typeface.BOLD));} else if (tag.equalsIgnoreCase("b")) {    end(mSpannableStringBuilder, Bold.class, new StyleSpan(Typeface.BOLD));} else if (tag.equalsIgnoreCase("em")) {    end(mSpannableStringBuilder, Italic.class, new StyleSpan(Typeface.ITALIC));} else if (tag.equalsIgnoreCase("cite")) {    end(mSpannableStringBuilder, Italic.class, new StyleSpan(Typeface.ITALIC));} else if (tag.equalsIgnoreCase("dfn")) {    end(mSpannableStringBuilder, Italic.class, new StyleSpan(Typeface.ITALIC));} else if (tag.equalsIgnoreCase("i")) {    end(mSpannableStringBuilder, Italic.class, new StyleSpan(Typeface.ITALIC));} else if (tag.equalsIgnoreCase("big")) {    end(mSpannableStringBuilder, Big.class, new RelativeSizeSpan(1.25f));} else if (tag.equalsIgnoreCase("small")) {    end(mSpannableStringBuilder, Small.class, new RelativeSizeSpan(0.8f));}

这也解答了之前说的,为什么不同的标签却会产生相同效果的疑问。