[譯] 論 Android 中 Span 的正確打開方式

Span 夠爲文字和段落設置樣式,它是經過讓用戶使用 TextPaint 和 Canvas 等組件來實現這些功能的。在上一篇文章中,咱們討論瞭如何使用 Span、Span 是什麼、Span 自己自帶的功能,以及如何實現並測試本身的 span。html

咱們看看在特定的用例中,可使用什麼 API 來確保最佳性能。咱們將探索 span 的原理,以及 framework 是如何使用它們的。最後,咱們將瞭解如何在進程中或跨進程傳遞 span,以及基於這些,你在建立自定義 span 時須要警戒哪些陷阱。前端

原理:span 是怎樣工做的

Android 框架在數個類中涉及了文字樣式處理以及 span:TextViewEditText、layout 類 (LayoutStaticLayoutDynamicLayout) 以及 TextLine (一個 Layout 中的包私有類) 並且它取決於數個參數:java

  • 文字類型:可選擇,可編輯或不可選擇。
  • BufferType
  • TextViewLayoutParams 類型
  • 等等

框架會檢查這些 Spanned 對象是否包含框架中不一樣類型的 span,並觸發相應的行爲。android

文本佈局和繪製背後的邏輯是很複雜的,而且遍及不一樣的類;在這一節中,咱們只能針對幾種狀況,簡單地說明一下文本是如何被處理的。ios

每當一個 span 改變時,TextView spanChange 檢查 span 是不是 UpdateAppearanceParagraphStyleCharacterStyle 的實例,並且,若是是的話,對本身調用 invalidate 方法,觸發視圖重繪。git

TextLine 類表示一行具備樣式的文字,而且它只接受 CharacterStyleMetricAffectingSpanReplacementSpan的子類。這是觸發 MetricAffectingSpan.updateMeasureStateCharacterStyle.updateDrawState 的類。github

管理文字佈局的基類是 android.text.LayoutLayout 和兩個子類,StaticLayoutDynamicLayout, 檢查設置給文字的 span 並計算行高和佈局 margin。除此之外,當一個 span 在 DynamicLayout 中展現並被更新時,layout 檢查 span 是不是一個 UpdateLayout,併爲被影響的文字生成一個新的 layout。後端

設置文字時確保最佳性能

有若干種辦法能夠在設置 TextView 的文字時有效節約內存,這取決於你的須要。bash

1. 爲一個永不改變的 TextView 設置文字

若是你只須要設置 TextView 的文字一次,並永遠不須要更新它,你能夠建立一個新的 SpannableStringSpannableStringBuilder 實例,設置所需的 span 並調用 textView.setText(spannable)。因爲你再也不修改這些文字,性能沒有提高的空間。app

2. 經過增長/刪除 span 改變文字樣式

考慮文字自己不改變,但附着於它的 span 會改變的狀況。例如,當一個按鈕被點擊時,你但願文字中的一個詞變成灰色。因此,咱們須要給文字添加一個新的 span。爲此,你頗有可能會調用 textView.setText(CharSequence) 兩次:第一次設置初始文字,第二次在按鈕被點擊時從新設置。一個更好的選擇是調用 textView.setText(CharSequence, BufferType) 並在按鈕被點擊時只更新 Spannable 對象的 span。

下面是這些狀況下底層發生的事情:

選項 1: 調用 textView.setText(CharSequence) 屢次 — 並不是最佳選擇

在調用 textView.setText(CharSequence)時,TextView 悄悄複製了一份你的 Spannable,把它做爲 SpannedString,並把它做爲 CharSequence 存儲在內存中。這樣作的後果是你的 文字和 span 是不可變的。因此,當你須要更新文字樣式時,你將須要使用文字和 span 建立一個新的 Spannable,並再次調用 textView.setText。這將會把整個對象再複製一次。

選項 2: 調用 textView.setText(CharSequence, BufferType) 一次並更新 spannable 對象 — 最佳選擇

在調用 textView.setText(CharSequence, BufferType)時, BufferType 參數通知 TextView 什麼類型的文字被設置了:靜態(調用 textView.setText(CharSequence) 時的默認選項)、styleable / spannable 文字或 editable(被 EditText 使用)。

因爲咱們正在使用樣式化的文字,咱們能夠調用:

textView.setText(spannableObject, BufferType.SPANNABLE)
複製代碼

在這種狀況下, TextView 再也不建立一個 SpannedString ,但它將在 Spannable.Factory 成員對象的幫助下建立一個 SpannableString。因此,如今  TextView 持有的 CharSequence 副本有 可變的標記和不可變的文字

爲了更新 span,咱們首先獲取做爲 Spannable 的文字,而後根據須要更新 span。

// 若是 setText 被以 BufferType.SPANNABLE 方式調用
textView.setText(spannable, BufferType.SPANNABLE)

// 文字可被轉爲 Spannable
val spannableText = textView.text as Spannable

// 如今咱們能夠設置或刪除 span
spannableText.setSpan(
     ForegroundColorSpan(color), 
     8, spannableText.length, 
     SPAN_INCLUSIVE_INCLUSIVE)
複製代碼

經過這個選項,咱們建立了初始的 Spannable 對象。TextView 將會持有它的一個副本,但當咱們須要調整它時,咱們不須要建立任何其它的對象,由於咱們將直接操做 TextView 持有的 Spannable 文字實例。可是,TextView 將只會被通知 span 的 添加/刪除/重排操做。若是你改變 span 的一個內部屬性,你將須要調用 invalidate()requestLayout(),這取決於改變的類型。你能夠在下面的 「額外的性能建議」 中看到其中的細節。

3. 文字改變(複用 TextView)

假設咱們想要複用 TextView 而且屢次設置文本,就像在 RecyclerView.ViewHolder 中同樣。默認狀況下,和 BufferType 無關,TextView 建立一個CharSequence 對象的副本並將其儲存在內存中。這確保全部 TextView 更新都是故意觸發的,而不是用戶因爲其它緣由修改 CharSequence 的值時不當心觸發的。

在上面的選項 2 中,咱們看到在經過 textView.setText(spannableObject, BufferType.SPANNABLE) 設置文字時,TextView.Spannable.Factory 實例建立一個新的 SpannableString,從而複製 CharSequence。因此每當咱們設置一個新的文本時,它就會建立一個新的對象。若是你想要更多地控制這個過程並避免額外的對象建立,就要實現你本身的 Spannable.Factory,重寫 newSpannable(CharSequence),並把它設置給 TextView

在咱們本身的實現中,咱們想要避免建立新的對象,因此咱們只須要返回 CharSequence 並將其轉爲 Spannable。記住,爲了實現這一點,你須要調用 textView.setText(spannableObject, BufferType.SPANNABLE)。不然,源 CharSequence將會是一個 Spanned 的實例,它不能被轉爲 Spannable,從而形成 ClassCastException

val spannableFactory = object : Spannable.Factory() {
    override fun newSpannable(source: CharSequence?): Spannable {
        return source as Spannable
    }
}
複製代碼

在你獲取 TextView 的引用以後,當即設置  Spannable.Factory 對象。若是你在使用 RecyclerView,在你首次建立你的 view 時這樣作。

textView.setSpannableFactory(spannableFactory)

這樣,你就能夠防止每次 RecyclerView 把新的條目綁定到你的 ViewHolder 時建立額外的對象。

當你在使用文字和 RecyclerViews 時,爲了獲取更好的性能,不要根據 ViewHolder 中的 String 建立你的 Spannable 對象,要在 你把列表傳給 Adapter 以前這樣作。這容許你在後臺線程中建立 Spannable 對象,並作完須要對列表元素作的全部操做。你的Adapter 能夠持有對 List<Spannable> 的一個引用。

額外的性能建議

若是你只須要改變一個 span 的內部屬性,在自定義的着重號 span 中改變其顏色),你不須要再次調用 TextView.setText ,而只須要調用 invalidate()requestLayout() 便可。再次調用 setText 將會在只須要從新 draw 或 measure 時觸發沒必要要的業務邏輯並建立沒必要要的對象。

你須要作的只是持有對可變 span 的一個引用,而且,取決於你改變了 view 的什麼屬性,調用:

  • TextView.invalidate() (若是你只是改變文字外觀),以觸發一次 redraw 並跳過 layout 過程。
  • TextView.requestLayout() (若是你改變文字大小),那麼這個 view 就能夠處理 measure, layout 和 draw

假如你實現了自定義的着重號,其默認的顏色爲紅色。當你按下一個按鈕時,你但願着重號的顏色變成灰色。你的實現以下所示:

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()
        }
    }
複製代碼

底層:進程內和跨進程的 span 傳遞

太長不看版

在進程內和跨進程的 span 傳遞中,自定義 span 特性將不會被使用。若是想要的樣式能夠經過框架自帶的 span 實現,儘量使用多個框架中的 span 取代你本身的 span。不然,儘可能在自定義 span 時實現一些基礎的接口或抽象類。

在 Android 中,文字能夠在進程內部(或跨進程)傳遞,例如在 Activity 間經過 Intent 傳遞,或當文字在 app 間傳遞時跨進程傳遞。

自定義 span 實現不能在進程之間傳遞,由於其它進程不瞭解它們,也不知道如何處理它們。Android 框架中的 span 是全局對象,但只有繼承了 ParcelableSpan 的才能夠在進程內或跨進程傳遞。這個功能容許框架定義的 span 的全部屬性實現 parcel 和 unparcel。TextUtils.writeToParcel 方法負責把 span 信息保存在 Parcel 中。

例如,你能夠在同進程中傳遞 span,或經過 intent 在 Activity 間傳遞:

// 使用文字和 span 啓動 Activity
val intent = Intent(this, MainActivity::class.java)
intent.putExtra(TEXT_EXTRA, mySpannableString)
startActivity(intent)

// 讀取帶有 Span 的文字
val intentCharSequence = intent.getCharSequenceExtra(TEXT_EXTRA)
複製代碼

因此,哪怕你在同一個進程中傳遞 span,只有框架中的 ParcelableSpan 經過 Intent 傳遞以後還能存活。

ParcelableSpan 也容許你把文字和 span 一塊兒跨進程傳遞。複製/粘貼文字經過 ClipboardService 實現,而它在底層使用一樣的 TextUtil.writeToParcel 方法。因此,若是你在同一個 app 內部複製/粘貼 span,這將是一個跨進程行爲,須要進行 parcel,由於文字須要通過 ClipboardService

默認狀況下,任何實現了 Parcelable 的類能夠被寫入 Parcel 和從 Parcel 中恢復。當跨進程傳遞 Parcelable 對象時,只有框架類能夠保證被正確存取。 若是數據類型在不一樣 app 中定義,致使試圖恢復數據的進程不能建立這個對象,進程將會崩潰。

有兩個重要的警告:

  1. 當帶有 span 的文字被傳遞時,不管是在進程中仍是跨進程,只有 framework 的 ParcelableSpan 引用被保留。這致使自定義 span 樣式不能被傳遞。
  2. 你不能建立本身的 ParcelableSpan 爲了防止未知數據類型致使的崩潰,框架不容許實現自定義 ParcelableSpan。這是經過把getSpanTypeIdInternalwriteToParcelInternal 設置爲隱藏方法實現的。它們都被 TextUtils.writeToParcel 使用。

假如你須要定義一個着重號 span,它能夠自定義着重號的大小,由於現有的 BulletSpan 將半徑規定爲 4px。如下是實現它的方式,以及各類方式的後果:

  1. 建立一個繼承了 CustomBulletSpan BulletSpan,它容許爲着重號設置大小。當 span 經過複製文字或在 Activity 間跳轉而傳遞時,附着於文字的 span 將會是 BulletSpan。這意味着若是文字被繪製,它將具備框架的默認文字半徑,而不是在 CustomBulletSpan 中設置的半徑。

  2. 建立一個繼承了 LeadingMarginSpan CustomBulletSpan 並從新實現着重號功能。當 span 經過複製文字或在 Activity 間跳轉而傳遞時,附着於文字的 span 將會是 LeadingMarginSpan。 這意味着若是文字被繪製,它將失去全部的樣式。

若是想要的樣式能夠經過框架自帶的 span 實現, 儘量使用多個框架中的 span取代你本身的 span。不然,儘可能在自定義 span 時實現一些基礎的接口或抽象類。這樣,你能夠防止在進程內或跨進程傳遞時,框架的實現被應用到 spannable。


經過理解 Android 如何渲染帶有 span 的文字,你將頗有但願在你的 app 中高效地使用它。下次你須要給文字設置樣式時,根據你未來須要怎樣使用這些文字來決定是使用多個框架 span,仍是實現自定義 span。

使用 Android 中的文本是一個常見的操做,調用正確的 TextView.setText 方法將有助於使你下降 app 的內存消耗,並提升其性能。

感謝 Siyamed Sinir、Clara Bayarri、Nick ButcherDaniel Galpin


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索