开发Flutter项目时遇到了一个奇怪的问题,之所以奇怪是因为源码从Dart语法上来看是没问题的。源码看不出问题,没办法只能尝试看看编译后是否有区别。
从报错信息看应该是Dart后端编译器(backend compiler
)报的错,不过看了看Dart SDK源码暂时没什么头绪,于是就想着要不先看看前端编译(front compiler
)后的中间表示(或者叫中间代码,IR
),也许问题的源头就出在这里。
谈到中间表示可能会感觉有点陌生,但是我要说Java的字节码就是一种中间表示,你可能就熟悉起来了。那么在Dart中,中间表示是什么呢?找到Dart编译相关的文档:
kernel
(内核)就是我们要找的中间表示。和Java的字节码类似,Dart的中间表示也可以在各个平台运行:
当然,这个对于大部分人还是有点陌生,但是说到dill
文件,很多人应该不陌生,Flutter项目编译时就会生成app.dill
文件(位于项目根目录下的.dart_tool/flutter_build/xxx/app.dill
路径)。
dill
文件是由中间表示序列化而成的二进制格式文件,本篇文章要做的就是将dill
文件反序列化到内存后再重新序列化为文本格式,这里之所以不说dill
文件反编译,是因为从dill
文件到文本文件只是反序列化与序列化,并没有涉及到反编译。从这可以看出,Dart的中间表示(kernel
)具有内存表现形式,同时可以序列化为二进制格式(dill
文件)和文本格式。
中间表示序列化为文本是根据抽象语法树(AST
)生成可读的文本格式,不同于抽象语法树的树形数据结构,生成的线性文本更易于查看中间表示的结构和内容。至于为什么是根据抽象语法树生成,请继续往下阅读。
参考文档:
执行以下命令会默认在xxx.dart
文件同目录下生成同名的xxx.dill
文件:
dart compile kernel xxx.dart
也可以通过设置--output
参数指定dill
文件的路径。如果想了解Flutter项目怎么手动生成app.dill
文件请看Dart - dill文件序列化为可读文本(续)。
很久以前,刚接触Flutter的时候,尝试过将dill
文件序列化为可读文本。那时大概是这样做的:
git clone https://github.com/dart-lang/sdk.git
因为后续需要用到sdk/pkg/vm/bin/dump_kernel.dart
文件,而Flutter自动下载的Dart SDK没有,所以需要手动获取完整的Dart SDK。
如果下载的Dart SDK版本和生成dill
文件的Dart版本不一致,后续可能会出现以下报错:
Unhandled exception:
Unexpected Kernel Format Version 101 (expected 77)
#0 BinaryBuilder.readComponent. (package:kernel/binary/ast_from_binary.dart:640:9)
#1 Timeline.timeSync (dart:developer/timeline.dart:166:22)
#2 BinaryBuilder.readComponent (package:kernel/binary/ast_from_binary.dart:627:21)
#3 main (file:///Users/xxx/sdk/pkg/vm/bin/dump_kernel.dart:54:40)
#4 _delayEntrypointInvocation. (dart:isolate-patch/isolate_patch.dart:294:33)
#5 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:189:12)
解决这个问题的办法有两种,一般选第二种:
dill
文件,但是这需要先构建Dart SDK,不然直接执行sdk/sdk/bin/dart
命令会报错:ls: /Users/xxx/sdk/sdk/bin/../../xcodebuild/: No such file or directory
No valid dart configuration found in /Users/xxx/sdk/sdk/bin/../../xcodebuild/
git checkout [版本名称]
[版本名称]
可以通过dart --version
命令获取。
执行以下命令将xxx.dill
文件序列化为xxx.txt
文件:
dart /xxx/sdk/pkg/vm/bin/dump_kernel.dart xxx.dill xxx.txt
按前面的方法在Dart SDK 3.0.5
版本操作,出现报错:
Error: Couldn't resolve the package 'kernel' in 'package:kernel/kernel.dart'.
Error: Couldn't resolve the package 'kernel' in 'package:kernel/binary/ast_from_binary.dart'.
Error: Couldn't resolve the package 'vm' in 'package:vm/metadata/direct_call.dart'.
Error: Couldn't resolve the package 'vm' in 'package:vm/metadata/inferred_type.dart'.
Error: Couldn't resolve the package 'vm' in 'package:vm/metadata/procedure_attributes.dart'.
Error: Couldn't resolve the package 'vm' in 'package:vm/metadata/table_selector.dart'.
Error: Couldn't resolve the package 'vm' in 'package:vm/metadata/unboxing_info.dart'.
Error: Couldn't resolve the package 'vm' in 'package:vm/metadata/unreachable.dart'.
Error: Couldn't resolve the package 'vm' in 'package:vm/metadata/call_site_attributes.dart'.
Error: Couldn't resolve the package 'vm' in 'package:vm/metadata/loading_units.dart'.
用Android Studio打开Dart SDK项目,不出所料一堆报错,不过好像都是依赖库问题导致的未定义报错:
尝试执行dart pub get
命令解决依赖问题,结果报错了:
Because vm depends on dart2wasm any which doesn't exist (could not find package dart2wasm at https://pub.flutter-io.cn), version solving failed.
这个报错的意思是在Pub仓库找不到dart2wasm
库,仔细看看pkg/vm/pubspec.yaml
文件,发现依赖项是这么列的:
# Use 'any' constraints here; we get our versions from the DEPS file.
dependencies:
args: any
build_integration: any
collection: any
crypto: any
front_end: any
kernel: any
package_config: any
yaml: any
# Use 'any' constraints here; we get our versions from the DEPS file.
dev_dependencies:
dart2wasm: any
expect: any
json_rpc_2: any
lints: any
path: any
test: any
web_socket_channel: any
没有指定版本号,用的是any
。在Pub仓库搜索一番确实不存在dart2wasm
库,不过在Dart SDK项目里面找到了(位于pkg/dart2wasm
路径)。难道这些依赖库都是存在于Dart SDK项目,那我全部指定为相对路径是不是就可以了?
先改为这样:
# Use 'any' constraints here; we get our versions from the DEPS file.
dev_dependencies:
dart2wasm:
path: ../dart2wasm
...
重新执行dart pub get
命令,确实不再报dart2wasm
库的错,但是换成了其他库。如果一个个修改那也太多了,肯定还有其他办法。依赖项前面有一句注释提到了从DEPS
文件中获取依赖版本,DEPS
文件是什么呢?DEPS
文件就是用于描述依赖关系的文件,本质是一个python脚本。关于DEPS
文件的一些补充内容请看Dart - dill文件序列化为可读文本(续)。
看来不知道是从哪个版本开始更改了依赖管理方式,查看Git历史提交记录,是在2.18.0
的dev版本做了改动,所以如果你还在用2.18.0
之前的Dart版本,前面的方法应该还是有效的。通过以下命令切换到2.17.7
版本:
git checkout 2.17.7
你会发现前面报错的这些库原先就是通过指定相对路径实现依赖的。
需要python
环境,如果执行python3
命令失败,那么需要先安装python3
。可以通过官网下载安装或brew
命令安装:
brew install python
安装过程可能会遇到这样的问题:
Error: [email protected]: the bottle needs the Apple Command Line Tools to be installed.
You can install them, if desired, with:
xcode-select --install
执行xcode-select --install
命令安装Xcode命令行工具解决。
depot_tools
是Chromium的源码管理工具,后续获取源码和管理依赖项都需要用到。
安装流程:
clone
命令(需要代理)git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
在~/.bashrc
或~/.zshrc
文件中加上:
export PATH=[存放的路径]/depot_tools:$PATH
参考文档:
切换到想要存放Dart SDK源码的位置新建dart-sdk
目录:
mkdir dart-sdk
切换到dart-sdk
目录下执行命令获取源码(需要代理):
fetch dart
这个操作比较耗时,大概耗费5GB流量以及占用12GB空间。如果将命令改为fetch --no-history dart
(仅获取最新源码),实测大概耗费3GB流量以及占用9GB空间。这么一对比,好像是能少下载一些东西,不过不建议,由于只获取最新源码导致后面不好切换版本。
获取源码成功后会自动执行gclient sync
命令同步依赖项,如果同步依赖项时遇到错误被中断可以手动执行gclient sync
命令继续同步。如果命令执行卡在Updating depot_tools...
,请看这篇文章depot_tools问题记录 - 执行fetch/gclient命令无响应。
参考文档:
这时将dill
文件序列化为可读文本大概率会因为版本不一致出现前面提到的问题,所以需要先切换Dart SDK版本。切换到dart-sdk/sdk
路径下执行:
git checkout [版本名称]
[版本名称]
可以通过dart --version
命令获取。你可能会遇到这样的错误:
error: pathspec 'xxx' did not match any file(s) known to git
这可能是因为当前源码不是最新的,无法切换到未知的版本(tag)。解决办法:先执行git pull
命令拉取最新的仓库源码,然后再切换版本。
切换成功后执行命令同步依赖项:
gclient sync
改变版本后,依赖的第三方库可能会有所删减,如果你不想有未使用的第三方库还残留在项目中,可以将同步依赖项的命令改为gclient sync -D
。
执行以下命令将xxx.dill
文件序列化为xxx.txt
文件:
dart /xxx/dart-sdk/sdk/pkg/vm/bin/dump_kernel.dart xxx.dill xxx.txt
如果出现这样的提示:
Usage: dump_kernel input.dill output.txt
Dumps kernel binary file with VM-specific metadata.
请检查路径是否有空格,如果有空格请用双引号包裹路径。
新建一个Dart项目,在项目的lib
目录下新建一个main.dart
文件,然后往文件中简单写点东西:
class Test {
void test(List<String> params) {
for (var param in params) {
print(param);
}
}
}
在项目根路径下执行dart compile kernel lib/main.dart
命令,执行成功后lib
目录下会生成main.dill
文件。
继续执行dart /xxx/dart-sdk/sdk/pkg/vm/bin/dump_kernel.dart lib/main.dill lib/main.txt
命令(xxx
是需要你自己补全的路径),执行成功后lib
目录下会生成main.txt
文件。
main.txt
:
main = ;
library from "package:untitled/main.dart" as main {
class Test extends core::Object {
synthetic constructor •() → main::Test
: super core::Object::•()
;
method test(core::List params) → void {
{
synthesized core::Iterator :sync-for-iterator = params.{core::Iterable::iterator}{core::Iterator};
for (; :sync-for-iterator.{core::Iterator::moveNext}(){() → core::bool}; ) {
core::String param = :sync-for-iterator.{core::Iterator::current}{core::String};
{
core::print(param);
}
}
}
}
}
}
文本内容可读性很高,再加上有源码对照阅读,理解起来还是很容易的。从文本内容可以反推源码中的for-in 循环
语法糖大致等价于这段源码:
void test(List<String> params) {
{
Iterator<String> iterator = params.iterator;
for (; iterator.moveNext();) {
String param = iterator.current;
{
print(param);
}
}
}
}
for-in 循环
语法糖替换为大致等价源码后重新生成的main.txt
:
main = ;
library from "package:untitled1/main.dart" as main {
class Test extends core::Object {
synthetic constructor •() → main::Test
: super core::Object::•()
;
method test(core::List params) → void {
{
core::Iterator iterator = params.{core::Iterable::iterator}{core::Iterator};
for (; iterator.{core::Iterator::moveNext}(){() → core::bool}; ) {
core::String param = iterator.{core::Iterator::current}{core::String};
{
core::print(param);
}
}
}
}
}
}
是不是基本一致?如果想知道更多关于Dart语法糖的大致等价源码,请看这篇文章Dart-语法糖(持续更新)。
前面提到中间表示序列化为文本是根据抽象语法树(AST
)生成可读的文本格式,这句话是有依据的。打开dump_kernel.dart
文件:
main(List<String> arguments) async {
// 必须指定input.dill和output.txt两个参数
if (arguments.length != 2) {
print(_usage);
exit(1);
}
final input = arguments[0];
final output = arguments[1];
// 抽象语法树的根节点,定义于pkg/kernel/lib/ast.dart
final component = new Component();
// Register VM-specific metadata.
component.addMetadataRepository(new DirectCallMetadataRepository());
component.addMetadataRepository(new InferredTypeMetadataRepository());
component.addMetadataRepository(new ProcedureAttributesMetadataRepository());
component.addMetadataRepository(new TableSelectorMetadataRepository());
component.addMetadataRepository(new UnboxingInfoMetadataRepository());
component.addMetadataRepository(new UnreachableNodeMetadataRepository());
component.addMetadataRepository(new CallSiteAttributesMetadataRepository());
component.addMetadataRepository(new LoadingUnitsMetadataRepository());
// 读取dill文件
final List<int> bytes = new File(input).readAsBytesSync();
// 将dill文件反序列化为内存中的抽象语法树
new BinaryBuilderWithMetadata(bytes).readComponent(component);
// 将内存中的抽象语法树序列化为文本
writeComponentToText(component, path: output, showMetadata: true);
}
核心方法有两个,一是readComponent
,定义于pkg/kernel/lib/binary/ast_from_binary.dart
文件,用于dill
文件反序列化;二是writeComponentFile
,从writeComponentToText
方法点进去可以看到,定义于pkg/kernel/lib/text/ast_to_text.dart
文件,用于序列化为文本。
关于readComponent
方法,这里要提到因为Dart SDK版本不一致导致的报错,抛出这个报错的判断就在这个方法:
List<SubComponentView>? readComponent(Component component,
{bool checkCanonicalNames = false, bool createView = false}) {
return Timeline.timeSync<List<SubComponentView>?>(
"BinaryBuilder.readComponent", () {
...
int version = readUint32();
if (version != Tag.BinaryFormatVersion) {
throw InvalidKernelVersionError(filename, version);
}
...
return views;
});
}
readUint32()
方法用于读取dill
文件中的版本,Tag.BinaryFormatVersion
是当前的版本,如果不匹配则抛出InvalidKernelVersionError
异常,我们看到的报错内容就是来源于这个异常的toString
方法:
class InvalidKernelVersionError {
final String? filename;
final int version;
InvalidKernelVersionError(this.filename, this.version);
String toString() {
StringBuffer sb = new StringBuffer();
sb.write('Unexpected Kernel Format Version ${version} '
'(expected ${Tag.BinaryFormatVersion})');
if (filename != null) {
sb.write(' when reading $filename.');
}
return '$sb';
}
}
注意,这个版本不是Dart SDK的版本号,而是二进制格式的版本号,例如Dart SDK 3.0.5
和3.0.4
版本的Tag.BinaryFormatVersion
都是101
。简单来说,序列化与反序列化就是按照约定的格式去生成和解析文件,当约定的格式发生变化,用新的格式去解析旧的格式生成的文件大概率是不兼容的,所以设置一个版本号,每当约定的格式发生变化时,版本号递增。
关于writeComponentFile
方法建议直接看ast_to_text.dart
文件,如果你不太理解序列化的文本内容,那就更建议看这个文件。例如前面文本内容中出现的::
,虽然能猜出这个是用于指定库或类的成员,但是也不能完全确定,在ast_to_text.dart
文件搜索::
,根据搜索结果基本能确定下来:
如果你想深入了解关于序列化与反序列化的源码,那么调试源码少不了。调试方法有两种:
先用Android Studio打开dart-sdk/sdk
目录下的项目,然后配置命令参数,如果路径存在空格,请用双引号包裹路径:
参数配置保存后,和日常开发一样打断点调试就行。
原先的命令增加参数(--pause-isolates-on-start --observe
)执行后打开链接进行断点调试:
dart --pause-isolates-on-start --observe /xxx/dart-sdk/sdk/pkg/vm/bin/dump_kernel.dart xxx.dill xxx.txt
如果看到这对于怎么调试还不清楚或者遇到问题,可以参考这篇文章Flutter - 命令行工具源码调试环境搭建。
补充一点,目前只有二进制格式文件支持反序列化为抽象语法树,文本格式是不支持的,所以想通过修改文本内容后重新序列化为dill
文件暂时是不行的。
如果按照前面的方法操作,可能大部分人都直接放弃了,毕竟挺麻烦的,需要代理又耗时,占用的磁盘空间也不小,所以为了方便大家,生成一些快照文件供大家下载使用。
生成快照命令(切换到sdk/pkg/vm/bin
路径下执行):
dart --snapshot=dump_kernel.snapshot dump_kernel.dart
一开始我以为只要二进制格式的版本号一致就可以通用,后面发现不行,如果Dart SDK版本不一致会报错:
Can't load Kernel binary: Invalid SDK hash.
通过搜索关键词Invalid SDK hash
,可知这个报错来自sdk/runtime/vm/kernel_binary.cc
文件:
从源码看,Dart VM加载快照文件时除了会校验二进制格式的版本号,还会校验Dart SDK版本,当Dart SDK版本不一致(哈希值不一致)时就会出现以上报错。也就是说,运行快照文件的Dart SDK版本必须和生成快照文件时的版本一致。
这个版本校验是在2020年加上的,详见这个issue。如果不想开启这个校验,可以在构建Dart SDK时加上--no-verify-sdk-hash
参数,不过大家用的应该都是构建好的SDK。
目前已有的快照文件(不定期更新):
快照文件名称中的3.0.5
是Dart SDK版本名称,101
是二进制格式版本,请根据自己的开发环境(Dart SDK版本)选择匹配的快照文件下载使用。如果没有找到可用的快照文件或分享链接失效,欢迎留言告诉我。
分享链接:
简单使用示例:
dart /xxx/dump_kernel_xxx_xxx.snapshot xxx.dill xxx.txt
这个示例只是将dump_kernel.dart
替换为了dump_kernel.snapshot
,如果有疑问,请参考前面的内容。
如果这篇文章对你有所帮助,请不要吝啬你的点赞加星,谢谢~