天天看點

Flutter 地圖在攜程的最佳實踐

作者:閃念基因

一、背景

随着各種多端技術的蓬勃發展,項目主體從純 Native 項目,到 Native+RN,到現在的 Native+RN+Flutter。基于我們的業務都在 Flutter 技術棧上面,這要求我們需要嵌套展示地圖。目前,實作嵌套展示地圖的主要方案有二個:

接入官方提供的 Flutter 地圖插件,主要面臨的問題有:

  • 官方提供的插件成熟度不夠,有一些 Native 已有的 API 在 Flutter 上不支援;
  • 目前接入 Flutter 地圖插件的應用很少,我們需要去蹚雷。
  • 由于官方适配的是純 Flutter 項目,混合工程可能遇到很多未知棘手問題。

直接在 Flutter 頁面上展示 Native 的地圖:

  • Native 地圖成熟,不會遇到很大的坑;
  • 主要問題在于業務在 Flutter上,Flutter 需要大量的和地圖元件進行互動、請求資料、關聯。需要通過大量的橋方法去傳遞操作資料;
  • 要嵌套 Native 地圖需要定制容器,Android 和 IOS 上各自得實作一遍橋、容器和地圖邏輯,增加了維護成本。

考慮維護成本、權衡再三我們還是選擇接入 Flutter 地圖插件。為了能更好的定制一些 API 和更快速的修複一些官方沒有及時更新的問題。我們采用的是源碼接入 Flutter 地圖插件。本文将重點突出基于 flutter-boost 的混合工程,單引擎模式下接入 Flutter 地圖插件遇到的問題和解決方案。

二、如何源碼內建

在混合項目中內建插件主要分 flutter 和原生兩側,內建 Flutter 插件時,官方 demo 中可以直接下載下傳到插件的源碼。本文以接入 flutter 地圖插件 3.3.1 版本示例。

2.1 Flutter 端內建

Flutter 地圖在攜程的最佳實踐

擷取到官方 demo 後在該目錄下執行 flutter pub get,然後去 flutter SDK 下找到 pub-cache 依賴緩存檔案目錄,根據業務需要将每個插件 src 檔案下的代碼導入到 flutter 工程中。

2.2 IOS 端內建

Flutter 地圖在攜程的最佳實踐

執行完 flutter pub get 後,根據需要将每個插件 iOS/Classes/ 目錄下的代碼導入工程中。

2.3 Android 端內建

Android 的 Native 側的內建和 IOS 端是類似的。在 Native 工程中建立一個地圖 Module。把地圖 Demo 中的地圖插件源碼 Android 部分放入工程即可。

三、地圖插件實作原理:platformView

Flutter 地圖在攜程的最佳實踐

地圖插件按功能分為 Map、Search、Util 等子產品,其基本實作類似,使用 MethodChannel 與 native 通信,我們以 Map 為例分析其實作。插件使用了 PlatformView 将原生地圖嵌入到 flutter 頁面中,在 flutter 層為 UIKitView、AndroidView,native 在生成地圖後根據 viewId 初始化 BMFMapViewController,包含對應的 MethodChannel。BMFMapViewController 聚合了對地圖操作,派發到不同子產品調用地圖 native 方法。

3.1 什麼是PlatformView

PlatformView 是允許原生元件嵌入到 Flutter 頁面的一種技術,能夠讓我們将一些原生成熟元件、flutter UI 架構難以實作的地圖、WebView 等元件展示在 flutter 頁面中。

Flutter 提供了 Virtual Display、Hybrid Composition 兩種方式實作 PlatformView。Virtual Display 模式将 native view 加載到記憶體當中,随着 flutter Widget 一起渲染出來。Hybrid Composition 模式是直接将 native view 添加到 flutter view 圖層上。iOS采用了 Hybrid Composition 模式,Android 采用了 Virtual Display 和 Hybrid Composition 兩種模式。

3.2 PlatformView 實作原理

1)flutter 渲染流程

在介紹 Hybrid Composition 實作之前,先通過下圖大緻了解下 flutter 的渲染流程。

Flutter 地圖在攜程的最佳實踐

在收到 VSync 信号之後,Dart 層在 UI Thread 完成 Widget Tree、Element Tree、RenderObject Tree 三棵樹的更新與生成,然後生成包含繪制資訊的 layer Tree 交給 Engine 去渲染,最後在 GPU Thread 經曆 Compositor、Skia 将 flutter 視圖渲染出來。

2)Hybrid Composition 模式分析

Flutter 地圖在攜程的最佳實踐

以 iOS 為例逐漸分析 Hybird Composition 模式執行流程。首先 Dart 層提供了 UIKitView 元件來展示 native view,didChangeDependencies 方法中通過 channel 初始化一次 native view,生成唯一辨別 native view 的 viewId,并将 native view 緩存在 root_views_ 中。在實際組裝 layer 層時,dart 層會傳輸給 engine 展示 native view 的坐标和大小,并生成一個 PlatformViewLayer,也就是說 native view 的位置、大小資訊是由 dart 層控制的。

void FlutterPlatformViewsController::OnCreate(FlutterMethodCall* call, FlutterResult& result) {
  NSDictionary<NSString*, id>* args = [call arguments];
  long viewId = [args[@"id"] longValue];
  NSObject<FlutterPlatformView>* embedded_view = [factory createWithFrame:CGRectZero                                          viewIdentifier:viewId                                               arguments:params]; // 初始化
  UIView* platform_view = [embedded_view view]; 


  FlutterTouchInterceptingView* touch_interceptor = [[[FlutterTouchInterceptingView alloc]
                  initWithEmbeddedView:platform_view
               platformViewsController:GetWeakPtr()
gestureRecognizersBlockingPolicy:gesture_recognizers_blocking_policies[viewType]]
      autorelease];
  ChildClippingView* clipping_view =
      [[[ChildClippingView alloc] initWithFrame:CGRectZero] autorelease];
  [clipping_view addSubview:touch_interceptor];
  root_views_[viewId] = fml::scoped_nsobject<UIView>([clipping_view retain]); // 緩存
}           

生成目前幀的 Layer Tree 之後,會進入到 Rasterizer 流程。首先會調用 BeginFrame 渲染一幀,觸發 PlatformViewLayer::Preroll,PlatformViewLayer 标記出目前幀有 PlatformView ,然後調用 FlutterPlatformViewsController::PrerollCompositeEmbeddedView 更新 view_params_,包含 Platform View 坐标、size 等資訊,最後在 SubmitFrame 方法中取出 native view 添加到 flutter view 中,完成渲染。

void PlatformViewLayer::Preroll(PrerollContext* context,
                                const SkMatrix& matrix) {
  set_paint_bounds(SkRect::MakeXYWH(offset_.x(), offset_.y(), size_.width(),
                                    size_.height()));
  context->has_platform_view = true;
  set_subtree_has_platform_view(true); // 标記目前幀存在Platform View
  std::unique_ptr<EmbeddedViewParams> params =
      std::make_unique<EmbeddedViewParams>(matrix, size_,
                                           context->mutators_stack);  context->view_embedder->PrerollCompositeEmbeddedView(view_id_,
                                                       std::move(params));
}           

3.3 PlatformView 是如何實作幀同步?

Flutter 地圖在攜程的最佳實踐

在原生開發中,我們知道UI操作不能在其他線程執行,會出現幀不同步的問題。flutter Engine 中有 platform、ui、raster、io四個線程,native view 是在 Platform Thread(主線程)渲染,而 flutter 渲染正常情況在 Raster Thread 執行的,flutter 又是如何保證幀同步的呢?

flutter 解決幀同步是通過線程合并的方案。上圖 Raster 流程 PostPrerollAction 方法中,會判斷如果有 PlatformView 存在,在接下來的繪制過程中 Raster Thread 與 Platform Thread 會合并,将 Raster 隊列任務放到 Platform 隊列中。這樣所有的渲染任務都在 Platform Thread 中執行,保證了畫面的同步。

PostPrerollResult FlutterPlatformViewsController::PostPrerollAction(
    fml::RefPtr<fml::RasterThreadMerger> raster_thread_merger) {
  if (!HasPlatformViewThisOrNextFrame()) { // 沒有Platform View不用處理
    return PostPrerollResult::kSuccess;
  }
  if (!raster_thread_merger->IsMerged()) { // 線程還沒有并不用處理
    CancelFrame(); // 取消繪制目前幀
    return PostPrerollResult::kSkipAndRetryFrame; // 合并後完成目前幀
  }
  BeginCATransaction();
  raster_thread_merger->ExtendLeaseTo(kDefaultMergedLeaseDuration);
  return PostPrerollResult::kSuccess;
}
// 合并隊列
bool MessageLoopTaskQueues::Merge(TaskQueueId owner, TaskQueueId subsumed) {
  if (owner == subsumed) {
    return true;
  }
  std::lock_guard guard(queue_mutex_);
  auto& owner_entry = queue_entries_.at(owner);
  auto& subsumed_entry = queue_entries_.at(subsumed);
  auto& subsumed_set = owner_entry->owner_of;
  if (subsumed_set.find(subsumed) != subsumed_set.end()) {
    return true;
  }
  owner_entry->owner_of.insert(subsumed);
  subsumed_entry->subsumed_by = owner;
  if (HasPendingTasksUnlocked(owner)) {
    WakeUpUnlocked(owner, GetNextWakeTimeUnlocked(owner));
  }
  return true;
}           

四、問題及解決方案

4.1 IOS 頁面切換 Map 元件白屏問題

在使用 flutter_boost 混合開發時,當 A 頁面中使用 platformview,開啟新容器跳轉到 flutter B 頁面,platformView 會出現短暫的白屏,從 A 頁面跳轉 native 頁面不會出現。根據表象首先猜測是單引擎導緻的。flutter A頁面跳轉到其他頁面時都會觸發 SceneBuilder::pushTransform 重新渲染一次 A 頁面。

void SceneBuilder::pushTransform(Dart_Handle layer_handle,
                                 tonic::Float64List& matrix4,
                                 fml::RefPtr<EngineLayer> oldLayer) {
  SkMatrix sk_matrix = ToSkMatrix(matrix4);
  auto layer = std::make_shared<flutter::TransformLayer>(sk_matrix);
  PushLayer(layer);
  // matrix4 has to be released before we can return another Dart object
  matrix4.Release();
  EngineLayer::MakeRetained(layer_handle, layer);
  if (oldLayer && oldLayer->Layer()) {
    layer->AssignOldLayer(oldLayer->Layer().get());
  }
}           

flutter A頁面在建立新容器 push 到 flutter B 頁面時,首先會觸發 viewDidLayoutSubviews,方法内部會修改 engine 對應的 viewController flutterView,SceneBuilder::pushTransform 是在 viewDidLayoutSubviews 之後還會觸發,而 platformView 是在 native 渲染,重新渲染 A 頁面時就找不到對應的 platformView,導緻白屏的問題。push 到非 flutter 頁面時不會觸發 surfaceUpdated,是以不會出現該問題。

- (void)viewDidLayoutSubviews {
  ...
  if (firstViewBoundsUpdate && applicationIsActive && _engine) {
    [self surfaceUpdated:YES];
  }
  ...
}
- (void)surfaceUpdated:(BOOL)appeared {
  if (appeared) {
    [self installFirstFrameCallback];
    [_engine.get() platformViewsController]->SetFlutterView(_flutterView.get());
    [_engine.get()     platformViewsController]->SetFlutterViewController(self);
    [_engine.get() iosPlatformView]->NotifyCreated();
  }
}           

一開始的方案是在 viewWillAppear 中調用 sufaceUpdated,但是在 release 環境中會出現卡死的現象。另一方案是 [super bridge_viewWillAppear:animated]; 改為 [super viewWillAppear:animated]; [super viewWillAppear:animated]; 會調用父類的方法,父類方法又會調用 sufaceUpdated,就可以解決白屏的問題。

4.2 Android 地圖卡死不能操作問題

1)問題描述

A 頁面内嵌地圖,跳轉到 B 頁面。然後傳回 A 頁面,地圖就不能滑動。

結合上文提到的 Flutter 地圖插件其實是通過 MathodChannel 将操作傳遞到 Native 的地圖視圖處理的。我們調試 Native 的代碼發現 PlatformViewsController 類裡面的 onTouch()方法中,context 報了一個Attempt to invoke virtual method 'android.content.res.Resources android.content.Context.getResources()' on a null object reference。

public void onTouch(@NonNull PlatformViewsChannel.PlatformViewTouch touch) {
          final float density = context.getResources().getDisplayMetrics().density;
          }           

2)分析問題

由于 context 對象被回收,造成的報錯。現在我們隻有分析出來為什麼 context 對象會被回收掉了就能找出問題了,讀源碼發現隻有在 detach() 方法中才會回收 context 對象。

public void detach() {
    context = null;
  }           

結合日志輸出,确實發現回到 A 頁面是執行了 attach() 方法,但是馬上又執行了 detach() 方法。現在就是要找出,為什麼 A 頁面的 PlatformViewsController 會被執行 datach()。

從B頁面 傳回A頁面
2022-08-22 15:13:08.126 21878-21878/ctrip.flutter.demo D/PlatformViewsController: B===>detach()
2022-08-22 15:13:08.135 21878-21878/ctrip.flutter.demo D/PlatformViewsController: A====>attach()
2022-08-22 15:13:08.249 21878-21878/ctrip.flutter.demo D/PlatformViewsController: A=====>detach()           

檢視調用鍊:

Flutter 地圖在攜程的最佳實踐

逐個類讀源碼我們發現在 FlutterActivityAndFragmentDelegate的OnDetach() 方法中如果引擎的生命周期和 Activity 的生命周期是綁定的。頁面結束時,引擎就會被銷毀掉。

void onDetach() {
    if (host.shouldAttachEngineToActivity()) {
      if (host.getActivity().isChangingConfigurations()) {
flutterEngine.getActivityControlSurface().detachFromActivityForConfigChanges();
} else {
flutterEngine.getActivityControlSurface().detachFromActivity();
      }
    }           

3)解決問題

設定 shouldAttachEngineToActivity 傳回 flase 使得 Flutter 引擎将在應用程式的整個生命周期内持久化存在,并獨立于 Activity,當 Activity 被銷毀時,Flutter 引擎不被銷毀 。問題就解決了。産生問題的原因是我們新開 B 頁面是通過新開容器的方式建立的。B 頁面 FlutterFragment 中 onDetach() 方法在 A 頁面 onAttach() 之後被執行的。純 Flutter 工程或者是采用 Push 的方式打開新頁面,不新開容器都能規避掉這個問題。

public boolean shouldAttachEngineToActivity() {
        return false;
    }           

4.3 Android 地圖記憶體溢出問題

1)問題描述

多次打開 Android Flutter 地圖頁面會越來越卡,到後面整個地圖都黑一下,顯然是有記憶體溢出了。通過 Android Studio IDE 自帶的記憶體工具 Android Profiler 可以很明顯的看出來,每打開一次頁面,記憶體占有都會上升,結束頁面記憶體沒有得到釋放。

Flutter 地圖在攜程的最佳實踐

2)分析問題

Flutter Boost 和地圖插件如此大量的第三方代碼,我們如何去定位問題呢?是插件引起的,還是架構引起的呢?借助 LeakCanary 就能很好的找到記憶體洩露的地方了。

接入也非常的簡單,在 Android build.gradle引入leakcanary。

debugImplementation'com.squareup.leakcanary:leakcanary-android:2.6'           

然後運作應用,反複操作問題複現流程,直到 LeakCanary 提示。檢視 leaks 記憶體溢出的堆棧資訊。是由于 SingleViewPresentation 一直持有了容器 TripFlutterActivity 的 context 對象。懷疑是 MapView 的生命周期有問題。是不是沒有執行 dispose。調試下來的情況 PlatformViewsHandler handler 對象空了,後面的流程都不會執行。

Flutter 地圖在攜程的最佳實踐

3)解決問題

檢視源碼隻有 PaltformViewsController detach() 方法會把 handler 設定為 null。

public void detach() {
    if (platformViewsChannel != null) {
      platformViewsChannel.setPlatformViewsHandler(null);
    }
    }           

調試下來 FlutterActivity 容器結束,調用了 onDestroy() 方法的時候 PaltformViewsController detach() 就已經被執行了。容器的 onDestroy() 在 MapView 的 dispos e之前,造成了 handler 對象空了。

解決問題的思路很簡單,在 onDestroy() 的時候先保留 handler 對象,然後找個時機清除一下。采用 viewIdSet 自己維護一份 View 的資料。在 creat 方法中 disposeArgs.get("id") 執行過 dispose 方法的就删除掉 viewIdSet.remove(viewId)。setPlatformViewsHandler 為空的情況判斷一下,有沒有執行 dispose 的 view handler 先不回收。如下:

public void setPlatformViewsHandler(@Nullable PlatformViewsHandler handler) {
    if(handler == null && viewIdSet != null && viewIdSet.size() > 0) {
      needReset = true;
      return;
    }
    this.handler = handler;
  }           

目前是執行 dispose 的時候 needReset 為 true 時會将 handler 設定為 null。為什麼官方的 Demo 是沒有問題的呢?主要原因還是我們接入了 FlutterBoost 預設是單引擎的,官方 Demo 是的純 Flutter 項目多引擎。頁面結束,通過銷毀 engine 把問題覆寫了,是以記憶體回收表現的很平滑。

五、自定義文本 BitMap Marker

地圖業務中自定義 marker 是比較常見的需求,由于地圖是通過 PlatformView 實作的,最容易想到的做法是,通過 Channel 傳入 marker 對應的樣式 Id 和展示所需資料,在各端繪制 marker,這種做法會增加人工成本,樣式也可能存在不一緻的情況,失去了 flutter 架構的優勢。

地圖插件在 v3.0(v3.0 之前需要自己實作)提供了 iconData 參數傳入圖檔 data 資訊,在 flutter 側将文本、圖檔繪制出來生成一張圖,将生成圖檔 Data 傳遞給原生,該實作并不需要改動各端代碼,繪制時要注意視圖大小是實體像素點,而不是邏輯像素點。

Future<Uint8List?> customMark(String name, BuildContext context) async {
  final scale = MediaQuery.of(context).devicePixelRatio;
  final recorder = PictureRecorder();
  final canvas = Canvas(recorder);
  final paint = Paint();
  final textPainter = TextPainter(textDirection: TextDirection.ltr);
  ...
  final path = Path();
  canvas.drawPath(path, paint);
  // 繪制圖檔
  final imageInfo = await UIImageLoader.imageInfoByAsset(HotelListImage.mapPoiMark);
  paintImage(canvas: canvas,rect: rect,image: imageInfo.image);
  // 生成繪制圖檔
  final image = await recorder.endRecording().toImage(
      width.toInt(), (textBgHeight + arrowHeight + iconHeight + 2).toInt());
  final data = await image.toByteData(format: ImageByteFormat.png);
  return data?.buffer.asUint8List();
}           

從 flutter 2 更新到 flutter 3 出現了小插曲,iOS debug 環境調用 toImage 程序會被終止。flutter 更新之後對弱引用指針調用做了線程檢查,建立和使用不是在同一線程在 debug 環境程序會被終止。toImage() 方法内使用了 fml::WeakPtr<SnapshotDelegate> snapshot_delegate 弱引用指針,由于 snapshot_delegate 在 raster 線程中被建立,正常調用也應該是在 raster 線程,當在 flutter 頁面中嵌入 PlatformView 時,為了保證渲染的一緻性,會将 raster 線程與主線程合并,造成了 snapshot_delegate 在主線程調用的情況,觸發了線程檢查終止程序,但并不影響 release 環境。

class WeakPtr {
    T* operator->() const {
    CheckThreadSafety();
    return get();
  }
}


if (0 == pthread_getname_np(current_thread, actual_thread,
                                  buffer_length) &&
          0 == pthread_getname_np(self_, expected_thread, buffer_length)) {
        FML_DLOG(ERROR) << "IsCreationThreadCurrent expected thread: '"
                        << expected_thread << "' actual thread:'" // Object被建立的線程
                        << actual_thread << "'";  // 實際執行線程
}           

六、自定義讓 Marker 展示在可見範圍

在地圖上添加 marker 之後,将已添加的 marker 全部展示在可視範圍内也是常見的需求。插件提供了支援 iOS 的 showmarkers 方法,這顯然不能夠滿足需求。我們思考通過 setVisibleMapRectWithPadding 指定顯示地圖地理範圍,該方法要求我們傳入參數 visibleMapBounds,設定地理範圍的東北坐标、西南坐标。由于右上角、左下角經緯度分為可視地理範圍最大、最小,即可拿到東北、西南坐标。

BMFCoordinateBounds? getMarkersVisibleMapBounds(List<BMFMarker> markers) {
  if (markers.isEmpty) return null;
  final firstPosition = markers.first.position;
  double maxLatitude = firstPosition.latitude;
  double minLatitude = firstPosition.latitude;
  double maxLongitude = firstPosition.longitude;
  double minLongitude = firstPosition.longitude;
  for (final marker in markers) {
    final lat = marker.position.latitude;
    final lon = marker.position.longitude;
    maxLatitude = max(maxLatitude, lat);
    minLatitude = min(minLatitude, lat);
    maxLongitude = max(maxLongitude, lon);
    minLongitude = min(minLongitude, lon);
  }
  return BMFCoordinateBounds(
      northeast: BMFCoordinate(maxLatitude, maxLongitude),
      southwest: BMFCoordinate(minLatitude, minLongitude));
}           

随着業務的疊代,需要将大地圖融合到清單中。為了将大地圖與小地圖切換動畫更加流暢,當小地圖被加載時,地圖 size 實際已經渲染成和大地圖同樣大小,下半部分被清單遮擋。這意味小地圖需要設定可見範圍的偏移量,但 inserts 參數 iOS、Android 計算方式不一樣,iOS 是根據 point 計算,Android 是通過 pixel 計算,要區分平台做一次轉換。

Flutter 地圖在攜程的最佳實踐
Flutter 地圖在攜程的最佳實踐
Future<bool> setAllMarkersVisibleWithPadding(
  List<BMFMarker> markers,
  BuildContext context, {
  EdgeInsets insets = const EdgeInsets.all(20.0),
}) async {
  final bounds = getMarkersVisibleMapBounds(markers);
  if (bounds == null) return false;
  if (Util.isAndroid()) {
    final scale = MediaQuery.of(context).devicePixelRatio;
    insets = EdgeInsets.only(
        top: insets.top * scale,
        bottom: insets.bottom * scale,
        left: insets.left * scale,
        right: insets.right * scale);
  }
  return await setVisibleMapRectWithPadding(
      visibleMapBounds: bounds, insets: insets, animated: true);
}           

七、總結

Flutter 地圖插件基于Native地圖 Android 和 iOS SDK 二次封裝而成,通過在 Flutter 使用MethodChannel互動實作地圖的顯示、互動、覆寫物繪制和事件響應等功能。混合項目接入Flutter地圖容易發生問題的點,基本集中在PlatformView這一塊。通常是容器和View的事件、生命周期同步問題。

本文主要介紹FlutterBoost的混合工程,在接入Flutter地圖插件遇到的各種問題和解決方案。闡述了PlatformView的工作原理,友善我們更好的了解Flutter地圖插件。同時也介紹了如何用Android Studio 自帶的工具直覺地看記憶體異常。并且推薦leakcanary定位記憶體溢出的類和方法,希望對你接入Flutter地圖插件有一定的幫助。

作者:

Leo,攜程進階移動開發工程師,關注跨端技術,緻力于高效、高性能開發。

Jarmon,攜程進階移動開發工程師,專注 Flutter、iOS 開發。

來源:微信公衆号:攜程技術

出處:https://mp.weixin.qq.com/s/FBaIYs9Jncp_79KgNJEwNg