解读 Flutter 中热重载原理
本文要点:
- 了解 Flutter 的热重载原理,有利于日常开发中高效排查问题。
- 掌握如何调试断点 Flutter 工具链源码。
前言
1.1 热重载是什么?
熟悉 JS 的同学,可能会嗤之以鼻,在 N 年前就已经用上热重载了,但是对客户端开发人员来说,简直是福音。
那先来看下 Flutter 官方的定义:
Flutter’s hot reload feature helps you quickly and easily experiment, build UIs, add features, and fix bugs. Hot reload works by injecting updated source code files into the running Dart Virtual Machine (VM). After the VM updates classes with the new versions of fields and functions, the Flutter framework automatically rebuilds the widget tree, allowing you to quickly view the effects of your changes.
简单来说,就是通过将修改后的源代码文件注入到正在运行的 Dart 虚拟机来实现,注入之后, Flutter 会自动重新构建 widget 树。
1.2 为什么需要热重载?
程序猿在刀耕火种的时代,开发调试是这样子的:
当项目不大,人数不多的情况下,画面是非常和谐的,效率也是毋庸置疑的高效。但现实是,在大公司,项目往往很大,编译巨慢无比,同时开发人员众多,有着非常严格的流程制度,导致看起来本没有问题的开发调试流程,变得异常的痛苦,降低了个体的效率,这里强调下,指的是个体的效率,个人认为越是完善的流程体系,对个体的约束往往越强,但从团队的角度去看待效率,一定是能 1+ 1 大于 2 的。
而此时的心情是这样子的:
而有了热重载,开发调试是这样子的:
心情也就成这样子的:
1.3 抛出问题
从热重载定义来看,不少人脑子里蹦出不少跟我一样的疑惑:
- 怎么知道哪个文件被修改?
- 修改的源代码到底被转成什么?
- 修改的源代码是怎么注入到 Dart 虚拟机的?
- Flutter 框架又是怎么触发 widget 重绘的?
同时在日常使用热重载的过程中,也会碰到不少这样那样的疑惑:
- 为什么运行 flutter attach 后还需要手动输入 r 来热重载?
- 手动敲 r,这么无(gou)语(shi)的设计,我们能做成自动化吗?
当你在网上看过大量热重载文章后,又衍生了额外的问题:
- 尝试去探索源码时,case 太多,怎么能模拟真实环境?能否断点调试 Flutter 源码?
- 热重载看着跟动态化很像,那能否运用在动态化技术上?
不急,本文会对上述疑问进行一一解答。
dart 的热重载
由于 Flutter 采用 dart 作为开发语言,我们先从 dart 角度来验证下热重载。
2.1 编写验证 demo
考虑到 dart 执行完会关闭当前进程,我们写了个定时器来保证进程存活,同时能看到热重载效果。
import 'dart:async';
void main() {
// 不能退出进程,同时需要看热重载效果,每隔 3s 输出 Hello JDFlutter
Timer.periodic(Duration(seconds: 3), (timer) {
runApp();});}
void runApp() {
print("Hello JDFlutter");}
2.2 开启 VMService
终端下执行 dart --enable-vm-service main.dart,其中的 main.dart 为 2.1 中代码文件:
可以看到终端会不断输出"Hello JDFlutter"的字符。
2.3 执行热重载
我们将 main.dart 文件中打印日志修改为”Hello JD”,同时打开终端输出的 Observatory 链接地址,如下:
找到我们 main.dart 的 Isolate(读者可以简单理解为是 dart 中的线程,只不过 Isolate 没有共享内存),图中红圈部分,进入后找到 Reload Source:
点击 Reload Source 后,终端开始输出”Hello JD”的字符,完成了一次热重载过程,如下图:
2.4 自动化热重载
还是以上面例子为基础,我们加入文件监听,并且通过发送消息给 vm_service 来实现热重载,代码如下:
void main() {
// 不能退出进程,同时需要看热重载效果,每隔 3m 输出 Hello JDFlutter
Timer.periodic(Duration(seconds: 3), (timer) {
runApp();});
// 文件监听
Directory watchDir = Directory(".");
if (watchDir.existsSync()) {
Stream<FileSystemEvent> stream = watchDir.watch(recursive: true);
StreamSubscription _subscription = stream.listen((event) {
// 发送 hotreload
print(event);
hotReload();});}}
void runApp() {
print("Hello JDFlutter");}
Future<ReloadReport> hotReload() async{
final Uri serverUri = (await Service.getInfo()).serverUri;
final Uri webSocketUri = convertToWebSocketUrl(serviceProtocolUrl: serverUri);
final VmService service = await vmServiceConnectUri(webSocketUri.toString());
final VM vm = await service.getVM();
final String isolateId = vm.isolates.first.id;
final ReloadReport report = await service.reloadSources(isolateId);
return report;}
直接运行 dart --enable-vm-service main.dart,期间修改”Hello JDFlutter”为”Hello JD”,运行结果如下:
可以看出,我们成功实现了自动化热重载,上述代码跟 Dart 虚拟机通信步骤如下:
- 获取 Dart VM 的 websocket 服务 URI
- 通过 URI 连接上 Dart VM 的 service
- 通过 service 获取 Dart VM
- 通过 Dart VM 获取 isolateId
- 通过 service 重载指定 isolateId 的任务
2.5 Dart 虚拟机可做的事情
到这里,大家可以放飞自我,Dart Service 提供了大量对外协议,包含断点、获取虚拟机状态,性能等协议,可以参考:Dart 虚拟机服务接口(https://github.com/dart-lang/sdk/blob/master/runtime/vm/service/service.md)
Flutter 的热重载
Flutter 的热重载,本质是在封装 dart 热重载并且对不同的设备启动安装加载等流程,接下来准备好在 Flutter 源码世界里翱翔吧,以下分析基于 v1.22.5 分支的源码。
俗话说,工欲善其事必先利其器,在源码翱翔久了,容易迷茫,找不到东西南北,看到关键方法,又不知道是不是代码真实的 case,需要能验证我们的想法,最简单的办法打断点,有针对性的去看源码。
3.1 IDE 断点
Flutter 源码的下载也很简单,这里就不赘述了,大家可以上网搜下。Flutter 工具链的源码位于 packages/flutter_tools(https://github.com/flutter/flutter/tree/1.22.5/packages/flutter\_tools)下。
本文是通过 Android Studio(比较熟)来配置和查看源码,配置如下:
第一步,先新建一个运行配置,选 Dart Command Line App;
第二步,找到 Flutter 源码中工具链的入口文件,flutter_tools.dart;
第三步,输入想运行的命令;
第四步,找到要调试的 Flutter 工程;
一顿配置下来,就可以用工具链完美的 debug 指定 Flutter 工程的源码,接下来就是选好设备,点击 debug 按钮,如下图:
3.2 整体流程
以下是 Flutter 热重载流程图:
简述为:
- 代码改动:工具会扫描工程下的文件,通过修改时间来比对哪些文件被修改;
- 首次编译:第一次启动会生成全量 app.dill 文件;
- 增量编译:对修改的文件编译生成 app.dill.incremental.dill 增量文件;
- 更新文件:将增量产物推送到设备中;
- UI 更新:DartVM 收到增量文件后进行合并,并通知 Flutter 引擎更新 UI
- 整个过程并没有让 App 重启,从而达到高效开发调试效果。
3.3 源码分析
3.3.1 run 命令流程
我们从 flutter run 命令为入口分析,类位于 packages/flutter_tools/lib/executable.dart 中的 main()方法,run 命令最终实现类位于 packages/flutter_tools/lib/src/commands/run.dart。
RunCommand 在构造函数中默认开启了 hot 标识,如果需要关闭,要新增入参--no-hot。
//packages/flutter_tools/lib/src/commands/run.dart
RunCommand({ bool verboseHelp = false }) : super(verboseHelp: verboseHelp) {
// 无关的省了
argParser
..addFlag('hot’, // 默认开启热重载模式
negatable: true,
defaultsTo: kHotReloadDefault,
help: 'Run with support for hot reloading. Only available for debug mode. Not available with "--trace-startup".',
)
// 无关的省了 }
Run 命令会先执行到 RunCommand.runCommand 中:
Future<FlutterCommandResult> runCommand() async {
// 判断是否需要开启热重载模式
final bool hotMode = shouldUseHotMode();
// 获取本次 build 的模式,比如 release,debug,profile,jitRelease
final BuildMode buildMode = getBuildMode();
// 检查设备是否支持热重载
for (final Device device in devices) {
if (!await device.supportsRuntimeMode(buildMode)) {
throwToolExit(
'${toTitleCase(getFriendlyModeName(buildMode))} '
'mode is not supported by ${device.name}.', );}
if (hotMode) {
if (!device.supportsHotReload) {
throwToolExit('Hot reload is not supported by ${device.name}. Run with --no-hot.');}}}
// 对已连接的设置,创建 FlutterDevice,对应 Android、iOS、Mac 等设备
final List<FlutterDevice> flutterDevices = <FlutterDevice>[
for (final Device device in devices)
await FlutterDevice.create(
device,
...),]; // web 模式不需要开启 Dart 虚拟机的热重载
final bool webMode = featureFlags.isWebEnabled &&
devices.length == 1 &&
await devices.single.targetPlatform == TargetPlatform.web_javascript;
ResidentRunner runner;
if (hotMode && !webMode) {
// 需要热重载
runner = HotRunner(
flutterDevices,
... );
} else if (webMode) {
runner = webRunnerFactory.createWebRunner(
flutterDevices.single,
...);
} else {
runner = ColdRunner(
flutterDevices,
...);}
final int result = await runner.run(
appStartedCompleter: appStartedTimeRecorder,
route: route,);}
从 run 命令的流程,可以看出,主要是做了默认参数设置,参数校验,flutter 设备初始,模式判断等,热重载是从 HotRunner.run 中开始执行。
3.3.2 热重载流程-首次启动
在 HotRunner 中,流程也并不复杂:
//packages/flutter_tools/lib/src/run_hot.dart
Future<int> run({
Completer<DebugConnectionInfo> connectionInfoCompleter,
Completer<void> appStartedCompleter,
String route,
}) async {
final List<Future<bool>> startupTasks = <Future<bool>>[];
for (final FlutterDevice device in flutterDevices) {
if (device.generator != null) {
// 编译生成 dill 文件
startupTasks.add(
device.generator.recompile(
globals.fs.file(mainPath).uri,
<Uri>[],
suppressErrors: applicationBinary == null,
outputPath: dillOutputPath ??
getDefaultApplicationKernelPath(trackWidgetCreation: debuggingOptions.buildInfo.trackWidgetCreation),
packageConfig: packageConfig,
).then((CompilerOutput output) => output?.errorCount == 0));}
// 安装运行 App
startupTasks.add(device.runHot(
hotRunner: this,
route: route,
).then((int result) => result == 0));}
// ...
// 对已启动的设备和已安装运行的 App 进行 attach
return attach(
connectionInfoCompleter: connectionInfoCompleter,
appStartedCompleter: appStartedCompleter,);}
可以看出,HotRunner 做了三件事:
- 对目标设备,编译生成 dill 文件(有人叫 kernel 文件,本质是一种中间描述,后文会介绍);
- 对目标设备,安装运行 App;
- 对目标设备进行 attach,从而开启 attach;
第二步会涉及到不同平台不同做法,对 iOS 和 Android 来说,分别对应 xcrun 和 adb,不是本文重点,流程也比较长,以后有机会再展开讲,重点说第一步和第三步。
编译生成 dill 文件
最终调用到_compile 方法,代码太过于繁琐,我们直接断点看,如下:
从断点信息可以获知,dart 文件会被转为 kernel 文件 app.dill,以下截取部分 app.dill 内容,可以看出 app.dill 是一份完整的代码文件,包含了 main.dart 的内容,右边为 main.dart 源文件,左边为 app.dill 文件内容:
生成的 app.dill 是一份全量的代码,接下来编译不同设备(Android、iOS)的安装包,同时运行指定的包。
此时生成 app.dill 的进程,我们暂且称为“编译进程”,后续热重载增量的 dill,也是驱动该进程生成。
attach 设备
在上述的第二步,设备在启动运行 App 时,会打开 App 中 DartVM 的 Observatory 服务,本质是一个 websocket 服务,按照自定义的 jsonrpc2.0 协议进行通信,在 attach 时,会通过 URI 连接上设备服务,如下:
//packages/flutter_tools/lib/src/resident_runner.dart
Future<void> connect({
ReloadSources reloadSources,
Restart restart,
CompileExpression compileExpression,
ReloadMethod reloadMethod,
GetSkSLMethod getSkSLMethod,
PrintStructuredErrorLogMethod printStructuredErrorLogMethod,
bool disableDds = false,
bool ipv6 = false,
}) {
final Completer<void> completer = Completer<void>();
StreamSubscription<void> subscription;
bool isWaitingForVm = false;
// 监听设备的 URI,通过第二步打开 App 会返回 URI
subscription = observatoryUris.listen((Uri observatoryUri) async {
isWaitingForVm = true;
vm_service.VmService service;
try {
// 通过 URI 连接 Dart 虚拟机服务,后续对设备 App 的操作都是通过该 service,比如断点、截屏等
service = await connectToVmService(
observatoryUri,
reloadSources: reloadSources,
restart: restart,
compileExpression: compileExpression,
reloadMethod: reloadMethod,
getSkSLMethod: getSkSLMethod,
printStructuredErrorLogMethod: printStructuredErrorLogMethod,
device: device,);
}
// ...
}
连上 DartVM 服务后,会注册几个热重载事件:reloadSources,reloadMethod,hotRestart,这几个事件并不是注册到 App 中的 Dart 虚拟机,而是提供给 flutter tool 其他命令使用,如下:
// packages/flutter_tools/lib/src/vmservice.dart
vm_service.VmService setUpVmService(
ReloadSources reloadSources,
Restart restart,
CompileExpression compileExpression,
Device device,
ReloadMethod reloadMethod,
GetSkSLMethod skSLMethod,
PrintStructuredErrorLogMethod printStructuredErrorLogMethod,
vm_service.VmService vmService) {
if (reloadSources != null) {// VM 中注册热重载资源
vmService.registerServiceCallback('reloadSources', (Map<String, dynamic> params) async {
final String isolateId = _validateRpcStringParam('reloadSources', params, 'isolateId');
final bool force = _validateRpcBoolParam('reloadSources', params, 'force');
final bool pause = _validateRpcBoolParam('reloadSources', params, 'pause');
// 接收 reloadSources 事件回调,触发 reloadSources
await reloadSources(isolateId, force: force, pause: pause);});
vmService.registerService('reloadSources', 'Flutter Tools');}
if (reloadMethod != null) {
// VM 中注册热重载方法
vmService.registerServiceCallback('reloadMethod', (Map<String, dynamic> params) async {
final String libraryId = _validateRpcStringParam('reloadMethod', params, 'library');
final String classId = _validateRpcStringParam('reloadMethod', params, 'class');
globals.printTrace('reloadMethod not yet supported, falling back to hot reload');
// 接收 reloadMethod 事件回调,触发 reloadMethod
await reloadMethod(libraryId: libraryId, classId: classId);});
vmService.registerService('reloadMethod', 'Flutter Tools');}
if (restart != null) {
// VM 中注册 hotRestart 方法
vmService.registerServiceCallback('hotRestart', (Map<String, dynamic> params) async {
final bool pause = _validateRpcBoolParam('compileExpression', params, 'pause');
// 接收 hotRestart 事件回调,触发 hotRestart
await restart(pause: pause);});
vmService.registerService('hotRestart', 'Flutter Tools');}
return vmService;}
同时通过 DartVM 服务,来初始设备中 flutter 产物,设备中产物路径是临时生成,用 XXX 代替,产物路径为:
- Android 中为:
file:///data/user/0/com.example.flutter_app/code_cache/XXX/flutter_app/
- iOS 模拟器中为:
/Users/hexianting/资源库/Developer/CoreSimulator/Devices/BC003085-8F19-4EF3-AB84-BD44282F79B7(模拟器设备 ID)/data/Containers/Data/Application/745DE582-59F1-4193-9692-131E611A9359/tmp/XXX/flutter_app/
具体代码如下:
// packages/flutter_tools/lib/src/run_hot.dart
Future<int> attach({
Completer<DebugConnectionInfo> connectionInfoCompleter,
Completer<void> appStartedCompleter,
}) async {
_didAttach = true;
try {
// 连接 DartVM 服务
await connectToServiceProtocol(
reloadSources: _reloadSourcesService,
restart: _restartService,
compileExpression: _compileExpressionService,
reloadMethod: reloadMethod,
getSkSLMethod: writeSkSL,);
} catch (error) { }
try {
// 初始目标设备 flutter 产物
final List<Uri> baseUris = await _initDevFS();
if (connectionInfoCompleter != null) {
// Only handle one debugger connection.
connectionInfoCompleter.complete(
DebugConnectionInfo(
httpUri: flutterDevices.first.vmService.httpAddress,
wsUri: flutterDevices.first.vmService.wsAddress,
baseUri: baseUris.first.toString(), ), ); }
} on Exception catch (error) { }
final Stopwatch initialUpdateDevFSsTimer = Stopwatch()..start();
// 更新 flutter 产物
final UpdateFSReport devfsResult = await _updateDevFS(fullRestart: true);
// ...}
3.3.3 触发热重载
下面分别从源码角度,看看到底做了什么?
开发者在执行 flutter run 或者 flutter attach 后,在终端中输入 r,即可体验到重载效果,如果在 Android Studio 和 VSCode 中,直接 Ctrl+S 或者 Cmd+S 即可。
对应到源码入口:
// packages/flutter_tools/lib/src/resident_runner.dart
Future<bool> _commonTerminalInputHandler(String character) async {
switch(character) {
case 'r':
if (!residentRunner.canHotReload) {
return false;}
final OperationResult result = await residentRunner.restart(fullRestart: false);
if (result.fatal) {
throwToolExit(result.message);}
if (!result.isOk) {
globals.printStatus('Try again after fixing the above error(s).', emphasis: true);}
return true;
case 'R':
if (!residentRunner.canHotRestart || !residentRunner.hotMode) {
return false; }
final OperationResult result = await residentRunner.restart(fullRestart: true);
if (result.fatal) {
throwToolExit(result.message);}
if (!result.isOk) {
globals.printStatus('Try again after fixing the above error(s).', emphasis: true); }
return true;}return false;}
不管是 HotReload 还是 HotRestart,最终都是调用 HotRunner.restart 方法,一路跟进,最终会到某个具体设备 update 方法,并再次调用上述《热重载流程-首次启动》中的_compile 方法,通知编译进程生成增量的 dill 文件 app.dill.incremental.dill。那这个增量文件到底是什么呢?demo 中修改字符串"Flutter Demo Home Page"为"Flutter Demo Home Page2",来看看 dill 文件内容:
第一张图为修改前,第二种为修改后,第三张为增量的 dill 内容。可以看出增量的 dill 文件仅包含改动的 dart 文件代码。
生成增量的 dill 后,会通过_DevFSHttpWriter 写入设备,如下图:
当同步完增量文件,最后还需要通知 DartVM 去刷新 UI 界面,这个步骤就跟我们上述的 2.4 节内容类似:
// packages/flutter_tools/lib/src/resident_runner.dart
Future<List<Future<vm_service.ReloadReport>>> reloadSources(
String entryPath, {
bool pause = false,
}) async {
final String deviceEntryUri = devFS.baseUri
.resolveUri(globals.fs.path.toUri(entryPath)).toString();
// 取出设备中的 DartVM
final vm_service.VM vm = await vmService.getVM();
return <Future<vm_service.ReloadReport>>[
// 通知所有的 Isolate 重载资源
for (final vm_service.IsolateRef isolateRef in vm.isolates)
vmService.reloadSources(
isolateRef.id,
pause: pause,
rootLibUri: deviceEntryUri,)];}
vmService.reloadSources 最终调用了_call 方法,这是一个 dart 官方库,如下:
// /Users/hexianting/.pub-cache/hosted/pub.flutter-io.cn/vm_service-4.2.0/lib/src/vm_service.dart
Future<T> _call<T>(String method, [Map args = const {}]) {
String id = '${++_id}';
Completer<T> completer = Completer<T>();
_completers[id] = completer;
_methodCalls[id] = method;
Map m = {
'jsonrpc': '2.0',
'id': id,
'method': method,
'params': args,};
String message = jsonEncode(m);
_onSend.add(message);
// 发送消息给 DartVM
_writeMessage(message);
return completer.future;}
HotRestart 与 HotReload 区别
Flutter 官方提供两种快速调试方法,一种是 HotReload,另一种是 HotRestart。前者无感知局部刷新,体验最好,但是缺点也很明显,适用比较局限,可以参考官网给出样例:HotReload(https://flutter.dev/docs/development/tools/hot-reload),主要有这几种场景不适用:
- enum 改成 class 类;
- 字体修改;
- 泛类型修改;
- Android 和 iOS 原生修改;
而在 HotRestart 流程中,相比 HotReload 流程,增加了清除资源操作,同时不再生成增量的 dill 文件,每次改动都是生成全量的 app.dill 文件,该细节就不展开,感兴趣读者可以 debug 源码看。
// packages/flutter_tools/lib/src/run_hot.dart
Future<OperationResult> _restartFromSources({
String reason,
bool benchmarkMode = false,
}) async {
// 生成 app.dill 并更新到设备中
final UpdateFSReport updatedDevFS = await _updateDevFS(fullRestart: true);
// 清空资源
_resetDirtyAssets();
for (final FlutterDevice device in flutterDevices) {
if (device.generator != null) {
device.generator.accept();}}
final List<Future<void>> operations = <Future<void>>[];
for (final FlutterDevice device in flutterDevices) {
final Set<String> uiIsolatesIds = <String>{};
final List<FlutterView> views = await device.vmService.getFlutterViews();
for (final FlutterView view in views) {
if (view.uiIsolate == null) {
continue;}
// 查找所有 UI 相关的 Isolate 并触发 resume
uiIsolatesIds.add(view.uiIsolate.id);
final Future<vm_service.Isolate> reloadIsolate = device.vmService
.getIsolateOrNull(view.uiIsolate.id);
operations.add(reloadIsolate.then((vm_service.Isolate isolate) async {
if ((isolate != null) && isPauseEvent(isolate.pauseEvent.kind)) {
// 是否 Isolate 暂停状态,移除所有断点并 resume
final List<Future<void>> breakpointRemoval = <Future<void>>[
for (final vm_service.Breakpoint breakpoint in isolate.breakpoints)
device.vmService.removeBreakpoint(isolate.id, breakpoint.id)];
await Future.wait(breakpointRemoval);
await device.vmService.resume(view.uiIsolate.id);}}));}
// kill 掉非 UI 相关的 isolate
final vm_service.VM vm = await device.vmService.getVM();
for (final vm_service.IsolateRef isolateRef in vm.isolates) {
if (uiIsolatesIds.contains(isolateRef.id)) {
continue;}
operations.add(device.vmService.kill(isolateRef.id)
.catchError((dynamic error, StackTrace stackTrace) {
}, test: (dynamic error) => error is vm_service.SentinelException));}}
await Future.wait(operations);
// 通知 Flutter 引擎去加载 dill 文件
await _launchFromDevFS('$mainPath${_swap ? '.swap' : ''}.dill');
_swap =! _swap;
return OperationResult.ok;}
上述可以看出 HotRestart 额外处理了一些事情,包括杀掉非 UI 的 isolate,重置 UI 的 isolate 等。
对于 dill 文件同步到设备中位置,不同设备不一样:
- Android:file:///data/user/0/com.example.flutter_app/code_cache/XXX/flutter_app/lib/
- iOS 模拟器:/Users/hexianting/Library/Developer/CoreSimulator/Devices/BC003085-8F19-4EF3-AB84-BD44282F79B7(模拟器设备 ID)/data/Containers/Data/Application/9C8E4694-AC99-4A5C-BC46-63567F1C6FD9/tmp/XXX/flutter_app/lib/
至此,热重载源码就告一段落,很多奇技淫巧并不能一一展现,值得大家动手去看看。
总结
经过上述一顿探索,文章最早提出的几个疑问,想必都有了答案。这里只是介绍了 Flutter 源码的冰山一角,更多源码还需要继续探索,通过阅读源码,可做的事情很多:
- 通过文件监听 + vm_service 通信,干掉手动输入 r 或者 R 的这种无(gou)语(shi)设计;
- 源码中并没有限制多个设备,flutter run 同时运行在多个模拟器中,并开启热重载;
- iOS 模拟器不重新安装 App 的情况下,直接替换模拟器中的 flutter 产物,以达到快速调试手段;
- debug 状态下的 DartVM 可以通过热重载来动态化,但性能较低,与谷歌 Flutter 的高性能目标不符;
- 总之,可做的事情很多,那我们看源码的意义就非常清晰:
- 深入了解 Flutter 运行机制,去定制 Flutter 框架;
- 通过研究这些顶级工程师的实现思路,去完善我们自己的逻辑体系,从而成为一个更加严谨的人。
参考资料
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论