Android TextView 在雪球中的应用

前端 @ 雪球财经

作者:卢磊

图片

雪球 APP 是一个典型的 UGC 社区,在我们的应用中,有着丰富的信息流和长文供用户阅读,同时也有大量的用户每天使用编辑器发帖、发专栏文章。在这样一个阅读比重非常大的 Android 应用中,文本内容的展示非常重要,优雅的排版和快速的渲染能给用户带来非常好的阅读感受。所以掌握 Android 系统中提供的各种类与接口、扩展这些基础类库,对于完成设计师的排版要求,开发完善雪球社区相关的功能非常重要。

这篇文章简单介绍以 TextView 为核心的相关控件,同时总结一下雪球 APP 在开发中碰到的问题以及解决这些问题的思路。

涉及到的类库

  • TextView 及其子类附属工具类

  • CharSequence / Spanned / Spannable / SpannableString

  • CharacterStyle / ParagraphStyle 以及其各种实现

  • Html 与 Span 之间的转换

TextView 如何绘文本

TextView 是 Android 应用开发过程中最常用的控件,也是最复杂的控件(也许是之一),从源头上了解 Texview 是如何渲染出一段文本,有助于我们更优雅的使用系统提供的 API 以及工具类来完成产品和设计师同学提出的需求和设计。


TextView 中文字的坐标系以及显示范围

首先我们来看一下下面这张图片,通过它来了解如何确定一行文字在屏幕上绘制的范围以及如何定位这行文字的坐标。

图片

了解过 View 的绘制流程的人都知道,在 Android 系统中,显示在屏幕上的图文,绝大部分都是依靠 Canvas 和 Paint 来进行绘制(这里不谈论 OpenGL),具体对于 TextView 而言,使用的是 Paint 的子类 TextPaint。对于一行文字来说,确定他在屏幕中显示的位置,由以下五条线来确定:

  • Baseline - 一行文字基线(重心)。

  • Top - 给定字体的文字在基线之上的最大距离。

  • Ascent - 单行文字在基线之上的推荐距离。

  • Descent - 单行文字在基线之下的推荐距离。

  • Bottom - 给定字体文本在基线之下的最大距离。

而超过一行的文字,还有一个距离叫做 Leading 来保证行与行之间的距离。

  • Leading - 两行文字中间的空隙。

通过上面的解释可以知道,Baseline 是一行文字的重心,一行文字始终显示在Ascent 和 Decent 两条线之间,而这一行最远的距离则是在 Top 和 Bottom 之间。这样设计使得各种类型的文字都能美观的绘制在屏幕之上。当文字超过两行以上时,Leading 的作用就凸显出来了,它能够使得行之间有一定的空隙,多行文本的显示就更加自然(看下图)。

图片

TextView 绘制流程

对于 View 以及其子类的绘制流程,无非是经过 measure 、layout、draw 三个过程。但是 TextView 的绘制又有着其特殊的方式,他的绘制都交由 Layout 和TextLine 两个类以及它们的子类来完成。

当 APP 开发着敲下 setText 这个方法时,经过一系列的分析和计算之后,在TextView 中显示的内容就已经被确定。BingLayout 负责绘制单行文本,DynamicLayout 负责绘制带有 Span 的标记文本,StaticLayout 则负责绘制多行不带 Span 的纯文本。前面说到,虽然对于开发者而言 TextView 是一个单独的控件,但是如果再往细看下去,它是由一行一行的文字组合而成,经过计算,每一行显示哪些文本得以确认,而最终的显示则是由 TextLine 来进行绘制。联系到上面所说的坐标系,对于每一行文本,我们就能够理解 Baseline 等几条范围线的重要性,通过它们,TextLine 可以正确的将每一行文字绘制到屏幕正确的位置,同时可以保证行与行之间保留合适的空隙,呈现在用户视野中是优雅的阅读感受良好的效果。

先从代码中来看一看上述的这些过程是如何实现(由于代码太多,这里摘取了部分比较关键的地方进行说明 )。

图片

上面的一段代码功能在于,确保 TextView 中 Layout 正确的被创建以及修改,同时通过 Layout 来计算出高度和宽度。那么接下来看一看这一部分开始时所说的如何根据文本类型来创建不同的 Layout:

图片

由于 layout 过程并无特殊之处,这里跳过 layout 直接看一看 draw 的流程。前面已经提到,TextView 由 Layout 负责绘制,先来看一下代码:

图片

在 Layout 中,通过两部进行绘制,一步绘制背景,一步绘制 Text:

图片

当 Layout 计算出第一行和最后一行之后,开始对 TextView 进行文本绘制。继续分析代码,可以看出通过一个循环,使用 TextLine 将文本一行一行绘制出来。这里需要关注的是,每一行文字开始和结束的位置计算,在 Andoird 系统中,通常会碰到的一个问题是文本某一些行的右侧会留有很大的空白,没有类似 CSS 中 break all 这种参数使得文本能左右都进行对齐,我们其实是可以通过修改 Textline 中 start 和 end 的参数使得文本左右对齐详细代码。

图片

仔细观察上面代码中 tl.draw(canvas, x, ltop, lbaseline, lbottom) 这一行,我们清楚的看到每一行绘制时,会将前面所讲到的坐标 top、baseline、bottom 传递到 Textline 中,辅助其在正确的位置上绘制出这一行文字。

总结下来 Layout 对 Textline 进行两步安排,一步设置其显示文本的范围和属性,一步告诉坐标,让其进行绘制。

进入到 Textline 之中,它的工作复杂而单一:计算完毕有交给 Canvas 进行绘制。由于这里的代码比较复杂,建议仔细阅读整个流程,所以不再进行摘取 完整代码。

小结

前面从代码层面分析了 setText 之后,整个控件经历的处理,单单从代码来看非常复杂,但是我认为对于这个最常用的控件,只有深入了解了绘制流程,才能做出更好的扩展来满足各种各样的业务需求。 

下面这种图简单的总结了一下流程,标注了每一个部分具体所做的工作。

图片

可被标记的文本

**前面分析 TextView 的绘制流程中提到,对于含有 Spanned 类型的文本需要使用 DynamicLayout 来进行绘制。需要了解到的是,对于实现了 Spanned 接口的类及其子类都属于 Spanned 类型。这里把它们称为标记文本,通过下面这个图可以清晰的看出它们的继承关系。

图片

在 Android 开发中重点关注下面几个类型即可:

  • Spanned - 用来表示一段文本中含有被标记的对象的接口。

  • Spannable - 用来表示一段文本中含有被标记的对象,该标记对象也可被detach。

  • SpannedString - 用来表示一段文本的内容中含有被标记对象,该文本不可改变。

  • SpannableStringBuilder - 用来表示一段文本内容和标记都可以改变。

  • Editable - 用来表示一段文本内容和标记都可以改变。

从上面的继承关系图以及简介可以看出,开发者实际操作的有三种类型可标记文本,对于内容确定的文本,构造 SpannedString 对象并在合适的位置设置标记,而对于文本内容变化的,需要使用 SpannableStringBuilder,而 Editable 几乎特殊被使用在 TextView 的子类 EditText 中,它们都提供了 append、delete、insert 等方式来进行变化标记的文本。

将它们称为可被标记的文本,原因在于,我们可以针对此类型的文本任意位置设置一系列的标记。举例来讲,我们可以将 start 到 end 的位置标记为粗体,同样标记为斜体,对任意其他地方标记前景色、标记背景......不仅如此,我们还可以对同一个位置做出不同的标记,例如同时加上颜色、变化字体。设置标记统一通过下面的方法:

图片

一个小例子

图片

结果图如下:

图片

使用多个 Span:

图片

结果图如下

图片

这个小例子主要想说明三点:

  • 根据业务需要,选择合适的 Spanned文 本。

  • 标记是一个具体的对象,在同一个可标记文本中,每一个标记对象只能针对一个地方进行标记,重复使用会导致只有最后一个生效。

  • Editable 对象更新之后,会更新UI,不需要重新设置。


CharacterStyle / ParagraphStyle

前面提到 TextView 绘制的流程以及 Spannd 以下的各种可标记文本,当我们需要将文本中的部分文字标上标记文本时,需要对相应的部分设置具体的标记。所有的标记类型源于四个类型的组合,它们分别是 CharacterStyle、ParagraphStyle、UpdateAppearance、UpdateLayout。 

四者所影响的范围分别是:

  • 如果一个 Span 影响字符级的文本格式,则继承 CharacterStyle 包括我们常用的 ForegroundColorSpan、BackgroundColorSpan 等。

  • 如果一个 Span 影响段落层次的文本格式,则实现 ParagraphStyle 包括了AlignmenttSpan、LeadingMarginSpan、 LineBackgroundSpan 等。

  • 如果一个 Span 修改字符级别的文本外观,则实现 UpdateAppearance 包括了 ClickableSpan

  • 如果一个 Span 修改字符级文本度量大小,则实现 UpdateLayout 包括了 AbsoluteSizeSpan

继承关系

下图为四个基础类型以及其对应的子类型

图片

常用的子类

**首先简单介绍一下几个最常用的标记类型:

图片

示例图:

图片图片图片

Html 文本的 Native 转换

对于大部分应用而言,长篇的 Html 文本通常会使用 Webview 来进行渲染排版,但是对于一些简单的 Html 文本,我们使用 TextView 一样可以展示的非常好,这里涉及到 Html 文本与 Native 的转换。简而言之,就是将一个 String 类型的 Html 文本转换为 Spanned 的可标记文本,将样式转换成各种各样的 Spanned,让 TextView 可以正确显示出来。 

转换通常使用 Html 这个工具类中的 fromHtml 方法处理。查看代码:

图片

再看一下下面的实际使用例子,下面是一个简单的 Html 文本。

图片

可以看到,里面包含有常见的 Html 标签:Br、A 、Img。进过下面的代码处理:

图片

转换之后 Br 变成 \n,A 变成 URLSpan, Img 变成 ImageSpan,效果如图所示:

图片

雪球 APP 中一些实践

前面介绍了 TextView 绘制的流程、可标记文本 Span 以及常见的标记类型。三者相互合作使得 TextView 可以展现出丰富的文本效果。下面简单介绍一下雪球 APP 中的一些实践。

编辑器添加和删除
发帖时,一旦有 @ 某个人 或者有插入一个超链接,碰到需要编辑删除的时候,总是需要同时将这个好链接一同删去。这时候,利用 Span 来处理,会非常方便。

先来看一下插入一个标记文本

图片

实现起来非常简单,我们在增加一个超链接的时候,如上一节的例子所示,构造一个 Spanned 文本,然后直接 insert 到 Editable 对应的位置上,EditText 会自动更新 UI(前面已经说明原理)。

图片

当需要整体删除某个超链接时的时候,可以监听光标的位置移动,一旦监听到删除这个操作的时候,可以判断需要删除的位置是否是一个需要删除的标记文本。

图片

效果如下:

图片

在上图中,两者都是 URLSpan 的子类,拿到标记文本的起始位置,同时删除所有。另一个用处在于,当用户光标移动时候,禁止移动到标记文本的正中间,根据接近位置,重新设置移动后光标的最终位置。

信息流中对特殊文本进行标记(专栏、悬赏)

很多时候,系统提供的类型并不能满足我们的需求,这个时候我们需要扩展某些基础类型或者实现某些接口来达到预期。最开始说到,影响(或者说修改)不同层级的文本属性,需要扩展不同的基础类型。

雪球信息流中,有一个非常重要的使用场景是:将不固定长度和内容的前缀,添加到不固定长度和内容的文本之前。具体做法如下:

扩展出来一个自定义 Span,使用它来标记需要特殊显示的文本。先贴上两张效果图查看一下:

图片

图片

这里选择的方式是继承 ReplacementSpan(它继承自MetricAffectingSpan),看下面的代码:

图片

选择 ReplacementSpan 在于,我们针对文本,需要改变的是背景色的样式和文字的颜色。这里只需要重写 onDraw 方法,在特定的区域中设置设计师提出的样式要求。

信息流中对 A 标签标记

广义上来看雪球社区的信息流与使用度更广的微博、Twitter 并没有特别大的差别,都需要让用户在信息流中快速的找到人、话题,并且方便的进行交互。具体到雪球的信息流,我们需要做到的是:从信息流快速到达个人页(雪球信息流中特殊的一点是需要对付费 @ 某个用户单独区隔开来进行突出)、个股页、话题页,同时提供快速查看图片的方式。如图所示:

图片

看图需要满足三个需求,首先要区分出不同的超链接让其显示不同的颜色,其次点击超链接时需要有点击状态并且点击状态可以与连接颜色对应上,第三不同的超链接点击后跳转不同。根据需求我们选择扩展 URLSpan 来作为文本标记的类型,原因在于:URLSpan 继承自 ClickableSpan,可以解决点击事件的处理。URLSpan 中可以对 URL 进行解析,通过 URL 的类型来显示超链接的颜色,点击时候的点击状态。代码如图所示:

图片

信息流中对 ICON 标签标记(居中)

在雪球应用中,并没有使用通用的 emoji 作为小图标,取而代之的是设计师同学设计了一套符合金融投资社区语言环境的小 ICON。对于客户端开发同学而言,需要做到的是,如何让任意数量的 ICON 在一段文字中的任意位置优雅的显示出来。 

先来看一下最终需要显示的样子(如图关注红框框中的内容):

图片

先来看一下后端返回的内容,如图(这里仅截取了上面图中红框中最后几个 ICON 对应的文本)

图片

碰到的第一个问题是如何解析这些 ICON。处理方式有很多,雪球中选择的是,通过解析 Img 标签,使用 APK 中本地资源文件,将对应的 Img 标签转换成对应的 Drawable,最终通过 ImageSpan 在显示。

图片

图片

对比一下前后变化:

图片

图片

总结

TextView(这里也包括 EditText,Button)是一个非常复杂的控件,在 FrameWork 中,与其功能相关的类就有数十个之多。但由于它作为最基础的控件, APP 开发者又必须要深刻的了解它的特性,完整的掌握它的功能和属性。这篇文章的作用仅仅是做到揭开 TextView 这个控件的外衣,更深刻的学习,当然需要去阅读相关的源码。虽然代码量巨大,但是我认为值得学习。

当然除了分析 TextView 如何绘制文本,可标记文本的使用与扩展,与 TextView相关的知识还有非常多的值得探讨的地方,例如如何做到长文章的图文混排、如何做到左右对齐(TextView 每一行右侧都不留很大的空白),如何做到预加载提高渲染效率。限于篇幅有限,这里就不再讨论了。感兴趣的同学可 Google 相关知识。

参考

flavienlaurent.com/blog/2014/0…

instagram-engineering.tumblr.com/post/114508…

还有一件事

雪球的工程师团队在招聘,Java 工程师,运维开发工程师,测试开发工程师,算法工程师,有意的同学可以查看原文看看具体的职位和要求,就等你了。

分类:
Android
标签:
分类:
Android
标签: