打造Flutter高性能富文本編輯器——渲染篇
2022-11-30|13:21|發(fā)布在分類 / 開網(wǎng)店| 閱讀:133
2022-11-30|13:21|發(fā)布在分類 / 開網(wǎng)店| 閱讀:133
協(xié)議篇文章,我們介紹了Flutter富文本編輯器協(xié)議層的設(shè)計(jì)。以Slate為例,介紹了協(xié)議層設(shè)計(jì)的幾個(gè)重要的概念:嵌套Model、Opeartion、Normalizing;站在Slate的肩膀上,讓我們有了一個(gè)強(qiáng)壯、設(shè)計(jì)完善的富文本協(xié)議層,接下來就讓我們看看渲染層是如何實(shí)現(xiàn)的;
讓我們回顧一下Mural整體的架構(gòu)設(shè)計(jì)分層:
渲染層主要工作是將協(xié)議Model轉(zhuǎn)換成Widget渲染到屏幕上,以及處理選區(qū)、光標(biāo)的計(jì)算和繪制,處理用戶的手勢(shì)交互、鍵盤交互等一系列工作;
首先讓我們來看下Flutter的TextField是如何渲染的:
如上圖所示,Textfield繼承自StatefulWidget,會(huì)build嵌套的Widget tree,其中有幾個(gè)比較關(guān)鍵的Widget:
TextSelectionGestureDetector處理手勢(shì)交互相關(guān)的邏輯,比如單擊移動(dòng)光標(biāo)、長(zhǎng)按選擇文字展示Toolbar等等;
另一個(gè)比較重要的Widget——EditableText;EditableText在build的時(shí)候,通過buildTextSpan方法,根據(jù)TextEditingValue的普通文本以及composing部分,創(chuàng)建一個(gè)Textspan對(duì)象給_Editable;最終RenderEditable通過TextPainter將文本繪制到canvas上;
如上圖所示,Mural在渲染層的設(shè)計(jì)上,與原生TextField前面一部分基本是一致的,不同之處從MuralEditable開始,對(duì)應(yīng)到TextField的EditableText;
上面在協(xié)議層我們說了,Slate在協(xié)議在設(shè)計(jì)上是與Dom一致的,到Flutter渲染層,就會(huì)將Dom樹轉(zhuǎn)換成Widget tree,最終渲染到屏幕上;
MuralEditable不再是簡(jiǎn)單的創(chuàng)建一個(gè)TextSpan,而是按照Dom樹結(jié)構(gòu),每一個(gè)Element映射成一個(gè)Widget;每個(gè)Element對(duì)應(yīng)的Widget,創(chuàng)建的RenderObject實(shí)現(xiàn)了抽象類:RenderEditorInlineBox;
接下來我們?cè)賮砜纯碋lement對(duì)應(yīng)的Widget,是怎么處理它的子節(jié)點(diǎn)的:
我們以最簡(jiǎn)單的EditableTextLine為例,包含Leading和Body兩部分,Leading負(fù)責(zé)渲染段落修飾相關(guān)的內(nèi)容,比如有序段落的序號(hào)、引用段落前面的裝飾豎線等;Body則負(fù)責(zé)渲染具體的富文本內(nèi)容,實(shí)現(xiàn)了抽象類:RenderEditorTextBox,最終依然將所有的葉子節(jié)點(diǎn)轉(zhuǎn)換成InlineSpan,通過TextPainer將文本繪制到屏幕上;
EditorUtils的buildChildren方法實(shí)現(xiàn)如下:
光標(biāo)和選區(qū)是富文本編輯器渲染層另外一個(gè)需要處理的難點(diǎn);
與原生TextField相比,Mural在處理光標(biāo)和選區(qū)處理更加復(fù)雜;TextField所有輸入文本都繪制在一個(gè)TextPainter,前面我們說過,Mural每個(gè)Element都是一個(gè)獨(dú)立的段落,對(duì)應(yīng)一個(gè)RenderObject;在Mural中,我們需要計(jì)算用戶手勢(shì)操作不同段落的光標(biāo)位置以及段落之間的選區(qū)計(jì)算;
要實(shí)現(xiàn)Mural的光標(biāo)和選區(qū)渲染,需要解決如下問題:
如上圖所示,當(dāng)用戶點(diǎn)擊綠色光點(diǎn)位置之后,首先我們可以根據(jù)點(diǎn)擊事件確認(rèn)被點(diǎn)擊是哪一個(gè)Element所渲染的RenderObject;
首先我們通過globalToLocal方法將手勢(shì)回調(diào)的globalPosition轉(zhuǎn)換為相對(duì)于Mural的localPosition;接下來遍歷MuralRenderEditable的child,尋找包含localPosition的child;
如上面介紹的,Element渲染的RenderObject實(shí)現(xiàn)了RenderEditorInlineBox抽象類,也就可以通過getPositionForOffset方法獲取到相對(duì)于當(dāng)前TextPainter的TextPosition;
接下來就要解決第二個(gè)問題,如何將TextPosition轉(zhuǎn)換為協(xié)議對(duì)于光標(biāo)、選區(qū)位置的描述;
以上圖為例,點(diǎn)擊之后,TextPosition的Offset為12,而Slate協(xié)議是如何描述這樣一個(gè)光標(biāo)位置呢?如上圖所示,變成了Path為[0,2],offset為2的Point。
接下來就是光標(biāo)位置計(jì)算,通過TextPainter的getOffsetForCaret方法,獲取選中Element對(duì)應(yīng)RenderObject的光標(biāo)位置,然后轉(zhuǎn)換成相對(duì)于Mural全局的Offset;
整體過程梳理如下:
在實(shí)現(xiàn)自定義表情的過程中,我們發(fā)現(xiàn)在展示狀態(tài),復(fù)雜的WidgetSpan渲染是不存在問題的,但是在編輯狀態(tài)支持WidgetSpan遇到了一系列問題;
簡(jiǎn)單一點(diǎn)的做法就是,在編輯狀態(tài)將表情變成中括號(hào)包裹的文字,變成一個(gè)不可編輯的inline&void類型的Element;
但我們目標(biāo)是實(shí)現(xiàn)一個(gè)所見即所得的富文本編輯器,為了在編輯狀態(tài)支持WidgetSpan,需要解決如下幾個(gè)問題:
我們定義了MuralCustomElement這樣一個(gè)自定義Element的抽象類,如果要實(shí)現(xiàn)自定義表情Element的渲染,需要繼承自它:
其中自定義表情長(zhǎng)度計(jì)算與Emoji不同的一點(diǎn),我們認(rèn)為自定義表情始終長(zhǎng)度為一;
因?yàn)槭荌nline&Void類型,所以isInline和isVoid都返回true;
Flutter文本輸入組件的基本原理,就是在Native側(cè)創(chuàng)建一個(gè)TextField組件,通過TextInputConnection實(shí)現(xiàn)雙端事件交互以及TextValue同步等邏輯;
當(dāng)用戶操作鍵盤進(jìn)行文字的輸入刪除、鍵盤收起、移動(dòng)光標(biāo)等操作,會(huì)同步到Flutter側(cè);同樣的,在Flutter進(jìn)行插入、復(fù)制、手勢(shì)導(dǎo)致Selection變化等操作,通過調(diào)用TextInputConnection的setEditingState同步給Native側(cè)的組件;
當(dāng)我們輸入一個(gè)表情的時(shí)候,從Flutter角度看,我們輸入了一個(gè)特殊的長(zhǎng)度為1的字符,這個(gè)時(shí)候我們就需要將這個(gè)TextValue的變化同步給Native;
我們參考PlaceholderSpan的實(shí)現(xiàn),使用字符\uFFFC同步給Native;
如果我們不做任何處理會(huì)發(fā)現(xiàn),當(dāng)包含WidgetSpan的時(shí)候,光標(biāo)的位置總會(huì)計(jì)算Offset為零;深入了解代碼發(fā)現(xiàn)問題所在:
我們需要處理WidgetSpan的codeUnitAtVisitor以及getSpanForPositionVisitor 方法:
自定義表情作為WidgetSpan的例子,其實(shí)是相對(duì)簡(jiǎn)單的;對(duì)于WidgetSpan嵌套WidgetSpan,嵌套的WidgetSpan可以被選擇、光標(biāo)移動(dòng)的場(chǎng)景,要怎么實(shí)現(xiàn)呢?大家可以想一想。
當(dāng)用戶鍵盤輸入的時(shí)候,Engine側(cè)會(huì)通過message channel發(fā)送TextInputClient.updateEditingState事件,將最新的TextEditingValue同步到Flutter側(cè);
對(duì)于TextField來說,更新的過程比較簡(jiǎn)單,整體更新TextValue即可;但對(duì)于Mural來說,每一次TextValue的更新,都進(jìn)行一次TextValue到Slate Model的轉(zhuǎn)換,頻繁執(zhí)行導(dǎo)致編輯狀態(tài)下的卡頓,性能大大下降;我們采用了diff的方式,判斷用戶輸入、刪除內(nèi)容,進(jìn)而調(diào)用Commond更新Model,刷新界面渲染;
我們需要對(duì)于換行符做特殊的處理,正如之前提到過的,Element是不包含換行符的,每一次換行都會(huì)新增一個(gè)新的Element節(jié)點(diǎn);
另外一個(gè)需要處理的問題就是移動(dòng)光標(biāo)的處理,如:iOS的長(zhǎng)按移動(dòng)光標(biāo)、Android的橫掃鍵盤移動(dòng)光標(biāo)以及第三方輸入法移動(dòng)光標(biāo)的鍵盤操作;這里的處理方案,iOS主要是處理TextInputClient.updateFloatingCursor事件,根據(jù)Offset計(jì)算光標(biāo)位置,Android以及第三方輸入法的操作,主要是在TextInputClient.updateEditingState同步處理。
擴(kuò)展能力是我們?cè)O(shè)計(jì)之初就非常重視的能力,為接入方提供簡(jiǎn)單、強(qiáng)大的自定義擴(kuò)展能力,支持復(fù)雜、不斷變化的業(yè)務(wù)訴求;接下來我們就以自定義主題和撤銷功能的實(shí)現(xiàn),來看一看Mural在擴(kuò)展能力方面的設(shè)計(jì)。
如上面視頻演示的,當(dāng)輸入兩個(gè)#中間包含字符,則變成一個(gè)主題的樣式,點(diǎn)擊可以跳轉(zhuǎn)到對(duì)應(yīng)的主題落地頁;可以對(duì)主題進(jìn)行編輯,如果刪掉其中一個(gè)#,則變成普通的文本。
要實(shí)現(xiàn)這樣一個(gè)自定義主題,我們需要實(shí)現(xiàn)以下幾個(gè)步驟:自定義Element、自定義Normalizing;
首先是定義Element:
接下來就輪到強(qiáng)大的自定義Normalizing出場(chǎng)了,通過自定義規(guī)則,處理主題Node節(jié)點(diǎn)校驗(yàn):
只需要這樣簡(jiǎn)單兩步,就實(shí)現(xiàn)了主題能力的支持;業(yè)務(wù)還可以根據(jù)自己的需求定制更加復(fù)雜的場(chǎng)景,比如有序段落等等。
如上面圖所示,我們實(shí)現(xiàn)了一個(gè)簡(jiǎn)單的Plugin層的擴(kuò)展——撤銷功能;在前面講到協(xié)議層設(shè)計(jì)的時(shí)候,我們討論過Slate的精簡(jiǎn)的Opeartion設(shè)計(jì),每一次交互的Commond,最終都會(huì)拆解成一個(gè)或者多個(gè)Opeartion執(zhí)行;我們可以通過以下三步實(shí)現(xiàn)plugin的擴(kuò)展:
通過兩篇文章,我們介紹了富文本編輯器協(xié)議層、渲染層設(shè)計(jì)和實(shí)現(xiàn),完成了一個(gè)功能完善的Flutter富文本編輯器;接下來我們會(huì)介紹Flutter富文本編輯器體驗(yàn)優(yōu)化方面閑魚的一些實(shí)踐和挑戰(zhàn)。
這個(gè)問題還有疑問的話,可以加幕.思.城火星老師免費(fèi)咨詢,微.信號(hào)是為: msc496。
推薦閱讀:
開淘寶店鋪寶貝展現(xiàn)量在哪里能夠查詢到呢?寶貝展現(xiàn)由什么決定?
更多資訊請(qǐng)關(guān)注幕 思 城。
微信掃碼回復(fù)「666」
別默默看了 登錄\ 注冊(cè) 一起參與討論!