当前位置:   article > 正文

Flutter 入门指北系列最终之实战篇

flutter指北

码个蛋(codeegg)第 696 次推文

作者:Kuky_xs

博客:https://www.jianshu.com/p/97c2dbcac3af

还记得Flutter系列不?上一次讲到网络的,想不起来的回顾一下~ 

Flutter入门指北(Part 13)之网络

今天分析Flutter入门指北系列的最终篇啦~

讲完了常用的部件和网络请求后,差不多该进入整体实战了,这里我们将写一个比较熟悉的项目,郭神的 cool weather。项目将使用 fluro 实现路由管理,dio 实现网络请求,rxdart 实现 BLoC 进行状态管理和逻辑分离,使用文件,shared_preferences,sqflite 实现本地的数据持久化。这边先给出项目的地址:flutter_weather,以及最后实现的效果图:

除了 fluro 别的基本上前面都讲了,所以在开始正式的实战前,先讲下 fluro

Fluro

fluro 是对 Navigator 的一个封装,方便更好的管理路由跳转,当然还存在一些缺陷,例如目前只支持传递字符串,不能传递中文等,但是这些问题都算不上是大问题。

fluro 的使用很简单,大概分如下的步骤:

1. 在全局定义一个 Router实例

final router = Router();

2. 使用 Router实例定义路径和其对应的 Handler对象

  1. // 例如定义一个 CityPage 的路径和 Handler
  2. Handler cityHandler = Handler(handlerFunc: (_, params) {
  3. // 传递的参数都在 params 中,params 是一个 Map<String, List<String>> 类型参数
  4. String cityId = params['city_id']?.first;
  5. return BlocProvider(child: WeatherPage(city: cityId), bloc: WeatherBloc());
  6. });
  7. // 定义路由的路径和参数
  8. // 需要注意的是,第一个页面的路径必须为 "/",别的可为 "/" + 任意拼接
  9. router.define('/city', handler: cityHandler);
  10. // 或者官方提供的另一种方式
  11. router.define('/city/:city_id', handler: cityHandler);

3. 将router注册到MaterialApp的 onGenerateRoute 中

MaterialApp(onGenerateRoute: router);

4. 最后通过 Router 实例进行跳转,如果有参数传递则会在新的页面收到

  1. router.navigateTo(context, '/city?city_id=CN13579');
  2. // 或者官方的方式
  3. router.navigateTo(context, '/city/CN13579');

在 fluro 中提供了多种路由动画,包括 fadeIn,inFromRight 等。讲完了使用,就进入实战了。

flutter_weather 实战

导入插件

在开始的时候,已经提到了整体功能的实现需求,所以这边需要导入的插件以及存放图片的文件夹如下:

  1. dependencies:
  2. flutter:
  3. sdk: flutter
  4. cupertino_icons: ^0.1.2
  5. fluro: ^1.4.0
  6. dio: ^2.1.0
  7. shared_preferences: ^0.5.1+2
  8. sqflite: ^1.1.3
  9. fluttertoast: ^3.0.3
  10. rxdart: ^0.21.0
  11. path_provider: 0.5.0+1
  12. dev_dependencies:
  13. flutter_test:
  14. sdk: flutter
  15. flutter:
  16. uses-material-design: true
  17. assets:
  18. - images/
顶层静态实例的实现

有许多实例需要在顶层注册,然后在全局使用,包括但不限于 fluro 的 router,http,database 等等。在这个项目中,需要用到的就是这三个实例,会在全局调用,所以在开始前进行初始化,当然 http 和 database 在使用的时候创建也可以,完全看个人习惯,但是 fluro 的管理类必须在一开始就注册完成。首先需要定义一个 Application类用来存放这些静态实例

  1. class Application {
  2. static HttpUtils http; // 全局网络
  3. static Router router; // 全局路由
  4. static DatabaseUtils db; // 全局数据库
  5. }

接着就是对相应方法类的编写,其中 HttpUtil和 DatabaseUtils在前面有讲过,这边不重复讲,会讲下数据库如何建立。

Fluro 路由管理类

首先,需要知道,该项目的界面大概分如下的界面(当然可先只定义首页,剩下用到了再定义,该项目相对简单,所以先列出来):省选择页,市选择页,区选择页,天气展示页,设置页。所以 fluro 的管理类可按如下定义:

  1. // 查看 `routers/routers.dart` 文件
  2. class Routers {
  3. /// 各个页面对应的路径
  4. static const root = '/';
  5. static const weather = '/weather';
  6. static const provinces = '/provinces';
  7. static const cities = '/cities';
  8. static const districts = '/districts';
  9. static const settings = '/settings';
  10. /// 该方法用于放到 `main` 方法中定义所有的路由,
  11. /// 对应的 handler 可放同一个文件,也可放另一个文件,看个人喜好
  12. static configureRouters(Router router) {
  13. router.notFoundHandler = notFoundHandler;
  14. router.define(root, handler: rootHandler); // 首页
  15. router.define(weather, handler: weatherHandler); // 天气展示页
  16. router.define(provinces, handler: provincesHandler); // 省列表页
  17. router.define(cities, handler: citiesHandler); // 省下市列表页
  18. router.define(districts, handler: districtsHandler); // 市下区列表页
  19. router.define(settings, handler: settingsHandler); // 设置页
  20. }
  21. /// 生成天气显示页面路径,需要用到城市 id
  22. static generateWeatherRouterPath(String cityId) => '$weather?city_id=$cityId';
  23. /// 生成省下的市列表页相应路径 需要用到省 id 及省名
  24. static generateProvinceRouterPath(int provinceId, String name)
  25. => '$cities?province_id=$provinceId&name=$name';
  26. /// 生成市下的区列表页相应路径,需用到市 id 及市名
  27. static generateCityRouterPath(int provinceId, int cityId, String name)
  28. => '$districts?province_id=$provinceId&city_id=$cityId&name=$name';
  29. }
  1. /// 查看 `routers/handler.dart` 文件
  2. Handler notFoundHandler = Handler(handlerFunc: (_, params) {
  3. Logger('RouterHandler:').log('Not Found Router'); // 当找不到相应的路由时,打印信息处理
  4. });
  5. Handler rootHandler = Handler(handlerFunc: (_, params) => SplashPage());
  6. Handler weatherHandler = Handler(handlerFunc: (_, params) {
  7. String cityId = params['city_id']?.first; // 获取相应的参数
  8. return WeatherPage(city: cityId);
  9. });
  10. Handler provincesHandler = Handler(handlerFunc: (_, params) => ProvinceListPage());
  11. Handler citiesHandler = Handler(handlerFunc: (_, params) {
  12. String provinceId = params['province_id']?.first;
  13. String name = params['name']?.first;
  14. return CityListPage(provinceId: provinceId,
  15. name: FluroConvertUtils.fluroCnParamsDecode(name));
  16. });
  17. Handler districtsHandler = Handler(handlerFunc: (_, params) {
  18. String provinceId = params['province_id']?.first;
  19. String cityId = params['city_id']?.first;
  20. String name = params['name']?.first;
  21. return DistrictListPage(provinceId: provinceId, cityId: cityId,
  22. name: FluroConvertUtils.fluroCnParamsDecode(name));
  23. });
  24. Handler settingsHandler = Handler(handlerFunc: (_, params) => SettingsPage());

那么界面的路由到这就编写好了,但是前面提到了 fluro 目前不支持中文的传递,所以在传递中文时候,需要先进行转码,这边提供一个自己写的方法,小伙伴有更好的方法也可以直接在项目提 issue

  1. /// 查看 `utils/fluro_convert_util.dart` 文件
  2. class FluroConvertUtils {
  3. /// fluro 传递中文参数前,先转换,fluro 不支持中文传递
  4. static String fluroCnParamsEncode(String originalCn) {
  5. StringBuffer sb = StringBuffer();
  6. var encoded = Utf8Encoder().convert(originalCn); // utf8 编码,会生成一个 int 列表
  7. encoded.forEach((val) => sb.write('$val,')); // 将 int 列表重新转换成字符串
  8. return sb.toString().substring(0, sb.length - 1).toString();
  9. }
  10. /// fluro 传递后取出参数,解析
  11. static String fluroCnParamsDecode(String encodedCn) {
  12. var decoded = encodedCn.split('[').last.split(']').first.split(','); // 对参数字符串分割
  13. var list = <int>[];
  14. decoded.forEach((s) => list.add(int.parse(s.trim()))); // 转回 int 列表
  15. return Utf8Decoder().convert(list); // 解码
  16. }
  17. }
Database 管理类编写

因为数据库的开启是一个很耗资源的过程,所以这边通过单例并提取到顶层。在该项目中,数据库主要用于存储城市信息,因为城市之间的关联比较复杂,如果通过 shared_preferences 或者文件存储会很复杂。

  1. /// 查看 `utils/db_utils.dart` 文件
  2. class DatabaseUtils {
  3. final String _dbName = 'weather.db'; // 数据表名
  4. final String _tableProvinces = 'provinces'; // 省表
  5. final String _tableCities = 'cities'; // 市表
  6. final String _tableDistricts = 'districts'; // 区表
  7. static Database _db;
  8. static DatabaseUtils _instance;
  9. static DatabaseUtils get instance => DatabaseUtils();
  10. /// 将数据库的初始化放到私有构造中,值允许通过单例访问
  11. DatabaseUtils._internal() {
  12. getDatabasesPath().then((path) async {
  13. _db = await openDatabase(join(path, _dbName), version: 1, onCreate: (db, version) {
  14. db.execute('create table $_tableProvinces('
  15. 'id integer primary key autoincrement,'
  16. 'province_id integer not null unique,' // 省 id,id 唯一
  17. 'province_name text not null' // 省名
  18. ')');
  19. db.execute('create table $_tableCities('
  20. 'id integer primary key autoincrement,'
  21. 'city_id integer not null unique,' // 市 id,id 唯一
  22. 'city_name text not null,' // 市名
  23. 'province_id integer not null,' // 对应的省的 id,作为外键同省表关联
  24. 'foreign key(province_id) references $_tableProvinces(province_id)'
  25. ')');
  26. db.execute('create table $_tableDistricts('
  27. 'id integer primary key autoincrement,'
  28. 'district_id integer not null unique,' // 区 id
  29. 'district_name text not null,' // 区名
  30. 'weather_id text not null unique,' // 查询天气用的 id,例如 CN13579826,id 唯一
  31. 'city_id integer not null,' // 对应市的 id,作为外键同市表关联
  32. 'foreign key(city_id) references $_tableCities(city_id)'
  33. ')');
  34. }, onUpgrade: (db, oldVersion, newVersion) {});
  35. });
  36. }
  37. /// 构建单例
  38. factory DatabaseUtils() {
  39. if (_instance == null) {
  40. _instance = DatabaseUtils._internal();
  41. }
  42. return _instance;
  43. }
  44. /// 查询所有的省,`ProvinceModel` 为省市接口返回数据生成的 model 类
  45. /// 查看 `model/province_model.dart` 文件
  46. Future<List<ProvinceModel>> queryAllProvinces() async =>
  47. ProvinceModel.fromProvinceTableList(await _db.rawQuery('select province_id, province_name from $_tableProvinces'));
  48. /// 查询某个省内的所有市
  49. Future<List<ProvinceModel>> queryAllCitiesInProvince(String proid) async => ProvinceModel.fromCityTableList(await _db.rawQuery(
  50. 'select city_id, city_name from $_tableCities where province_id = ?',
  51. [proid],
  52. ));
  53. /// 查询某个市内的所有区,`DistrictModel` 为区接口返回数据生成的 model 类
  54. /// 查看 `model/district_model.dart` 文件
  55. Future<List<DistrictModel>> queryAllDistrictsInCity(String cityid) async => DistrictModel.fromDistrictTableList(await _db.rawQuery(
  56. 'select district_id, district_name, weather_id from $_tableDistricts where city_id = ?',
  57. [cityid],
  58. ));
  59. /// 将所有的省插入数据库
  60. Future<void> insertProvinces(List<ProvinceModel> provinces) async {
  61. var batch = _db.batch();
  62. provinces.forEach((p) => batch.rawInsert(
  63. 'insert or ignore into $_tableProvinces (province_id, province_name) values (?, ?)',
  64. [p.id, p.name],
  65. ));
  66. batch.commit();
  67. }
  68. /// 将省对应下的所有市插入数据库
  69. Future<void> insertCitiesInProvince(List<ProvinceModel> cities, String proid) async {
  70. var batch = _db.batch();
  71. cities.forEach((c) => batch.rawInsert(
  72. 'insert or ignore into $_tableCities (city_id, city_name, province_id) values (?, ?, ?)',
  73. [c.id, c.name, proid],
  74. ));
  75. batch.commit();
  76. }
  77. /// 将市下的所有区插入数据库
  78. Future<void> insertDistrictsInCity(List<DistrictModel> districts, String cityid) async {
  79. var batch = _db.batch();
  80. districts.forEach((d) => batch.rawInsert(
  81. 'insert or ignore into $_tableDistricts (district_id, district_name, weather_id, city_id) values (?, ?, ?, ?)',
  82. [d.id, d.name, d.weatherId, cityid],
  83. ));
  84. batch.commit();
  85. }
  86. }

定义完全局使用的方法,就可以在 main 函数中进行相关的初始化了

  1. /// 查看 `main.dart` 文件
  2. void main() {
  3. // 初始化 fluro router
  4. Router router = Router();
  5. Routers.configureRouters(router);
  6. Application.router = router;
  7. // 初始化 http
  8. Application.http = HttpUtils(baseUrl: WeatherApi.WEATHER_HOST);
  9. // 初始化 db
  10. Application.db = DatabaseUtils.instance;
  11. // 强制竖屏,因为设置竖屏为 `Future` 方法,防止设置无效可等返回值后再启动 App
  12. SystemChrome.setPreferredOrientations([DeviceOrientation.portraitDown, DeviceOrientation.portraitUp]).then((_) {
  13. runApp(WeatherApp()); // App 类可放在同个文件,个人习惯单独一个文件存放
  14. if (Platform.isAndroid) {
  15. SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(statusBarColor: Colors.transparent));
  16. }
  17. });
  18. }
  1. class WeatherApp extends StatelessWidget {
  2. @override
  3. Widget build(BuildContext context) {
  4. return MaterialApp(
  5. title: 'Weather App',
  6. onGenerateRoute: Application.router.generator, // 将 fluro 的路由进行注册
  7. debugShowCheckedModeBanner: false,
  8. );
  9. }
  10. }

初始化完毕,接着就可以进行页面的编写了。

首页编写

首页主要是为了对 App 的一个大概展示,或者是一些广告的展示,同时也给一些数据初始化提供时间,当用户进入后有更好的体验效果。我们在这里就做一个图标的展示(图标可自行到项目中 images文件夹查找),延时 5s 后跳转下个页面。

  1. /// 查看 `splash_page.dart` 文件
  2. class SplashPage extends StatelessWidget {
  3. @override
  4. Widget build(BuildContext context) {
  5. /// 因为已经引入了 rxdart,这里通过 rxdart.timer 进行倒计时
  6. /// 当然也可以使用 Futuer.delayed 进行倒计时
  7. /// 5s 计时,如果已经选择城市,跳转天气界面,否则进入城市选择
  8. Observable.timer(0, Duration(milliseconds: 5000)).listen((_) {
  9. PreferenceUtils.instance.getString(PreferencesKey.WEATHER_CITY_ID)
  10. .then((city) {
  11. // 如果当前还未选择城市,则进入城市选择页,否则跳转天气详情页
  12. // replace: true 即为 Navigator.pushReplacement 方法
  13. Application.router.navigateTo(context, city.isEmpty
  14. ? Routers.provinces
  15. : Routers.generateWeatherRouterPath(city),
  16. replace: true);
  17. });
  18. });
  19. return Scaffold(
  20. body: Container(
  21. alignment: Alignment.center,
  22. color: Colors.white,
  23. child: Column(
  24. mainAxisAlignment: MainAxisAlignment.center,
  25. children: <Widget>[
  26. // 展示图标
  27. Image.asset(Resource.pngSplash, width: 200.0, height: 200.0),
  28. // 展示文字提醒,用 SizedBox 设置区域大小
  29. SizedBox(
  30. width: MediaQuery.of(context).size.width * 0.7,
  31. child: Text(
  32. '所有天气数据均为模拟数据,仅用作学习目的使用,请勿当作真实的天气预报软件来使用',
  33. textAlign: TextAlign.center,
  34. softWrap: true,
  35. style: TextStyle(color: Colors.red[700], fontSize: 16.0),
  36. ))
  37. ],
  38. ),
  39. ),
  40. );
  41. }
  42. }
城市选择页面

当首次进入的时候,用户肯定没有选择城市,所以先编写城市选择列表页面,因为整体的项目使用 BLoC 分离业务逻辑和页面,所以先编写数据管理类吧,把数据请求和改变的业务逻辑放到这块,BLoC 的实现在前面讲过了,这边就不重复提了。可以查看文章:状态管理及 BLoC

  1. /// 查看 `provinces_bloc.dart` 文件
  2. class ProvincesBloc extends BaseBloc {
  3. final _logger = Logger('ProvincesBloc');
  4. List<ProvinceModel> _provinces = []; // 全国省
  5. List<ProvinceModel> _cities = []; // 省内市
  6. List<DistrictModel> _districts = []; // 市内区
  7. List<ProvinceModel> get provinces => _provinces;
  8. List<ProvinceModel> get cities => _cities;
  9. List<DistrictModel> get districts => _districts;
  10. BehaviorSubject<List<ProvinceModel>> _provinceController = BehaviorSubject();
  11. BehaviorSubject<List<ProvinceModel>> _citiesController = BehaviorSubject();
  12. BehaviorSubject<List<DistrictModel>> _districtController = BehaviorSubject();
  13. /// stream,用于 StreamBuilder 的 stream 参数
  14. Observable<List<ProvinceModel>> get provinceStream
  15. => Observable(_provinceController.stream);
  16. Observable<List<ProvinceModel>> get cityStream => Observable(_citiesController.stream);
  17. Observable<List<DistrictModel>> get districtStream
  18. => Observable(_districtController.stream);
  19. /// 通知刷新省份列表
  20. changeProvinces(List<ProvinceModel> provinces) {
  21. _provinces.clear();
  22. _provinces.addAll(provinces);
  23. _provinceController.add(_provinces);
  24. }
  25. /// 通知刷新城市列表
  26. changeCities(List<ProvinceModel> cities) {
  27. _cities.clear();
  28. _cities.addAll(cities);
  29. _citiesController.add(_cities);
  30. }
  31. /// 通知刷新区列表
  32. changeDistricts(List<DistrictModel> districts) {
  33. _districts.clear();
  34. _districts.addAll(districts);
  35. _districtController.add(_districts);
  36. }
  37. /// 请求全国省
  38. Future<List<ProvinceModel>> requestAllProvinces() async {
  39. var resp = await Application.http.getRequest(WeatherApi.WEATHER_PROVINCE,
  40. error: (msg) => _logger.log(msg, 'province'));
  41. return resp == null || resp.data == null ? [] : ProvinceModel.fromMapList(resp.data);
  42. }
  43. /// 请求省内城市
  44. Future<List<ProvinceModel>> requestAllCitiesInProvince(String proid) async {
  45. var resp = await Application.http
  46. .getRequest('${WeatherApi.WEATHER_PROVINCE}/$proid',
  47. error: (msg) => _logger.log(msg, 'city'));
  48. return resp == null || resp.data == null ? [] : ProvinceModel.fromMapList(resp.data);
  49. }
  50. /// 请求市内的区
  51. Future<List<DistrictModel>> requestAllDistricts(String proid, String cityid) async {
  52. var resp = await Application.http
  53. .getRequest('${WeatherApi.WEATHER_PROVINCE}/$proid/$cityid',
  54. error: (msg) => _logger.log(msg, 'district'));
  55. return resp == null || resp.data == null ? [] : DistrictModel.fromMapList(resp.data);
  56. }
  57. @override
  58. void dispose() { // 及时销毁
  59. _provinceController?.close();
  60. _citiesController?.close();
  61. _districtController?.close();
  62. }
  63. }

写完 BLoC 需要对其进行注册,因为城市选择相对还是比较频繁的,所以可以放最顶层进行注册

  1. return BlocProvider(
  2. bloc: ProvincesBloc(), // 城市切换 BLoC
  3. child: MaterialApp(
  4. title: 'Weather App',
  5. onGenerateRoute: Application.router.generator,
  6. debugShowCheckedModeBanner: false,
  7. ),
  8. );

城市选择就是一个列表,直接通过 ListView 生成即可,前面讲 ListView 的时候提到,尽可能固定 item 的高度,会提高绘制效率

  1. /// 查看 `provinces_page.dart` 文件
  2. class ProvinceListPage extends StatelessWidget {
  3. @override
  4. Widget build(BuildContext context) {
  5. var _bloc = BlocProvider.of<ProvincesBloc>(context);
  6. // 进入的时候先使用数据库的数据填充界面
  7. Application.db.queryAllProvinces().then((ps) => _bloc.changeProvinces(ps));
  8. // 网络数据更新列表并刷新数据库数据
  9. _bloc.requestAllProvinces().then((provinces) {
  10. _bloc.changeProvinces(provinces);
  11. Application.db.insertProvinces(provinces);
  12. });
  13. return Scaffold(
  14. appBar: AppBar(
  15. title: Text('请选择省份'),
  16. ),
  17. body: Container(
  18. color: Colors.black12,
  19. alignment: Alignment.center,
  20. // 省列表选择
  21. child: StreamBuilder(
  22. stream: _bloc.provinceStream,
  23. initialData: _bloc.provinces,
  24. builder: (_, AsyncSnapshot<List<ProvinceModel>> snapshot)
  25. => !snapshot.hasData || snapshot.data.isEmpty
  26. // 如果当前的数据未加载则给一个加载,否则显示列表加载
  27. ? CupertinoActivityIndicator(radius: 12.0)
  28. : ListView.builder(
  29. physics: BouncingScrollPhysics(),
  30. padding: const EdgeInsets.symmetric(horizontal: 12.0),
  31. itemBuilder: (_, index) => InkWell(
  32. child: Container(
  33. alignment: Alignment.centerLeft,
  34. child: Text(snapshot.data[index].name, style: TextStyle(fontSize: 18.0, color: Colors.black)),
  35. ),
  36. onTap: () => Application.router.navigateTo(
  37. context,
  38. // 跳转下层省内城市选择,需要将当前的省 id 以及省名传入
  39. Routers.
  40. generateProvinceRouterPath(snapshot.data[index].id, FluroConvertUtils.fluroCnParamsEncode(snapshot.data[index].name)),
  41. transition: TransitionType.fadeIn),
  42. ),
  43. itemExtent: 50.0,
  44. itemCount: snapshot.data.length),
  45. ),
  46. ),
  47. );
  48. }
  49. }

对于市和区的列表选择也类似,除了最后的点击会有些区别页面的布局几乎一致,这边只提下点击事件

  1. /// 查看 `cities_page.dart` 文件
  2. Application.router.navigateTo(
  3. context,
  4. // 跳转下层省内城市选择
  5. Routers.generateProvinceRouterPath(
  6. snapshot.data[index].id, FluroConvertUtils.fluroCnParamsEncode(snapshot.data[index].name)),
  7. transition: TransitionType.fadeIn),
  8. )
  1. // 设置为当前区,并清理路由 stack,并将天气界面设置到最上层
  2. onTap: () {
  3. PreferenceUtils.instance
  4. .saveString(PreferencesKey.WEATHER_CITY_ID, snapshot.data[index].weatherId);
  5. Application.router.navigateTo(context, Routers.generateWeatherRouterPath(snapshot.data[index].weatherId),
  6. transition: TransitionType.inFromRight, clearStack: true);
  7. })
天气详情页面

天气详情页面相对部件会多点,为了看着舒服一点,这里拆成多个部分来编写,在这之前还是先编写数据的管理类,因为天气详情接口返回的数据嵌套层次比较多,关系比较复杂,不适合用 database来做持久化,所以这里采用文件持久化方式。当然有些小伙伴会问干嘛不使用 shared_preferences来存储,理论上应该没有太大的问题,但是个人建议相对复杂的数据使用文件存储会相对比较好点,一定要说个为什么,我也说不出来。

  1. /// 查看 `weather_bloc.dart` 文件
  2. class WeatherBloc extends BaseBloc {
  3. final _logger = Logger('WeatherBloc');
  4. WeatherModel _weather; // 天气情况
  5. String _background = WeatherApi.DEFAULT_BACKGROUND; // 背景
  6. WeatherModel get weather => _weather;
  7. String get background => _background;
  8. BehaviorSubject<WeatherModel> _weatherController = BehaviorSubject();
  9. BehaviorSubject<String> _backgroundController = BehaviorSubject();
  10. Observable<WeatherModel> get weatherStream => Observable(_weatherController.stream);
  11. Observable<String> get backgroundStream => Observable(_backgroundController.stream);
  12. /// 更新天气情况
  13. updateWeather(WeatherModel weather) {
  14. _weather = weather;
  15. _weatherController.add(_weather);
  16. }
  17. /// 更新天气背景
  18. updateBackground(String background) {
  19. _background = background;
  20. _backgroundController.add(_background);
  21. }
  22. // 请求天气情况
  23. Future<WeatherModel> requestWeather(String id) async {
  24. var resp = await Application.http
  25. .getRequest(WeatherApi.WEATHER_STATUS,
  26. params: {'cityid': id, 'key': WeatherApi.WEATHER_KEY},
  27. error: (msg) => _logger.log(msg, 'weather'));
  28. // 请求数据成功则写入到文件中
  29. if (resp != null && resp.data != null) {
  30. _writeIntoFile(json.encode(resp.data));
  31. }
  32. return WeatherModel.fromMap(resp.data);
  33. }
  34. Future<String> requestBackground() async {
  35. var resp = await Application.http
  36. .getRequest<String>(WeatherApi.WEATHER_BACKGROUND,
  37. error: (msg) => _logger.log(msg, 'background'));
  38. return resp == null || resp.data == null ? WeatherApi.DEFAULT_BACKGROUND : resp.data;
  39. }
  40. // 获取存储文件路径
  41. Future<String> _getPath() async =>
  42. '${(await getApplicationDocumentsDirectory()).path}/weather.txt';
  43. // 写入到文件
  44. _writeIntoFile(String contents) async {
  45. File file = File(await _getPath());
  46. if (await file.exists()) file.deleteSync();
  47. file.createSync();
  48. file.writeAsString(contents);
  49. }
  50. // 文件读取存储信息,如果不存在文件则返回空字符串 '',不推荐返回 null
  51. Future<String> readWeatherFromFile() async {
  52. File file = File(await _getPath());
  53. return (await file.exists()) ? file.readAsString() : '';
  54. }
  55. @override
  56. void dispose() {
  57. _weatherController?.close();
  58. _backgroundController?.close();
  59. }
  60. }

天气详情的刷新只有当个页面,所以 BLoC 的注册值需要在路由上注册即可,在 fluro 对应 handler 中加入注册

  1. Handler weatherHandler = Handler(handlerFunc: (_, params) {
  2. String cityId = params['city_id']?.first; // 这个 id 可以通过 BLoC 获取也可以
  3. return BlocProvider(child: WeatherPage(city: cityId), bloc: WeatherBloc());
  4. });

那么接下来就可以编写界面了,先实现最外层的背景图变化

  1. /// 查看 `weather_page.dart` 文件
  2. class WeatherPage extends StatelessWidget {
  3. final String city;
  4. WeatherPage({Key key, this.city}) : super(key: key);
  5. @override
  6. Widget build(BuildContext context) {
  7. var _bloc = BlocProvider.of<WeatherBloc>(context);
  8. // 请求背景并更新
  9. _bloc.requestBackground().then((b) => _bloc.updateBackground(b));
  10. // 先读取本地文件缓存进行页面填充
  11. _bloc.readWeatherFromFile().then((s) {
  12. if (s.isNotEmpty) {
  13. _bloc.updateWeather(WeatherModel.fromMap(json.decode(s)));
  14. }
  15. });
  16. // 再请求网络更新数据
  17. _bloc.requestWeather(city).then((w) => _bloc.updateWeather(w));
  18. return Scaffold(
  19. body: StreamBuilder(
  20. stream: _bloc.backgroundStream,
  21. initialData: _bloc.background,
  22. builder: (_, AsyncSnapshot<String> themeSnapshot) => Container(
  23. padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 20.0),
  24. alignment: Alignment.center,
  25. decoration: BoxDecoration(
  26. color: Colors.black12,
  27. image: DecorationImage(
  28. image: NetworkImage(themeSnapshot.data), fit: BoxFit.cover),
  29. ),
  30. child: // 具体内部布局通过拆分小部件实现
  31. )),
  32. );
  33. }
  34. }

页面最顶部是显示两个按钮,一个跳转城市选择,一个跳转设置页面,显示当前的城市

  1. class FollowedHeader extends StatelessWidget {
  2. final AsyncSnapshot<WeatherModel> snapshot; // snapshot 通过上层传入
  3. FollowedHeader({Key key, this.snapshot}) : super(key: key);
  4. @override
  5. Widget build(BuildContext context) {
  6. return Row(
  7. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  8. children: <Widget>[
  9. // 城市选择页面跳转按钮
  10. IconButton(
  11. icon: Icon(Icons.home, color: Colors.white, size: 32.0),
  12. onPressed: () => Application.router.
  13. navigateTo(context, Routers.provinces,
  14. transition: TransitionType.inFromLeft)),
  15. // 当前城市
  16. Text('${snapshot.data.heWeather[0].basic.location}',
  17. style: TextStyle(fontSize: 28.0, color: Colors.white)),
  18. // 设置页面跳转按钮
  19. IconButton(
  20. icon: Icon(Icons.settings, color: Colors.white, size: 32.0),
  21. onPressed: () => Application.router
  22. .navigateTo(context, Routers.settings,
  23. transition: TransitionType.inFromRight))
  24. ],
  25. );
  26. }
  27. }

接着是当前的天气详情部分

  1. class CurrentWeatherState extends StatelessWidget {
  2. final AsyncSnapshot<WeatherModel> snapshot;
  3. CurrentWeatherState({Key key, this.snapshot}) : super(key: key);
  4. @override
  5. Widget build(BuildContext context) {
  6. var _now = snapshot.data.heWeather[0].now;
  7. var _update = snapshot.data.heWeather[0].update.loc.split(' ').last;
  8. return Column(
  9. crossAxisAlignment: CrossAxisAlignment.end,
  10. children: <Widget>[
  11. // 当前的温度
  12. Text('${_now.tmp}℃', style: TextStyle(fontSize: 50.0, color: Colors.white)),
  13. // 当前的天气状况
  14. Text('${_now.condTxt}', style: TextStyle(fontSize: 24.0, color: Colors.white)),
  15. Row( // 刷新的时间
  16. mainAxisAlignment: MainAxisAlignment.end,
  17. children: <Widget>[
  18. Icon(Icons.refresh, size: 16.0, color: Colors.white),
  19. Padding(padding: const EdgeInsets.only(left: 4.0)),
  20. Text(_update, style: TextStyle(fontSize: 12.0, color: Colors.white))
  21. ],
  22. )
  23. ],
  24. );
  25. }
  26. }

接下来是一个天气预报的列表块,以为是一个列表,当然可以通过 Cloumn来实现,但是前面有提到过一个列表「粘合剂」---- CustomScrollView,所以这里的整体连接最后会通过 CustomScrollView来实现,那么你可以放心在最上层容器的 child属性加上 CustomScrollView了。接着来实现这块预报模块

  1. class WeatherForecast extends StatelessWidget {
  2. final AsyncSnapshot<WeatherModel> snapshot;
  3. WeatherForecast({Key key, this.snapshot}) : super(key: key);
  4. @override
  5. Widget build(BuildContext context) {
  6. var _forecastList = snapshot.data.heWeather[0].dailyForecasts; // 获取天气预报
  7. return SliverFixedExtentList(
  8. delegate: SliverChildBuilderDelegate(
  9. (_, index) => Container(
  10. color: Colors.black54, // 外层设置背景色,防止被最外层图片背景遮挡文字
  11. padding: const EdgeInsets.all(12.0),
  12. alignment: Alignment.centerLeft,
  13. child: index == 0 // 当第一个 item 情况,显示 ‘预报’
  14. ? Text('预报', style: TextStyle(fontSize: 24.0, color: Colors.white))
  15. : Row(
  16. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  17. crossAxisAlignment: CrossAxisAlignment.center,
  18. children: <Widget>[
  19. Text(_forecastList[index - 1].date, // 预报的日期
  20. style: TextStyle(fontSize: 16.0, color: Colors.white)),
  21. Expanded( // 天气情况,这边通过 expanded 进行占位,并居中显示
  22. child: Center(child: Text(_forecastList[index - 1].cond.txtD,
  23. style: TextStyle(fontSize: 16.0, color: Colors.white))),
  24. flex: 2),
  25. Expanded(
  26. child: Row( // 最高温度,最低温度
  27. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  28. children: <Widget>[
  29. Text(_forecastList[index - 1].tmp.max,
  30. style: TextStyle(fontSize: 16.0,
  31. color: Colors.white)),
  32. Text(_forecastList[index - 1].tmp.min,
  33. style: TextStyle(fontSize: 16.0,
  34. color: Colors.white)),
  35. ],
  36. ),
  37. flex: 1)
  38. ],
  39. )),
  40. childCount: _forecastList.length + 1, // 这个数量需要 +1,因为有个标题需要一个数量
  41. ),
  42. itemExtent: 50.0);
  43. }
  44. }

接着是空气质量报告,一个标题,下面由两个布局进行平分

  1. class AirQuality extends StatelessWidget {
  2. final AsyncSnapshot<WeatherModel> snapshot;
  3. AirQuality({Key key, this.snapshot}) : super(key: key);
  4. @override
  5. Widget build(BuildContext context) {
  6. var quality = snapshot.data.heWeather[0].aqi.city;
  7. return Container(
  8. padding: const EdgeInsets.all(12.0),
  9. color: Colors.black54,
  10. alignment: Alignment.centerLeft,
  11. child: Column(
  12. crossAxisAlignment: CrossAxisAlignment.start,
  13. children: <Widget>[
  14. // 标题
  15. Padding(padding: const EdgeInsets.only(bottom: 20.0), child:
  16. Text('空气质量', style: TextStyle(fontSize: 24.0,
  17. color: Colors.white))),
  18. Row(
  19. children: <Widget>[
  20. // 通过 expanded 进行平分横向距离
  21. Expanded(
  22. child: Center(
  23. // 内部居中显示
  24. child: Column(
  25. children: <Widget>[
  26. Text('${quality.aqi}', style:
  27. TextStyle(fontSize: 40.0, color: Colors.white)),
  28. Text('AQI 指数', style:
  29. TextStyle(fontSize: 20.0, color: Colors.white)),
  30. ],
  31. ),
  32. )),
  33. Expanded(
  34. child: Center(
  35. child: Column(
  36. children: <Widget>[
  37. Text('${quality.pm25}', style:
  38. TextStyle(fontSize: 40.0, color: Colors.white)),
  39. Text('PM2.5 指数', style:
  40. TextStyle(fontSize: 20.0, color: Colors.white)),
  41. ],
  42. ),
  43. )),
  44. ],
  45. )
  46. ],
  47. ));
  48. }
  49. }

接下来是生活质量模块,看着也是个列表,但是后台返回的不是列表,而是根据不同字段获取不同质量指数,因为布局类似,所以可以对其进行封装再整体调用

  1. class LifeSuggestions extends StatelessWidget {
  2. final AsyncSnapshot<WeatherModel> snapshot;
  3. LifeSuggestions({Key key, this.snapshot}) : super(key: key);
  4. // 生活指数封装
  5. Widget _suggestionWidget(String content) =>
  6. Padding(padding: const EdgeInsets.only(top: 20.0), child:
  7. Text(content, style: TextStyle(color: Colors.white, fontSize: 16.0)));
  8. @override
  9. Widget build(BuildContext context) {
  10. var _suggestion = snapshot.data.heWeather[0].suggestion;
  11. return Container(
  12. padding: const EdgeInsets.all(12.0),
  13. color: Colors.black54,
  14. alignment: Alignment.centerLeft,
  15. child: Column(
  16. crossAxisAlignment: CrossAxisAlignment.start,
  17. children: <Widget>[
  18. Text('生活建议', style: TextStyle(fontSize: 24.0, color: Colors.white)),
  19. _suggestionWidget('舒适度:${_suggestion.comf.brf}\n${_suggestion.comf.txt}'),
  20. _suggestionWidget('洗车指数:${_suggestion.cw.brf}\n${_suggestion.cw.txt}'),
  21. _suggestionWidget('运动指数:
  22. ${_suggestion.sport.brf}\n${_suggestion.sport.txt}'),
  23. ],
  24. ),
  25. );
  26. }
  27. }

所有的分模块都已经编写完成,剩下就是通过粘合剂进行组装了

  1. child: StreamBuilder(
  2. initialData: _bloc.weather,
  3. stream: _bloc.weatherStream,
  4. builder: (_, AsyncSnapshot<WeatherModel> snapshot) => !snapshot.hasData
  5. ? CupertinoActivityIndicator(radius: 12.0)
  6. : SafeArea(
  7. child: RefreshIndicator(
  8. child: CustomScrollView(
  9. physics: BouncingScrollPhysics(),
  10. slivers: <Widget>[
  11. SliverToBoxAdapter(child: FollowedHeader(snapshot: snapshot)),
  12. // 实时天气
  13. SliverPadding(
  14. padding: const EdgeInsets.symmetric(vertical: 30.0),
  15. sliver: SliverToBoxAdapter(
  16. child: CurrentWeatherState(snapshot: snapshot, city: city),
  17. ),
  18. ),
  19. // 天气预报
  20. WeatherForecast(snapshot: snapshot),
  21. // 空气质量
  22. SliverPadding(
  23. padding: const EdgeInsets.symmetric(vertical: 30.0),
  24. sliver: SliverToBoxAdapter(child: AirQuality(snapshot: snapshot)),
  25. ),
  26. // 生活建议
  27. SliverToBoxAdapter(child: LifeSuggestions(snapshot: snapshot))
  28. ],
  29. ),
  30. onRefresh: () async {
  31. _bloc.requestWeather(city).then((w) => _bloc.updateWeather(w));
  32. return null;
  33. }),
  34. )),

最后就剩下设置页的全局主题切换了

设置页全局主题切换

既然提到了数据的切换,那肯定就涉及 BLoC 毫无疑问了,还是照常编写管理类

  1. /// 查看 `setting_bloc.dart` 文件
  2. class SettingBloc extends BaseBloc {
  3. /// 所有主题色列表
  4. static const themeColors = [Colors.blue, Colors.red, Colors.green,
  5. Colors.deepOrange, Colors.pink, Colors.purple];
  6. Color _color = themeColors[0];
  7. Color get color => _color;
  8. BehaviorSubject<Color> _colorController = BehaviorSubject();
  9. Observable<Color> get colorStream => Observable(_colorController.stream);
  10. /// 切换主题通知刷新
  11. switchTheme(int themeIndex) {
  12. _color = themeColors[themeIndex];
  13. _colorController.add(_color);
  14. }
  15. @override
  16. void dispose() {
  17. _colorController?.close();
  18. }
  19. }

因为是全局的切换,那么这个 BLoC 肯定需要在最顶层进行注册,这边就不贴代码了,同 ProvinceBloc一致。接着编写界面,设置界面因为有 GridView和其他部件,所以也需要用 CustomScrollView作为粘合剂,当然,你也可以用 Wrap代替 GridView来实现网格,就不需要用 CustomScrollView,使用 Column即可。

  1. class SettingsPage extends StatelessWidget {
  2. @override
  3. Widget build(BuildContext context) {
  4. var _bloc = BlocProvider.of<SettingBloc>(context);
  5. return StreamBuilder(
  6. stream: _bloc.colorStream,
  7. initialData: _bloc.color,
  8. // Theme 是 Flutter 自带的一个设置主题的部件,里面可以设置多种颜色,
  9. // 通过接收到 color 的变化,改变主题色,其他页面也如此设置,小伙伴可以自己添加
  10. builder: (_, AsyncSnapshot<Color> snapshot) => Theme(
  11. // IconThemeData 用于设置按钮的主题色
  12. data: ThemeData(primarySwatch: snapshot.data, iconTheme: IconThemeData(color: snapshot.data)),
  13. child: Scaffold(
  14. appBar: AppBar(
  15. title: Text('设置'),
  16. ),
  17. body: Container(
  18. color: Colors.black12,
  19. padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 20.0),
  20. child: CustomScrollView(
  21. slivers: <Widget>[
  22. SliverPadding(
  23. padding: const EdgeInsets.only(right: 12.0),
  24. sliver: SliverToBoxAdapter(
  25. child: Row(
  26. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  27. crossAxisAlignment: CrossAxisAlignment.center,
  28. children: <Widget>[
  29. Text('当前主题色:', style: TextStyle(fontSize: 16.0,
  30. color: snapshot.data)),
  31. Container(width: 20.0, height: 20.0, color: snapshot.data)
  32. ],
  33. )),
  34. ),
  35. SliverPadding(padding: const EdgeInsets.symmetric(vertical: 15.0)),
  36. SliverGrid(
  37. delegate: SliverChildBuilderDelegate(
  38. (_, index) => InkWell(
  39. child: Container(color: SettingBloc.themeColors[index]),
  40. onTap: () {
  41. // 选择后进行保存,当下次进入的时候直接使用该主题色
  42. // 同时切换主题色
  43. _bloc.switchTheme(index);
  44. PreferenceUtils.instance.saveInteger(PreferencesKey.THEME_COLOR_INDEX, index);
  45. },
  46. ),
  47. childCount: SettingBloc.themeColors.length),
  48. gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, mainAxisSpacing: 20.0, crossAxisSpacing: 20.0)),
  49. ],
  50. ),
  51. ),
  52. ),
  53. ));
  54. }
  55. }

最终全局的主题切换也实现了。

编写完代码,需要打包啊,Android 下的打包大家肯定没问题,这里讲下 flutter 下如何打包 apk,ipa 因为没有 mac 所以你们懂的。

apk文件打包

1. 创建 jks 文件,如果已经存在可忽略这步从第二步开始。打开终端并输入

keytool -genkey -v -keystore [你的签名文件路径].jks -keyalg RSA -keysize 2048 -validity 10000 -alias key

    然后输入密码以及一些基本信息就可以创建成功了

2. 在项目的 android 目录下创建一个 key.properties 文件,里面进行如下配置

  1. storePassword=<password from previous step>
  2. keyPassword=<password from previous step>
  3. keyAlias=key
  4. storeFile=<[你的签名文件路径].jks>

3. 在 android/app 下的 build.gradle 中进行如下修改

  1. apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
  2. // 增加如下部分代码
  3. def keystorePropertiesFile = rootProject.file("key.properties")
  4. def keystoreProperties = new Properties()
  5. keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
  6. android {
  7. // ...
  8. defaultConfigs{
  9. // ...
  10. }
  11. // 增加如下代码
  12. signingConfigs {
  13. release {
  14. keyAlias keystoreProperties['keyAlias']
  15. keyPassword keystoreProperties['keyPassword']
  16. storeFile file(keystoreProperties['storeFile'])
  17. storePassword keystoreProperties['storePassword']
  18. }
  19. }
  20. buildTypes{
  21. // ...
  22. }
  23. }

4. 再次打开终端运行 flutter build apk会自动生成一个 apk 文件,文件路径为

    [你的项目地址]\build\app\outputs\apk\release

5. 通过 flutter install就可以将正式包运行到手机上

总结

2019.03.09 - 2019.04.08,一个月时间,花了整整一个月终于是写完了,也算是给一直关注的小伙伴们有个交代了。

对于写系列文,说实话真的很煎熬,一是因为前期需要做好整体的思路构造,需要从简入繁,一步步深入,如果整体思路没有搭建好,那就会走向一条不归路,要么硬着头皮上,要么退回来从头开始,二是为了写这套系列文,基本上舍弃了所有的私人时间,写完了自己还得看上好几遍才敢发布。但是收获的的确很多,期间看了很多 flutter 的源码,对部件上的一些认识和理解也会有所的加深,也会去学习源码的编写规范,对日后工作上也会有很大帮助。

最后希望这套系列文能带更多的小伙伴入门 Flutter,大家下次再见咯~

往期Flutter系列文,保你一周掌握!(持续更新!!!)

今日问题:

Flutter最近有在学习吗?

快来码仔社群解锁新姿势吧!社群升级:Max你的学习效率

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小桥流水78/article/detail/913611
推荐阅读
相关标签
  

闽ICP备14008679号