Android文字显示


显示文本信息可能是许多Android应用程序中最基本,最重要的部分。 本文将讨论TextView。 从“ Hello World”开始的每个开发人员都经常遇到此用户界面元素。 有时,在处理文本时,您必须考虑实现各种设计解决方案或在渲染屏幕时提高性能。


我将讨论TextView设备以及使用它的一些技巧。 关键提示来自过去的Google I / O报告。


引擎盖下的TextView


为了在Android中渲染文本,引擎盖下使用了一大堆不同的库。 它们可以分为两个主要部分-Java代码和本机代码:



Java代码本质上是应用程序开发人员可以使用的Android SDK的一部分,并且可以将其中的新功能移植到支持库中。


TextView核心本身是用C ++编写的,这限制了从新版本的操作系统向实现该功能的支持库的移植。 核心是以下库:


  • Minikin用于按音节测量文本,换行符和单词的长度。
  • ICU提供Unicode支持。
  • HarfBuzz为Unicode字符找到字体中的相应图形元素(字形)。
  • FreeType生成字形的位图。
  • Skia是绘制2D图形的引擎。

测量文本长度和换行符


如果将行传递到在TextView内部使用的Minikin库,则它确定的第一件事是该行包含哪些字形:



从本示例中可以看到,将带有字形的Unicode字符匹配并不总是一对一的:这里,一次3个字符将对应一个ffi字形。 另外,值得注意的是,可以在各种系统字体中找到必要的字形。


仅在系统字体中找到字形可能会导致困难,尤其是如果图标或表情符号是通过字符显示的,并且应该将来自不同字体的字符合并在一行中。 因此,从Android Q(29)开始 ,可以创建应用程序随附的自己的字体列表。 此列表将用于搜索字形:


 textView.typeface = TypeFace.CustomFallbackBuilder( FontFamily.Builder( Font.Builder(assets, “lato.ttf”).build() ).build() ).addCustomFallback( FontFamily.Builder( Font.Builder(assets, “kosugi.ttf”).build() ).build() ).build() 

现在,使用CustomFallbackBuilder当将字符与字形匹配时,SDK将按顺序CustomFallbackBuilder指定的字体系列,如果找不到,则搜索将继续使用系统字体(并通过setSystemFallback()方法可以指定首选的系统字体系列)。 CustomFallbackBuilder对字体系列的数量有限制-您最多可以添加64种字体。


Minikin库将字符串拆分为单词并测量单个单词。 为了加快工作速度,从Lollipop(21)开始 ,使用系统LRU单词缓存。 这样的缓存可带来巨大的性能提升:对一个缓存的单词的Paint.measureText()调用将平均花费其首次计算其大小的时间的3%。



如果文本不适合指定的宽度,则Minikin会在文本中排列换行符和单词。 从棉花糖(23)开始,您可以通过为TextView指定breakStrategybreakStrategy特殊属性来控制其行为。


使用breakStrategy=simple值时breakStrategy=simple库将简单地按顺序排列连字符,并遍历文本:一旦行不再适合,则将连字符放在最后一个单词的前面。



balanced值时balanced库将尝试进行换行,以使行的宽度对齐。



high_quality的行为几乎与balanced相同,但有一些区别(其中之一:倒数第二行,连字符不仅可以是单独的单词,而且可以是音节的单词)。


hyphenationFrequency属性使hyphenationFrequency可以控制音节自动换行的策略。 值none不会自动断字, normal会使断字的频率降低,而full会使用最大的单词数。



文本呈现性能取决于所选标志(在Android P(28)上测得):



鉴于性能受到很大影响,从版本Q(29)AppCompat 1.1.0开始,Google开发人员决定默认关闭连字符。 如果自动换行在应用程序中很重要,则现在需要显式启用它。


使用自动换行时,必须考虑到操作系统中当前选择的语言会影响库的操作。 根据语言,系统将选择带有传输规则的特殊词典。


文字样式


有几种在Android中设置文字样式的方法:


  • 适用于整个TextView元素的单一样式
  • 多种样式(多种样式) -可以同时在段落或单个字符级别将多种样式应用于文本。 有几种方法可以做到这一点:
    • 在画布上绘制文字
    • html标签
    • 特殊标记元素-跨度

单一样式意味着在TextView标记中使用XML样式或XML属性。 在这种情况下,系统将按以下顺序应用资源中的值:TextAppearance,主题(主题),默认样式(Default样式),应用程序中的样式,并且最高优先级是View属性的值。


使用资源是一个相当简单的解决方案,但是不幸的是,它不允许您将样式应用于文本的某些部分。


HTML标签是另一种简单的解决方案,它提供的功能包括使单个单词加粗,斜体,甚至突出显示列表中带有文本点的列表。 开发人员所需要做的只是调用Html.fromHtml()方法,该方法会将标记的文本转换Html.fromHtml()标记的文本。 但是此解决方案功能有限,因为它只能识别html标记的一部分,并且不支持CSS样式。


 val text = "My text <ul><li>bullet one</li><li>bullet two</li></ul>" myTextView.text = Html.fromHtml(text) 

可以将各种样式化TextView的方法组合在一起,但值得记住的是特定方法的优先级,这会影响最终结果:



另一种方法-在画布上绘制文本-使开发人员可以完全控制文本输出:例如,您可以沿曲线绘制文本。 但是,根据要求,这种解决方案可能很难实施,超出了本文的范围。


跨度


TextView使用跨度来微调样式。 使用跨度,您可以更改字符范围的颜色,使文本成为链接的一部分,更改文本的大小,在段落前面绘制点等。


可以区分以下几类跨度:


  • 字符跨度 -在字符串的字符级别应用。
    • 外观影响 -请勿更改文字大小。
    • 度量标准影响 -调整文本大小。
  • 段落跨度 -在段落级别应用。


Android框架具有接口和抽象类,这些接口和抽象类具有在onMeasure()和呈现TextView期间调用的方法,这些方法可跨度访问TextPaintCanvas等较低级别的对象。 使用跨度,Android框架检查此对象实现的接口以调用必要的方法。



android框架定义了大约20个跨度,因此在制作自己的跨度之前,最好检查一下SDK是否合适。


外观与指标影响范围


跨度的第一类影响字符串中字符的外观:字符颜色,背景颜色,带下划线或划线的字符等。 这些范围实现UpdateAppearance接口并继承自CharacterStyle类,该类提供对TextPaint对象的访问。



影响跨度的度量标准会影响文本和布局的大小,因此,使用这种跨度不仅需要重绘TextView,还需要调用onMeasure() / onLayout() 。 这些范围通常是从MetricAffectingSpan类继承的,而该类是从上述CharacterStyle继承的。



字符与段落影响跨度


段落跨度会影响整个文本块:它可以更改对齐方式,缩进甚至在段落的开头插入一个点。 此类跨度应从ParagraphStyle类继承,并从ParagraphStyle的开头到结尾完全插入到文本中。 如果范围不正确,则跨度将不起作用。



在Android上,段落被认为是用换行符( \n )分隔的文本的一部分。



编写跨度


在编写自己的跨度时,您需要确定跨度将影响什么,以便选择要从哪个类继承:


  • 在字符级别上影响文本-> CharacterStyle
  • 在段落级别影响文本-> ParagraphStyle
  • 影响文本视图→ UpdateAppearance
  • 影响文字大小- UpdateLayout

这是更改字体的跨度示例:


 class CustomTypefaceSpan(private val font: Typeface?) : MetricAffectingSpan() { override fun updateMeasureState(textPaint: TextPaint) = update(textPaint) override fun updateDrawState(textPaint: TextPaint) = update(textPaint) fun update(textPaint: TextPaint) { textPaint.apply { val old = typeface val oldStyle = old?.style ?: 0 val font = Typeface.create(font, oldStyle) typeface = font //    } } } 

想象一下,我们想创建自己的跨度以突出显示代码块,为此,我们将编辑先前的跨度-添加字体后,我们还将添加文本背景色的更改:


 class CodeBlockSpan(private val font: Typeface?) : MetricAffectingSpan() { … fun update(textPaint: TextPaint) { textPaint.apply { //    … bgColor = lightGray //    } } } 

将跨度应用于文本:


 //    span spannable.setSpan(CodeBlockSpan(typeface), ...) 

但是,您可以通过组合两个跨度来获得完全相同的结果:从Android框架中获取我们之前的CustomTypefaceSpanBackgroundColorSpan


 //    spannable.setSpan(BackgroundColorSpan(lightGray), ...) //   spannable.setSpan(CustomTypefaceSpan(typeface), ...) 

这两种解决方案将有所不同。 事实是,自写跨度无法实现Parcelable接口,这与系统接口不同。


当通过Intent或剪贴板传输风格化的行时,如果跨度为自写标记,则不会保存。 当使用框架的跨度时,标记将保留。



在文本中使用跨度


框架中有两个用于样式化文本的接口: SpannableSpannable (分别具有不变的标记和可变的标记)和三个实现: SpannedString (不变的文本), SpannableString (不变的文本)和SpannableStringBuilder (可变文本)。


可变文字可变标记
跨度字符串没有啦没有啦
可扩展字符串没有啦是的
Spannablestring构建器是的是的

例如, SpannableStringBuilder用于需要更改文本的EditText中。


您可以使用以下方法向行添加新的跨度:


setSpan(Object what, int start, int end, int flags)


跨度通过第一个参数传递,然后指示文本中的索引范围。 最后一个参数是可以控制的,插入新文本时跨度的行为是什么:跨度是否会扩展到在起点或终点插入的文本(如果在中间插入新文本,则无论标志值如何,该跨度都会自动应用于该文本) 。


上面列出的类不仅在语义上有所不同,而且在内部排列方面也有所不同: SpannedStringSpannableString使用数组存储跨度,而SpannableStringBuilder使用间隔树


如果根据跨度的数量测试渲染文本的速度,您将得到以下结果:当连续使用多达250个跨度时, SpannableStringSpannableStringBuilder工作速度大致相同,但是如果标记元素超过250,则SpannableString启动输。 因此,如果任务是将样式应用于某些文本,则在选择类时,应遵循语义要求:线条和样式是否可变。 但是,如果标记需要超过250个跨度,则应始终将SpannableStringBuilderSpannableStringBuilder


检查文本中的跨度


该任务会定期出现,以检查跨度线是否具有特定跨度。 在Stackoverflow上,您可以找到以下代码:


 fun <T> hasSpan(spanned: Spanned, clazz: Class<T>): Boolean { val spans: Array<out T> = spanned.getSpans(0, spanned.length, clazz) return spans.isNotEmpty() } 

这样的解决方案可以工作,但是效率很低:您必须遍历所有范围,检查每个范围是否属于传递的类型,将结果收集到数组中,最后只检查数组是否为空。


一个更有效的解决方案是使用nextSpanTransition()方法:


 fun <T> hasSpan(spanned: Spanned, clazz: Class<T>): Boolean { val limit = spanned.length return spanned.nextSpanTransition(0, limit, clazz) < limit } 

各种语言资源中的文本标记


当您想借助各种字符串资源中的标记来突出显示特定单词时,可能会出现这样的任务。 例如,我们需要在英语版本中突出显示“文本”一词,在西班牙语中突出显示“ texto”一词


 <!-- values-en/strings.xml --> <string name="title">Best practices for text in Android</string> <!-- values-es/strings.xml --> <string name=”title”>Texto en Android: mejores prácticas</string> 

例如,如果您需要简单的内容以粗体突出显示该单词,则可以使用常用的html标签( <b> )。 在用户界面中,您只需要在TextView中设置字符串资源:


 textView.setText(R.string.title) 

但是,如果您需要更复杂的内容(例如更改字体),则无法再使用html。 解决方案是使用特殊的<annotation> 。 该标记允许您在xml文件中定义任何键值对。 当我们从资源中提取字符串时,这些标签会自动转换为Annotation范围,并在文本中带有相应的键和值。 之后,您可以解析文本中的注释列表并应用必要的跨度。


假设我们需要使用CustomTypefaceSpan更改字体。


添加一个标签,并为其定义一个“字体”键和一个值-我们要使用的字体类型为“ title_emphasis”

 <!-- values-en/strings.xml --> <string name="title">Best practices for <annotation font=”title_emphasis”>text</annotation> in Android</string> <!-- values-es/strings.xml --> <string name=”title”><annotation font=”title_emphasis”>Texto</annotation> en Android: mejores prácticas</string> 

从资源中拉出字符串,使用“ font”键找到注释并排列跨度:


 //      SpannedString,     span' val titleText = getText(R.string.title) as SpannedString //    val annotations = titleText.getSpans(0, titleText.length, Annotation::class.java) //     SpannableString //      val spannableString = SpannableString(titleText) //     for (annotation in annotations) { //     "font" if (annotation.key == "font") { val fontName = annotation.value //   ,     if (fontName == "title_emphasis") { val typeface = getFontCompat(R.font.permanent_marker) //  span    ,    spannableString.setSpan( CustomTypefaceSpan(typeface), titleText.getSpanStart(annotation), titleText.getSpanEnd(annotation), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) } } } styledText.text = spannableString 


上面已经提到,Android框架之外的跨度无法实现Parcelable ,而是通过Intent进行传输。 但这不适用于实现Parcelable注释。 因此,您可以通过Intent传递带注释的字符串,并通过安排跨度以完全相同的方式进行解析。


文本如何放置在TextView中


TextView不仅可以显示文本,还可以显示图片。 您还可以在文本前面设置各种缩进。 在幕后,这样做可以使TextView创建一个子类Layout,它直接负责显示文本。 这是一个具有三个实现的抽象类;通常,除非您编写自己的控件,否则不必直接使用它们:


  • BoringLayout用于简单的文本,不支持换行符 ,RTL等,但是它是最轻量的。 如果文本符合所有限制,则TextView将使用它。
  • 在其他情况下,在TextView中使用StaticLayout
  • DynamicLayout用于EditText中的可变文本。

布局有许多方法可以让您知道所显示文本的各种参数:行的坐标,基线,行中文本的开头和结尾的坐标等。 (更多详细信息可以在文档中找到)


这样的方法可能非常有用。 例如,一些开发人员面临着将部分文本提取为圆角矩形,并试图通过不适用于解决此问题的跨度找到其解决方案的任务。



但是Layout类的方法可以解决。 这是一个示例解决方案:


使用注释,我们选择应在矩形中圈出的单词。


然后为所有文本环绕情况创建4个可绘制资源,这些资源应放在矩形中:



接下来,如上所述,我们在文本中找到了所需的注释。 现在,我们有了此类注释的开始和结束的索引。 通过Layout方法,您可以找到带注释的文本开始和结束的行号:


 val startLine = layout.getLineForOffset(spanStartIndex) val endLine = layout.getLineForOffset(spanEndIndex) 

接下来,您必须绘制一个或多个矩形。 考虑一个简单的情况,即文本的带注释的部分显示在一行上,那么我们只需要一个带有四个圆角的矩形。 定义其坐标并绘制:


 ... if (startLine == endLine) { val lineTop = layout.getLineTop(startLine) //    val lineBottom = layout.getLineBottom(startLine) //    val startCoor = layout.getPrimaryHorizontal(spanStartIndex).toInt() //    val endCoor = layout.getPrimaryHorizontal(spanEndIndex).toInt() //    //   drawable.setBounds(startCoor, lineTop, endCoor, lineBottom) drawable.draw(canvas) ... 

从该示例中可以看到,布局在显示的文本上存储了许多有用的信息,这可以帮助实现各种非标准任务。


TextView性能


与任何View一样,TextView在显示时经历三个阶段: onMeasure()onLayout()onDraw() 。 同时,与其他两种方法不同, onMeasure()花费的时间最多:此时,重新创建Layout类并计算文本大小。 因此,更改文本大小(例如,更改字体)需要进行大量工作。 更改文本的颜色将更加轻巧,因为它只需要调用onDraw() 。 如上所述,系统具有计算出的大小的全局字缓存。 如果单词已经在高速缓存中,则onMeasure()调用onMeasure()将会花费完整计算所需时间的11-16%。


文字加速


2015年,Instagram开发人员使用全局缓存加快了照片评论的显示速度。 想法是在将文本显示在屏幕上之前先对其进行虚拟绘制,从而“预热”系统缓存。 当需要显示文本时,由于文本已经过测量并且位于缓存中,因此用户可以更快地看到它。


Android P(28)开始 ,Google开发人员已在API中添加了预先在后台线程中执行文本大小测量阶段的功能PrecomputedText (以及从Android I(14)开始的API的PrecomputedTextCompat )。 使用新的API,90%的工作将在后台线程中完成。


一个例子:


 // UI thread val params: PrecomputedText.Params = textView.getTextMetricsParams() val ref = WeakReference(textView) executor.execute { // background thread val text = PrecomputedText.create("Hello", params) val textView = ref.get() textView?.post { // UI thread val textView = ref.get() textView?.text = text } } 

显示大文字


如果您需要显示大文本,则不要立即将其传输到TextView。 否则,该应用程序可能会停止平稳运行或完全冻结,因为它将在主线程上进行大量工作以显示巨大的文本,用户甚至可能无法滚动到末尾。 解决方案是将文本拆分为多个部分(例如段落),然后在RecyclerView中显示各个部分。 为了进一步提高速度,您可以使用PrecomputedText预计算文本块的大小。


为了促进PrecomputedText在RecyclerView中的嵌入,Google开发人员为PrecomputedTextCompat.getTextFuture()AppCompatTextView.setTextFuture()制作了特殊的方法:


 fun onBindViewHolder(vh: ViewHolder, position: Int) { val data = getData(position) vh.textView.setTextSize(...) vh.textView.setFontVariationSettings(...) //    val future = PrecomputedTextCompat.getTextFuture( data.text, vh.textView.getTextMetricsParamsCompat(), myExecutor ) //  future  TextView,      onMeasure() vh.textView.setTextFuture(future) } 

RecyclerView , , , .


, getTextFuture() (, ), , , getTextFuture() , , TextView.


, TextView


TextView.setText() :


 if (type == SPANNABLE || movementMethod != null) { text = spannableFactory.newSpannable(spannable) //  } else { text = new SpannedString(spannable) //  } 

span' TextView, setText() , .


, . TextView , -, . , . , , TextView spannableFactory :


 class MySpannableFactory : Spannable.Factory() { override fun newSpannable(source: CharSequence): Spannable { return source as? Spannable ?: super.newSpannable(source) } } textView.spannableFactory = MySpannableFactory() 

textView.setText(spannable, BufferType.SPANNABLE) , .


Google span' RecyclerView, .


TextView, span, setText() . TextView span. TextView spannable- span', :


 val spannable = textView.getText() as Spannable val span = CustomTypefaceSpan(span) spannable.setSpan(span, ...) 

span, TextView, TextView . , invalidate() , – requestLayout() :


 val spannable = textView.getText() as Spannable val span = CustomTypefaceSpan(span) spannable.setSpan(span, ...) span.setTypeface(anotherTypeface) textView.requestLayout() // re-measure and re-draw // or textView.invalidate() // re-draw 


TextView . autoLink . autoLink=”web” TextView URL URLSpan . , SDK setText() :


 spannable = new SpannableString(string); Matcher m = pattern.matcher(text); while (...) { //      String utl = … URLSpan span = new URLSpan(url); spannable.setSpan(span, ...); } 

UI , autoLink=”web” RecyclerView. . LinkifyCompat :


 //  ,       background thread val spannable = SpannableString(string) LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS) //   RecyclerView override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.textView.setText(spannable, BufferType.SPANNABLE) // ... } 

autoLink map – ( all ). . , WebView, ! SDK Linkify.gatherMapLinks() , :


 while ((address = WebView.findAddress(string)) != null) { ... } 

WebView TODO SDK:


 public static String findAddress(String addr) { // TODO: Rewrite this in Java so it is not needed to start up chromium // Could also be deprecated return getFactory().getStatics().findAddress(addr); } 

? Smart Linkify, Android P (28) , , . :


 // UI thread val text: Spannable = … val request = TextLinks.Request.Builder(text) val ref = WeakReference(textView) executor.execute { // background thread TextClassifier.generateLinks(request).apply(text) val textView = ref.get() textView?.post { // UI thread val textView = ref.get() textView?.text = text } } 

Linkify, . toolbar , Google .


Smart Linkify : , .



Magnifier


Android P (28) , – Magnifier, . .



TextView, EditText WebView, : API .


结论


Android , , :


  • , TextView (, EditText)

- , Google I/O'19 “Best Practices for Using Text in Android” .



有用的链接




报告书


Source: https://habr.com/ru/post/zh-CN461787/


All Articles