Flutter圈子Flutter中文社区Flutter

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

2019-06-07  本文已影响31人  飘逸解构

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

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

Android原生项目中引入Flutter

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

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

有两种方式来创建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。

首先在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页面之间的跳转。

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和原生通信的工具,有三种类型:

这里我就只介绍一下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原生之间的页面跳转和参数传递,此外还有一些需要我们注意的地方。

在开发中我们经常会遇到关闭当前页面的同时返回给上一个页面数据的场景,在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页面并返回数据的场景,获取到数据后就可以为所欲为啦。

看到这里不知道大家是否和我有相同的感受,在原生页面(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;
}

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

总结

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

参考文章

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

上一篇下一篇

猜你喜欢

热点阅读