Flutter框架在已有工程的开发实践

Android工程配置

首先做flutter混合开发请参阅google的官方指引:https://github.com/flutter/flutter/wiki/Add-Flutter-to-existing-apps

如果现有工程有固定的打包环境(CI)且无法随意改动,那么意味着我们无法随意安装部署flutter开发框架,为了不影响线上打包我们需要将线上打包环境做不依赖flutter框架的处理,这就是让现有项目对flutter模块做产物依赖(aar)。

当我们的flutter模块依赖了一些plugins的时候,这些plugins是会生成平台相关的项目的,这些项目的编译产物也是aar。我们最终的产物集成除了集成flutter模块的aar外,还要额外依赖这些plugins产生的编译产物。先来看一下依赖了一些plugins的flutter module工程的产物出了flutter模块本身还有哪些aar:


android flutter module编译产物

加上flutter本身的aar,这里演示的flutter module工程的编译产物共有4个aar。如果我们不能在远程编译环境上编译flutter模块,那么就需要在本地编译好这些aar,并且拷贝到主工程下,做aar依赖,这样就可以把flutter环境从远程打包环境中隔离出来。问题是如何自动化,减少手动干预?可以引入一个脚本,每次进行flutter模块的编译,以及后续产物的拷贝工作:

#!/usr/bin/env bash

echo 'need flutter 1.12 stable'
cd ../module/ || return
flutter clean
flutter build aar --no-debug --no-profile
cp -Rvf ./build/host/outputs/repo/ ../app_project/app/flutter_repo
echo 'all done!'

再修改一下工程配置,在开发时稍微做下改动即可很方便的将flutter工程的开发依赖等细节彻底从主工程分离出去:

...
repositories {
    flatDir {
        dirs 'libs'
    }
    maven {
        url './flutter_repo'
    }
    maven {
        url 'http://download.flutter.io'
    }
}
...
   ///////// if local debug //////////////////
//    debugImplementation project(':flutter')  //debug下打开注释,按照google的指引方式开发
    ////////  else if CI /////////////////////
    implementation (name:'flutter_release-1.0', ext:'aar') 
    ///////// end if //////////////////

工程改造就此完成,这样的改造后就可以把现在的项目小组分成两组分别开发。一组可以继续native的需求开发,另一组可以并行开发flutter模块,两组互不干扰。

Android工程改造

看一下flutter模块编译出来的aar中的so类型:


flutter模块编译产物的so类型

上图可以看出flutter模块编译后的so类型只支持v7a和arm64两种cpu架构。这带来一个问题,有很多旧工程依赖的native功能很多只提供armeabi架构的so,那么带来的问题就是,老功能没有v7a版本so,新功能只有v7a版本so,如果两种架构都支持,每一个版本的so文件夹中的so文件都不全。Android运行时只会寻找一个目录下的so文件,这样就会造成程序运行时直接崩溃。

通常Android App只会带一个cpu架构的so文件,这样做是为了减少包体积,一旦选择这样,就需要为app的兼容性考虑,所以通常的做法是只保留armeabi架构或者只保留armeabi-v7a架构。

        release {
            debuggable false
            minifyEnabled true
            zipAlignEnabled true
            shrinkResources true
            signingConfig signingConfigs.release
            ndk {
                abiFilters 'armeabi'
                //abiFilters 'armeabi-v7a' //或只保留v7a版本
            }
        }

前面提到过,由于flutter只提供v7a版本,所以只要仅仅保留某一种cpu架构的so都会产生问题。如何解决?

armeabi是v5版本的架构,其在硬件上不支持浮点运算,只能通过软件方式模拟运算;armeabi-v7a是v7版本的架构,其在硬件上支持浮点数运算,所以其运行效率相较v5版本提升显著,基于这一点也可以理解google仅仅为flutter引擎提供最低v7a版本的支持的原因。两种版本的cpu同属32位处理器架构。而cpu架构指令集都是向下兼容的,所以理论上我们只要将原v5版本的so直接拷贝到v7a文件夹下,系统就可以自动兼容运行v5版本的so。

基于上述分析,我们只需要将armeabi架构的so拷贝到工程目录下的jniLibs/armeabi-v7a下即可。


armeabiv7a.png

上述做法有个缺点,就是有时候你可能都不知道哪个模块依赖了某一个native库,所以你不知道去哪里拷贝armeabi版本的so库。那么哪里可以找到最全的so库呢?这就需要了解一下打包流程,在打包前所有类型的所有so库都会生成到一个临时编译文件夹等待so库优化(去除调试符号等)以及存放优化后的所有类型so库。我们只需要进行一次重新编译,就会在这些文件夹下找到所有类型的所有so库。


sotmp.png
sostrip.png

mergeJniLibs就是在优化前所有so库的存放位置,stripDebugSymbol文件夹就是经过去除调试符号优化后的so库存放的位置。在这里就可以将需要拷贝出来的so挑选出来,拷贝到工程目录需要的so库目录下了。

更进一步,这个操作我们可以在gradle的打包阶段写一些代码使其变成自动化运行,这样就一劳永逸的解决了老项目和flutter项目so库架构不同的问题了。

AOT模式在Android平台的两种模式

前面提到了flutter模块的编译产物,有必要简要介绍一些flutter框架编译后的编译产物。从debug和release纬度上讲编译产物可以分为JIT编译产物和AOT编译产物。而Android平台上AOT模式下编译后的产物又分为两种模式。

JIT即Just In Time,会在运行时编译执行源码。典型的例子如JsCore或V8引擎,会在运行时编译执行JS代码;又如JVM,java源码会在JVM中被编译运行。显然,JIT模式非常灵活,但它需要额外消耗CPU和内存实时编译执行代码,这往往带来的用户体验式运行时比较慢。flutter在开发过程中即debug模式采用的是JIT的方式,这样可以很好的支持hotload来实时更改代码并且运行得到结果,使开发调试的效率大幅增加。
此时编译产物为:
isolate_snapshot_data:用于快速启动dart isolate,与业务代码无关。
platform.dill:dart运行时核心数据,供flutter engine使用。
vm_snapshot_data:用于dart vm的快速启动,与业务无关。
kernel_blob.bin:业务相关代码。

AOT即Ahead Of Time,会在编译期编译为目标平台的二进制可执行代码,如可执行文件,静态库(.a),动态库(.so)等,典型如c\c++编译后的产物。显然,AOT没有额外的运行期编译,所以效率会较JIT方式高很多,但是无法做到JIT模式的动态逻辑变动,灵活性不如JIT模式。在最终发行版本中,flutter会采用AOT方式编译dart代码,此时最终app不再有实时编译运行的vm功能。在AOT模式下,android相比iOS会多一种AOT模式,称作CoreJIT。

编译 Android(默认编译) iOS Android(动态库编译)
编译方式 AOT(Core JIT) AOT AOT(原生机器代码方式,so)
编译命令 flutter build --release flutter build --release flutter build --release -build-shared-library
产物/产物路径 flutter_assets/
isolate_snapshot_data
vm_snapshot_data
isolate_snapshot_instr
vm_snapshot_instr
App.framework armeabi-v7a/ && arm64-v8a/
libapp.so && libflutter.so

注:在flutter的1.7.1stable版本以及之前版本可以可见到Android的两种不同AOT模式。在1.9.1stable版本中Android的默认release编译模式已经变为动态库方式,CoreJIT方式已经不再被支持。

在android中,CoreJIT和二进制AOT方式在运行效率上基本上相同,CoreJIT的设计初衷可能是为了业务代码的动态下发,但这种方式iOS是被禁止的,无法做到双端统一。目前最新的stable flutter版本中,android的默认release打包方式已经变为二进制方式的AOT的模式,理论上这种方式要比CoreJIT方式更快。新版flutter对于android默认打包方式的改变, 某种程度上说明可能google放弃了运行时动态加载运行代码的功能。

Flutter引擎的线程模型

如果不了解flutter框架运行时的线程模型,那么在混合开发中很有可能掉入隐藏很深的陷阱。


flutter_overview.png

这是google官方的flutter框架整体视图。从平台是否相关角度, 我们也可以把flutter框架氛围两部分,一部分为flutter引擎,它包含flutter framework以及dart runtime、skia等底层库;一部分为flutter embedder,它用来做平台相关的功能实现,为flutter引擎提供统一的上层接口。


flutterembeder.png

上图为简略后的flutter框架,其中flutter engine包含了flutter framework以及dart runtime、skia等底层实现。这一层的实现是平台无关的也就是说它不与特定平台产生耦合,所以在这一层,没有操作系统层面的线程概念。这里的代码会被运行在一些特定的task中。

flutter embedder是平台相关的实现,它为上层engine来提供task的运行环境,通常来讲这个运行环境就是操作系统的线程环境。

在flutter engine中,有四种特定类型的task,他们分别运行在embedder提供的四个runner中(由特定平台提供的运行环境,即线程)。他们是:

  • Platform Task Runner
  • UI Task Runner
  • GPU Task Runner
  • IO Task Runner

其中Platform Task Runner在移动平台中由embedder提供的运行环境是Android的Main Thread和iOS的Main Thread。flutter和native的交互代码都运行在这个Runner中,亦即运行在移动平台的主线程中。任何运行在native侧的,准备与flutter代码交互的代码必须在这个Runner中调用,亦即必须在native的主线程中调用,否则会有未知后果。如果存在耗时操作的话,将会阻塞native主线程,这并不会影响flutter的UI流畅性(后续说明),但是会引发如android上的watchdogs强制退出程序的问题。所以超时操作应放在native侧的工作线程中,在线程中运算完成后,发送到Platform Task Runner所在的线程即主线程,完成与flutter侧的函数调用。

UI Task Runner在移动平台中embedder也同样开辟了独立的线程供其运行。这个Runner中承载的就是我们写的所有dart代码,我们可以把这些代码统一叫做flutter ui代码。注意这里的承载flutter ui的线程与android或者iOS的UI线程(即主线程)不是一条线程。也就是说我们写的flutter的业务dart代码跑在一条独立的线程,并不运行在native的主线程中。这条线程也是root isolate(后面会介绍)的一部分。root isolate是一个特殊的isolate,它承载着为flutter engine提供渲染数据的任务,并且它会相应所有异步实践的回调结果,包括网络、文件、timer、与platform runner task的交互返回等。UI Task Runner是业务代码运行的核心,它为渲染提供每一帧的数据,如果在这个任务中(线程中)运行耗时操作,将会引起flutter ui的卡顿。

GPU Task Runner是用来运行GPU渲染任务的,它同样由embedder提供一条独立线程作为运行环境。只有这个任务(线程)可以访问GPU以及GPU数据。这个线程是实际执行渲染逻辑线程,所以这条线程的卡顿会直接造成界面感官的卡顿,但我们的上层代码并没有直接操作这条线程的途径,所以可以认为,它的执行效率跟硬件相关。

IO Task Runner是用来执行一些耗时操作的无人,它同样由embedder提供一条独立线程作为运行环境。一些不适宜在上述3种Runner中运行的耗时操作,会在IO Task Runner中处理。比如加载本地图片等操作。

对于我们关心的android和iOS平台来说,下表总结了四种Runner和线程的对应关系:

Runner/Platform Android iOS Running code
Platform Task Runner Main Thread(UI Thread) Main Thread(UI Thread) 与原生系统交互的Channel调用,Event调用等。
UI Task Runner 独立Working Thread 独立Working Thread 业务相关的dart代码,供GPU生成渲染texture的渲染树数据
GPU Task Runner 独立Working Thread 独立Working Thread 渲染
IO Task Runner 独立Working Thread 独立Working Thread 耗时操作

除了这4种Runner以及其所对应的4条线程外,DartVM在运行时会维护自己的线程池来执行一些并行任务。这些线程无法被flutter engine访问,也无法被上层代码所控制。典型的如dart中提供的http库的网络功能就运行在这里。
下面是整个线程模型的全景图:


allarch.png

为什么要用isolate

在dart中没有线程的概念,开发dart代码是不需要关心线程的。通过前面介绍的flutter线程模型我们知道,我们为业务编写的dart代码统一运行在UI Task Runner这个线程中。这条线程同样承担着为渲染线程提供渲染数据的任务,如果用dart代码实现一个耗时操作(比如cpu密集型运算)会不会影响flutter ui的流畅性?答案是会。这种情况下就需要将耗时代码运行在一个独立线程中,但dart又没有线程的概念,所以解决这个问题就需要用到dart提供的isolate机制。

isolate是什么

isolate是dart创造的一个概念,它拥有私有的内存和并行于其他isolate的运行环境(线程),所以正如中文译文一样它们之间是相互“隔离”的。正因为isolate之间无法共享数据,所以也不存在竞争读写,也就不需要锁来同步。

isolate通过特定的api来跟其他isolate传递数据,可以把isolate之间类比为网络主机,通过特定的socket来传递数据。

元素 isolate OSThread
栈内存 私有 私有
堆内存 私有 共用
运行环境 运行在所在平台的线程中 线程本身
共享数据方式 通过特定api传递数据 直接访问共享的内存

我们的工程中对需要对网络请求、请求收到的数据转换做业务无关的统一封装。当网络接口将数据返回给业务代码时,这时我们的代码运行在UI Task Runner中,接下来要做的事情是将json字符串反序列化为json model对象以方便业务使用。这个过程如果json串很大、嵌套很深的话对于cpu的消耗是很大的,尤其在程序刚运行的时候,往往业务要请求一些列接口,这时候频繁的在UI Task Runner中进行反序列化就有可能造成flutter ui的卡顿。这种场景下,我们用isolate来运行json反序列化代码,保证UI Task Runner中不存在任何耗时操作,这样UI就不会有任何由于业务代码引起的卡顿。

如何保证双端生命周期一致

在我们的业务场景中,有页面展示时长的统计逻辑,这就要求flutter页面可以在被遮挡(home到后台或者被其他页面遮挡)或者重新可见时去做打点统计的动作,以此来计算页面的展现时长。不仅仅这个需求,一些常见场景也需要业务逻辑需要知道当前页面的状态,如页面重新可见时可能业务逻辑需要刷新页面的数据等等。flutter framework提供了对于生命周期变化通知的一些通知接口,它们有两种:

abstract class WidgetsBindingObserver {
  Future<bool> didPopRoute() => Future<bool>.value(false);
  
  Future<bool> didPushRoute(String route) => Future<bool>.value(false);

  void didChangeMetrics() { }

  void didChangeTextScaleFactor() { }

  void didChangePlatformBrightness() { }

  void didChangeLocales(List<Locale> locale) { }

  void didChangeAppLifecycleState(AppLifecycleState state) { }

  void didHaveMemoryPressure() { }

  void didChangeAccessibilityFeatures() { }
}

实现WidgetsBindingObserver即可通过didChangeAppLifecycleState函数收到app的native容器页的生命周期变化通知,它们是:

enum AppLifecycleState {
  /// The application is visible and responding to user input.
  resumed,

  /// The application is in an inactive state and is not receiving user input.
  ///
  /// On iOS, this state corresponds to an app or the Flutter host view running
  /// in the foreground inactive state. Apps transition to this state when in
  /// a phone call, responding to a TouchID request, when entering the app
  /// switcher or the control center, or when the UIViewController hosting the
  /// Flutter app is transitioning.
  ///
  /// On Android, this corresponds to an app or the Flutter host view running
  /// in the foreground inactive state.  Apps transition to this state when
  /// another activity is focused, such as a split-screen app, a phone call,
  /// a picture-in-picture app, a system dialog, or another window.
  ///
  /// Apps in this state should assume that they may be [paused] at any time.
  inactive,

  /// The application is not currently visible to the user, not responding to
  /// user input, and running in the background.
  ///
  /// When the application is in this state, the engine will not call the
  /// [Window.onBeginFrame] and [Window.onDrawFrame] callbacks.
  ///
  /// Android apps in this state should assume that they may enter the
  /// [suspending] state at any time.
  paused,

  /// The application will be suspended momentarily.
  ///
  /// When the application is in this state, the engine will not call the
  /// [Window.onBeginFrame] and [Window.onDrawFrame] callbacks.
  ///
  /// On iOS, this state is currently unused.
  suspending,
}

另一个抽象类RouteAware,用来获取flutter页面之间进栈出栈引起的页面可见性变化:

abstract class RouteAware {
  /// Called when the top route has been popped off, and the current route
  /// shows up.
  void didPopNext() { }

  /// Called when the current route has been pushed.
  void didPush() { }

  /// Called when the current route has been popped off.
  void didPop() { }

  /// Called when a new route has been pushed, and the current route is no
  /// longer visible.
  void didPushNext() { }
}

同时这两个抽象类理论上就可以处理好双端在任何情况下的页面可见性变化。
然而,在实际中并不这样,这里列出了我们的测试结论:

  • 第一种情况,native程序的页面栈中只有flutter页面
lifecycle1.png
  • 第二种情况,native程序的页面栈栈底为flutter页面,此时调用起另一个native页面
liefcycle2.png

上述两种情况我们可以看出当有native页面入栈时,切到后台和从后台切回,在iOS平台上无法正确的收到flutter framework的回调。为此我们放弃了使用flutter提供的WidgetsBindingObserver去监听容器页面的声明周期变化,而是通过eventChannel的方式自实现了native页面的完整声明周期。flutter本身的页面跳转还沿用了RouteAware的监听。结合两种方式,就可以在双端实现生命周期的一致性。

可能遇到的麻烦

这里是我猜到的坑,可能你也会遇到。

json解析

dart提供的json解析库对一些特殊字符会抛出异常,这一点与android和iOS的json解析库行为是不一致的。

比如如果json字符串中含有0x20以下的字符时,会因为这些字符为控制字符而抛出异常。这样的行为会因为线上数据污染而导致测试期很难发现,如果json解析在关键的流程链路上,那么抛出异常就会使问题极难排查。

我们采取了一种措施来规避这样的问题,即在json解析异常的处理中过滤掉0x20以下的所有字符再重新解析:

  _filterString(String strInput) {
    List<int> tmp = List();
    strInput.codeUnits.forEach((int value) {
      if (value >= 0x20) {
        tmp.add(value);
      }
    });
    return String.fromCharCodes(tmp);
  }

StatefulWidget中的对象或者数值是时刻变化的

由于flutter framework对ui描述的设计,StatefulWidget是一个轻量级对象,它会频繁的被创建,通过它来做数据的承载传递给相应的state,用来描述某一个ui widget。

在State中访问StatefulWidget中的成员要切记它是时刻变化的。看一下下面的代码:

class TestPage extends StatefulWidget {
  final StreamController<bool> _controller = StreamController();

  @override
  State<StatefulWidget> createState() {
    return _TestPageState();
  }

  void performResume() {
    _controller.add(true);
  }
}
class _HomePageState extends BaseState<HomePage> {
  @override
  void initState() {
    super.initState();
    subscriptions.add(widget._controller.stream.listen((_) { //initState只在widget创建时调用,当父widget刷新时,会重建数据,TestPage中的_controller这是会成为另一个对象。
      some();
    }));
    ...
  }
  ...
}

看上面代码,在initState()中调用了widget._controller.stream.listen注册了一个监听事件,但是这里面的_controller随着widget重建会变成另一个对象,所以在重建后的后续监听事件都不会被State收到。这样的隐患很难发现,并且错误表现存在一定的随机性。

WillPopScope导致iOS手势返回失效

在android平台上activity中有一个函数onBackPressed,通过继承这个函数可以改变点击back键的行为。

在flutter framework中也有类似的实现,通过用WillPopScope包裹子widget,在实现一个回调函数即可实现类似的拦截行为。

一旦实现了这个拦截函数,那么在iOS平台上默认的手势回退就会失效。所以这里要根据业务的场景来评估,对我我们的场景,我们仅在android平台需要拦截这个事件, 所以我们做了如下适配:

  _getWidgetForIOSOrAndroid(BuildContext context) {
    if (Platform.isIOS) {
      return buildWidget(context);
    } else {
      return WillPopScope(child: buildWidget(context), onWillPop: onWillPop);
    }
  }

这样就会实现android需要的功能,并且不影响iOS的用户使用习惯。

调试

做为module模块嵌入已有工程的flutter模块跟用flutter作为app开发的项目在调试上有一些小小差别或者说不便

作为module方式接入flutter模块 flutter作为app主工程
打印调试信息 会在logcat作为“flutter”tag打印出来。 会在flutter run打印出信息。
debug 1、先在module工程点击flutter attach
2、启动app
注:如果不这样操作,无法进行断点调试,算是flutter工具链还不完善的地方。
直接debug即可断点调试,跟普通native开发没有区别。

GestureDetector不要忘记设置behavior属性

如果不设置GestureDetector的behavior属性为HitTestBehavior.opaque,那么在GestureDetector包裹一个Container时在点击空白区域的时候是不会响应点击事件的,这个行为往往和我们传统手机开发习惯是相悖的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270

推荐阅读更多精彩内容