幕思城>電商行情>開店>開網(wǎng)店>打造Flutter高性能富文本編輯器——渲染篇

    打造Flutter高性能富文本編輯器——渲染篇

    2022-11-30|13:21|發(fā)布在分類 / 開網(wǎng)店| 閱讀:133

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

    讓我們回顧一下Mural整體的架構(gòu)設(shè)計分層:

    渲染層主要工作是將協(xié)議Model轉(zhuǎn)換成Widget渲染到屏幕上,以及處理選區(qū)、光標的計算和繪制,處理用戶的手勢交互、鍵盤交互等一系列工作;

    Textfield的渲染實現(xiàn)

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

    如上圖所示,Textfield繼承自StatefulWidget,會build嵌套的Widget tree,其中有幾個比較關(guān)鍵的Widget:

    TextSelectionGestureDetector處理手勢交互相關(guān)的邏輯,比如單擊移動光標、長按選擇文字展示Toolbar等等;

    另一個比較重要的Widget——EditableText;EditableText在build的時候,通過buildTextSpan方法,根據(jù)TextEditingValue的普通文本以及composing部分,創(chuàng)建一個Textspan對象給_Editable;最終RenderEditable通過TextPainter將文本繪制到canvas上;

    Mural的渲染實現(xiàn)

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

    上面在協(xié)議層我們說了,Slate在協(xié)議在設(shè)計上是與Dom一致的,到Flutter渲染層,就會將Dom樹轉(zhuǎn)換成Widget tree,最終渲染到屏幕上;

    MuralEditable不再是簡單的創(chuàng)建一個TextSpan,而是按照Dom樹結(jié)構(gòu),每一個Element映射成一個Widget;每個Element對應的Widget,創(chuàng)建的RenderObject實現(xiàn)了抽象類:RenderEditorInlineBox;

    接下來我們再來看看Element對應的Widget,是怎么處理它的子節(jié)點的:

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

    EditorUtils的buildChildren方法實現(xiàn)如下:

    光標&選區(qū)渲染

    光標和選區(qū)是富文本編輯器渲染層另外一個需要處理的難點;

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

    要實現(xiàn)Mural的光標和選區(qū)渲染,需要解決如下問題:

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

    多Element點擊獲取TextPosition

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

    首先我們通過globalToLocal方法將手勢回調(diào)的globalPosition轉(zhuǎn)換為相對于Mural的localPosition;接下來遍歷MuralRenderEditable的child,尋找包含localPosition的child;

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

    TextPosition to MuralPoint

    接下來就要解決第二個問題,如何將TextPosition轉(zhuǎn)換為協(xié)議對于光標、選區(qū)位置的描述;

    以上圖為例,點擊之后,TextPosition的Offset為12,而Slate協(xié)議是如何描述這樣一個光標位置呢?如上圖所示,變成了Path為[0,2],offset為2的Point。

    光標位置計算

    接下來就是光標位置計算,通過TextPainter的getOffsetForCaret方法,獲取選中Element對應RenderObject的光標位置,然后轉(zhuǎn)換成相對于Mural全局的Offset;

    整體過程梳理如下:

    支持WidgetSpan

    在實現(xiàn)自定義表情的過程中,我們發(fā)現(xiàn)在展示狀態(tài),復雜的WidgetSpan渲染是不存在問題的,但是在編輯狀態(tài)支持WidgetSpan遇到了一系列問題;

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

    但我們目標是實現(xiàn)一個所見即所得的富文本編輯器,為了在編輯狀態(tài)支持WidgetSpan,需要解決如下幾個問題:

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

    Element到WidgetSpan渲染

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

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

    因為是Inline&Void類型,所以isInline和isVoid都返回true;

    TextValue與Native同步問題

    Flutter文本輸入組件的基本原理,就是在Native側(cè)創(chuàng)建一個TextField組件,通過TextInputConnection實現(xiàn)雙端事件交互以及TextValue同步等邏輯;

    當用戶操作鍵盤進行文字的輸入刪除、鍵盤收起、移動光標等操作,會同步到Flutter側(cè);同樣的,在Flutter進行插入、復制、手勢導致Selection變化等操作,通過調(diào)用TextInputConnection的setEditingState同步給Native側(cè)的組件;

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

    我們參考PlaceholderSpan的實現(xiàn),使用字符\uFFFC同步給Native;

    光標、選區(qū)TextBox計算問題

    如果我們不做任何處理會發(fā)現(xiàn),當包含WidgetSpan的時候,光標的位置總會計算Offset為零;深入了解代碼發(fā)現(xiàn)問題所在:

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

    自定義表情作為WidgetSpan的例子,其實是相對簡單的;對于WidgetSpan嵌套WidgetSpan,嵌套的WidgetSpan可以被選擇、光標移動的場景,要怎么實現(xiàn)呢?大家可以想一想。

    鍵盤交互問題

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

    對于TextField來說,更新的過程比較簡單,整體更新TextValue即可;但對于Mural來說,每一次TextValue的更新,都進行一次TextValue到Slate Model的轉(zhuǎn)換,頻繁執(zhí)行導致編輯狀態(tài)下的卡頓,性能大大下降;我們采用了diff的方式,判斷用戶輸入、刪除內(nèi)容,進而調(diào)用Commond更新Model,刷新界面渲染;

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

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

    擴展能力

    擴展能力是我們設(shè)計之初就非常重視的能力,為接入方提供簡單、強大的自定義擴展能力,支持復雜、不斷變化的業(yè)務訴求;接下來我們就以自定義主題和撤銷功能的實現(xiàn),來看一看Mural在擴展能力方面的設(shè)計。

    自定義Node——主題能力

    如上面視頻演示的,當輸入兩個#中間包含字符,則變成一個主題的樣式,點擊可以跳轉(zhuǎn)到對應的主題落地頁;可以對主題進行編輯,如果刪掉其中一個#,則變成普通的文本。

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

    首先是定義Element:

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

    只需要這樣簡單兩步,就實現(xiàn)了主題能力的支持;業(yè)務還可以根據(jù)自己的需求定制更加復雜的場景,比如有序段落等等。

    Plugin擴展——實現(xiàn)撤銷功能

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

    1. 1. 重寫Operation的apply方法,通過過濾、合并等操作,記錄Opeartion執(zhí)行的歷史;
    2. 2. 實現(xiàn)Opeartion的reverse方法;
    3. 3. 根據(jù)Opeartion執(zhí)行歷史,調(diào)用Opeartion的reverse方法,執(zhí)行reverse操作;

    總結(jié)

    通過兩篇文章,我們介紹了富文本編輯器協(xié)議層、渲染層設(shè)計和實現(xiàn),完成了一個功能完善的Flutter富文本編輯器;接下來我們會介紹Flutter富文本編輯器體驗優(yōu)化方面閑魚的一些實踐和挑戰(zhàn)。

    這個問題還有疑問的話,可以加幕.思.城火星老師免費咨詢,微.信號是為: msc496。

    難題沒解決?加我微信給你講!【僅限淘寶賣家交流運營知識,非賣家不要加我哈】
    >

    推薦閱讀:

    淘寶店鋪的推廣怎么投放代碼?具體需要怎么操作?

    開淘寶店鋪寶貝展現(xiàn)量在哪里能夠查詢到呢?寶貝展現(xiàn)由什么決定?

    淘寶直通車的意義是什么?淘寶店鋪開直通車有什么好處嗎?

    更多資訊請關(guān)注幕 思 城。

    發(fā)表評論

    別默默看了 登錄\ 注冊 一起參與討論!

      微信掃碼回復「666