Flutter异常监测与上报

Flutter异常

众所周知,软件项目的交付是一个复杂的过程,任何缘由都有可能致使交付的失败。不少时候常常遇到的一个现象是,应用在开发测试时没有任何异常,但一旦上线就问题频出。出现这些异常,多是由于不充分的机型适配或者用户糟糕的网络情况形成的,也多是Flutter框架自身缺陷形成的,甚至是操做系统底层的问题。android

而处理此类异常的最佳方式是捕获用户的异常信息,将异常现场保存起来并上传至服务器,而后经过分析异常上下文来定位引发异常的缘由,并最终解决此类问题。ios

所谓Flutter异常,指的是Flutter程序中Dart代码运行时发生的错误。与Java和OC等多线程模型的编程语言不一样,Dart是一门单线程的编程语言,采用事件循环机制来运行任务,因此各个任务的运行状态是互相独立的。也便是说,当程序运行过程当中出现异常时,并不须要像Java那样使用try-catch机制来捕获异常,由于即使某个任务出现了异常,Dart程序也不会退出,只会致使当前任务后续的代码不会被执行,而其它功能仍然能够继续使用。git

在Flutter开发中,根据异常来源的不一样,能够将异常分为Framework异常和Dart异常。Flutter对这两种异常提供了不一样的捕获方式,Framework异常是由Flutter框架引起的异常,一般是因为错误的应用代码形成Flutter框架底层的异常判断引发的,当出现Framework异常时,Flutter会自动弹出一个的红色错误界面。而对于Dart异常,则可使用try-catch机制和catchError语句进行处理。github

除此以外,Flutter还提供了集中处理框架异常的方案。集中处理框架异常须要使用Flutter提供的FlutterError类,此类的onError属性会在接收到框架异常时执行相应的回调。所以,要实现自定义捕获异常逻辑,只须要为它提供一个自定义的错误处理回调函数便可。编程

异常捕获

在Flutter开发中,根据异常来源的不一样,能够将异常分为Framework异常和Dart异常。所谓Dart异常,指的是应用代码引发的异常。根据异常代码的执行时序,Dart异常能够分为同步异常和异步异常两类。对于同步异常,可使用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-catch语句进行捕获的,所以下面的写法就是错误的。服务器

//如下代码没法捕获异步异常
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");
}

所以,对于Dart中出现的异常,同步异常使用的是try-catch,异步异常则使用的是catchError。若是想集中管理代码中的全部异常,那么能够Flutter提供的Zone.runZoned()方法。在Dart语言中,Zone表示一个代码执行的环境范围,其概念相似沙盒,不一样沙盒之间是互相隔离的。若是想要处理沙盒中代码执行出现的异常,可使用沙盒提供的onError回调函数来拦截那些在代码执行过程当中未捕获的异常,以下所示。网络

//同步抛出异常
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');
});

能够看到,在没有使用try-catch、catchError语句的状况下,不管是同步异常仍是异步异常,均可以使用Zone直接捕获到。
同时,若是须要集中捕获Flutter应用中未处理的异常,那么能够把main函数中的runApp语句也放置在Zone中,这样就能够在检测到代码运行异常时对捕获的异常信息进行统一处理,以下所示。多线程

runZoned<Future<Null>>(() async {
  runApp(MyApp());
}, onError: (error, stackTrace) async {
  //异常处理
});

除了Dart异常外,Flutter应用开发中另外一个比较常见的异常是Framework异常。Framework异常指的是Flutter框架引发的异常,一般是因为执行错误的应用代码形成Flutter框架底层异常判断引发的,当出现Framework异常时,系统会自动弹出一个的红色错误界面,以下图所示。
在这里插入图片描述
之因此会弹出一个错误提示页面,是因为系统在调用build()方法构建页面时会进行try-catch处理,若是出现任何错误就会调用ErrorWidget页面展现异常信息,而且Flutter框架在不少关键位置都自动进行了异常捕获处理。架构

一般,此页面反馈的错误信息对于开发环境的问题定位仍是颇有帮助的,但若是让线上用户也看到这样的错误页面,体验上就不是很友比如较了。对于Framework异常,最通用的处理方式就是重写ErrorWidget.builder()方法,而后将默认的错误提示页面替换成一个更加友好的自定义提示页面,以下所示。

ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){
  //自定义错误提示页面
  return Scaffold(
    body: Center(
      child: Text("Custom Error Widget"),
    )
  );
};

应用示例

一般,只有当代码运行出现错误时,系统才会给出异常错误提示。为了说明Flutter捕获异常的工做流程,首先来看一个越界访问的示例。首先,新建一个Flutter项目,而后修改main.dart文件的代码,以下所示。

class MyHomePage extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   List<String> numList = ['1', '2'];
   print(numList[5]);
   return Container();
 }
}

上面的代码模拟的是一个越界访问的异常场景。当运行上面的代码时,控制台会给出以下的错误信息。

RangeError (index): Invalid value: Not in range 0..2, inclusive: 5

对于程序中出现的异常,一般只须要在Flutter应用程序的入口main.dart文件中,使用Flutter提供的FlutterError类集中处理便可,以下所示。

Future<Null> main() async {
  FlutterError.onError = (FlutterErrorDetails details) async {
    Zone.current.handleUncaughtError(details.exception, details.stack);
  };
  
  runZoned<Future<void>>(() async {
    runApp(MyApp());
  },  onError: (error, stackTrace) async {
    await _reportError(error, stackTrace);
  });
}

Future<Null> _reportError(dynamic error, dynamic stackTrace) async {
  print('catch error='+error);
}

同时,对于开发环境和线上环境还须要区别对待。由于,对于开发环境遇到的错误,通常是能够当即定位并修复问题的,而对于线上问题才须要对日志进行上报。所以,对于错误日志上报,须要对开发环境和线上环境进行区分对待,以下所示。

Future<Null> main() async {
  FlutterError.onError = (FlutterErrorDetails details) async {
    if (isDebugMode) {
      FlutterError.dumpErrorToConsole(details);
    } else {
      Zone.current.handleUncaughtError(details.exception, details.stack);
    }
  };
   … //省略其余代码
}

bool get isDebugMode {
  bool inDebugMode = false;
  assert(inDebugMode = true);
  return inDebugMode;
}

异常上报

目前为止,咱们已经对应用中出现的全部未处理异常进行了捕获,不过这些异常还只能被保存在移动设备中,若是想要将这些异常上报到服务器还须要作不少的工做。
目前,支持Flutter异常的日志上报的方案有Sentry、Crashlytics等。其中,Sentry是收费的且免费天数只有13天左右,不过它提供的Flutter插件能够帮助开发者快速接入日志上报功能。Crashlytics是Flutter官方支持的日志上报方案,开源且免费,缺点是没有公开的Flutter插件,而flutter_crashlytics插件接入起来也比较麻烦。

Sentry方案

Sentry是一个商业级的日志管理系统,支持自动上报和手动上报两种方方。在Flutter开发中,因为Sentry提供了Flutter插件,所以若是有日志上报的需求,Sentry是一个不错的选择。
使用Sentry以前,须要先在官方网站注册开发者帐号。若是尚未Sentry帐号,能够先注册一个,而后再建立一个App工程。等待工程建立完成以后,系统会自动生成一个DSN,能够依次点击【Project】→【Settings 】→【Client Keys】来打开DSN,以下图所示。

在这里插入图片描述
接下来,使用Android Studio打开Flutter工程,在pubspec.yaml文件中添加Sentry插件依赖,以下所示。

dependencies:
  sentry: ">=3.0.0 <4.0.0"

而后,使用flutter packages get命令将插件拉取到本地。使用Sentry以前,须要先建立一个SentryClient对象,以下所示。

const dsn='';
final SentryClient _sentry = new SentryClient(dsn: dsn);

为了方便对错误日志进行上传,能够提供一个日志的上报方法,而后在须要进行日志上报的地方调用日志上报方法便可,以下所示。

Future<void> _reportError(dynamic error, dynamic stackTrace) async {
  _sentry.captureException(
      exception: error,
      stackTrace: stackTrace,
    );
}

runZoned<Future<void>>(() async {
  runApp(MyApp());
}, onError: (error, stackTrace) {
  _reportError(error, stackTrace);         //上传异常日志
});

同时,开发环境遇到的异常一般是不须要上报的,由于能够当即定位并修复问题,线上遇到的问题才须要进行上报,所以在进行异常上报时还须要区分开发环境和线上环境。

const dsn='https://872ea62a55494a73b73ee139da1c1449@sentry.io/5189144';
final SentryClient _sentry = new SentryClient(dsn: dsn);

Future<Null> main() async {
  FlutterError.onError = (FlutterErrorDetails details) async {
    if (isInDebugMode) {
      FlutterError.dumpErrorToConsole(details);
    } else {
      Zone.current.handleUncaughtError(details.exception, details.stack);
    }
  };

  runZoned<Future<Null>>(() async {
    runApp(MyApp());
  }, onError: (error, stackTrace) async {
    await _reportError(error, stackTrace);
  });
}

Future<Null> _reportError(dynamic error, dynamic stackTrace) async {
  if (isInDebugMode) {
    print(stackTrace);
    return;
  }
  final SentryResponse response = await _sentry.captureException(
    exception: error,
    stackTrace: stackTrace,
  );

  //上报结果处理
  if (response.isSuccessful) {
    print('Success! Event ID: ${response.eventId}');
  } else {
    print('Failed to report to Sentry.io: ${response.error}');
  }
}

bool get isInDebugMode {
  bool inDebugMode = false;
  assert(inDebugMode = true);
  return inDebugMode;
}

在真机上运行Flutter应用,若是出现错误,就能够在Sentry服务器端看到对应的错误日志,以下图所示。
在这里插入图片描述
除此以外,目前市面上还有不少优秀的日志采集服务厂商,如Testin、Bugly和友盟等,不过它们大多尚未提供Flutter接入方案,所以须要开发者在原平生台进行接入。

Bugly方案

目前,Bugly尚未提供Flutter插件,那么,咱们针对混合工程,能够采用下面的方案。接入Bugly时,只须要完成一些前置应用信息关联绑定和 SDK 初始化工做,就可使用 Dart 层封装好的数据上报接口去上报异常了。能够看到,对于一个应用而言,接入数据上报服务的过程,整体上能够分为两个步骤:

  1. 初始化 Bugly SDK;
  2. 使用数据上报接口。

这两步对应着在 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 接入指南

首先,咱们须要在插件工程下的 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 接口实现

与 iOS 相似,咱们须要使用 Android Studio 打开 example 下的 android 工程进行插件开发工做。一样,在打开 android 工程前,你须要确保整个工程代码至少 build 过一次,不然 IDE 会报错。如下是Bugly 异常上报 Android SDK 接入指南

首先,咱们须要在插件工程下的 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 的官方网站,为应用注册惟一标识符(即 AppKey)。这里须要注意的是,在 Bugly 中,Android 应用与 iOS 应用被视为不一样的产品,因此咱们须要分别注册。
在这里插入图片描述
在这里插入图片描述
在获得了 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: xxx

在下面的代码中,咱们在 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.'));
              },
            )
          ],
        ),
      ),
    );
  }
}

运行上面的代码,模拟异常上传,而后咱们打开Bugly 开发者后台,选择对应的 App,切换到错误分析选项查看对应的面板信息。能够看到,Bugly 已经成功接收到上报的异常上下文了,以下图所示。
在这里插入图片描述

总结

对于 Flutter 应用的异常捕获,能够分为单个异常捕获和多异常统一拦截两种状况。其中,单异常捕获,使用 Dart 提供的同步异常 try-catch,以及异步异常 catchError 机制便可实现。而对多个异常的统一拦截,能够细分为以下两种状况:一是 App 异常,咱们能够将代码执行块放置到 Zone 中,经过 onError 回调进行统一处理;二是 Framework 异常,咱们可使用 FlutterError.onError 回调进行拦截。

须要注意的是,Flutter 提供的异常拦截只能拦截 Dart 层的异常,而没法拦截 Engine 层的异常。这是由于,Engine 层的实现大部分是 C++ 的代码,一旦出现异常,整个程序就直接 Crash 掉了。不过一般来讲,这类异常出现的几率极低,通常都是 Flutter 底层的 Bug,与咱们在应用层的实现没太大关系,因此咱们也无需过分担忧。

若是咱们想要追踪 Engine 层的异常(好比给 Flutter 提 Issue),则须要借助于原生系统提供的 Crash 监听机制。不过,这方面的内容比较繁琐,具体能够参考:Flutter官方文档

相关文章
相关标签/搜索