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

Android_開發者發表於2019-03-04

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

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

原理:span 是怎樣工作的

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

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

框架會檢查這些 Spanned 物件是否包含框架中不同型別的 span,並觸發相應的行為。

文字佈局和繪製背後的邏輯是很複雜的,並且遍佈不同的類;在這一節中,我們只能針對幾種情況,簡單地說明一下文字是如何被處理的。

每當一個 span 改變時,TextView spanChange 檢查 span 是否是 UpdateAppearanceParagraphStyleCharacterStyle 的例項,而且,如果是的話,對自己呼叫 invalidate 方法,觸發檢視重繪。

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

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

設定文字時確保最佳效能

有若干種辦法可以在設定 TextView 的文字時有效節約記憶體,這取決於你的需要。

1. 為一個永不改變的 TextView 設定文字

如果你只需要設定 TextView 的文字一次,並永遠不需要更新它,你可以建立一個新的 SpannableStringSpannableStringBuilder 例項,設定所需的 span 並呼叫 textView.setText(spannable)。由於你不再修改這些文字,效能沒有提升的空間。

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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章