該文已授權(quán)公眾號 「碼個蛋」对嚼,轉(zhuǎn)載請指明出處
講完了常用的部件和網(wǎng)絡(luò)請求后,差不多該進(jìn)入整體實戰(zhàn)了绳慎,這里我們將寫一個比較熟悉的項目纵竖,郭神的 cool weather。項目將使用 fluro 實現(xiàn)路由管理偷线,dio 實現(xiàn)網(wǎng)絡(luò)請求磨确,rxdart 實現(xiàn) BLoC 進(jìn)行狀態(tài)管理和邏輯分離,使用文件声邦,shared_preferences乏奥,sqflite 實現(xiàn)本地的數(shù)據(jù)持久化。這邊先給出項目的地址:flutter_weather亥曹,以及最后實現(xiàn)的效果圖:
除了 fluro 別的基本上前面都講了邓了,所以在開始正式的實戰(zhàn)前恨诱,先講下 fluro
Fluro
fluro 是對 Navigator 的一個封裝,方便更好的管理路由跳轉(zhuǎn)骗炉,當(dāng)然還存在一些缺陷照宝,例如目前只支持傳遞字符串,不能傳遞中文等句葵,但是這些問題都算不上是大問題厕鹃。
fluro 的使用很簡單,大概分如下的步驟:
-
在全局定義一個
Router
實例final router = Router();
-
使用
Router
實例定義路徑和其對應(yīng)的Handler
對象// 例如定義一個 CityPage 的路徑和 Handler Handler cityHandler = Handler(handlerFunc: (_, params) { // 傳遞的參數(shù)都在 params 中乍丈,params 是一個 Map<String, List<String>> 類型參數(shù) String cityId = params['city_id']?.first; return BlocProvider(child: WeatherPage(city: cityId), bloc: WeatherBloc()); }); // 定義路由的路徑和參數(shù) // 需要注意的是剂碴,第一個頁面的路徑必須為 "/",別的可為 "/" + 任意拼接 router.define('/city', handler: cityHandler); // 或者官方提供的另一種方式 router.define('/city/:city_id', handler: cityHandler);
-
將
router
注冊到MaterialApp
的onGenerateRoute
中MaterialApp(onGenerateRoute: router);
-
最后通過
Router
實例進(jìn)行跳轉(zhuǎn)轻专,如果有參數(shù)傳遞則會在新的頁面收到router.navigateTo(context, '/city?city_id=CN13579'); // 或者官方的方式 router.navigateTo(context, '/city/CN13579');
在 fluro 中提供了多種路由動畫忆矛,包括 fadeIn
,inFromRight
等请垛。講完了使用催训,就進(jìn)入實戰(zhàn)了。
flutter_weather 實戰(zhàn)
導(dǎo)入插件
在開始的時候宗收,已經(jīng)提到了整體功能的實現(xiàn)需求漫拭,所以這邊需要導(dǎo)入的插件以及存放圖片的文件夾如下:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^0.1.2
fluro: ^1.4.0
dio: ^2.1.0
shared_preferences: ^0.5.1+2
sqflite: ^1.1.3
fluttertoast: ^3.0.3
rxdart: ^0.21.0
path_provider: 0.5.0+1
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
assets:
- images/
頂層靜態(tài)實例的實現(xiàn)
有許多實例需要在頂層注冊,然后在全局使用镜雨,包括但不限于 fluro 的 router嫂侍,http,database 等等荚坞。在這個項目中挑宠,需要用到的就是這三個實例,會在全局調(diào)用颓影,所以在開始前進(jìn)行初始化各淀,當(dāng)然 http 和 database 在使用的時候創(chuàng)建也可以,完全看個人習(xí)慣诡挂,但是 fluro 的管理類必須在一開始就注冊完成碎浇。首先需要定義一個 Application
類用來存放這些靜態(tài)實例
class Application {
static HttpUtils http; // 全局網(wǎng)絡(luò)
static Router router; // 全局路由
static DatabaseUtils db; // 全局?jǐn)?shù)據(jù)庫
}
接著就是對相應(yīng)方法類的編寫,其中 HttpUtil
和 DatabaseUtils
在前面有講過璃俗,這邊不重復(fù)講奴璃,會講下數(shù)據(jù)庫如何建立。
Fluro 路由管理類
首先城豁,需要知道苟穆,該項目的界面大概分如下的界面(當(dāng)然可先只定義首頁,剩下用到了再定義,該項目相對簡單雳旅,所以先列出來):省選擇頁跟磨,市選擇頁,區(qū)選擇頁攒盈,天氣展示頁抵拘,設(shè)置頁。所以 fluro 的管理類可按如下定義:
// 查看 `routers/routers.dart` 文件
class Routers {
/// 各個頁面對應(yīng)的路徑
static const root = '/';
static const weather = '/weather';
static const provinces = '/provinces';
static const cities = '/cities';
static const districts = '/districts';
static const settings = '/settings';
/// 該方法用于放到 `main` 方法中定義所有的路由型豁,
/// 對應(yīng)的 handler 可放同一個文件僵蛛,也可放另一個文件,看個人喜好
static configureRouters(Router router) {
router.notFoundHandler = notFoundHandler;
router.define(root, handler: rootHandler); // 首頁
router.define(weather, handler: weatherHandler); // 天氣展示頁
router.define(provinces, handler: provincesHandler); // 省列表頁
router.define(cities, handler: citiesHandler); // 省下市列表頁
router.define(districts, handler: districtsHandler); // 市下區(qū)列表頁
router.define(settings, handler: settingsHandler); // 設(shè)置頁
}
/// 生成天氣顯示頁面路徑,需要用到城市 id
static generateWeatherRouterPath(String cityId) => '$weather?city_id=$cityId';
/// 生成省下的市列表頁相應(yīng)路徑 需要用到省 id 及省名
static generateProvinceRouterPath(int provinceId, String name)
=> '$cities?province_id=$provinceId&name=$name';
/// 生成市下的區(qū)列表頁相應(yīng)路徑,需用到市 id 及市名
static generateCityRouterPath(int provinceId, int cityId, String name)
=> '$districts?province_id=$provinceId&city_id=$cityId&name=$name';
}
/// 查看 `routers/handler.dart` 文件
Handler notFoundHandler = Handler(handlerFunc: (_, params) {
Logger('RouterHandler:').log('Not Found Router'); // 當(dāng)找不到相應(yīng)的路由時豹芯,打印信息處理
});
Handler rootHandler = Handler(handlerFunc: (_, params) => SplashPage());
Handler weatherHandler = Handler(handlerFunc: (_, params) {
String cityId = params['city_id']?.first; // 獲取相應(yīng)的參數(shù)
return WeatherPage(city: cityId);
});
Handler provincesHandler = Handler(handlerFunc: (_, params) => ProvinceListPage());
Handler citiesHandler = Handler(handlerFunc: (_, params) {
String provinceId = params['province_id']?.first;
String name = params['name']?.first;
return CityListPage(provinceId: provinceId,
name: FluroConvertUtils.fluroCnParamsDecode(name));
});
Handler districtsHandler = Handler(handlerFunc: (_, params) {
String provinceId = params['province_id']?.first;
String cityId = params['city_id']?.first;
String name = params['name']?.first;
return DistrictListPage(provinceId: provinceId, cityId: cityId,
name: FluroConvertUtils.fluroCnParamsDecode(name));
});
Handler settingsHandler = Handler(handlerFunc: (_, params) => SettingsPage());
那么界面的路由到這就編寫好了亿卤,但是前面提到了 fluro 目前不支持中文的傳遞,所以在傳遞中文時候热凹,需要先進(jìn)行轉(zhuǎn)碼泵喘,這邊提供一個自己寫的方法,小伙伴有更好的方法也可以直接在項目提 issue
/// 查看 `utils/fluro_convert_util.dart` 文件
class FluroConvertUtils {
/// fluro 傳遞中文參數(shù)前般妙,先轉(zhuǎn)換纪铺,fluro 不支持中文傳遞
static String fluroCnParamsEncode(String originalCn) {
StringBuffer sb = StringBuffer();
var encoded = Utf8Encoder().convert(originalCn); // utf8 編碼,會生成一個 int 列表
encoded.forEach((val) => sb.write('$val,')); // 將 int 列表重新轉(zhuǎn)換成字符串
return sb.toString().substring(0, sb.length - 1).toString();
}
/// fluro 傳遞后取出參數(shù)碟渺,解析
static String fluroCnParamsDecode(String encodedCn) {
var decoded = encodedCn.split('[').last.split(']').first.split(','); // 對參數(shù)字符串分割
var list = <int>[];
decoded.forEach((s) => list.add(int.parse(s.trim()))); // 轉(zhuǎn)回 int 列表
return Utf8Decoder().convert(list); // 解碼
}
}
Database 管理類編寫
因為數(shù)據(jù)庫的開啟是一個很耗資源的過程鲜锚,所以這邊通過單例并提取到頂層。在該項目中苫拍,數(shù)據(jù)庫主要用于存儲城市信息芜繁,因為城市之間的關(guān)聯(lián)比較復(fù)雜,如果通過 shared_preferences
或者文件存儲會很復(fù)雜绒极。
/// 查看 `utils/db_utils.dart` 文件
class DatabaseUtils {
final String _dbName = 'weather.db'; // 數(shù)據(jù)表名
final String _tableProvinces = 'provinces'; // 省表
final String _tableCities = 'cities'; // 市表
final String _tableDistricts = 'districts'; // 區(qū)表
static Database _db;
static DatabaseUtils _instance;
static DatabaseUtils get instance => DatabaseUtils();
/// 將數(shù)據(jù)庫的初始化放到私有構(gòu)造中骏令,值允許通過單例訪問
DatabaseUtils._internal() {
getDatabasesPath().then((path) async {
_db = await openDatabase(join(path, _dbName), version: 1, onCreate: (db, version) {
db.execute('create table $_tableProvinces('
'id integer primary key autoincrement,'
'province_id integer not null unique,' // 省 id,id 唯一
'province_name text not null' // 省名
')');
db.execute('create table $_tableCities('
'id integer primary key autoincrement,'
'city_id integer not null unique,' // 市 id垄提,id 唯一
'city_name text not null,' // 市名
'province_id integer not null,' // 對應(yīng)的省的 id榔袋,作為外鍵同省表關(guān)聯(lián)
'foreign key(province_id) references $_tableProvinces(province_id)'
')');
db.execute('create table $_tableDistricts('
'id integer primary key autoincrement,'
'district_id integer not null unique,' // 區(qū) id
'district_name text not null,' // 區(qū)名
'weather_id text not null unique,' // 查詢天氣用的 id,例如 CN13579826铡俐,id 唯一
'city_id integer not null,' // 對應(yīng)市的 id凰兑,作為外鍵同市表關(guān)聯(lián)
'foreign key(city_id) references $_tableCities(city_id)'
')');
}, onUpgrade: (db, oldVersion, newVersion) {});
});
}
/// 構(gòu)建單例
factory DatabaseUtils() {
if (_instance == null) {
_instance = DatabaseUtils._internal();
}
return _instance;
}
/// 查詢所有的省,`ProvinceModel` 為省市接口返回數(shù)據(jù)生成的 model 類
/// 查看 `model/province_model.dart` 文件
Future<List<ProvinceModel>> queryAllProvinces() async =>
ProvinceModel.fromProvinceTableList(await _db.rawQuery('select province_id, province_name from $_tableProvinces'));
/// 查詢某個省內(nèi)的所有市
Future<List<ProvinceModel>> queryAllCitiesInProvince(String proid) async => ProvinceModel.fromCityTableList(await _db.rawQuery(
'select city_id, city_name from $_tableCities where province_id = ?',
[proid],
));
/// 查詢某個市內(nèi)的所有區(qū)审丘,`DistrictModel` 為區(qū)接口返回數(shù)據(jù)生成的 model 類
/// 查看 `model/district_model.dart` 文件
Future<List<DistrictModel>> queryAllDistrictsInCity(String cityid) async => DistrictModel.fromDistrictTableList(await _db.rawQuery(
'select district_id, district_name, weather_id from $_tableDistricts where city_id = ?',
[cityid],
));
/// 將所有的省插入數(shù)據(jù)庫
Future<void> insertProvinces(List<ProvinceModel> provinces) async {
var batch = _db.batch();
provinces.forEach((p) => batch.rawInsert(
'insert or ignore into $_tableProvinces (province_id, province_name) values (?, ?)',
[p.id, p.name],
));
batch.commit();
}
/// 將省對應(yīng)下的所有市插入數(shù)據(jù)庫
Future<void> insertCitiesInProvince(List<ProvinceModel> cities, String proid) async {
var batch = _db.batch();
cities.forEach((c) => batch.rawInsert(
'insert or ignore into $_tableCities (city_id, city_name, province_id) values (?, ?, ?)',
[c.id, c.name, proid],
));
batch.commit();
}
/// 將市下的所有區(qū)插入數(shù)據(jù)庫
Future<void> insertDistrictsInCity(List<DistrictModel> districts, String cityid) async {
var batch = _db.batch();
districts.forEach((d) => batch.rawInsert(
'insert or ignore into $_tableDistricts (district_id, district_name, weather_id, city_id) values (?, ?, ?, ?)',
[d.id, d.name, d.weatherId, cityid],
));
batch.commit();
}
}
定義完全局使用的方法吏够,就可以在 main
函數(shù)中進(jìn)行相關(guān)的初始化了
/// 查看 `main.dart` 文件
void main() {
// 初始化 fluro router
Router router = Router();
Routers.configureRouters(router);
Application.router = router;
// 初始化 http
Application.http = HttpUtils(baseUrl: WeatherApi.WEATHER_HOST);
// 初始化 db
Application.db = DatabaseUtils.instance;
// 強(qiáng)制豎屏,因為設(shè)置豎屏為 `Future` 方法,防止設(shè)置無效可等返回值后再啟動 App
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitDown, DeviceOrientation.portraitUp]).then((_) {
runApp(WeatherApp()); // App 類可放在同個文件稿饰,個人習(xí)慣單獨一個文件存放
if (Platform.isAndroid) {
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(statusBarColor: Colors.transparent));
}
});
}
class WeatherApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Weather App',
onGenerateRoute: Application.router.generator, // 將 fluro 的路由進(jìn)行注冊
debugShowCheckedModeBanner: false,
);
}
}
初始化完畢锦秒,接著就可以進(jìn)行頁面的編寫了。
首頁編寫
首頁主要是為了對 App 的一個大概展示喉镰,或者是一些廣告的展示旅择,同時也給一些數(shù)據(jù)初始化提供時間,當(dāng)用戶進(jìn)入后有更好的體驗效果侣姆。我們在這里就做一個圖標(biāo)的展示(圖標(biāo)可自行到項目中 images
文件夾查找)生真,延時 5s 后跳轉(zhuǎn)下個頁面。
/// 查看 `splash_page.dart` 文件
class SplashPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
/// 因為已經(jīng)引入了 rxdart捺宗,這里通過 rxdart.timer 進(jìn)行倒計時
/// 當(dāng)然也可以使用 Futuer.delayed 進(jìn)行倒計時
/// 5s 計時柱蟀,如果已經(jīng)選擇城市,跳轉(zhuǎn)天氣界面蚜厉,否則進(jìn)入城市選擇
Observable.timer(0, Duration(milliseconds: 5000)).listen((_) {
PreferenceUtils.instance.getString(PreferencesKey.WEATHER_CITY_ID)
.then((city) {
// 如果當(dāng)前還未選擇城市长已,則進(jìn)入城市選擇頁,否則跳轉(zhuǎn)天氣詳情頁
// replace: true 即為 Navigator.pushReplacement 方法
Application.router.navigateTo(context, city.isEmpty
? Routers.provinces
: Routers.generateWeatherRouterPath(city),
replace: true);
});
});
return Scaffold(
body: Container(
alignment: Alignment.center,
color: Colors.white,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// 展示圖標(biāo)
Image.asset(Resource.pngSplash, width: 200.0, height: 200.0),
// 展示文字提醒昼牛,用 SizedBox 設(shè)置區(qū)域大小
SizedBox(
width: MediaQuery.of(context).size.width * 0.7,
child: Text(
'所有天氣數(shù)據(jù)均為模擬數(shù)據(jù)术瓮,僅用作學(xué)習(xí)目的使用,請勿當(dāng)作真實的天氣預(yù)報軟件來使用',
textAlign: TextAlign.center,
softWrap: true,
style: TextStyle(color: Colors.red[700], fontSize: 16.0),
))
],
),
),
);
}
}
城市選擇頁面
當(dāng)首次進(jìn)入的時候贰健,用戶肯定沒有選擇城市胞四,所以先編寫城市選擇列表頁面,因為整體的項目使用 BLoC 分離業(yè)務(wù)邏輯和頁面伶椿,所以先編寫數(shù)據(jù)管理類吧辜伟,把數(shù)據(jù)請求和改變的業(yè)務(wù)邏輯放到這塊,BLoC 的實現(xiàn)在前面講過了脊另,這邊就不重復(fù)提了导狡。可以查看文章:狀態(tài)管理及 BLoC
/// 查看 `provinces_bloc.dart` 文件
class ProvincesBloc extends BaseBloc {
final _logger = Logger('ProvincesBloc');
List<ProvinceModel> _provinces = []; // 全國省
List<ProvinceModel> _cities = []; // 省內(nèi)市
List<DistrictModel> _districts = []; // 市內(nèi)區(qū)
List<ProvinceModel> get provinces => _provinces;
List<ProvinceModel> get cities => _cities;
List<DistrictModel> get districts => _districts;
BehaviorSubject<List<ProvinceModel>> _provinceController = BehaviorSubject();
BehaviorSubject<List<ProvinceModel>> _citiesController = BehaviorSubject();
BehaviorSubject<List<DistrictModel>> _districtController = BehaviorSubject();
/// stream尝蠕,用于 StreamBuilder 的 stream 參數(shù)
Observable<List<ProvinceModel>> get provinceStream
=> Observable(_provinceController.stream);
Observable<List<ProvinceModel>> get cityStream => Observable(_citiesController.stream);
Observable<List<DistrictModel>> get districtStream
=> Observable(_districtController.stream);
/// 通知刷新省份列表
changeProvinces(List<ProvinceModel> provinces) {
_provinces.clear();
_provinces.addAll(provinces);
_provinceController.add(_provinces);
}
/// 通知刷新城市列表
changeCities(List<ProvinceModel> cities) {
_cities.clear();
_cities.addAll(cities);
_citiesController.add(_cities);
}
/// 通知刷新區(qū)列表
changeDistricts(List<DistrictModel> districts) {
_districts.clear();
_districts.addAll(districts);
_districtController.add(_districts);
}
/// 請求全國省
Future<List<ProvinceModel>> requestAllProvinces() async {
var resp = await Application.http.getRequest(WeatherApi.WEATHER_PROVINCE,
error: (msg) => _logger.log(msg, 'province'));
return resp == null || resp.data == null ? [] : ProvinceModel.fromMapList(resp.data);
}
/// 請求省內(nèi)城市
Future<List<ProvinceModel>> requestAllCitiesInProvince(String proid) async {
var resp = await Application.http
.getRequest('${WeatherApi.WEATHER_PROVINCE}/$proid',
error: (msg) => _logger.log(msg, 'city'));
return resp == null || resp.data == null ? [] : ProvinceModel.fromMapList(resp.data);
}
/// 請求市內(nèi)的區(qū)
Future<List<DistrictModel>> requestAllDistricts(String proid, String cityid) async {
var resp = await Application.http
.getRequest('${WeatherApi.WEATHER_PROVINCE}/$proid/$cityid',
error: (msg) => _logger.log(msg, 'district'));
return resp == null || resp.data == null ? [] : DistrictModel.fromMapList(resp.data);
}
@override
void dispose() { // 及時銷毀
_provinceController?.close();
_citiesController?.close();
_districtController?.close();
}
}
寫完 BLoC 需要對其進(jìn)行注冊烘豌,因為城市選擇相對還是比較頻繁的,所以可以放最頂層進(jìn)行注冊
return BlocProvider(
bloc: ProvincesBloc(), // 城市切換 BLoC
child: MaterialApp(
title: 'Weather App',
onGenerateRoute: Application.router.generator,
debugShowCheckedModeBanner: false,
),
);
城市選擇就是一個列表看彼,直接通過 ListView
生成即可廊佩,前面講 ListView
的時候提到,盡可能固定 item 的高度靖榕,會提高繪制效率
/// 查看 `provinces_page.dart` 文件
class ProvinceListPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var _bloc = BlocProvider.of<ProvincesBloc>(context);
// 進(jìn)入的時候先使用數(shù)據(jù)庫的數(shù)據(jù)填充界面
Application.db.queryAllProvinces().then((ps) => _bloc.changeProvinces(ps));
// 網(wǎng)絡(luò)數(shù)據(jù)更新列表并刷新數(shù)據(jù)庫數(shù)據(jù)
_bloc.requestAllProvinces().then((provinces) {
_bloc.changeProvinces(provinces);
Application.db.insertProvinces(provinces);
});
return Scaffold(
appBar: AppBar(
title: Text('請選擇省份'),
),
body: Container(
color: Colors.black12,
alignment: Alignment.center,
// 省列表選擇
child: StreamBuilder(
stream: _bloc.provinceStream,
initialData: _bloc.provinces,
builder: (_, AsyncSnapshot<List<ProvinceModel>> snapshot)
=> !snapshot.hasData || snapshot.data.isEmpty
// 如果當(dāng)前的數(shù)據(jù)未加載則給一個加載标锄,否則顯示列表加載
? CupertinoActivityIndicator(radius: 12.0)
: ListView.builder(
physics: BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemBuilder: (_, index) => InkWell(
child: Container(
alignment: Alignment.centerLeft,
child: Text(snapshot.data[index].name, style: TextStyle(fontSize: 18.0, color: Colors.black)),
),
onTap: () => Application.router.navigateTo(
context,
// 跳轉(zhuǎn)下層省內(nèi)城市選擇,需要將當(dāng)前的省 id 以及省名傳入
Routers.
generateProvinceRouterPath(snapshot.data[index].id, FluroConvertUtils.fluroCnParamsEncode(snapshot.data[index].name)),
transition: TransitionType.fadeIn),
),
itemExtent: 50.0,
itemCount: snapshot.data.length),
),
),
);
}
}
對于市和區(qū)的列表選擇也類似茁计,除了最后的點擊會有些區(qū)別頁面的布局幾乎一致料皇,這邊只提下點擊事件
/// 查看 `cities_page.dart` 文件
Application.router.navigateTo(
context,
// 跳轉(zhuǎn)下層省內(nèi)城市選擇
Routers.generateProvinceRouterPath(
snapshot.data[index].id, FluroConvertUtils.fluroCnParamsEncode(snapshot.data[index].name)),
transition: TransitionType.fadeIn),
)
// 設(shè)置為當(dāng)前區(qū)谓松,并清理路由 stack,并將天氣界面設(shè)置到最上層
onTap: () {
PreferenceUtils.instance
.saveString(PreferencesKey.WEATHER_CITY_ID, snapshot.data[index].weatherId);
Application.router.navigateTo(context, Routers.generateWeatherRouterPath(snapshot.data[index].weatherId),
transition: TransitionType.inFromRight, clearStack: true);
})
天氣詳情頁面
天氣詳情頁面相對部件會多點践剂,為了看著舒服一點鬼譬,這里拆成多個部分來編寫,在這之前還是先編寫數(shù)據(jù)的管理類逊脯,因為天氣詳情接口返回的數(shù)據(jù)嵌套層次比較多优质,關(guān)系比較復(fù)雜,不適合用 database
來做持久化军洼,所以這里采用文件持久化方式巩螃。當(dāng)然有些小伙伴會問干嘛不使用 shared_preferences
來存儲,理論上應(yīng)該沒有太大的問題匕争,但是個人建議相對復(fù)雜的數(shù)據(jù)使用文件存儲會相對比較好點避乏,一定要說個為什么,我也說不出來甘桑。
/// 查看 `weather_bloc.dart` 文件
class WeatherBloc extends BaseBloc {
final _logger = Logger('WeatherBloc');
WeatherModel _weather; // 天氣情況
String _background = WeatherApi.DEFAULT_BACKGROUND; // 背景
WeatherModel get weather => _weather;
String get background => _background;
BehaviorSubject<WeatherModel> _weatherController = BehaviorSubject();
BehaviorSubject<String> _backgroundController = BehaviorSubject();
Observable<WeatherModel> get weatherStream => Observable(_weatherController.stream);
Observable<String> get backgroundStream => Observable(_backgroundController.stream);
/// 更新天氣情況
updateWeather(WeatherModel weather) {
_weather = weather;
_weatherController.add(_weather);
}
/// 更新天氣背景
updateBackground(String background) {
_background = background;
_backgroundController.add(_background);
}
// 請求天氣情況
Future<WeatherModel> requestWeather(String id) async {
var resp = await Application.http
.getRequest(WeatherApi.WEATHER_STATUS,
params: {'cityid': id, 'key': WeatherApi.WEATHER_KEY},
error: (msg) => _logger.log(msg, 'weather'));
// 請求數(shù)據(jù)成功則寫入到文件中
if (resp != null && resp.data != null) {
_writeIntoFile(json.encode(resp.data));
}
return WeatherModel.fromMap(resp.data);
}
Future<String> requestBackground() async {
var resp = await Application.http
.getRequest<String>(WeatherApi.WEATHER_BACKGROUND,
error: (msg) => _logger.log(msg, 'background'));
return resp == null || resp.data == null ? WeatherApi.DEFAULT_BACKGROUND : resp.data;
}
// 獲取存儲文件路徑
Future<String> _getPath() async =>
'${(await getApplicationDocumentsDirectory()).path}/weather.txt';
// 寫入到文件
_writeIntoFile(String contents) async {
File file = File(await _getPath());
if (await file.exists()) file.deleteSync();
file.createSync();
file.writeAsString(contents);
}
// 文件讀取存儲信息拍皮,如果不存在文件則返回空字符串 '',不推薦返回 null
Future<String> readWeatherFromFile() async {
File file = File(await _getPath());
return (await file.exists()) ? file.readAsString() : '';
}
@override
void dispose() {
_weatherController?.close();
_backgroundController?.close();
}
}
天氣詳情的刷新只有當(dāng)個頁面跑杭,所以 BLoC 的注冊值需要在路由上注冊即可春缕,在 fluro 對應(yīng) handler 中加入注冊
Handler weatherHandler = Handler(handlerFunc: (_, params) {
String cityId = params['city_id']?.first; // 這個 id 可以通過 BLoC 獲取也可以
return BlocProvider(child: WeatherPage(city: cityId), bloc: WeatherBloc());
});
那么接下來就可以編寫界面了,先實現(xiàn)最外層的背景圖變化
/// 查看 `weather_page.dart` 文件
class WeatherPage extends StatelessWidget {
final String city;
WeatherPage({Key key, this.city}) : super(key: key);
@override
Widget build(BuildContext context) {
var _bloc = BlocProvider.of<WeatherBloc>(context);
// 請求背景并更新
_bloc.requestBackground().then((b) => _bloc.updateBackground(b));
// 先讀取本地文件緩存進(jìn)行頁面填充
_bloc.readWeatherFromFile().then((s) {
if (s.isNotEmpty) {
_bloc.updateWeather(WeatherModel.fromMap(json.decode(s)));
}
});
// 再請求網(wǎng)絡(luò)更新數(shù)據(jù)
_bloc.requestWeather(city).then((w) => _bloc.updateWeather(w));
return Scaffold(
body: StreamBuilder(
stream: _bloc.backgroundStream,
initialData: _bloc.background,
builder: (_, AsyncSnapshot<String> themeSnapshot) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 20.0),
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.black12,
image: DecorationImage(
image: NetworkImage(themeSnapshot.data), fit: BoxFit.cover),
),
child: // 具體內(nèi)部布局通過拆分小部件實現(xiàn)
)),
);
}
}
頁面最頂部是顯示兩個按鈕艘蹋,一個跳轉(zhuǎn)城市選擇,一個跳轉(zhuǎn)設(shè)置頁面票灰,顯示當(dāng)前的城市
class FollowedHeader extends StatelessWidget {
final AsyncSnapshot<WeatherModel> snapshot; // snapshot 通過上層傳入
FollowedHeader({Key key, this.snapshot}) : super(key: key);
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
// 城市選擇頁面跳轉(zhuǎn)按鈕
IconButton(
icon: Icon(Icons.home, color: Colors.white, size: 32.0),
onPressed: () => Application.router.
navigateTo(context, Routers.provinces,
transition: TransitionType.inFromLeft)),
// 當(dāng)前城市
Text('${snapshot.data.heWeather[0].basic.location}',
style: TextStyle(fontSize: 28.0, color: Colors.white)),
// 設(shè)置頁面跳轉(zhuǎn)按鈕
IconButton(
icon: Icon(Icons.settings, color: Colors.white, size: 32.0),
onPressed: () => Application.router
.navigateTo(context, Routers.settings,
transition: TransitionType.inFromRight))
],
);
}
}
接著是當(dāng)前的天氣詳情部分
class CurrentWeatherState extends StatelessWidget {
final AsyncSnapshot<WeatherModel> snapshot;
CurrentWeatherState({Key key, this.snapshot}) : super(key: key);
@override
Widget build(BuildContext context) {
var _now = snapshot.data.heWeather[0].now;
var _update = snapshot.data.heWeather[0].update.loc.split(' ').last;
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
// 當(dāng)前的溫度
Text('${_now.tmp}℃', style: TextStyle(fontSize: 50.0, color: Colors.white)),
// 當(dāng)前的天氣狀況
Text('${_now.condTxt}', style: TextStyle(fontSize: 24.0, color: Colors.white)),
Row( // 刷新的時間
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Icon(Icons.refresh, size: 16.0, color: Colors.white),
Padding(padding: const EdgeInsets.only(left: 4.0)),
Text(_update, style: TextStyle(fontSize: 12.0, color: Colors.white))
],
)
],
);
}
}
接下來是一個天氣預(yù)報的列表塊女阀,以為是一個列表,當(dāng)然可以通過 Cloumn
來實現(xiàn)屑迂,但是前面有提到過一個列表「粘合劑」---- CustomScrollView
浸策,所以這里的整體連接最后會通過 CustomScrollView
來實現(xiàn),那么你可以放心在最上層容器的 child
屬性加上 CustomScrollView
了惹盼。接著來實現(xiàn)這塊預(yù)報模塊
class WeatherForecast extends StatelessWidget {
final AsyncSnapshot<WeatherModel> snapshot;
WeatherForecast({Key key, this.snapshot}) : super(key: key);
@override
Widget build(BuildContext context) {
var _forecastList = snapshot.data.heWeather[0].dailyForecasts; // 獲取天氣預(yù)報
return SliverFixedExtentList(
delegate: SliverChildBuilderDelegate(
(_, index) => Container(
color: Colors.black54, // 外層設(shè)置背景色庸汗,防止被最外層圖片背景遮擋文字
padding: const EdgeInsets.all(12.0),
alignment: Alignment.centerLeft,
child: index == 0 // 當(dāng)?shù)谝粋€ item 情況,顯示 ‘預(yù)報’
? Text('預(yù)報', style: TextStyle(fontSize: 24.0, color: Colors.white))
: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text(_forecastList[index - 1].date, // 預(yù)報的日期
style: TextStyle(fontSize: 16.0, color: Colors.white)),
Expanded( // 天氣情況手报,這邊通過 expanded 進(jìn)行占位蚯舱,并居中顯示
child: Center(child: Text(_forecastList[index - 1].cond.txtD,
style: TextStyle(fontSize: 16.0, color: Colors.white))),
flex: 2),
Expanded(
child: Row( // 最高溫度,最低溫度
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(_forecastList[index - 1].tmp.max,
style: TextStyle(fontSize: 16.0,
color: Colors.white)),
Text(_forecastList[index - 1].tmp.min,
style: TextStyle(fontSize: 16.0,
color: Colors.white)),
],
),
flex: 1)
],
)),
childCount: _forecastList.length + 1, // 這個數(shù)量需要 +1掩蛤,因為有個標(biāo)題需要一個數(shù)量
),
itemExtent: 50.0);
}
}
接著是空氣質(zhì)量報告枉昏,一個標(biāo)題,下面由兩個布局進(jìn)行平分
class AirQuality extends StatelessWidget {
final AsyncSnapshot<WeatherModel> snapshot;
AirQuality({Key key, this.snapshot}) : super(key: key);
@override
Widget build(BuildContext context) {
var quality = snapshot.data.heWeather[0].aqi.city;
return Container(
padding: const EdgeInsets.all(12.0),
color: Colors.black54,
alignment: Alignment.centerLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 標(biāo)題
Padding(padding: const EdgeInsets.only(bottom: 20.0), child:
Text('空氣質(zhì)量', style: TextStyle(fontSize: 24.0,
color: Colors.white))),
Row(
children: <Widget>[
// 通過 expanded 進(jìn)行平分橫向距離
Expanded(
child: Center(
// 內(nèi)部居中顯示
child: Column(
children: <Widget>[
Text('${quality.aqi}', style:
TextStyle(fontSize: 40.0, color: Colors.white)),
Text('AQI 指數(shù)', style:
TextStyle(fontSize: 20.0, color: Colors.white)),
],
),
)),
Expanded(
child: Center(
child: Column(
children: <Widget>[
Text('${quality.pm25}', style:
TextStyle(fontSize: 40.0, color: Colors.white)),
Text('PM2.5 指數(shù)', style:
TextStyle(fontSize: 20.0, color: Colors.white)),
],
),
)),
],
)
],
));
}
}
接下來是生活質(zhì)量模塊揍鸟,看著也是個列表兄裂,但是后臺返回的不是列表,而是根據(jù)不同字段獲取不同質(zhì)量指數(shù),因為布局類似晰奖,所以可以對其進(jìn)行封裝再整體調(diào)用
class LifeSuggestions extends StatelessWidget {
final AsyncSnapshot<WeatherModel> snapshot;
LifeSuggestions({Key key, this.snapshot}) : super(key: key);
// 生活指數(shù)封裝
Widget _suggestionWidget(String content) =>
Padding(padding: const EdgeInsets.only(top: 20.0), child:
Text(content, style: TextStyle(color: Colors.white, fontSize: 16.0)));
@override
Widget build(BuildContext context) {
var _suggestion = snapshot.data.heWeather[0].suggestion;
return Container(
padding: const EdgeInsets.all(12.0),
color: Colors.black54,
alignment: Alignment.centerLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('生活建議', style: TextStyle(fontSize: 24.0, color: Colors.white)),
_suggestionWidget('舒適度:${_suggestion.comf.brf}\n${_suggestion.comf.txt}'),
_suggestionWidget('洗車指數(shù):${_suggestion.cw.brf}\n${_suggestion.cw.txt}'),
_suggestionWidget('運動指數(shù):
${_suggestion.sport.brf}\n${_suggestion.sport.txt}'),
],
),
);
}
}
所有的分模塊都已經(jīng)編寫完成谈撒,剩下就是通過粘合劑進(jìn)行組裝了
child: StreamBuilder(
initialData: _bloc.weather,
stream: _bloc.weatherStream,
builder: (_, AsyncSnapshot<WeatherModel> snapshot) => !snapshot.hasData
? CupertinoActivityIndicator(radius: 12.0)
: SafeArea(
child: RefreshIndicator(
child: CustomScrollView(
physics: BouncingScrollPhysics(),
slivers: <Widget>[
SliverToBoxAdapter(child: FollowedHeader(snapshot: snapshot)),
// 實時天氣
SliverPadding(
padding: const EdgeInsets.symmetric(vertical: 30.0),
sliver: SliverToBoxAdapter(
child: CurrentWeatherState(snapshot: snapshot, city: city),
),
),
// 天氣預(yù)報
WeatherForecast(snapshot: snapshot),
// 空氣質(zhì)量
SliverPadding(
padding: const EdgeInsets.symmetric(vertical: 30.0),
sliver: SliverToBoxAdapter(child: AirQuality(snapshot: snapshot)),
),
// 生活建議
SliverToBoxAdapter(child: LifeSuggestions(snapshot: snapshot))
],
),
onRefresh: () async {
_bloc.requestWeather(city).then((w) => _bloc.updateWeather(w));
return null;
}),
)),
最后就剩下設(shè)置頁的全局主題切換了
設(shè)置頁全局主題切換
既然提到了數(shù)據(jù)的切換,那肯定就涉及 BLoC 毫無疑問了匾南,還是照常編寫管理類
/// 查看 `setting_bloc.dart` 文件
class SettingBloc extends BaseBloc {
/// 所有主題色列表
static const themeColors = [Colors.blue, Colors.red, Colors.green,
Colors.deepOrange, Colors.pink, Colors.purple];
Color _color = themeColors[0];
Color get color => _color;
BehaviorSubject<Color> _colorController = BehaviorSubject();
Observable<Color> get colorStream => Observable(_colorController.stream);
/// 切換主題通知刷新
switchTheme(int themeIndex) {
_color = themeColors[themeIndex];
_colorController.add(_color);
}
@override
void dispose() {
_colorController?.close();
}
}
因為是全局的切換啃匿,那么這個 BLoC 肯定需要在最頂層進(jìn)行注冊,這邊就不貼代碼了午衰,同 ProvinceBloc
一致立宜。接著編寫界面,設(shè)置界面因為有 GridView
和其他部件臊岸,所以也需要用 CustomScrollView
作為粘合劑橙数,當(dāng)然,你也可以用 Wrap
代替 GridView
來實現(xiàn)網(wǎng)格帅戒,就不需要用 CustomScrollView
灯帮,使用 Column
即可。
class SettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var _bloc = BlocProvider.of<SettingBloc>(context);
return StreamBuilder(
stream: _bloc.colorStream,
initialData: _bloc.color,
// Theme 是 Flutter 自帶的一個設(shè)置主題的部件逻住,里面可以設(shè)置多種顏色钟哥,
// 通過接收到 color 的變化,改變主題色瞎访,其他頁面也如此設(shè)置腻贰,小伙伴可以自己添加
builder: (_, AsyncSnapshot<Color> snapshot) => Theme(
// IconThemeData 用于設(shè)置按鈕的主題色
data: ThemeData(primarySwatch: snapshot.data, iconTheme: IconThemeData(color: snapshot.data)),
child: Scaffold(
appBar: AppBar(
title: Text('設(shè)置'),
),
body: Container(
color: Colors.black12,
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 20.0),
child: CustomScrollView(
slivers: <Widget>[
SliverPadding(
padding: const EdgeInsets.only(right: 12.0),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text('當(dāng)前主題色:', style: TextStyle(fontSize: 16.0,
color: snapshot.data)),
Container(width: 20.0, height: 20.0, color: snapshot.data)
],
)),
),
SliverPadding(padding: const EdgeInsets.symmetric(vertical: 15.0)),
SliverGrid(
delegate: SliverChildBuilderDelegate(
(_, index) => InkWell(
child: Container(color: SettingBloc.themeColors[index]),
onTap: () {
// 選擇后進(jìn)行保存,當(dāng)下次進(jìn)入的時候直接使用該主題色
// 同時切換主題色
_bloc.switchTheme(index);
PreferenceUtils.instance.saveInteger(PreferencesKey.THEME_COLOR_INDEX, index);
},
),
childCount: SettingBloc.themeColors.length),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, mainAxisSpacing: 20.0, crossAxisSpacing: 20.0)),
],
),
),
),
));
}
}
最終全局的主題切換也實現(xiàn)了扒秸。
編寫完代碼播演,需要打包啊,Android 下的打包大家肯定沒問題伴奥,這里講下 flutter 下如何打包 apk写烤,ipa 因為沒有 mac 所以你們懂的。
apk 文件打包
-
創(chuàng)建 jks 文件拾徙,如果已經(jīng)存在可忽略這步從第二步開始洲炊。打開終端并輸入
keytool -genkey -v -keystore [你的簽名文件路徑].jks -keyalg RSA -keysize 2048 -validity 10000 -alias key
release1.png然后輸入密碼以及一些基本信息就可以創(chuàng)建成功了
-
在項目的
android
目錄下創(chuàng)建一個key.properties
文件,里面進(jìn)行如下配置storePassword=<password from previous step> keyPassword=<password from previous step> keyAlias=key storeFile=<[你的簽名文件路徑].jks>
-
在
android/app
下的build.gradle
中進(jìn)行如下修改apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" // 增加如下部分代碼 def keystorePropertiesFile = rootProject.file("key.properties") def keystoreProperties = new Properties() keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) android { // ... defaultConfigs{ // ... } // 增加如下代碼 signingConfigs { release { keyAlias keystoreProperties['keyAlias'] keyPassword keystoreProperties['keyPassword'] storeFile file(keystoreProperties['storeFile']) storePassword keystoreProperties['storePassword'] } } buildTypes{ // ... } }
-
再次打開終端運行
flutter build apk
會自動生成一個 apk 文件尼啡,文件路徑為[你的項目地址]\build\app\outputs\apk\release
通過
flutter install
就可以將正式包運行到手機(jī)上
總結(jié)
2019.03.09 - 2019.04.08暂衡,一個月時間,花了整整一個月終于是寫完了崖瞭,也算是給一直關(guān)注的小伙伴們有個交代了古徒。對于寫系列文,說實話真的很煎熬读恃,一是因為前期需要做好整體的思路構(gòu)造隧膘,需要從簡入繁代态,一步步深入,如果整體思路沒有搭建好疹吃,那就會走向一條不歸路蹦疑,要么硬著頭皮上,要么退回來從頭開始萨驶,二是為了寫這套系列文歉摧,基本上舍棄了所有的私人時間,寫完了自己還得看上好幾遍才敢發(fā)布腔呜。但是收獲的的確很多叁温,期間看了很多 flutter 的源碼,對部件上的一些認(rèn)識和理解也會有所的加深核畴,也會去學(xué)習(xí)源碼的編寫規(guī)范膝但,對日后工作上也會有很大幫助。最后希望這套系列文能帶更多的小伙伴入門 Flutter谤草,大家下次再見咯~