阅读 2247

Flutter 动态化方案探索

一、背景

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

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

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

方案名称 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作为最近两年发展势头迅猛的一种跨平台解决方案, 进入了我们调研的视线范围,我们主要会从以下几个方面去衡量:

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

二、探索动态化方案

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

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

三种实现方案

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

1. 动态组件方案

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

总结特点如下:

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

1.1 关于语法树

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

Flutter analyze 解析源码得到ASTNode过程:

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

根据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
关注下面的标签,发现更多相似文章
评论