mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-14 21:23:49 +08:00
mod
This commit is contained in:
560
极客时间专栏/Flutter核心技术与实战/Flutter综合应用/39 | 线上出现问题,该如何做好异常捕获与信息采集?.md
Normal file
560
极客时间专栏/Flutter核心技术与实战/Flutter综合应用/39 | 线上出现问题,该如何做好异常捕获与信息采集?.md
Normal 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) => throw StateError('This is a Dart exception in Future.'))
|
||||
.catchError((e)=>print(e));
|
||||
|
||||
//注意,以下代码无法捕获异步异常
|
||||
try {
|
||||
Future.delayed(Duration(seconds: 1))
|
||||
.then((e) => throw StateError('This is a Dart exception in Future.'))
|
||||
}
|
||||
catch(e) {
|
||||
print("This line will never be executed. ");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,这两种方式是不能混用的。可以看到,在上面的代码中,我们是无法使用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) => 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<Future<Null>>(() 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("building $this"), e, stack));
|
||||
...
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个页面反馈的信息比较丰富,适合开发期定位问题。但如果让用户看到这样一个页面,就很糟糕了。因此,我们通常会重写ErrorWidget.builder方法,将这样的错误提示页面替换成一个更加友好的页面。
|
||||
|
||||
下面的代码演示了自定义错误页面的具体方法。在这个例子中,我们直接返回了一个居中的Text控件:
|
||||
|
||||
```
|
||||
ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Text("Custom Error Widget"),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
运行效果如下所示:
|
||||
|
||||
<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<Future<Null>>(() 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("setUp",{'app_id':appID});
|
||||
}
|
||||
static void postException(error, stack) {
|
||||
//将异常和堆栈上报至Bugly
|
||||
_channel.invokeMethod("postException",{'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<FlutterPluginRegistrar>*)registrar {
|
||||
//注册方法通道
|
||||
FlutterMethodChannel* channel = [FlutterMethodChannel
|
||||
methodChannelWithName:@"flutter_crash_plugin"
|
||||
binaryMessenger:[registrar messenger]];
|
||||
//初始化插件实例,绑定方法通道
|
||||
FlutterCrashPlugin* instance = [[FlutterCrashPlugin alloc] init];
|
||||
//注册方法通道回调函数
|
||||
[registrar addMethodCallDelegate:instance channel:channel];
|
||||
}
|
||||
|
||||
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
|
||||
if([@"setUp" isEqualToString:call.method]) {
|
||||
//Bugly SDK初始化方法
|
||||
NSString *appID = call.arguments[@"app_id"];
|
||||
[Bugly startWithAppId:appID];
|
||||
} else if ([@"postException" isEqualToString:call.method]) {
|
||||
//获取Bugly数据上报所需要的各个参数信息
|
||||
NSString *message = call.arguments[@"crash_message"];
|
||||
NSString *detail = call.arguments[@"crash_detail"];
|
||||
|
||||
NSArray *stack = [detail componentsSeparatedByString:@"\n"];
|
||||
//调用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(), "flutter_crash_plugin");
|
||||
//初始化插件实例,绑定方法通道,并注册方法通道回调函数
|
||||
channel.setMethodCallHandler(new FlutterCrashPlugin(registrar));
|
||||
}
|
||||
|
||||
private FlutterCrashPlugin(Registrar registrar) {
|
||||
this.registrar = registrar;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMethodCall(MethodCall call, Result result) {
|
||||
if(call.method.equals("setUp")) {
|
||||
//Bugly SDK初始化方法
|
||||
String appID = call.argument("app_id");
|
||||
|
||||
CrashReport.initCrashReport(registrar.activity().getApplicationContext(), appID, true);
|
||||
result.success(0);
|
||||
}
|
||||
else if(call.method.equals("postException")) {
|
||||
//获取Bugly数据上报所需要的各个参数信息
|
||||
String message = call.argument("crash_message");
|
||||
String detail = call.argument("crash_detail");
|
||||
//调用Bugly数据上报接口
|
||||
CrashReport.postException(4,"Flutter Exception",message,detail,null);
|
||||
result.success(0);
|
||||
}
|
||||
else {
|
||||
result.notImplemented();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在完成了Bugly Android接口的封装之后,由于Android系统的权限设置较细,考虑到Bugly还需要网络、日志读取等权限,因此我们还需要在插件工程的AndroidManifest.xml文件中,将这些权限信息显示地声明出来,完成对系统的注册:
|
||||
|
||||
```
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.hangchen.flutter_crash_plugin">
|
||||
<!-- 电话状态读取权限 -->
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<!-- 网络权限 -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- 访问网络状态权限 -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- 访问wifi状态权限 -->
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<!-- 日志读取权限 -->
|
||||
<uses-permission android:name="android.permission.READ_LOGS" />
|
||||
</manifest>
|
||||
|
||||
```
|
||||
|
||||
至此,在完成了极光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
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 网络安全配置 -->
|
||||
<network-security-config>
|
||||
<!-- 允许明文传输数据 -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<!-- 将Bugly的域名加入白名单 -->
|
||||
<domain includeSubdomains="true">android.bugly.qq.com</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
||||
//AndroidManifest/xml
|
||||
<application
|
||||
...
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
...>
|
||||
</application>
|
||||
|
||||
```
|
||||
|
||||
至此,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<Null> _reportError(dynamic error, dynamic stackTrace) async {
|
||||
FlutterCrashPlugin.postException(error, stackTrace);
|
||||
}
|
||||
|
||||
Future<Null> 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("Custom Error Widget"),
|
||||
)
|
||||
);
|
||||
};
|
||||
//使用runZone方法将runApp的运行放置在Zone中,并提供统一的异常回调
|
||||
runZoned<Future<Null>>(() async {
|
||||
runApp(MyApp());
|
||||
}, onError: (error, stackTrace) async {
|
||||
await _reportError(error, stackTrace);
|
||||
});
|
||||
}
|
||||
|
||||
class MyApp extends StatefulWidget {
|
||||
@override
|
||||
State<StatefulWidget> createState() => _MyAppState();
|
||||
}
|
||||
|
||||
class _MyAppState extends State<MyApp> {
|
||||
@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: <Widget>[
|
||||
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) => 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<Null> _reportError(dynamic error, dynamic stackTrace) async {
|
||||
FlutterCrashPlugin.postException(error, stackTrace);
|
||||
}
|
||||
|
||||
//自定义错误提示页面
|
||||
ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Text("Custom Error Widget"),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
第二个问题,并发Isolate的异常可以通过今天分享中介绍的捕获机制去拦截吗?如果不行,应该怎么做呢?
|
||||
|
||||
```
|
||||
//并发Isolate
|
||||
doSth(msg) => throw ConcurrentModificationError('This is a Dart exception.');
|
||||
|
||||
//主Isolate
|
||||
Isolate.spawn(doSth, "Hi");
|
||||
|
||||
```
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -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<Null> _reportError(dynamic error, dynamic stackTrace) async {
|
||||
exceptionCount++; //累加异常次数
|
||||
FlutterCrashPlugin.postException(error, stackTrace);
|
||||
}
|
||||
|
||||
Future<Null> main() async {
|
||||
FlutterError.onError = (FlutterErrorDetails details) async {
|
||||
//将异常转发至Zone
|
||||
Zone.current.handleUncaughtError(details.exception, details.stack);
|
||||
};
|
||||
|
||||
runZoned<Future<Null>>(() 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则会有比较严重的卡顿现象。
|
||||
|
||||
为方便开发者统计FPS,Flutter在全局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<FrameTiming>();
|
||||
//基准VSync信号周期
|
||||
const frameInterval = const Duration(microseconds: Duration.microsecondsPerSecond ~/ 60);
|
||||
|
||||
void onReportTimings(List<FrameTiming> timings) {
|
||||
lastFrames.addAll(timings);
|
||||
//仅保留25帧
|
||||
if(lastFrames.length > 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 < 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() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
//通过帧绘制回调获取渲染完成时间
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.endTime = DateTime.now().millisecondsSinceEpoch;
|
||||
int timeSpend = widget.endTime - widget.startTime;
|
||||
print("Page render time:${timeSpend} ms");
|
||||
});
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
试着运行一下代码,观察命令行输出:
|
||||
|
||||
```
|
||||
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)中,你可以下载下来,反复运行几次,加深理解与记忆。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留一道思考题吧。
|
||||
|
||||
如果页面的渲染需要依赖单个或多个网络接口数据,这时的页面加载时长应该如何统计呢?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -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(即Don’t Repeat Yourself)原则可以算是最重要的一个。
|
||||
|
||||
通俗来讲,DRY原则就是“不要重复”。这是一个很朴素的概念,因为即使是最初级的开发者,在写了一段时间代码后,也会不自觉地把一些常用的重复代码抽取出来,放到公用的函数、类或是独立的组件库中,从而实现代码复用。
|
||||
|
||||
在软件开发中,我们通常从架构设计中就要考虑如何去管理重复性(即代码复用),即如何将功能进行分治,将大问题分解为多个较为独立的小问题。而在这其中,组件化和平台化就是客户端开发中最流行的分治手段。
|
||||
|
||||
所以今天,我们就一起来学习一下这两类分治复用方案的中心思想,这样我们在设计Flutter应用的架构时也就能做到有章可循了。
|
||||
|
||||
## 组件化
|
||||
|
||||
组件化又叫模块化,即基于可重用的目的,将一个大型软件系统(App)按照关注点分离的方式,拆分成多个独立的组件或模块。每个独立的组件都是一个单独的系统,可以单独维护、升级甚至直接替换,也可以依赖于别的独立组件,只要组件提供的功能不发生变化,就不会影响其他组件和软件系统的整体功能。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4f/82/4f49b09f5bc9fd33db010d9286ae2e82.png" alt="">
|
||||
|
||||
可以看到,组件化的中心思想是将独立的功能进行拆分,而在拆分粒度上,组件化的约束则较为松散。一个独立的组件可以是一个软件包(Package)、页面、UI控件,甚至可能是封装了一些函数的模块。
|
||||
|
||||
**组件的粒度可大可小,那我们如何才能做好组件的封装重用呢?哪些代码应该被放到一个组件中?**这里有一些基本原则,包括单一性原则、抽象化原则、稳定性原则和自完备性原则。
|
||||
|
||||
接下来,我们先看看这些原则具体是什么意思。
|
||||
|
||||
**单一性原则指的是**,每个组件仅提供一个功能。分而治之是组件化的中心思想,每个组件都有自己固定的职责和清晰的边界,专注地做一件事儿,这样这个组件才能良性发展。
|
||||
|
||||
一个反例是Common或Util组件,这类组件往往是因为在开发中出现了定义不明确、归属边界不清晰的代码:“哎呀,这段代码放哪儿好像都不合适,那就放Common(Util)吧”。久而久之,这类组件就变成了无人问津的垃圾堆。所以,再遇到不知道该放哪儿的代码时,就需要重新思考组件的设计和职责了。
|
||||
|
||||
**抽象化原则**指的是,组件提供的功能抽象应该尽量稳定,具有高复用度。而稳定的直观表现就是对外暴露的接口很少发生变化,要做到这一点,需要我们提升对功能的抽象总结能力,在组件封装时做好功能抽象和接口设计,将所有可能发生变化的因子都在组件内部做好适配,不要暴露给它的调用方。
|
||||
|
||||
**稳定性原则**指的是,不要让稳定的组件依赖不稳定的组件。比如组件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层)和Framework(UI 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架构设计中,你会采用何种方式去管理涉及资源类的依赖呢?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -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&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="$PATH:`pwd`/flutter/bin"
|
||||
script:
|
||||
- flutter doctor && 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 && flutter -v build apk
|
||||
|
||||
#声明iOS的运行环境
|
||||
- os: osx
|
||||
language: objective-c
|
||||
osx_image: xcode10.2
|
||||
script:
|
||||
- flutter doctor && flutter -v build ios --no-codesign
|
||||
install:
|
||||
- git clone https://github.com/flutter/flutter.git
|
||||
- export PATH="$PATH:`pwd`/flutter/bin"
|
||||
|
||||
```
|
||||
|
||||
## 如何将打包好的二进制文件自动发布出来?
|
||||
|
||||
在这个案例中,我们构建任务的命令是打包,那打包好的二进制文件可以自动发布出来吗?
|
||||
|
||||
答案是肯定的。我们只需要为这两个构建任务增加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 && 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 && mkdir app/Payload
|
||||
- cp -r build/ios/iphoneos/Runner.app app/Payload
|
||||
- pushd app && zip -r -m app.ipa Payload && 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构建产物是aar,iOS构建产物是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 && 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 && zip -r FlutterEngine.zip ./ && 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)呢?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -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混合开发工作流
|
||||
|
||||
对于软件开发而言,工程师的职责涉及从需求到上线的整个生命周期,包含需求阶段->方案阶段->开发阶段->发布阶段->线上运维阶段。可以看出,这其实就是一种抽象的工作流程。
|
||||
|
||||
其中,**和工程化关联最为紧密的是开发阶段和发布阶段**。我们将工作流中和工程开发相关的部分抽离,定义为开发工作流,根据生命周期中关键节点和高频节点,可以将整个工作流划分为如下七个阶段,即初始化->开发/调试->构建->测试->发布->集成->原生工具链:
|
||||
|
||||
<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版本完全一致呢?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -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<String> doRequest(url,params) async {
|
||||
//使用方法通道调用原生接口doRequest,传入URL和param两个参数
|
||||
final String result = await _channel.invokeMethod('doRequest', {
|
||||
"url": url,
|
||||
"param": params,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Dart层接口封装搞定了,我们再来看看**接管真实网络调用的Android和iOS代码宿主如何响应Dart层的接口调用**。
|
||||
|
||||
我刚刚与你提到过,原生代码宿主提供的基础通信能力是基于AFNetworking(iOS)和OkHttp(Android)做的封装,所以为了在原生代码中使用它们,我们**首先**需要分别在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 "com.squareup.okhttp3:okhttp:4.2.0"
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**然后**,我们需要在原生接口FlutterPluginNetworkPlugin类中,完成例行的初始化插件实例、绑定方法通道工作。
|
||||
|
||||
最后,我们还需要在方法通道中取出对应的URL和query参数,为doRequest分别提供AFNetworking和OkHttp的实现版本。
|
||||
|
||||
对于iOS的调用而言,由于AFNetworking的网络调用对象是AFHTTPSessionManager类,所以我们需要这个类进行实例化,并定义其接口返回的序列化方式(本例中为字符串)。然后剩下的工作就是用它去发起网络请求,使用方法通道通知Dart层执行结果了:
|
||||
|
||||
```
|
||||
@implementation FlutterPluginNetworkPlugin
|
||||
...
|
||||
//方法通道回调
|
||||
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
|
||||
//响应doRequest方法调用
|
||||
if ([@"doRequest" isEqualToString:call.method]) {
|
||||
//取出query参数和URL
|
||||
NSDictionary *arguments = call.arguments[@"param"];
|
||||
NSString *url = call.arguments[@"url"];
|
||||
[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[@"ppp"] = @"yyyy";
|
||||
//发起网络调用
|
||||
[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:@"Error" 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("doRequest")) {
|
||||
//取出query参数和URL
|
||||
HashMap param = call.argument("param");
|
||||
String url = call.argument("url");
|
||||
doRequest(url,param,result);
|
||||
} else {
|
||||
//其他方法未实现
|
||||
result.notImplemented();
|
||||
}
|
||||
}
|
||||
//处理网络调用
|
||||
void doRequest(String url, HashMap<String, String> 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("ppp", "yyyy");
|
||||
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("Error", 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("doRequest"),
|
||||
//点击按钮发起网络请求,打印数据
|
||||
onPressed:()=>FlutterPluginNetwork.doRequest("https://jsonplaceholder.typicode.com/posts", {'userId':'2'}).then((s)=>print('Result:$s')),
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
运行这段代码,点击doRequest按钮,观察控制台输出,可以看到,接口返回的数据信息能够被正常打印,证明Flutter模块的功能表现是完全符合预期的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/83/6855481fc112697ff2cc03fdcc185883.png" alt="">
|
||||
|
||||
## 构建产物应该如何封装?
|
||||
|
||||
我们都知道,模块工程的Android构建产物是aar,iOS构建产物是Framework。而在第[28](https://time.geekbang.org/column/article/129754)和[42](https://time.geekbang.org/column/article/144156)篇文章中,我与你介绍了不带插件依赖的模块工程构建产物的两种封装方案,即手动封装方案与自动化封装方案。这两种封装方案,最终都会输出同样的组织形式(Android是aar,iOS则是带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 => 'Flutter'
|
||||
pod 'flutter_plugin_network', :path => '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:@"FlutterPluginNetworkPlugin"]];
|
||||
//设置路由标识符
|
||||
[vc setInitialRoute:@"defaultRoute"];
|
||||
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 "com.squareup.okhttp3:okhttp:4.2.0"
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,我们就可以在原生工程中为其设置入口,在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(), "defaultRoute");
|
||||
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 "com.squareup.okhttp3:okhttp:4.2.0"
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**至此,将模块工程及其插件依赖封装成原生组件的全部工作就完成了,原生工程可以像使用一个普通的原生组件一样,去使用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模块工程的原生组件封装形式是aar(Android)和Framework(Pod)。与纯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<dynamic> noSuchMethod(Invocation invocation) {
|
||||
//从字符串Symbol("methodName")中取出方法名
|
||||
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<dynamic> someMethodNotImplemented();
|
||||
//某未实现的带参数方法
|
||||
Future<dynamic> someMethodNotImplementedWithParameter(param);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user