第一个Flutter程序
以前没有真正学过Android或iOS客户端开发,只使用React开发过H5页面,但是H5页面的性能远比不上Native,即使使用React Native也达不到60 FPS。
刚好Flutter可以实现一套代码在Android与iOS上运行,性能是最接近Native。于是参考微信的布局,使用Flutter实现一个Demo:主屏4个页面、一个子页面、网络请求。
1. 创建Flutter项目
参考Flutter.cn安装环境:Flutter、Android Studio,再参考“编写第一个Flutter应用”创建项目。
2. 目录结构
参考:flutter_architecture_samples 与 Flutter-Movie。对小、中型项目,我更倾向于Flutter-Movie这样的目录结构,没有必要像大型项目那样先按领域划分目录。
本样例的目录结构,见 flutter_demo_app:
├─android
├─ios
├─lib
│ ├─app.dart
│ ├─main.dart
│ ├─constants
│ │ └─base_api
│ ├─globalbasestate # Redux跨页面数据
│ ├─models # 业务模型类型定义与基本操作
│ │ └─base_api # 来源于后端的业务模型
│ ├─pages
│ │ ├─contacts
│ │ │ ├─model.dart # 页面内部的Redux相关的数据
│ │ │ └─...
│ │ ├─discover
│ │ ├─home
│ │ ├─msg
│ │ ├─personal
│ │ └─search
│ ├─routes # 路由定义,参考 MaterialApp的routes参数
│ ├─services
│ │ ├─xxx.dart # 前端自有数据操作
│ │ └─api # 与后端的API交互
│ ├─utils
│ └─widgets # 视觉组件,VM即可确定视觉效果
│ ├─book_list.dart
│ └─msg.dart
└─test
与Web或H5类似,widgets等价于components。
3. 主屏
import 'package:flutter/material.dart';
import '../../constants/route_path.dart';
import '../contacts/contacts.dart';
import '../discover/discover.dart';
import '../msg/msg.dart';
import '../personal/personal.dart';
class HomePage extends StatefulWidget {
HomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_HomePageState createState() => _HomePageState();
}
const navSelMsg = 0;
const navSelContacts = 1;
const navSelDiscover = 2;
const navSelPersonal = 3;
class _HomePageState extends State<HomePage> {
int _selectedIndex = 0;
static const List<String> _navTitles = [
'信息',
'联系人',
'发现',
'我',
];
static List<Widget> _widgetOptions = <Widget>[
MsgWidget(),
ContactsWidget(),
DiscoverWidget(),
PersonalWidget(),
];
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _selectedIndex == navSelPersonal ? AppBar() : AppBar(
title: Text(_navTitles[_selectedIndex]),
centerTitle: true,
actions: <Widget>[
IconButton(
icon: const Icon(Icons.search),
tooltip: '搜索',
onPressed: () {
Navigator.pushNamed(context, RoutePath.searchPage);
},
),
_AddIconBtn(),
],
),
body: _widgetOptions.elementAt(_selectedIndex),
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.message),
label: '信息',
),
BottomNavigationBarItem(
icon: Icon(Icons.person_add_alt),
label: '联系人',
),
BottomNavigationBarItem(
icon: Icon(Icons.assistant_navigation),
label: '发现',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: '我',
),
],
type: BottomNavigationBarType.fixed,
currentIndex: _selectedIndex,
unselectedItemColor: Colors.grey,
selectedItemColor: Colors.amber[800],
onTap: _onItemTapped,
),
);
}
}
class _AddIconBtn extends StatelessWidget {
@override
Widget build(BuildContext context) {
return IconButton(
icon: const Icon(Icons.add_circle_outline),
tooltip: '添加',
onPressed: () {
final snackBar = SnackBar(
content: Text('Yay! Clicked "Add" button!'),
action: SnackBarAction(
label: 'Undo',
onPressed: () {
// Some code to undo the change.
},
),
);
// Find the Scaffold in the widget tree and use
// it to show a SnackBar.
Scaffold.of(context).showSnackBar(snackBar);
},
);
}
}
有一个坑:在AppBar的按钮事件响应中,需要访问context向上获取第一个父Scaffold( Scaffold.of(context)
),必须新建_AddIconBtn类。
4. 子页面
新建书籍列表子页面,还是使用 Scaffold 来创建统一风格的子页面,同时带有返回箭头。
import 'package:flutter/material.dart';
import '../../models/base_api/book.dart';
import '../../widgets/book_list.dart';
import '../../services/api/book_api.dart';
class BooksPage extends StatefulWidget {
@override
_BooksPageState createState() => _BooksPageState();
}
class _BooksPageState extends State<BooksPage> {
List<Book> books = <Book>[
Book(id: 1, name: 'C-1', price: 12.3),
Book(id: 2, name: 'C-2', price: 23.4),
Book(id: 3, name: 'C-3', price: 34.5),
];
@override
void initState() {
super.initState();
updateBooks();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Books'),
),
body: Center(
child: BookListWidget(books),
),
);
}
void updateBooks() async {
final books = await fetchBooks();
if (books != null) {
setState(() {
this.books = books;
});
}
}
}
5. 路由
5.1. 路由定义
import 'package:flutter/material.dart';
import '../constants/route_path.dart';
import '../pages/home/home.dart';
import '../pages/search/search.dart';
import '../pages/personal/books.dart';
final routes = <String, WidgetBuilder>{
'${RoutePath.homePage}': (BuildContext context) {
return HomePage();
},
'${RoutePath.searchPage}': (BuildContext context) {
return SearchPage();
},
'${RoutePath.booksPage}': (BuildContext context) {
return BooksPage();
},
};
5.2. 路由切换
使用 Navigator.pushNamed() 切换路由非常方便:
import 'package:flutter/material.dart';
import '../../constants/route_path.dart';
class PersonalWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView(
children: <Widget>[
TextButton.icon(
icon: Icon(
Icons.favorite,
color: Colors.pink,
size: 24.0,
semanticLabel: 'Text to announce in accessibility modes',
),
label: Text("My Books"),
onPressed: () {
Navigator.pushNamed(context, RoutePath.booksPage);
},
)
],
);
}
}
6. 网络请求与JSON转换
网络请求使用dio非常方便。JSON转换到数组时,需要自己使用 List.map() 函数转换每个元素。
import 'package:dio/dio.dart';
import '../../models/base_api/book.dart';
import 'request.dart';
Future<List<Book>> fetchBooks() async {
try {
Response<List> response = await dio.get("http://192.168.80.1:18080");
if (response.data != null) {
return response.data.map((e) => Book.fromJson(e)).toList();
}
} catch (e) {
print(e);
}
return null;
}