干货 | Flutter 地图在携程的最佳实践

手机APP/开发
296
0
0
2024-01-16
标签   Flutter

作者简介

Leo,携程高级移动开发工程师,关注跨端技术,致力于高效、高性能开发。

Jarmon,携程高级移动开发工程师,专注 Flutter、iOS 开发。

一、背景

随着各种多端技术的蓬勃发展,项目主体从纯 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 端集成

获取到官方 demo 后在该目录下执行 flutter pub get,然后去 flutter SDK 下找到 pub-cache 依赖缓存文件目录,根据业务需要将每个插件 src 文件下的代码导入到 flutter 工程中。

2.2 IOS 端集成

执行完 flutter pub get 后,根据需要将每个插件 iOS/Classes/ 目录下的代码导入工程中。

2.3 Android 端集成

Android 的 Native 侧的集成和 IOS 端是类似的。在 Native 工程中新建一个地图 Module。把地图 Demo 中的地图插件源码 Android 部分放入工程即可。

三、地图插件实现原理:platformView

地图插件按功能分为 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 的渲染流程。

在收到 VSync 信号之后,Dart 层在 UI Thread 完成 Widget Tree、Element Tree、RenderObject Tree 三棵树的更新与生成,然后生成包含绘制信息的 layer Tree 交给 Engine 去渲染,最后在 GPU Thread 经历 Compositor、Skia 将 flutter 视图渲染出来。

2)Hybrid Composition 模式分析

以 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 是如何实现帧同步?

在原生开发中,我们知道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()

查看调用链:

逐个类读源码我们发现在 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 可以很明显的看出来,每打开一次页面,内存占有都会上升,结束页面内存没有得到释放。

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 对象空了,后面的流程都不会执行。

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 计算,要区分平台做一次转换。

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地图插件有一定的帮助。