Flutter技术与实战(6)

Flutter技术与实战(6)

八归少年 2,804 2020-09-14

Flutter综合应用

线上出现问题,该如何做好异常捕获和信息采集

  • 这些异常,可能是因为不充分的机型适配、用户糟糕的网络状况;也可能是因为 Flutter 框架自身的 Bug,甚至是操作系统底层的问题。这些异常一旦发生,Flutter 应用会无法响应用户的交互事件,轻则报错,重则功能无法使用甚至闪退,这对用户来说都相当不友好,是开发者最不愿意看到的。
  • 所以,我们要想办法去捕获用户的异常信息,将异常现场保存起来,并上传至服务器,这样我们就可以分析异常上下文,定位引起异常的原因,去解决此类问题了。
Flutter异常
  • Flutter 异常指的是,Flutter 程序中 Dart 代码运行时意外发生的错误事件。我们可以通过与 Java 类似的 try-catch 机制来捕获它。但与 Java 不同的是,Dart 程序不强制要求我们必须处理异常。
  • 这是因为,Dart 采用事件循环的机制来运行任务,所以各个任务的运行状态是互相独立的。也就是说,即便某个任务出现了异常我们没有捕获它,Dart 程序也不会退出,只会导致当前任务后续的代码不会被执行,用户仍可以继续使用其他功能。
  • Dart 异常,根据来源又可以细分为 App 异常和 Framework 异常。
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 回调函数,拦截那些在代码执行对象中的未捕获异常。
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 异常,就是 Flutter 框架引发的异常,通常是由应用代码触发了 Flutter 框架底层的异常判断引起的。比如,当布局不合规范时,Flutter 就会自动弹出一个触目惊心的红色错误界面,如下所示。

Flutter布局错误提示

  • 这其实是因为,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 方法,将这样的错误提示页面替换成一个更加友好的页面。
ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){
  return Scaffold(
    body: Center(
      child: Text("Custom Error Widget"),
    )
  );
};
  • 比起之前触目惊心的红色错误页面,白色主题的自定义页面看起来稍微友好些了。需要注意的是,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 的社区活跃度比较高,因此我就以它为例,与你演示在抓取到异常后,如何实现自定义数据上报。
  • 注:bugly已经支持Flutter插件buglycrash。
Dart接口实现
  • 目前 Bugly 仅提供了原生 Android/iOS 的 SDK,采用插件工程,为 Bugly 的数据上报提供 Dart 层接口。
  • 与接入 Push 能力相比,接入数据上报要简单得多,我们只需要完成一些前置应用信息关联绑定和 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 层是原生代码宿主的代理,可以看到这一层的接口设计还是比较简单的。
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
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();
    }
  }
}
  • 由于 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>
  • FlutterCrashPlugin 插件为 Flutter 应用提供了数据上报的封装,不过要想 Flutter 工程能够真正地上报异常消息,我们还需要为 Flutter 工程关联 Bugly 的应用配置。
  • 在单独为 Android/iOS 应用进行数据上报配置之前,我们首先需要去Bugly 的官方网站,为应用注册唯一标识符(即 AppKey)。这里需要注意的是,在 Bugly 中,Android 应用与 iOS 应用被视为不同的产品,所以我们需要分别注册。
    在这里插入图片描述
    iOS应用Demo配置
  • 依次进行 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 工程中的 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 按钮几次,可以看到我们的应用以及控制台并没有提示任何异常信息。
  • 然后,我们打开Bugly 开发者后台,选择对应的 App,切换到错误分析选项查看对应的面板信息。可以看到,Bugly 已经成功接收到上报的异常上下文了。
    Bugly Android错误分析上报数据查看
总结
  • 需要注意的是,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官方文档

衡量FLutter App线上质量,需要关注三个指标

  • 对于以“丝般顺滑”著称的 Flutter 应用而言,页面渲染的性能同样需要我们重点关注。比如,界面渲染是否出现会掉帧卡顿现象,或者页面加载是否会出现性能问题导致耗时过长?这些问题,虽不至于让应用完全不能使用,但也很容易引起用户对应用质量的质疑,甚至是反感。
  • 通过上面的分析,可以看到,衡量线上 Flutter 应用整体质量的指标,可以分为以下 3 类:
    • 页面异常率;
    • 页面帧率;
    • 页面加载时长。
  • 其中,页面异常率反应了页面的健康程度,页面帧率反应了视觉效果。
  • 这三项数据指标,是度量 Flutter 应用是否优秀的重要质量指标。通过梳理这些指标的统计口径,建立起 Flutter 应用的质量监控能力,这样一来我们不仅可以及早发现线上隐患,还可以确定质量基线,从而持续提升用户体验。
页面异常率
  • 页面异常率指的是,页面渲染过程中出现异常的概率。它度量的是页面维度下功能不可用的情况,其统计公式为:页面异常率 = 异常发生次数 / 整体页面 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 数,其实就是页面的打开次数。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 走势是一致的。
    FPS指标走势
页面加载时长
  • 页面加载时长,指的是页面从创建到可见的时间。它反应的是代码中创建页面视图是否存在过度绘制,或者绘制不合理导致创建视图时间过长的情况。
  • 从定义可以看出,页面加载时长的统计口径为页面可见的时间 - 页面创建的时间。获取页面创建的时间比较容易,我们只需要在页面的初始化函数里记录时间即可。那么,页面可见的时间应该如何统计呢?
  • 在之前的“提到生命周期,我们在说什么”,在介绍 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 应用的性能时,就有一个具体的数字化标准了。而有了数据之后,我们不仅可以及早发现问题隐患,准确定位及修复问题,还可以根据它们去评估应用的健康程度和页面的渲染性能,从而确定后续的优化方向。

组件化和平台化,如何组织合理稳定的Flutter工程结构

  • 在软件开发中,我们不仅要在代码实现中遵守常见的设计模式,更需要在架构设计中遵从基本的设计原则。而在这其中,DRY(即 Don’t Repeat Yourself)原则可以算是最重要的一个。
  • 通俗来讲,DRY 原则就是“不要重复”。这是一个很朴素的概念,因为即使是最初级的开发者,在写了一段时间代码后,也会不自觉地把一些常用的重复代码抽取出来,放到公用的函数、类或是独立的组件库中,从而实现代码复用。
  • 在软件开发中,我们通常从架构设计中就要考虑如何去管理重复性(即代码复用),即如何将功能进行分治,将大问题分解为多个较为独立的小问题。而在这其中,组件化和平台化就是客户端开发中最流行的分治手段。
组件化
  • 组件化又叫模块化,即基于可重用的目的,将一个大型软件系统(App)按照关注点分离的方式,拆分成多个独立的组件或模块。每个独立的组件都是一个单独的系统,可以单独维护、升级甚至直接替换,也可以依赖于别的独立组件,只要组件提供的功能不发生变化,就不会影响其他组件和软件系统的整体功能。

组件化示意图

  • 可以看到,组件化的中心思想是将独立的功能进行拆分,而在拆分粒度上,组件化的约束则较为松散。一个独立的组件可以是一个软件包(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 就会陷入不稳定的状态,即所谓牵一发而动全身。
    循环依赖现象
  • 平台化是组件化的升级,即在组件化的基础上,对它们提供的功能进行分类,统一分层划分,增加依赖治理的概念。为了对这些功能单元在概念上进行更为统一的分类,我们按照四象限分析法,把应用程序的组件按照业务和 UI 分解为 4 个维度,来分析组件可以分为哪几类。
    组件划分原则
  • 可以看出,经过业务与 UI 的分解之后,这些组件可以分为 4 类:
    1、具备 UI 属性的独立业务模块;
    2、不具备 UI 属性的基础业务功能;
    3、不具备业务属性的 UI 控件;
    4、不具备业务属性的基础功能;
  • 按照自身定义,这 4 类组件其实隐含着分层依赖的关系。比如,处于业务模块中的首页,依赖位于基础业务模块中的账号功能;再比如,位于 UI 控件模块中的轮播卡片,依赖位于基础功能模块中的存储管理等功能。我们将它们按照依赖的先后顺序从上到下进行划分,就是一个完整的 App 了。
    Travis CI 持续交付流程示意图
  • 可以看到,平台化与组件化最大的差异在于增加了分层的概念,每一层的功能均基于同层和下层的功能之上,这使得各个组件之间既保持了独立性,同时也具有一定的弹性,在不越界的情况下按照功能划分各司其职。
  • 与组件化更关注组件的独立性相比,平台化更关注的是组件之间关系的合理性,而这也是在设计平台化架构时需要重点考虑的单向依赖原则。
  • 所谓单向依赖原则,指的是组件依赖的顺序应该按照应用架构的层数从上到下依赖,不要出现下层模块依赖上层模块这样循环依赖的现象。这样可以最大限度地避免复杂的耦合,减少组件化时的困难。如果我们每个组件都只是单向依赖其他组件,各个组件之间的关系都是清晰的,代码解耦也就会变得非常轻松了。
  • 平台化强调依赖的顺序性,除了不允许出现下层组件依赖上层组件的情况,跨层组件和同层组件之间的依赖关系也应当严格控制,因为这样的依赖关系往往会带来架构设计上的混乱。
如果下层组件确实需要调用上层组件的代码怎么办?
  • 这时,我们可以采用增加中间层的方式,比如 Event Bus、Provider 或 Router,以中间层转发的形式实现信息同步。比如,位于第 4 层的网络引擎中,会针对特定的错误码跳转到位于第 1 层的统一错误页,这时我们就可以利用 Router 提供的命名路由跳转,在不感知错误页的实现情况下来完成。又比如,位于第 2 层的账号组件中,会在用户登入登出时主动刷新位于第 1 层的首页和我的页面,这时我们就可以利用 Event Bus 来触发账号切换事件,在不需要获取页面实例的情况下通知它们更新界面。
  • 平台化架构是目前应用最广的软件架构设计,其核心在于如何将离散的组件依照单向依赖的原则进行分层。而关于具体的分层逻辑,除了我们上面介绍的业务和 UI 四象限法则之外,你也可以使用其他的划分策略,只要整体结构层次清晰明确,不存在难以确定归属的组件就可以了。
  • 比如,Flutter 就采用 Embedder(操作系统适配层)、Engine(渲染引擎及 Dart VM 层)和 Framework(UI SDK 层)整体三层的划分。可以看到,Flutter 框架每一层的组件定义都有着明确的边界,其向上提供的功能和向下依赖的能力也非常明确。
    Flutter框架结构

如何构建高效的Flutter App打包环境

  • ,产品交付不仅是一个令工程师头疼的过程,还是一个高风险动作。其实,失败并不可怕,可怕的是每次失败的原因都不一样。所以,为了保障可靠交付,我们需要关注从源代码到发布的整个流程,提供一种可靠的发布支撑,确保 App 是以一种可重复的、自动化的方式构建出来的。同时,我们还应该将打包过程提前,将构建频率加快,因为这样不仅可以尽早发现问题,修复成本也会更低,并且能更好地保证代码变更能够顺利发布上线。
  • 其实,这正是持续交付的思路。
  • 所谓持续交付,指的是建立一套自动监测源代码变更,并自动实施构建、测试、打包和相关操作的流程链机制,以保证软件可以持续、稳定地保持在随时可以发布的状态。 持续交付可以让软件的构建、测试与发布变得更快、更频繁,更早地暴露问题和风险,降低软件开发的成本。
  • 你可能会觉得,大型软件工程里才会用到持续交付。其实不然,通过运用一些免费的工具和平台,中小型项目也能够享受到开发任务自动化的便利。而 Travis CI 就是这类工具之中,市场份额最大的一个
Travis CI
  • Travis CI 是在线托管的持续交付服务,用 Travis 来进行持续交付,不需要自己搭服务器,在网页上点几下就好,非常方便。Travis 和 GitHub 是一对配合默契的工作伙伴,只要你在 Travis 上绑定了 GitHub 上的项目,后续任何代码的变更都会被 Travis 自动抓取。然后,Travis 会提供一个运行环境,执行我们预先在配置文件中定义好的测试和构建步骤,并最终把这次变更产生的构建产物归档到 GitHub Release 上,如下所示。

[外链图片转存失败,源站可能有防盗Travis CI 持续交付流程示意!链机制,建议将保]上https://传(blog.csdniGg.cn/20200913VmHS144110506.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shado,text_aHRwcHM6Ly9ibG9nLmNzZG0ubmV0L0lhbmdfc3R2ZHlfZmlyc3Q=,size_16,color_FFFFFF,t_74#pic_center310)((https://img-blog.csdnimg.cn/20200913210351627.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3lhbmdfc3R1ZHlfZmlyc3Q=,size_16,color_FFFFFF,t_70#pic_center)]

  • 可以看到,通过 Travis 提供的持续构建交付能力,我们可以直接看到每次代码的更新的变更结果,而不需要累积到发布前再做打包构建。这样不仅可以更早地发现错误,定位问题也会更容易。
  • 要想为项目提供持续交付的能力,我们首先需要在 Travis 上绑定 GitHub。我们打开Travis 官网,使用自己的 GitHub 账号授权登陆就可以了。登录完成后页面中会出现一个“Activate”按钮,点击按钮会跳回到 GitHub 中进行项目访问权限设置。我们保留默认的设置,点击“Approve&Install”即可。

激活GitHub集成
授权Travis读取项目变更记录

  • 完成授权之后,页面会跳转到 Travis。Travis 主页上会列出 GitHub 上你的所有仓库,以及你所属于的组织,如下图所示。
    完成Github项目绑定

  • 完成项目绑定后,接下来就是为项目增加 Travis 配置文件了。配置的方法也很简单,只要在项目的根目录下放一个名为.travis.yml 的文件就可以了。

  • .travis.yml 是 Travis 的配置文件,指定了 Travis 应该如何应对代码变更。代码 commit 上去之后,一旦 Travis 检测到新的变更,Travis 就会去查找这个文件,根据项目类型(language)确定执行环节,然后按照依赖安装(install)、构建命令(script)和发布(deploy)这三大步骤,依次执行里面的命令。一个 Travis 构建任务流程如下所示。

Travis工作流

  • 可以看到,为了更精细地控制持续构建过程,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 构建详情,去查看失败原因并尽快修复问题了。
    代码变更验证
  • 可以看到,为一个工程引入自动化任务的能力,只需要提炼出能够让工程自动化运行需要的命令就可以了。
  • 在“ 如何通过自动化测试提高交付质量”中,我与你介绍了 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 进入开发者设置。
    进入开发者设置

  • 在开发者设置页面中,我们点击左下角的 Personal access tokens 选项,生成访问 token。token 设置页面提供了比较丰富的访问权限控制,比如仓库限制、用户限制、读写限制等,这里我们选择只访问公共的仓库,填好 token 名称 cd_demo,点击确认之后,GitHub 会将 token 的内容展示在页面上。
    生成访问token

  • 需要注意的是,这个 token 你只会在 GitHub 上看到一次,页面关了就再也找不到了,所以我们先把这个 token 复制下来。

访问token界面

  • 接下来,我们打开 Travis 主页,找到我们希望配置自动发布的项目,然后点击右上角的 More options 选择 Settings 打开项目配置页面。
    Travis项目设置

  • 在 Environment Variable 里,把刚刚复制的 token 改名为 GITHUB_TOKEN,加到环境变量即可。
    加入Travis环境变量

  • 最后,我们只要把配置文件中的 api_key 替换成 $就可以了。

...
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 构建完毕后可以看到,我们的工程已经具备自动发布构建产物的能力了。
    Flutter App发布构建产物
如何为 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 工程也可以自动的发布原生组件了。

Flutter Module工程发布构建产物

  • 通过这些例子我们可以看到,任务配置的关键在于提炼出项目自动化运行需要的命令集合,并确认它们的执行顺序。只要把这些命令集合按照 install、script 和 deploy 三个阶段安置好,接下来的事情就交给 Travis 去完成,我们安心享受持续交付带来的便利就可以了。
总结
  • 俗话说,“90% 的故障都是由变更引起的”,这凸显了持续交付对于发布稳定性保障的价值。通过建立持续交付流程链机制,我们可以将代码变更与自动化手段关联起来,让测试和发布变得更快、更频繁,不仅可以提早暴露风险,还能让软件可以持续稳定地保持在随时可发布的状态。
  • 需要注意的是,在今天的示例分析中,我们构建的是一个未签名的 ipa 文件,这意味着我们需要先完成签名之后,才能在真实的 iOS 设备上运行,或者发布到 App Store。
  • iOS 的代码签名涉及私钥和多重证书的校验,以及对应的加解密步骤,是一个相对繁琐的过程。如果我们希望在 Travis 上部署自动化签名操作,需要导出发布证书、私钥和描述文件,并提前将这些文件打包成一个压缩包后进行加密,上传至仓库。
  • 然后,我们还需要在 before_install 时,将这个压缩包进行解密,并把证书导到 Travis 运行环境的钥匙串中,这样构建脚本就可以使用临时钥匙串对二进制文件进行签名了。完整的配置,你可以参考手机内侧服务厂商蒲公英提供的集成文档了解进一步的细节。
  • 如果你不希望将发布证书、私钥暴露给 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/

如何构建自己的Flutter混合开发框架(一)

  • 所谓混合开发,是指在 App 的整体架构继续使用原生技术栈的基础上,将 Flutter 运行环境嵌入到原生 App 工程中:由原生开发人员为 Flutter 运行提供宿主容器及基础能力支撑,而 Flutter 开发人员则负责应用层业务及 App 内大部分渲染工作。
  • 这种开发模式的好处十分明显。对于工程师而言,跨平台的 Flutter 框架减少了对底层环境的依赖,使用完整的技术栈和工具链隔离了各个终端系统的差异,无论是 Android、iOS 甚至是前端工程师,都可以使用统一而标准化的能力进行业务开发,从而扩充了技能栈。而对于企业而言,这种方式不仅具备了原生 App 良好的用户体验,以及丰富的底层能力,还同时拥有了跨平台技术开发低成本和多端体验一致性的优势,直接节省研发资源。
  • 那么,在原生工程中引入 Flutter 混合开发能力,我们应该如何设计工程架构,原生开发与 Flutter 开发的工作模式又是怎样的呢?
混合开发架构
  • 在“组件化和平台化,该如何组织合理稳定的Flutter工程结构”中,我与你介绍了软件功能分治的两种手段,即组件化和平台化,以及如何在满足单向依赖原则的前提下,以分层的形式将软件功能进行分类聚合的方法。这些设计思想,能够让我们在设计软件系统架构时,降低整体工程的复杂性,提高 App 的可扩展性和可维护性。

  • 与纯 Flutter 工程能够以自治的方式去分拆软件功能、管理工程依赖不同,Flutter 混合工程的功能分治需要原生工程与 Flutter 工程一起配合完成,即:在 Flutter 模块的视角看来,一部分与渲染相关的基础能力完全由 Flutter 代码实现,而另一部分涉及操作系统底层、业务通用能力部分,以及整体应用架构支撑,则需要借助于原生工程给予支持。

  • 在“组件化和平台化,该如何组织合理稳定的Flutter工程结构”中,我们通过四象限分析法,把纯 Flutter 应用按照业务和 UI 分解成 4 类。同样的,混合工程的功能单元也可以按照这个分治逻辑分为 4 个维度,即不具备业务属性的原生基础功能、不具备业务属性的原生 UI 控件、不具备 UI 属性的原生基础业务功能和带 UI 属性的独立业务模块。
    四象限分析法

  • 从图中可以看到,对于前 3 个维度(即原生 UI 控件、原生基础功能、原生基础业务功能)的定义,纯 Flutter 工程与混合工程并无区别,只不过实现方式由 Flutter 变成了原生;对于第四个维度(即独立业务模块)的功能归属,考虑到业务模块的最小单元是页面,而 Flutter 的最终呈现形式也是独立的页面,因此我们把 Flutter 模块也归为此类,我们的工程可以像依赖原生业务模块一样直接依赖它,为用户提供独立的业务功能。

  • 我们把这些组件及其依赖按照从上到下的方式进行划分,就是一个完整的混合开发架构了。可以看到,原生工程和 Flutter 工程的边界定义清晰,双方都可以保持原有的分层管理依赖的开发模式不变。
    Flutter混合开发架构

  • 需要注意的是,作为一个内嵌在原生工程的功能组件,Flutter 模块的运行环境是由原生工程提供支持的,这也就意味着在渲染交互能力之外的部分基础功能(比如网络、存储),以及和原生业务共享的业务通用能力(比如支付、账号)需要原生工程配合完成,即原生工程以分层的形式提供上层调用接口,Flutter 模块以插件的形式直接访问原生代码宿主对应功能实现。

  • 因此,不仅不同归属定义的原生组件之前存在着分层依赖的关系,Flutter 模块与原生组件之前也隐含着分层依赖的关系。比如,Flutter 模块中处于基础业务模块的账号插件,依赖位于原生基础业务模块中的账号功能;Flutter 模块中处于基础业务模块的网络插件,依赖位于原生基础功能的网络引擎。

  • 可以看到,在混合工程架构中,像原生工程依赖 Flutter 模块、Flutter 模块又依赖原生工程这样跨技术栈的依赖管理行为,我们实际上是通过将双方抽象为彼此对应技术栈的依赖,从而实现分层管理的:即将原生对 Flutter 的依赖抽象为依赖 Flutter 模块所封装的原生组件,而 Flutter 对原生的依赖则抽象为依赖插件所封装的原生行为。

Flutter 混合开发工作流
  • 对于软件开发而言,工程师的职责涉及从需求到上线的整个生命周期,包含需求阶段 -> 方案阶段 -> 开发阶段 -> 发布阶段 -> 线上运维阶段。可以看出,这其实就是一种抽象的工作流程。

  • 其中,和工程化关联最为紧密的是开发阶段和发布阶段。我们将工作流中和工程开发相关的部分抽离,定义为开发工作流,根据生命周期中关键节点和高频节点,可以将整个工作流划分为如下七个阶段,即初始化 -> 开发 / 调试 -> 构建 -> 测试 -> 发布 -> 集成 -> 原生工具链。

  • Flutter混合开发工作流

  • 前 6 个阶段是 Flutter 的标准工作流,最后一个阶段是原生开发的标准工作流。可以看到,在混合开发工作模式中,Flutter 的开发模式与原生开发模式之间有着清晰的分工边界:Flutter 模块是原生工程的上游,其最终产物是原生工程依赖。从原生工程视角看,其开发模式与普通原生应用并无区别,因此这里就不再赘述了,我们重点讨论 Flutter 开发模式。对于 Flutter 标准工作流的 6 个阶段而言,每个阶段都会涉及业务或产品特性提出的特异性要求,技术方案的选型,各阶段工作成本可用性、可靠性的衡量,以及监控相关基础服务的接入和配置等。

  • 每件事儿都是一个固定的步骤,而当开发规模随着文档、代码、需求增加时,我们会发现重复的步骤越来越多。此时,如果我们把这些步骤像抽象代码一样,抽象出一些相同操作,就可以大大提升开发效率。

  • 优秀的程序员会发掘工作中的问题,从中探索提高生产力的办法,而转变思维模式就是一个不错的起点。以持续交付的指导思想来看待这些问题,我们希望整体方案能够以可重复、可配置化的形式,来保障整个工作流的开发体验、效率、稳定性和可靠性,而这些都离不开 Flutter 对命令行工具支持。

  • 比如,对于测试阶段的 Dart 代码分析,我们可以使用 flutter analyze 命令对代码中可能存在的语法或语义问题进行检查;又比如,在发布期的 package 发布环节,我们可以使用 flutter packages pub publish --dry-run 命令对待发布的包进行发布前检查,确认无误后使用去掉 dry-run 参数的 publish 命令将包提交至 Pub 站点。

  • 这些基本命令对各个开发节点的输入、输出以及执行过程进行了抽象,熟练掌握它们及对应的扩展参数用法,我们不仅可以在本地开发时打造一个易用便捷的工程开发环境,还可以将这些命令部署到云端,实现工程构建及部署的自动化。

  • 把这六个阶段涉及的关键命令总结为了一张表格,你可以结合这张表格,体会落实在具体实现中的 Flutter 标准工作流。

Flutter标准工作流命令

总结
  • 对于 Flutter 混合开发而言,如何处理好原生与 Flutter 之间的关系,需要从工程架构与工作模式上定义清晰的分工边界。
  • 在架构层面,将 Flutter 模块定义为原生工程的独立业务层,以原生基础业务层向 Flutter 模块提供业务通用能力、原生基础能力层向 Flutter 模块提供基础功能支持这样的方式去分层管理依赖。
  • 在工作模式层面,将作为原生工程上游的 Flutter 模块开发,抽象为原生依赖产物的工程管理,并提炼出对应的工作流,以可重复、配置化的命令行方式对各个阶段进行统一管理。
  • 可以看到,在原生 App 工程中引入 Flutter 运行环境,由原生开发主做应用架构和基础能力赋能、Flutter 开发主做应用层业务的混合开发协作方式,能够综合原生 App 与 Flutter 框架双方的特点和优势,不仅可以直接节省研发资源,也符合目前行业人才能力模型的发展趋势。

如何构建自己的Flutter混合开发框架(二)

  • 在 Flutter 混合框架中,Flutter 模块与原生工程是相互依存、互利共赢的关系。
    • Flutter 跨平台开发效率高,渲染性能和多端体验一致性好,因此在分工上主要专注于实现应用层的独立业务(页面)的渲染闭环;
    • 而原生开发稳定性高,精细化控制力强,底层基础能力丰富,因此在分工上主要专注于提供整体应用架构,为 Flutter 模块提供稳定的运行环境及对应的基础能力支持。
  • 在原生工程中为 Flutter 模块提供基础能力支撑的过程中,面对跨技术栈的依赖管理,我们该遵循何种原则呢?对于 Flutter 模块及其依赖的原生插件们,我们又该如何以标准的原生工程依赖形式进行组件封装呢?
原生插件依赖管理原则
  • 在“如何在Dart层兼容Android/iOS平台特定实现(一)”和“如何实现原生推送能力”里,我与你讲述了为 Flutter 应用中的 Dart 代码提供原生能力支持的两种方式,即:在原生工程中的 Flutter 应用入口注册原生代码宿主回调的轻量级方案,以及使用插件工程进行独立拆分封装的工程化解耦方案。
  • 无论使用哪种方式,Flutter 应用工程都为我们提供了一体化的标准解决方案,能够在集成构建时自动管理原生代码宿主及其相应的原生依赖,因此我们只需要在应用层使用 pubspec.yaml 文件去管理 Dart 的依赖。
  • 但对于混合工程而言,依赖关系的管理则会复杂一些。这是因为,与 Flutter 应用工程有着对原生组件简单清晰的单向依赖关系不同,混合工程对原生组件的依赖关系是多向的:Flutter 模块工程会依赖原生组件,而原生工程的组件之间也会互相依赖。
  • 如果继续让 Flutter 的工具链接管原生组件的依赖关系,那么整个工程就会陷入不稳定的状态之中。因此,对于混合工程的原生依赖,Flutter 模块并不做介入,完全交由原生工程进行统一管理。而 Flutter 模块工程对原生工程的依赖,体现在依赖原生代码宿主提供的底层基础能力的原生插件上。
  • 接下来,以网络通信这一基础能力为例,与你展开说明原生工程与 Flutter 模块工程之间应该如何管理依赖关系。
网络插件依赖管理实践
  • 在“HTTP 网络编程与 JSON 解析”中,介绍了在 Flutter 中,我们可以通过 HttpClient、http 与 dio 这三种通信方式,实现与服务端的数据交换。
  • 但在混合工程中,考虑到其他原生组件也需要使用网络通信能力,所以通常是由原生工程来提供网络通信功能的。因为这样不仅可以在工程架构层面实现更合理的功能分治,还可以统一整个 App 内数据交换的行为。比如,在网络引擎中为接口请求增加通用参数,或者是集中拦截错误等。
  • 关于原生网络通信功能,目前市面上有很多优秀的第三方开源 SDK,比如 iOS 的 AFNetworking 和 Alamofire、Android 的 OkHttp 和 Retrofit 等。考虑到 AFNetworking 和 OkHttp 在各自平台的社区活跃度相对最高,因此我就以它俩为例,与你演示混合工程的原生插件管理方法。
网络插件接口封装
  • 要想搞清楚如何管理原生插件,我们需要先使用方法通道来建立 Dart 层与原生代码宿主之间的联系。
  • 原生工程为 Flutter 模块提供原生代码能力,我们同样需要使用 Flutter 插件工程来进行封装。关于这部分内容,在"如何实现原生推送能力"和"线上出现问题,该如何做好异常捕获与信息采集"中,已经分别为你演示了推送插件和数据上报插件的封装方法,你也可以再回过头来复习下相关内容。所以,今天我就不再与你过多介绍通用的流程和固定的代码声明部分了,而是重点与你讲述与接口相关的实现细节。

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 模块的功能表现是完全符合预期的。
    Flutter模块工程运行示例
构建产物应该如何封装
  • 我们都知道,模块工程的 Android 构建产物是 aar,iOS 构建产物是 Framework。而在“如何在原生应用中混编Flutter工程”和“如何构建高效的Flutter App打包发布环境”中,介绍了不带插件依赖的模块工程构建产物的两种封装方案,即手动封装方案与自动化封装方案。这两种封装方案,最终都会输出同样的组织形式(Android 是 aar,iOS 则是带 podspec 的 Framework 封装组件)。

  • 如果我们的模块工程存在插件依赖,封装过程是否有区别呢?

  • 答案是,对于模块工程本身而言,这个过程没有区别;但对于模块工程的插件依赖来说,我们需要主动告诉原生工程,哪些依赖是需要它去管理的。

  • 由于 Flutter 模块工程把所有原生的依赖都交给了原生工程去管理,因此其构建产物并不会携带任何原生插件的封装实现,所以我们需要遍历模块工程所使用的原生依赖组件们,为它们逐一生成插件代码对应的原生组件封装。

  • 在第 18 篇文章“依赖管理(二):第三方组件库在 Flutter 中要如何管理?”中,我与你介绍了 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 模块了。

原生iOS工程运行示例

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 封装的页面组件了。
    原生Android工程运行示例
  • 当然,考虑到手动封装模块工程及其构建产物的过程,繁琐且容易出错,我们可以把这些步骤抽象成命令行脚本,并把它部署到 Travis 上。这样在 Travis 检测到代码变更之后,就会自动将 Flutter 模块的构建产物封装成原生工程期望的组件格式了。
总结
  • 对于有着插件依赖的 Android 组件封装来说,由于 aar 本身并不携带任何配置信息,因此其操作以手工为主:我们不仅要执行构建命令依次生成插件对应的 aar,还需要将插件自身的原生依赖拷贝至原生工程,其步骤相对 iOS 组件封装来说要繁琐一些。
  • 为了解决这一问题,业界出现了一种名为fat-aar的打包手段,它能够将模块工程本身,及其相关的插件依赖统一打包成一个大的 aar,从而省去了依赖遍历和依赖声明的过程,实现了更好的功能自治性。但这种解决方案存在一些较为明显的不足。
    • 依赖冲突问题。如果原生工程与插件工程都引用了同样的原生依赖组件(OkHttp),则原生工程的组件引用其依赖时会产生合并冲突,因此在发布时必须手动去掉原生工程的组件依赖。
    • 嵌套依赖问题。fat-aar 只会处理 embedded 关键字指向的这层一级依赖,而不会处理再下一层的依赖。因此,对于依赖关系复杂的插件支持,我们仍需要手动处理依赖问题。
    • Gradle 版本限制问题。fat-aar 方案对 Gradle 插件版本有限制,且实现方式并不是官方设计考虑的点,加之 Gradle API 变更较快,所以存在后续难以维护的问题。其他未知问题。fat-aar 项目已经不再维护了,最近一次更新还是 2 年前,在实际项目中使用“年久失修”的项目存在较大的风险。
  • 考虑到这些因素,fat-aar 并不是管理插件工程依赖的好的解决方案,所以我们最好还是得老老实实地去遍历插件依赖,以持续交付的方式自动化生成 aar。

Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://www.yanghujun.com/archives/fluttertechnologycombat6