Flutter入坑分享

Author Avatar
tangdaohai 12月 10, 2018
  • 在其它设备中阅读本文章

flutter 入坑分享

本文只适合初次接触 Flutter 的开发者。

简介

Flutter 是 Google 推出并开源的移动端开发框架(基于「Dart」语言)。使用 Flutter 开发的APP可以同时运行在 IOS 与 Android 平台上。并且 Flutter 默认带有 Material 风格 与 Cupertino 风格的主题包(前者Android,后者IOS),可以快速开发一个IOS 风格或者 Android 风格的…Demo…

  • 跨平台

    Flutter 不使用 WebView 也不使用操作系统的原生控件,而是自己有用一个 高性能 的渲染引擎,可以非常高效的进行组件绘制UI渲染。这样 Flutter 可以保证在 IOS 与 Android 上的UI表现一致性 ,开发者无需过多关注平台差异性上的问题。对于初创公司来说,前期节约开发成本就是最好的融资。。。

  • 高性能

    React Native (以下简称RN)的跨平台不同的是,RN是会将JS编写的对应组件转换为原生组件去渲染,而 Flutter 是基于最底层 Skia 的图形库去渲染(我觉得有点类似于 DOM 中的 canvas , 从平台上得到一个画布,自己在画布上去渲染),所有的渲染都有 Skia 来完成。

    Skia 延伸…

    Flutter使用Skia作为其2D渲染引擎,Skia是Google的一个2D图形处理函数库,包含字型、坐标转换,以及点阵图都有高效能且简洁的表现,Skia是跨平台的,并提供了非常友好的API,目前Google Chrome浏览器和Android均采用Skia作为其绘图引擎,值得一提的是,由于Android系统已经内置了Skia,所以Flutter在打包APK(Android应用安装包)时,不需要再将Skia打入APK中,但iOS系统并未内置Skia,所以构建iPA时,也必须将Skia一起打包,这也是为什么Flutter APP的Android安装包比iOS安装包小的主要原因。

    正是因为基于自己的渲染机制,不需要与原生平台之间频繁通信,才体现出来他的高效率、高性能。Flutter 的布局、渲染都是 Dart 直接控制,在一些交互中,比如滑动的时候它的高性能就会体现出来。而RN在这方面的渲染则是与原生平台进行通信,不断的进行信息同步,这部分的开销放到手机上还是很大的。

    而且在渲染层,Flutter 底层也有一个类似虚拟DOM的组件,在UI进行变化后,会进行diff算法。

  • 开发高效率

    Flutter 在开发的时候有一个特点,热重载。 就像在webpack 与 浏览器,在编辑器中保存后,界面立马就能看到变化。Flutter 也是这样,当将 APP 在虚拟容器中或者真机设备中调试时,保存后,APP会立刻响应。节省了大量时间。

Dart 初步了解

因为 Flutter 是基于 Dart 语言开发的,所以我们多多少少也要了解下 Dart 这玩意怎么写,他的语法与结构是个怎样的。虽然官网的 Demo 有提到说:「如果您熟悉面向对象和基本编程概念(如变量、循环和条件控制),则可以完成本教程,您无需要了解Dart或拥有移动开发的经验。」emmmm… 纯属扯淡…

如果不了解 Dart,那也仅限于看 Demo 是怎么写的…

Dart 出自Google。是一种面向对象编程的强类型语言,语法有点像 Java 与 JavaScript 的集合体。

官方学习资料

以下是使用 Flutter 需要掌握的 Dart 基础语法:

(以下内容摘抄来至 官网文档 , 没必要细看,可快速的过一遍,只做了解。)

变量声明

  1. var

    类似于JavaScript中的var,它可以接收任何类型的变量,但最大的不同是Dart中var变量一旦赋值,类型便会确定,则不能再改变其类型,如:

    1
    2
    3
    4
    5
    var t;
    t="hi world";
    // 下面代码在dart中会报错,应为变量t的类型已经确定为String,
    // 类型一旦确定后则不能再更改其类型。
    t=1000;

    上面的代码在JavaScript是没有问题的,前端开发者需要注意一下,之所以有此差异是因为Dart本身是一个强类型语言,任何变量都是有确定类型的,在Dart中,当用var声明一个变量后,Dart在编译时会根据第一次赋值数据的类型来推断其类型,编译结束后其类型就已经被确定,而JavaScript是纯粹的弱类型脚本语言,var只是变量的声明方式而已。

  2. dynamicObject

    DynamicObjectvar功能相似,都会在赋值时自动进行类型推断,不同在于,赋值后可以改变其类型,如:

    1
    2
    3
    4
    dynamic t;
    t="hi world";
    //下面代码没有问题
    t=1000;

    Object 是dart所有对象的根基类,也就是说所有类型都是Object的子类,所以任何类型的数据都可以赋值给Object声明的对象,所以表现效果和dynamic相似。

  3. finalconst

    如果您从未打算更改一个变量,那么使用 finalconst,不是var,也不是一个类型。 一个 final 变量只能被设置一次,两者区别在于:const 变量是一个编译时常量,final变量在第一次使用时被初始化。被final或者const修饰的变量,变量类型可以省略,如:

    1
    2
    3
    4
    5
    //可以省略String这个类型声明
    final str = "hi world";
    //final str = "hi world";
    const str1 = "hi world";
    //const String str1 = "hi world";

函数

Dart是一种真正的面向对象的语言,所以即使是函数也是对象,并且有一个类型Function。这意味着函数可以赋值给变量或作为参数传递给其他函数,这是函数式编程的典型特征。

  1. 函数声明

    1
    2
    3
    bool isNoble(int atomicNumber) {
    return _nobleGases[atomicNumber] != null;
    }

    dart函数声明如果没有显示申明返回值类型时会默认当做dynamic处理,注意,函数返回值没有类型推断:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    typedef bool CALLBACK();

    //不指定返回类型,此时默认为dynamic,不是bool
    isNoble(int atomicNumber) {
    return _nobleGases[atomicNumber] != null;
    }

    void test(CALLBACK cb){
    print(cb());
    }
    //报错,isNoble不是bool类型
    test(isNoble);
  2. 对于只包含一个表达式的函数,可以使用简写语法

    1
    bool isNoble (int atomicNumber )=> _nobleGases [ atomicNumber ] != null ;
  3. 函数作为变量

    1
    2
    3
    4
    var say= (str){
    print(str);
    };
    say("hi world");
  4. 函数作为参数传递

    1
    2
    3
    4
    void execute(var callback){
    callback();
    }
    execute(()=>print("xxx"))
  5. 可选的位置参数

    包装一组函数参数,用[]标记为可选的位置参数:

    1
    2
    3
    4
    5
    6
    7
    String say(String from, String msg, [String device]) {
    var result = '$from says $msg';
    if (device != null) {
    result = '$result with a $device';
    }
    return result;
    }

    下面是一个不带可选参数调用这个函数的例子:

    1
    say('Bob', 'Howdy'); //结果是: Bob says Howdy

    下面是用第三个参数调用这个函数的例子:

    1
    say('Bob', 'Howdy', 'smoke signal'); //结果是:Bob says Howdy with a smoke signal
  6. 可选的命名参数

    定义函数时,使用{param1, param2, …},用于指定命名参数。例如:

    1
    2
    3
    4
    //设置[bold]和[hidden]标志
    void enableFlags({bool bold, bool hidden}) {
    // ...
    }

    调用函数时,可以使用指定命名参数。例如:paramName: value

    1
    enableFlags(bold: true, hidden: false);

    可选命名参数在Flutter中使用非常多。

异步支持

Dart类库有非常多的返回Future或者Stream对象的函数。 这些函数被称为异步函数:它们只会在设置好一些需要消耗一定时间的操作之后返回,比如像 IO操作。而不是等到这个操作完成。

asyncawait关键词支持了异步编程,运行您写出和同步代码很像的异步代码。

  1. Future

Future与JavaScript中的Promise非常相似,表示一个异步操作的最终完成(或失败)及其结果值的表示。简单来说,它就是用于处理异步操作的,异步处理成功了就执行成功的操作,异步处理失败了就捕获错误或者停止后续操作。一个Future只会对应一个结果,要么成功,要么失败。

由于本身功能较多,这里我们只介绍其常用的API及特性。还有,请记住,Future 的所有API的返回值仍然是一个Future对象,所以可以很方便的进行链式调用。

  1. Future.then

为了方便示例,在本例中我们使用Future.delayed 创建了一个延时任务(实际场景会是一个真正的耗时任务,比如一次网络请求),即2秒后返回结果字符串”hi world!”,然后我们在then中接收异步结果并打印结果,代码如下:

1
2
3
4
5
Future.delayed(new Duration(seconds: 2),(){
return "hi world!";
}).then((data){
print(data);
});
  1. Future.catchError

如果异步任务发生错误,我们可以在catchError中捕获错误,我们将上面示例改为:

1
2
3
4
5
6
7
8
9
10
Future.delayed(new Duration(seconds: 2),(){
//return "hi world!";
throw AssertionError("Error");
}).then((data){
//执行成功会走到这里
print("success");
}).catchError((e){
//执行失败会走到这里
print(e);
});

在本示例中,我们在异步任务中抛出了一个异常,then的回调函数将不会被执行,取而代之的是 catchError回调函数将被调用;但是,并不是只有 catchError回调才能捕获错误,then方法还有一个可选参数onError,我们也可以它来捕获异常:

1
2
3
4
5
6
7
8
Future.delayed(new Duration(seconds: 2), () {
//return "hi world!";
throw AssertionError("Error");
}).then((data) {
print("success");
}, onError: (e) {
print(e);
});
  1. Future.whenComplete

有些时候,我们会遇到无论异步任务执行成功或失败都需要做一些事的场景,比如在网络请求前弹出加载对话框,在请求结束后关闭对话框。这种场景,有两种方法,第一种是分别在thencatch中关闭一下对话框,第二种就是使用FuturewhenComplete回调,我们将上面示例改一下:

1
2
3
4
5
6
7
8
9
10
11
12
Future.delayed(new Duration(seconds: 2),(){
//return "hi world!";
throw AssertionError("Error");
}).then((data){
//执行成功会走到这里
print(data);
}).catchError((e){
//执行失败会走到这里
print(e);
}).whenComplete((){
//无论成功或失败都会走到这里
});
  1. Future.wait

有些时候,我们需要等待多个异步任务都执行结束后才进行一些操作,比如我们有一个界面,需要先分别从两个网络接口获取数据,获取成功后,我们需要将两个接口数据进行特定的处理后再显示到UI界面上,应该怎么做?答案是Future.wait,它接受一个Future数组参数,只有数组中所有Future都执行成功后,才会触发then的成功回调,只要有一个Future执行失败,就会触发错误回调。下面,我们通过模拟Future.delayed 来模拟两个数据获取的异步任务,等两个异步任务都执行成功时,将两个异步任务的结果拼接打印出来,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Future.wait([
// 2秒后返回结果
Future.delayed(new Duration(seconds: 2), () {
return "hello";
}),
// 4秒后返回结果
Future.delayed(new Duration(seconds: 4), () {
return " world";
})
]).then((results){
print(results[0]+results[1]);
}).catchError((e){
print(e);
});

执行上面代码,4秒后你会在控制台中看到“hello world”。

Async/await

Dart中的async/await 和JavaScript中的async/await功能和用法是一模一样的,如果你已经了解JavaScript中的async/await的用法,可以直接跳过本节。

  1. 回调地狱(Callback hell)

如果代码中有大量异步逻辑,并且出现大量异步任务依赖其它异步任务的结果时,必然会出现Future.then回调中套回调情况。举个例子,比如现在有个需求场景是用户先登录,登录成功后会获得用户Id,然后通过用户Id,再去请求用户个人信息,获取到用户个人信息后,为了使用方便,我们需要将其缓存在本地文件系统,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
//先分别定义各个异步任务
Future<String> login(String userName, String pwd){
...
//用户登录
};
Future<String> getUserInfo(String id){
...
//获取用户信息
};
Future saveUserInfo(String userInfo){
...
// 保存用户信息
};

接下来,执行整个任务流:

1
2
3
4
5
6
7
8
9
10
login("alice","******").then((id){
//登录成功后通过,id获取用户信息
getUserInfo(id).then((userInfo){
//获取用户信息后保存
saveUserInfo(userInfo).then((){
//保存用户信息,接下来执行其它操作
...
});
});
})

可以感受一下,如果业务逻辑中有大量异步依赖的情况,将会出现上面这种在回调里面套回调的情况,过多的嵌套会导致的代码可读性下降以及出错率提高,并且非常难维护,这个问题被形象的称为回调地狱(Callback hell)。回调地狱问题在之前JavaScript中非常突出,也是JavaScript被吐槽最多的点,但随着ECMAScript6和ECMAScript7标准发布后,这个问题得到了非常好的解决,而解决回调地狱的两大神器正是ECMAScript6引入了Promise,以及ECMAScript7中引入的async/await。 而在Dart中几乎是完全平移了JavaScript中的这两者:Future相当于Promise,而async/await连名字都没改。接下来我们看看通过Futureasync/await如何消除上面示例中的嵌套问题。

  1. 使用Future消除callback hell
1
2
3
4
5
6
7
8
9
10
login("alice","******").then((id){
return getUserInfo(id);
}).then((userInfo){
return saveUserInfo(userInfo);
}).then((e){
//执行接下来的操作
}).catchError((e){
//错误处理
print(e);
});

正如上文所述, Future 的所有API的返回值仍然是一个Future对象,所以可以很方便的进行链式调用” ,如果在then中返回的是一个Future的话,该future会执行,执行结束后会触发后面的then回调,这样依次向下,就避免了层层嵌套。

  1. 使用async/await消除callback hell

通过Future回调中再返回Future的方式虽然能避免层层嵌套,但是还是有一层回调,有没有一种方式能够让我们可以像写同步代码那样来执行异步任务而不使用回调的方式?答案是肯定的,这就要使用async/await了,下面我们先直接看代码,然后再解释,代码如下:

1
2
3
4
5
6
7
8
9
10
11
task() async {
try{
String id = await login("alice","******");
String userInfo = await getUserInfo(id);
await saveUserInfo(userInfo);
//执行接下来的操作
} catch(e){
//错误处理
print(e);
}
}
  • async用来表示函数是异步的,定义的函数会返回一个Future对象,可以使用then方法添加回调函数。
  • await 后面是一个Future,表示等待该异步任务完成,异步完成后才会往下走;await必须出现在 async 函数内部。

可以看到,我们通过async/await将一个异步流用同步的代码表示出来了。

其实,无论是在JavaScript还是Dart中,async/await都只是一个语法糖,编译器或解释器最终都会将其转化为一个Promise(Future)的调用链。

Stream

Stream 也是用于接收异步事件数据,和Future 不同的是,它可以接收多个异步操作的结果(成功或失败)。 也就是说,在执行异步任务时,可以通过多次触发成功或失败事件而传递结果数据或错误异常。 Stream 常用于会多次读取数据的异步任务场景,如网络内容下载、文件读写等。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Stream.fromFutures([
// 1秒后返回结果
Future.delayed(new Duration(seconds: 1), () {
return "hello 1";
}),
// 抛出一个异常
Future.delayed(new Duration(seconds: 2),(){
throw AssertionError("Error");
}),
// 3秒后返回结果
Future.delayed(new Duration(seconds: 3), () {
return "hello 3";
})
]).listen((data){
print(data);
}, onError: (e){
print(e.message);
},onDone: (){

});

上面的代码依次会输出:

1
2
3
I/flutter (17666): hello 1
I/flutter (17666): Error
I/flutter (17666): hello 3

代码很简单,就不赘述了。

思考题:既然Stream可以接收多次事件,那能不能用Stream来实现一个订阅者模式的事件总线?

总结

通过上面介绍,相信你对Dart应该有了一个初步的印象,由于笔者平时也使用Java和JavaScript,下面笔者根据自己的经验,结合Java和JavaScript,谈一下自己的看法。

之所以将Dart与Java和JavaScript对比,是因为,这两者分别是强类型语言和弱类型语言的典型代表,并且Dart 语法中很多地方也都借鉴了Java和JavaScript。

Dart vs Java

客观的来讲,Dart在语法层面确实比Java更有表现力;在VM层面,Dart VM在内存回收和吞吐量都进行了反复的优化,但具体的性能对比,笔者没有找到相关测试数据,但在笔者看来,只要Dart语言能流行,VM的性能就不用担心,毕竟Google在go(没用vm但有GC)、javascript(v8)、dalvik(android上的java vm)上已经有了很多技术积淀。值得注意的是Dart在Flutter中已经可以将GC做到10ms以内,所以Dart和Java相比,决胜因素并不会是在性能方面。而在语法层面,Dart要比java更有表现力,最重要的是Dart对函数式编程支持要远强于Java(目前只停留在lamda表达式),而Dart目前真正的不足是生态,但笔者相信,随着Futter的逐渐火热,会回过头来反推Dart生态加速发展,对于Dart来说,现在需要的是时间。

Dart vs JavaScript

JavaScript的弱类型一直被抓短,所以TypeScript、Coffeescript甚至是Facebook的flow(虽然并不能算JavaScript的一个超集,但也通过标注和打包工具提供了静态类型检查)才有市场。就笔者使用过的脚本语言中(笔者曾使用过Python、PHP),JavaScript无疑是动态化支持最好的脚本语言,比如在JavaScript中,可以给任何对象在任何时候动态扩展属性,对于精通JavaScript的高手来说,这无疑是一把利剑。但是,任何事物都有两面性,JavaScript的强大的动态化特性也是把双刃剑,你可经常听到另一个声音,认为JavaScript的这种动态性糟糕透了,太过灵活反而导致代码很难预期,无法限制不被期望的修改。毕竟有些人总是对自己或别人写的代码不放心,他们希望能够让代码变得可控,并期望有一套静态类型检查系统来帮助自己减少错误。正因如此,在Flutter中,Dart几乎放弃了脚本语言动态化的特性,如不支持反射、也不支持动态创建函数等。并且Dart在2.0强制开启了类型检查(Strong Mode),原先的检查模式(checked mode)和可选类型(optional type)将淡出,所以在类型安全这个层面来说,Dart和TypeScript、Coffeescript是差不多的,所以单从这一点来看,Dart并不具备什么明显优势,但综合起来看,dart既能进行服务端脚本、APP开发、web开发,这就有优势了!

官方PPT宣传截图

Flutter 底层架构的一个大概示意图:

官方PPT截图

Material 和 Cupertino 是 Flutter 官方提供的两个不同的 UI 风格组件库(前者Android,后者IOS)。

在 Flutter 中,一切皆是 Widget 。 一个按钮是 Widget,一段文字也是 Widget,一个图片也是 Widget,一个路由导航 也是 Widget。所以前期接触 Flutter 可以先学习这两个UI库如何使用即可。(个人见解)

基础组件库

Material 组件库

Cupertino 组件库

搭建开发环境

搭建过程很简单,下载 SDK 包,然后配置下环境变量就ok了。

编辑器推荐

VScode,轻巧、简洁。

配置好 Flutter环境,只需要在安装一个 Flutter 插件就好了。

官方配置教程

第一个Demo

在 VScode 中安装好插件后,按下shift + command + p 输入 flutter ,选择 New Project

第一次创建时可能需要选择 Flutter SDK 的位置。

下面的Demo是官网上的给出的代码,整理出来的一个完整的。

  1. 先在 pubspec.yaml 中添加一个依赖: english_words 它是 Dart 语言编写的一个随机生成英文单词的工具包。

    pubspec.yaml 是 Flutter 配置文件,可以理解为 npm 中的 package.json

    找到文件的第21行:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    dependencies:
    flutter:
    sdk: flutter

    # The following adds the Cupertino Icons font to your application.
    # Use with the CupertinoIcons class for iOS style icons.
    cupertino_icons: ^0.1.2

    # 在这里添加 版本号遵循 语义化(Semantic Versioning)
    english_words: ^3.1.5

    dev_dependencies:
    flutter_test:
    sdk: flutter

    Flutter 有一个官方的包管理平台,pub.dartlang.org 类似于npm

    添加完成后,在控制台输入flutter packages get 或者在编辑器中右键点击 pubspes.yaml 选择 Get Packages

    也就是安装新的依赖。

  2. 替换Demo代码

    这个Demo是一个随机生成英文名字的程序,有一个可以无限滚动的列表,可以让用户对喜欢的名字进行红心标记搜藏,然后点击右上角,可以查看已收藏的名字(路由跳转来实现的)。

    将lib/main.dart 中的所有代码删除,替换成下面的代码:

下面的代码是将官网Demo中的代码整理好的,可以先不去管它什么样的结果或者具体每句代码什么意思,先将Demo在模拟器中跑起来再说。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
import 'package:flutter/material.dart';
import 'package:english_words/english_words.dart';

// 程序入口
void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new MaterialApp(
title: 'Startup Name Generator',
home: new RandomWords(),
theme: new ThemeData(
primaryColor: Colors.white,
),
);
}
}

class RandomWords extends StatefulWidget {
@override
createState() => new RandomWordsState();
}

class RandomWordsState extends State<RandomWords> {
final _suggestions = <WordPair>[];

final _saved = new Set<WordPair>();

final _biggerFont = const TextStyle(fontSize: 18.0);
@override
Widget build(BuildContext context) {
return new Scaffold (
appBar: new AppBar(
title: new Text('Startup Name Generator'),
actions: <Widget>[
new IconButton(icon: new Icon(Icons.list), onPressed: _pushSaved),
],
),
body: _buildSuggestions(),
);
}

void _pushSaved() {
Navigator.of(context).push(
new MaterialPageRoute(
builder: (context) {
final tiles = _saved.map(
(pair) {
return new ListTile(
title: new Text(
pair.asPascalCase,
style: _biggerFont,
),
);
},
);
final divided = ListTile
.divideTiles(
context: context,
tiles: tiles,
)
.toList();

return new Scaffold(
appBar: new AppBar(
title: new Text('Saved Suggestions'),
),
body: new ListView(children: divided),
);
},
)
);
}

Widget _buildRow(WordPair pair) {
final alreadySaved = _saved.contains(pair);
return new ListTile(
title: new Text(
pair.asPascalCase,
style: _biggerFont,
),
trailing: new Icon(
alreadySaved ? Icons.favorite : Icons.favorite_border,
color: alreadySaved ? Colors.red : null,
),
onTap: () {
setState(() {
if (alreadySaved) {
_saved.remove(pair);
} else {
_saved.add(pair);
}
});
},
);
}

Widget _buildSuggestions() {
return new ListView.builder(
padding: const EdgeInsets.all(16.0),
// 对于每个建议的单词对都会调用一次itemBuilder,然后将单词对添加到ListTile行中
// 在偶数行,该函数会为单词对添加一个ListTile row.
// 在奇数行,该行书湖添加一个分割线widget,来分隔相邻的词对。
// 注意,在小屏幕上,分割线看起来可能比较吃力。
itemBuilder: (context, i) {
// 在每一列之前,添加一个1像素高的分隔线widget
if (i.isOdd) return new Divider();

// 语法 "i ~/ 2" 表示i除以2,但返回值是整形(向下取整),比如i为:1, 2, 3, 4, 5
// 时,结果为0, 1, 1, 2, 2, 这可以计算出ListView中减去分隔线后的实际单词对数量
final index = i ~/ 2;
// 如果是建议列表中最后一个单词对
if (index >= _suggestions.length) {
// ...接着再生成10个单词对,然后添加到建议列表
_suggestions.addAll(generateWordPairs().take(10));
}
return _buildRow(_suggestions[index]);
}
);
}
}
  1. 选择调试 -> 启动调试 然后选择 ios emulator , 等待启动即可。(这个是macOS上的操作,windows只能选择Android的模拟器,当前所有的前提是你的 Flutter 环境确保搭建成功了。)

    运行成功后如下图所示:

    flutter-ios

官方学习资料链接

Flutter 中文网

Flutter 实战

以上,致那颗骚动的心……