作者:閒魚技術-塵蕭
引言
在漫長的從Native向Flutter過渡的混合工程時期,要想平滑地過渡,在Flutter中使用Native中較為完善的控制元件會是一個很好的選擇。本文希望向大家介紹AndroidView的使用方式以及在此基礎之上擴充的雙端嵌入Native元件的解決方案。
1. 使用教程
1.1. DemoRun
嵌入地圖這一場景可能在很多App中都會存在,但是現在的地圖SDK都沒有提供Flutter的庫,而自己開發一套地圖顯然不太現實。這種場景下,使用混合棧的形式是一個比較好的選擇。我們可以直接在Native的繪圖樹中嵌入一個Map,但是這個方案嵌入的View並不在Flutter的繪圖樹中,是一種比較暴力且不優雅的方式,使用起來也很費勁。
這時候,使用Flutter官方提供的控制元件AndroidView就是一種比較優雅的解決方案了。這裡做了一個簡單的嵌入高德地圖的demo,就讓我們跟著這個應用場景,看一下AndroidView的使用方式和實現原理。
1.2. AndroidView使用方式
AndroidView的使用方式和MethodChannel類似,比較簡單,主要分為三個步驟:
第一步:在dart程式碼的相應位置使用AndroidView,使用時需要傳入一個viewType
,這個String將用於唯一標識該Widget,用於和Native的View建立關聯。
第二步:在native側新增程式碼,寫一個PlatformViewFactory,PlatformViewFactory的主要任務是,在create()
方法中建立一個View並把它傳給Flutter(這個說法並不準確,但是我們姑且可以這麼理解,後續會進行解釋)
第三步:使用registerViewFactory()
方法註冊剛剛寫好的PlatformViewFactory,該方法需要傳入兩個引數,第一個引數需要和之前在Flutter端寫的viewType
對應,第二個引數是剛剛寫好的的PlatformViewFactory。
配置高德地圖的部分這裡就省略不說了,官方有比較詳細的文件,可以去高德開發者平臺進行查閱。
以上便是使用AndroidView的所有操作,總體看起來還是比較簡單的,但是真正要用起來,還是有兩個無法忽視的問題:
- View最終的顯示尺寸由誰決定?
- 觸控事件是如何處理的?
下面就讓小閒魚來給各位一一解答。
2. 原理講解
想要解決上面的兩個問題,首先必須得理解所謂"傳View"的本質是什麼?
2.1. 所謂"傳View"的本質是什麼?
要解決這個問題,自然避免不了的需要去閱讀原始碼,從更深的層面去看這個傳遞的整個過程,可以整理出一張這樣的流程圖:
我們可以看到,Flutter最終拿到的是native層返回的一個textureId。根據native的知識ky h這個textureId是已經在native側渲染好了的view的繪圖資料對應的ID,通過這個ID可以直接在GPU中找到相應的繪圖資料並使用,那麼Flutter是如何去利用這個ID的呢?
在之前的深入瞭解Flutter介面開發中,也給大家介紹了Flutter的繪圖流程。我這裡也給大家再簡單整理一下
Flutter的Framework層最後會遞交給Engine層一個layerTree,在管線中會遍歷layertree的每一個葉子節點,每一個葉子節點最終會呼叫Skia引擎完成介面元素的繪製,在遍歷完成後,在呼叫glPresentRenderBuffer(IOS)或者glSwapBuffer(Android)按完成上屏操作。
Layer的種類有很多,而AndroidView則使用的是其中的TextureLayer。TextureLayer在之前的《Flutter外接紋理》中有更為詳細的介紹,這裡就不再贅述。TextureLayer在被遍歷到時,會呼叫一個engine層的方法SceneBuilder::addTexture()
將textureId作為引數傳入。最終在繪製的時候,skia會直接在GPU中根據textureId找到相應的繪製資料,並將其繪製到螢幕上。
那麼是不是誰拿到這個ID都可以進行這樣的操作呢?答案當然是否定的,Texture資料儲存在建立它的EGLContext對應的執行緒中,所以如果在別的執行緒進行操作是無法獲取到對應的資料的。這裡需要引入幾個概念:
- 螢幕物件(Display):提供合理的顯示器的畫素密度和大小的資訊
- Presentation:它給Android提供了在對應的上下文(Context)和螢幕物件(Display)上繪製的能力,通常用於雙屏異顯。
這裡不展開講解Presentation,我們只需要明白Flutter是通過Presentation實現了外接紋理,在建立Presentation時,傳入FlutterView對應的Context和建立出來的一個虛擬螢幕物件,使得Flutter可以直接通過ID找到並使用Native建立出來的紋理資料。
2.2. View最終的顯示尺寸由誰決定?
通過上面的流程大家應該都能想到,顯示尺寸看起來像是由兩部分決定的:AndroidView的大小,Android端View的大小。那麼實際上到底是有誰來決定的呢,讓我們來做一個實驗?
直接新建一個Flutter工程,並把中間改成一個AndroidView。
//Flutter
class _MyHomePageState extends State<MyHomePage> {
double size = 200.0;
void _changeSize() {
setState(() {
size = 100.0;
});
}
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text(widget.title),
),
body: Container(
color: Color(0xff0000ff),
child: SizedBox(
width: size,
height: size,
child: AndroidView(
viewType: 'testView',
),
),
),
floatingActionButton: new FloatingActionButton(
onPressed: _changeSize,
child: new Icon(Icons.add),
),
);
}
}
複製程式碼
在Android端也要加上對應的程式碼,為了更好地看出裁切效果,這裡使用ImageView。
//Android
@Override
public PlatformView create(final Context context, int i, Object o) {
final ImageView imageView = new ImageView(context);
imageView.setLayoutParams(new ViewGroup.LayoutParams(500,500));
imageView.setBackground(context.getResources().getDrawable(R.drawable.idle_fish));
return new PlatformView() {
@Override
public View getView() {
return imageView;
}
@Override
public void dispose() {
}
};
}
複製程式碼
首先先看AndroidView,AndroidView對應的RenderObject是RenderAndroidView,而一個RenderObject的最終大小的確定是存在兩種可能,一種是由父節點所指定,還有一種是在父節點指定的範圍中根據自身情況確定大小。開啟對應的原始碼,可以看到其中有個很重要的屬性sizedByParent = true
,也就是說AndroidView的大小是由其父節點所決定的,我們可以使用Container、SizedBox等控制元件控制AndroidView的大小。
AndroidView的繪圖資料是Native層所提供的,那麼當Native中渲染的View的實際畫素大小大於AndroidView的大小時,會發生什麼呢?通常情況下,這種情況的處理思路無非就兩種選擇,一種是裁切,另一種是縮放。Flutter保持了其一貫的做法,所有out of the bounds的Widget統一使用裁切的方式進行展示,上面所描述的情況就被當作是一種out of the bounds。
當這個View的實際畫素大小小於AndroidView的時候,會發現View並不會相應地變小(Container的背景色並沒有顯露出來),沒有內容的地方會被白色填充。這其中的原因是SingleViewPresentation::onCreate中,會使用一個FrameLayout作為rootView。
2.3. 觸控事件如何傳遞
Android的事件流大家應該都很熟悉了,自頂向下傳遞,自底向上處理或迴流。Flutter同樣是使用這一規則,但是其中AndroidView通過兩個類來去處理手勢:
MotionEventsDispatcher:負責將事件封裝成Native的事件並向Native傳遞;
AndroidViewGestureRecognizer:負責識別出相應的手勢,其中有兩個屬性:
cachedEvents
和forwardedPointers
,只有當PointerEvent的pointer屬性在forwardedPointers中時才會去進行分發,否則會存在cacheEvents中。這裡的實現主要是為了解決一些事件的衝突,比如滑動事件,可以通過gestureRecognizers來進行處理,這裡可以參考官方註釋。
/// For example, with the following setup vertical drags will not be dispatched to the Android view as the vertical drag gesture is claimed by the parent [GestureDetector].
///
/// GestureDetector(
/// onVerticalDragStart: (DragStartDetails d) {},
/// child: AndroidView(
/// viewType: 'webview',
/// gestureRecognizers: <OneSequenceGestureRecognizer>[],
/// ),
/// )
///
/// To get the [AndroidView] to claim the vertical drag gestures we can pass a vertical drag gesture recognizer in [gestureRecognizers] e.g:
///
/// GestureDetector(
/// onVerticalDragStart: (DragStartDetails d) {},
/// child: SizedBox(
/// width: 200.0,
/// height: 100.0,
/// child: AndroidView(
/// viewType: 'webview',
/// gestureRecognizers: <OneSequenceGestureRecognizer>[ new VerticalDragGestureRecognizer() ],
/// ),
/// ),
/// )
複製程式碼
所以總結起來,這部分流程總結起來其實也很簡單:事件最初從Native到Flutter這一階段不在本文的討論範圍之內,Flutter按照自己的規則去處理事件,如果AndroidView贏得了事件,事件就會被封裝成相應的Native端的事件並且通過方法通道傳回Native,Native再根據自己的處理事件的規則去處理。
3. 總結
3.1. 方案侷限性
往大里說:這套方案是Google為了解決開發者日益增長的業務需求與落後的生態環境之間的矛盾而產生的,這一矛盾是一個新生態必然需要去面對的主要矛盾。為了解決這一個問題,最簡單的方式當然就是允許開發者使用老生態中已經非常成熟的控制元件。當然,這樣是可以臨時解決Flutter生態發展不全面的問題,但是使用這套方案不可避免的需要去編寫雙端程式碼(甚至現在iOS還沒有對應的控制元件,當然之後肯定會更新),不能做到真正的跨端。
往小裡說:這套方案存在著效能上的缺陷,在AndroidView這個類的第三句註釋中,官方就已經提到了這是一套比較昂貴的方案,避免在使用Flutter控制元件也能實現的情況下去使用它。如果之前有看過《Flutter外接紋理》這一文章的同學應該知道,Flutter實現外接紋理的方案中,資料從GPU->CPU->GPU的過程代價是比較大的,在大量使用的場景會造成明顯的效能缺陷。我們通過一些手段繞過了中間CPU這一步,並且將這項技術在APP中落地,用於處理圖片資源。
3.2. 實際應用
目前閒魚從Native向Flutter的遷移工作遇到了Native的本地圖片資源在Flutter側無法訪問的問題,在現在Flutter和Native必將長期共存的情況下,重新拷貝一份資源以Flutter的規則來儲存當然可以,但是不可避免地增大了包體積,而且不好管理。
面對這個問題,我們的解法便是借鑑了AndroidView使用Texture的思路並在將其優化。實現了Native和Flutter的圖片資源歸一化。除了用於載入位於Native資源目錄下的本地圖片之外,還可以利用Native的圖片庫來載入網路圖片。
我們這麼去做的原因是我們在Native側的圖片庫較為完善並且經受過大量的線上考驗,現在這一階段,我們不希望將過多的精力投入到重複造輪子這一件事上,而處理網路圖片資源和處理本地圖片資源的思路其實是一樣的,所以我們選擇將圖片資源進行了統一地整合,在與官方的團隊進行溝通並完善後會和大家同步,敬請關注我們的公眾號。