Flutter 动态化方案探索

1、背景

随着移动平台的发展,移动端用户规模愈来愈大,相应地产品需求也是日益见长。为了解决诸多快速迭代的业务产品线及需求,提升咱们的开发效率,业内的同行们尝试探索了许多跨平台方案,现在比较主流的方案大体有如下几种。如:java

  1. React Native;
  2. Weex;
  3. Hybrid App;
  4. Flutter;
  5. 小程序;

上述的几种方案或多或少都存在一些瓶颈或使用场景的缺陷,这里就很少展开讨论了。下面列出主要的对照信息,给你们一个参考:node

方案名称 React Native Weex Hybrid App Flutter 小程序
平台实现 JS JS 无桥接 无桥接 无桥接
引擎 JSCore JS V8 原生渲染 Flutter engine -
核心语言 React Vue Java/Obeject-C Dart WXML
Apk大小(Release) 4-6M左右 10M左右 - 8-10M左右 -
bundle文件大小 默认单一,较大 较小,多页面可多文件 不须要 不须要 不须要
上手难度(原生角度) 容易 通常 通常 容易 容易
框架程度 较重 较轻 较重 较轻
特色 适合开发总体App 适合单页面 适合开发总体App 适合开发总体App 适合开发总体App
社区 丰富(FaceBook) 通常(阿里) 通常 丰富(Google) 通常(微信)
跨平台支持 Android、iOS Android、iOS Android、iOS Android、iOS、Web、Fuchsia 等 Android、iOS

Flutter做为最近两年发展势头迅猛的一种跨平台解决方案, 进入了咱们调研的视线范围,咱们主要会从如下几个方面去衡量:linux

  1. 接入难度。
  2. 学习成本。
  3. 性能。
  4. 包体积。
  5. 动态化能力。

2、探索动态化方案

前面几个方面,相信你们在接触Flutter的时候都已经有了一些了解,这里就很少做深刻探讨了。而做为跨平台解决方案,动态化算是一个比较重要的功能之一,经过查资料&翻文档&技术群交流讨论,发现目前在Flutter中主要有如下三种实现方案:android

  1. 相似React Native 框架。
  2. 替换Flutter编译产物。
  3. 页面动态组件框架。

三种实现方案

接下来咱们简要介绍一下这几个方案的具体实现原理。ios

1. 动态组件方案

目前,市面上大多技术团队都是经过这种页面动态组件的思想去实现动态化,好比闲鱼、惟品会、头条等。该方案的核心原理是在打包应用前,如在编译期时插桩/预埋好DynamicWidget到代码中,而后动态下发Json 数据,经过协定好的语义匹配到JSON内的数据,动态替换Widget内容来实现动态化 (除UI外,若须要实现逻辑代码的动态化,则能够经过相似Lua 这种比较动态的脚本语言写业务逻辑)。c++

总结特色以下:git

  1. 在市面上已经有不少与之相似的成熟框架,如天猫的Tangram,淘宝的DinamicX等。它在性能以及动态性,开发成本上取得相对较好的平衡。它能知足常见状况的动态性需求,在必定程度上能解决实际问题。
  2. 能支持Android/iOS 两端的动态化。
  3. UI动态化相对较容易,业务逻辑动态化较麻烦。
  4. 语义解析器开发成本相对较大,且不易维护。

1.1 关于语法树

Tangram、DinamicX等框架它们有个共同点,都是经过Xml或者Html 作为DSL。可是Flutter 是React Style语法,Flutter本身的语法已经能很好的来表达页面。所以,这个方案无需自定义语法。用Flutter 源码作为DSL便可。这样 能大大减轻开发以及测试过程,不须要额外的工具支持。github

Flutter analyze 解析源码获得ASTNode过程:算法

从上图能够看出,插件或者命令对analysis server发起请求,请求中带须要分析的文件path,和分析的类型,analysis_server通过使用 package:analyzer 获取 commilationUnit (ASTNode),再对ASTNode 通过computer分析,返回一个分析结果list。shell

根据Flutter的原理,一样咱们也可使用 package:analyzer 把源文件转换为commilationUnit (ASTNode),ASTNode是一个抽象语法树(abstract syntax tree或者缩写为AST)是源代码的抽象语法结构的树状表现形式,利用抽象语法树能很好的解析Dart 源码。

方案缺陷:

须要对源码的格式制定规则,好比不支持 直接写if else ,须要使用逻辑wiget组件来代替if else 语句。若是不制定规则,那AST Node 到widget node 的解析过程会很复杂。所以能够引入lua 来实现逻辑代码的动态化。

1.2 json +lua 方案的总体架构规划:

Flutter 动态组件框架设计图.jpg

开源方案:

github.com/dart-lang/s…

github.com/dengyin2000…

luakit_plugin:github.com/williamwen1…

参考资料:

dart.dev/tools/darta…

yq.aliyun.com/articles/67…

2. 相似RN的方案(JS bundle)

参考 React Native 的设计思路,总结起来就是利用 JavasSriptCore 替换DartVM,用 JavaScript(简称JS) 把 XML DSL 转为 Flutter 的原子widget组件,而后再让 Flutter 来渲染。从技术上来讲是可行的,但成本也很大,这会是一个庞大的工程。

具体来讲就是把 Flutter 的渲染逻辑中的三棵树(即:WidgetTree、Element、RenderObject )中的第一棵(即:WidgetTree),放到 JS 中生成。用 JS 完整实现了 Flutter 控件层封装,可使用 JS 以相似 Dart 的开发方式,开发Flutter应用。利用JavaScript版的轻量级Flutter Runtime,生成UI描述,传递给Dart层的UI引擎,而后UI引擎把UI描述生产真正的 Flutter 控件。

手机QQ看点团队开源方案:《基于JS的高性能Flutter动态化框架》

方案缺陷:无论JSWidget建立有多快,老是有跨语言执行,对于性能老是会有影响的。另外因为iOS系统内置支持JS,因此它在iOS上是彻底动态化的,可是Android 端须要额外引入JS库。目前MXFlutter 这套方案也仅仅实现了iOS版的动态化,而且实现起来较复杂。

3. 替换编译产物方案

若要实现编译产物的动态化,那么在Android平台上,则会被限制在JIT代码上;而在iOS平台上,则会被限制在解释执行的代码。谷歌Flutter团队的以前尝试过提供官方的解决方案,但后来放弃了并回滚了代码。他们是说法是对于这样在有平台限制下的解决方案,在iOS平台上的性能表现可否达到预期并没太多信心(简单地说就是,在iOS系统上跑起来会卡得没法让人忍受,由于iOS不像android 那样,能够直接加载动态库so,它须要加载的是静态库)所以,若采用这种编译产物替换的方案,那么目前只能使用在Android 端。

首先,咱们得知道Flutter的编译产物是什么,就正如咱们所熟知的Android那套编译产物是dex文件,经过对dex文件的加载流程进行偷梁换柱,能够达到动态化的目的。那么,咱们先来了解一下Flutter的编译产物,这里须要注意的是Flutter目前的更新速度太快了,不一样版本下的编译产物也不太一致。

3.1 Flutter的编译指令

(1)编译apk & aar 默认引擎

// 编译纯Flutter apk,默认是release版本
flutter build apk

// 编译纯debug版apk
flutter build apk --debug

// 编译 aar, 默认是release 版本
flutter build aar

// 编译aar, 默认是debug版本
flutter build aar --debug
复制代码

关于编译的指令,能够经过flutter build -h进行查看,以下截图:

(2)编译apk & aar 指定本地引擎

  • 关于如何编译引擎,能够查看这篇文章[Ubuntu 16.04 编译Flutter Engine](/home/lichaojian/文档/Ubuntu 16.04 编译Flutter Engine.md)

  • 如何引用本地引擎

// 指定引用本地的引擎去编译apk,适用于纯Flutter应用
flutter build apk --target-platform android-arm64 --local-engine=android_release_arm64 --local-engine-src-path=/home/lichaojian/engine/src

// 指定引用本地的引擎去编译aar,适用于Flutter & Native 的混编项目
flutter build aar --target-platform android-arm64 --local-engine=android_release_arm64 --local-engine-src-path=/home/lichaojian/engine/src
复制代码

(3)查看Flutter 编译指令的源码

​ 其实不管咱们是编译apk或者是aar,都是经过flutter这个指令,因此查看一下这个flutter指令的源码其实是什么。接下来咱们能够查看一下/your_flutter_sdk_path/bin/flutter,打开flutter这个文件,里面最核心的一句话以下:

FLUTTER_TOOLS_DIR="$FLUTTER_ROOT/packages/flutter_tools"
SNAPSHOT_PATH="$FLUTTER_ROOT/bin/cache/flutter_tools.snapshot"
STAMP_PATH="$FLUTTER_ROOT/bin/cache/flutter_tools.stamp"
SCRIPT_PATH="$FLUTTER_TOOLS_DIR/bin/flutter_tools.dart"
DART_SDK_PATH="$FLUTTER_ROOT/bin/cache/dart-sdk"

DART="$DART_SDK_PATH/bin/dart"
PUB="$DART_SDK_PATH/bin/pub"
 # FLUTTER_TOOL_ARGS isn't quoted below, because it is meant to be considered as
# separate space-separated args.
"$DART" --packages="$FLUTTER_TOOLS_DIR/.packages" $FLUTTER_TOOL_ARGS "$SNAPSHOT_PATH" "$@"
复制代码
  • $DART: 启动一个dart虚拟机
  • $SNAPSHOT_PATH: 指定一个可执行的snapshot文件,路径是/your_flutter_sdk_path/bin/cache/flutter_tools.snapshot
  • $@: 就是你传过来的参数(例如:build apk)
// 从上面能够看出,平时咱们运行的
flutter build apk

// 其实是
/your_flutter_sdk_path/bin/cache/dart-sdk/bin/dart /your_flutter_sdk_path/bin/cache/flutter_tools.snapshot build apk
复制代码

运行上述指令,以下截图,编译apk成功,aar也是一样的原理:

  • 接下来查看一下flutter_tools.snapshot的源码文件,位于/your_flutter_sdk_path/flutter/package/flutter_tools/bin/flutter_tools.dart

从上述截图能够看出,实际是调用了executable.main的方法,接下来咱们看一下executable.dart

能够看出,runner这里运行了一系列的Command类,而后咱们熟悉的固然是flutter build这个命令,因此咱们能够看一下flutter build的命令对应的就是BuildCommand

从上图能够看出,实际上,BuildCommand其实是由不少的子Command组成来的,例如aar、apk、aot等都是属于BuildCommand的子命令。

若是想更详细的了解Flutter的打包编译流程,推荐查看 [研读Flutter——打包编译流程详解]

3.2 Flutter不一样版本下的编译产物差别

(v1.5.4-hotfixes & v1.9.1)

  • V1.5.4-hofixed

(1)debug 模式

image-20191026064033993

(2)release模式

image-20191026064004887

  • v1.9.1

(1)debug模式

(2)release模式

从上面的截图能够看出来,debug模式下,v1.5.4-hofixes以及v1.9.1的产物没多大变化,这里咱们也不针对debug版本进行讨论,能够忽略,可是咱们能够发现二者的区别以下:

v1.5.4-hotfixes release模式下产物

  • isolate_snapshot_instr
  • isolate_snapshot_data
  • vm_snapshot_data
  • assets/vm_snapshot_instr

v1.9.1 release模式下产物

  • libapp.so

着重分析v1.9.1 release模式下的产物主要分为这几个:

  • /lib/libapp.so 主要是编译Dart的生成的可执行文件
  • /lib/libflutter.so 主要存放Flutter Engine 的可执行文件
  • /assets/flutter_assets 主要存放flutter的一些资源文件,例如字体,图片等。

​ 能够看出,在v1.9.1版本之后,Flutter的代码编译产物就变得更单一了,这是有助于咱们进行动态化的研究的,咱们知道,libapp.so是天生支持动态连接的。意思是咱们就能够替换掉libapp.so文件,从而达到动态化的目的。这个最开始也是立森经过直接root手机替换掉产物,发现是支持的,而后才有了咱们的后续。

​ 既然是支持替换libapp.so来实现动态更新的,那么咱们怎么经过代码去实现呢?

3.3 Flutter 如何动态替换编译产物?

​ 从上面咱们能够知道,Flutter的编译产物到底有哪些东西了,因此咱们经过对代码进行动态指定加载编译产物的路径便可达到动态化的效果,那么应该怎样对代码进行修改呢?有两种方式:

(1)经过修改Flutter Engine的方式。

优势:

  • 便于熟悉Engine 代码。
  • 可定制扩展Engine。

缺点:

  • 对Engine的代码的入侵性较强。
  • 须要维护一个本身的Engine,对外提供。
  • 须要按期更新同步官方Engine代码。

(2)经过Hook 的方式。

优势:对Engine代码入侵性较小。

缺点:须要维护SDK,Engine版本更新时,需跟进hook点是否须要替换。

3.3.1 so文件的替换流程

  • (1)Android 是如何加载so文件的。

    ​ 经过上面介绍的编译产物,能够看出,release版本下,Android下,目前flutter会编译成一个libapp.so文件,那么Android自己加载so文件的方式有哪几种呢?主要分为如下两种:

// 默认加载路径加载,对应~/app/libs
System.loadLibrary("libname")
    
// 经过绝对路径进行加载
System.load("/your_so_path/libupdate.so")
复制代码

这两种方式的主要区别,就是loadLibrary经过加载app下的libs目录的so文件,load的话是经过加载其绝对路径加载。

关于Android当中,加载.so文件的原理,能够看一下gityuan的 loadLibrary动态库加载过程分析,也能够看一下

深刻理解System.loadLibrary 这篇文章。

简单的说,都是经过调用dlfcn.h 这个头文件下的函数,以下:

void *dlopen(const char *filename, int flag);  //打开动态连接库
char *dlerror(void);   //获取错误信息
void *dlsym(void *handle, const char *symbol);  //获取方法指针
int dlclose(void *handle); //关闭动态连接库 
复制代码

​ 了解完Android是如何加载so文件的,接下来看一下Flutter是如何加载so文件的。

  • (2)Flutter 是如何加载so文件的。
  1. 初始化Flutter,经过查看源码,咱们知道必须调用的方法有两个。

    FlutterMain.startInitialization(@NonNull Context applicationContext)
    FlutterMain.ensureInitializationComplete(@NonNull Context applicationContext, @Nullable String[] args)
    复制代码

此处省略不少.......直接进入重点....

native_library_posix.cc

NativeLibrary::NativeLibrary(const char* path) {
  ::dlerror();

  FML_LOG(ERROR)<< "lichaojian-path = " << path;
  
  handle_ = ::dlopen(path, RTLD_NOW);
  if (handle_ == nullptr) {
    FML_DLOG(ERROR) << "Could not open library '" << path << "' due to error '"
                    << ::dlerror() << "'.";
  }
}

fml::RefPtr<NativeLibrary> NativeLibrary::Create(const char* path) {
  auto library = fml::AdoptRef(new NativeLibrary(path));
  FML_LOG(ERROR)<< "lichaojian-Create = " << path;
  return library->GetHandle() != nullptr ? library : nullptr;
}
复制代码

从上述指令能够看出,实际上也是调用dlopen来加载so库的。因此知道这个原理以后,咱们就知道怎么处理了,我在这两个函数加的日志打印以下:

知道了原理以后,实现Flutter动态加载so文件的方式主要分为两部分:

​ (1) native层

经过更改native层代码,让native层判断某个预约好的路径是否存在更新的文件,存在的话,则进行加载更新的文件,不存在的话,则加载原来的libapp文件。
复制代码

(2) java 层

​ 刚才在FlutterMain#ensureInitializationComplete方法当中,咱们能够看到libapp相关的参数当中,有两行代码相当重要,咱们来回顾一下:

private static final String DEFAULT_AOT_SHARED_LIBRARY_NAME = "libapp.so";
private static String sAotSharedLibraryName = DEFAULT_AOT_SHARED_LIBRARY_NAME;

shellArgs.add("--" + AOT_SHARED_LIBRARY_NAME + "=" + sAotSharedLibraryName);

                // Most devices can load the AOT shared library based on the library name
                // with no directory path. Provide a fully qualified path to the library
                // as a workaround for devices where that fails.
                shellArgs.add("--" + AOT_SHARED_LIBRARY_NAME + "=" + applicationInfo.nativeLibraryDir + File.separator + sAotSharedLibraryName);
复制代码

经过上述代码能够发现,这里把aot_share_library_name以及其路径都加载到了shellArgs参数当中,因此咱们能够经过在java层更改这个路径以及名称,从而达到动态加载so的目的。为何连名称都要更改,若是名称不更改的化,找到libapp.so这个名称的时候,会直接映射到lib目录下的libapp.so这个文件,因此致使动态加载so失效。

经过替换一个路径已经更更名字的so文件,达到动态加载。

3.3.2 资源的替换

关于flutter资源

相比起Android系统的资源管理,flutter对资源的管理实在是简单得太多了,flutter的资源没有通过编译的任何处理,彻底是以源文件的形式暴露出来,获取资源就是以文件的读取方式来进行,返回给到flutter的是资源在内存中的buffer内容,资源是以目录名+文件名来标识,以下:

关于flutter AssetManager

flutter engine内部也有一个AssetManager,源码路径是flutter/assets/asset_manager.h AssetManager的代码很少,只是内部维护了AssetResolver的一个队列,核心的方法有两个

//往队列里面添加一个AssetResolver
void AssetManager::PushBack(std::unique_ptr<AssetResolver> resolver) {
  if (resolver == nullptr || !resolver->IsValid()) {
    return;
  }

  resolvers_.push_back(std::move(resolver));
}

//检索资源
std::unique_ptr<fml::Mapping> AssetManager::GetAsMapping(
    const std::string& asset_name) const {
  if (asset_name.size() == 0) {
    return nullptr;
  }
  TRACE_EVENT1("flutter", "AssetManager::GetAsMapping", "name",
               asset_name.c_str());
  for (const auto& resolver : resolvers_) {
    auto mapping = resolver->GetAsMapping(asset_name);
    if (mapping != nullptr) {
      return mapping;
    }
  }
  FML_DLOG(WARNING) << "Could not find asset: " << asset_name;
  return nullptr;
}
复制代码

从代码里面能够看得出来,其实真正的资源是由AssetResolver提供的。

关于flutter AssetResolver

AssetResolver是个接口类,flutter资源提供者必需要实现这个接口源码是在flutter/assets/asset_resolver.h 下面,定义大体以下

namespace flutter {

class AssetResolver {
 public:
  // 无关重要的被我省略了。。。
  virtual std::unique_ptr<fml::Mapping> GetAsMapping(
      const std::string& asset_name) const = 0;

 private:
  FML_DISALLOW_COPY_AND_ASSIGN(AssetResolver);
};

}  // namespace flutter
复制代码

其中最为核心的就是GetAsMapping方法,此方法返回了一个文件的MappingMapping也是个接口类,其定义也极为简单,源码是在 flutter/fml/mapping.h下面,这里直接给出

class Mapping {
 public:
  // 无关重要的被我省略了。。。
  virtual size_t GetSize() const = 0;
  virtual const uint8_t* GetMapping() const = 0;
};
复制代码

其中GetSize 返回了文件的大小,GetMapping返回的是资源在内存中的地址,整个资源的管理结构大体以下图所示:

关于flutter APKAssetProvider

APKAssetProvider实现了AssetResolver接口,为flutter提供了Android平台下的资源获取能力,其本质就是把Java层的AssetManager经过AAssetManager_fromJava接口转换到C++层,而后再经过AAssetManager_open AAsset_getBuffer AAsset_close等NDK接口来读取Asset资源, 源码路径在flutter/shell/platform/android/apk_asset_provider.h 下面,代码也很少,这里直接给出调用流程

关于flutter资源动态部署的几种方案

  • Android平台

经过前面的代码分析,咱们能够清楚的看见,在Android平台下面,flutter的资源其实也是由AssetManager提供的,因此咱们能够借鉴热修复的原理(其实比热修复还简单得多,由于这里咱们不须要作全量合成,只要作一次半全量合成就能够了,也不须要去replace系统的AssetManager只管调addAssetPath就能够了)。 固然,用这种方案的话必需要解决Android 9对私有API的限制问题。

  • 跨平台通用方式

上面的方案弊端是很明显的,第一只能知足Android平台,第二须要解决系统对私有API的约束问题等,其实在作第一种方案前,做者我就已经先实现了基于c++层的跨平台通用方式,其过程及原理也是很是的简单,经过前面的分析,咱们只要实现一个本身的AssetResolverMapping,而后把AssetResolver塞到flutter的AssetManager队列里面就能够了。

  • 利用flutter提供的原生支持方案

这种方式是昨晚在写此文章时才发现的,因此暂时尚未通过验证,不过从理论上来说也是可行的,而且就目前来看应该是最简单,最有效的一种方案。

RunConfiguration里面咱们能找到以下代码(源码路径flutter/shell/common/run_configuration.h

RunConfiguration RunConfiguration::InferFromSettings(
    const Settings& settings,
    fml::RefPtr<fml::TaskRunner> io_worker) {
  // 下面无关重要的代码已经被我删除了。。。
  if (fml::UniqueFD::traits_type::IsValid(settings.assets_dir)) {
    asset_manager->PushBack(std::make_unique<DirectoryAssetBundle>(
        fml::Duplicate(settings.assets_dir)));
  }
  asset_manager->PushBack(
      std::make_unique<DirectoryAssetBundle>(fml::OpenDirectory(
          settings.assets_path.c_str(), false, fml::FilePermission::kRead)));
}
复制代码

实际上这里的InferFromSettings是给fuchsia用的(flutter跨平台,在engine工程里面随处都能看见相似于fuchsia android ios windows linux darwin等等目录结构),咱们不能直接调这个函数,可是DirectoryAssetBundle倒是能够公共的(事实上ios平台也是没有像Android平台那样包装一个APKAssetProvider出来,ios也是直接使用DirectoryAssetBundle的) DirectoryAssetBundle本质上也是AssetResolver的一个实现,源码路径是在flutter/assets/directory_asset_bundle.h下面,这里就再也不分析了,有兴趣的能够直接去看下。

3.3.3 实现流程

方案大体流程

这里实现的原理大体与Tinker 等Android 热更新方案相似,经过对比新旧版本的文件差别,生成一份补丁包。而后将补丁包放到服务器,下发给旧版本APK的用户。以后下载好再在本地解压,将补丁包合并以实现全量替换。

差分包的生成与合并

在这块走了一些弯路,一开始在网上找的时候,都是推荐了bsdiff和bspatch,可是官网只有c的代码,这时候,我比较懵逼,就直接经过NDK的方式,直接移植代码到Android平台上,在移植编译动态库的时候,就踩了一些坑把,可是主要仍是一些不熟悉CMake以及c++引发的新手坑。

关于bsdiff的一些参考连接:

bsdiff.pdf

bsdiff算法

Google 的差量更新,实际上也是用了bsdiff

Tinker 基于 bsdiff v4.2封装的java代码

关于差分包的生成与合并,都是用现成的框架,因此难度并不会很大。

总结

本文主要探索并讲解了Flutter 中目前主流的三种动态化实现方案,动态组件方案以及相似RN这种Js 方案,本质上都是经过AST 解析语义树来实现的。而编译产物的动态化,经过分析源码发现目前可以在Android 平台实现,iOS平台则尚未太好的解决方案。Android的产物编译动态化方案,目前来讲实现起来相对容易些,难度不算太大。在探索过程当中或多或少踩过一些坑,文章如有不足之处还望你们多多指正~

感谢阅读~

做者


xiaosongzeem
相关文章
相关标签/搜索