Flutter移动端实战手册

HansomModesty 发布于1年前

该文章属于<简书 — 刘小壮>原创,转载请注明:

<简书 — 刘小壮> https://www.jianshu.com/p/d27c1f5ee3ff

Flutter移动端实战手册

iOS接入Flutter

在进行 iOS 和 Flutter 的混编时, iOS 比 Android 的接入方式略复杂,但也还好。现在市面上有不少接入 Flutter 的方案,但大多数都是千篇一律相互抄的,没什么意义。

进行 Flutter 混编之前,有一些必要的文件。

  1. xcode_backend.sh 文件,在配置 flutter 环境的时候由 Flutter 工具包提供。
  2. xcconfig 环境变量文件,在 Flutter 工程中自动生成,每个工程都不一样。

xcconfig文件

xcconfig 是 Xcode 的配置文件, Flutter 在里面配置了一些基本信息和路径,接入 Flutter 前需要先将 xcconfig 接入进来,否则一些路径等信息将会出错或找不到。

Flutter 的 xcconfig 包含三个文件, Debug.xcconfig 、 Release.xcconfig 、 Generated.xcconfig ,需要将这些文件配置在下面的位置,并且按照不同环境配置不同的文件。

Project -> Info -> Development Target -> Configurations

Flutter移动端实战手册

有些比较大的工程中已经在 Configurations 中设置了 xcconfig 文件,由于每个 Target 的一种环境只能配置一个 xcconfig 文件,所以可以在已有的 xcconfig 文件中 import 引入 Generated.xcconfig 文件,并且不需要区分环境。

脚本文件

xcode_backend.sh 脚本文件用来构建和导出 Flutter 产物,这是 Flutter 开发包为我们默认提供的。需要在工程 Target 的 Build Phases 加入一个 Run Script 文件,并将下面的脚本代码粘贴进去。需要注意的是,不要忘记前面的 /bin/sh 操作,否则会导致权限错误。

/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" build
/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" embed

在 xcode_backend.sh 中有三个参数类型, build 、 thin 、 embed , thin 没有太大意义,其他两个则负责构建和导出。

混合开发

随后可以对 Xcode 工程进行编译,这时候肯定会报错的。但是不要慌张,报错后我们在工程主目录下会发现一个名为 Flutter 的文件夹,其中会包含两个 framework ,这个文件夹就是 Flutter 的编译产物,我们将这个文件夹整体拖入项目中即可。

这时候就可以在 iOS 工程中添加 Flutter 代码了,下面是详细步骤。

  1. 将 AppDelegate 的集成改为 FlutterAppDelegate ,并且需要遵循 FlutterAppLifeCycleProvider 代理。
#import <Flutter/Flutter.h>
#import <UIKit/UIKit.h>

@interface AppDelegate : FlutterAppDelegate <FlutterAppLifeCycleProvider>

@end
  1. 创建一个 FlutterPluginAppLifeCycleDelegate 的实例对象,这个对象负责管理 Flutter 的生命周期,并从 Platform 侧接收 AppDelegate 的事件。我直接将其声明为一个属性,在 AppDelegate 中的各个方法中,调用其方法进行中转操作。
- (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [self.lifeCycleDelegate application:application willFinishLaunchingWithOptions:launchOptions];
    return YES;
}

- (void)applicationWillResignActive:(UIApplication *)application {
    [self.lifeCycleDelegate applicationWillResignActive:application];
}

 - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url sourceApplication:(NSString *)sourceApplication annotation:(id)annotation {
    [self.lifeCycleDelegate application:application openURL:url sourceApplication:sourceApplication annotation:annotation];
    return YES;
}
  1. 随后即可加入 Flutter 代码,加入的方式也很简单,直接实例化一个 FlutterViewController 控制器即可,也不需要传其他参数进去(这里先不考虑多实例的问题)。
FlutterViewController *flutterViewController = [[FlutterViewController alloc] init];

Flutter 将其看做是一个画布,实例化一个画布上去之后,任何操作其实都是在当前页面完成的。

常见错误

到这个步骤集成操作就已经完成,但是很多人在集成过程中会遇到一些错误,下面是一些常见错误。

  1. 路径错误,读取不到 xcode_backend.sh 文件等。这是因为环境变量 FLUTTER_ROOT 没有获取到, FLUTTER_ROOT 配置在 Generated.xcconfig 中,可以看一下这个文件是不是配置的有问题。
  2. lipo info *** arm64 类似这样的错误,一般都是因为 xcode_backend.sh 脚本导致的,可以检查一下 FLUTTER_ROOT 环境变量是否正确。
  3. 下面这种问题一般都是因为权限导致的,可以查看 Build Phases 的脚本写的是不是有问题。
***/flutter_tools/bin/xcode_backend.sh: Permission denied

混合开发

在进行混编过程中, Flutter 有一个很大的优势,就是如果 Flutter 代码出问题,不会导致原生应用的崩溃。当 Flutter 代码出现崩溃时,会在屏幕上显示错误信息。

在开发过程中经常会涉及到网络请求和持久化的问题,如果混编的话可能会涉及到写两套逻辑。例如网络请求有一些公共参数,或返回数据的统一处理等,如果维护两套逻辑的话会容易出问题。所以建议将网络请求和持久化操作都交给 Platform 处理, Flutter 侧只负责向 Platform 请求并拿来使用即可。

这个过程就涉及到两端数据交互的问题, Flutter 对于混编给出了两套方案, MethodChannel 和 EventChannel 。从名字上来看,一个是方法调用,另一个是事件传递。但实际开发过程中,只需要使用 MethodChannel 即可完成所有需求。

Flutter to Native

下面是 Flutter 调用 Native 的代码,在 Native 中通过 FlutterMethodChannel 设置指定的回调代码,并且在接收参数并处理。由 Flutter 通过 MethodChannel 对 Native 发起调用,并传入对应的参数。

代码中在 Flutter 侧构建好数据模型,然后调用 MethodChannel 的 invokeMethod ,会触发 Native 的回调。 Native 拿到 Flutter 传过来的数据,进行解析并执行播放操作,随后会把播放的状态码回调给 Flutter 侧,交互完成。

import 'package:flutter/services.dart';

Future<Null> playVideo() async{
  var methodChannel = MethodChannel('flutterChannelName');
  Map params = {'playID' : '302998298', 'duration' : '2520', 'name' : '三生三世十里桃花'};
  String result;
  result = await methodChannel.invokeMethod('PlayAlbumVideo', params);

  String playID   = params['playID'];
  String duration = params['duration'];
  String name     = params['name'];
  showCupertinoDialog(context: context, builder: (BuildContext context){
    return CupertinoAlertDialog(
      title: Text(result),
      content: Text('name:$name playID:$playID duration:$duration'),
      actions: <Widget>[
        FlatButton(
          child: Text('确定'),
          onPressed: (){
            Navigator.pop(context);
          },
        )
      ],
    );
  });
}
NSString *channelName = @"flutterChannelName";
FlutterMethodChannel *methodChannel = [FlutterMethodChannel methodChannelWithName:channelName binaryMessenger:flutterVC];
[methodChannel setMethodCallHandler:^(FlutterMethodCall * _Nonnull call, FlutterResult  _Nonnull result) {
    if ([call.method isEqualToString:@"PlayAlbumVideo"]) {
        NSDictionary *params = call.arguments;
        
        VideoPlayerModel *model = [[VideoPlayerModel alloc] init];
        model.playID = [params stringForKey:@"playID"];
        model.duration = [params stringForKey:@"duration"];
        model.name = [params stringForKey:@"name"];
        NSString *playStatus = [SVHistoryPlayUtil playVideoWithModel:model 
                                                        showPlayerVC:self.flutterVC];
        
        result([NSString stringWithFormat:@"播放状态 %@", playStatus]);
    }
}];

Native to Flutter

Native 调用 Flutter 的代码和 Flutter 调用 Native 的基本类似,只是调用和设置回调的角色不同。同样的, Flutter 由于要接收 Native 的消息回调,所以需要注册一个回调,由 Native 发起对 Flutter 的调用并传入参数。

Native 和 Flutter 的相互调用都需要设置一个名字,每一个名字对应一个 MethodChannel 对象,每一个对象可以发起多次调用,不同调用以 invokeMethod 做区分。

import 'package:flutter/services.dart';

@override
void initState() {
    super.initState();
    
    MethodChannel methodChannel = MethodChannel('nativeChannelName');
    methodChannel.setMethodCallHandler(callbackHandler);
}

Future<dynamic> callbackHandler(MethodCall call) {
    if(call.method == 'requestHomeData') {
      String title = call.arguments['title'];
      String content = call.arguments['content'];
      showCupertinoDialog(context: context, builder: (BuildContext context){
        return CupertinoAlertDialog(
          title: Text(title),
          content: Text(content),
          actions: <Widget>[
            FlatButton(
              child: Text('确定'),
              onPressed: (){
                Navigator.pop(context);
              },
            )
          ],
        );
      });
    }
}
NSString *channelName = @"nativeChannelName";
FlutterMethodChannel *methodChannel = [FlutterMethodChannel methodChannelWithName:channelName binaryMessenger:flutterVC];
[RequestManager requestWithURL:url success:^(NSDictionary *result) {
    [methodChannel invokeMethod:@"requestHomeData" arguments:result];
}];

调试工具集

在 iOS 和 Android 开发中,各自的编译器都提供了很好的调试工具集,方便进行内存、性能、视图等调试。 Flutter 也提供了调试工具和命令,下面基于 VSCode 编译器来讲一下 Flutter 调试,相对而言 Android Studio 提供的调试功能可能会更多一些。

性能调试

VSCode 支持一些简单的命令行调试指令,在程序运行过程中,在 Command Palette 命令行面板中输入 performance ,并选择 Toggle Performance Overlay 命令即可。此命令有一个要求就是需要App在运行状态。

Flutter移动端实战手册

随后会在界面上出现一个性能面板,这个页面分为两部分,GPU线程和UI线程的帧率。每个部分分为三个横线,代表着不同的卡顿层级。如果是绿色则表示不会影响界面渲染,如果是红色则有可能会影响界面的流畅性。如果出现红色线条,则表示当前执行的代码需要优化。

Dart DevTools

VSCode 为 Flutter 提供了一套调试工具集- Dart DevTools ,这套工具集功能非常全,包含性能、UI、热更新、热重载、log日志等很多功能。

安装 Dart DevTools 后,在App运行状态下,可以在 VSCode 的右下角启动这个工具,工具会以网页的形式展现,并且可以控制App。

主界面

下面是 Dart DevTools 的主界面,我运行的是一个界面类似于微信的App。从 Inspector 中可以看到页面的视图结构, Android Studio 也有类似的功能。页面整体是一个树形结构,并且选中某一个控件后,会在右侧展示出控件的变量值,例如 frame 、 color 等,这个功能非常实用。

Flutter移动端实战手册

我运行的设备是 Xcode 模拟器,如果想切换 Android 的 Material Design ,点击上面的 iOS 按钮即可直接切换设备。刚才上面说到的查看内存的性能面板,点击 iOS 按钮旁边的 Performance Overlay 即可出现。

Select Widget

如果想知道在 Dart DevTools 中选择的节点,具体对应哪个控件,可以选择 Select Widget Mode 使屏幕上被选中的控件高亮。

Flutter移动端实战手册

Debug Paint

点击 Debug Paint 可以让每个控件都高亮,通过这个模式可以看到 ListView 的滑动方向,以及每个控件的大小及控件之间的距离。

Flutter移动端实战手册

除此之外,还可以选择 Paint Baseline 使所有控件的底线高亮,功能和 Debug Paint 类似,不做叙述。

Memory

Dart DevTools 中提供的内存调试工具更加直观,可以实时显示内存使用情况。在刚开始运行时,我们发现一个内存峰值,把鼠标放上去可以看到具体的内存使用情况。内存会有具体分类, Used 、 GC 等。

Flutter移动端实战手册

Dart DevTools 的内存工具还是不够完美, Xcode 可以选择某段内存,看到这块内存中涉及到主要堆栈调用,并且点击调用栈可以跳转到 Xcode 对应的代码中,而 Dart DevTools 还不具备这个功能,可能和 Web 的展示形式有关系。

内存管理 Flutter 使用的是 GC ,回收速度可能不是很快, iOS 中的 ARC 则是基于引用计数立即回收的。还有很多其他的功能,这里就不一一详细叙述了,各位同学可以自己探索。

多实例

项目中是通过实例化 FlutterViewController 控制器来显示 Flutter 界面的,整个 Flutter 页面可以理解为一个画布,通过页面不断的变化,改变画布上的东西。所以,在单实例的情况下, Flutter 页面中间不能插入原生页面。

这时候如果我们想在多个地方展示 Flutter 页面,而这些页面并不是 Flutter -> Flutter 的连贯跳转形式,那怎么来实现这个场景呢? Google 的建议是创建 Flutter 的多实例,并通过传入不同的参数实例化不同的页面。但这样会造成很严重的内存问题,所以并不能这么做。

Router

如果不能真正创建多个实例对象,那就需要通过其他方式来实现多实例。 Flutter 页面显示其实并不是跟着 FlutterVC 走的,而是跟着 FlutterEngine 走的。所以在创建一次 FlutterVC 之后,就将 FlutterEngine 保存下来,在其他位置创建 FlutterVC 时直接通过 FlutterEngine 的方式创建,并且在创建后进行跳转操作。

在进行页面切换时,通过 channelMethod 调用 Flutter 侧的路由切换代码,并将切换后的新页面 FlutterVC 添加到 Native 上。这种实现方式,就是通过 Flutter 的 Router 的方式实现的,下面将会介绍 Router 的两种表现形式,静态路由和动态路由。

静态路由

静态路由是 MaterialApp 提供的一个 API , routes 本质上是一个 Map 对象,其组成结构是 key 是调用页面的唯一标识符, value 就是对应页面的 Widget 。

在定义静态路由时,可以在创建 Widget 时传入参数,例如实例化 ContactWidget 时就可以传入对应的参数过去。

void main() {
  runApp(
    MaterialApp(
      home: Page2(),
      routes: {
        'page1': (_) => Page1(),
        'page2': (_) => Page2()
      },
    ),
  );
}

class Page1 extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ContactWidget();
  }
}

class Page2 extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return HomeScreen();
  }
}

进行页面跳转时,通过 Navigator 进行调用,每次调用都会重新创建对应的 Widget 。进行调用时 pushNamed 函数会传入一个参数,这个参数就是定义 Map 时对应页面的 key 。

Navigator.of(context).pushNamed('page1');

动态路由

静态路由的方式并不是很灵活,相对而言动态路由更加灵活。动态路由不需要预先设定 routes ,直接调用即可。和普通 push 不同的是,动态路由在 push 时通过 PageRouteBuilder 来构建 push 对象,在 Builder 的构建方法中执行对应的页面跳转操作即可。

结合之前说的 channelMethod ,就是在 channelMethod 对应的 Callback 回调中,执行 Navigator 的 push 函数,接收 Native 传递过来的参数并构建对应的 Widget 页面,将 Widget 返回给 Builder 即可完成页面跳转操作。所以说动态路由的方式非常灵活。

无论是通过静态路由还是动态路由的方式创建,都可以通过 then 函数接收新页面返回时的返回值。

Navigator.of(context).push(PageRouteBuilder(
    pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
      return ContactWidget('next page value');
    }
    transitionsBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
      return FadeTransition(
        child: child,
        opacity: animation,
      );
    }
)).then((onValue){
      print('pop的返回值 $onValue');
});

但动态路由的跳转方式也有一些问题,会导致动画失效。所以需要重写 Builder 的 transitionsBuilder 函数,来自定义转场动画。

无论是通过静态路由还是动态路由的方式创建,都会存在一些问题。由于每次都是新创建 Widget ,所以在创建时会有黑屏的问题。而且每次创建的话,都会丢失当前页面上次的上下文状态,每次进来都是一个新页面。

查看原文: Flutter移动端实战手册

  • smallgoose
  • ticklishtiger
  • smallladybug
  • crazyduck
  • bigostrich
  • purplerabbit