Flutter入門指北(Part14)之實戰篇

NO IMAGE

該文已授權公眾號 「碼個蛋」,轉載請指明出處

講完了常用的部件和網絡請求後,差不多該進入整體實戰了,這裡我們將寫一個比較熟悉的項目,郭神的 cool weather。項目將使用 fluro 實現路由管理,dio 實現網絡請求,rxdart 實現 BLoC 進行狀態管理和邏輯分離,使用文件,shared_preferences,sqflite 實現本地的數據持久化。這邊先給出項目的地址:flutter_weather,以及最後實現的效果圖:

Flutter入門指北(Part14)之實戰篇

Flutter入門指北(Part14)之實戰篇

Flutter入門指北(Part14)之實戰篇

Flutter入門指北(Part14)之實戰篇

除了 fluro 別的基本上前面都講了,所以在開始正式的實戰前,先講下 fluro

Fluro

fluro 是對 Navigator 的一個封裝,方便更好的管理路由跳轉,當然還存在一些缺陷,例如目前只支持傳遞字符串,不能傳遞中文等,但是這些問題都算不上是大問題。

fluro 的使用很簡單,大概分如下的步驟:

  1. 在全局定義一個 Router 實例

    final router = Router(); 
    
  2. 使用 Router 實例定義路徑和其對應的 Handler 對象

    // 例如定義一個 CityPage 的路徑和 Handler
    Handler cityHandler = Handler(handlerFunc: (_, params) {
    // 傳遞的參數都在 params 中,params 是一個 Map<String, List<String>> 類型參數
    String cityId = params['city_id']?.first; 
    return BlocProvider(child: WeatherPage(city: cityId), bloc: WeatherBloc());
    });
    // 定義路由的路徑和參數
    // 需要注意的是,第一個頁面的路徑必須為 "/",別的可為 "/" + 任意拼接
    router.define('/city', handler: cityHandler);
    // 或者官方提供的另一種方式
    router.define('/city/:city_id', handler: cityHandler);
    
  3. router 註冊到 MaterialApponGenerateRoute

    MaterialApp(onGenerateRoute: router); 
    
  4. 最後通過 Router 實例進行跳轉,如果有參數傳遞則會在新的頁面收到

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

在 fluro 中提供了多種路由動畫,包括 fadeIninFromRight 等。講完了使用,就進入實戰了。

####flutter_weather 實戰

導入插件

在開始的時候,已經提到了整體功能的實現需求,所以這邊需要導入的插件以及存放圖片的文件夾如下:

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/
頂層靜態實例的實現

有許多實例需要在頂層註冊,然後在全局使用,包括但不限於 fluro 的 router,http,database 等等。在這個項目中,需要用到的就是這三個實例,會在全局調用,所以在開始前進行初始化,當然 http 和 database 在使用的時候創建也可以,完全看個人習慣,但是 fluro 的管理類必須在一開始就註冊完成。首先需要定義一個 Application 類用來存放這些靜態實例

class Application {
static HttpUtils http; // 全局網絡
static Router router; // 全局路由
static DatabaseUtils db; // 全局數據庫
}

接著就是對相應方法類的編寫,其中 HttpUtilDatabaseUtils 在前面有講過,這邊不重複講,會講下數據庫如何建立。

######Fluro 路由管理類

首先,需要知道,該項目的界面大概分如下的界面(當然可先只定義首頁,剩下用到了再定義,該項目相對簡單,所以先列出來):省選擇頁,市選擇頁,區選擇頁,天氣展示頁,設置頁。所以 fluro 的管理類可按如下定義:

// 查看 `routers/routers.dart` 文件
class Routers {
/// 各個頁面對應的路徑
static const root = '/';
static const weather = '/weather';
static const provinces = '/provinces';
static const cities = '/cities';
static const districts = '/districts';
static const settings = '/settings';
/// 該方法用於放到 `main` 方法中定義所有的路由,
/// 對應的 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); // 市下區列表頁
router.define(settings, handler: settingsHandler); // 設置頁
}
/// 生成天氣顯示頁面路徑,需要用到城市 id
static generateWeatherRouterPath(String cityId) => '$weather?city_id=$cityId';
/// 生成省下的市列表頁相應路徑 需要用到省 id 及省名
static generateProvinceRouterPath(int provinceId, String name)
=> '$cities?province_id=$provinceId&name=$name';
/// 生成市下的區列表頁相應路徑,需用到市 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'); // 當找不到相應的路由時,打印信息處理
});
Handler rootHandler = Handler(handlerFunc: (_, params) => SplashPage());
Handler weatherHandler = Handler(handlerFunc: (_, params) {
String cityId = params['city_id']?.first; // 獲取相應的參數
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 目前不支持中文的傳遞,所以在傳遞中文時候,需要先進行轉碼,這邊提供一個自己寫的方法,小夥伴有更好的方法也可以直接在項目提 issue

/// 查看 `utils/fluro_convert_util.dart` 文件
class FluroConvertUtils {
/// fluro 傳遞中文參數前,先轉換,fluro 不支持中文傳遞
static String fluroCnParamsEncode(String originalCn) {
StringBuffer sb = StringBuffer();
var encoded = Utf8Encoder().convert(originalCn); // utf8 編碼,會生成一個 int 列表
encoded.forEach((val) => sb.write('$val,')); // 將 int 列表重新轉換成字符串
return sb.toString().substring(0, sb.length - 1).toString();
}
/// fluro 傳遞後取出參數,解析
static String fluroCnParamsDecode(String encodedCn) {
var decoded = encodedCn.split('[').last.split(']').first.split(','); // 對參數字符串分割
var list = <int>[];
decoded.forEach((s) => list.add(int.parse(s.trim()))); // 轉回 int 列表
return Utf8Decoder().convert(list); // 解碼
}
}
Database 管理類編寫

因為數據庫的開啟是一個很耗資源的過程,所以這邊通過單例並提取到頂層。在該項目中,數據庫主要用於存儲城市信息,因為城市之間的關聯比較複雜,如果通過 shared_preferences 或者文件存儲會很複雜。

/// 查看 `utils/db_utils.dart` 文件
class DatabaseUtils {
final String _dbName = 'weather.db'; // 數據表名
final String _tableProvinces = 'provinces'; // 省表
final String _tableCities = 'cities'; // 市表
final String _tableDistricts = 'districts'; // 區表
static Database _db;
static DatabaseUtils _instance;
static DatabaseUtils get instance => DatabaseUtils();
/// 將數據庫的初始化放到私有構造中,值允許通過單例訪問
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,' // 對應的省的 id,作為外鍵同省表關聯
'foreign key(province_id) references $_tableProvinces(province_id)'
')');
db.execute('create table $_tableDistricts('
'id integer primary key autoincrement,'
'district_id integer not null unique,' // 區 id
'district_name text not null,' // 區名
'weather_id text not null unique,' // 查詢天氣用的 id,例如 CN13579826,id 唯一
'city_id integer not null,' // 對應市的 id,作為外鍵同市表關聯
'foreign key(city_id) references $_tableCities(city_id)'
')');
}, onUpgrade: (db, oldVersion, newVersion) {});
});
}
/// 構建單例
factory DatabaseUtils() {
if (_instance == null) {
_instance = DatabaseUtils._internal();
}
return _instance;
}
/// 查詢所有的省,`ProvinceModel` 為省市接口返回數據生成的 model 類
/// 查看 `model/province_model.dart` 文件
Future<List<ProvinceModel>> queryAllProvinces() async =>
ProvinceModel.fromProvinceTableList(await _db.rawQuery('select province_id, province_name from $_tableProvinces'));
/// 查詢某個省內的所有市
Future<List<ProvinceModel>> queryAllCitiesInProvince(String proid) async => ProvinceModel.fromCityTableList(await _db.rawQuery(
'select city_id, city_name from $_tableCities where province_id = ?',
[proid],
));
/// 查詢某個市內的所有區,`DistrictModel` 為區接口返回數據生成的 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],
));
/// 將所有的省插入數據庫
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();
}
/// 將省對應下的所有市插入數據庫
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();
}
/// 將市下的所有區插入數據庫
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 函數中進行相關的初始化了

/// 查看 `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;
// 強制豎屏,因為設置豎屏為 `Future` 方法,防止設置無效可等返回值後再啟動 App
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitDown, DeviceOrientation.portraitUp]).then((_) {
runApp(WeatherApp()); // App 類可放在同個文件,個人習慣單獨一個文件存放
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 的路由進行註冊
debugShowCheckedModeBanner: false,
);
}
}

初始化完畢,接著就可以進行頁面的編寫了。

首頁編寫

首頁主要是為了對 App 的一個大概展示,或者是一些廣告的展示,同時也給一些數據初始化提供時間,當用戶進入後有更好的體驗效果。我們在這裡就做一個圖標的展示(圖標可自行到項目中 images 文件夾查找),延時 5s 後跳轉下個頁面。

/// 查看 `splash_page.dart` 文件
class SplashPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
/// 因為已經引入了 rxdart,這裡通過 rxdart.timer 進行倒計時
/// 當然也可以使用 Futuer.delayed 進行倒計時
/// 5s 計時,如果已經選擇城市,跳轉天氣界面,否則進入城市選擇
Observable.timer(0, Duration(milliseconds: 5000)).listen((_) {
PreferenceUtils.instance.getString(PreferencesKey.WEATHER_CITY_ID)
.then((city) {
// 如果當前還未選擇城市,則進入城市選擇頁,否則跳轉天氣詳情頁
// 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>[
// 展示圖標
Image.asset(Resource.pngSplash, width: 200.0, height: 200.0),
// 展示文字提醒,用 SizedBox 設置區域大小
SizedBox(
width: MediaQuery.of(context).size.width * 0.7,
child: Text(
'所有天氣數據均為模擬數據,僅用作學習目的使用,請勿當作真實的天氣預報軟件來使用',
textAlign: TextAlign.center,
softWrap: true,
style: TextStyle(color: Colors.red[700], fontSize: 16.0),
))
],
),
),
);
}
}
城市選擇頁面

當首次進入的時候,用戶肯定沒有選擇城市,所以先編寫城市選擇列表頁面,因為整體的項目使用 BLoC 分離業務邏輯和頁面,所以先編寫數據管理類吧,把數據請求和改變的業務邏輯放到這塊,BLoC 的實現在前面講過了,這邊就不重複提了。可以查看文章:狀態管理及 BLoC

/// 查看 `provinces_bloc.dart` 文件
class ProvincesBloc extends BaseBloc {
final _logger = Logger('ProvincesBloc');
List<ProvinceModel> _provinces = []; // 全國省
List<ProvinceModel> _cities = []; // 省內市
List<DistrictModel> _districts = []; // 市內區
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 參數
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);
}
/// 通知刷新區列表
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);
}
/// 請求省內城市
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);
}
/// 請求市內的區
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 需要對其進行註冊,因為城市選擇相對還是比較頻繁的,所以可以放最頂層進行註冊

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);
// 進入的時候先使用數據庫的數據填充界面
Application.db.queryAllProvinces().then((ps) => _bloc.changeProvinces(ps));
// 網絡數據更新列表並刷新數據庫數據
_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
// 如果當前的數據未加載則給一個加載,否則顯示列表加載
? 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,
// 跳轉下層省內城市選擇,需要將當前的省 id 以及省名傳入
Routers.
generateProvinceRouterPath(snapshot.data[index].id, FluroConvertUtils.fluroCnParamsEncode(snapshot.data[index].name)),
transition: TransitionType.fadeIn),
),
itemExtent: 50.0,
itemCount: snapshot.data.length),
),
),
);
}
}

對於市和區的列表選擇也類似,除了最後的點擊會有些區別頁面的佈局幾乎一致,這邊只提下點擊事件

/// 查看 `cities_page.dart` 文件
Application.router.navigateTo(
context,
// 跳轉下層省內城市選擇
Routers.generateProvinceRouterPath(
snapshot.data[index].id, FluroConvertUtils.fluroCnParamsEncode(snapshot.data[index].name)),
transition: TransitionType.fadeIn),
)
// 設置為當前區,並清理路由 stack,並將天氣界面設置到最上層
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);
})
天氣詳情頁面

天氣詳情頁面相對部件會多點,為了看著舒服一點,這裡拆成多個部分來編寫,在這之前還是先編寫數據的管理類,因為天氣詳情接口返回的數據嵌套層次比較多,關係比較複雜,不適合用 database 來做持久化,所以這裡採用文件持久化方式。當然有些小夥伴會問幹嘛不使用 shared_preferences 來存儲,理論上應該沒有太大的問題,但是個人建議相對複雜的數據使用文件存儲會相對比較好點,一定要說個為什麼,我也說不出來。

/// 查看 `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'));
// 請求數據成功則寫入到文件中
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();
}
}

天氣詳情的刷新只有當個頁面,所以 BLoC 的註冊值需要在路由上註冊即可,在 fluro 對應 handler 中加入註冊

Handler weatherHandler = Handler(handlerFunc: (_, params) {
String cityId = params['city_id']?.first; // 這個 id 可以通過 BLoC 獲取也可以
return BlocProvider(child: WeatherPage(city: cityId), bloc: WeatherBloc());
});

那麼接下來就可以編寫界面了,先實現最外層的背景圖變化

/// 查看 `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));
// 先讀取本地文件緩存進行頁面填充
_bloc.readWeatherFromFile().then((s) {
if (s.isNotEmpty) {
_bloc.updateWeather(WeatherModel.fromMap(json.decode(s)));
}
});
// 再請求網絡更新數據
_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: // 具體內部佈局通過拆分小部件實現
)),
);
}
}

頁面最頂部是顯示兩個按鈕,一個跳轉城市選擇,一個跳轉設置頁面,顯示當前的城市

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>[
// 城市選擇頁面跳轉按鈕
IconButton(
icon: Icon(Icons.home, color: Colors.white, size: 32.0),
onPressed: () => Application.router.
navigateTo(context, Routers.provinces, 
transition: TransitionType.inFromLeft)),
// 當前城市
Text('${snapshot.data.heWeather[0].basic.location}', 
style: TextStyle(fontSize: 28.0, color: Colors.white)),
// 設置頁面跳轉按鈕
IconButton(
icon: Icon(Icons.settings, color: Colors.white, size: 32.0),
onPressed: () => Application.router
.navigateTo(context, Routers.settings, 
transition: TransitionType.inFromRight))
],
);
}
}

接著是當前的天氣詳情部分

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>[
// 當前的溫度
Text('${_now.tmp}℃', style: TextStyle(fontSize: 50.0, color: Colors.white)),
// 當前的天氣狀況
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))
],
)
],
);
}
}

接下來是一個天氣預報的列表塊,以為是一個列表,當然可以通過 Cloumn 來實現,但是前面有提到過一個列表「粘合劑」—- CustomScrollView,所以這裡的整體連接最後會通過 CustomScrollView 來實現,那麼你可以放心在最上層容器的 child 屬性加上 CustomScrollView 了。接著來實現這塊預報模塊

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; // 獲取天氣預報
return SliverFixedExtentList(
delegate: SliverChildBuilderDelegate(
(_, index) => Container(
color: Colors.black54, // 外層設置背景色,防止被最外層圖片背景遮擋文字
padding: const EdgeInsets.all(12.0),
alignment: Alignment.centerLeft,
child: index == 0 // 當第一個 item 情況,顯示 ‘預報’
? Text('預報', style: TextStyle(fontSize: 24.0, color: Colors.white))
: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text(_forecastList[index - 1].date,  // 預報的日期
style: TextStyle(fontSize: 16.0, color: Colors.white)),
Expanded( // 天氣情況,這邊通過 expanded 進行佔位,並居中顯示
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, // 這個數量需要 +1,因為有個標題需要一個數量
),
itemExtent: 50.0);
}
}

接著是空氣質量報告,一個標題,下面由兩個佈局進行平分

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>[
// 標題
Padding(padding: const EdgeInsets.only(bottom: 20.0), child: 
Text('空氣質量', style: TextStyle(fontSize: 24.0, 
color: Colors.white))),
Row(
children: <Widget>[
// 通過 expanded 進行平分橫向距離
Expanded(
child: Center(
// 內部居中顯示
child: Column(
children: <Widget>[
Text('${quality.aqi}', style: 
TextStyle(fontSize: 40.0, color: Colors.white)),
Text('AQI 指數', 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 指數', style: 
TextStyle(fontSize: 20.0, color: Colors.white)),
],
),
)),
],
)
],
));
}
}

接下來是生活質量模塊,看著也是個列表,但是後臺返回的不是列表,而是根據不同字段獲取不同質量指數,因為佈局類似,所以可以對其進行封裝再整體調用

class LifeSuggestions extends StatelessWidget {
final AsyncSnapshot<WeatherModel> snapshot;
LifeSuggestions({Key key, this.snapshot}) : super(key: key);
// 生活指數封裝
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('洗車指數:${_suggestion.cw.brf}\n${_suggestion.cw.txt}'),
_suggestionWidget('運動指數:
${_suggestion.sport.brf}\n${_suggestion.sport.txt}'),
],
),
);
}
}

所有的分模塊都已經編寫完成,剩下就是通過粘合劑進行組裝了

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),
),
),
// 天氣預報
WeatherForecast(snapshot: snapshot),
// 空氣質量
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;
}),
)),

最後就剩下設置頁的全局主題切換了

設置頁全局主題切換

既然提到了數據的切換,那肯定就涉及 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 肯定需要在最頂層進行註冊,這邊就不貼代碼了,同 ProvinceBloc 一致。接著編寫界面,設置界面因為有 GridView 和其他部件,所以也需要用 CustomScrollView 作為粘合劑,當然,你也可以用 Wrap 代替 GridView 來實現網格,就不需要用 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 自帶的一個設置主題的部件,裡面可以設置多種顏色,
// 通過接收到 color 的變化,改變主題色,其他頁面也如此設置,小夥伴可以自己添加
builder: (_, AsyncSnapshot<Color> snapshot) => Theme(
// IconThemeData 用於設置按鈕的主題色
data: ThemeData(primarySwatch: snapshot.data, iconTheme: IconThemeData(color: snapshot.data)),
child: Scaffold(
appBar: AppBar(
title: Text('設置'),
),
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('當前主題色:', 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: () {
// 選擇後進行保存,當下次進入的時候直接使用該主題色
// 同時切換主題色
_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)),
],
),
),
),
));
}
}

最終全局的主題切換也實現了。

編寫完代碼,需要打包啊,Android 下的打包大家肯定沒問題,這裡講下 flutter 下如何打包 apk,ipa 因為沒有 mac 所以你們懂的。

apk 文件打包
  1. 創建 jks 文件,如果已經存在可忽略這步從第二步開始。打開終端並輸入

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

    Flutter入門指北(Part14)之實戰篇

    然後輸入密碼以及一些基本信息就可以創建成功了

  2. 在項目的 android 目錄下創建一個 key.properties 文件,裡面進行如下配置

    storePassword=<password from previous step>
    keyPassword=<password from previous step>
    keyAlias=key
    storeFile=<[你的簽名文件路徑].jks>
    
  3. android/app 下的 build.gradle 中進行如下修改

    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{
    // ...
    }
    }
    
  4. 再次打開終端運行 flutter build apk 會自動生成一個 apk 文件,文件路徑為

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

  5. 通過 flutter install 就可以將正式包運行到手機上

總結

2019.03.09 – 2019.04.08,一個月時間,花了整整一個月終於是寫完了,也算是給一直關注的小夥伴們有個交代了。對於寫系列文,說實話真的很煎熬,一是因為前期需要做好整體的思路構造,需要從簡入繁,一步步深入,如果整體思路沒有搭建好,那就會走向一條不歸路,要麼硬著頭皮上,要麼退回來從頭開始,二是為了寫這套系列文,基本上舍棄了所有的私人時間,寫完了自己還得看上好幾遍才敢發佈。但是收穫的的確很多,期間看了很多 flutter 的源碼,對部件上的一些認識和理解也會有所的加深,也會去學習源碼的編寫規範,對日後工作上也會有很大幫助。最後希望這套系列文能帶更多的小夥伴入門 Flutter,大家下次再見咯~

相關文章

Flutter學習路線圖

fish_redux「食用指南」

JetpackNavigation分析

Flutter與Android的交互