NA嵌入Flutter页面
Nicolas • 发表于2021-08-17 14:47:43 • 24214次阅读
目录介绍
01.Android承载flutter容器
02.过时的NA跳转flutter方案
03.升级版本NA跳转Flutter处理
04.如何处理NA跳转flutter传参
05.思考遇到的几个问题分析
06.Flutter页面关闭时Crash
07.Android引入flutter本质
08.Flutter启动加载流程和优化
00.推荐
01.Android承载flutter容器
02.过时的NA跳转flutter方案
2.1 使用FlutterView
NA添加FlutterView
private void addFlutterView() {
// 通过FlutterView引入Flutter编写的页面
// Flutter.createView()方法返回的是一个FlutterView,它继承自View,我们可以把它当做一个普通的View
// Flutter.createView()方法的第三个参数传入了"yc_route"字符串,表示路由名称,它确定了Flutter中要显示的Widget
flutterView = Flutter.createView(this, getLifecycle(), INIT_ROUTE);
FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT);
//添加到布局中
frameLayout.addView(flutterView, layoutParams);
//addContentView(flutterView, layout);
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
在NA创建一个Activity,在onCreate中创建FlutterView然后添加到布局中。
Flutter.createView()方法返回的是一个FlutterView,它继承自View,我们可以把它当做一个普通的View。
Flutter.createView()方法的第三个参数传入了"yc_route"字符串,表示路由名称,它确定了Flutter中要显示的Widget。
Flutter添加页面
import 'dart:ui';
import 'package:flutter/material.dart';
void main() => runApp(_widgetForRoute(window.defaultRouteName));
Widget _widgetForRoute(String route) {
switch (route) {
case 'yc_route':
return MyHomePage(title: '匹配到了,这个是flutter页面');
}
}
跳转flutter所在activity黑屏
2.2 使用FlutterFragment
NA添加FlutterView
private void addFlutterFragment(){
// 通过FlutterFragment引入Flutter编写的页面
FragmentTransaction tx = getSupportFragmentManager().beginTransaction();
// Flutter.createFragment()方法传入的参数同样表示路由名称,用于确定Flutter要显示的Widget
// 返回一个FlutterFragment,该类继承自Fragment,将该Fragment添加到Activity中就可以了。
FlutterFragment flutterFragment = Flutter.createFragment(INIT_ROUTE);
tx.replace(R.id.rl_flutter, flutterFragment);
tx.commit();
}
Flutter添加页面,这个同上
2.3 需要注意的问题
Flutter版本升级兼容问题
NA跳转flutter如何添加参数
03.升级版本NA跳转Flutter处理
3.1 使用新版本FlutterView
新版本简单说明
通过FlutterView引入Flutter页面,以前我们是通过io.flutter.facade包中Flutter类的createView()方法创建出一个FlutterView,然后添加到Activity的布局中,但是由于io.flutter.facade包的废弃,该方法已经无法使用。
官方的文档有说明目前不提供在View级别引入Flutter的便捷API,因此如果可能的话,我们应该避免使用FlutterView,但是通过FlutterView引入Flutter页面也是可行的。
需要注意,这里的FlutterView位于io.flutter.embedding.android包中,和此前我们所创建的FlutterView(位于io.flutter.view包中)是不一样的。
NA添加FlutterView
private void addFlutterView() {
flutterEngine = new FlutterEngine(this);
binaryMessenger = flutterEngine.getDartExecutor().getBinaryMessenger();
flutterEngine.getNavigationChannel().setInitialRoute("yc");
flutterEngine.getDartExecutor().executeDartEntrypoint(
DartExecutor.DartEntrypoint.createDefault()
);
// 通过FlutterView引入Flutter编写的页面
// 这里的FlutterView位于io.flutter.embedding.android包中
// 和此前我们所创建的FlutterView(位于io.flutter.view包中)是不一样的。
// 通过查看FlutterView的源码可以发现它继承自FrameLayout,因此像一个普通的View那样添加就可以了。
flutterView = new FlutterView(this);
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT);
rlFlutter.addView(flutterView, lp);
//flutterEngine.getNavigationChannel().setInitialRoute("yc");
// 关键代码,将Flutter页面显示到FlutterView中
// 这个方法的作用就是将Flutter编写的UI页面显示到FlutterView中
// flutterEngine的类型为FlutterEngine,字面意思就是Flutter引擎
// 它负责在Android端执行Dart代码,将Flutter编写的UI显示到FlutterView/FlutterActivity/FlutterFragment中。
flutterView.attachToFlutterEngine(flutterEngine);
// FlutterEngine加载的路由名称为"/",我们可以通过下面的代码指定初始路由名称
// 传参的情况没有变化,直接在路由名称后面拼接参数就可以
// todo 放在这里不生效,思考为什么
// flutterEngine.getNavigationChannel().setInitialRoute("yc");
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
在NA创建一个Activity,在onCreate中创建FlutterView然后添加到布局中。
调用FlutterView的attachToFlutterEngine()方法,这个方法的作用就是将Flutter编写的UI页面显示到FlutterView中,注意到这里传入了一个flutterEngine参数,它又是什么呢?flutterEngine的类型为FlutterEngine,字面意思就是Flutter引擎,它负责在Android端执行Dart代码,将Flutter编写的UI显示到FlutterView的容器中。
Flutter添加页面
import 'dart:ui';
import 'package:flutter/material.dart';
void main() => runApp(_widgetForRoute(window.defaultRouteName));
Widget _widgetForRoute(String route) {
switch (route) {
case 'yc_route':
return MyHomePage(title: '匹配到了,这个是flutter页面');
}
}
3.2 使用新版本FlutterFragment
NA有几种添加方式
执行的FlutterEngineCache.getInstance().put("my_engine_id", flutterEngine)就是将FlutterEngine缓存起来,这里传入的"my_engine_id"就相当于缓存名称。
之后调用FlutterFragment.withCachedEngine("my_engine_id").build();获取缓存的FlutterFragment对象
通过FlutterFragment.withNewEngine()获取到NewEngineFragmentBuilder对象,使用建造者模式构造出FlutterFragment对象,可以通过initialRoute()方法指定初始路由名称。
使用的withNewEngine()方法从名称上也能看出每次都是创建一个新的FlutterEngine对象来显示Flutter UI,但是从官方文档中可以了解到每个FlutterEngine对象在显示出Flutter UI之前是需要一个warm-up(简单理解为预热)期的,这会导致屏幕呈现短暂的空白,解决方式就是预先创建并启动FlutterEngine,完成warm-up过程,然后将这个FlutterEngine缓存起来,之后使用这个FlutterEngine来显示出Flutter UI。
通过FlutterFragment.createDefault()创建出FlutterFragment,创建出的Fragment显示的路由名称为"/",如果我们需要指定其他路由名称就不能使用这个方法了。
FlutterFragment.createDefault()
FlutterFragment.withNewEngine()
FlutterFragment.withCachedEngine
NA添加FlutterFragment
private void addFlutterView() {
// 通过FlutterFragment引入Flutter编写的页面
// 通过FlutterFragment.createDefault()创建出FlutterFragment
// 需要注意这里的FlutterFragment位于io.flutter.embedding.android包中
//FlutterFragment flutterFragment = FlutterFragment.createDefault();
// 通过FlutterFragment.withNewEngine()获取到NewEngineFragmentBuilder对象
FlutterFragment.NewEngineFragmentBuilder fragmentBuilder = FlutterFragment.withNewEngine();
// 使用建造者模式构造出FlutterFragment对象,可以通过initialRoute()方法指定初始路由名称。
// 传递参数只需要在路由名称后面进行拼接。
FlutterFragment.NewEngineFragmentBuilder initialRoute = fragmentBuilder.initialRoute("yc");
FlutterFragment flutterFragment = initialRoute.build();
getSupportFragmentManager()
.beginTransaction()
.add(R.id.rl_flutter, flutterFragment)
.commit();
// 存在的问题
// 使用的withNewEngine()方法从名称上也能看出每次都是创建一个新的FlutterEngine对象来显示Flutter UI,
// 但是从官方文档中我们可以了解到每个FlutterEngine对象在显示出Flutter UI之前
// 是需要一个warm-up(不知道能不能翻译为预热)期的,这会导致屏幕呈现短暂的空白,
// 解决方式就是预先创建并启动FlutterEngine,完成warm-up过程,然后将这个FlutterEngine缓存起来,
// 之后使用这个FlutterEngine来显示出Flutter UI。
// 解决方案看:FlutterFragmentCachedActivity
// 如何获取到FlutterEngine对象呢?FlutterFragment中定义了一个getFlutterEngine()方法,
// 从方法名来看大概就是获取FlutterEngine对象。
// 尝试过创建MethodChannel时传入flutterFragment.getFlutterEngine().getDartExecutor(),
// 运行后会直接抛出空指针异常,异常产生的位置在FlutterFragment的getFlutterEngine()方法中
// 错误原因是这里的delegate为null,全局搜索一下,发现在FlutterFragment的onAttach()方法中会对delegate赋值,也就是说明此时没有执行onAttach()方法。
// 猜测这就是由于上面提到过的FlutterEngine的warm-up机制,这是一个耗时过程,
// 因此FlutterFragment并不会立刻执行onAttach()方法,导致我们在Activity的onCreate()方法中直接使用FlutterFragment的getFlutterEngine()方法会抛出异常。
// todo 调用下面这句话会空指针崩溃
// FlutterEngine flutterEngine = flutterFragment.getFlutterEngine();
}
Flutter添加页面
3.3 使用新版本FlutterActivity
原生引入Flutter页面方式
首先在清单文件添加代码
<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" />
直接启动这个Activity,代码如下所示
/**
* 和介绍的创建FlutterFragment的三种方式是对应的
*
* FlutterActivity显示的Flutter路由是在创建Intent对象时指定的,
* 优点就是使用起来更简单,缺点就是不够灵活,
* 无法像FlutterView/FlutterFragment那样只是作为原生页面中的一部分展示,
* 因此这种方式更适合整个页面都是由Flutter编写的场景。
*/
private void test(){
// 方式一、FlutterActivity显示的路由名称为"/",不可设置
/*startActivity(
FlutterActivity.createDefaultIntent(this)
);*/
// 方式二、FlutterActivity显示的路由名称可设置,每次都创建一个新的FlutterEngine对象
startActivity(
FlutterActivity
.withNewEngine()
.initialRoute("yc")
.build(this)
);
// 方式三、FlutterActivity显示的路由名称可设置,使用缓存好的FlutterEngine对象
/*startActivity(
FlutterActivity
.withCachedEngine("my_engine_id")
.build(this)
);*/
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
使用这种方式特点
3.4 补充说明问题
将Flutter版本更新到了1.17,发现上述代码运行后FlutterView无法显示,这个是为什么呢?
@Override
protected void onResume() {
super.onResume();
// flutterEngine.getLifecycleChannel()获取到的是一个LifecycleChannel对象,类比于MethodChannel,
// 作用大概就是将Flutter和原生端的生命周期相互联系起来。
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就可以正常显示了。
为何在之后版本要添加
04.如何处理NA跳转flutter传参
4.1 NA如何传递参数给Flutter?
如果需要在页面跳转时传递参数呢,如何在Flutter代码中获取到原生代码中的参数呢?其实很简单,只需要在route后面拼接上参数就可以了。
以创建FlutterView的方式为例。
NavigationChannel navigationChannel = flutterEngine.getNavigationChannel();
String route = "yc?{\"name\":\"杨充\"}";
navigationChannel.setInitialRoute(route);
以创建FlutterFragment的方式为例
FlutterFragment.NewEngineFragmentBuilder fragmentBuilder = FlutterFragment.withNewEngine();
// 使用建造者模式构造出FlutterFragment对象,可以通过initialRoute()方法指定初始路由名称。
// 传递参数只需要在路由名称后面进行拼接。
String route = "yc?{\"author\":\"杨充\"}";
FlutterFragment.NewEngineFragmentBuilder initialRoute = fragmentBuilder.initialRoute(route);
FlutterFragment flutterFragment = initialRoute.build();
4.2 传递参数注意事项
4.3 Flutter接收传递参数
这时候Flutter端通过window.defaultRouteName获取到的就是路由名称+参数了,我们需要将路由名称和参数分开,这就只是单纯的字符串处理。
Widget _widgetForRoute() {
//var route = window.defaultRouteName;
Map<String, dynamic> router = parseRouter();
var route = router["route"];
switch (route) {
case 'yc':
return AboutMePage(title: '匹配到了,这个是flutter页面',params : router);
}
}
Map<String, dynamic> parseRouter(){
String url = window.defaultRouteName;
// route名称,路由path路径名称
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);
params["route"] = route;
return params;
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
通过"?"将路由名称和参数分开,将参数对应的Json字符串解析为Map对象,需要导入dart:convert包。
05.思考遇到的几个问题分析
5.1 setInitialRoute生效问题
flutterEngine.getNavigationChannel().setInitialRoute("yc")生效问题
//第一种是生效的
private void addFlutterView() {
flutterEngine = new FlutterEngine(this);
binaryMessenger = flutterEngine.getDartExecutor().getBinaryMessenger();
flutterEngine.getNavigationChannel().setInitialRoute("yc");
flutterEngine.getDartExecutor().executeDartEntrypoint(
DartExecutor.DartEntrypoint.createDefault()
);
flutterView = new FlutterView(this);
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT);
rlFlutter.addView(flutterView, lp);
flutterView.attachToFlutterEngine(flutterEngine);
}
//第二种是不生效的
private void addFlutterView() {
flutterEngine = new FlutterEngine(this);
binaryMessenger = flutterEngine.getDartExecutor().getBinaryMessenger();
flutterEngine.getDartExecutor().executeDartEntrypoint(
DartExecutor.DartEntrypoint.createDefault()
);
flutterView = new FlutterView(this);
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT);
rlFlutter.addView(flutterView, lp);
// todo 放在这里不生效,思考为什么
flutterEngine.getNavigationChannel().setInitialRoute("yc");
flutterView.attachToFlutterEngine(flutterEngine);
// todo 放在这里不生效,思考为什么
// flutterEngine.getNavigationChannel().setInitialRoute("yc");
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
5.2 flutterFragment.getFlutterEngine()空指针
使用场景分析
private void createChannel() {
// todo 调用下面这句话会空指针崩溃
FlutterEngine flutterEngine = flutterFragment.getFlutterEngine();
BinaryMessenger binaryMessenger = flutterEngine.getDartExecutor().getBinaryMessenger();
nativeChannel = new MethodChannel(binaryMessenger, METHOD_CHANNEL, StandardMethodCodec.INSTANCE);
}
//源码
@Nullable
public FlutterEngine getFlutterEngine() {
return delegate.getFlutterEngine();
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
错误原因是这里的delegate为null
问题分析
如何解决问题
06.Flutter页面关闭时Crash
报错日志如下所示
Caused by: java.lang.RuntimeException: Cannot execute operation because FlutterJNI is not attached to native.
at io.flutter.embedding.engine.FlutterJNI.ensureAttachedToNative(FlutterJNI.java:259)
at io.flutter.embedding.engine.FlutterJNI.onSurfaceDestroyed(FlutterJNI.java:369)
at io.flutter.embedding.engine.renderer.FlutterRenderer.stopRenderingToSurface(FlutterRenderer.java:219)
at io.flutter.embedding.android.FlutterTextureView.disconnectSurfaceFromRenderer(FlutterTextureView.java:223)
at io.flutter.embedding.android.FlutterTextureView.access$400(FlutterTextureView.java:33)
at io.flutter.embedding.android.FlutterTextureView$1.onSurfaceTextureDestroyed(FlutterTextureView.java:84)
at android.view.TextureView.releaseSurfaceTexture(TextureView.java:261)
at android.view.TextureView.onDetachedFromWindowInternal(TextureView.java:232)
at android.view.View.dispatchDetachedFromWindow(View.java:22072)
at android.view.ViewGroup.dispatchDetachedFromWindow(ViewGroup.java:4747)
at android.view.ViewGroup.removeAllViewsInLayout(ViewGroup.java:6606)
at android.view.ViewGroup.removeAllViews(ViewGroup.java:6552)
at com.yc.fluttercontainer.FlutterEngineActivity.onDestroy(FlutterEngineActivity.java:292)
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
报错的代码如下所示
@Override
protected void onDestroy() {
super.onDestroy();
if (flutterEngine != null) {
flutterEngine.destroy();
}
mFlutterContainer.removeAllViews();
mFlutterView.removeAllViews();
if (mRenderSurface != null) {
// 打断内存泄漏
((FixFlutterTextureView) mRenderSurface).setSurfaceTextureListener(null);
}
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
https://blog.csdn.net/cxz200367/article/details/105998930
07.Android引入flutter本质
08.Flutter启动加载优化
8.1 分析flutter的启动页面流程
通过flutter引擎,整个flutter引擎的相关初始化工作在onCreate方法里开始的
protected void onCreate(@Nullable Bundle savedInstanceState) {
this.switchLaunchThemeForNormalTheme();
super.onCreate(savedInstanceState);
this.lifecycle.handleLifecycleEvent(Event.ON_CREATE);
this.delegate = new FlutterActivityAndFragmentDelegate(this);
//创建绑定引擎等
delegate.onAttach(this);
//用于插件、框架恢复状态
delegate.onActivityCreated(savedInstanceState);
//设置窗口背景透明,隐藏 status bar
configureWindowForTransparency();
//从这里分析,这里是咱们的入口
setContentView(createFlutterView());
this.configureStatusBarForFullscreenFlutterExperience();
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
然后接着往下看,会调用到FlutterActivityAndFragmentDelegate类的onCreateView方法
@NonNull
View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
Log.v("FlutterActivityAndFragmentDelegate", "Creating FlutterView.");
this.ensureAlive();
if (this.host.getRenderMode() == RenderMode.surface) {
//flutter 应用在surface上显示,所以会进入到这里
FlutterSurfaceView flutterSurfaceView = new FlutterSurfaceView(this.host.getActivity(), this.host.getTransparencyMode() == TransparencyMode.transparent);
this.host.onFlutterSurfaceViewCreated(flutterSurfaceView);
//用flutterSurfaceView 初始化了一个 FlutterView
this.flutterView = new FlutterView(this.host.getActivity(), flutterSurfaceView);
} else {
//否则,应用在TextureView上显示
FlutterTextureView flutterTextureView = new FlutterTextureView(this.host.getActivity());
this.host.onFlutterTextureViewCreated(flutterTextureView);
//用flutterTextureView 初始化了一个 FlutterView
this.flutterView = new FlutterView(this.host.getActivity(), flutterTextureView);
}
this.flutterView.addOnFirstFrameRenderedListener(this.flutterUiDisplayListener);
//创建一个闪屏view - FlutterSplashView
this.flutterSplashView = new FlutterSplashView(this.host.getContext());
if (VERSION.SDK_INT >= 17) {
this.flutterSplashView.setId(View.generateViewId());
} else {
this.flutterSplashView.setId(486947586);
}
//显示闪屏页
this.flutterSplashView.displayFlutterViewWithSplash(this.flutterView, this.host.provideSplashScreen());
Log.v("FlutterActivityAndFragmentDelegate", "Attaching FlutterEngine to FlutterView.");
//所创建surface 绑定到engine上
this.flutterView.attachToFlutterEngine(this.flutterEngine);
return this.flutterSplashView;
}
随后我们再创建一个FlutterSplashView (继承FrameLayout)。重要看调用displayFlutterViewWithSplash()方法。
public void displayFlutterViewWithSplash(@NonNull FlutterView flutterView, @Nullable SplashScreen splashScreen) {
if (this.splashScreenView != null) {
this.removeView(this.splashScreenView);
}
//省略大量代码
this.flutterView = flutterView;
this.addView(flutterView);
this.splashScreen = splashScreen;
if (splashScreen != null) {
if (this.isSplashScreenNeededNow()) {
Log.v(TAG, "Showing splash screen UI.");
this.splashScreenView = splashScreen.createSplashView(this.getContext(), this.splashScreenState);
//添加 splashScreenView
this.addView(this.splashScreenView);
flutterView.addOnFirstFrameRenderedListener(this.flutterUiDisplayListener);
}
}
}
public FlutterSplashView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
this.onTransitionComplete = new Runnable() {
public void run() {
FlutterSplashView.this.removeView(FlutterSplashView.this.splashScreenView);
FlutterSplashView.this.previousCompletedSplashIsolate = FlutterSplashView.this.transitioningIsolateId;
}
};
this.setSaveEnabled(true);
}
得出结论
8.2 如何优化flutter启动屏
第一种方案
AndroidManifest.xml下的<meta-data
android:name="io.flutter.embedding.android.SplashScreenDrawable"
android:resource="@drawable/launch_background"/>
第二种方案
@Nullable
@Override
public SplashScreen provideSplashScreen() {
//创建自定义flutter启动屏view
return new FlutterSplashView();
}
public class FlutterSplashView implements SplashScreen {
@Nullable
@Override
public View createSplashView(@NonNull Context context, @Nullable Bundle savedInstanceState) {
View v = new View(context);
v.setBackgroundColor(Color.WHITE);
return v;
}
@Override
public void transitionToFlutter(@NonNull Runnable onTransitionComplete) {
onTransitionComplete.run();
}
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.