MeshEngine 跨平台 UI 框架讨论:Flutter VS RN

首先需要明确我们为什么要调研跨平台方案:目前的 MeshEngine 技术方案非常原始,开发体验差,效率差。因此我们想要找到一个更好的跨平台方案实现 MeshEngine。因此重点不是对比 Flutter 与 RN 的优劣,而是对于目前团队而言,哪一个是更好的迁移方案。
总结一下 MeshEngine 的业务目标:

  • 支持一套代码在 iOS 和 Android 端构建 UI,支持客户配置、执行飞行任务
  • 支持高效率的 UI 开发
  • Native 封装无人机 SDK,提供接口给 package 控制无人机
  • 支持 Native 控件经过封装嵌入到页面中。因为地图组件和视频流展示组件由于技术限制必须是 native 实现。

基于以上的几个出发点我们来对比两个框架哪个好。

UI 构建

Flutter 的渲染使用统一的高性能 skia 引擎,直接调用图形接口绘制。可以保证一套代码在各端的 UI 一致性。因为直接使用图形接口,性能可以接近原生。也避免了对原生控件的依赖。目前 Chrome 浏览器和 Android 也是使用 skia 作为绘图引擎,是一个很成熟的引擎。
RN 没有自己的绘制引擎,而是封装 native 的绘图方法和控件通过 JsBridge 提供给 JS 调用。JS 侧完成布局的逻辑后,RN 会通过 JsBridge 输出 diff 后的 UI tree 数据,原生根据最后的 UI tree 构建 UI。总结起来有两个重点:UI 布局使用原生提供的能力,通过 JsBridge 传输数据。
因为 JS 是一门解释型语言,每次 UI 的变动都需要通过 JsBridge 和 native 进行通信,这里有一个必然的性能上限,因此手势、动画这样的高级交互在 RN 目前的体系下不可能被解决好的。
布局最后使用的是原生的方法,因此一套代码不能保证展现在最后的 UI 一致性。所谓的 perfect pixel 是做不到的。可以粗暴的理解为 HTML 和浏览器的关系。同样的 HTML + CSS 在不同的浏览器上最后展示的效果不一定是百分百一致的。同理,RN 最后输出了一组 DOM,但是各端有自己的渲染方式,最后的渲染的结果可能有细微差别。如果这个布局里还使用是各端自己的控件,结果就更不可能一样了。

总结

在 UI 布局性能上和一致性上,Flutter 有着绝对的优势。如果只有绘制跨平台 UI 这么一个场景,技术上 flutter 无疑是最好的选择。
RN 虽然开发性能、开发效率上低一些(需要在各个端上查看 UI 是否符合预期),但是目前 MeshEngine 的业务场景里对 UI 性能要求不高。针对企业定制的 UI 交互上没有什么追求,简洁清晰易用就好。UI 也不复杂,因此 RN 也够用。

学习成本

Flutter 框架指定 Dart 作为开发语言,据说是需要一门语言同时支持 JIT 和 AOT 编译,加上又是自家人所以选择了 Dart。道理我都懂,但不管怎么样这是一门全新的语言。
Flutter 自身的布局方案也是一切皆 widget,布局、元素、事件响应全写在一起。嵌套一时爽,维护火葬场。这又是一种全新的写 UI 的方式。
我个人认为 Flutter 这种写 UI 的思路未来肯定是要大改一次的。
所以全新的写 UI 思路 + 全新的语言 + 大概率未来需要重写一次。这个成本还是挺大的。
RN 在这一点上优势很明显,前端圈熟悉的技术栈,JS + React。对于前端开发者而言,上手很轻松。
需要再解释一下这里的学习成本考核的思路,假设一个只写过移动端原生的开发者而言,学习 RN 这一套成本应该和 Flutter 是类似的,因为也是学习一个全新的语言加一个全新的框架。
这边需要结合大前端技术体系下的现状做一个思考:跨平台 UI 的开发的工作是三端共同承担的,本来对于前端而言,可以用熟悉的技术体系,使用 Flutter 却需要再学习一套技术。对于移动端的原生开发者而言,学习 RN 后,再接触前端其他的框架,也会觉得有一些相似之处。无形之中对于前端的框架思想有了一次接触。但是 Flutter 则是一块飞地。这套技术理念,在其他地方没有用武之地。团队希望最后移动端的开发者对另外两端技术体系有一定的了解,在这个目标下,使用 RN 有着更低的学习成本。

混合项目支持

两个框架最后都可以做到通过依赖管理工具以组件的形式引入到现有项目中。虽然从结果上看都支持,但是 flutter 官方没有直接支持这样的操作,需要通过一点点 hack 的方式。但是相信这应该只是 flutter 起步早导致的问题,最后官方应该会提供一种更优雅的方式集成。

与 native 通信

Flutter

Flutter 使用 PlatformChannel 进行与 native 之间的消息传递。
Client 通过 PlatformChannel 向 Host 发送消息。Host 监听 PlatformChannel,接受消息,然后将响应结果发送给 Client。这样就完成了 Flutter 与 Native 之间的消息传递。而且 Flutter 与 Native 之间的消息传递都是异步的,所以不会阻塞 UI。而且由于 PlatformChannel 是双工的,所以 Flutter 和 Native 可以互相做 Client 和 Host
PlatformChannel 通常使用有两类:MethodChannel、EventChannel。
MethodChannel 用于方法调用,调用完支持回调。EventChannel 用于持续的数据传递。
来看一下方法 MethodChannel 的例子。
Flutter 端声明一个 MethodChannel,注意这里要保证 channel 使用唯一的标识符:

class _MyHomePageState extends State<MyHomePage> {
  static const platform = const MethodChannel('samples.flutter.io/battery');

  // Get battery level.
  String _batteryLevel = 'Unknown battery level.';

  Future<Null> _getBatteryLevel() async {
    String batteryLevel;
    try {
      final int result = await platform.invokeMethod('getBatteryLevel');
      batteryLevel = 'Battery level at $result % .';
    } on PlatformException catch (e) {
      batteryLevel = "Failed to get battery level: '${e.message}'.";
    }

    setState(() {
      _batteryLevel = batteryLevel;
    });
  }
}

Dart 支持 Future 写法,前面说过消息的传递是是异步的,所以这里用了 future 的写法。
每个方法都使用字符串来标识,有点原始。
再来看下 iOS 这边的代码:

#import <Flutter/Flutter.h>

 AppDelegate
- (BOOL)application:(UIApplication*)application didFinishLaunchingWithOptions:(NSDictionary*)launchOptions {
  FlutterViewController* controller = (FlutterViewController*)self.window.rootViewController;

  FlutterMethodChannel* batteryChannel = [FlutterMethodChannel
                                          methodChannelWithName:@"samples.flutter.io/battery"
                                          binaryMessenger:controller];

  [batteryChannel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {

  }];

  return [super application:application didFinishLaunchingWithOptions:launchOptions];
}

把处理方法调用的函数单独抽出来:

[batteryChannel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
  if ([@"getBatteryLevel" isEqualToString:call.method]) {
    int batteryLevel = [self getBatteryLevel];

    if (batteryLevel == -1) {
      result([FlutterError errorWithCode:@"UNAVAILABLE"
                                 message:@"Battery info unavailable"
                                 details:nil]);
    } else {
      result(@(batteryLevel));
    }
  } else {
    result(FlutterMethodNotImplemented);
  }
}];

消息间的传递使用 Native Bindding 实现,因此性能很好。

RN

RN 可以通过特性标记把 native 类和方法直接暴露给 JS。
比如我们要把 iOS 的 CalendarManager 暴露给 JS:

// CalendarManager.h
#import <React/RCTBridgeModule.h>

 CalendarManager : NSObject <RCTBridgeModule>



// CalendarManager.m
#import "CalendarManager.h"

 CalendarManager

RCT_EXPORT_MODULE();

RCT_EXPORT_METHOD(addEvent:(NSString *)name location:(NSString *)location)
{
  RCTLogInfo(@"Pretending to create an event %@ at %@", name, location);
}

为了实现 RCTBridgeModule 协议,你的类需要包含 RCT_EXPORT_MODULE() 宏。这个宏也可以添加一个参数用来指定在 JavaScript 中访问这个模块的名字。如果你不指定,默认就会使用这个 Objective-C 类的名字。如果类名以 RCT 开头,则 JavaScript 端引入的模块名会自动移除这个前缀。
通过RCT_EXPORT_METHOD()宏来实现声明给 JavaScript 导出的方法。
现在从 Javascript 里可以这样调用这个方法:

import {NativeModules} from 'react-native';

const CalendarManager = NativeModules.CalendarManager;
CalendarManager.addEvent('Birthday Party', '4 Privet Drive, Surrey');

如果需要回调的话参数声明为 RCTResponseSenderBlock:

RCT_EXPORT_METHOD(findEvents:(RCTResponseSenderBlock)callback)
{
  NSArray *events = ...
  callback( null], events]);
}

也支持使用 promise 的语法:

RCT_REMAP_METHOD(findEvents
                 findEventsWithResolver:(RCTPromiseResolveBlock)resolve
                 rejecter:(RCTPromiseRejectBlock)reject)
{
  NSArray *events = ...
  if (events) {
    resolve(events);
  } else {
    NSError *error = ...
    reject(@"no_events", @"There were no events", error);
  }
}

JS 端则可以使用 await 的方式调用:

async function updateEvents() {
  try {
    var events = await CalendarManager.findEvents();

    this.setState({events});
  } catch (e) {
    console.error(e);
  }
}

updateEvents();

RN 也提供了一种方式 RCTEventEmitter 来发送事件。
通信还是通过 JsBridge 实现,因此性能还是有所限制。

总结

两个框架都提供了机制和 native 进行通信。因为 JSCore 对 native 有更好的支持,因此 RN 虽然写法虽然稍微复杂一些,但是 JS 端可以直接知道方法签名,体验好一点。Flutter 则都是通过字符串和通用的字典传递,原始一些。
性能上 Flutter 好很多,没有必要担心性能。RN 则受制于 JsBridge 有性能上限。

View 嵌套

Flutter 提供了 PlatformView 用于嵌入 Android 和 iOS 平台原生 view。但是 PlatformView 对性能损耗很大。因为 Flutter 的 UI 全都使用自有引擎渲染,那么有一块内容需要渲染原生的界面,原则上做不到自有引擎渲染空出一块,然后把原生的 view 刚好贴在那一块上。整个界面肯定是使用一种统一的方式渲染。于是 Flutter 实际上把原生 View 的内容复制了一份到 skia 引擎中进行渲染。
控件还有交互怎么办?于是把拦截的交互事件全部传回给原生控件。这。。。这就是传说中的屏幕共享啊!
控件还有属性方法要暴露呢?没别的办法,还是只能走 PlatformChannel 那一套。
另外 Flutter 一个界面对应一个环境(Isolate),Isolate 间互相是独立的。初始化一个 Isolate 的成本很高,如果你有一个场景想要在一个 app 里展示多个 flutter 页面,在现有 flutter 体系会很大的限制。这个场景多发生在 native 页面和 flutter 页面交互嵌套出现。比如 FlutterA -> NativeA-> FlutterB -> NativeB。
RN 采用的是各端自己的渲染机制,因此 RN 里面嵌入原生控件。原生控件里面嵌入 RN 控件没什么问题。

社区成熟度

RN 由 facebook 维护,Flutter 由谷歌维护。两个框架的背后公司都有长期维护这套框架的理由。现在看起来谷歌的投入更大一些。RN 的社区早期也一直抱怨 facebook 维护不力,但是 facebook 18 年底罕见的发布了一个 RN 2019 路线图,说已经听到了社区的声音,会好好维护。
从社区生态上看,RN 显然有着更大的优势。RN 第一版在 15 年发布,已经有了 4 年的沉淀。Flutter 满打满算发布了一年。RN 会遇到的问题基本上都能搜到,各种方案社区都有不少讨论。再加上 RN 渲染是各端各自进行的,因此如果发现渲染的结果有问题,或者框架提供的控件不够用,自己实现一个的门槛很低。
相比之下 Flutter 的技术体系是一个相对封闭的体系。Dart + Skia 对于大多数开发者而言还是比较陌生。如果框架提供的控件有问题,除了给官方提 issue 能做的事情也很少。或者说如果要自己修复门槛比较高。嵌入原生控件的方式又不推荐。当然完全可以相信这些控件的稳定性在未来肯定会越来越好,但是现在在 Flutter 的初期,这个问题怎样都是一个隐患。

总结

RN 成也 JS 败也 JS。JS 有着成熟的生态,却也自带性能上限。各个平台的 JSCore 也不一致,复杂场景下同样的 JS 代码可能有不一样的结果,遇到这样的问题也只能摊手,或者说这样的问题跟踪难度很大。就是大家常说的“代码在我这里还是好的”。
RN 是性能改善的重灾区,过去几年但凡有演讲涉及 RN 就肯定有 RN 的性能优化。可重用的长列表都憋了几年才发出来。
Flutter 的天花板很高,自有的引擎有很多的想象力。在跨平台的 UI 一致性上是一把利刃。如果是相对封闭的 UI 需要跨平台,Flutter 是很适合的。但是需要和一个已经成熟的现有项目混合的话目前还有很多限制。为了得到 flutter 的性能,团队还要多掌握一个技术栈,这也是一个不小的负担。

© 著作权归作者所有
这个作品真棒,我要支持一下!
奇志技术团队博客 http://meshtech.co/
0条评论
top Created with Sketch.