Flutter官方正在搞热更新(动态化)?或许会有人被这个标题吸引过来的吧。一定会有人抱着批判的态度来打脸了吧:
- Flutter官方不可能去搞热更新(动态化),在很早之前,官方已经声明不会去搞这一块了,怎么可能突然变卦?且根本一点消息也没有!
- 就算搞了热更新,但绕不过苹果AppStore审核,搞了等于白搞啊!
- 你说的不会是shorebird吧,这个不是官方的!
我到底是不是标题党?各位看官别着急,且听我狡辩,不对,是解释(抱头)。
早在一年多前,我就研究过Flutter动态化的,为此我还开源过一个动态化的实验方案:MicroDart 但这个项目并不完整,一直在实验阶段,且它有一些十分致命的缺点,比如编译速度慢,且编译后包太大等,桥接层难写等,让我感觉这个思路错了。所以就没有再继续更新。(实际上是因为我研究生毕业论文写的这个,毕业后就懒得管了(捂脸))
在接触shorebird后,让我更加肯定自己的想法,如果要真实现Flutter动态化,shorebird的思路在性能上才是最佳的。但shorebird的实现成本很高,它需要修改DartVM虚拟机的代码,让它能够以混合的方式去运行 AOT代码和Kernel代码。且shorebird不开源,又不免费,在国内还有网络影响。这让我对shorebird在国内发展不看好。
个人对shorebird的实现原理非常好奇,但个人能力有限且刚换新工作,新的工作也没有热更新的需求,就一直拖延着没有再继续研究下去。
终于最近又有了时间,准备研究一下DartVM的运行与实现原理。却无意中让我找到了一些蛛丝马迹:
- 首先,Dart Sdk的编译项中发现了DART_DYNAMIC_MODULES 这个特殊的项,查找发现这个项出现的次数非常频繁。
- 其次,在pkg目录下面发现了dynamic_modules 这个包,查看创建时间是2024年。
- 再次,在pkg目录下面还发现了 dart2bytecode这个包,可以将dart编译成字节码。
- 再再次,我在dart:_internal 包中发现了 loadDynamicModule 这么个函数。
让我们再看看loadDynamicModule函数的说明:
scss
/// Load a dynamic module and execute its entry point method.
///
/// Only one of the two arguments must be provided, depending on the delivery
/// mechanism and the underlying platform.
///
/// Entry point method is a no-argument method annotated with
/// `@pragma('dyn-module:entry-point')`.
///
/// Returns a future containing the result of the entry point method.
external Future<Object?> loadDynamicModule({Uri?uri, Uint8List?bytes});
第一句话就让我虎躯一震:"加载一个动态化模块并执行它的入口函数",这难道就是传说中的????
在研究dart2bytecode是让我的猜想的到证实, 这个包实际上就是将需要动态化部分的代码编译成字节码的包,在它的README中可以看到:
markdown
# Dart Bytecode File Format
## Overview
This file describes the binary format of Dart bytecode files.
Dart bytecode is a binary representation of dynamic modules
on the VM/AOT. It is generated by dart2bytecode tools and
can be executed by the VM and AOT runtime (built with dynamic modules support).
从上方的说明中也可以发现通过dart2bytecode生成的字节码不仅能运行在VM模式下,也能运行在AOT模式下。这他妈不就是shorebird的运行原理吗?我的虎躯再震。
dynamic_modules 这个包实际上就是对loadDynamicModule函数做的一个封装,它还写了丰富的测试用例。在编译dart sdk源码后可以通过输入下方命令执行 dynamic_modules中的测试用例:
shell
./xcodebuild/DebugARM64/dart-sdk/bin/dart run
./pkg/dynamic_modules/test/runner/main.dart -r aot -v
但是第一次执行我失败了,后来才发现是sdk的编译参数中没有开启DART_DYNAMIC_MODULES,我再次对sdk进行编译:
shell
./tools/build.py --mode debug create_sdk --dart-dynamic-modules
再次编译后发现dynamic_modules的测试用例可以正常跑起来了。
dynamic_modules包里的测试用例涵盖面非常广,大家可以在dynamic_modules/test/data目录下找到。
特别建议大家看一下 dynamic_modules/test/data/README.md里的内容,可以让我们充分理解dynamic_modules的代码结构,由于篇幅原因,我这边不过赘述,有兴趣的同学可以自行查看。
经过上面的测试与研究,我开始兴奋起来。有搞头啊,真的有搞头啊,这是Dart官方实现的AOT+字节码混合运行的方式,且看上去已经能够运用到实际生产环境中了!!!
但接下来我冷静了,光在PC上通过Dart Sdk上验证可行性并不代表在Flutter环境里以能用啊,也不能表示在android和ios里也能用啊,于是我又马不停蹄的去下载了Flutter Engine源码,继续开始了研究。
如何能够让dynamic_modules在Flutter中也能运行呢?我总结一下需要突破以下难题:
- loadDynamicModule函数是dart:_internal里的包,是不对开发者开放的,我们的项目如何调用这个函数呢?
- Flutter SDK默认并没有开放dynamic_modules特性,如何编译出支持dynamic_modules特性的Flutter SDK呢?
- 我们的Flutter项目都是通过flutter_tools进行编译的flutter_tools如何支持dynamic_modules特性呢?
- 我们的Fluter项目如何编译出符合动态化运行的字节码呢?
经过研究,发现以上难题都是能解决的:
- 首先是loadDynamicModule的调用问题,我们可以在kernal/target/targets.dart中找到如下代码:
dart
/// Whether a library is allowed to import the platform private library
/// [imported] from library [importer].
///
/// By default only `dart:*` libraries are allowed. May be overridden for
/// testing purposes.
boolallowPlatformPrivateLibraryAccess(Uri importer, Uri imported) =>
importer.isScheme("dart") ||
(importer.isScheme("package") &&
(importer.path.startsWith("dart_internal/") ||
importer.path.startsWith("dynamic_modules/")));
从上面代码中我们可以得知,如果库的名称是dart_internal或者 dynamic_modules则没有dart:_internal包的调用限制。那就很简单了,我们只需要创建一个名称为dynamic_modules的包,在里面调用dart:_internal的方法,编译器就不会报错。
其次是IDE的报错,例如VS Code,我们可以在dynamic_modules包下面的analysis_options.yaml中添加如下代码,报错提示就会消失:
yaml
include: package:lints/recommended.yaml
analyzer:
errors:
import_internal_library: ignore
- Flutter SDK编译问题:
注: Flutter Engine 源码建议通过depot_tools下载,可能还需要科学上网,不然第三方库不全,是没办法编译通过的,建议大家看一下官方仓库的说明。
Flutter Engine可以通过et命令进行编译,下载Flutter Engine源码后可以将 et命令加入环境变量,如果你和我一样是通过VS Code和Mac调试代码,可以在.vscode/settings.json中加入如下设置:
json
{
"terminal.integrated.env.osx": {
"PATH": "your-path-to-flutter-sdk/flutter/engine/src/flutter/bin:$PATH"
}
}
我们可以通过下面命令编译有dynamic-modules特性的host环境(因为我是apple M4 这里选了arm64):
shell
et build --config host_release_arm64 --gn-args="--dart-dynamic-modules" --verbose
host环境表示你的Flutter所属PC运行环境,可以是linux,windows或macos
- flutter_tools 支持dynamic-modules问题:
Flutter 源码的编译是通过flutter_tools中转,最终还是通过 frontend_server 进行编译的,frontend_server源代码可以在 dart sdk源码的 pkg/frontend_server中找到,在frontend_server中我们可以找到 --dynamic-interface 选项,因此如果我们可以通过flutter_tools将-dynamic-interface 选项透传过去,就能够编译出可混合运行字节码的包,而flutter_tools中刚刚有--extra-front-end-options这么个参数项可以透传到frontend_server中。以下命令可以参考:
shell
flutter run -d macos
--local-engine-host=your-path-to-flutter-sdk/flutter/engine/src/out/host_release_arm64
--local-engine=your-path-to-flutter-sdk/flutter/engine/src/out/host_release_arm64
--release --extra-front-end-options=--dynamic-interface=dynamic_interface.yaml
上面的代码可以让我们运行在Flutter Engine源码编译出运行环境中,且加上 --dynamic-interface参数。
- 编译动态化运行的字节码:
如何编译可动态化运行的字节码,早在dynamic_modules库中的测试用例的实现代码中就能够找到,这里简单说明一下dynamic_modules是如何测试的:
- a. 通过 gen_kernel_aot.dart.snapshot 编译出kernal文件,编译需要添加--dynamic-interface dynamic_interface.yaml 用来描述动态化的范围dynamic_interface.yaml 如何编写在dynamic_modules的README中有叙述,这里不在重复。
- b.调用createTrimmedCopy函数对kernel文件进行压缩(这个是可选项,不进行压缩也可以运行)
- c. 通过 dart2bytecode.dart.snapshot 编译出字节码,编译过程中需要用到上面编译出的kernal文件以及dynamic_interface.yaml 文件。
- d. gen_kernel_aot.dart.snapshot 编译出aot文件(这个是验证项,验证kernal能正常编译出aot产物)
注:步骤 a和步骤c是生成字节码的必须过程。
OK 准备工作已经做完,下面是重头戏:写一个Flutter程序用来验证这个动态化方案是否能跑通了。
测试程序已经开源: 下载地址
现在让我说一下这个测试程序的代码结构:
yaml
demo_dynamic_feature_modules # 测试程序(非动态化母包)
-assets # 资源
-modules
dynamic_module_1.bytecode # 动态化字节码1
dynamic_module_2.bytecode # 动态化字节码2
-bin
build_modules.dart # 执行编译字节码操作
env.dart # 编译环境初始化
-pacakges
-dynamic_modules # 动态化调用库
-dynamic_modules_1 # 动态化代码库1 会编译成字节码:dynamic_module_1.bytecode
-dynamic_modules_2 # 动态化代码库2 会编译成字节码:dynamic_module_2.bytecode
-lib
home.dart # 首页面
main.dart
router.dart # 路由以及懒加载动态化字节码实现逻辑
shared.dart # 动态化字节码可公用的代码部分
dynamic_interface.yaml #动态化描述文件
pubspec.yaml
一些代码的补充说明:
- 非动态化母包 demo_dynamic_feature_modules 是不依赖 dynamic_modules_1 和 dynamic_modules_2 的,这样我们可以保证在编译demo_dynamic_feature_modules时不会将动态化部分的代码编译进去。
- dynamic_modules_1 和 dynamic_modules_2 是依赖 demo_dynamic_feature_modules 的,这能保证 dynamic_modules_1 和dynamic_modules_2中的动态化代码可以调用非动态化母包中的代码。
- dynamic_modules_1 和 dynamic_modules_2编译成字节码后会打包成资源放在assets里,但在实际过程中我们可以将这些字节码文件通过网络方式下载并加载执行,达到热更新的目的。
- 如果是为了热修复,我们可以将dynamic_modules_1 和 dynamic_modules_2的代码打包进母程序里的,我们只需要将动态化部分的代码里的包名称修改一下,就能够实现与原有逻辑不冲突的前提下执行动态下发的逻辑,这个程序只是为了验证字节码是否能够在AOT模式下执行,并没有做这方面的实现。
一些代码段与注释:
yaml
# <dynamic_interface.yaml>
# Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
# for details. All rights reserved. Use of this source code is governed by a
# BSD-style license that can be found in the LICENSE file.
extendable:
# 动态化代码里继承了StatefulWidget这部分是必须的
- library: 'package:flutter/material.dart'
can-be-overridden:
# 动态化代码里继承了StatefulWidget这部分是必须的
- library: 'package:flutter/material.dart'
callable:
# core一般是必须倒入的,不然基本变量都没法调用
- library: 'dart:core'
# 动态化代码里继承了 StatefulWidget这部分是必须的
- library: 'package:flutter/material.dart'
# 这部分描述了母程序里可以调用的代码入口
- library: 'package:demo_dynamic_feature_modules/demo_dynamic_feature_modules.dart'
dart
// <bin/build_modules.dart>
import 'env.dart';
void main() async {
//非aot模式编译kernal
await compileFlutterKernel(
repoName: "demo_dynamic_feature_modules",
enterPoint: "lib/main.dart",
isAot: false,
);
//非aot模式编译kernal,并验证编译成aot文件
await compileFlutterKernel(
repoName: "demo_dynamic_feature_modules",
enterPoint: "lib/main.dart",
isAot: true,
);
//编译dynamic_module_1字节码
await compileDynamicModule(
repoName: "demo_dynamic_feature_modules",
enterPoint: "lib/dynamic_module_1.dart",
name: 'dynamic_module_1',
out: 'assets/modules/dynamic_module_1.bytecode',
isAot: false,
);
//编译dynamic_module_2字节码
await compileDynamicModule(
repoName: "demo_dynamic_feature_modules",
enterPoint: "lib/dynamic_module_2.dart",
name: 'dynamic_module_2',
out: 'assets/modules/dynamic_module_2.bytecode',
isAot: false,
);
}
dart
// <main.dart>
import 'package:demo_dynamic_feature_modules/demo_dynamic_feature_modules.dart';
import 'package:flutter/material.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Dynamic Module Demo',
theme: ThemeData(colorScheme: .fromSeed(seedColor: Colors.deepPurple)),
onGenerateRoute: MyRouter.onGenerateRoute,
);
}
}
dart
// <home.dart>
import 'package:flutter/material.dart';
//首页
class Home extends StatefulWidget {
const Home({super.key});
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Dynamic Feature Modules Test")),
body: SingleChildScrollView(
child: Column(
children: [
TextButton(
onPressed: () {
//点击跳转到动态化模块1
Navigator.of(context).pushNamed("/dynamic_module_1");
},
child: Text("dynamic_module_1"),
),
TextButton(
onPressed: () {
//点击跳转到动态化模块2
Navigator.of(context).pushNamed("/dynamic_module_2");
},
child: Text("dynamic_module_2"),
),
],
),
),
);
}
}
dart
// <shared.dart>
// 全局共用的参数,在母包中会被编译成aot代码
int _refreshCount = 0;
int get refreshCount => _refreshCount;
set refreshCount(int c) {
_refreshCount = c;
}
dart
// <router.dart>
import 'package:demo_dynamic_feature_modules/home.dart';
import 'package:flutter/material.dart';
import 'package:dynamic_modules/dynamic_modules.dart';
import 'package:flutter/services.dart' show rootBundle;
class MyRouter {
static final String home = "/home";
//路由表
static final Map<String, Widget Function(BuildContext)> routes = {
"/home": (BuildContext context) => const Home(),
"/": (BuildContext context) => const Home(), //home入口
};
//路由入口
static Route<dynamic>? onGenerateRoute(RouteSettings settings) {
print("onGenerateRoute ${settings.name}");
//查找路由表回调方法
var routerFun = routes[settings.name];
print("routerFun is ${routerFun}");
//如果没有找到则调用动态化加载器进行加载
if (routerFun == null) {
return MaterialPageRoute(
builder: (context) => DynamicModuleLoader(name: settings.name!),
);
}
//否则直接返回
return MaterialPageRoute(builder: (context) => routerFun.call(context));
}
}
//动态化代码加载器
class DynamicModuleLoader extends StatefulWidget {
final String name;
const DynamicModuleLoader({super.key, required this.name});
@override
State<DynamicModuleLoader> createState() => _DynamicModuleLoaderState();
String get library => "package:$name$name.dart";
String get module => "assets/modules$name.bytecode";
}
class _DynamicModuleLoaderState extends State<DynamicModuleLoader> {
@override
void initState() {
super.initState();
}
Widget _widgetError(String error) {
return Scaffold(
appBar: AppBar(),
body: Center(child: Text(error)),
);
}
Future createLoadFuture() {
var uri = Uri.parse(widget.library);
//判断该动态化包是否已经加载过
if (isModuleLoaded(uri)) {
//如果有以uri的方式加载
//测试发现dart底层代码并没有实现loadModuleFromUri,调用不会生效
return loadModuleFromUri(uri);
}
//加载资源中的字节码资源
return rootBundle.load(widget.module).then((value) {
return loadModuleFromBytes(
Uri.parse(widget.library),
value.buffer.asUint8List(),
);
});
}
Future<Object?> get loadFuture {
return createLoadFuture()
.then((r) async {
//模拟字节码下载过程,延时2秒
await Future.delayed(Duration(seconds: 2));
return r;
})
.onError((error, stackTrace) {
FlutterError.dumpErrorToConsole(
FlutterErrorDetails(exception: error!, stack: stackTrace),
forceReport: true,
);
throw error;
});
}
Widget _widgetLoading() {
return Scaffold(body: Center(child: const CircularProgressIndicator()));
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Object?>(
future: loadFuture,
builder: (context, snapshot) {
//加载完成
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasError) { //加载报错
return _widgetError('Error: ${snapshot.error}');
}
//检测是否已经注入了新路由
var routeFun = MyRouter.routes[widget.name];
//如果已经有新路由直接调用
return routeFun?.call(context) ??
_widgetError('Error: Router ${widget.name} not found');
}
//加载中
return _widgetLoading();
},
);
}
}
dart
// <pacakges/dynamic_module_1/lib/dynamic_module_1.dart>
// 动态化代码
import 'package:flutter/material.dart';
import 'package:demo_dynamic_feature_modules/demo_dynamic_feature_modules.dart';
//动态化代码执行入口,相当于母程序里的main函数
@pragma('dyn-module:entry-point')
Object? dynamicModuleEntrypoint() {
//添加母程序里的路由
MyRouter.routes["/dynamic_module_1"] = (BuildContext context) =>
const DynamicModule1();
return true;
}
//动态化页面,继承 StatefulWidget
class DynamicModule1 extends StatefulWidget {
const DynamicModule1({super.key});
@override
State<DynamicModule1> createState() => _DynamicModule1State();
}
class _DynamicModule1State extends State<DynamicModule1> {
//将母程序里的 refreshCount 赋值给动态化变量
int _counter = refreshCount;
void _incrementCounter() {
setState(() {
//修改母程序里的refreshCount
refreshCount += 1;
_counter = refreshCount;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text("DynamicModule1"),
),
body: Center(
child: Column(
mainAxisAlignment: .center,
children: [
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
目前的测试结果是:在mac debug,mac profile,mac release,android debug ,android profile , android release ,ios debug,ios profile,ios release中已经跑通
发现一个bug:不知道是不是我测试姿势不对,如果加载了 dynamic_module_1 再加载dynamic_module_2会报错,提示package:demo_dynamic_feature_modules/demo_dynamic_feature_modules.dart重复加载,猜测可能是分包的原因。但在dynamic_modules有加载多个字节码文件的例子,可能还需要再研究一下
发现另外一个bug:貌似相同字节码只能加载一次,不能覆盖操作,暂时也没找到卸载的办法,为此我添加了 isModuleLoaded函数去判断。原本动态化入口函数 dynamicModuleEntrypoint原本是返回一个Widget的,现在改成操作母程序里的路由了。
又发现一个的bug:虽然dart:_internal 包里 loadDynamicModule函数有两个参数 uri和bytes,但查找native的调用并没有实现通过uri加载的实现,坑爹噢。
不过,不过,不过,未来可期啊!!!,就像做一个汽车,最难的发动机已经有了,其他配件都是小菜一碟了。有能力的厂甚至已经能基于现状实现自己的动态化方案了。
所以不吹不黑, Flutter官方的动态化方案在不久的将来或许真的要来了。
不过我这也只是初步测试,性能方面的测试还比较欠缺的,希望能有大牛补上吧。
可能有同学问,这么搞到底AppStore能不能过审啊?我的看法是能过,因为下发的字节码是依赖Dart虚拟机执行的,且这些字节码的执行并没有用到jit,仍然是解释执行的范畴。下发的字节码也并不是可执行文件。是符合AppStore审核标准的,Google Play更不用说了。因为IOS这块的实现原理可以说跟shorebird是一般无二的。shorebird都符合审核,想来这个也是可以的。
可能还有同学问,性能到底怎么样啊?我没测试过,但是想来应该至少跟shorebird是一个档次吧,因为实现原理是相同的。肯定比不过纯aot运行的,但是肯定是好于Fair的用JsCore或一些Lua方案的,也肯定比我以前实现的MicroDart强。毕竟动态化代码是小部分的,大部分代码还是aot运行的(定量的话猜测有个10%左右的差距)。
例外多一嘴,如果Flutter官方真推出动态化解决方案,shorebird应该如何自处呢?哈哈。