一、项目简介
本节的学习目标是通过 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 目录,用来存放页面
- 首页(选课)- Home.dart
- 学习 - Study.dart
- 我 - Mine.dart
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);
@override
_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;
@override
void initState() {
super.initState();
currentIndex = 0 ;
}
@override
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;
});
}
}
@override
void dispose() {
super.dispose();
}
}
Fluro 路由
首页完成后,我们需要点击课程列表,跳转到课程详情页。并且,需要动态传递参数,来动态获取详情页的内容。此时,我们需要声明详情页的路由。之前我们学过 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<String, List<Object>> 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<T>(context)
取消监听
取消监听,不能在 [StatelessWidget.build] 或 [State.build] 中使用;换句话说,它可以在上述两个方法之外的所有方法中使用。监听值发生变化时,不会重建组件。
Provider.of<T>(context, listen: false) // 语法糖是: context.read<T>
(context)
访问数据
Provider.of<CurrentIndexProvider>(context).currentIndex;
访问方法
// 取消监听
Provider.of<CurrentIndexProvider>(context, listen:
false).changeIndex(index);
五、数据接口
接口简介
本项目所用的数据接口是拉勾教育的模拟接口,是通过 Java 开发的。接口格式与线上环境一致,只是数据为模拟数据(仅供学习调试使用)。接口地址为:
http://eduboss.lagou.com/front/doc.html#/home
本项目中,用到的接口有:
- 获取广告列表 首页顶部轮播的位置标记 spaceKeys 为 999
- 课程列表
- 课程详情
- 用户登陆
- 用户退出
- 用户详情
- 刷新 Token
- 用户已购课程
- 创建订单
- 发起支付
想要使用接口的话,我们需要借助 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。添加如下代码:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
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 = [];
@override
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 = [];
@override
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 上打开。
终端屏幕尺寸大小不一。如何进行屏幕适配呢?
适配原理:
设计尺寸(初始化指定 - 一般是设计师出图的尺寸,是可以预先知道的)
- designWidth: 750 px
- designHeight: 1334 px
终端尺寸(动态获取)
- 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<String, List<Object>> 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, @required this.id, @required this.title}) : super(key:
key);
final int id;
final String title;
@override
_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: "<h 1 >标题 1 </h 1 >"); /// 在 Flutter 中展示 HTML 代码
课程章节
课程章节的操作方式与详情类型
声明接口
在 lib/api/CourseAPI.dart 中声明接口
Future<dynamic> courseSection({
/// 课程id
@required 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;
@override
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 页面
Splash 页面就是打开 APP 时,看到的第一个广告页。主要的技术点是倒计时,默认展示广告图片,倒计时时间到了之后,跳转到首页。
import 'package:flutter/material.dart';
import 'dart:async';
import '../../utils/Global.dart';
class Splash extends StatefulWidget {
Splash({Key key}) : super(key: key);
@override
_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, '/');
}
@override
void initState() {
super.initState();
countDown(); // 指定倒计时
}
@override
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
)
),
)
),
]
);
}
@override
void dispose() {
super.dispose();
}
十五、 项目优化
虽然我们已经完成了项目的基本功能,但仍有很多细节,需要优化。
异步 UI 更新
试想这样一种场景:异步请求接口,在数据还未请求回来的时候,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);
@override
_HomeState createState() => _HomeState();
}
// 1. 使用 AutomaticKeepAliveClientMixin
class _HomeState extends State<Home> with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true; // 2. 声明 wantKeepAlive
// 避免 initState 重复调用
@override
void initState() {
super.initState();
print(' 333333 ');
}
@override
Widget build(BuildContext context) {
super.build(context); // 3. 在构造方法中调用父类的 build 方法
}
Tab 中,只保持某些页面的状态(需要修改 Tab 实现)
声明 PageController
PageController _pageController;
初始化 PageController
@override
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
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 支持的功能有如下一些:
- 检查和分析应用程序的UI布局和状态。
- 诊断应用的UI 性能问题。
- 检测和分析应用程序的CPU使用情况。
- 分析应用程序的网络使用情况。
- Flutter或Dart应用程序的源代码级调试。
- 调试Flutter或Dart应用程序的内存使用情况和分析内存问题。
- 查看运行的Flutter或Dart应用程序的一般日志和诊断信息。
安装 DevTools
编辑器中
在 Android Studio 或 VS Code 中,只要你安装了 Flutter 插件,则 DevTools 也已经默认安装了。
命令行中
如果在你的环境变量 PATH 中有 pub, 可以运行:
pub global activate devtools
如果环境变量 PATH 中有 flutter , 可以运行:
flutter pub global activate devtools
启动 DevTools
VS Code 中
命令行中
如果在你的环境变量 PATH 中有 pub, 可以运行:
pub global run devtools
如果环境变量 PATH 中有 flutter , 可以运行:
flutter pub global run devtools
等到应用启动后,可以将应用的调试地址,填写到上述输入框中
启动应用
debug 模式启动
flutter run
profile 模式启动
flutter run - -profile
将上述调试地址,填充的 DevTools 中,然后点击 connect。就可以看到 DevTools 页面了
使用 DevTools
- Flutter Inspector -这是一款用于可视化和浏览 Flutter Widget 树的工具。
- Performance -性能分析
- CPU Profiler -CPU 分析器,可以通过此视图记录应用的运行会话,并查看 CPU 在哪些方法中耗费了大量时间,然后就可以决定应该在哪里进行优化。
- Memory - 查看应用在特定时刻的内存使用情况
- Debugger - 调试器
- Network - 网络请求调试,例如:接口调试,HTTP 分析等
- Logging - 查看日志,支持关键词搜索。日志内容包括:
- Dart 运行时的垃圾回收事件。
- Flutter 框架事件,比如创建帧的事件。
- 应用的 stdout 和 stderr 输出。
- 应用的自定义日志事件。
- pp Size - App 打包后,可以对 APP 的大小进行分析
以 profile 方式启动 flutter (flutter run --profile)报错:
Flutter Profile mode is not supported by sdk gphone x86.
原因:模拟器是 x86 的,不支持 profile 方式运行,将模拟器换成 x64 的即可。
十八、架构原理
系统架构
首先,我们先来了解一下移动端架构的演进。只有了解了之前的技术架构,才能体会 Flutter 的优势。
- Native APP
- Android
- iOS
- Web APP
- SPA
- PWA
- HyBird APP
- WebView(Cordova | Ionic | 微信小程序)
- JSBridge(React Native | Weex)
- 自绘制(Flutter)
原生架构
WebView 架构
没有使用原生组件,在 WebView 中通过 H5 实现所需的界面效果,对前端友好,上手快,成本低。
硬件通信,通过 Bridge 来完成。
JSBridge 架构
使用原生的组件,组件和硬件都是通过 Bridge 与 JS 进行交互(性能受制于 Bridge)
Flutter 架构
没有使用原生的组件,所有组件都是 Flutter 自己绘制的,平台只提供一张画布。
通信机制
Fluter 跨端的通信效率也是高出 JSBridge 许许多多。Flutter 通过 Channel 进行通信,其中:
- BasicMessageChannel,用于传递字符串和半结构化的信息,是全双工的,可以双向请求数据。
- MethodChannel, 用于传递方案调用 ,即 Dart 侧可以调用原生侧的方法并通过 Result 接口 回调结果数据。
- EventChannel: 用户数据流的通信 ,即 Dart 侧监听原生侧的实时消息,一旦原生侧产生了数据,立即回调给 Dart 侧
为什么我们说 Channel 的性能高呢。我们来看一下 MethodChannel 调用时的调用栈。
整个流程中都是机器码的传递,而 JNI 的通信又和 JavaVM 内部通信效率一样,整个流程通信的流程相当于原生端的内部通信。因此,其通信效率比 JSBridge 要高
具体来说,Flutter 的系统架构共分三层,如下图:
Flutter Framework(框架层)
这是一个纯 Dart 实现的 SDK。从上往下包括了两大风格组件库(Material 和 Cupertino)、基础组件库、图形绘制、手势识别、动画等。
- Widget - 在 Flutter 中,可以把一切都看作一个组件,组件式的构建 UI。
- Rendering - Rendering 是渲染库,在 Flutter 中,界面的渲染主要包括三个阶段:
- 布局(Layout)
- 绘制(Painting)
- 合成(Composite)
- Animation(动画) - Animation 是一个动画相关类,可以通过这个类创建一些基础的动画。
- Painting(绘制)- Painting 封装了来自 Engine 层的绘制接口
- Gesture(手势)- 处理手势动作和手势相关交互
- Foundation(基础) - 底层框架,定义底层工具类和方法,提供其他层使用
Flutter Engine(引擎层)
这是一个 C++ 实现的 SDK,其中包括了 Skia 引擎(Google开源图形库)、Dart 运行时、文字排版引擎等。在代码调用 dart:ui 库时,调用最终会走到 Engine 层,然后实现真正的绘制逻辑。
Skia 是谷歌出品的开源二维图形库,提供常用的 API,并且可以在多种软硬件平台上运行。谷歌Chrome 浏览器、Chorme OS、Android、火狐浏览器和操作系统,及其他许多产品都使用它作为图形引擎。
和其他跨平台方案不同。Flutter 没有使用原生的 UI 和绘制框架,以此来保证 Flutter 的高性能体验。
Embedder(嵌入层)
嵌入层是操作系统适配层,其中主要负责的工作有:surface 渲染设置,线程的管理,原生插件管理,事件循环的交互等。
嵌入层位于整个框架的最底层,说明 Flutter 的平台相关层非常低,大部分的渲染操作在 Flutter 本身内部完成,各个平台(Android,iOS等)只需要提供一个画布,这就让 Flutter 本身有了很好的跨端一致性。
渲染过程
首先,我们从官网拿到下面这张图。这张图从用户的角度,来解释 Flutter 的渲染过程
另外,从源码的角度,对上述过程进行解剖,会得到下图
三棵树
从创建到渲染的大体流程是:
当应用启动时 Flutter 会遍历并创建所有的 Widget 形成 Widget Tree,同时与 Widget Tree 相对应,通 过调用 Widget 上的 createElement() 方法创建每个 Element 对象,形成 Element Tree。最后调用Element 的 createRenderObject() 方法创建每个渲染对象,形成一个 Render Tree。
这里我们可以做一个类比:
- Widget Tree 是设计原型(设计师)
- Element Tree 是产品原型(产品经理)
- RenderObject 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 主要属性和方法如下:
- constraints 对象,从其父级传递给它的约束
- parentData 对象,其父对象附加有用的信息。
- performLayout 方法,计算此渲染对象的布局。
- paint 方法,绘制该组件及其子组件。
布局过程
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 线程进行渲染工作。
- 动画(Animate)阶段:因为动画会随每个Vsync信号的到来而改变状态(State),所以动画阶段是流水线的第一个阶段。
- 构建(Build)需要被重新构建的 Widget 会在此时被重新构建。也就是我们熟悉的StatelessWidget.build() 或者 State.build() 被调用的时候。
- 布局(Layout)阶段,这时会确定各个显示元素的位置,尺寸。此时是RenderObject.performLayout() 被调用的时候。
- 绘制(Paint)阶段,此时是 RenderObject.paint() 被调用的时候。
渲染流水线
在了解了上述基础知识后, 我们终于可以呈现一个整体的绘制流水线了 :
以上,就是 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 树的影响,从而提升性能。具体这个函数的代码逻辑,我们拆解来分析。该函数的输入参数,包括三个参数:
- Element child - child 为当前节点的 Element 信息
- Widget newWidget - newWidget 为 Widget 树的新节点
- dynamic newSlot - newSlot 为节点的新位置
在了解参数后,接下来看下核心逻辑,首先判断是否有新的 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 操作。
首次 Build
当我们首次加载一个页面组件的时候,由于所有节点都是不存在的,因此这时候的流程大部分情况下都是创建新的节点,如下图:
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 编码的水平,同时,还会理解优秀产品的设计思路。一旦有所得,便有曲径通幽之感。在此,预祝各位一路顺风。