【译】深入理解Span
这是Span开发者的一篇非常好的文章,这里翻译出来,希望大家能对Span有新的认识。 原文地址如下:https://medium.com/androiddevelopers/underspanding-spans-1b91008b97e4
Spans是一个强大的概念,Span通过提供对TextPaint和Canvas等组件的访问,允许在字符或段落级别上对文本进行样式设计和修改。我们在之前的一篇文章中谈到了如何使用Spans,哪些Spans是开箱即用的,如何轻松创建自己的Spans,以及如何测试它们。
现在让我们看看在处理文本时,可以使用哪些API来确保特定场景的最大性能。我们将探索更多关于spans的秘密,以及Android框架如何使用它们。最后,我们将看到我们如何在同一进程或进程之间传递Span,并在此基础上,当你决定创建自己的自定义Span时,需要注意那些事项。
Under the hood: how spans work
Android框架在几个类中处理文本样式和Span。TextView, EditText, 布局类(Layout, StaticLayout , DynamicLayout)和TextLine(Layout中使用的一个私有类),它取决于下面几个参数:
- 文本类型:可选择、可编辑或不可选择。
- 缓冲区类型
- TextView的LayoutParams类型
- 等等
Android框架会检查Spanned对象是否包含不同框架Span的实例,并触发不同的动作。
文本布局和绘制背后的逻辑很复杂,分布在不同的类中,在本节中,我们只能简单地介绍文本的处理方式,而且只针对某些情况。
每当一个Span发生变化时,TextView.spanChange都会检查该Span是否是UpdateAppearance、ParagraphStyle或CharacterStyle的实例,如果是,则自行失效,触发视图的新绘制。
TextLine类代表了一行有风格的文本,它特别适用于扩展CharacterStyle、MetricAffectingSpan和ReplaceSpan的Span。这是触发MetricAffectingSpan.updateMeasureState和CharacterStyle.updateDrawState的类。
管理屏幕上视觉元素中文本布局的基类是android.text.Layout。Layout以及它的两个子类StaticLayout和DynamicLayout,检查设置在文本上的Span来计算行高和布局边距。除此之外,每当DynamicLayout中显示的Span被更新时,布局会检查该Span是否为UpdateLayout Span,并为受影响的文本生成一个新的布局。
Setting text for maximum performance
根据你的需求,有几种高效的内存方式可以在TextView中设置文本。
Text set on a TextView never changes
如果你只是在TextView上设置一次文本,而从不更新,你可以直接创建一个新的SpannableString或SpannableStringBuilder实例,设置所需的Span,然后调用textView.setText(spannable)。由于你不再对文本进行处理,所以没有更多的性能需要提高。
Text style changes by adding/removing spans
让我们考虑一下这样的情况,即文本不会改变,但附着在文本上的Span会改变。例如,假设每当一个按钮被点击时,你希望文本中的一个词变成灰色。所以,我们需要在文本中添加一个新的Span。要做到这一点,很可能你会想调用textView.setText(CharSequence)两次:首先设置初始文本,然后在按钮被点击时再次调用。
一个更理想的解决方案是调用textView.setText(CharSequence, BufferType),并在点击按钮时更新Spannable对象的Span。
下面是这些方案的底层操作。
- 选项1:多次调用textView.setText(CharSequence)--次优方案 当调用textView.setText(CharSequence)时,TextView会创建一个Spannable的副本作为SpannedString,并将其作为CharSequence保存在内存中。这样做的结果是,你的文本和Span是不可改变的。因此,当你需要更新文本样式时,你将不得不创建一个新的Spannable,包含文本和Span,再次调用textView.setText,反过来,这将创建一个新的对象副本。
- 方案2:调用一次textView.setText(CharSequence,BufferType),更新一个Spannable对象--最佳方案 当调用textView.setText(CharSequence, BufferType)时,BufferType参数告诉TextView设置了什么类型的文本:静态(调用textView.setText(CharSequence)时的默认类型)、styleable/spannable文本或可编辑(EditText会使用)。由于我们处理的是可样式化的文本,我们可以调用下面的代码。
textView.setText(spannableObject, BufferType.SPANNABLE)
在这种情况下,TextView不会再创建一个SpannedString,但它会在Spannable.Factory类型的成员对象的帮助下,创建一个SpannableString。因此现在,TextView保存的CharSequence副本具有可变的标记和不可变的文本。
为了更新Span,我们首先要得到文本为Spannable,然后根据需要更新Span。
代码语言:javascript复制// if setText was called with BufferType.SPANNABLE
textView.setText(spannable, BufferType.SPANNABLE)// the text can be cast to Spannable
val spannableText = textView.text as Spannable// now we can set or remove spans
spannableText.setSpan(
ForegroundColorSpan(color),
8, spannableText.length,
SPAN_INCLUSIVE_INCLUSIVE)
使用这种方式,我们只创建初始的Spannable对象。TextView将持有它的副本,但当我们需要修改它时,我们不需要创建任何其他对象,因为我们将直接使用TextView保存的Spannable文本实例。但是,TextView只会被告知添加/删除/重新定位Span的情况。如果你改变了Span的内部属性,你将不得不调用invalidate()或requestLayout(),这取决于改变的Span的类型。
Text changes (reusing TextView)
比方说,我们想重用一个TextView并多次设置文本,就像在RecyclerView.ViewHolder中一样。默认情况下,与设置的BufferType无关,TextView会创建CharSequence对象的副本,并将其保存在内存中。这就保证了所有TextView的更新都是有意识的,而不是在开发者因为其他原因改变CharSequence值时意外的。
在上面的方案2中,我们看到通过textView.setText(spannableObject,BufferType.SPANNABLE)设置文本时,TextView通过使用Spannable.Factory实例创建一个新的SpannableString来复制CharSequence。所以每次我们设置一个新的文本,它都会创建一个新的对象。如果你想对这个过程进行更多的控制,避免额外的对象创建,可以实现你自己的Spannable.Factory,覆盖newSpannable(CharSequence),并将Spannable.Factory设置给TextView。
在我们自己的实现中,我们希望避免创建新的对象,所以我们可以只返回CharSequence并转换为一个Spannable。请记住,为了做到这一点,你必须调用textView.setText(spannableObject, BufferType.SPANNABLE),否则,源CharSequence将是一个Spanned的实例,它不能被投射为Spannable,导致ClassCastException。
代码语言:javascript复制val spannableFactory = object : Spannable.Factory() {
override fun newSpannable(source: CharSequence?): Spannable {
return source as Spannable
}
}
在得到TextView的引用后,立即设置Spannable.Factory对象一次。如果你使用的是RecyclerView,请在第一次创建你的视图时这样做。
代码语言:javascript复制textView.setSpannableFactory(spannableFactory)
有了这个功能,你就可以避免每次在你的RecyclerView绑定一个新的项目到ViewHolder时,创建额外的对象。
为了在处理文本和RecyclerViews时获得更高的性能,在将列表传递给Adapter之前,不要从ViewHolder中的字符串创建Spannable对象。
你可以在后台线程上构造Spannable对象,以及你对列表元素所做的任何其他工作。然后,你的Adapter可以保留一个List的引用来进行列表的更新。
Bonus performance tip
如果你只需要改变一个Span的内部属性(例如,自定义BulletPointSpan的颜色),你不需要再次调用TextView.setText,而只需要调用invalidate()或requestLayout()。再次调用setText会导致不必要的逻辑被触发和对象被创建,而视图只需要重新绘制或重新测量即可。
你需要做的是保留一个对你的可变Span的引用,根据你在视图中改变了什么样的属性,调用:
- TextView.invalidate(),如果你只是改变了文本的外观,来触发重绘,跳过重做布局。
- TextView.requestLayout()如果你做了一个影响文本大小的改动,那么视图可以可以负责测量、布局和绘制。
比方说,你有你自定义的Bullet实现,其中默认的Bullet颜色是红色。每当你按下一个按钮时,你想把Bullet的颜色改为灰色。该实现将是这样的。
代码语言:javascript复制class MainActivity : AppCompatActivity() {
// keeping the span as a field
val bulletSpan = BulletPointSpan(color = Color.RED)
override fun onCreate(savedInstanceState: Bundle?) {
…
val spannable = 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( {
// change the color of our mutable span
bulletSpan.color = Color.GRAY
// color won’t be changed until invalidate is called
styledText.invalidate()
}
}
Under the hood: passing text with spans intra and inter-process
当Spanned对象在进程内或进程间传递时,将不会使用自定义span属性。如果仅用Span框架就能实现所需的样式,最好应用多个Span框架来实现自己的Span,否则,最好实现扩展一些基础接口或抽象类的自定义Span。否则,最好实现自定义的 spans,扩展一些基础接口或抽象类。
在Android中,文本可以在同一进程中传递(进程内),例如通过Intents从一个Activity传递到另一个Activity,当文本从一个应用复制到另一个应用时,可以在进程之间传递(进程间)。
自定义Span实现不能跨进程边界传递,因为其他进程不知道它们,也不会知道如何处理它们。Android框架的Span是全局对象,但只有从ParcelableSpan延伸出来的Span可以在进程内和进程间传递。这个功能可以对框架中定义的Span的所有属性进行装箱和拆箱。
TextUtils.writeToParcel方法负责将Span信息保存在Parcel中。
例如,你可以在同一个进程中,通过一个意图在Activity之间传递Spans。
代码语言:javascript复制// start Activity with text with spans
val intent = Intent(this, MainActivity::class.java)
intent.putExtra(TEXT_EXTRA, mySpannableString)
startActivity(intent)// read text with Spans
val intentCharSequence = intent.getCharSequenceExtra(TEXT_EXTRA)
所以,即使你在同一个过程中传递Spans,也只有框架ParcelableSpans通过Intent传递才能存活。
ParcelableSpans还允许将文本与Span一起从一个进程复制到另一个进程。复制和粘贴文本的过程是通过ClipboardService来完成的,而ClipboardService使用的是同一个TextUtil.writeToParcel方法。因此,即使你从你的应用程序中复制Span并在同一个应用程序中粘贴它们,这也是一个进程间的操作,需要进行包裹,因为文本会通过ClipboardService。
默认情况下,任何实现Parcelable的类都可以从Parcel中写入和还原。当在进程间传递一个Parcelable对象时,唯一能保证正确还原的类是框架类。如果试图从Parcel中还原数据的进程无法构造对象,因为数据类型是在不同的应用中定义的,那么这个进程就会崩溃。
这里有两个大的注意事项。
当带有span的文本被传递时,无论是在同一个进程中还是在不同进程之间,只有框架的ParcelableSpans引用被保留。因此,自定义的 Spans样式不会被传播。
你不能创建自己的ParcelableSpan。为了避免未知数据类型导致的崩溃,框架不允许实现自定义的ParcelableSpan,通过定义两个方法,getSpanTypeIdInternal和writeToParcelInternal,作为隐藏的。这两个方法都被TextUtils.writeToParcel使用。
假设你想定义一个允许自定义CustomBulletSpan,因为现有的BulletSpan定义了一个4px的固定半径大小。下面是你如何实现它,以及每种方式的后果是什么。
- 创建一个CustomBulletSpan,该CustomBulletSpan扩展了BulletSpan,但也允许为Bullet大小设置一个参数。当Span从一个Activity传递到另一个Activity或通过复制文本时,附加到文本上的Span将是BulletSpan。这意味着当文本被绘制时,它将具有框架的默认Bullet半径,而不是设置的Bullet半径。这意味着当文本被绘制时,它将拥有框架的默认的Bullet半径,而不是在CustomBulletSpan中设置的半径。
- 创建一个CustomBulletSpan,该CustomBulletSpan只是从LeadingMarginSpan中延伸出来并重新实现了bullet point功能。当span从一个Activity传递到另一个Activity或通过复制文本时,附加到文本的span将是LeadingMarginSpan。这意味着当文本被绘制时,它将失去所有的样式。
如果仅用Span框架就能实现所需的样式,最好应用多个Span框架来实现自己的Span。否则,最好实现自定义的Span,扩展一些基础接口或抽象类。像这样,当对象在进程内或进程间传递时,你可以避免框架的实现被应用到spannable上。
通过了解Android如何用spans渲染文本,希望你能在你的应用中有效地使用它。下次你需要对文本进行样式设计时,根据你对该文本的进一步工作,决定是否应该应用多个Span框架或创建自己的自定义Span。
在Android中处理文本是一项如此常见的任务,调用正确的TextView.setText方法可以帮助您减少应用程序的内存使用量并提高其性能。