This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,560 @@
<audio id="audio" title="39 | 线上出现问题,该如何做好异常捕获与信息采集?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8a/80/8af2ee6c7060668a0156a3dbc51c4280.mp3"></audio>
你好,我是陈航。
在上一篇文章中我与你分享了如何为一个Flutter工程编写自动化测试用例。设计一个测试用例的基本步骤可以分为3步即定义、执行和验证而Flutter提供的单元测试和UI测试框架则可以帮助我们简化这些步骤。
其中通过单元测试我们可以很方便地验证单个函数、方法或类的行为还可以利用mockito定制外部依赖返回任意数据从而让测试更可控而UI测试则提供了与Widget交互的能力我们可以模仿用户行为对应用进行相应的交互操作确认其功能是否符合预期。
通过自动化测试,我们可以把重复的人工操作变成自动化的验证步骤,从而在开发阶段更及时地发现问题。但终端设备的碎片化,使得我们终究无法在应用开发期就完全模拟出真实用户的运行环境。所以,无论我们的应用写得多么完美、测试得多么全面,总是无法完全避免线上的异常问题。
这些异常可能是因为不充分的机型适配、用户糟糕的网络状况也可能是因为Flutter框架自身的Bug甚至是操作系统底层的问题。这些异常一旦发生Flutter应用会无法响应用户的交互事件轻则报错重则功能无法使用甚至闪退这对用户来说都相当不友好是开发者最不愿意看到的。
所以我们要想办法去捕获用户的异常信息将异常现场保存起来并上传至服务器这样我们就可以分析异常上下文定位引起异常的原因去解决此类问题了。那么今天我们就一起来学习下Flutter异常的捕获和信息采集以及对应的数据上报处理。
## Flutter异常
Flutter异常指的是Flutter程序中Dart代码运行时意外发生的错误事件。我们可以通过与Java类似的try-catch机制来捕获它。但**与Java不同的是Dart程序不强制要求我们必须处理异常**。
这是因为Dart采用事件循环的机制来运行任务所以各个任务的运行状态是互相独立的。也就是说即便某个任务出现了异常我们没有捕获它Dart程序也不会退出只会导致当前任务后续的代码不会被执行用户仍可以继续使用其他功能。
Dart异常根据来源又可以细分为App异常和Framework异常。Flutter为这两种异常提供了不同的捕获方式接下来我们就一起看看吧。
## App异常的捕获方式
App异常就是应用代码的异常通常由未处理应用层其他模块所抛出的异常引起。根据异常代码的执行时序App异常可以分为两类即同步异常和异步异常同步异常可以通过try-catch机制捕获异步异常则需要采用Future提供的catchError语句捕获。
这两种异常的捕获方式,如下代码所示:
```
//使用try-catch捕获同步异常
try {
throw StateError('This is a Dart exception.');
}
catch(e) {
print(e);
}
//使用catchError捕获异步异常
Future.delayed(Duration(seconds: 1))
.then((e) =&gt; throw StateError('This is a Dart exception in Future.'))
.catchError((e)=&gt;print(e));
//注意,以下代码无法捕获异步异常
try {
Future.delayed(Duration(seconds: 1))
.then((e) =&gt; throw StateError('This is a Dart exception in Future.'))
}
catch(e) {
print(&quot;This line will never be executed. &quot;);
}
```
需要注意的是这两种方式是不能混用的。可以看到在上面的代码中我们是无法使用try-catch去捕获一个异步调用所抛出的异常的。
同步的try-catch和异步的catchError为我们提供了直接捕获特定异常的能力而如果我们想集中管理代码中的所有异常Flutter也提供了Zone.runZoned方法。
我们可以给代码执行对象指定一个Zone在Dart中Zone表示一个代码执行的环境范围其概念类似沙盒不同沙盒之间是互相隔离的。如果我们想要观察沙盒中代码执行出现的异常沙盒提供了onError回调函数拦截那些在代码执行对象中的未捕获异常。
在下面的代码中我们将可能抛出异常的语句放置在了Zone里。可以看到在没有使用try-catch和catchError的情况下无论是同步异常还是异步异常都可以通过Zone直接捕获到
```
runZoned(() {
//同步抛出异常
throw StateError('This is a Dart exception.');
}, onError: (dynamic e, StackTrace stack) {
print('Sync error caught by zone');
});
runZoned(() {
//异步抛出异常
Future.delayed(Duration(seconds: 1))
.then((e) =&gt; throw StateError('This is a Dart exception in Future.'));
}, onError: (dynamic e, StackTrace stack) {
print('Async error aught by zone');
});
```
因此如果我们想要集中捕获Flutter应用中的未处理异常可以把main函数中的runApp语句也放置在Zone中。这样在检测到代码中运行异常时我们就能根据获取到的异常上下文信息进行统一处理了
```
runZoned&lt;Future&lt;Null&gt;&gt;(() async {
runApp(MyApp());
}, onError: (error, stackTrace) async {
//Do sth for error
});
```
接下来我们再看看Framework异常应该如何捕获吧。
## Framework异常的捕获方式
Framework异常就是Flutter框架引发的异常通常是由应用代码触发了Flutter框架底层的异常判断引起的。比如当布局不合规范时Flutter就会自动弹出一个触目惊心的红色错误界面如下所示
<img src="https://static001.geekbang.org/resource/image/e1/04/e1169d19bd616705e035464020df5604.png" alt="">
这其实是因为Flutter框架在调用build方法构建页面时进行了try-catch 的处理并提供了一个ErrorWidget用于在出现异常时进行信息提示
```
@override
void performRebuild() {
Widget built;
try {
//创建页面
built = build();
} catch (e, stack) {
//使用ErrorWidget创建页面
built = ErrorWidget.builder(_debugReportException(ErrorDescription(&quot;building $this&quot;), e, stack));
...
}
...
}
```
这个页面反馈的信息比较丰富适合开发期定位问题。但如果让用户看到这样一个页面就很糟糕了。因此我们通常会重写ErrorWidget.builder方法将这样的错误提示页面替换成一个更加友好的页面。
下面的代码演示了自定义错误页面的具体方法。在这个例子中我们直接返回了一个居中的Text控件
```
ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){
return Scaffold(
body: Center(
child: Text(&quot;Custom Error Widget&quot;),
)
);
};
```
运行效果如下所示:
<img src="https://static001.geekbang.org/resource/image/03/a8/032d9bda533fa00a4b8cb86ffd2310a8.png" alt="">
比起之前触目惊心的红色错误页面白色主题的自定义页面看起来稍微友好些了。需要注意的是ErrorWidget.builder方法提供了一个参数details用于表示当前的错误上下文为避免用户直接看到错误信息这里我们并没有将它展示到界面上。但是我们不能丢弃掉这样的异常信息需要提供统一的异常处理机制用于后续分析异常原因。
为了集中处理框架异常,**Flutter提供了FlutterError类这个类的onError属性会在接收到框架异常时执行相应的回调**。因此,要实现自定义捕获逻辑,我们只要为它提供一个自定义的错误处理回调即可。
在下面的代码中我们使用Zone提供的handleUncaughtError语句将Flutter框架的异常统一转发到当前的Zone中这样我们就可以统一使用Zone去处理应用内的所有异常了
```
FlutterError.onError = (FlutterErrorDetails details) async {
//转发至Zone中
Zone.current.handleUncaughtError(details.exception, details.stack);
};
runZoned&lt;Future&lt;Null&gt;&gt;(() async {
runApp(MyApp());
}, onError: (error, stackTrace) async {
//Do sth for error
});
```
## 异常上报
到目前为止,我们已经捕获到了应用中所有的未处理异常。但如果只是把这些异常在控制台中打印出来还是没办法解决问题,我们还需要把它们上报到开发者能看到的地方,用于后续分析定位并解决问题。
关于开发者数据上报目前市面上有很多优秀的第三方SDK服务厂商比如友盟、Bugly以及开源的Sentry等而它们提供的功能和接入流程都是类似的。考虑到Bugly的社区活跃度比较高因此我就以它为例与你演示在抓取到异常后如何实现自定义数据上报。
### Dart接口实现
目前Bugly仅提供了原生Android/iOS的SDK因此我们需要采用与第31篇文章“[如何实现原生推送能力](https://time.geekbang.org/column/article/132818)”中同样的插件工程为Bugly的数据上报提供Dart层接口。
与接入Push能力相比接入数据上报要简单得多我们只需要完成一些前置应用信息关联绑定和SDK初始化工作就可以使用Dart层封装好的数据上报接口去上报异常了。可以看到对于一个应用而言接入数据上报服务的过程总体上可以分为两个步骤
1. 初始化Bugly SDK
1. 使用数据上报接口。
这两步对应着在Dart层需要封装的2个原生接口调用即setup和postException它们都是在方法通道上调用原生代码宿主提供的方法。考虑到数据上报是整个应用共享的能力因此我们将数据上报类FlutterCrashPlugin的接口都封装成了单例
```
class FlutterCrashPlugin {
//初始化方法通道
static const MethodChannel _channel =
const MethodChannel('flutter_crash_plugin');
static void setUp(appID) {
//使用app_id进行SDK注册
_channel.invokeMethod(&quot;setUp&quot;,{'app_id':appID});
}
static void postException(error, stack) {
//将异常和堆栈上报至Bugly
_channel.invokeMethod(&quot;postException&quot;,{'crash_message':error.toString(),'crash_detail':stack.toString()});
}
}
```
Dart层是原生代码宿主的代理可以看到这一层的接口设计还是比较简单的。接下来我们分别去接管数据上报的Android和iOS平台上完成相应的实现。
### iOS接口实现
考虑到iOS平台的数据上报配置工作相对较少因此我们先用Xcode打开example下的iOS工程进行插件开发工作。需要注意的是由于iOS子工程的运行依赖于Flutter工程编译构建产物所以在打开iOS工程进行开发前你需要确保整个工程代码至少build过一次否则IDE会报错。
>
备注:以下操作步骤参考[Bugly异常上报iOS SDK接入指南](https://bugly.qq.com/docs/user-guide/instruction-manual-ios/?v=20190712210424)。
**首先**我们需要在插件工程下的flutter_crash_plugin.podspec文件中引入Bugly SDK即Bugly这样我们就可以在原生工程中使用Bugly提供的数据上报功能了
```
Pod::Spec.new do |s|
...
s.dependency 'Bugly'
end
```
**然后**在原生接口FlutterCrashPlugin类中依次初始化插件实例、绑定方法通道并在方法通道中先后为setup与postException提供Bugly iOS SDK的实现版本
```
@implementation FlutterCrashPlugin
+ (void)registerWithRegistrar:(NSObject&lt;FlutterPluginRegistrar&gt;*)registrar {
//注册方法通道
FlutterMethodChannel* channel = [FlutterMethodChannel
methodChannelWithName:@&quot;flutter_crash_plugin&quot;
binaryMessenger:[registrar messenger]];
//初始化插件实例,绑定方法通道
FlutterCrashPlugin* instance = [[FlutterCrashPlugin alloc] init];
//注册方法通道回调函数
[registrar addMethodCallDelegate:instance channel:channel];
}
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
if([@&quot;setUp&quot; isEqualToString:call.method]) {
//Bugly SDK初始化方法
NSString *appID = call.arguments[@&quot;app_id&quot;];
[Bugly startWithAppId:appID];
} else if ([@&quot;postException&quot; isEqualToString:call.method]) {
//获取Bugly数据上报所需要的各个参数信息
NSString *message = call.arguments[@&quot;crash_message&quot;];
NSString *detail = call.arguments[@&quot;crash_detail&quot;];
NSArray *stack = [detail componentsSeparatedByString:@&quot;\n&quot;];
//调用Bugly数据上报接口
[Bugly reportExceptionWithCategory:4 name:message reason:stack[0] callStack:stack extraInfo:@{} terminateApp:NO];
result(@0);
}
else {
//方法未实现
result(FlutterMethodNotImplemented);
}
}
@end
```
至此在完成了Bugly iOS SDK的接口封装之后FlutterCrashPlugin插件的iOS部分也就搞定了。接下来我们去看看Android部分如何实现吧。
### Android接口实现
与iOS类似我们需要使用Android Studio打开example下的android工程进行插件开发工作。同样在打开android工程前你需要确保整个工程代码至少build过一次否则IDE会报错。
>
备注:以下操作步骤参考[Bugly异常上报Android SDK接入指南](https://bugly.qq.com/docs/user-guide/instruction-manual-android/)
**首先**我们需要在插件工程下的build.gradle文件引入Bugly SDK即crashreport与nativecrashreport其中前者提供了Java和自定义异常的的数据上报能力而后者则是JNI的异常上报封装
```
dependencies {
implementation 'com.tencent.bugly:crashreport:latest.release'
implementation 'com.tencent.bugly:nativecrashreport:latest.release'
}
```
**然后**在原生接口FlutterCrashPlugin类中依次初始化插件实例、绑定方法通道并在方法通道中先后为setup与postException提供Bugly Android SDK的实现版本
```
public class FlutterCrashPlugin implements MethodCallHandler {
//注册器通常为MainActivity
public final Registrar registrar;
//注册插件
public static void registerWith(Registrar registrar) {
//注册方法通道
final MethodChannel channel = new MethodChannel(registrar.messenger(), &quot;flutter_crash_plugin&quot;);
//初始化插件实例,绑定方法通道,并注册方法通道回调函数
channel.setMethodCallHandler(new FlutterCrashPlugin(registrar));
}
private FlutterCrashPlugin(Registrar registrar) {
this.registrar = registrar;
}
@Override
public void onMethodCall(MethodCall call, Result result) {
if(call.method.equals(&quot;setUp&quot;)) {
//Bugly SDK初始化方法
String appID = call.argument(&quot;app_id&quot;);
CrashReport.initCrashReport(registrar.activity().getApplicationContext(), appID, true);
result.success(0);
}
else if(call.method.equals(&quot;postException&quot;)) {
//获取Bugly数据上报所需要的各个参数信息
String message = call.argument(&quot;crash_message&quot;);
String detail = call.argument(&quot;crash_detail&quot;);
//调用Bugly数据上报接口
CrashReport.postException(4,&quot;Flutter Exception&quot;,message,detail,null);
result.success(0);
}
else {
result.notImplemented();
}
}
}
```
在完成了Bugly Android接口的封装之后由于Android系统的权限设置较细考虑到Bugly还需要网络、日志读取等权限因此我们还需要在插件工程的AndroidManifest.xml文件中将这些权限信息显示地声明出来完成对系统的注册
```
&lt;manifest xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
package=&quot;com.hangchen.flutter_crash_plugin&quot;&gt;
&lt;!-- 电话状态读取权限 --&gt;
&lt;uses-permission android:name=&quot;android.permission.READ_PHONE_STATE&quot; /&gt;
&lt;!-- 网络权限 --&gt;
&lt;uses-permission android:name=&quot;android.permission.INTERNET&quot; /&gt;
&lt;!-- 访问网络状态权限 --&gt;
&lt;uses-permission android:name=&quot;android.permission.ACCESS_NETWORK_STATE&quot; /&gt;
&lt;!-- 访问wifi状态权限 --&gt;
&lt;uses-permission android:name=&quot;android.permission.ACCESS_WIFI_STATE&quot; /&gt;
&lt;!-- 日志读取权限 --&gt;
&lt;uses-permission android:name=&quot;android.permission.READ_LOGS&quot; /&gt;
&lt;/manifest&gt;
```
至此在完成了极光Android SDK的接口封装和权限配置之后FlutterCrashPlugin插件的Android部分也搞定了。
FlutterCrashPlugin插件为Flutter应用提供了数据上报的封装不过要想Flutter工程能够真正地上报异常消息我们还需要为Flutter工程关联Bugly的应用配置。
### 应用工程配置
在单独为Android/iOS应用进行数据上报配置之前我们首先需要去[Bugly的官方网站](https://bugly.qq.com)为应用注册唯一标识符即AppKey。这里需要注意的是在Bugly中Android应用与iOS应用被视为不同的产品所以我们需要分别注册
<img src="https://static001.geekbang.org/resource/image/63/f3/63ca78fb4d188345c1659c9b8fb523f3.png" alt="">
<img src="https://static001.geekbang.org/resource/image/47/cb/4764fd46d9087c949b9d7270fd0043cb.png" alt="">
在得到了AppKey之后我们需要**依次进行Android与iOS的配置工作**。
iOS的配置工作相对简单整个配置过程完全是应用与Bugly SDK的关联工作而这些关联工作仅需要通过Dart层调用setUp接口访问原生代码宿主所封装的Bugly API就可以完成因此无需额外操作。
而Android的配置工作则相对繁琐些。由于涉及NDK和Android P网络安全的适配我们还需要分别在build.gradle和AndroidManifest.xml文件进行相应的配置工作。
**首先**由于Bugly SDK需要支持NDK因此我们需要在App的build.gradle文件中为其增加NDK的架构支持
```
defaultConfig {
ndk {
// 设置支持的SO库架构
abiFilters 'armeabi' , 'x86', 'armeabi-v7a', 'x86_64', 'arm64-v8a'
}
}
```
**然后**由于Android P默认限制http明文传输数据因此我们需要为Bugly声明一项网络安全配置network_security_config.xml允许其使用http传输数据并在AndroidManifest.xml中新增同名网络安全配置
```
//res/xml/network_security_config.xml
&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;!-- 网络安全配置 --&gt;
&lt;network-security-config&gt;
&lt;!-- 允许明文传输数据 --&gt;
&lt;domain-config cleartextTrafficPermitted=&quot;true&quot;&gt;
&lt;!-- 将Bugly的域名加入白名单 --&gt;
&lt;domain includeSubdomains=&quot;true&quot;&gt;android.bugly.qq.com&lt;/domain&gt;
&lt;/domain-config&gt;
&lt;/network-security-config&gt;
//AndroidManifest/xml
&lt;application
...
android:networkSecurityConfig=&quot;@xml/network_security_config&quot;
...&gt;
&lt;/application&gt;
```
至此Flutter工程所需的原生配置工作和接口实现就全部搞定了。
接下来我们就可以在Flutter工程中的main.dart文件中**使用FlutterCrashPlugin插件来实现异常数据上报能力了**。当然,我们**首先**还需要在pubspec.yaml文件中将工程对它的依赖显示地声明出来
```
dependencies:
flutter_push_plugin:
git:
url: https://github.com/cyndibaby905/39_flutter_crash_plugin
```
在下面的代码中我们在main函数里为应用的异常提供了统一的回调并在回调函数内使用postException方法将异常上报至Bugly。
而在SDK的初始化方法里由于Bugly视iOS和Android为两个独立的应用因此我们判断了代码的运行宿主分别使用两个不同的App ID对其进行了初始化工作。
此外,为了与你演示具体的异常拦截功能,我们还在两个按钮的点击事件处理中分别抛出了同步和异步两类异常:
```
//上报数据至Bugly
Future&lt;Null&gt; _reportError(dynamic error, dynamic stackTrace) async {
FlutterCrashPlugin.postException(error, stackTrace);
}
Future&lt;Null&gt; main() async {
//注册Flutter框架的异常回调
FlutterError.onError = (FlutterErrorDetails details) async {
//转发至Zone的错误回调
Zone.current.handleUncaughtError(details.exception, details.stack);
};
//自定义错误提示页面
ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){
return Scaffold(
body: Center(
child: Text(&quot;Custom Error Widget&quot;),
)
);
};
//使用runZone方法将runApp的运行放置在Zone中并提供统一的异常回调
runZoned&lt;Future&lt;Null&gt;&gt;(() async {
runApp(MyApp());
}, onError: (error, stackTrace) async {
await _reportError(error, stackTrace);
});
}
class MyApp extends StatefulWidget {
@override
State&lt;StatefulWidget&gt; createState() =&gt; _MyAppState();
}
class _MyAppState extends State&lt;MyApp&gt; {
@override
void initState() {
//由于Bugly视iOS和Android为两个独立的应用因此需要使用不同的App ID进行初始化
if(Platform.isAndroid){
FlutterCrashPlugin.setUp('43eed8b173');
}else if(Platform.isIOS){
FlutterCrashPlugin.setUp('088aebe0d5');
}
super.initState();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Crashy'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: &lt;Widget&gt;[
RaisedButton(
child: Text('Dart exception'),
onPressed: () {
//触发同步异常
throw StateError('This is a Dart exception.');
},
),
RaisedButton(
child: Text('async Dart exception'),
onPressed: () {
//触发异步异常
Future.delayed(Duration(seconds: 1))
.then((e) =&gt; throw StateError('This is a Dart exception in Future.'));
},
)
],
),
),
);
}
}
```
运行这段代码分别点击Dart exception按钮和async Dart exception按钮几次可以看到我们的应用以及控制台并没有提示任何异常信息。
<img src="https://static001.geekbang.org/resource/image/2c/48/2c87e21fcfc55b1b80b599032f2de148.png" alt="">
<img src="https://static001.geekbang.org/resource/image/dd/f7/dd668e2fe931b66cf315b04fdfedecf7.png" alt="">
**然后**,我们打开[Bugly开发者后台](https://bugly.qq.com/v2/workbench/apps)选择对应的App切换到错误分析选项查看对应的面板信息。可以看到Bugly已经成功接收到上报的异常上下文了。
<img src="https://static001.geekbang.org/resource/image/d5/c0/d54b4af764a71ed8865c2888e8df36c0.png" alt="">
<img src="https://static001.geekbang.org/resource/image/a8/25/a819f51de79ea327cd04cff2b4ab7525.png" alt="">
## 总结
好了,今天的分享就到这里,我们来小结下吧。
对于Flutter应用的异常捕获可以分为单个异常捕获和多异常统一拦截两种情况。
其中单异常捕获使用Dart提供的同步异常try-catch以及异步异常catchError机制即可实现。而对多个异常的统一拦截可以细分为如下两种情况一是App异常我们可以将代码执行块放置到Zone中通过onError回调进行统一处理二是Framework异常我们可以使用FlutterError.onError回调进行拦截。
在捕获到异常之后我们需要上报异常信息用于后续分析定位问题。考虑到Bugly的社区活跃度比较高所以我以Bugly为例与你讲述了以原生插件封装的形式如何进行异常信息上报。
需要注意的是Flutter提供的异常拦截只能拦截Dart层的异常而无法拦截Engine层的异常。这是因为Engine层的实现大部分是C++的代码一旦出现异常整个程序就直接Crash掉了。不过通常来说这类异常出现的概率极低一般都是Flutter底层的Bug与我们在应用层的实现没太大关系所以我们也无需过度担心。
如果我们想要追踪Engine层的异常比如给Flutter提Issue则需要借助于原生系统提供的Crash监听机制。这就是一个很繁琐的工作了。
幸运的是我们使用的数据上报SDK Bugly就提供了这样的能力可以自动收集原生代码的Crash。而在Bugly收集到对应的Crash之后我们需要做的事情就是将Flutter Engine层对应的符号表下载下来使用Android提供的ndk-stack、iOS提供的symbolicatecrash或atos命令对相应Crash堆栈进行解析从而得出Engine层崩溃的具体代码。
关于这些步骤的详细说明你可以参考Flutter[官方文档](https://github.com/flutter/flutter/wiki/Crashes)。
我把今天分享涉及的知识点打包到了[GitHub](https://github.com/cyndibaby905/39_crashy_demo)中,你可以下载下来,反复运行几次,加深理解与记忆。
## 思考题
最后,我给你留下两道思考题吧。
第一个问题请扩展_reportError和自定义错误提示页面的实现在Debug环境下将异常数据打印至控制台并保留原有系统错误提示页面实现。
```
//上报数据至Bugly
Future&lt;Null&gt; _reportError(dynamic error, dynamic stackTrace) async {
FlutterCrashPlugin.postException(error, stackTrace);
}
//自定义错误提示页面
ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){
return Scaffold(
body: Center(
child: Text(&quot;Custom Error Widget&quot;),
)
);
};
```
第二个问题并发Isolate的异常可以通过今天分享中介绍的捕获机制去拦截吗如果不行应该怎么做呢
```
//并发Isolate
doSth(msg) =&gt; throw ConcurrentModificationError('This is a Dart exception.');
//主Isolate
Isolate.spawn(doSth, &quot;Hi&quot;);
```
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,251 @@
<audio id="audio" title="40 | 衡量Flutter App线上质量我们需要关注这三个指标" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/64/13/64f57505dd24028f093afc9f00a7f313.mp3"></audio>
你好,我是陈航。
在上一篇文章中我与你分享了如何捕获Flutter应用的未处理异常。所谓异常指的是Dart代码在运行时意外发生的错误事件。对于单一异常来说我们可以使用try-catch或是catchError去处理而如果我们想对异常进行集中的拦截治理则需要使用Zone并结合FlutterError进行统一管理。异常一旦被抓取我们就可以利用第三方数据上报服务比如Bugly上报其上下文信息了。
这些线上异常的监控数据,对于开发者尽早发现线上隐患,确定问题根因至关重要。如果我们想进一步评估应用整体的稳定性的话,就需要把异常信息与页面的渲染关联起来。比如,页面渲染过程是否出现了异常,而导致功能不可用?
而对于以“丝般顺滑”著称的Flutter应用而言页面渲染的性能同样需要我们重点关注。比如界面渲染是否出现会掉帧卡顿现象或者页面加载是否会出现性能问题导致耗时过长这些问题虽不至于让应用完全不能使用但也很容易引起用户对应用质量的质疑甚至是反感。
通过上面的分析可以看到衡量线上Flutter应用整体质量的指标可以分为以下3类
- 页面异常率;
- 页面帧率;
- 页面加载时长。
其中,页面异常率反应了页面的健康程度,页面帧率反应了视觉效果的顺滑程度,而页面加载时长则反应了整个渲染过程中点对点的延时情况。
这三项数据指标是度量Flutter应用是否优秀的重要质量指标。通过梳理这些指标的统计口径建立起Flutter应用的质量监控能力这样一来我们不仅可以及早发现线上隐患还可以确定质量基线从而持续提升用户体验。
所以在今天的分享中我会与你详细讲述这3项指标是如何采集的。
## 页面异常率
页面异常率指的是,页面渲染过程中出现异常的概率。它度量的是页面维度下功能不可用的情况,其统计公式为:**页面异常率=异常发生次数/整体页面PV数**。
在了解了页面异常率的统计口径之后,接下来我们分别来看一下这个公式中的分子与分母应该如何统计吧。
我们先来看看**异常发生次数的统计方法**。通过上一篇文章我们已经知道了在Flutter中未处理异常需要通过Zone与FlutterError去捕获。所以如果我们想统计异常发生次数的话依旧是利用这两个方法只不过要在异常拦截的方法中通过一个计数器进行累加统一记录。
下面的例子演示了异常发生次数的具体统计方法。我们使用全局变量exceptionCount在异常捕获的回调方法_reportError中持续地累加捕获到的异常次数
```
int exceptionCount = 0;
Future&lt;Null&gt; _reportError(dynamic error, dynamic stackTrace) async {
exceptionCount++; //累加异常次数
FlutterCrashPlugin.postException(error, stackTrace);
}
Future&lt;Null&gt; main() async {
FlutterError.onError = (FlutterErrorDetails details) async {
//将异常转发至Zone
Zone.current.handleUncaughtError(details.exception, details.stack);
};
runZoned&lt;Future&lt;Null&gt;&gt;(() async {
runApp(MyApp());
}, onError: (error, stackTrace) async {
//拦截异常
await _reportError(error, stackTrace);
});
}
```
接下来,我们再看看**整体页面PV数如何统计**吧。整体页面PV数其实就是页面的打开次数。通过第21篇文章“[路由与导航Flutter是这样实现页面切换的](https://time.geekbang.org/column/article/118421)”我们已经知道了Flutter页面的切换需要经过Navigator来实现所以页面切换状态也需要通过Navigator才能感知到。
与注册页面路由类似的在MaterialApp中我们可以通过NavigatorObservers属性去监听页面的打开与关闭。下面的例子演示了**NavigatorObserver的具体用法**。在下面的代码中我们定义了一个继承自NavigatorObserver的观察者并在其didPush方法中去统计页面的打开行为
```
int totalPV = 0;
//导航监听器
class MyObserver extends NavigatorObserver{
@override
void didPush(Route route, Route previousRoute) {
super.didPush(route, previousRoute);
totalPV++;//累加PV
}
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
//设置路由监听
navigatorObservers: [
MyObserver(),
],
home: HomePage(),
);
}
}
```
现在我们已经收集到了异常发生次数和整体页面PV数这两个参数接下来我们就可以计算出页面异常率了
```
double pageException() {
if(totalPV == 0) return 0;
return exceptionCount/totalPV;
}
```
可以看到,页面异常率的计算还是相对比较简单的。
## 页面帧率
页面帧率即FPS是图像领域中的定义指的是画面每秒传输帧数。由于人眼的视觉暂留特质当所见到的画面传输帧数高于一定数量的时候就会认为是连贯性的视觉效果。因此对于动态页面而言每秒钟展示的帧数越多画面就越流畅。
由此我们可以得出,**FPS的计算口径为单位时间内渲染的帧总数**。在移动设备中FPS的推荐数值通常是60Hz即每秒刷新页面60次。
为什么是60Hz而不是更高或更低的值呢这是因为显示过程是由VSync信号周期性驱动的而VSync信号的周期就是每秒60次这也是FPS的上限。
CPU与GPU在接收到VSync信号后就会计算图形图像准备渲染内容并将其提交到帧缓冲区等待下一次VSync信号到来时显示到屏幕上。如果在一个VSync时间内CPU或者GPU没有完成内容提交这一帧就会被丢弃等待下一次机会再显示而这时页面会保留之前的内容不变造成界面卡顿。因此FPS低于60Hz时就会出现掉帧现象而如果低于45Hz则会有比较严重的卡顿现象。
为方便开发者统计FPSFlutter在全局window对象上提供了帧回调机制。我们可以**在window对象上注册onReportTimings方法**将最近绘制帧耗费的时间即FrameTiming以回调的形式告诉我们。有了每一帧的绘制时间后我们就可以计算FPS了。
需要注意的是onReportTimings方法只有在有帧被绘制时才有数据回调如果用户没有和App发生交互界面状态没有变化时是不会产生新的帧的。考虑到单个帧的绘制时间差异较大逐帧计算可能会产生数据跳跃所以为了让FPS的计算更加平滑我们需要保留最近25个FrameTiming用于求和计算。
而另一方面对于FPS的计算我们并不能孤立地只考虑帧绘制时间而应该结合VSync信号的周期即1/60秒即16.67毫秒)来综合评估。
由于帧的渲染是依靠VSync信号驱动的如果帧绘制的时间没有超过16.67毫秒我们也需要把它当成16.67毫秒来算因为绘制完成的帧必须要等到下一次VSync信号来了之后才能渲染。而如果帧绘制时间超过了16.67毫秒则会占用后续的VSync信号周期从而打乱后续的绘制次序产生卡顿现象。这里有两种情况
- 如果帧绘制时间正好是16.67的整数倍比如50则代表它花费了3个VSync信号周期即本来可以绘制3帧但实际上只绘制了1帧
- 如果帧绘制时间不是16.67的整数倍比如51那么它花费的VSync信号周期应该向上取整即4个这意味着本来可以绘制4帧实际上只绘制了1帧。
所以我们的FPS计算公式最终确定为**FPS=60*实际渲染的帧数/本来应该在这个时间内渲染完成的帧数**。
下面的示例演示了如何通过onReportTimings回调函数实现FPS的计算。在下面的代码中我们定义了一个容量为25的列表用于存储最近的帧绘制耗时FrameTiming。在FPS的计算函数中我们将列表中每帧绘制时间与VSync周期frameInterval进行比较得出本来应该绘制的帧数最后两者相除就得到了FPS指标。
需要注意的是Android Studio提供的Flutter插件里展示的FPS信息其实也来自于onReportTimings回调所以我们在注册回调时需要保留原始回调引用否则插件就读不到FPS信息了。
```
import 'dart:ui';
var orginalCallback;
void main() {
runApp(MyApp());
//设置帧回调函数并保存原始帧回调函数
orginalCallback = window.onReportTimings;
window.onReportTimings = onReportTimings;
}
//仅缓存最近25帧绘制耗时
const maxframes = 25;
final lastFrames = List&lt;FrameTiming&gt;();
//基准VSync信号周期
const frameInterval = const Duration(microseconds: Duration.microsecondsPerSecond ~/ 60);
void onReportTimings(List&lt;FrameTiming&gt; timings) {
lastFrames.addAll(timings);
//仅保留25帧
if(lastFrames.length &gt; maxframes) {
lastFrames.removeRange(0, lastFrames.length - maxframes);
}
//如果有原始帧回调函数,则执行
if (orginalCallback != null) {
orginalCallback(timings);
}
}
double get fps {
int sum = 0;
for (FrameTiming timing in lastFrames) {
//计算渲染耗时
int duration = timing.timestampInMicroseconds(FramePhase.rasterFinish) - timing.timestampInMicroseconds(FramePhase.buildStart);
//判断耗时是否在Vsync信号周期内
if(duration &lt; frameInterval.inMicroseconds) {
sum += 1;
} else {
//有丢帧,向上取整
int count = (duration/frameInterval.inMicroseconds).ceil();
sum += count;
}
}
return lastFrames.length/sum * 60;
}
```
运行这段代码可以看到我们统计的FPS指标和Flutter插件展示的FPS走势是一致的。
<img src="https://static001.geekbang.org/resource/image/a8/1b/a807f4338b5a1979f7255ad2a3bb051b.png" alt="">
## 页面加载时长
页面加载时长,指的是页面从创建到可见的时间。它反应的是代码中创建页面视图是否存在过度绘制,或者绘制不合理导致创建视图时间过长的情况。
从定义可以看出,**页面加载时长的统计口径为页面可见的时间-页面创建的时间**。获取页面创建的时间比较容易,我们只需要在页面的初始化函数里记录时间即可。那么,**页面可见的时间应该如何统计**呢?
在第11篇文章“[提到生命周期,我们是在说什么?](https://time.geekbang.org/column/article/109490)”中我在介绍Widget的生命周期时曾向你介绍过Flutter的帧回调机制。WidgetsBinding提供了单次Frame回调addPostFrameCallback方法它会在当前Frame绘制完成之后进行回调并且只会回调一次。一旦监听到Frame绘制完成回调后我们就可以确认页面已经被渲染出来了因此我们可以借助这个方法去获取页面可见的时间。
下面的例子演示了如何通过帧回调机制获取页面加载时长。在下面的代码中我们在页面MyPage的初始化方法中记录了页面的创建时间startTime然后在页面状态的初始化方法中通过addPostFrameCallback注册了单次帧绘制回调并在回调函数中记录了页面的渲染完成时间endTime。将这两个时间做减法我们就得到了MyPage的页面加载时长
```
class MyHomePage extends StatefulWidget {
int startTime;
int endTime;
MyHomePage({Key key}) : super(key: key) {
//页面初始化时记录启动时间
startTime = DateTime.now().millisecondsSinceEpoch;
}
@override
_MyHomePageState createState() =&gt; _MyHomePageState();
}
class _MyHomePageState extends State&lt;MyHomePage&gt; {
@override
void initState() {
super.initState();
//通过帧绘制回调获取渲染完成时间
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.endTime = DateTime.now().millisecondsSinceEpoch;
int timeSpend = widget.endTime - widget.startTime;
print(&quot;Page render time:${timeSpend} ms&quot;);
});
}
...
}
```
试着运行一下代码,观察命令行输出:
```
flutter: Page render time:548 ms
```
可以看到通过单次帧绘制回调统计得出的页面加载时间为548毫秒。
至此我们就已经得到了页面异常率、页面帧率和页面加载时长这3个指标了。
## 总结
好了,今天的分享就到这里,我们来总结下主要内容吧。
今天我们一起学习了衡量Flutter应用线上质量的3个指标即页面异常率、页面帧率和页面加载时长以及分别对应的数据采集方式。
其中页面异常率表示页面渲染过程中的稳定性可以通过集中捕获未处理异常结合NavigatorObservers观察页面PV计算得出页面维度下功能不可用的概率。
页面帧率则表示了页面的流畅情况可以利用Flutter提供的帧绘制耗时回调onReportTimings以加权的形式计算出本应该绘制的帧数得到更为准确的FPS。
而页面加载时长,反应的是渲染过程的延时情况。我们可以借助于单次帧回调机制,来获取页面渲染完成时间,从而得到整体页面的加载时长。
通过这3个数据指标统计方法我们再去评估Flutter应用的性能时就有一个具体的数字化标准了。而有了数据之后我们不仅可以及早发现问题隐患准确定位及修复问题还可以根据它们去评估应用的健康程度和页面的渲染性能从而确定后续的优化方向。
我把今天分享涉及的知识点打包到了[GitHub](https://github.com/cyndibaby905/40_peformance_demo)中,你可以下载下来,反复运行几次,加深理解与记忆。
## 思考题
最后,我给你留一道思考题吧。
如果页面的渲染需要依赖单个或多个网络接口数据,这时的页面加载时长应该如何统计呢?
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,106 @@
<audio id="audio" title="41 | 组件化和平台化该如何组织合理稳定的Flutter工程结构" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a1/33/a1151fc5ffe03e70c5ffb8193ac04c33.mp3"></audio>
你好我是陈航。今天我们来聊一聊Flutter应用的工程架构这个话题。
在软件开发中我们不仅要在代码实现中遵守常见的设计模式更需要在架构设计中遵从基本的设计原则。而在这其中DRY即Dont Repeat Yourself原则可以算是最重要的一个。
通俗来讲DRY原则就是“不要重复”。这是一个很朴素的概念因为即使是最初级的开发者在写了一段时间代码后也会不自觉地把一些常用的重复代码抽取出来放到公用的函数、类或是独立的组件库中从而实现代码复用。
在软件开发中,我们通常从架构设计中就要考虑如何去管理重复性(即代码复用),即如何将功能进行分治,将大问题分解为多个较为独立的小问题。而在这其中,组件化和平台化就是客户端开发中最流行的分治手段。
所以今天我们就一起来学习一下这两类分治复用方案的中心思想这样我们在设计Flutter应用的架构时也就能做到有章可循了。
## 组件化
组件化又叫模块化即基于可重用的目的将一个大型软件系统App按照关注点分离的方式拆分成多个独立的组件或模块。每个独立的组件都是一个单独的系统可以单独维护、升级甚至直接替换也可以依赖于别的独立组件只要组件提供的功能不发生变化就不会影响其他组件和软件系统的整体功能。
<img src="https://static001.geekbang.org/resource/image/4f/82/4f49b09f5bc9fd33db010d9286ae2e82.png" alt="">
可以看到组件化的中心思想是将独立的功能进行拆分而在拆分粒度上组件化的约束则较为松散。一个独立的组件可以是一个软件包Package、页面、UI控件甚至可能是封装了一些函数的模块。
**组件的粒度可大可小,那我们如何才能做好组件的封装重用呢?哪些代码应该被放到一个组件中?**这里有一些基本原则,包括单一性原则、抽象化原则、稳定性原则和自完备性原则。
接下来,我们先看看这些原则具体是什么意思。
**单一性原则指的是**,每个组件仅提供一个功能。分而治之是组件化的中心思想,每个组件都有自己固定的职责和清晰的边界,专注地做一件事儿,这样这个组件才能良性发展。
一个反例是Common或Util组件这类组件往往是因为在开发中出现了定义不明确、归属边界不清晰的代码“哎呀这段代码放哪儿好像都不合适那就放CommonUtil吧”。久而久之这类组件就变成了无人问津的垃圾堆。所以再遇到不知道该放哪儿的代码时就需要重新思考组件的设计和职责了。
**抽象化原则**指的是,组件提供的功能抽象应该尽量稳定,具有高复用度。而稳定的直观表现就是对外暴露的接口很少发生变化,要做到这一点,需要我们提升对功能的抽象总结能力,在组件封装时做好功能抽象和接口设计,将所有可能发生变化的因子都在组件内部做好适配,不要暴露给它的调用方。
**稳定性原则**指的是不要让稳定的组件依赖不稳定的组件。比如组件1依赖了组件5如果组件1很稳定但是组件5经常变化那么组件1也就会变得不稳定了需要经常适配。如果组件5里确实有组件1不可或缺的代码我们可以考虑把这段代码拆出来单独做成一个新的组件X或是直接在组件1中拷贝一份依赖的代码。
**自完备性**即组件需要尽可能地做到自给自足尽量减少对其他底层组件的依赖达到代码可复用的目的。比如组件1只是依赖某个大组件5中的某个方法这时更好的处理方法是剥离掉组件1对组件5的依赖直接把这个方法拷贝到组件1中。这样一来组件1就能够更好地应对后续的外部变更了。
在理解了组件化的基本原则之后,**我们再来看看组件化的具体实施步骤**,即剥离基础功能、抽象业务模块和最小化服务能力。
首先我们需要剥离应用中与业务无关的基础功能比如网络请求、组件中间件、第三方库封装、UI组件等将它们封装为独立的基础库然后我们在项目里用pub进行管理。如果是第三方库考虑到后续的维护适配成本我们最好再封装一层使项目不直接依赖外部代码方便后续更新或替换。
基础功能已经封装成了定义更清晰的组件,接下来我们就可以按照业务维度,比如首页、详情页、搜索页等,去拆分独立的模块了。拆分的粒度可以先粗后细,只要能将大体划分清晰的业务组件进行拆分,后续就可以通过分布迭代、局部微调,最终实现整个业务项目的组件化。
在业务组件和基础组件都完成拆分封装后应用的组件化架构就基本成型了最后就可以按照刚才我们说的4个原则去修正各个组件向下的依赖以及最小化对外暴露的能力了。
## 平台化
从组件的定义可以看到,组件是个松散的广义概念,其规模取决于我们封装的功能维度大小,而各个组件之间的关系也仅靠依赖去维持。如果组件之间的依赖关系比较复杂,就会在一定程度上造成功能耦合现象。
如下所示的组件示意图中组件2和组件3同时被多个业务组件和基础功能组件直接引用甚至组件2和组件5、组件3和组件4之间还存在着循环依赖的情况。一旦这些组件的内部实现和外部接口发生变化整个App就会陷入不稳定的状态即所谓牵一发而动全身。
<img src="https://static001.geekbang.org/resource/image/4b/73/4bd68bca82792a0e12561d072372c573.png" alt="">
平台化是组件化的升级即在组件化的基础上对它们提供的功能进行分类统一分层划分增加依赖治理的概念。为了对这些功能单元在概念上进行更为统一的分类我们按照四象限分析法把应用程序的组件按照业务和UI分解为4个维度来分析组件可以分为哪几类。
<img src="https://static001.geekbang.org/resource/image/b6/78/b65d4d3e320763be9794a31fb6658978.png" alt="">
可以看出经过业务与UI的分解之后这些组件可以分为4类
1. 具备UI属性的独立业务模块
1. 不具备UI属性的基础业务功能
1. 不具备业务属性的UI控件
1. 不具备业务属性的基础功能
按照自身定义这4类组件其实隐含着分层依赖的关系。比如处于业务模块中的首页依赖位于基础业务模块中的账号功能再比如位于UI控件模块中的轮播卡片依赖位于基础功能模块中的存储管理等功能。我们将它们按照依赖的先后顺序从上到下进行划分就是一个完整的App了。
<img src="https://static001.geekbang.org/resource/image/95/9c/954527aafaaab090b5bb5a044725d49c.png" alt="">
可以看到,平台化与组件化最大的差异在于增加了分层的概念,每一层的功能均基于同层和下层的功能之上,这使得各个组件之间既保持了独立性,同时也具有一定的弹性,在不越界的情况下按照功能划分各司其职。
**与组件化更关注组件的独立性相比,平台化更关注的是组件之间关系的合理性,而这也是在设计平台化架构时需要重点考虑的单向依赖原则。**
所谓单向依赖原则,指的是组件依赖的顺序应该按照应用架构的层数从上到下依赖,不要出现下层模块依赖上层模块这样循环依赖的现象。这样可以最大限度地避免复杂的耦合,减少组件化时的困难。如果我们每个组件都只是单向依赖其他组件,各个组件之间的关系都是清晰的,代码解耦也就会变得非常轻松了。
平台化强调依赖的顺序性,除了不允许出现下层组件依赖上层组件的情况,跨层组件和同层组件之间的依赖关系也应当严格控制,因为这样的依赖关系往往会带来架构设计上的混乱。
**如果下层组件确实需要调用上层组件的代码怎么办?**
这时我们可以采用增加中间层的方式比如Event Bus、Provider或Router以中间层转发的形式实现信息同步。比如位于第4层的网络引擎中会针对特定的错误码跳转到位于第1层的统一错误页这时我们就可以利用Router提供的命名路由跳转在不感知错误页的实现情况下来完成。又比如位于第2层的账号组件中会在用户登入登出时主动刷新位于第1层的首页和我的页面这时我们就可以利用Event Bus来触发账号切换事件在不需要获取页面实例的情况下通知它们更新界面。关于这部分内容你可以参考第[20](https://time.geekbang.org/column/article/116382)和[21](https://time.geekbang.org/column/article/118421)篇文章中的相关内容,这里就不再赘述了。
**平台化架构是目前应用最广的软件架构设计,其核心在于如何将离散的组件依照单向依赖的原则进行分层。**而关于具体的分层逻辑除了我们上面介绍的业务和UI四象限法则之外你也可以使用其他的划分策略只要整体结构层次清晰明确不存在难以确定归属的组件就可以了。
比如Flutter就采用Embedder操作系统适配层、Engine渲染引擎及Dart VM层和FrameworkUI SDK层整体三层的划分。可以看到Flutter框架每一层的组件定义都有着明确的边界其向上提供的功能和向下依赖的能力也非常明确。
<img src="https://static001.geekbang.org/resource/image/eb/dd/eb1550dd9df00fd5b37e181628b782dd.png" alt="">
备注:此图引自[Flutter System Overview](https://flutter.dev/docs/resources/technical-overview)
## 总结
好了,今天的分享就到这里,我们总结一下主要内容吧。
组件化和平台化都是软件开发中流行的分治手段能够将App内的功能拆分成多个独立的组件或模块。
其中组件化更关注如何保持组件的独立性只要拆分的功能独立即可约束较为松散在中大型App中容易造成一定程度的功能耦合现象。而平台化则更强调组件之间关系的合理性增加了分层的概念使得组件之间既有边界也有一定的弹性。只要满足单向依赖原则各个组件之间的关系都是清晰的。
分治是一种与技术无关的架构思想有利于降低工程的复杂性从而提高App的可扩展和可维护性。今天这篇文章我重点与你分享的是组件化与平台化这两种架构设计的思路并没有讲解它们的具体实现。而关于组件化与平台化的实现细节网络上已经有很多文章了你可以在网上自行搜索了解。如果你还有关于组件化和平台化的其他问题那就在评论区中给我留言吧。
其实你也可以琢磨出今天这篇文章的目的是带你领会App架构设计的核心思想。因为理解思想之后剩下的就是去实践了当你需要设计App架构时再回忆起这些内容或是翻出这篇文章一定会事半功倍。
## 思考题
最后,我给你留一道思考题吧。
在App架构设计中你会采用何种方式去管理涉及资源类的依赖呢
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,288 @@
<audio id="audio" title="42 | 如何构建高效的Flutter App打包发布环境" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/69/ca/696532689dac774729f38ba60cc9edca.mp3"></audio>
你好我是陈航。今天我们来聊一聊Flutter应用的交付这个话题。
软件项目的交付是一个复杂的过程任何原因都有可能导致交付过程失败。中小型研发团队经常遇到的一个现象是App在开发测试时没有任何异常但一到最后的打包构建交付时就问题频出。所以每到新版本发布时大家不仅要等候打包结果还经常需要加班修复临时出现的问题。如果没有很好地线上应急策略即使打包成功交付完成后还是非常紧张。
可以看到产品交付不仅是一个令工程师头疼的过程还是一个高风险动作。其实失败并不可怕可怕的是每次失败的原因都不一样。所以为了保障可靠交付我们需要关注从源代码到发布的整个流程提供一种可靠的发布支撑确保App是以一种可重复的、自动化的方式构建出来的。同时我们还应该将打包过程提前将构建频率加快因为这样不仅可以尽早发现问题修复成本也会更低并且能更好地保证代码变更能够顺利发布上线。
其实,这正是持续交付的思路。
所谓持续交付,指的是建立一套自动监测源代码变更,并自动实施构建、测试、打包和相关操作的流程链机制,以保证软件可以持续、稳定地保持在随时可以发布的状态。 持续交付可以让软件的构建、测试与发布变得更快、更频繁,更早地暴露问题和风险,降低软件开发的成本。
你可能会觉得大型软件工程里才会用到持续交付。其实不然通过运用一些免费的工具和平台中小型项目也能够享受到开发任务自动化的便利。而Travis CI就是这类工具之中市场份额最大的一个。所以接下来我就以Travis CI为例与你分享如何为Flutter工程引入持续交付的能力。
## Travis CI
Travis CI 是在线托管的持续交付服务用Travis来进行持续交付不需要自己搭服务器在网页上点几下就好非常方便。
Travis和GitHub是一对配合默契的工作伙伴只要你在Travis上绑定了GitHub上的项目后续任何代码的变更都会被Travis自动抓取。然后Travis会提供一个运行环境执行我们预先在配置文件中定义好的测试和构建步骤并最终把这次变更产生的构建产物归档到GitHub Release上如下所示
<img src="https://static001.geekbang.org/resource/image/1e/85/1e416da5f8bd0295b75328c728b75e85.png" alt="">
可以看到通过Travis提供的持续构建交付能力我们可以直接看到每次代码的更新的变更结果而不需要累积到发布前再做打包构建。这样不仅可以更早地发现错误定位问题也会更容易。
要想为项目提供持续交付的能力我们首先需要在Travis上绑定GitHub。我们打开[Travis官网](https://travis-ci.com/)使用自己的GitHub账号授权登陆就可以了。登录完成后页面中会出现一个“Activate”按钮点击按钮会跳回到GitHub中进行项目访问权限设置。我们保留默认的设置点击“Approve&amp;Install”即可。
<img src="https://static001.geekbang.org/resource/image/06/4a/0655107bfdbc132e9e1ab72dc42c194a.png" alt="">
<img src="https://static001.geekbang.org/resource/image/a5/4d/a5512881dd0dd42dd845300302d8fb4d.png" alt="">
完成授权之后页面会跳转到Travis。Travis主页上会列出GitHub上你的所有仓库以及你所属于的组织如下图所示
<img src="https://static001.geekbang.org/resource/image/6f/36/6ffd97d34bbbb496d95d11fbaf9b2d36.png" alt="">
完成项目绑定后,接下来就是**为项目增加Travis配置文件**了。配置的方法也很简单,只要在项目的根目录下放一个名为.travis.yml的文件就可以了。
.travis.yml是Travis的配置文件指定了Travis应该如何应对代码变更。代码commit上去之后一旦Travis检测到新的变更Travis就会去查找这个文件根据项目类型language确定执行环节然后按照依赖安装install、构建命令script和发布deploy这三大步骤依次执行里面的命令。一个Travis构建任务流程如下所示
<img src="https://static001.geekbang.org/resource/image/53/04/535659463b5bcc2bde187dcabfa5fc04.png" alt="">
可以看到为了更精细地控制持续构建过程Travis还为install、script和deploy提供了对应的钩子before_install、before_script、after_failure、after_success、before_deploy、after_deploy、after_script可以前置或后置地执行一些特殊操作。
如果你的项目比较简单没有其他的第三方依赖也不需要发布到GitHub Release上只是想看看构建会不会失败那么你可以省略配置文件中的install和deploy。
## 如何为项目引入Travis
可以看到一个最简单的配置文件只需要提供两个字段即language和script就可以让Travis帮你自动构建了。下面的例子演示了如何为一个Dart命令行项目引入Travis。在下面的配置文件中我们将language字段设置为Dart并在script字段中将dart_sample.dart定义为程序入口启动运行
```
#.travis.yml
language: dart
script:
- dart dart_sample.dart
```
将这个文件提交至项目中我们就完成了Travis的配置工作。
Travis会在每次代码提交时自动运行配置文件中的命令如果所有命令都返回0就表示验证通过完全没有问题你的提交记录就会被标记上一个绿色的对勾。反之如果命令运行过程中出现了异常则表示验证失败你的提交记录就会被标记上一个红色的叉这时我们就要点击红勾进入Travis构建详情去查看失败原因并尽快修复问题了。
<img src="https://static001.geekbang.org/resource/image/97/90/97d9fa2c64e48ff50152c4b346372190.png" alt="">
可以看到,为一个工程引入自动化任务的能力,只需要提炼出能够让工程自动化运行需要的命令就可以了。
在[第38篇文章](https://time.geekbang.org/column/article/140079)中我与你介绍了Flutter工程运行自动化测试用例的命令即flutter test所以如果我们要为一个Flutter工程配置自动化测试任务直接把这个命令放置在script字段就可以了。
但需要注意的是Travis并没有内置Flutter运行环境所以我们还需要在install字段中为自动化任务安装Flutter SDK。下面的例子演示了**如何为一个Flutter工程配置自动化测试能力**。在下面的配置文件中我们将os字段设置为osx在install字段中clone了Flutter SDK并将Flutter命令设置为环境变量。最后我们在script字段中加上flutter test命令就完成了配置工作
```
os:
- osx
install:
- git clone https://github.com/flutter/flutter.git
- export PATH=&quot;$PATH:`pwd`/flutter/bin&quot;
script:
- flutter doctor &amp;&amp; flutter test
```
其实为Flutter工程的代码变更引入自动化测试能力相对比较容易但考虑到Flutter的跨平台特性**要想在不同平台上验证工程自动化构建的能力即iOS平台构建出ipa包、Android平台构建出apk包又该如何处理呢**
我们都知道Flutter打包构建的命令是flutter build所以同样的我们只需要把构建iOS的命令和构建Android的命令放到script字段里就可以了。但考虑到这两条构建命令执行时间相对较长所以我们可以利用Travis提供的并发任务选项matrix来把iOS和Android的构建拆开分别部署在独立的机器上执行。
下面的例子演示了如何使用matrix分拆构建任务。在下面的代码中我们定义了两个并发任务即运行在Linux上的Android构建任务执行flutter build apk和运行在OS X上的iOS构建任务flutter build ios。
考虑到不同平台的构建任务需要提前准备运行环境比如Android构建任务需要设置JDK、安装Android SDK和构建工具、接受相应的开发者协议而iOS构建任务则需要设置Xcode版本因此我们分别在这两个并发任务中提供对应的配置选项。
最后需要注意的是由于这两个任务都需要依赖Flutter环境所以install字段并不需要拆到各自任务中进行重复设置
```
matrix:
include:
#声明Android运行环境
- os: linux
language: android
dist: trusty
licenses:
- 'android-sdk-preview-license-.+'
- 'android-sdk-license-.+'
- 'google-gdk-license-.+'
#声明需要安装的Android组件
android:
components:
- tools
- platform-tools
- build-tools-28.0.3
- android-28
- sys-img-armeabi-v7a-google_apis-28
- extra-android-m2repository
- extra-google-m2repository
- extra-google-android-support
jdk: oraclejdk8
sudo: false
addons:
apt:
sources:
- ubuntu-toolchain-r-test
packages:
- libstdc++6
- fonts-droid
#确保sdkmanager是最新的
before_script:
- yes | sdkmanager --update
script:
- yes | flutter doctor --android-licenses
- flutter doctor &amp;&amp; flutter -v build apk
#声明iOS的运行环境
- os: osx
language: objective-c
osx_image: xcode10.2
script:
- flutter doctor &amp;&amp; flutter -v build ios --no-codesign
install:
- git clone https://github.com/flutter/flutter.git
- export PATH=&quot;$PATH:`pwd`/flutter/bin&quot;
```
## 如何将打包好的二进制文件自动发布出来?
在这个案例中,我们构建任务的命令是打包,那打包好的二进制文件可以自动发布出来吗?
答案是肯定的。我们只需要为这两个构建任务增加deploy字段设置skip_cleanup字段告诉Travis在构建完成后不要清除编译产物然后通过file字段把要发布的文件指定出来最后就可以通过GitHub提供的API token上传到项目主页了。
下面的示例演示了deploy字段的具体用法在下面的代码中我们获取到了script字段构建出的app-release.apk并通过file字段将其指定为待发布的文件。考虑到并不是每次构建都需要自动发布所以我们在下面的配置中增加了on选项告诉Travis仅在对应的代码更新有关联tag时才自动发布一个release版本
```
...
#声明构建需要执行的命令
script:
- yes | flutter doctor --android-licenses
- flutter doctor &amp;&amp; flutter -v build apk
#声明部署的策略即上传apk至github release
deploy:
provider: releases
api_key: xxxxx
file:
- build/app/outputs/apk/release/app-release.apk
skip_cleanup: true
on:
tags: true
...
```
需要注意的是由于我们的项目是开源库因此GitHub的API token不能明文放到配置文件中需要在Travis上配置一个API token的环境变量然后把这个环境变量设置到配置文件中。
我们先打开GitHub点击页面右上角的个人头像进入Settings随后点击Developer Settings进入开发者设置。
<img src="https://static001.geekbang.org/resource/image/c1/87/c15f24234d621e6c1c1fa5f096acc587.png" alt="">
在开发者设置页面中我们点击左下角的Personal access tokens选项生成访问token。token设置页面提供了比较丰富的访问权限控制比如仓库限制、用户限制、读写限制等这里我们选择只访问公共的仓库填好token名称cd_demo点击确认之后GitHub会将token的内容展示在页面上。
<img src="https://static001.geekbang.org/resource/image/1c/71/1c7ac4bd801f44f3940eff855b9e2171.png" alt="">
需要注意的是这个token 你只会在GitHub上看到一次页面关了就再也找不到了所以我们先把这个token复制下来。
<img src="https://static001.geekbang.org/resource/image/8e/ca/8ef0ba439f181596f516ec814d80c5ca.png" alt="">
接下来我们打开Travis主页找到我们希望配置自动发布的项目然后点击右上角的More options选择Settings打开项目配置页面。
<img src="https://static001.geekbang.org/resource/image/4d/94/4d34efe29bb2135751f5aba3ffdc4694.png" alt="">
在Environment Variable里把刚刚复制的token改名为GITHUB_TOKEN加到环境变量即可。
<img src="https://static001.geekbang.org/resource/image/67/c7/67826feaefba3105368387e1cfefd5c7.png" alt="">
最后我们只要把配置文件中的api_key替换成${GITHUB_TOKEN}就可以了。
```
...
deploy:
api_key: ${GITHUB_TOKEN}
...
```
这个案例介绍的是Android的构建产物apk发布。而对于iOS而言我们还需要对其构建产物app稍作加工让其变成更通用的ipa格式之后才能发布。这里我们就需要用到deploy的钩子before_deploy字段了这个字段能够在正式发布前执行一些特定的产物加工工作。
下面的例子演示了**如何通过before_deploy字段加工构建产物**。由于ipa格式是在app格式之上做的一层包装所以我们把app文件拷贝到Payload后再做压缩就完成了发布前的准备工作接下来就可以在deploy阶段指定要发布的文件正式进入发布环节了
```
...
#对发布前的构建产物进行预处理打包成ipa
before_deploy:
- mkdir app &amp;&amp; mkdir app/Payload
- cp -r build/ios/iphoneos/Runner.app app/Payload
- pushd app &amp;&amp; zip -r -m app.ipa Payload &amp;&amp; popd
#将ipa上传至github release
deploy:
provider: releases
api_key: ${GITHUB_TOKEN}
file:
- app/app.ipa
skip_cleanup: true
on:
tags: true
...
```
将更新后的配置文件提交至GitHub随后打一个tag。等待Travis构建完毕后可以看到我们的工程已经具备自动发布构建产物的能力了。
<img src="https://static001.geekbang.org/resource/image/36/25/362ff95d6f289e75bb238a06daf88d25.png" alt="">
## 如何为Flutter Module工程引入自动发布能力
这个例子介绍的是传统的Flutter App工程即纯Flutter工程**如果我们想为Flutter Module工程即混合开发的Flutter工程引入自动发布能力又该如何设置呢**
其实也并不复杂。Module工程的Android构建产物是aariOS构建产物是Framework。Android产物的自动发布比较简单我们直接复用apk的发布把file文件指定为aar文件即可iOS的产物自动发布稍繁琐一些需要将Framework做一些简单的加工将它们转换成Pod格式。
下面的例子演示了Flutter Module的iOS产物是如何实现自动发布的。由于Pod格式本身只是在App.Framework和Flutter.Framework这两个文件的基础上做的封装所以我们只需要把它们拷贝到统一的目录FlutterEngine下并将声明了组件定义的FlutterEngine.podspec文件放置在最外层最后统一压缩成zip格式即可。
```
...
#对构建产物进行预处理压缩成zip格式的组件
before_deploy:
- mkdir .ios/Outputs &amp;&amp; mkdir .ios/Outputs/FlutterEngine
- cp FlutterEngine.podspec .ios/Outputs/
- cp -r .ios/Flutter/App.framework/ .ios/Outputs/FlutterEngine/App.framework/
- cp -r .ios/Flutter/engine/Flutter.framework/ .ios/Outputs/FlutterEngine/Flutter.framework/
- pushd .ios/Outputs &amp;&amp; zip -r FlutterEngine.zip ./ &amp;&amp; popd
deploy:
provider: releases
api_key: ${GITHUB_TOKEN}
file:
- .ios/Outputs/FlutterEngine.zip
skip_cleanup: true
on:
tags: true
...
```
将这段代码提交后可以看到Flutter Module工程也可以自动的发布原生组件了。
<img src="https://static001.geekbang.org/resource/image/80/0d/808aa463cec67002b26ad47a745f8a0d.png" alt="">
通过这些例子我们可以看到,**任务配置的关键在于提炼出项目自动化运行需要的命令集合,并确认它们的执行顺序。**只要把这些命令集合按照install、script和deploy三个阶段安置好接下来的事情就交给Travis去完成我们安心享受持续交付带来的便利就可以了。
## 总结
俗话说“90%的故障都是由变更引起的”,这凸显了持续交付对于发布稳定性保障的价值。通过建立持续交付流程链机制,我们可以将代码变更与自动化手段关联起来,让测试和发布变得更快、更频繁,不仅可以提早暴露风险,还能让软件可以持续稳定地保持在随时可发布的状态。
在今天的分享中我与你介绍了如何通过Travis CI为我们的项目引入持续交付能力。Travis的自动化任务的工作流依靠.travis.yml配置文件驱动我们可以在确认好构建任务需要的命令集合后在这个配置文件中依照install、script和deploy这3个步骤拆解执行过程。完成项目的配置之后一旦Travis检测到代码变更就可以自动执行任务了。
简单清晰的发布流程是软件可靠性的前提。如果我们同时发布了100个代码变更导致App性能恶化了我们可能需要花费大量时间和精力去定位究竟是哪些变更影响了App性能以及它们是如何影响的。而如果以持续交付的方式发布App我们能够以更小的粒度去测量和理解代码变更带来的影响是改善还是退化从而可以更早地找到问题更有信心进行更快的发布。
**需要注意的是,**在今天的示例分析中我们构建的是一个未签名的ipa文件这意味着我们需要先完成签名之后才能在真实的iOS设备上运行或者发布到App Store。
iOS的代码签名涉及私钥和多重证书的校验以及对应的加解密步骤是一个相对繁琐的过程。如果我们希望在Travis上部署自动化签名操作需要导出发布证书、私钥和描述文件并提前将这些文件打包成一个压缩包后进行加密上传至仓库。
然后我们还需要在before_install时将这个压缩包进行解密并把证书导到Travis运行环境的钥匙串中这样构建脚本就可以使用临时钥匙串对二进制文件进行签名了。完整的配置你可以参考手机内侧服务厂商蒲公英提供的[集成文档](https://www.pgyer.com/doc/view/travis_ios)了解进一步的细节。
如果你不希望将发布证书、私钥暴露给Travis也可以把未签名的ipa包下载下来解压后通过codesign命令分别对App.Framework、Flutter.Framework以及Runner进行重签名操作然后重新压缩成ipa包即可。[这篇文章](https://www.yangshebing.com/2018/01/06/iOS%E9%80%86%E5%90%91%E5%BF%85%E5%A4%87%E7%BB%9D%E6%8A%80%E4%B9%8Bipa%E9%87%8D%E7%AD%BE%E5%90%8D/)介绍了详细的操作步骤,这里我们也不再赘述了。
我把今天分享涉及的Travis配置上传到了GitHub你可以把这几个项目[Dart_Sample](https://github.com/cyndibaby905/08_Dart_Sample)、[Module_Page](https://github.com/cyndibaby905/28_module_page)、[Crashy_Demo](https://github.com/cyndibaby905/39_crashy_demo)下载下来观察它们的配置文件并在Travis网站上查看对应的构建过程从而加深理解与记忆。
## 思考题
最后,我给你留一道思考题吧。
在Travis配置文件中如何选用特定的Flutter SDK版本比如v1.5.4-hotfix.2)呢?
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,77 @@
<audio id="audio" title="43 | 如何构建自己的Flutter混合开发框架" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e1/70/e17c2b1a964aa1129154245ddc0fe370.mp3"></audio>
你好我是陈航。在本次课程的最后一个主题里我来和你聊聊如何设计自己的Flutter混合开发框架。
所谓混合开发是指在App的整体架构继续使用原生技术栈的基础上将Flutter运行环境嵌入到原生App工程中由原生开发人员为Flutter运行提供宿主容器及基础能力支撑而Flutter开发人员则负责应用层业务及App内大部分渲染工作。
这种开发模式的好处十分明显。对于工程师而言跨平台的Flutter框架减少了对底层环境的依赖使用完整的技术栈和工具链隔离了各个终端系统的差异无论是Android、iOS甚至是前端工程师都可以使用统一而标准化的能力进行业务开发从而扩充了技能栈。而对于企业而言这种方式不仅具备了原生App良好的用户体验以及丰富的底层能力还同时拥有了跨平台技术开发低成本和多端体验一致性的优势直接节省研发资源。
那么在原生工程中引入Flutter混合开发能力我们应该如何设计工程架构原生开发与Flutter开发的工作模式又是怎样的呢
接下来,在今天的分享中,我会着重为你介绍这两个主题设计思路和建设方向;而在下一次分享中,我则会通过一个实际的案例,与你详细说明在业务落地中,我们需要重点考虑哪些技术细节,这样你在为自己的原生工程中设计混合开发框架时也就有迹可循了。
## 混合开发架构
在[第41篇文章](https://time.geekbang.org/column/article/144121)中我与你介绍了软件功能分治的两种手段即组件化和平台化以及如何在满足单向依赖原则的前提下以分层的形式将软件功能进行分类聚合的方法。这些设计思想能够让我们在设计软件系统架构时降低整体工程的复杂性提高App的可扩展性和可维护性。
与纯Flutter工程能够以自治的方式去分拆软件功能、管理工程依赖不同**Flutter混合工程的功能分治**需要原生工程与Flutter工程一起配合完成在Flutter模块的视角看来一部分与渲染相关的基础能力完全由Flutter代码实现而另一部分涉及操作系统底层、业务通用能力部分以及整体应用架构支撑则需要借助于原生工程给予支持。
在第41篇文章中我们通过四象限分析法把纯Flutter应用按照业务和UI分解成4类。同样的混合工程的功能单元也可以按照这个分治逻辑分为4个维度即不具备业务属性的原生基础功能、不具备业务属性的原生UI控件、不具备UI属性的原生基础业务功能和带UI属性的独立业务模块。
<img src="https://static001.geekbang.org/resource/image/c4/4f/c49f49bd45b4f1ff2137f2a04539d84f.png" alt="">
从图中可以看到对于前3个维度即原生UI控件、原生基础功能、原生基础业务功能的定义纯Flutter工程与混合工程并无区别只不过实现方式由Flutter变成了原生对于第四个维度即独立业务模块的功能归属考虑到业务模块的最小单元是页面而Flutter的最终呈现形式也是独立的页面因此我们把Flutter模块也归为此类我们的工程可以像依赖原生业务模块一样直接依赖它为用户提供独立的业务功能。
我们把这些组件及其依赖按照从上到下的方式进行划分就是一个完整的混合开发架构了。可以看到原生工程和Flutter工程的边界定义清晰双方都可以保持原有的分层管理依赖的开发模式不变。
<img src="https://static001.geekbang.org/resource/image/e7/cd/e79fe918939247337e9b474a79ad84cd.png" alt="">
需要注意的是作为一个内嵌在原生工程的功能组件Flutter模块的运行环境是由原生工程提供支持的这也就意味着在渲染交互能力之外的部分基础功能比如网络、存储以及和原生业务共享的业务通用能力比如支付、账号需要原生工程配合完成即原生工程以分层的形式提供上层调用接口Flutter模块以插件的形式直接访问原生代码宿主对应功能实现。
因此不仅不同归属定义的原生组件之前存在着分层依赖的关系Flutter模块与原生组件之前也隐含着分层依赖的关系。比如Flutter模块中处于基础业务模块的账号插件依赖位于原生基础业务模块中的账号功能Flutter模块中处于基础业务模块的网络插件依赖位于原生基础功能的网络引擎。
可以看到在混合工程架构中像原生工程依赖Flutter模块、Flutter模块又依赖原生工程这样跨技术栈的依赖管理行为我们实际上是通过**将双方抽象为彼此对应技术栈的依赖,从而实现分层管理**的即将原生对Flutter的依赖抽象为依赖Flutter模块所封装的原生组件而Flutter对原生的依赖则抽象为依赖插件所封装的原生行为。
## Flutter混合开发工作流
对于软件开发而言,工程师的职责涉及从需求到上线的整个生命周期,包含需求阶段-&gt;方案阶段-&gt;开发阶段-&gt;发布阶段-&gt;线上运维阶段。可以看出,这其实就是一种抽象的工作流程。
其中,**和工程化关联最为紧密的是开发阶段和发布阶段**。我们将工作流中和工程开发相关的部分抽离,定义为开发工作流,根据生命周期中关键节点和高频节点,可以将整个工作流划分为如下七个阶段,即初始化-&gt;开发/调试-&gt;构建-&gt;测试-&gt;发布-&gt;集成-&gt;原生工具链:
<img src="https://static001.geekbang.org/resource/image/81/0d/819e4a250478ddba6a0b51badf1ad20d.png" alt="">
前6个阶段是Flutter的标准工作流最后一个阶段是原生开发的标准工作流。
可以看到,**在混合开发工作模式中Flutter的开发模式与原生开发模式之间有着清晰的分工边界**Flutter模块是原生工程的上游其最终产物是原生工程依赖。从原生工程视角看其开发模式与普通原生应用并无区别因此这里就不再赘述了我们**重点讨论Flutter开发模式**。
对于Flutter标准工作流的6个阶段而言每个阶段都会涉及业务或产品特性提出的特异性要求技术方案的选型各阶段工作成本可用性、可靠性的衡量以及监控相关基础服务的接入和配置等。
每件事儿都是一个固定的步骤,而当开发规模随着文档、代码、需求增加时,我们会发现重复的步骤越来越多。此时,**如果我们把这些步骤像抽象代码一样,抽象出一些相同操作,就可以大大提升开发效率。**
优秀的程序员会发掘工作中的问题,从中探索提高生产力的办法,而**转变思维模式就是一个不错的起点**。以持续交付的指导思想来看待这些问题我们希望整体方案能够以可重复、可配置化的形式来保障整个工作流的开发体验、效率、稳定性和可靠性而这些都离不开Flutter对命令行工具支持。
比如对于测试阶段的Dart代码分析我们可以使用flutter analyze命令对代码中可能存在的语法或语义问题进行检查又比如在发布期的package发布环节我们可以使用flutter packages pub publish --dry-run命令对待发布的包进行发布前检查确认无误后使用去掉dry-run参数的publish命令将包提交至Pub站点。
这些基本命令对各个开发节点的输入、输出以及执行过程进行了抽象,熟练掌握它们及对应的扩展参数用法,我们不仅可以在本地开发时打造一个易用便捷的工程开发环境,还可以将这些命令部署到云端,实现工程构建及部署的自动化。
我把这六个阶段涉及的关键命令总结为了一张表格你可以结合这张表格体会落实在具体实现中的Flutter标准工作流。
<img src="https://static001.geekbang.org/resource/image/ce/5c/ce14569558ffc149a361c2993c54025c.png" alt="">
## 总结
对于Flutter混合开发而言如何处理好原生与Flutter之间的关系需要从工程架构与工作模式上定义清晰的分工边界。
在架构层面将Flutter模块定义为原生工程的独立业务层以原生基础业务层向Flutter模块提供业务通用能力、原生基础能力层向Flutter模块提供基础功能支持这样的方式去分层管理依赖。
在工作模式层面将作为原生工程上游的Flutter模块开发抽象为原生依赖产物的工程管理并提炼出对应的工作流以可重复、配置化的命令行方式对各个阶段进行统一管理。
可以看到在原生App工程中引入Flutter运行环境由原生开发主做应用架构和基础能力赋能、Flutter开发主做应用层业务的混合开发协作方式能够综合原生App与Flutter框架双方的特点和优势不仅可以直接节省研发资源也符合目前行业人才能力模型的发展趋势。
## 思考题
除了工程依赖之外我们还需要管理Flutter SDK自身的依赖。考虑到Flutter SDK升级非常频繁对于多人协作的团队模式中如何保证每个人使用的Flutter SDK版本完全一致呢
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,427 @@
<audio id="audio" title="44 | 如何构建自己的Flutter混合开发框架" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/aa/b2/aa1c1a1a569927a1346a9db34b3e97b2.mp3"></audio>
你好,我是陈航。
在上一篇文章中我从工程架构与工作模式两个层面与你介绍了设计Flutter混合框架需要关注的基本设计原则即确定分工边界。
在工程架构维度由于Flutter模块作为原生工程的一个业务依赖其运行环境是由原生工程提供的因此我们需要将它们各自抽象为对应技术栈的依赖管理方式以分层依赖的方式确定二者的边界。
而在工作模式维度考虑到Flutter模块开发是原生开发的上游因此我们只需要从其构建产物的过程入手抽象出开发过程中的关键节点和高频节点以命令行的形式进行统一管理。构建产物是Flutter模块的输出同时也是原生工程的输入一旦产物完成构建我们就可以接入原生开发的工作流了。
可以看到在Flutter混合框架中Flutter模块与原生工程是相互依存、互利共赢的关系
- Flutter跨平台开发效率高渲染性能和多端体验一致性好因此在分工上主要专注于实现应用层的独立业务页面的渲染闭环
- 而原生开发稳定性高精细化控制力强底层基础能力丰富因此在分工上主要专注于提供整体应用架构为Flutter模块提供稳定的运行环境及对应的基础能力支持。
那么在原生工程中为Flutter模块提供基础能力支撑的过程中面对跨技术栈的依赖管理我们该遵循何种原则呢对于Flutter模块及其依赖的原生插件们我们又该如何以标准的原生工程依赖形式进行组件封装呢
在今天的文章中,我就通过一个典型案例,与你讲述这两个问题的解决办法。
## 原生插件依赖管理原则
在前面[第26](https://time.geekbang.org/column/article/127601)和[31篇](https://time.geekbang.org/column/article/132818)文章里我与你讲述了为Flutter应用中的Dart代码提供原生能力支持的两种方式在原生工程中的Flutter应用入口注册原生代码宿主回调的轻量级方案以及使用插件工程进行独立拆分封装的工程化解耦方案。
无论使用哪种方式Flutter应用工程都为我们提供了一体化的标准解决方案能够在集成构建时自动管理原生代码宿主及其相应的原生依赖因此我们只需要在应用层使用pubspec.yaml文件去管理Dart的依赖。
但**对于混合工程而言,依赖关系的管理则会复杂一些**。这是因为与Flutter应用工程有着对原生组件简单清晰的单向依赖关系不同混合工程对原生组件的依赖关系是多向的Flutter模块工程会依赖原生组件而原生工程的组件之间也会互相依赖。
如果继续让Flutter的工具链接管原生组件的依赖关系那么整个工程就会陷入不稳定的状态之中。因此对于混合工程的原生依赖Flutter模块并不做介入完全交由原生工程进行统一管理。而Flutter模块工程对原生工程的依赖体现在依赖原生代码宿主提供的底层基础能力的原生插件上。
接下来我就以网络通信这一基础能力为例与你展开说明原生工程与Flutter模块工程之间应该如何管理依赖关系。
## 网络插件依赖管理实践
在第24篇文章“[HTTP网络编程与JSON解析](https://time.geekbang.org/column/article/121163)”中我与你介绍了在Flutter中我们可以通过HttpClient、http与dio这三种通信方式实现与服务端的数据交换。
但在混合工程中考虑到其他原生组件也需要使用网络通信能力所以通常是由原生工程来提供网络通信功能的。因为这样不仅可以在工程架构层面实现更合理的功能分治还可以统一整个App内数据交换的行为。比如在网络引擎中为接口请求增加通用参数或者是集中拦截错误等。
关于原生网络通信功能目前市面上有很多优秀的第三方开源SDK比如iOS的AFNetworking和Alamofire、Android的OkHttp和Retrofit等。考虑到AFNetworking和OkHttp在各自平台的社区活跃度相对最高因此我就以它俩为例与你演示混合工程的原生插件管理方法。
## 网络插件接口封装
要想搞清楚如何管理原生插件我们需要先使用方法通道来建立Dart层与原生代码宿主之间的联系。
原生工程为Flutter模块提供原生代码能力我们同样需要使用Flutter插件工程来进行封装。关于这部分内容我在第[31](https://time.geekbang.org/column/article/132818)和[39](https://time.geekbang.org/column/article/141164)篇文章中,已经分别为你演示了推送插件和数据上报插件的封装方法,你也可以再回过头来复习下相关内容。所以,今天我就不再与你过多介绍通用的流程和固定的代码声明部分了,而是重点与你讲述与接口相关的实现细节。
**首先我们来看看Dart代码部分。**
对于插件工程的Dart层代码而言由于它仅仅是原生工程的代码宿主代理所以这一层的接口设计比较简单只需要提供一个可以接收请求URL和参数并返回接口响应数据的方法doRequest即可
```
class FlutterPluginNetwork {
...
static Future&lt;String&gt; doRequest(url,params) async {
//使用方法通道调用原生接口doRequest传入URL和param两个参数
final String result = await _channel.invokeMethod('doRequest', {
&quot;url&quot;: url,
&quot;param&quot;: params,
});
return result;
}
}
```
Dart层接口封装搞定了我们再来看看**接管真实网络调用的Android和iOS代码宿主如何响应Dart层的接口调用**。
我刚刚与你提到过原生代码宿主提供的基础通信能力是基于AFNetworkingiOS和OkHttpAndroid做的封装所以为了在原生代码中使用它们我们**首先**需要分别在flutter_plugin_network.podspec和build.gradle文件中将工程对它们的依赖显式地声明出来
在flutter_plugin_network.podspec文件中声明工程对AFNetworking的依赖
```
Pod::Spec.new do |s|
...
s.dependency 'AFNetworking'
end
```
在build.gradle文件中声明工程对OkHttp的依赖
```
dependencies {
implementation &quot;com.squareup.okhttp3:okhttp:4.2.0&quot;
}
```
**然后**我们需要在原生接口FlutterPluginNetworkPlugin类中完成例行的初始化插件实例、绑定方法通道工作。
最后我们还需要在方法通道中取出对应的URL和query参数为doRequest分别提供AFNetworking和OkHttp的实现版本。
对于iOS的调用而言由于AFNetworking的网络调用对象是AFHTTPSessionManager类所以我们需要这个类进行实例化并定义其接口返回的序列化方式本例中为字符串。然后剩下的工作就是用它去发起网络请求使用方法通道通知Dart层执行结果了
```
@implementation FlutterPluginNetworkPlugin
...
//方法通道回调
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
//响应doRequest方法调用
if ([@&quot;doRequest&quot; isEqualToString:call.method]) {
//取出query参数和URL
NSDictionary *arguments = call.arguments[@&quot;param&quot;];
NSString *url = call.arguments[@&quot;url&quot;];
[self doRequest:url withParams:arguments andResult:result];
} else {
//其他方法未实现
result(FlutterMethodNotImplemented);
}
}
//处理网络调用
- (void)doRequest:(NSString *)url withParams:(NSDictionary *)params andResult:(FlutterResult)result {
//初始化网络调用实例
AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
//定义数据序列化方式为字符串
manager.responseSerializer = [AFHTTPResponseSerializer serializer];
NSMutableDictionary *newParams = [params mutableCopy];
//增加自定义参数
newParams[@&quot;ppp&quot;] = @&quot;yyyy&quot;;
//发起网络调用
[manager GET:url parameters:params progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) {
//取出响应数据响应Dart调用
NSString *string = [[NSString alloc] initWithData:responseObject encoding:NSUTF8StringEncoding];
result(string);
} failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
//通知Dart调用失败
result([FlutterError errorWithCode:@&quot;Error&quot; message:error.localizedDescription details:nil]);
}];
}
@end
```
Android的调用也类似OkHttp的网络调用对象是OkHttpClient类所以我们同样需要这个类进行实例化。OkHttp的默认序列化方式已经是字符串了所以我们什么都不用做只需要URL参数加工成OkHttp期望的格式然后就是用它去发起网络请求使用方法通道通知Dart层执行结果了
```
public class FlutterPluginNetworkPlugin implements MethodCallHandler {
...
@Override
//方法通道回调
public void onMethodCall(MethodCall call, Result result) {
//响应doRequest方法调用
if (call.method.equals(&quot;doRequest&quot;)) {
//取出query参数和URL
HashMap param = call.argument(&quot;param&quot;);
String url = call.argument(&quot;url&quot;);
doRequest(url,param,result);
} else {
//其他方法未实现
result.notImplemented();
}
}
//处理网络调用
void doRequest(String url, HashMap&lt;String, String&gt; param, final Result result) {
//初始化网络调用实例
OkHttpClient client = new OkHttpClient();
//加工URL及query参数
HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder();
for (String key : param.keySet()) {
String value = param.get(key);
urlBuilder.addQueryParameter(key,value);
}
//加入自定义通用参数
urlBuilder.addQueryParameter(&quot;ppp&quot;, &quot;yyyy&quot;);
String requestUrl = urlBuilder.build().toString();
//发起网络调用
final Request request = new Request.Builder().url(requestUrl).build();
client.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, final IOException e) {
//切换至主线程通知Dart调用失败
registrar.activity().runOnUiThread(new Runnable() {
@Override
public void run() {
result.error(&quot;Error&quot;, e.toString(), null);
}
});
}
@Override
public void onResponse(Call call, final Response response) throws IOException {
//取出响应数据
final String content = response.body().string();
//切换至主线程响应Dart调用
registrar.activity().runOnUiThread(new Runnable() {
@Override
public void run() {
result.success(content);
}
});
}
});
}
}
```
需要注意的是,**由于方法通道是非线程安全的所以原生代码与Flutter之间所有的接口调用必须发生在主线程。**而OktHtp在处理网络请求时由于涉及非主线程切换所以需要调用runOnUiThread方法以确保回调过程是在UI线程中执行的否则应用可能会出现奇怪的Bug甚至是Crash。
有些同学可能会比较好奇,**为什么doRequest的Android实现需要手动切回UI线程而iOS实现则不需要呢**这其实是因为doRequest的iOS实现背后依赖的AFNetworking已经在数据回调接口时为我们主动切换了UI线程所以我们自然不需要重复再做一次了。
在完成了原生接口封装之后Flutter工程所需的网络通信功能的接口实现就全部搞定了。
## Flutter模块工程依赖管理
通过上面这些步骤我们以插件的形式提供了原生网络功能的封装。接下来我们就需要在Flutter模块工程中使用这个插件并提供对应的构建产物封装提供给原生工程使用了。这部分内容主要包括以下3大部分
- 第一如何使用FlutterPluginNetworkPlugin插件也就是模块工程功能如何实现
- 第二模块工程的iOS构建产物应该如何封装也就是原生iOS工程如何管理Flutter模块工程的依赖
- 第三模块工程的Android构建产物应该如何封装也就是原生Android工程如何管理Flutter模块工程的依赖。
接下来,我们具体看看每部分应该如何实现。
## 模块工程功能实现
为了使用FlutterPluginNetworkPlugin插件实现与服务端的数据交换能力我们首先需要在pubspec.yaml文件中将工程对它的依赖显示地声明出来
```
flutter_plugin_network:
git:
url: https://github.com/cyndibaby905/44_flutter_plugin_network.git
```
然后我们还得在main.dart文件中为它提供一个触发入口。在下面的代码中我们在界面上展示了一个RaisedButton按钮并在其点击回调函数时使用FlutterPluginNetwork插件发起了一次网络接口调用并把网络返回的数据打印到了控制台上
```
RaisedButton(
child: Text(&quot;doRequest&quot;),
//点击按钮发起网络请求,打印数据
onPressed:()=&gt;FlutterPluginNetwork.doRequest(&quot;https://jsonplaceholder.typicode.com/posts&quot;, {'userId':'2'}).then((s)=&gt;print('Result:$s')),
)
```
运行这段代码点击doRequest按钮观察控制台输出可以看到接口返回的数据信息能够被正常打印证明Flutter模块的功能表现是完全符合预期的。
<img src="https://static001.geekbang.org/resource/image/68/83/6855481fc112697ff2cc03fdcc185883.png" alt="">
## 构建产物应该如何封装?
我们都知道模块工程的Android构建产物是aariOS构建产物是Framework。而在第[28](https://time.geekbang.org/column/article/129754)和[42](https://time.geekbang.org/column/article/144156)篇文章中我与你介绍了不带插件依赖的模块工程构建产物的两种封装方案即手动封装方案与自动化封装方案。这两种封装方案最终都会输出同样的组织形式Android是aariOS则是带podspec的Framework封装组件
如果你已经不熟悉这两种封装方式的具体操作步骤了,可以再复习下这两篇文章的相关内容。接下来,我重点与你讲述的问题是:**如果我们的模块工程存在插件依赖,封装过程是否有区别呢?**
答案是,对于模块工程本身而言,这个过程没有区别;但对于模块工程的插件依赖来说,我们需要主动告诉原生工程,哪些依赖是需要它去管理的。
由于Flutter模块工程把所有原生的依赖都交给了原生工程去管理因此其构建产物并不会携带任何原生插件的封装实现所以我们需要遍历模块工程所使用的原生依赖组件们为它们逐一生成插件代码对应的原生组件封装。
在第18篇文章“[依赖管理第三方组件库在Flutter中要如何管理](https://time.geekbang.org/column/article/114180)”中我与你介绍了Flutter工程管理第三方依赖的实现机制其中.packages文件存储的是依赖的包名与系统缓存中的包文件路径。
类似的,插件依赖也有一个类似的文件进行统一管理,即**.flutter-plugins**。我们可以通过这个文件找到对应的插件名字本例中即为flutter_plugin_network及缓存路径
```
flutter_plugin_network=/Users/hangchen/Documents/flutter/.pub-cache/git/44_flutter_plugin_network-9b4472aa46cf20c318b088573a30bc32c6961777/
```
插件缓存本身也可以被视为一个Flutter模块工程所以我们可以采用与模块工程类似的办法为它生成对应的原生组件封装。
对于iOS而言这个过程相对简单些所以我们先来看看模块工程的iOS构建产物封装过程。
### iOS构建产物应该如何封装
在插件工程的ios目录下为我们提供了带podspec文件的源码组件podspec文件提供了组件的声明及其依赖因此我们可以把这个目录下的文件拷贝出来连同Flutter模块组件一起放到原生工程中的专用目录并写到Podfile文件里。
原生工程会识别出组件本身及其依赖,并按照声明的依赖关系依次遍历,自动安装:
```
#Podfile
target 'iOSDemo' do
pod 'Flutter', :path =&gt; 'Flutter'
pod 'flutter_plugin_network', :path =&gt; 'flutter_plugin_network'
end
```
然后我们就可以像使用不带插件依赖的模块工程一样把它引入到原生工程中为其设置入口在FlutterViewController中展示Flutter模块的页面了。
不过需要注意的是由于FlutterViewController并不感知这个过程因此不会主动初始化项目中的插件所以我们还需要在入口处手动将工程里所有的插件依次声明出来
```
//AppDelegate.m:
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
//初始化Flutter入口
FlutterViewController *vc = [[FlutterViewController alloc]init];
//初始化插件
[FlutterPluginNetworkPlugin registerWithRegistrar:[vc registrarForPlugin:@&quot;FlutterPluginNetworkPlugin&quot;]];
//设置路由标识符
[vc setInitialRoute:@&quot;defaultRoute&quot;];
self.window.rootViewController = vc;
[self.window makeKeyAndVisible];
return YES;
}
```
在Xcode中运行这段代码点击doRequest按钮可以看到接口返回的数据信息能够被正常打印证明我们已经可以在原生iOS工程中顺利的使用Flutter模块了。
<img src="https://static001.geekbang.org/resource/image/32/c8/329866c452354bd0524fc3de798b4fc8.png" alt="">
我们再来看看模块工程的Android构建产物应该如何封装。
### Android构建产物应该如何封装
与iOS的插件工程组件在ios目录类似Android的插件工程组件在android目录。对于iOS的插件工程我们可以直接将源码组件提供给原生工程但对于Andriod的插件工程来说我们只能将aar组件提供给原生工程所以我们不仅需要像iOS操作步骤那样进入插件的组件目录还需要借助构建命令为插件工程生成aar
```
cd android
./gradlew flutter_plugin_network:assRel
```
命令执行完成之后aar就生成好了。aar位于android/build/outputs/aar目录下我们打开插件缓存对应的路径提取出对应的aar本例中为flutter_plugin_network-debug.aar就可以了。
我们把生成的插件aar连同Flutter模块aar一起放到原生工程的libs目录下最后在build.gradle文件里将它显式地声明出来就完成了插件工程的引入。
```
//build.gradle
dependencies {
...
implementation(name: 'flutter-debug', ext: 'aar')
implementation(name: 'flutter_plugin_network-debug', ext: 'aar')
implementation &quot;com.squareup.okhttp3:okhttp:4.2.0&quot;
...
}
```
然后我们就可以在原生工程中为其设置入口在FlutterView中展示Flutter页面愉快地使用Flutter模块带来的高效开发和高性能渲染能力了
```
//MainActivity.java
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
View FlutterView = Flutter.createView(this, getLifecycle(), &quot;defaultRoute&quot;);
setContentView(FlutterView);
}
}
```
不过**需要注意的是**与iOS插件工程的podspec能够携带组件依赖不同Android插件工程的封装产物aar本身不携带任何配置信息。所以如果插件工程本身存在原生依赖像flutter_plugin_network依赖OkHttp这样我们是无法通过aar去告诉原生工程其所需的原生依赖的。
面对这种情况我们需要在原生工程中的build.gradle文件里手动地将插件工程的依赖即OkHttp显示地声明出来。
```
//build.gradle
dependencies {
...
implementation(name: 'flutter-debug', ext: 'aar')
implementation(name: 'flutter_plugin_network-debug', ext: 'aar')
implementation &quot;com.squareup.okhttp3:okhttp:4.2.0&quot;
...
}
```
**至此将模块工程及其插件依赖封装成原生组件的全部工作就完成了原生工程可以像使用一个普通的原生组件一样去使用Flutter模块组件的功能了。**
在Android Studio中运行这段代码并点击doRequest按钮可以看到我们可以在原生Android工程中正常使用Flutter封装的页面组件了。
<img src="https://static001.geekbang.org/resource/image/54/f3/543a78c6839639a28b2eb9246c0196f3.png" alt="">
当然考虑到手动封装模块工程及其构建产物的过程繁琐且容易出错我们可以把这些步骤抽象成命令行脚本并把它部署到Travis上。这样在Travis检测到代码变更之后就会自动将Flutter模块的构建产物封装成原生工程期望的组件格式了。
关于这部分内容,你可以参考我在[flutter_module_demo](https://github.com/cyndibaby905/44_flutter_module_demo)里的[generate_aars.sh](https://github.com/cyndibaby905/44_flutter_module_demo/blob/master/generate_aars.sh)与[generate_pods.sh](https://github.com/cyndibaby905/44_flutter_module_demo/blob/master/generate_pods.sh)实现。如果关于这部分内容有任何问题,都可以直接留言给我。
## 总结
好了关于Flutter混合开发框架的依赖管理部分我们就讲到这里。接下来我们一起总结下今天的主要内容吧。
Flutter模块工程的原生组件封装形式是aarAndroid和FrameworkPod。与纯Flutter应用工程能够自动管理插件的原生依赖不同这部分工作在模块工程中是完全交给原生工程去管理的。因此我们需要查找记录了插件名称及缓存路径映射关系的.flutter-plugins文件提取出每个插件所对应的原生组件封装集成到原生工程中。
从今天的分享可以看出对于有着插件依赖的Android组件封装来说由于aar本身并不携带任何配置信息因此其操作以手工为主我们不仅要执行构建命令依次生成插件对应的aar还需要将插件自身的原生依赖拷贝至原生工程其步骤相对iOS组件封装来说要繁琐一些。
为了解决这一问题,业界出现了一种名为[fat-aar](https://github.com/adwiv/android-fat-aar)的打包手段它能够将模块工程本身及其相关的插件依赖统一打包成一个大的aar从而省去了依赖遍历和依赖声明的过程实现了更好的功能自治性。但这种解决方案存在一些较为明显的不足
- 依赖冲突问题。如果原生工程与插件工程都引用了同样的原生依赖组件OkHttp则原生工程的组件引用其依赖时会产生合并冲突因此在发布时必须手动去掉原生工程的组件依赖。
- 嵌套依赖问题。fat-aar只会处理embedded关键字指向的这层一级依赖而不会处理再下一层的依赖。因此对于依赖关系复杂的插件支持我们仍需要手动处理依赖问题。
- Gradle版本限制问题。fat-aar方案对Gradle插件版本有限制且实现方式并不是官方设计考虑的点加之Gradle API变更较快所以存在后续难以维护的问题。
- 其他未知问题。fat-aar项目已经不再维护了最近一次更新还是2年前在实际项目中使用“年久失修”的项目存在较大的风险。
考虑到这些因素fat-aar并不是管理插件工程依赖的好的解决方案所以**我们最好还是得老老实实地去遍历插件依赖以持续交付的方式自动化生成aar。**
我把今天分享涉及知识点打包上传到了GitHub中你可以把[插件工程](https://github.com/cyndibaby905/44_flutter_plugin_network)、[Flutter模块工程](https://github.com/cyndibaby905/44_flutter_module_demo)、[原生Android](https://github.com/cyndibaby905/44_AndroidDemo)和[iOS工程](https://github.com/cyndibaby905/44_iOSDemo)下载下来查看其Travis持续交付配置文件的构建执行命令体会在混合框架中如何管理跨技术栈的组件依赖。
## 思考题
最后,我给你留一道思考题吧。
原生插件的开发是一个需要Dart层代码封装以及原生Android、iOS代码层实现的长链路过程。如果需要支持的基础能力较多开发插件的过程就会变得繁琐且容易出错。我们都知道Dart是不支持反射的但是原生代码可以。我们是否可以利用原生的反射去实现插件定义的标准化呢
提示在Dart层调用不存在的接口或未实现的接口可以通过noSuchMethod方法进行统一处理。
```
class FlutterPluginDemo {
//方法通道
static const MethodChannel _channel =
const MethodChannel('flutter_plugin_demo');
//当调用不存在接口时Dart会交由该方法进行统一处理
@override
Future&lt;dynamic&gt; noSuchMethod(Invocation invocation) {
//从字符串Symbol(&quot;methodName&quot;)中取出方法名
String methodName = invocation.memberName.toString().substring(8, string.length - 2);
//参数
dynamic args = invocation.positionalArguments;
print('methodName:$methodName');
print('args:$args');
return methodTemplate(methodName, args);
}
//某未实现的方法
Future&lt;dynamic&gt; someMethodNotImplemented();
//某未实现的带参数方法
Future&lt;dynamic&gt; someMethodNotImplementedWithParameter(param);
}
```
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。