本节的学习目标是通过 Flutter 技术,实现 仿拉勾教育 App 的效果。其主要的 UI 效果如下:
初始化项目
flutter create flutter_project
修改文件 flutter项目/android/build.gradle ,把 google() 和 jcenter() 这两行去掉。改为阿里的镜像地址。
// google()
// jcenter()
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/jcenter' }
maven { url 'https://maven.aliyun.com/repository/public' }
启动项目
flutter run
Tab 导航
准备三个页面
在 lib 下,新建 pages 目录,用来存放页面
lib
|--pages
|----home
|------Home.dart 首页(选课)
|----study
|------Study.dart 学习
|----mine
|------Mine.dart 我
可以在页面中临时写一些内容,用来区分不同的页面。
准备底部导航菜单,分别跳转到上述三个页面
在 lib 下创建 Index.dart
import 'package:flutter/material.dart';
import 'home/Home.dart';
import 'study/Study.dart';
import 'mine/Mine.dart';
class Index extends StatefulWidget {
Index({Key key}) : super(key: key);
_IndexState createState() => _IndexState();
}
class _IndexState extends State<Index> {
final List<BottomNavigationBarItem> bottomNavItems = [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: '选课',
),
BottomNavigationBarItem(
icon: Icon(Icons.message),
label: '学习',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: '我',
),
];
final pages = [
{
"appBar": AppBar(
title: Text('首页'),
elevation: 0. 0 ,
centerTitle: true,
),
"widget": Home(),
},
{
"appBar": AppBar(
title: Text('学习中心'),
elevation: 0. 0 ,
centerTitle: true,
),
"widget": Study(),
},
{
"appBar": AppBar(
title: Text('个人中心'),
elevation: 0. 0 ,
centerTitle: true,
),
"widget": Mine(),
}
];
int currentIndex;
void initState() {
super.initState();
currentIndex = 0 ;
}
Widget build(BuildContext context) {
return Scaffold(
appBar: pages[currentIndex]['appBar'],
bottomNavigationBar: BottomNavigationBar(
items: bottomNavItems,
currentIndex: currentIndex,
type: BottomNavigationBarType.fixed,
onTap: (index) {
_changePage(index);
}
),
body: pages[currentIndex]['widget'],
);
}
void _changePage(int index) {
if (index != currentIndex) {
setState(() {
currentIndex = index;
});
}
}
void dispose() {
super.dispose();
}
}
首页完成后,我们需要点击课程列表,跳转到课程详情页。并且,需要动态传递参数,来动态获取详情页的内容。此时,我们需要声明详情页的路由。之前我们学过 Flutter 内置的路由方案(Navigator)。
这里我们介绍一款企业级的路由框架 - Fluro
安装
dependencies:
fluro: ^ 1. 7. 8
声明路由处理器
我们将所有路由文件,统一放到 lib/routes 中
lib
|--routes
|----RoutesHandler.dart 路由处理器
|----Routes.dart 路由
创建 lib/routes/RoutesHandler.dart
import 'package:fluro/fluro.dart';
import 'package:flutter/material.dart';
import '../pages/unknown/UnknownPage.dart';
import '../pages/Index.dart';
import '../pages/course/CourseDetail.dart';
import '../pages/mine/Mine.dart';
import '../pages/study/Study.dart';
// 空页面
var unknownHandler = new Handler(
handlerFunc: (BuildContext context, Map<String, List<String>> params) {
return UnknownPage();
}
);
// 默认页
var indexHandler = new Handler(
handlerFunc: (BuildContext context, Map<String, List<Object>> params) {
return Index();
}
);
// 课程详情页
// var courseDetailHandler = new Handler(
// handlerFunc: (BuildContext context, Map> params) {
// return CourseDetail(id: int.parse(params['id'].first), title:
params['title'].first);
// }
// );
// 个人中心页面
var mineHandler = new Handler(
handlerFunc: (BuildContext context, Map<String, List<Object>> params) {
return Mine();
}
);
// 学习页面
var studyHandler = new Handler(
handlerFunc: (BuildContext context, Map<String, List<Object>> params) {
return Study();
}
);
声明路由
创建 lib/routes/Routes.dart
import 'package:flutter/material.dart';
import 'package:fluro/fluro.dart';
import 'RouteHandler.dart';
class Routes {
static void configureRoutes(FluroRouter router) {
router.define('/', handler: indexHandler);
router.define('/course_detail', handler: courseDetailHandler);
router.define('/mine', handler: mineHandler);
router.define('/study', handler: studyHandler);
router.notFoundHandler = unknownHandler; // 未知页面
}
}
然后把路由相关的内容,也放到 lib/utils/Global.dart 中
import 'package:fluro/fluro.dart';
class G {
/// Fluro路由
static FluroRouter router;
}
在入口文件(lib/main.dart)中初始化 router
import 'package:fluro/fluro.dart';
import 'routes/Routes.dart';
import 'utils/global.dart';
//final MyRouter router = MyRouter();
void main() {
FluroRouter router = FluroRouter();
Routes.configureRoutes(router);
G.router = router; // 初始化全局中的 router
/// ...
使用路由
首页跳转到详情页
/// course 是文章详情
Map<String, dynamic> p = {
'id': course['id'],
'title': course['courseName'],
};
// print("/course_detail?id= 123 &title=课程名称");
G.router.navigateTo(context, "/course_detail"+G.parseQuery(params: p));
上述代码中的 parseQuery,是将 Map 类型,转成 URL 中的 query 字符串。代码详情:
// lib/utils/Global.dart
import 'package:flutter/material.dart';
class G {
/// 将请求参数,由 Map 解析成 query
static parseQuery({Map<String, dynamic> params}) {
String query = "";
if (params != null) {
int index = 0 ;
for (String key in params.keys) {
final String value = Uri.encodeComponent(params[key].toString());
if (index == 0 ) {
query = "?";
} else {
query = query + "\&";
}
query += "$key=$value";
index++;
}
return query.toString();
}
}
Provider
安装 Provider
https://pub.dev/packages/provider
创建数据模型
import 'package:flutter/material.dart';
class CurrentIndexProvider with ChangeNotifier {
int currentIndex = 0 ;
changeIndex(int index) {
currentIndex = index;
notifyListeners();
}
}
注册数据模型
注册单个数据模型
ChangeNotifierProvider(
create: (BuildContext context) => new UserProvider(),
child: MyApp(),
);
注册多个数据模型
有些时候,我们需要多个数据模型,此时,我们可以使用 MultiProvider 来注册多个数据模型
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: CurrentIndexProvider()),
ChangeNotifierProvider.value(value: UserProvider()),
],
child: MyApp(),
),
在具体组件中使用 Provider 中的数据
访问 Provider 时,有两种方式:监听和取消监听
监听
监听方法只能用来 [StatelessWidget.build] 和 [State.build] 中使用。监听值发生变化时,会重建组件。
Provider.of<T>(context) // 语法糖是: context.watch(context)
取消监听
取消监听,不能在 [StatelessWidget.build] 或 [State.build] 中使用;换句话说,它可以在上述两个方法之外的所有方法中使用。监听值发生变化时,不会重建组件。
Provider.of(context, listen: false) // 语法糖是: context.read
(context)
访问数据
Provider.of(context).currentIndex;
访问方法
// 取消监听
Provider.of<CurrentIndexProvider>(context, listen:
false).changeIndex(index);
接口简介
本项目所用的数据接口是拉勾教育的模拟接口,是通过 Java 开发的。接口格式与线上环境一致,只是数据为模拟数据(仅供学习调试使用)。接口地址为:
http://eduboss.lagou.com/front/doc.html#/home
本项目中,用到的接口有:
想要使用接口的话,我们需要借助 Flutter 中的接口请求插件。这里我们选用 Dio
接口调用
Dio
安装 Dio:https://pub.dev/packages/dio
报错: Insecure HTTP is not allowed by platform
原因:平台不支持不安全的 HTTP 协议,即不允许访问 HTTP 域名的地址。
Android 解决
打开 android/app/src/main/AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" /> <!-- 添加这
一行 - ->
<application
android:label="flutter_app"
android:usesCleartextTraffic="true" <!-- 添加这一行 - ->
android:icon="@mipmap/ic_launcher">
iOS 解决
打开 ios/Runner/Info.plist。添加如下代码:
NSAppTransportSecurity
NSAllowsArbitraryLoads
Dio 手册: https://github.com/flutterchina/dio/blob/master/README-ZH.md
初始化 Dio
我们把所有接口操作相关的代码都集中放到 api 目录下。例如:
lib
|--api
|----initDio.dart 初始化 Dio
|----AdAPI.dart 广告 API
|----CourseAPI.dart 课程 API
|----UserAPI.dart 用户 API
|----OrderAPI.dart 订单 API
|----API.dart 所有 API(包括用户,订单,课程,广告等)
接下来,我们来创建具体的文件。首先,创建 lib/api/initDio.dart
Dio initDio() {
BaseOptions _baseOptions = BaseOptions(
baseUrl: "http://eduboss.lagou.com", // 接口请求路径
);
// 初始化
Dio dio = Dio(_baseOptions);
// 添加请求拦截
dio.interceptors.add(
InterceptorsWrapper(
// 请求拦截
onRequest:(RequestOptions options) async {
// 在请求被发送之前做一些事情
// ......
return options;
},
// 响应拦截
onResponse:(Response response) async {
// 在返回响应数据之前做一些预处理
if (response.data['state'] != 1 ) {
print("响应失败:" + response.data['message']);
response.data = null;
}
return response;
},
onError: (DioError e) async {
return e;
}
}
}
}
使用 Dio
获取首页广告列表
创建 lib/api/AdAPI.dart
import 'package:dio/dio.dart';
class AdAPI {
final Dio _dio;
AdAPI(this._dio);
// 广告列表
Future<dynamic> adList({
///此处的 ' 999 ' 代表了首页顶部轮播图的广告位
String spaceKeys = ' 999 ',
}) async {
Response res = await _dio.get('/front/ad/getAllAds',
queryParameters: {
"spaceKeys": spaceKeys,
}
);
List adList = res.data['content'][ 0 ]['adDTOList'];
return adList;
}
}
创建 lib/api/API.dart
import 'package:dio/dio.dart';
import 'initDio.dart';
import 'AdAPI.dart';
class API {
Dio _dio;
API() {
// 初始化 dio
_dio = initDio();
}
// 广告接口
AdAPI get ad => AdAPI(_dio);
// 课程接口
// CourseAPI get course => CourseAPI(_dio);
}
为了操作方便,我们可以把常用内容统一放到一个全局文件中 。
例如,创建 lib/utils/Global.dart。然后,把我们写好的接口放到 Global.dart 中。
import 'package:flutter/material.dart';
import '../api/API.dart';
class G {
/// 初始化 API
static final API api = API();
}
在首页中调用接口 adList()
import '../api/API.dart';
/// ....
List adList = [];
void initState() {
super.initState();
// 广告列表
G.api.ad.adList().then((value) {
setState(() {
adList = value.where((ad) => ad['status'] == 1 ).toList();
});
});
}
// ....
展示内容
首页包含两部分内容:
广告轮播
数据接口已经完成。想要展示轮播的话需要使用 flutter_swiper 插件。
课程列表
准备接口
创建 lib/api/CourseAPI.dart。创建方式与 AdAPI.dart 一致。下面给出关键代码
Future<dynamic> courseList() async {
Response res = await _dio.get('/front/course/getAllCourse');
List target = res.data['content'];
return target;
}
如果是初次创建 lib/api/CourseAPI.dart,需要在 lib/api/API.dart 中,添加相应的 getter
后续再有新的 API 文件的创建,也需要在 lib/api/API.dart 中,添加对应的 getter。
import 'package:dio/dio.dart';
import 'initDio.dart';
import 'AdAPI.dart';
class API {
Dio _dio;
API() {
// 初始化 dio
_dio = initDio();
}
// 广告接口
AdAPI get ad => AdAPI(_dio);
// 课程接口
CourseAPI get course => CourseAPI(_dio); // 添加这一行
}
调用接口
List courseList = [];
void initState() {
super.initState();
// 课程列表
G.api.course.courseList().then((value) {
setState(() {
courseList = value;
});
});
}
展示数据
展示数据的组件有多种,例如:ListView,GridView。我们这里使用 SliverList:
SliverList(
delegate: SliverChildBuilderDelegate((BuildContext context, int index) {
var course = courseList[index];
//创建列表项
return GestureDetector(
onTap: () {
// ...
}
child: 具体组件实现
}
}
);
屏幕适配
除了内容展示之外,还有屏幕适配的问题。例如,我的应用可能在手机上打开,也可能在 Pad 上打开。
终端屏幕尺寸大小不一。如何进行屏幕适配呢?
适配原理:
设计尺寸(初始化指定 - 一般是设计师出图的尺寸,是可以预先知道的)
终端尺寸(动态获取)
deviceWidth: 1080 px
deviceHeight: 1920 px
缩放比例
scaleWidth = deviceWidth / designWidth
scaleHeight = deviceHeight / designHeight
明确了缩放比例后,我们就可以适配终端了。
例如:终端宽度是 1080 px,此时,如果声明 50% 的宽度,可以写成 375.w(因为设计尺寸的宽度是 750 px,所以 50% 的宽度就是 375 ),375.w 会根据缩放比例,计算出实际终端的宽度。计算公式为:
实际宽度的 50% = 375 X scaleWidth = 375 X (1080 / 750) = 540
以上就是屏幕适配的基本原理。当然,在实际操作中,还涉及到 px 到 dp 的转换,这里不再展开。
flutter_screenutil
flutter_screenutil 是用来解决屏幕适配的包。
详情查看: https://pub.dev/packages/flutter_screenutil
flutter_screenutil 的工作原理是:在具体设备上,把原型图的尺寸,等比例放大或缩小。
具体用法:
初始化设计尺寸
ScreenUtilInit(
designSize: Size( 750 , 1334 ), // 初始化设计尺寸 1334是高度
allowFontScaling: false, // 字体大小是否跟随终端
builder: () => MaterialApp(
title: 'Flutter Demo',
// home: Index(),
onGenerateRoute: G.router.generator,
initialRoute: '/',
),
);
在实际使用过程中,以 Flutter 1.2 为分割线,有两种不同的语法
Flutter 1.2 之前
width: ScreenUtil().setWidth( 50 );
height: ScreenUtil().setHeight( 200 );
Flutter 1.2 之后
width: 50 .w;
height: 200 .h;
接收参数
路由传参(lib/routes/RoutesHandler.dart)
// 课程详情页
var courseDetailHandler = new Handler(
handlerFunc: (BuildContext context, Map> params) {
// params['id'] 是数组。例如:[ 123 ],params['id'].first 可以取到第一个元素
return CourseDetail(
id: int.parse(params['id'].first),
title: params['title'].first
);
}
);
组件接收参数(lib/pages/course/CourseDetail.dart)
/// 课程详情
class CourseDetail extends StatefulWidget {
// 构造函数
CourseDetail({Key key, this.id, this.title}) : super(key:
key);
final int id;
final String title;
_CourseDetailState createState() {
return _CourseDetailState();
}
}
class _CourseDetailState extends State<CourseDetail> {
// 通过 widget.id 可以取到课程 Id
/// ...
}
调用接口
Map course = {};
// print(widget.id);
G.api.course.courseDetail(id: widget.id).then((value) {
setState(() {
course = value;
});
});
课程详情
内容展示过程中只有一个技术难点。就是课程详情。课程详情是在后台,通过富文本编辑器添加的。也
就是说, 课程详情是一些 HTML 代码。但是 HTML 不能直接在 Flutter 中展示 。因此,我们需要将HTML 代码,转成 Flutter 支持的 Dart 代码。这里,我们借助 flutter_html 来完成课程详情的展示。
flutter_html
详情查看:https://pub.dev/packages/flutter_html
使用步骤:
安装
在 pubspec.yaml 中设置依赖
dependencies:
flutter_html: ^ 1. 3. 0
安装依赖
VS Code 中,保存 pubspec.yaml 会自动安装依赖
或者:
在 Flutter 项目根目录下运行
flutter pub get
配置 gradle-plugin 的中文镜像
为了能够通过 Flutter 的项目构建。我们需要对两个文件进行配置(目的是修改 gradle-plugin 的中文镜像)
一个是 Flutter 安装路径下的文件。例如,我本地把 Flutter 安装到了 D:\flutter,我的文件路径是:
D:\flutter\packages\flutter_tools\gradle\flutter.gradle
buildscript {
repositories {
// google()
// jcenter()
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/jcenter' }
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/gradle-plugin/'
} // 新加
}
dependencies {
classpath 'com.android.tools.build:gradle: 4. 1. 0 '
}
}
修改 flutter项目/android/build.gradle
buildscript {
ext.kotlin_version = ' 1. 3. 50 '
repositories {
// google()
// jcenter()
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/jcenter' }
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/gradle-plugin/'
} // 新加
}
dependencies {
classpath 'com.android.tools.build:gradle: 4. 1. 0 '
classpath "org.jetbrains.kotlin:kotlin-gradle-
plugin:$kotlin_version"
}
}
allprojects {
repositories {
// google()
// jcenter()
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/jcenter' }
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/gradle-plugin/'
} // 新加
}
}
使用
import 'package:flutter_html/flutter_html.dart';
Html(data: "标题 1 "); /// 在 Flutter 中展示 HTML 代码
课程章节
课程章节的操作方式与详情类型
声明接口
在 lib/api/CourseAPI.dart 中声明接口
Future<dynamic> courseSection({
/// 课程id
int id
}) async {
Response res = await _dio.get('/front/course/session/getSectionAndLesson',
queryParameters: {
'courseId': id
}
);
Map target = res.data['content'];
return target;
}
调用接口
在 lib/pages/Course/CourseDetail.dart 中调用接口
Map courseSection = {};
/// ...
// 课程章节
G.api.course.courseSection(id: widget.id).then((value) {
setState(() {
courseSection = value;
});
});
展示内容
根据效果图,选择合适的组件,展示即可。
如果课程不存在,则不展示
声明登录页面(lib/pages/user/Login.dart)
用户登录页面的表单,可以参考 Flutter 基础中,表单的代码。
在 lib/route/RoutesHandler.dart 中声明路由处理器
import '../pages/user/Login.dart';
// ...
// 登陆页面
var loginHandler = new Handler(
handlerFunc: (BuildContext context, Map<String, List<Object>> params) {
return UserLogin();
}
);
在 lib/route/Routes.dart 中声明路由
router.define('/login', handler: loginHandler);
声明 lib/provider/UserProvider
import 'package:flutter/material.dart';
import 'dart:convert';
class UserProvider with ChangeNotifier {
bool _isLogin = false;
Map _user = {};
bool get isLogin => _isLogin;
Map get user => _user;
/// 登录
doLogin(data) async {
if (data != null) {
_isLogin = true;
_user = json.decode(data);
}
// 通过 UI 更新
notifyListeners();
}
/// 退出
doLogout() async {
_isLogin = false;
_user = {};
// 通过 UI 更新
notifyListeners();
}
}
使用 UserProvider
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: CurrentIndexProvider()),
ChangeNotifierProvider.value(value: UserProvider()), // 添加 UserProvider
],
child: MyApp(),
),
判断登录状态
// 获取 userProvider
final UserProvider userProvider = Provider.of<UserProvider>(context);
/// ...
if (userProvider.isLogin == false) {
print('跳转到登陆页');
G.router.navigateTo(context, '/login');
return;
} else {
// 获取用户信息
// Map userInfo = await G.api.user.userInfo();
// userProvider.setUserInfo(userInfo);
}
Future<dynamic> login({ String phone, String password }) async {
// 表单数据
FormData formData = FormData.fromMap({"phone": phone, "password": password});
// 发送 post 请求
Response res = await _dio.post('/front/user/login', data: formData);
if (res.data['content'] != null && res.data['content'] != "") {
return res.data['content'];
} else {
return false;
}
}
APP 中的提示主要有三种:toast、snackbar 以及 dialog
dialog 一般用于较为正式的场景,前面我们已经介绍过,例如:
AlertDialog(
title: Text("提示"),
content: Text("确定要删除吗"),
actions: [
TextButton(
child: Text("取消"),
onPressed: () => Navigator.pop(context, "cancel")
),
TextButton(
child: Text("确定"),
onPressed: () => Navigator.pop(context, "yes")
),
],
);
toast 通常用于提示用户一些不那么重要的信息, 会弹出并显示文字一段时间。时间一到就会消失。相较于snackbar和dialog, 对屏幕的入侵较少。Flutter 中最常用的 toast 组件是 fluttertoast
fluttertoast 的用法:
安装
https://pub.dev/packages/fluttertoast
引入
import 'package:fluttertoast/fluttertoast.dart';
使用
ElevatedButton(
child: Text("弹出toast"),
onPressed: () {
Fluttertoast.showToast(
msg: "弹出消息内容",
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.BOTTOM,
timeInSecForIosWeb: 1 ,
backgroundColor: Colors.black 45 ,
textColor: Colors.white,
fontSize: 16. 0
);
},
)
Fluttertoast.showToast 常用的属性
请求时,添加 access_token 到请求头
// lib/api/InitDio.dart
dio.interceptors.add(
InterceptorsWrapper(
// 请求拦截
onRequest: (RequestOptions options) {
print('请求之前进行拦截');
/// 将 access_token 封装到 header 中
var user = G.getCurrentContext().read<UserProvider>().user;
if (user.isNotEmpty) {
print(user['access_token']);
options.headers['Authorization'] = user['access_token'];
}
}
/// ...
由于 lib/api/InitDio.dart 中,不存在构建上下文(BuildContext)。因此我们需要获取。
如何在 Flutter 的任意位置获取构建上下文呢?
我们可以声明路由的全局唯一键(navigatorKey),然后,通过 navigatorKey 来获取构建上下文:
在 lib/utils/Global.dart 中声明全局 key
class G {
/// 导航唯一key
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey();
/// 获取构建上下文
static BuildContext getCurrentContext() =>
navigatorKey.currentContext;
/// ...
}
在 MaterialApp 中注册 navigatorKey
MaterialApp(
navigatorKey: G.navigatorKey,
/// ...
}
然后,调用 G.getCurrentContext() 就可以取到上下文
调用用户信息接口
此时(用户已登录),我们就可以通过 header 中绑定的 Authorization(即 access_token)来获取用户信息了。
/// 获取用户基本信息
Future<dynamic> userInfo() async {
Response res = await _dio.get('/front/user/getInfo');
if (res.data != null) {
return res.data['content'];
} else {
return null;
}
}
调用终端的摄像头,或者在相册中选取图片。我们需要使用第三方插件 image_picker
详情查看:https://pub.dev/packages/image_picker
安装
在 pubspec.yaml 中,配置合适的版本
配置权限
编辑 android/app/arc/main/AndroidManifest.xml
<!-- 调用摄像头权限 - ->
<uses-permission android:name="android.permission.CAMERA" />
<!-- 获取 SD 卡内容(访问相册)权限 - ->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"/>
<application
android:requestLegacyExternalStorage="true" <!-- Android API 29 + 添加这一行
- ->
其他配置项 >
import 'package:image_picker/image_picker.dart';
import 'dart:io';
/// ...
final picker = ImagePicker();
File _image;
/// ...
// 拍照获取图片
Future _takePhoto() async {
final pickedFile = await picker.getImage(source: ImageSource.camera);
setState(() {
if (pickedFile != null) {
_image = File(pickedFile.path);
} else {
print('No Image');
}
});
}
// 在相册中选取一张图片
Future _openGallery() async {
final pickedFile = await picker.getImage(source: ImageSource.gallery);
setState(() {
if (pickedFile != null) {
_image = File(pickedFile.path);
} else {
print('No Image');
}
});
}
onTap: () {
showModalBottomSheet(
context: context,
builder: (BuildContext context) {
return renderBottomSheet(context);
}
);
},
/// ...
Widget renderBottomSheet(BuildContext context) {
return Container(
height: 160 ,
child: Column(
children: [
InkWell(
onTap: () {
_takePhoto();
G.router.pop(context);
},
child: Container(
child: Text('拍照'),
height: 50 ,
alignment: Alignment.center,
)
),
InkWell(
onTap: () {
_openGallery();
G.router.pop(context);
},
child: Container(
child: Text('从相册中选取'),
height: 50 ,
alignment: Alignment.center,
)
),
Container(
color: Colors.grey[ 200 ],
height: 10 ,
),
InkWell(
onTap: () {
G.router.pop(context);
},
child: Container(
child: Text('取消'),
height: 50 ,
alignment: Alignment.center,
)
)
],
)
);
}
基本逻辑:默认展示用户昵称,点击用户昵称时,显示为可编辑的表单,而且表单中保留用户的昵称
bool _isEditable = false;
String _initText = "";
TextEditingController _editingController;
void initState() {
super.initState();
// _initText = userProvider.userInfo['userName'];
_initText = G.getCurrentContext().watch<UserProvider>().userInfo['userName'];
_editingController = TextEditingController(text: _initText);
}
/// ...
Widget renderUserName() {
if (_isEditable) {
// 展示表单
return Container(
width: 60 ,
child: TextField(
controller: _editingController,
autofocus: true,
onSubmitted: (value) {
setState(() {
_initText = value;
print(_initText);
_isEditable = false;
});
},
)
);
} else {
// 展示文本
return InkWell(
onTap: () {
setState(() {
_isEditable = true;
});
},
child: Text(
_initText,
style: TextStyle(
fontSize: 18
)
)
);
}
}
/// 创建订单接口
Future<dynamic> createOrder({ int goodsId }) async {
Response res = await _dio.post('/front/order/saveOrder', data: {"goodsId":
goodsId});
return res.data['content'];
}
/// 发起支付接口
Future<dynamic> createPay({
String orderNo,
int channel,
String returnUrl = 'http://edufront.lagou.com'
}) async {
Map payData = {
"goodsOrderNo": orderNo,
"channel": channel == 1? 'weChat' : 'aliPay',
"returnUrl": returnUrl
};
Response res = await _dio.post('/front/pay/saveOrder', data: payData);
if (res.data != null) {
return res.data['content'];
} else {
return false;
}
}
如果发起支付成功,上述接口会返回支付链接,现在,只需要跳转到支付链接,进行支付就可以了。
在 APP 中,跳转到指定 URL 地址,需要使用第三方插件 url_launcher。
详情查看:https://pub.dev/packages/url_launcher
import 'package:url_launcher/url_launcher.dart';
/// ...
/// 跳转到指定链接
void _launchURL(_url) async =>
await canLaunch(_url)
?
await launch(_url)
:
throw '不能跳转到 $_url';
/// 确定支付
doPay() {
// 发起支付
G.api.order.createPay(orderNo: orderNo, channel: payment).then((value) {
if (value != false) {
_launchURL(value['payUrl']); /// 跳转到支付链接
} else {
print('支付失败');
}
});
}
如果是真机调试的话,发起支付后,会调转到支付宝 APP 中
学习页面展示当前用户已经购买的课程列表
/// 获取购买课程
Future<dynamic> getPurchaseCourse() async {
Response res = await _dio.get('/front/course/getPurchaseCourse');
if (res.data != null) {
return res.data['content'];
} else {
return false;
}
}
Splash 页面就是打开 APP 时,看到的第一个广告页。主要的技术点是倒计时,默认展示广告图片,倒计时时间到了之后,跳转到首页。
import 'package:flutter/material.dart';
import 'dart:async';
import '../../utils/Global.dart';
class Splash extends StatefulWidget {
Splash({Key key}) : super(key: key);
_SplashState createState() => _SplashState();
}
class _SplashState extends State<Splash> {
Timer _timer;
int counter = 3 ;
// 倒计时
countDown() async {
var _duration = Duration(seconds: 1 );
Timer(_duration, () {
/// 等待 1 秒之后,再计时
_timer = Timer.periodic(_duration, (timer) {
counter--;
if (counter == 0 ) {
// 执行跳转
goHome();
} else {
setState(() {
});
}
});
return _timer;
});
}
void goHome() {
_timer.cancel();
G.router.navigateTo(context, '/');
}
void initState() {
super.initState();
countDown(); // 指定倒计时
}
Widget build(BuildContext context) {
return Stack(
alignment: Alignment( 1. 0 , - 1. 0 ),
children: [
ConstrainedBox(
constraints: BoxConstraints.expand(),
child: Image.asset(
"lib/assets/images/splash.jpeg",
fit: BoxFit.fill
)
),
Container(
color: Colors.grey,
margin: EdgeInsets.fromLTRB( 0 , 50 , 10 , 0 ),
padding: EdgeInsets.all( 5 ),
child: TextButton(
onPressed: () {
goHome();
},
child: Text(
"$counter 跳过广告",
style: TextStyle(
color: Colors.white,
fontSize: 14
)
),
)
),
]
);
}
void dispose() {
super.dispose();
}
虽然我们已经完成了项目的基本功能,但仍有很多细节,需要优化。
试想这样一种场景:异步请求接口,在数据还未请求回来的时候,UI 就已经更新了。此时,UI 会因为拿不到数据而报错。
而异步 UI 更新,就是为了解决这一问题的。其基本思路是: 先等待数据请求,后刷新 UI FutureBuilder 是对 Future 的封装。我们先来看看它的构造方法
FutureBuilder({
Key key,
Future<dynamic> future,
dynamic initialData,
Widget Function(BuildContext, AsyncSnapshot<dynamic>) builder
})
future 接收Future类型的值,实际上就是我们的异步函数,通常是接口请求函数
initialData 初始数据,在异步请求完成之前使用
builder :是一个回调函数,接收两个参数一个 AsyncWidgetBuilder 类型的值
builder: (
BuildContext context,
AsyncSnapshot<dynamic> snapshot
) {
/// ...
}
AsyncSnapshot (即 snapshot)中封装了三个内容:
connectionState(连接状态 - 一共有四个)
none :当前未连接到任何异步计算。
waiting : 连接成功等待交互
active :正在交互中,可以理解为正在返回数据
done :交互完成,可以理解为数据返回完成。通过 snapshot.data 获取数据
data(实际上就是 future 执行后返回的数据)
error(实际上就是 future 错误时返回的错误信息)
默认情况,我们进行页面跳转时。都会重新刷新页面(包括请求后代数据接口)。但是,有些页面的数据不会频繁变化(或及时性要求不高),此时,我们可以将页面数据 暂时保存起来 ,从能避免页面频繁的刷新。
保持页面状态相当于缓存数据,是一种常规的优化手段。具体实现方案有如下几种
IndexedStack
IndexedStack 的逻辑是,一次加载多有的 Tab 页面,但同时,只展示其中一个。
body: IndexedStack(
index: curIndex,
// children: _listViews,
children: pages.map<Widget>((e) => e['widget']).toList(),
)
如果希望所有 Tab 页面都保持状态,建议使用 IndexedStack
AutomaticKeepAliveClientMixin
// home page
class Home extends StatefulWidget {
Home({Key key}) : super(key: key);
_HomeState createState() => _HomeState();
}
// 1. 使用 AutomaticKeepAliveClientMixin
class _HomeState extends State<Home> with AutomaticKeepAliveClientMixin {
bool get wantKeepAlive => true; // 2. 声明 wantKeepAlive
// 避免 initState 重复调用
void initState() {
super.initState();
print(' 333333 ');
}
Widget build(BuildContext context) {
super.build(context); // 3. 在构造方法中调用父类的 build 方法
}
Tab 中,只保持某些页面的状态(需要修改 Tab 实现)
声明 PageController
PageController _pageController;
初始化 PageController
void initState() {
// 2. 初始化 PageController
_pageController = PageController(
initialPage: G.getCurrentContext().watch<CurrentIndexProvider>
().currentIndex
);
super.initState();
}
修改 Tab 的 body
body: PageView(
controller: _pageController,
children: pages.map<Widget>((e) => e['page']).toList(),
)
跳转到指定页面
onTap: (index) async {
// 4. 跳转到指定页面
setState(() {
_pageController.jumpToPage(index);
});
},
DevTools 是一套 Dart 和 Flutter 性能调试工具。
在开始 DevTools 之前,我们先来介绍一下 Flutter 的运行模式。
Flutter 有四种运行模式:Debug、Release、Profile和test,这四种模式在build的时候是完全独立的。
debug
Debug 模式可以在真机和模拟器上同时运行:会打开所有的断言,包括 debugging 信息。debug 模式适合调试代码,但是不适合做性能分析。
命令 flutter run 就是以这种模式运行的。
release
Release 模式只能在真机上运行,不能在模拟器上运行:会关闭所有断言和 debugging 信息。关闭所有debugger工具。优化了快速启动、快速执行和减小包体积。
命令 flutter run --release 就是以这种模式运行的
profile
Profile 模式只能在真机上运行,不能在模拟器上运行:基本和 Release 模式一致,除了启用了服务扩展和tracing。
命令 flutter run --profile 就是以这种模式运行的
test
headless test 模式只能在桌面上运行:基本和 Debug 模式一致,除了是 headless 的而且你能在桌面运行。
命令 flutter test 就是以这种模式运行的
判断当前运行环境
// 当 App 运行在 Release 环境时,inProduction 为 true
// 当 App 运行在 Debug 和 Profile 环境时,inProduction 为 false
const bool inProduction = const bool.fromEnvironment("dart.vm.product");
目前,DevTools 支持的功能有如下一些:
编辑器中
在 Android Studio 或 VS Code 中,只要你安装了 Flutter 插件,则 DevTools 也已经默认安装了。
命令行中
如果在你的环境变量 PATH 中有 pub, 可以运行:
pub global activate devtools
如果环境变量 PATH 中有 flutter , 可以运行:
flutter pub global activate devtools
命令行中
如果在你的环境变量 PATH 中有 pub, 可以运行:
pub global run devtools
如果环境变量 PATH 中有 flutter , 可以运行:
flutter pub global run devtools
等到应用启动后,可以将应用的调试地址,填写到上述输入框中
启动应用
debug 模式启动
flutter run
profile 模式启动
flutter run - -profile
将上述调试地址,填充的 DevTools 中,然后点击 connect。就可以看到 DevTools 页面了
使用 DevTools
以 profile 方式启动 flutter (flutter run --profile)报错:
Flutter Profile mode is not supported by sdk gphone x86.
原因:模拟器是 x86 的,不支持 profile 方式运行,将模拟器换成 x64 的即可。
首先,我们先来了解一下移动端架构的演进。只有了解了之前的技术架构,才能体会 Flutter 的优势。
没有使用原生组件,在 WebView 中通过 H5 实现所需的界面效果,对前端友好,上手快,成本低。
硬件通信,通过 Bridge 来完成。
使用原生的组件,组件和硬件都是通过 Bridge 与 JS 进行交互(性能受制于 Bridge)
没有使用原生的组件,所有组件都是 Flutter 自己绘制的,平台只提供一张画布。
Fluter 跨端的通信效率也是高出 JSBridge 许许多多。Flutter 通过 Channel 进行通信,其中:
BasicMessageChannel,用于传递字符串和半结构化的信息,是全双工的,可以双向请求数据。
MethodChannel, 用于传递方案调用 ,即 Dart 侧可以调用原生侧的方法并通过 Result 接口 回调结果数据。
EventChannel: 用户数据流的通信 ,即 Dart 侧监听原生侧的实时消息,一旦原生侧产生了数据,立即回调给 Dart 侧
为什么我们说 Channel 的性能高呢。我们来看一下 MethodChannel 调用时的调用栈。
整个流程中都是机器码的传递,而 JNI 的通信又和 JavaVM 内部通信效率一样,整个流程通信的流程相当于原生端的内部通信。因此,其通信效率比 JSBridge 要高
这是一个纯 Dart 实现的 SDK。从上往下包括了两大风格组件库(Material 和 Cupertino)、基础组件库、图形绘制、手势识别、动画等。
这是一个 C++ 实现的 SDK,其中包括了 Skia 引擎(Google开源图形库)、Dart 运行时、文字排版引擎等。在代码调用 dart:ui 库时,调用最终会走到 Engine 层,然后实现真正的绘制逻辑。
Skia 是谷歌出品的开源二维图形库,提供常用的 API,并且可以在多种软硬件平台上运行。谷歌Chrome 浏览器、Chorme OS、Android、火狐浏览器和操作系统,及其他许多产品都使用它作为图形引擎。
和其他跨平台方案不同。Flutter 没有使用原生的 UI 和绘制框架,以此来保证 Flutter 的高性能体验。
嵌入层是操作系统适配层,其中主要负责的工作有:surface 渲染设置,线程的管理,原生插件管理,事件循环的交互等。
嵌入层位于整个框架的最底层,说明 Flutter 的平台相关层非常低,大部分的渲染操作在 Flutter 本身内部完成,各个平台(Android,iOS等)只需要提供一个画布,这就让 Flutter 本身有了很好的跨端一致性。
首先,我们从官网拿到下面这张图。这张图从用户的角度,来解释 Flutter 的渲染过程
从创建到渲染的大体流程是:
当应用启动时 Flutter 会遍历并创建所有的 Widget 形成 Widget Tree,同时与 Widget Tree 相对应,通 过调用 Widget 上的 createElement() 方法创建每个 Element 对象,形成 Element Tree。最后调用Element 的 createRenderObject() 方法创建每个渲染对象,形成一个 Render Tree。
这里我们可以做一个类比:
Widget Tree
第一棵树,是 Widget Tree。程序员写的用来构建页面的组件树。
需要注意的是, Widget 是不可变的(immutable) ,当 Widget 发生变化时,Flutter 会重建 Widget来进行更新。
那为什么将 Widget Tree 设计为 immutable?Flutter 主张 simple is fast ,不用关心数据与节点之间的关系。
相当于是牺牲空间换时间,从而保证性能
Element Tree
Element 就是 Widget 在 UI 树具体位置的一个实例化对象。持久存在于Dart Runtime上下文之中。它承载了构建的上下文数据,是Widget Tree 和 RenderObject Tree 的桥梁。
之所以让它持久地存在于 Dart 上下文中,而不是像 Widget 那样重新构建, 因为 Element Tree 的重新创建和重新渲染的开销会非常大, 所以 Element Tree 到 RenderObject Tree 也有一个 Diff 环节,来计 算最小重绘区域。
需要注意的是,Element 同时持有 Widget 和 RenderObject,但无论是 Widget 还是 Element,其实都不负责最后的渲染,它们只是“发号施令”,真正对配置信息进行渲染的是 RenderObject。
RenderObject Tree
渲染树的任务就是做具体渲染工作。RenderObject 用于应用界面的 布局(Layout) 和 绘制(Paint) ,保存了元素的大小,布局等信息。RenderObject 主要属性和方法如下:
布局过程
Flutter 中的组件在屏幕上绘制渲染之前,需要先进行布局(Layout)操作。其具体可分为两个过程:
1、Constraints Down(从顶部向下传递约束)
父节点给每个子节点传递约束,这些约束是每个子节点在布局阶段必须要遵守的规则。
常见的约束包括,规定子节点最大最小宽度或者子节点最大最小的高度。这种约束会向下延伸,子组件也会产生约束传递给自己的孩子,一直到叶子结点。
2、Layout Up(从底部向上传递布局信息)
子节点接受到来自父节点的约束后,会依据这些约束,产生自己的布局信息;
例如:父节点规定我的最小宽度是 500 的单位像素,子节点按照这个规则,可能定义自己的宽度为 500 个像素,或者大于 500 像素的任何一个值。
确定好自己的布局信息之后,将这些信息告诉父节点。父节点也会继续此操作向上传递,一直到最顶部。
Flutter 中有两种主要的布局协议:Box 盒子协议和 Sliver 滑动协议。RenderObject 作为一个抽象类。每个节点需要实现它才能进行实际渲染。扩展 RenderOject 的两个最重要的类是 RenderBox 和RenderSliver。这两个类分别是应用了 Box 协议和 Sliver 协议。绘制过程。
RenderObject 可以通过 paint() 方法来完成具体绘制逻辑,流程和布局流程相似,子类可以实现 paint()
void paint(PaintingContext context, Offset offset) { }
通过 context.canvas 可以取到 Canvas 对象,接下来就可以调用 Canvas API 来实现具体的绘制逻 辑。
如果节点有子节点,它除了完成自身绘制逻辑之外,还要通过 paintChild() 方法来调用子节点的绘制方法。
如此递归完成整个节点树的绘制,最终调用栈为: paint() > paintChild() > paint() … 。
另外,Flutter 使用 Composited Layer 来对 RenderObject 的绘制进行组织,通常一个 Composited Layer 对应一棵 RenderObject 子树,Composited Layer 的 Display List 记录了这棵 RenderObject 子树的绘制指令。
使用三棵树的目的是尽可能复用 Element。复用 Element 对性能非常重要,因为 Element 拥有两份关键数据:StatefulWidget 的状态对象及底层的 RenderObject。当应用的结构很简单时,或许体现不出这种优势,一旦应用复杂起来,构成页面的元素越来越多,重新创建 3 棵树的代价是很高的,所以需要最小化更新操作。
Element Tree 的定位,有点像 Web 端的 Virtual DOM
在开始 Flutter 的渲染机制之前,我们先介绍一下屏幕绘制的原理
我们知道显示器以固定的频率刷新,比如 iPhone的 60Hz。当一帧图像绘制完毕后,准备绘制下一帧时,显示器会发出一个垂直同步信号(VSync)。一般地来说,计算机中,CPU、GPU 和显示器以一种特定的方式协作:
CPU 将计算好的显示内容提交给 GPU,GPU 渲染后放入帧缓冲区,然后视频控制器按照 VSync 信号从帧缓冲区取帧数据,传递给显示器显示。屏幕上的每一帧的绘制过程,实际上是 Engine 通过接收的VSync 信号不断地触发帧的绘制。
Flutter 只关心向 GPU 提供视图数据,GPU 的 VSync信号同步到 UI 线程,UI 线程使用 Dart 来构建抽象的视图结构,这份视图结构在 GPU 线程进行图层合成,视图数据提供给 Skia 引擎渲染为 GPU 数据,这些数据通过 OpenGL 或 Vulkan提供给 GPU。
Flutter 的渲染流水线也包括两个线程 —— UI 线程 和 GPU 线程 。
UI 线程主要负责的是根据 UI 界面的描述生成 UI 界面的绘制指令,而 GPU 线程负责光栅化和合成。
在 Flutter 框架中存在着一个渲染流水线(Rendering pipline)。这个渲染流水线是由垂直同步信号(Vsync)驱动的,而 Vsync 信号是由系统提供的,如果你的 Flutter 是运行在 Android 上的话,Flutter 会向 Android 系统的 Choreographer 注册并接收 VSync 信号,GPU 硬件产生 VSync 信号以后,系统便会触发回调,并驱动 UI 线程进行渲染工作。
在了解了上述基础知识后, 我们终于可以呈现一个整体的绘制流水线了 :
以上,就是 Flutter 渲染的整体过程。
接下来,我们以 Flutter 的启动过程为例,来分析一下 Flutter 的源码。
上述图比较复杂,你可以先简单了解下,等下我们会详细拆分来讲解。我们先来看下这几个关键函数的作用。
首先我们从 runApp() 开始
void main() {
runApp(MyApp());
}
runApp() 函数声明在 widgets/binding.dart 中
void runApp(Widget app) {
WidgetsFlutterBinding.ensureInitialized() /// 单例模式初始化
..scheduleAttachRootWidget(app) /// 将 app 添加到根组件
..scheduleWarmUpFrame(); /// 调度热身帧
}
ensureInitialized 实例化过程中,实现了很多绑定
class WidgetsFlutterBinding extends BindingBase
with
GestureBinding, /// 手势绑定
ServicesBinding, /// 服务绑定
SchedulerBinding, /// 调度绑定
PaintingBinding, /// 绘制绑定
SemanticsBinding, /// 语义绑定(辅助功能)
RendererBinding, /// 渲染绑定
WidgetsBinding /// 组件绑定
{
static WidgetsBinding ensureInitialized() { /// 单例模式实例化
if (WidgetsBinding.instance == null)
WidgetsFlutterBinding();
return WidgetsBinding.instance;
}
}
scheduleAttachRootWidget, 创建根 widget ,并且从根 widget 向子节点递归创建元素Element,对子节点为 RenderObjectWidget 的小部件创建 RenderObject 树节点,从而创建出View 的渲染树,这里源代码中使用 Timer.run 事件任务的方式来运行,目的是避免影响到微任务的执行。
void scheduleAttachRootWidget(Widget rootWidget) {
Timer.run(() {
attachRootWidget(rootWidget);
});
}
attachRootWidget 与 scheduleAttachRootWidget 作用一致,首先是创建根节点,然后调用attachToRenderTree 循环创建子节点。
void attachRootWidget(Widget rootWidget) {
_readyToProduceFrames = true;
_renderViewElement = RenderObjectToWidgetAdapter<RenderBox>(
container: renderView,
debugShortDescription: '[root]',
child: rootWidget,
).attachToRenderTree(buildOwner, renderViewElement as
RenderObjectToWidgetElement<RenderBox>);
}
attachToRenderTree ,该方法中有两个比较关键的调用,我只举例出核心代码部分,这里会先执行 buildScope ,但是在 buildScope 中会优先调用第二个参数(回调函数,也就是element.mount ),而 mount 就会循环创建子节点,并在创建的过程中将需要更新的数据标记为dirty。
owner.buildScope(element, () {
element.mount(null, null);
});
buildScope ,如果首次渲染 dirty 是空的列表,因此首次渲染在该函数中是没有任何执行流程的,该函数的核心还是在第二次渲染或者 setState 后,有标记 dirty 的 Element 时才会起作用,该函数的目的也是循环 dirty 数组,如果 Element 有 child 则会递归判断子元素,并进行子元素的 build ,创建新的 Element 或者修改 Element 或者创建 RenderObject。
updateChild ,该方法非常重要,所有子节点的处理都是经过该函数,在该函数中 Flutter 会处理Element 与 RenderObject 的转化逻辑,通过 Element 树的中间状态来减少对 RenderObject 树的影响,从而提升性能。具体这个函数的代码逻辑,我们拆解来分析。该函数的输入参数,包括三个参数:
在了解参数后,接下来看下核心逻辑,首先判断是否有新的 Widget 节点。
if (newWidget == null) {
if (child != null)
deactivateChild(child);
return null;
}
如果不存在,则将当前节点的 Element 直接销毁,
如果 Widget 存在该节点,并且 Element 中也存在该节点,那么就首先判断两个节点是否一致,如果一致只是位置不同,则更新位置即可。其他情况下判断是否可更新子节点,如果可以则更新,如果不可以则销毁原来的 Element 子节点,并重新创建一个。
if (hasSameSuperclass && child.widget == newWidget) {
if (child.slot != newSlot)
updateSlotForChild(child, newSlot);
newChild = child;
} else if (hasSameSuperclass && Widget.canUpdate(child.widget, newWidget)) {
if (child.slot != newSlot)
updateSlotForChild(child, newSlot);
child.update(newWidget); // 根据不同的节点类型,调用不同的 Update
assert(child.widget == newWidget);
assert(() {
child.owner._debugElementWasRebuilt(child);
return true;
}());
newChild = child;
} else {
deactivateChild(child);
assert(child._parent == null);
newChild = inflateWidget(newWidget, newSlot);
}
上面代码的第 8 行非常关键,在 child.update 函数逻辑里面,会根据当前节点的类型,调用不同的 update ,可参考上图中的 update 下的流程,每一个流程也都会递归调用子节点,并循环返回到 updateChild 中。有以下三个核心的函数会重新进入 updateChild 流程中,分别是performRebuild、inflateWidget 和 markNeedsBuild,接下来我们看下这三个函数具体的作用。
performRebuild 是非常关键的一个代码,这部分就是我们在组件中写的 build 逻辑函数,StatelessWidget 和 StatefulWidget 的 build 函数都是在此执行,执行完成后将作为该节点的子节点,并进入 updateChild 递归函数中。
inflateWidget 创建一个新的节点,在创建完成后会根据当前 Element 类型,判断是RenderObjectElement 或者 ComponentElement 。根据两者类型的不同,调用不同 mount, 挂载到当前节点上,在两种类型的 mount 中又会循环子节点,调用 updateChild 重新进入子节点更新流程。这里还有一点,当为 RenderObjectElement 的时候会去创建 RenderObject 。
markNeedsBuild ,标记为 dirty ,并且调用 scheduleBuildFor 等待下一次 buildScope 操作。
当我们首次加载一个页面组件的时候,由于所有节点都是不存在的,因此这时候的流程大部分情况下都是创建新的节点,如下图:
runApp 到 RenderObjectToWidgetElement(mount) 逻辑都是一样的,在 _rebuild 中会调用updateChild 更新节点,由于节点是不存在的,因此这时候就调用 inflateWidget 来创建 Element。当 Element 为 Component 时,会调用 Component.mount ,在 Component.mount 中会创建Element 并挂载到当前节点上,其次会调用 _firstBuild 进行子组件的 build ,build 完成后则将 build 好的组件作为子组件,进入 updateChild 的子组件更新。当 Element 为 RenderObjectElement 时,则会调用 RenderObjectElement.mount,在RenderObjectElement.mount 中会创建 RenderObjectElement 并且调用 createRenderObject 创建RenderObject,并将该 RenderObject 和 RenderObjectElement 分别挂载到当前节点的 Element 树和RenderObject 树,最后同样会调用 updateChild 来递归创建子节点。以上就是首次 build 的逻辑,单独来看还是非常清晰的,接下来我们看下 setState 的逻辑。
setState
首先,我们查看 StatefulWidget 中的 setState
void setState(VoidCallback fn) {
/// ...
_element.markNeedsBuild(); // 标记需要构建的 element
}
然后,我们来看一下 markNeedsBuild
void markNeedsBuild() {
/// ...
if (dirty)
return;
_dirty = true;
owner.scheduleBuildFor(this);
}
Widget 对应的 element 将自身标记为 dirty 状态,并调用 owner.scheduleBuildFor(this); 通知buildOwner 进行处理。
void scheduleBuildFor(Element element) {
...
if (!_scheduledFlushDirtyElements && onBuildScheduled != null) {
_scheduledFlushDirtyElements = true;
onBuildScheduled(); // 这是一个callback,调用的方法是下面的
_handleBuildScheduled
}
_dirtyElements.add(element); // 把当前 element 添加到 _dirtyElements 数组里面,后
面重新build会遍历这个数组
element._inDirtyList = true;
}
此时 buildOwner 会将所有 dirty 的 Element 添加到 _dirtyElements 当中经过 Framework 一连串的调用后,最终调用 scheduleFrame 来通知 Engine 需要更新 UI,Engine 就会在下个 vSync 到达的时候通过调用 _drawFrame 来通知 Framework,然后 Framework 就会通过 BuildOwner 进行Build 和 PipelineOwner 进行 Layout,Paint,最后把生成 Layer,组合成 Scene 提交给 Engine。
void _drawFrame() { // Engine 回调 Framework 入口
_invoke(window.onDrawFrame, window._onDrawFrameZone);
}
void initInstances() {
super.initInstances();
_instance = this;
ui.window.onBeginFrame = _handleBeginFrame;
// 初始化的时候把 onDrawFrame 设置为 _handleDrawFrame
ui.window.onDrawFrame = _handleDrawFrame;
SystemChannels.lifecycle.setMessageHandler(_handleLifecycleMessage);
}
void _handleDrawFrame() {
if (_ignoreNextEngineDrawFrame) {
_ignoreNextEngineDrawFrame = false;
return;
}
handleDrawFrame();
}
void handleDrawFrame() {
_schedulerPhase = SchedulerPhase.persistentCallbacks; // 记录当前更新UI的状态
for (FrameCallback callback in _persistentCallbacks)
_invokeFrameCallback(callback, _currentFrameTimeStamp);
}
}
void initInstances() {
/// ....
addPersistentFrameCallback(_handlePersistentFrameCallback);
}
void _handlePersistentFrameCallback(Duration timeStamp) {
drawFrame();
}
void drawFrame() {
/// ...
if (renderViewElement != null)
buildOwner.buildScope(renderViewElement); // 先重新build widget
super.drawFrame();
buildOwner.finalizeTree();
}
核心方法 buildScope
```dart
void buildScope(Element context, [VoidCallback callback]){
/// ...
}
需要传入一个 Element 的参数,这个方法通过字面意思应该理解就是对这个 Element 以下范围 rebuild
void buildScope(Element context, [VoidCallback callback]) {
/// ...
try {
/// ...
_dirtyElements.sort(Element._sort); // 1 .排序
/// ...
int dirtyCount = _dirtyElements.length;
int index = 0 ;
while (index < dirtyCount) {
try {
_dirtyElements[index].rebuild(); // 2 .遍历 rebuild
} catch (e, stack) {}
index += 1 ;
}
} finally {
for (Element element in _dirtyElements) {
element._inDirtyList = false;
}
_dirtyElements.clear(); // 3 .清空
/// ...
}
}
第 1 步:按照 Element 的深度从小到大,对 _dirtyElements 进行排序
由于父 Widget 的 build 方法必然会触发子 Widget 的 build,如果先 build 了子 Widget,后面再build 父Widget 时,子 Widget 又要被 build 一次。所以这样排序之后,可以避免子 Widget 的重复 build。
第 2 步:遍历执行 _dirtyElements 当中 element 的 rebuild 方法
值得一提的是,遍历执行的过程中,也有可能会有新的 element 被加入到 _dirtyElements 集合中,此时会根据 dirtyElements 集合的长度判断是否有新的元素进来了,如果有,就重新排序。element 的 rebuild 方法最终会调用 performRebuild(),而 performRebuild() 不同的Element 有不同的实现。
第 3 步:遍历结束之后,清空 dirtyElements 集合
因此 setState() 的主要工作是记录所有的脏元素,添加到 BuildOwner 对象的 _dirtyElements 中,然后调用scheduleFrame 来注册 Vsync 回调。 当下一次 Vsync 信号到来时,会执行 handleBeginFrame()和handleDrawFrame() 来更新 UI。至此,关于 setState 的执行逻辑我们也分析完了。
源码分析是一个与时俱进的过程,不可能在短时间内获得显著的提高。但是,读源码,是每个工程师的必修课。通过分析源码,不但可以理解 Flutter 的运行机制。也可以提高 Dart 编码的水平,同时,还会理解优秀产品的设计思路。一旦有所得,便有曲径通幽之感。在此,预祝各位一路顺风。