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 變成
,A 變成 URLSpan, Img 變成 ImageSpan,效果如圖所示:
| 雪球 APP 中一些實踐
前面介紹了 TextView 繪製的流程、可標記文本 Span 以及常見的標記類型。三者相互合作使得 TextView 可以展現出豐富的文本效果。下面簡單介紹一下雪球 APP 中的一些實踐。
編輯器添加和刪除
發帖時,一旦有 @ 某個人 或者有插入一個超鏈接,碰到需要編輯刪除的時候,總是需要同時將這個好鏈接一同刪去。這時候,利用 Span 來處理,會非常方便。
先來看一下插入一個標記文本
GIF
實現起來非常簡單,我們在增加一個超鏈接的時候,如上一節的例子所示,構造一個 Spanned 文本,然後直接 insert 到 Editable 對應的位置上,EditText 會自動更新 UI(前面已經說明原理)。
當需要整體刪除某個超鏈接時的時候,可以監聽游標的位置移動,一旦監聽到刪除這個操作的時候,可以判斷需要刪除的位置是否是一個需要刪除的標記文本。
效果如下:
GIF
在上圖中,兩者都是 URLSpan 的子類,拿到標記文本的起始位置,同時刪除所有。另一個用處在於,當用戶游標移動時候,禁止移動到標記文本的正中間,根據接近位置,重新設置移動後游標的最終位置。
信息流中對特殊文本進行標記(專欄、懸賞)
很多時候,系統提供的類型並不能滿足我們的需求,這個時候我們需要擴展某些基礎類型或者實現某些介面來達到預期。最開始說到,影響(或者說修改)不同層級的文本屬性,需要擴展不同的基礎類型。
雪球信息流中,有一個非常重要的使用場景是:將不固定長度和內容的前綴,添加到不固定長度和內容的文本之前。具體做法如下:
擴展出來一個自定義 Span,使用它來標記需要特殊顯示的文本。先貼上兩張效果圖查看一下:
這裡選擇的方式是繼承 ReplacementSpan(它繼承自MetricAffectingSpan),看下面的代碼:
選擇 ReplacementSpan 在於,我們針對文本,需要改變的是背景色的樣式和文字的顏色。這裡只需要重寫 onDraw 方法,在特定的區域中設置設計師提出的樣式要求。
信息流中對 A 標籤標記
廣義上來看雪球社區的信息流與使用度更廣的微博、Twitter 並沒有特別大的差別,都需要讓用戶在信息流中快速的找到人、話題,並且方便的進行交互。具體到雪球的信息流,我們需要做到的是:從信息流快速到達個人頁(雪球信息流中特殊的一點是需要對付費 @ 某個用戶單獨區隔開來進行突出)、個股頁、話題頁,同時提供快速查看圖片的方式。如圖所示:
GIF
看圖需要滿足三個需求,首先要區分出不同的超鏈接讓其顯示不同的顏色,其次點擊超鏈接時需要有點擊狀態並且點擊狀態可以與連接顏色對應上,第三不同的超鏈接點擊後跳轉不同。根據需求我們選擇擴展 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 相關知識。
| 參考
http://flavienlaurent.com/blog/2014/01/31/spans/
//instagram-engineering.tumblr.com/post/114508858967/improving-comment-rendering-on-android
TAG:雪球工程師團隊 |