Flutter学习小计:Android原生项目引入Flutter

前言
目前Flutter可以说是非常火热了,多次更新过后也越来越稳定,受到了很多开发者的青睐。不过纯Flutter开发还是存在一定成本和风险的,尤其是对于规模稍大一些的项目,可能更加适合的是将Flutter用于项目中的某一个模块,因此我们有必要了解一下如何在原生项目中引入Flutter。

本文介绍一下Android原生项目引入Flutter的方法以及Flutter如何与原生进行交互,包括页面间的跳转和方法的调用,本人不懂IOS开发,有需要的话还是自行百度吧o(╥﹏╥)o,但是基本思路我觉得不会差太多的。

Android原生项目中引入Flutter

这应该是目前Flutter在实际开发中应用最多的一种场景,在已有的Android原生项目中引入Flutter,针对一些复杂的页面,使用Flutter开发可以有效地提高开发效率。
官方提供的文档Add Flutter to existing apps详细介绍了原生app引入Flutter的步骤,不过很遗憾是英文的。我也是参考了网上的一些相关文章,总结了一下文档中的提到的几个步骤。

  • 第一步、新建Android项目

这没什么可说的,毕竟我们是要在原生项目中引入Flutter嘛。

  • 第二步、新建Flutter Module

有两种方式来创建Flutter Module,第一种是通过命令行来创建,首先切换到Android项目的同级目录下,执行以下命令:

flutter create -t module my_flutter

其中my_flutter为module的名字。第二种是直接使用Android Studio来创建,依次点击左上角的File --> New --> New Flutter Project,然后选择Flutter Module。

新建Flutter Module.png

然后填写module的名称、路径。

最后填写module的包名,点击Finish就创建好了一个Flutter Module。

  • 第三步、在Android项目中引入Flutter Module

首先在app下的build.gradle文件中添加以下配置:

compileOptions {
  sourceCompatibility 1.8
  targetCompatibility 1.8
}

我们知道这是使用Java 8所需要的配置,在这里的作用是为了解决版本兼容问题,如果不配置的话运行项目可能会报错:Invoke-customs are only supported starting with Android O (--min-api 26)
然后在项目根目录下的setting.gradle文件中配置:

include ':app'
// 加入下面配置
setBinding(new Binding([gradle: this]))
evaluate(new File(
        settingsDir.parentFile,
        'my_flutter/.android/include_flutter.groovy'
))  

记得修改成自己的Flutter Module名称,之后Sync一下项目。Binding可能会因为找不到而标红,我没有导包最后也可以Sync成功,并不影响module的引入,这一点我还不清楚是什么原因,如果有知道的小伙伴欢迎提出。
Sync后我们可以看到项目中多了一个名称为flutter的library module,我们需要在app下的build.gradle文件中添加该module的依赖。

implementation project(':flutter')

这样就成功地将Flutter引入到了Android原生项目中。

Android和Flutter的交互

通过上面的几个步骤我们已经在Android原生项目中集成了Flutter,之后就需要解决交互问题了。首先介绍一下Android页面和Flutter页面之间的跳转。

Tips:由于Flutter版本的更新,下面介绍的内容中存在一些API已经被废弃的情况,不过各种交互场景的处理思路是不变的,关于Flutter版本变更的内容我补充到了文章最后,大家可以结合起来看。

Android原生页面跳转Flutter页面

基本思路就是将Flutter编写的页面嵌入到Activity中,官方提供了两种方式:通过FlutterViewFlutterFragment,下面我们分别看一下这两种方式是如何实现的。
1.使用FlutterView
首先新建一个Activity,命名为FlutterPageActivity(名称随意起),在onCreate()方法中添加以下代码:

@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    // 通过FlutterView引入Flutter编写的页面
    View flutterView = Flutter.createView(this, getLifecycle(), "route1");
    FrameLayout.LayoutParams layout = new FrameLayout.LayoutParams(600, 800);
    layout.leftMargin = 100;
    layout.topMargin = 200;
    addContentView(flutterView, layout);
}

Flutter.createView()方法返回的是一个FlutterView,它继承自View,我们可以把它当做一个普通的View,调用addContentView()方法将这个View添加到Activity的contentView中。我们注意到Flutter.createView()方法的第三个参数传入了"route1"字符串,表示路由名称,它确定了Flutter中要显示的Widget,接下来需要在之前创建好的Flutter Module中编写逻辑了,修改main.dart文件中的代码:

import 'dart:ui';
import 'package:flutter/material.dart';

void main() => runApp(_widgetForRoute(window.defaultRouteName));

Widget _widgetForRoute(String route) {
  switch (route) {
    case 'route1':
      return MaterialApp(
        home: Scaffold(
          appBar: AppBar(
            title: Text('Flutter页面'),
          ),
          body: Center(
            child: Text('Flutter页面,route=$route'),
          ),
        ),
      );
    default:
      return Center(
        child: Text('Unknown route: $route', textDirection: TextDirection.ltr),
      );
  }
}

runApp()方法中通过window.defaultRouteName可以获取到我们在Flutter.createView()方法中传入的路由名称,即"route1",之后编写了一个_widgetForRoute()方法,根据传入的route字符串显示相应的Widget。
最后在MainActivity中添加一个Button,编写点击事件,点击Button跳转到FlutterPageActivity。

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    Button btnJumpToFlutter = findViewById(R.id.btn_jump_to_flutter);
    btnJumpToFlutter.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            Intent intent = new Intent(MainActivity.this, FlutterPageActivity.class);
            startActivity(intent);
        }
    });

运行项目,点击MainActivity中的Button跳转到FlutterPageActivity,效果如下图所示:

可以看到我们已经成功地将Flutter编写的Widget嵌入到了Activity中,为了更逼真一些,还需要做一些调整。首先修改LayoutParams参数,将View占满屏幕。

View flutterView = Flutter.createView(this, getLifecycle(), "route1");
FrameLayout.LayoutParams layout = new FrameLayout.LayoutParams(
        ViewGroup.LayoutParams.MATCH_PARENT,
        ViewGroup.LayoutParams.MATCH_PARENT);
addContentView(flutterView, layout);

然后需要隐藏原生的标题栏,在资源文件夹res/values中的style.xml文件中添加一个FlutterPageTheme。

<style name="FlutterPageTheme" parent="Theme.AppCompat.Light.NoActionBar">
    <!--状态栏透明-->
    <item name="android:windowTranslucentStatus">true</item>
</style>

然后在AndroidManifest.xml文件中设置Activity的Theme。

<activity
    android:name=".FlutterPageActivity"
    android:theme="@style/FlutterPageTheme" />

再次运行项目看一下效果,这样就自然多了,当然我们还可以继续修改标题栏的背景颜色,这里就不提了。


2.使用FlutterFragment
为了简单,我们依然使用FlutterPageActivity,新建一个布局文件activity_flutter_page

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <FrameLayout
        android:id="@+id/fl_container"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

修改onCreate()方法:

@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_flutter_page);
    // 通过FlutterFragment引入Flutter编写的页面
    FragmentTransaction tx = getSupportFragmentManager().beginTransaction();
    tx.replace(R.id.fl_container, Flutter.createFragment("route1"));
    tx.commit();
}

Flutter.createFragment()方法传入的参数同样表示路由名称,用于确定Flutter要显示的Widget,返回一个FlutterFragment,该类继承自Fragment,将该Fragment添加到Activity中就可以了。
在调试时会遇到一个问题,显示出Flutter页面之前会黑屏几秒,不要担心,打了release包后就没问题了。
如何传递参数跳转
通过以上两种方式实现了将Flutter编写的页面嵌入到Activity中,但是这只是最简单的情况,如果我们需要在页面跳转时传递参数呢,如何在Flutter代码中获取到原生代码中的参数呢?其实很简单,只需要在route后面拼接上参数就可以了,以创建FlutterView的方式为例。

View flutterView = Flutter.createView(this, getLifecycle(),
                "route1?{\"name\":\"StephenCurry\"}");

这里将路由名称和参数间用“?”隔开,就像浏览器中的url一样,参数使用了Json格式传递,原因就是方便Flutter端解析,而且对于一些复杂的数据,比如自定义对象,使用Json序列化也很好实现。这时候Flutter端通过window.defaultRouteName获取到的就是路由名称+参数了,我们需要将路由名称和参数分开,这就只是单纯的字符串处理了,代码如下所示:

String url = window.defaultRouteName;
// route名称
String route =
    url.indexOf('?') == -1 ? url : url.substring(0, url.indexOf('?'));
// 参数Json字符串
String paramsJson =
    url.indexOf('?') == -1 ? '{}' : url.substring(url.indexOf('?') + 1);
// 解析参数
Map<String, dynamic> params = json.decode(paramsJson);

通过"?"将路由名称和参数分开,将参数对应的Json字符串解析为Map对象,需要导入dart:convert包,之后再将参数传递给对应的Widget即可,这里就不展示了,详细代码可以查看Demo。运行效果如下图所示:

Android原生页面跳转Flutter页面

Flutter页面跳转Android原生页面

在实现Flutter页面跳转Android原生页面之前首先介绍一下Platform Channel,它是Flutter和原生通信的工具,有三种类型:

  • BasicMessageChannel:用于传递字符串和半结构化的信息,Flutter和平台端进行消息数据交换时候可以使用。
  • MethodChannel:用于传递方法调用(method invocation),Flutter和平台端进行直接方法调用时候可以使用。
  • EventChannel:用于数据流(event streams)的通信,Flutter和平台端进行事件监听、取消等可以使用。

这里我就只介绍一下MethodChannel的使用,它也是我们开发中最常用的,关于其他两种Channel的使用可以自行查阅网上的文章。Flutter跳转原生页面就是通过MethodChannel来实现的,在Flutter中调用原生的跳转方法就可以了,接下来我们具体看一下如何实现:
1.Android端

// 定义Channel名称
private static final String CHANNEL_NATIVE = "com.example.flutter/native";

@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_flutter_page);
    // 通过FlutterView引入Flutter编写的页面
    FlutterView flutterView = Flutter.createView(this, getLifecycle(),
            "route1?{\"name\":\"" + getIntent().getStringExtra("name") + "\"}");
    FrameLayout.LayoutParams layout = new FrameLayout.LayoutParams(
            ViewGroup.LayoutParams.MATCH_PARENT,
            ViewGroup.LayoutParams.MATCH_PARENT);
    addContentView(flutterView, layout);

    MethodChannel nativeChannel = new MethodChannel(flutterView, CHANNEL_NATIVE);
    nativeChannel.setMethodCallHandler(new MethodChannel.MethodCallHandler() {
        @Override
        public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
            switch (methodCall.method) {
                case "jumpToNative":
                    // 跳转原生页面
                    Intent jumpToNativeIntent = new Intent(FlutterPageActivity.this, NativePageActivity.class);
                    jumpToNativeIntent.putExtra("name", (String) methodCall.argument("name"));
                    startActivity(jumpToNativeIntent);
                    break;
                default:
                    result.notImplemented();
                    break;
            }
        }
    });
}

首先定义Channel名称,需要保证是唯一的,在Flutter端需要使用同样的名称来创建MethodChannel。MethodChannel的构造方法有三个参数,第一个是messenger,类型是BinaryMessenger,是一个接口,代表消息信使,是消息发送与接收的工具,由于FlutterView实现了BinaryMessenger,因此这里直接传入了Flutter.createView()方法的返回值;第二个参数是name,就是Channel名称;第三个参数是codec,类型是MethodCodec,代表消息的编解码器,这里没有传该参数,默认使用StandardMethodCodec。
这里补充一下,如果采用FlutterFragment的方式该如何获取到FlutterView呢,我们可以查看一下FlutterFragment的源码。

public class FlutterFragment extends Fragment {
  public static final String ARG_ROUTE = "route";
  private String mRoute = "/";

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    if (getArguments() != null) {
      mRoute = getArguments().getString(ARG_ROUTE);
    }
  }

  @Override
  public void onInflate(Context context, AttributeSet attrs, Bundle savedInstanceState) {
    super.onInflate(context, attrs, savedInstanceState);
  }

  @Override
  public FlutterView onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
    return Flutter.createView(getActivity(), getLifecycle(), mRoute);
  }
}

可以看到FlutterFragment的onCreateView()方法也是通过Flutter.createView()创建了FlutterView并返回,因此可以通过Fragment的getView()方法获取到FlutterView。但是这里还有一个问题,在Activity中通过Flutter.createFragment()创建出Fragment后再调用getView()方法获取到的View为null,这是因为只有在onCreateView()方法执行完成后才会给Fragment持有的View赋值,关于这个问题,我也没有太好的解决方案,能想到的只是仿照FlutterFragment自定义一个Fragment,在内部创建MethodChannel。

public class MyFlutterFragment extends FlutterFragment {

    private static final String CHANNEL_NATIVE = "com.example.flutter/native";

    public static MyFlutterFragment newInstance(String route) {
        MyFlutterFragment fragment = new MyFlutterFragment();
        Bundle args = new Bundle();
        args.putString(ARG_ROUTE, route);
        fragment.setArguments(args);
        return fragment;
    }

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        // 这里保证了getView()返回值不为null
        MethodChannel nativeChannel = new MethodChannel((FlutterView) getView(), CHANNEL_NATIVE);
        nativeChannel.setMethodCallHandler(new MethodChannel.MethodCallHandler() {
            @Override
            public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
                switch (methodCall.method) {
                    case "jumpToNative":
                        // 跳转原生页面
                        Intent jumpToNativeIntent = new Intent(getActivity(), NativePageActivity.class);
                        jumpToNativeIntent.putExtra("name", (String) methodCall.argument("name"));
                        startActivity(jumpToNativeIntent);
                        break;
                    default:
                        result.notImplemented();
                        break;
                }
            }
        });
    }
}

创建FlutterFragment时使用MyFlutterFragment.newInstance()代替Flutter.createFragment(),传入路由名称和参数。

FragmentTransaction tx = getSupportFragmentManager().beginTransaction();
MyFlutterFragment flutterFragment = MyFlutterFragment.newInstance("route1?{\"name\":\"StephenCurry\"}");
tx.replace(R.id.fl_container, flutterFragment);
tx.commit();

这样就解决了FlutterFragment获取FlutterView的问题,不过我觉得这种方案并不好,将MethodChannel定义在了Fragment中,耦合度太高,如果大家有更好的解决方案欢迎提出,目前来看我还是建议使用Flutter.createView()的方式来引入Flutter页面。
回到正题,定义好了MethodChannel之后调用setMethodCallHandler()方法设置消息处理回调,参数是MethodHandler类型,需要实现它的onMethodCall()方法。onMethodCall()方法有两个参数methodCallresultmethodCall记录了调用的方法信息,包括方法名和参数,result用于方法的返回值,可以通过result.success()方法返回信息给Flutter端。之后根据方法名和参数来执行原生的代码就可以了,这里是跳转到原生Activity。
2.Flutter端
在Flutter端同样需要定义一个MethodChannel,使用MethodChannel需要引入services.dart包,Channel名称要和Android端定义的相同。

static const nativeChannel =
    const MethodChannel('com.example.flutter/native');

在Flutter页面中添加一个按钮,点击按钮执行跳转原生页面操作,通过调用MethodChannel的invokeMethod()方法可以执行原生代码,该方法有两个参数,第一个是方法名,在Android端可以通过回调方法中的methodCall.method获取到;第二个是方法的参数,可以不传,在Android端可以通过methodCall.arguments()以及methodCall.argument()获取到所有参数或者指定名称的参数。

RaisedButton(
    child: Text('跳转Android原生页面'),
    onPressed: () {
      // 跳转原生页面
      Map<String, dynamic> result = {'name': 'KlayThompson'};
      nativeChannel.invokeMethod('jumpToNative', result);
    })

这里我们也注意到了,Flutter页面跳转原生页面传递参数是通过invokeMethod()方法的第二个参数实现的,在Android端通过methodCall.argument()方法获取到参数后再put到Intent里面就可以了。运行效果如下图所示:

Flutter页面跳转Android原生页面

到这里我们已经基本实现了Flutter和Android原生之间的页面跳转和参数传递,此外还有一些需要我们注意的地方。

  • 1.onActivityResult如何实现

在开发中我们经常会遇到关闭当前页面的同时返回给上一个页面数据的场景,在Android中是通过startActivityForResultonActivityResult()实现的,而纯Flutter页面之间可以通过在Navigator.of(context).pop()方法中添加参数来实现,那么对于Flutter页面和Android原生页面之间如何在返回上一页时传递数据呢,通过MethodChannel就可以实现。
Flutter页面返回Android原生页面
这种情况直接在Flutter端调用原生的返回方法就可以了,首先在Flutter页面添加一个按钮,点击按钮返回原生页面,代码如下:

RaisedButton(
    child: Text('返回上一页'),
    onPressed: () {
      // 返回给上一页的数据
      Map<String, dynamic> result = {'message': '我从Flutter页面回来了'};
      nativeChannel.invokeMethod('goBackWithResult', result);
    }),

Android端依然是通过判断methodCall.method的值来执行指定的代码,通过methodCall.argument()获取Flutter传递的参数。

nativeChannel.setMethodCallHandler(new MethodChannel.MethodCallHandler() {
    @Override
    public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
        switch (methodCall.method) {
            case "goBackWithResult":
                // 返回上一页,携带数据
                Intent backIntent = new Intent();
                backIntent.putExtra("message", (String) methodCall.argument("message"));
                setResult(RESULT_OK, backIntent);
                finish();
                break;
        }
    }
});

之后在上一个Activity的onActivityResult()方法中编写逻辑就可以了,这里就不展示了。
Android原生页面返回Flutter页面
与上一种情况不同的是,这种情况需要原生来调用Flutter代码,和Flutter调用原生方法的步骤是一样的,我们来具体看一下。首先在Flutter跳转到的页面NativePageActivity中添加一个按钮,点击按钮返回Flutter页面,并传递数据。

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_native_page);

    Button btnBack = findViewById(R.id.btn_back);
    btnBack.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            Intent intent = new Intent();
            intent.putExtra("message", "我从原生页面回来了");
            setResult(RESULT_OK, intent);
            finish();
        }
    });
}

然后修改一下Flutter跳转原生页面的代码,将startActivity改为startActivityForResult,并重写onActivityResult()方法,在方法内部获取到原生页面返回的数据,创建MethodChannel,调用invokeMethod()方法将数据传递给Flutter端,这里定义的方法名为"onActivityResult"。

private static final String CHANNEL_FLUTTER = "com.example.flutter/flutter";

@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    switch (requestCode) {
        case 0:
            if (data != null) {
                // NativePageActivity返回的数据
                String message = data.getStringExtra("message");
                Map<String, Object> result = new HashMap<>();
                result.put("message", message);
                // 创建MethodChannel,这里的flutterView即Flutter.createView所返回的View
                MethodChannel flutterChannel = new MethodChannel(flutterView, CHANNEL_FLUTTER);
                // 调用Flutter端定义的方法
                flutterChannel.invokeMethod("onActivityResult", result);
            }
            break;
        default:
            break;
    }
}

接下来需要在Flutter端定义MethodChannel和回调方法,同样是根据MethodCall.method的值来执行相应代码,通过MethodCall.arguments来获取参数。

static const flutterChannel =
    const MethodChannel('com.example.flutter/flutter');

@override
void initState() {
  super.initState();
  Future<dynamic> handler(MethodCall call) async {
    switch (call.method) {
      case 'onActivityResult':
        // 获取原生页面传递的参数
        print(call.arguments['message']);
        break;
    }
  }

  flutterChannel.setMethodCallHandler(handler);
}

这样就实现了原生页面返回Flutter页面并返回数据的场景,获取到数据后就可以为所欲为啦。

  • 2.Flutter栈管理

看到这里不知道大家是否和我有相同的感受,在原生页面(Activity)中引入Flutter页面有些类似于Android开发中使用WebView加载url,每个Flutter页面对应着一个route(url),那么我们自然就会想到一个问题:如果在Flutter页面中继续跳转到其他Flutter页面,这时候点击手机的返回键是否会直接返回到上一个Activity,而不是返回上一个Flutter页面呢,通过测试发现确实是这样。

那么应该如何解决这个问题呢,我的实现思路是在Flutter端利用Navigator.canPop(context)方法判断是否可以返回上一页,如果可以就调用Navigator.of(context).pop()返回,反之则说明当前显示的Flutter页面已经是第一个页面了,直接返回上一个Activity即可。至于如何返回上一个Activity,当然还是要使用MethodChannel了。既然明确了思路,我们就来看看具体实现吧。
首先在Flutter页面中添加一个按钮,点击按钮跳转到一个新的Flutter页面,这里的SecondPage是我新建的一个页面,可以随意修改,重点不在页面本身,就不展示出来了。

RaisedButton(
    child: Text('跳转Flutter页面'),
    onPressed: () {
      Navigator.of(context)
          .push(MaterialPageRoute(builder: (context) {
        return SecondPage();
      }));
    }),

然后定义MethodChannel和MethodCallHandler回调,这里的逻辑是是调用Navigator.canPop(context)判断是否可以返回上一页,如果可以就调用Flutter自身的返回上一页方法,如果已经是第一个Flutter页面了就调用原生方法返回上一个Activity,即这里的nativeChannel.invokeMethod('goBack')

static const nativeChannel =
    const MethodChannel('com.example.flutter/native');
static const flutterChannel =
    const MethodChannel('com.example.flutter/flutter');

@override
void initState() {
  super.initState();
  Future<dynamic> handler(MethodCall call) async {
    switch (call.method) {
      case 'goBack':
        // 返回上一页
        if (Navigator.canPop(context)) {
          Navigator.of(context).pop();
        } else {
          nativeChannel.invokeMethod('goBack');
        }
        break;
    }
  }

  flutterChannel.setMethodCallHandler(handler);
}

接下来我们再来看Android端,首先需要重写onBackPressed()方法,将返回键的事件处理交给Flutter端。

private static final String CHANNEL_FLUTTER = "com.example.flutter/flutter";

@Override
public void onBackPressed() {
    MethodChannel flutterChannel = new MethodChannel(flutterView, CHANNEL_FLUTTER);
    flutterChannel.invokeMethod("goBack", null);
}

最后编写原生端的MethodCallHandler回调,如果当前Flutter页面是第一个时调用该方法直接finish掉Activity。

nativeChannel.setMethodCallHandler(new MethodChannel.MethodCallHandler() {
@Override
public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
switch (methodCall.method) {
    case "goBack":
        // 返回上一页
        finish();
        break;
    default:
        result.notImplemented();
        break;
}

现在我们再来看看运行效果,这样就很舒服了。

Flutter升级到1.12后遇到的问题

前些日子评论区里wangwhatlh同学反馈
遇到了程序包io.flutter.facade不存在问题,起初我运行了一下之前的项目,发现可以正常运行,加上我自己有一段时间没有用过Flutter了,也就没太重视这个问题。说来也是惭愧,最近又陆续有多位小伙伴反馈了这个问题,我才终于意识到这是一个普遍性问题,简单查了一下了解到这个错误是Flutter 1.12版本废弃了io.flutter.facade包导致的,我自己更新了Flutter版本后重新运行项目也遇到了这个问题,所以要对此前遇到这个问题的大家说声抱歉,确实是我没有重视这个问题,之后对于大家提出的问题我一定尽快反馈。
好了,接下来就介绍一下解决方案吧,首先附上官方的一些相关说明文档,大家可以自行阅读一下文档,文档中介绍的还是比较详细的。
Upgrading pre 1.12 Android projects
Experimental: Add Flutter View
Add Flutter to existing app
下面进入正题,简单介绍一下在Flutter 1.12版本中几个需要修改的地方。

  • 原生页面中引入Flutter

上文在介绍Android原生页面跳转Flutter页面时提到了两种方案:FlutterView和FlutterFragment,我们来分别看一下现在应该如何实现。
首先是通过FlutterView引入Flutter页面,以前我们是通过io.flutter.facade包中Flutter类的createView()方法创建出一个FlutterView,然后添加到Activity的布局中,但是由于io.flutter.facade包的废弃,该方法已经无法使用。官方的文档有说明目前不提供在View级别引入Flutter的便捷API,因此如果可能的话,我们应该避免使用FlutterView,但是通过FlutterView引入Flutter页面也是可行的,代码如下:

// 通过FlutterView引入Flutter编写的页面
FlutterView flutterView = new FlutterView(this);
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(
        ViewGroup.LayoutParams.MATCH_PARENT,
        ViewGroup.LayoutParams.MATCH_PARENT);
FrameLayout flContainer = findViewById(R.id.fl_container);
flContainer.addView(flutterView, lp);
// 关键代码,将Flutter页面显示到FlutterView中
flutterView.attachToFlutterEngine(flutterEngine);

需要注意,这里的FlutterView位于io.flutter.embedding.android包中,和此前我们所创建的FlutterView(位于io.flutter.view包中)是不一样的。我们通过查看FlutterView的源码可以发现它继承自FrameLayout,因此像一个普通的View那样添加就可以了。接下来的这一步很关键,调用FlutterView的attachToFlutterEngine()方法,这个方法的作用就是将Flutter编写的UI页面显示到FlutterView中,我们注意到这里传入了一个flutterEngine参数,它又是什么呢?flutterEngine的类型为FlutterEngine,字面意思就是Flutter引擎,它负责在Android端执行Dart代码,将Flutter编写的UI显示到FlutterView/FlutterActivity/FlutterFragment中。创建FlutterEngine的代码如下:

FlutterEngine flutterEngine = new FlutterEngine(this);
flutterEngine.getDartExecutor().executeDartEntrypoint(
        DartExecutor.DartEntrypoint.createDefault()
);

这样就创建好了一个FlutterEngine对象,默认情况下FlutterEngine加载的路由名称为"/",我们可以通过下面的代码指定初始路由名称:

flutterEngine.getNavigationChannel().setInitialRoute("route1");

至于传参的情况没有变化,直接在路由名称后面拼接参数就可以了。当然,FlutterView也可以直接在xml布局文件中添加,最后同样需要调用attachToFlutterEngine()方法将Flutter编写的UI页面显示到FlutterView中,这里就不展示了。
补充一下,最近我将Flutter版本更新到了1.17,发现上述代码运行后FlutterView无法显示,和官方提供的示例flutter_view进行了对比,才发现缺少了下面的代码:

@Override
protected void onResume() {
    super.onResume();
    flutterEngine.getLifecycleChannel().appIsResumed();
}

@Override
protected void onPause() {
    super.onPause();
    flutterEngine.getLifecycleChannel().appIsInactive();
}

@Override
protected void onStop() {
    super.onStop();
    flutterEngine.getLifecycleChannel().appIsPaused();
}

相信大家都能看出这和生命周期有关,flutterEngine.getLifecycleChannel()获取到的是一个LifecycleChannel对象,类比于MethodChannel,作用大概就是将Flutter和原生端的生命周期相互联系起来。这里分别在onResume()onPause()onStop()方法中调用了LifecycleChannel的appIsResumed()appIsInactive()appIsPaused()方法,作用就是同步Flutter端与原生端的生命周期。添加上述代码后,FlutterView就可以正常显示了。至于为什么在Flutter 1.17版本(也有可能是更早的版本)中需要添加上述代码,我猜想可能是FlutterVIew的渲染机制有了一些变化,在接收到原生端对应生命周期方法中发送的通知才会显示,具体原理我也不是很清楚,如果有说得不对的地方或是大家有了解这部分内容的欢迎提出。
然后是通过FlutterFragment引入Flutter页面,我们此前是通过Flutter.createFragment()方法创建出FlutterFragment,现在同样无法使用了。官方提供了三种创建FlutterFragment的方式,我们来分别看一下。
方式一、FlutterFragment.createDefault()

// 通过FlutterFragment引入Flutter编写的页面
FlutterFragment flutterFragment = FlutterFragment.createDefault();
getSupportFragmentManager()
        .beginTransaction()
        .add(R.id.fl_container, flutterFragment)
        .commit();

通过FlutterFragment.createDefault()创建出FlutterFragment,需要注意这里的FlutterFragment位于io.flutter.embedding.android包中,和我们此前使用的FlutterFragment不是同一个类。创建好之后就没什么可说的了,按照正常的Fragment添加就好。createDefault()方法创建出的Fragment显示的路由名称为"/",如果我们需要指定其他路由名称就不能使用这个方法了。
方式二、FlutterFragment.withNewEngine()

// 通过FlutterFragment引入Flutter编写的页面
FlutterFragment flutterFragment = FlutterFragment.withNewEngine()
        .initialRoute("route1")
        .build();
getSupportFragmentManager()
        .beginTransaction()
        .add(R.id.fl_container, flutterFragment)
        .commit();

通过FlutterFragment.withNewEngine()获取到NewEngineFragmentBuilder对象,使用建造者模式构造出FlutterFragment对象,可以通过initialRoute()方法指定初始路由名称。同样地,传递参数只需要在路由名称后面进行拼接。
方式三、FlutterFragment.withCachedEngine

// 创建可缓存的FlutterEngine对象
FlutterEngine flutterEngine = new FlutterEngine(this);
flutterEngine.getNavigationChannel().setInitialRoute("route1");
flutterEngine.getDartExecutor().executeDartEntrypoint(
        DartExecutor.DartEntrypoint.createDefault()
);
FlutterEngineCache.getInstance().put("my_engine_id", flutterEngine);

// 通过FlutterFragment引入Flutter编写的页面
FlutterFragment flutterFragment = FlutterFragment.withCachedEngine("my_engine_id")
        .build();

方式二使用的withNewEngine()方法从名称上也能看出每次都是创建一个新的FlutterEngine对象来显示Flutter UI,但是从官方文档中我们可以了解到每个FlutterEngine对象在显示出Flutter UI之前是需要一个warm-up(不知道能不能翻译为预热)期的,这会导致屏幕呈现短暂的空白,解决方式就是预先创建并启动FlutterEngine,完成warm-up过程,然后将这个FlutterEngine缓存起来,之后使用这个FlutterEngine来显示出Flutter UI。上面的代码中执行的FlutterEngineCache.getInstance().put("my_engine_id", flutterEngine)就是将FlutterEngine缓存起来,这里传入的"my_engine_id"就相当于缓存名称。之后通过FlutterFragment.withCachedEngine()方法来创建FlutterFragment,参数传入上面的缓存名称。需要注意,withCachedEngine()方法返回的是一个CachedEngineFragmentBuilder对象,同样是使用了建造者模式,但是它是没有initialRoute()方法的,如果我们要指定初始路由,需要在创建FlutterEngine对象时通过setInitialRoute()方法来设置。
除此之外,Flutter 1.12中还提供了一种原生引入Flutter页面方式——使用FlutterActivity,这里的FlutterActivity也是位于io.flutter.embedding.android包下的。下面我简单介绍一下如何通过FlutterActivity引入Flutter编写的UI,大家也可以参考官网的介绍。
首先需要在AndroidManifest.xml文件中注册FlutterActivity,代码如下:

<activity
    android:name="io.flutter.embedding.android.FlutterActivity"
    android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
    android:hardwareAccelerated="true"
    android:theme="@style/AppTheme"
    android:windowSoftInputMode="adjustResize" />

这里的theme可以替换为自己项目中定义的主题。注册好FlutterActivity后,第二步就是直接启动这个Activity了,启动FlutterActivity有以下三种方式:

// 方式一、FutterActivity显示的路由名称为"/",不可设置
startActivity(
        FlutterActivity.createDefaultIntent(this)
);

// 方式二、FutterActivity显示的路由名称可设置,每次都创建一个新的FlutterEngine对象
startActivity(
        FlutterActivity
                .withNewEngine()
                .initialRoute("route1")
                .build(this)
);

// 方式三、FutterActivity显示的路由名称可设置,使用缓存好的FlutterEngine对象
startActivity(
        FlutterActivity
                .withCachedEngine("my_engine_id")
                .build(this)
);

是不是很熟悉,和上面介绍的创建FlutterFragment的三种方式是对应的,这里我就不再介绍了。与通过FlutterView/FlutterFragment引入Flutter UI不同,这种方式不需要我们自己创建一个Activity,FlutterActivity显示的Flutter路由是在创建Intent对象时指定的,优点就是使用起来更简单,缺点就是不够灵活,无法像FlutterView/FlutterFragment那样只是作为原生页面中的一部分展示,因此这种方式更适合整个页面都是由Flutter编写的场景。
在调试阶段,跳转FlutterActivity之后也会黑屏几秒,同样地,打了release包后就没有问题了。

  • Android原生与Flutter交互

交互这块的变化不大,还是使用MethodChannel来进行方法的调用,但是在Android端创建MethodChannel时需要注意了,我们此前都是传入io.flutter.view包下的FlutterView作为BinaryMessenger,现在肯定是无法获取到该类对象了,那么这个参数应该传什么呢。通过查看继承关系我们可以找到两个相关的类:DartExecutorDartMessenger。DartExecutor可以通过FlutterEngine的getDartExecutor()方法获得,而DartMessenger又可以通过DartExecutor的getBinaryMessenger()方法获得,因此我们可以这样创建MethodChannel:

MethodChannel nativeChannel = new MethodChannel(flutterEngine.getDartExecutor(), "com.example.flutter/native");
// 或
MethodChannel nativeChannel = new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), "com.example.flutter/native");

至于这两种方式使用哪个,我目前是没发现有什么表现上的区别,如果哪位大佬有了解过可以分享一下。
此外,我们还需要注意,这里在创建MethodChannel时传入的FlutterEngine对象必须和我们此前创建好的FlutterView/FlutterFragment中使用的是同一个。拿FlutterFragment举例,上面介绍了三种创建FlutterFragment的方式,第三种是使用已经创建好的FlutterEngine对象,我们可以直接传入这个FlutterEngine对象。但是前两种方式都是会创建出一个新的FlutterEngine,我们如何获取到FlutterEngine对象呢?FlutterFragment中定义了一个getFlutterEngine()方法,从方法名来看大概就是获取FlutterEngine对象。我尝试过创建MethodChannel时传入flutterFragment.getFlutterEngine().getDartExecutor(),运行后会直接抛出空指针异常,异常产生的位置在FlutterFragment的getFlutterEngine()方法中:

@Nullable
public FlutterEngine getFlutterEngine() {
    return delegate.getFlutterEngine();
}

错误原因是这里的delegate为null,全局搜索一下,发现在FlutterFragment的onAttach()方法中会对delegate赋值,也就是说明此时没有执行onAttach()方法。我猜测这就是由于上面提到过的FlutterEngine的warm-up机制,这是一个耗时过程,因此FlutterFragment并不会立刻执行onAttach()方法,导致我们在Activity的onCreate()方法中直接使用FlutterFragment的getFlutterEngine()方法会抛出异常。目前我也没想到有什么解决方案,如果我们要利用FlutterFragment来进行交互,还是只能使用withCachedEngine()方法来创建FlutterFragment,在构造MethodChannel时传入创建好的FlutterEngine对象。
到这里Flutter 1.12中关于原生交互的几个变更基本上就介绍得差不多了,相关代码我也已经更新。最后还是要感叹一下Flutter的更新速度,才几个月没看就变化这么大,之后的版本可能还会修改,如果我了解到有什么变更会及时更新文章,大家有什么发现也欢迎提出。

关于AndroidX

现在越来越多的Android项目都使用了AndroidX库,之前写这篇文章时由于还是使用的support库,因此这里简单介绍一下AndroidX的迁移。
关于Android原生项目的迁移我就不介绍了,相信大家接触过Android开发的都有了解过,网上也有很多相关文章。在Flutter版本1.12.13之后,新建的Module默认就是使用AndroidX库的,那么如何把现有的Flutter Module迁移到AndroidX呢,其实很简单,在Flutter Module根目录下的pubspec.yaml文件中添加下面的配置就可以了:

module:
  androidX: true // Add this line.

添加之后在命令行执行flutter clean命令即可,执行完成后我们打开.android(或android)目录下的gradle.properties文件,会看到添加了如下配置,这就说明Flutter Module已经成功迁移到了AndroidX库。

当我们把Flutter Module和Android项目都迁移到AndroidX库之后会发现代码中有报错:

可以看到是在调用FragmentTransaction的add()方法时报的错,报错的原因就是因为这里传入的参数类型(flutterFragment类型)不正确,我们可以点开FlutterFragment类看一下,发现在类文件中定义的FlutterFragment类还是继承自support包下的Fragment,那么这里当然就会报错了。先别慌,当你尝试运行项目时会发现可以正常运行,这是为什么呢?其实一开始我也有写疑惑,后来仔细想了想大概是android.enableJetifier=true这个配置的作用,上面也见到过,是项目迁移到AndroidX库后自动添加的,大家可能知道它的作用是将项目中的第三方库自动迁移到AndroidX库,因此虽然我们从源码中看到的FlutterFragment类还是使用的support包,但是gardle在编译时已经自动将FlutterFragment类迁移到了AndroidX库,所以运行时不会报错。
关于AndroidX库迁移的具体内容大家也可以查看官方文档AndroidX Migration
目前最新版本(1.18.0)的Flutter中,FlutterFragment已经改为继承自androidx包下的Fragment了,编辑器也不会报错了,不过具体是哪个版本修改的我就不清楚了,如果大家有知道的欢迎提出。

总结

本文介绍了Android项目中引入Flutter的方法以及简单交互场景的实现。
1.Android项目引入Flutter本质上是将Flutter编写的Widget嵌入到Activity中,类似于WebView,容器Activity相当于WebView,route相当于url,有两种方式FlutterView和FlutterFragment。页面间的跳转和传参可以借助MethodChannel来实现。
2.关于MethodChannel,它的作用是Flutter和原生方法的互相调用,使用时在两端都要定义MethodChannel,通过相同的name联系起来,调用方使用invokeMethod(),传入方法名和参数;被调用方定义MethodCallHandler回调,根据方法名和方法参数执行相应的平台代码。
3.本文中所提到的一些方案可能并不是最好的,如果大家有自己的见解欢迎一起交流学习。此外,文中的一些代码只展示了部分,Demo我已经上传到了github,大家如果需要的话可以查看。
4.最后提一下flutter_boost,这是闲鱼团队开源的一个Flutter混合开发插件,我简单地尝试了一下,还是挺好用的,在页面跳转和传参方面都很方便,大家感兴趣的话可以了解一下。

参考文章

Add Flutter to existing app
Flutter混编:在Android原生中混编Flutter
flutter接入现有的app详细介绍

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容