打造Flutter高效能富文字編輯器——渲染篇

語言: CN / TW / HK

作者:閒魚技術——光酒

本系列文章主要介紹Flutter富文字編輯的設計和實現,從協議層、渲染層、自定義擴充套件以及體驗優化等方面,詳細介紹如何實現一個功能完善、可擴充套件、高效能的Flutter富文字編輯器,以及閒魚在實踐過程中遇到的問題和我們的一些解法。

協議篇:https://yuque.antfin-inc.com/guangjiu.wh/iwrm1g/wg8eol 渲染篇:https://yuque.antfin-inc.com/guangjiu.wh/iwrm1g/ho4x25

上一篇文章,我們介紹了Flutter富文字編輯器協議層的設計。以Slate為例,介紹了協議層設計的幾個重要的概念:巢狀Model、Opeartion、Normalizing;站在Slate的肩膀上,讓我們有了一個強壯、設計完善的富文字協議層,接下來就讓我們看看渲染層是如何實現的;

讓我們回顧一下Mural整體的架構設計分層:

渲染層主要工作是將協議Model轉換成Widget渲染到螢幕上,以及處理選區、游標的計算和繪製,處理使用者的手勢互動、鍵盤互動等一系列工作;

Textfield的渲染實現

首先讓我們來看下Flutter的TextField是如何渲染的:

p11

p11

如上圖所示,Textfield繼承自StatefulWidget,會build巢狀的Widget tree,其中有幾個比較關鍵的Widget:

TextSelectionGestureDetector處理手勢互動相關的邏輯,比如單擊移動游標、長按選擇文字展示Toolbar等等;

另一個比較重要的Widget——EditableText;EditableText在build的時候,通過buildTextSpan方法,根據TextEditingValue的普通文字以及composing部分,建立一個Textspan物件給_Editable;最終RenderEditable通過TextPainter將文字繪製到canvas上;

Mural的渲染實現

p12

p12

如上圖所示,Mural在渲染層的設計上,與原生TextField前面一部分基本是一致的,不同之處從MuralEditable開始,對應到TextField的EditableText

上面在協議層我們說了,Slate在協議在設計上是與Dom一致的,到Flutter渲染層,就會將Dom樹轉換成Widget tree,最終渲染到螢幕上;

MuralEditable不再是簡單的建立一個TextSpan,而是按照Dom樹結構,每一個Element對映成一個Widget;每個Element對應的Widget,建立的RenderObject實現了抽象類:RenderEditorInlineBox

接下來我們再來看看Element對應的Widget,是怎麼處理它的子節點的:

我們以最簡單的EditableTextLine為例,包含Leading和Body兩部分,Leading負責渲染段落修飾相關的內容,比如有序段落的序號、引用段落前面的裝飾豎線等;Body則負責渲染具體的富文字內容,實現了抽象類:RenderEditorTextBox,最終依然將所有的葉子節點轉換成InlineSpan,通過TextPainer將文字繪製到螢幕上;

EditorUtilsbuildChildren方法實現如下:

游標&選區渲染

游標和選區是富文字編輯器渲染層另外一個需要處理的難點;

與原生TextField相比,Mural在處理游標和選區處理更加複雜;TextField所有輸入文字都繪製在一個TextPainter,前面我們說過,Mural每個Element都是一個獨立的段落,對應一個RenderObject;在Mural中,我們需要計算使用者手勢操作不同段落的游標位置以及段落之間的選區計算;

要實現Mural的游標和選區渲染,需要解決如下問題:

  1. 1. 多Element點選獲取TextPosition;
  2. 2. TextPosition to MuralPoint;
  3. 3. 游標位置計算;

多Element點選獲取TextPosition

p14

p14

如上圖所示,當用戶點選綠色光點位置之後,首先我們可以根據點選事件確認被點選是哪一個Element所渲染的RenderObject;

首先我們通過globalToLocal方法將手勢回撥的globalPosition轉換為相對於Mural的localPosition;接下來遍歷MuralRenderEditable的child,尋找包含localPosition的child;

如上面介紹的,Element渲染的RenderObject實現了RenderEditorInlineBox抽象類,也就可以通過getPositionForOffset方法獲取到相對於當前TextPainter的TextPosition;

TextPosition to MuralPoint

接下來就要解決第二個問題,如何將TextPosition轉換為協議對於游標、選區位置的描述;

以上圖為例,點選之後,TextPosition的Offset為12,而Slate協議是如何描述這樣一個游標位置呢?如上圖所示,變成了Path[0,2]offset2Point

游標位置計算

接下里就是游標位置計算,通過TextPainter的getOffsetForCaret方法,獲取選中Element對應RenderObject的游標位置,然後轉換成相對於Mural全域性的Offset;

整體過程梳理如下:

p15

p15

支援WidgetSpan

在實現自定義表情的過程中,我們發現在展示狀態,複雜的WidgetSpan渲染是不存在問題的,但是在編輯狀態支援WidgetSpan遇到了一系列問題;

簡單一點的做法就是,在編輯狀態將表情變成中括號包裹的文字,變成一個不可編輯的inline&void型別的Element;

但我們目標是實現一個所見即所得的富文字編輯器,為了在編輯狀態支援WidgetSpan,需要解決如下幾個問題:

  1. 1. Element到WidgetSpan渲染;
  2. 2. TextValue與Native同步問題;
  3. 3. 游標、選區TextBox計算問題;

custom_emoji

custom_emoji

Element到WidgetSpan渲染

我們定義了MuralCustomElement這樣一個自定義Element的抽象類,如果要實現自定義表情Element的渲染,需要繼承自它:

其中自定義表情長度計算與Emoji不同的一點,我們認為自定義表情始終長度為一;

因為是Inline&Void型別,所以isInlineisVoid都返回true

TextValue與Native同步問題

Flutter文字輸入元件的基本原理,就是在Native側建立一個TextField元件,通過TextInputConnection實現雙端事件互動以及TextValue同步等邏輯;

當用戶操作鍵盤進行文字的輸入刪除、鍵盤收起、移動游標等操作,會同步到Flutter側;同樣的,在Flutter進行插入、複製、手勢導致Selection變化等操作,通過呼叫TextInputConnectionsetEditingState同步給Native側的元件;

當我們輸入一個表情的時候,從Flutter角度看,我們輸入了一個特殊的長度為1的字元,這個時候我們就需要將這個TextValue的變化同步給Native;

我們參考PlaceholderSpan的實現,使用字元\uFFFC同步給Native;

游標、選區TextBox計算問題

如果我們不做任何處理會發現,當包含WidgetSpan的時候,游標的位置總會計算Offset為零;深入瞭解程式碼發現問題所在:

22

22

我們需要處理WidgetSpan的codeUnitAtVisitor以及getSpanForPositionVisitor 方法:

自定義表情作為WidgetSpan的例子,其實是相對簡單的;對於WidgetSpan巢狀WidgetSpan,巢狀的WidgetSpan可以被選擇、游標移動的場景,要怎麼實現呢?大家可以想一想。

鍵盤互動問題

當用戶鍵盤輸入的時候,Engine側會通過message channel傳送TextInputClient.updateEditingState事件,將最新的TextEditingValue同步到Flutter側;

對於TextField來說,更新的過程比較簡單,整體更新TextValue即可;但對於Mural來說,每一次TextValue的更新,都進行一次TextValue到Slate Model的轉換,頻繁執行導致編輯狀態下的卡頓,效能大大下降;我們採用了diff的方式,判斷使用者輸入、刪除內容,進而呼叫Commond更新Model,重新整理介面渲染;

我們需要對於換行符做特殊的處理,正如之前提到過的,Element是不包含換行符的,每一次換行都會新增一個新的Element節點;

另外一個需要處理的問題就是移動游標的處理,如:iOS的長按移動游標、Android的橫掃鍵盤移動游標以及第三方輸入法移動游標的鍵盤操作;這裡的處理方案,iOS主要是處理TextInputClient.updateFloatingCursor事件,根據Offset計算游標位置,Android以及第三方輸入法的操作,主要是在TextInputClient.updateEditingState同步處理。

擴充套件能力

擴充套件能力是我們設計之初就非常重視的能力,為接入方提供簡單、強大的自定義擴充套件能力,支援複雜、不斷變化的業務訴求;接下來我們就以自定義主題和撤銷功能的實現,來看一看Mural在擴充套件能力方面的設計。

自定義Node——主題能力

custom_node

custom_node

如上面影片演示的,當輸入兩個#中間包含字元,則變成一個主題的樣式,點選可以跳轉到對應的主題落地頁;可以對主題進行編輯,如果刪掉其中一個#,則變成普通的文字。

要實現這樣一個自定義主題,我們需要實現以下幾個步驟:自定義Element、自定義Normalizing;

首先是定義Element:

接下來就輪到強大的自定義Normalizing出場了,通過自定義規則,處理主題Node節點校驗:

只需要這樣簡單兩步,就實現了主題能力的支援;業務還可以根據自己的需求定製更加複雜的場景,比如有序段落等等。

Plugin擴充套件——實現撤銷功能

undo

undo

如上面圖所示,我們實現了一個簡單的Plugin層的擴充套件——撤銷功能;在前面講到協議層設計的時候,我們討論過Slate的精簡的Opeartion設計,每一次互動的Commond,最終都會拆解成一個或者多個Opeartion執行;我們可以通過以下三步實現plugin的擴充套件:

  1. 1. 重寫Operation的apply方法,通過過濾、合併等操作,記錄Opeartion執行的歷史;
  2. 2. 實現Opeartion的reverse方法;
  3. 3. 根據Opeartion執行歷史,呼叫Opeartion的reverse方法,執行reverse操作;

總結

通過兩篇文章,我們介紹了富文字編輯器協議層、渲染層設計和實現,完成了一個功能完善的Flutter富文字編輯器;接下來我們會介紹Flutter富文字編輯器體驗優化方面閒魚的一些實踐和挑戰。