我的界面搭建:
繼續接着上一次https://www.cnblogs.com/webor2006/p/12879031.html的代碼繼續編寫,這次則會使用到開源中國的API的調用了,所以說怎么在Flutter中來進行網絡訪問的技能通過這次就能夠GET到的,這里先從用戶登錄開始,因為開源中國的API都依賴於AccessToken,所以先來搭建一下我的界面,目前我的界面是一個空殼:
而最終要的效果是這樣:
下面先來搭建界面:
菜單數據准備:
先定義元素文本及圖標:
import 'package:flutter/material.dart'; class ProfilePage extends StatefulWidget { @override _ProfilePageState createState() => _ProfilePageState(); } class _ProfilePageState extends State<ProfilePage> { List menuTitles = [ '我的消息', '閱讀記錄', '我的博客', '我的問答', '我的活動', '我的團隊', '邀請好友', ]; List menuIcons = [ Icons.message, Icons.print, Icons.error, Icons.phone, Icons.send, Icons.people, Icons.person, ]; @override Widget build(BuildContext context) { return Center( child: Text('我的'), ); } }
構建菜單項:
這里肯定是用ListView來構建了,這塊之前學習過,直接上代碼:
import 'package:flutter/material.dart'; class ProfilePage extends StatefulWidget { @override _ProfilePageState createState() => _ProfilePageState(); } class _ProfilePageState extends State<ProfilePage> { List menuTitles = [ '我的消息', '閱讀記錄', '我的博客', '我的問答', '我的活動', '我的團隊', '邀請好友', ]; List menuIcons = [ Icons.message, Icons.print, Icons.error, Icons.phone, Icons.send, Icons.people, Icons.person, ]; @override Widget build(BuildContext context) { return ListView.separated( itemBuilder: (context, index) { if (index == 0) { return Container( height: 200.0, color: Colors.red, ); } index -= 1; return ListTile( leading: Icon(menuIcons[index]), //左圖標 title: Text(menuTitles[index]), //中間標題 trailing: Icon(Icons.arrow_forward_ios), ); }, separatorBuilder: (context, index) { return Divider(); }, //分隔線 itemCount: menuTitles.length + 1); } }
上面由於將頭像也做為列表的一項,所以在總列表項個數是+1,比較好理解,先看一下運行效果:
是不是感覺Flutter構建UI代碼還是挺少的,像模像樣的列表就出來了,接下來則來構建用戶頭像:
@override Widget build(BuildContext context) { return ListView.separated( itemBuilder: (context, index) { if (index == 0) { //用戶頭像 return Center( child: Column( children: <Widget>[ Container( width: 60.0, height: 60.0, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: Colors.white, width: 2.0, ), image: DecorationImage( image: AssetImage('assets/images/ic_avatar_default.png'), fit: BoxFit.cover, )), ) ], ), ); } index -= 1; return ListTile( leading: Icon(menuIcons[index]), //左圖標 title: Text(menuTitles[index]), //中間標題 trailing: Icon(Icons.arrow_forward_ios), ); }, separatorBuilder: (context, index) { return Divider(); }, //分隔線 itemCount: menuTitles.length + 1); }
關於BoxDecoration的使用可以參考https://www.cnblogs.com/webor2006/p/12845831.html,其中用到了一個默認圖像圖:
上面是個純白的圖哦,其實是長這樣:
記得要在yaml中聲明哦:
運行瞅一下效果:
呃,樣式不太對, 咱們先給它加一個背景顏色:
@override Widget build(BuildContext context) { return ListView.separated( itemBuilder: (context, index) { if (index == 0) { //用戶頭像 return Container( color: Color(AppColors.APP_THEME), height: 150.0, child: Center( child: Column( children: <Widget>[ Container( width: 60.0, height: 60.0, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: Colors.white, width: 2.0, ), image: DecorationImage( image: AssetImage( 'assets/images/ic_avatar_default.png'), fit: BoxFit.cover, )), ) ], ), ), ); } index -= 1; return ListTile( leading: Icon(menuIcons[index]), //左圖標 title: Text(menuTitles[index]), //中間標題 trailing: Icon(Icons.arrow_forward_ios), ); }, separatorBuilder: (context, index) { return Divider(); }, //分隔線 itemCount: menuTitles.length + 1); }
運行:
頭像木有居中,所以改一下:
再運行:
接下來則需要在它下面加一個用戶名稱的文本:
@override Widget build(BuildContext context) { return ListView.separated( itemBuilder: (context, index) { if (index == 0) { //用戶頭像 return Container( color: Color(AppColors.APP_THEME), height: 150.0, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Container( width: 60.0, height: 60.0, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: Colors.white, width: 2.0, ), image: DecorationImage( image: AssetImage( 'assets/images/ic_avatar_default.png'), fit: BoxFit.cover, )), ), SizedBox( //加間距 height: 10.0, ), Text( '點擊頭像登錄', style: TextStyle(color: Colors.white), ) ], ), ), ); } index -= 1; return ListTile( leading: Icon(menuIcons[index]), //左圖標 title: Text(menuTitles[index]), //中間標題 trailing: Icon(Icons.arrow_forward_ios), ); }, separatorBuilder: (context, index) { return Divider(); }, //分隔線 itemCount: menuTitles.length + 1); }
運行:
貌似界面有點不太和諧呀,看這:
所以將標題欄的陰影得去掉,加個屬性配置就成了:
再運行看一下:
另外咱們給頭像先增加一個點擊事件,其它菜單列表暫且先放着不管,這次主要焦點是通過登錄這個案例來學會Flutter的網絡請求,這里則需要將頭像用手勢的widget包裹一下了:
@override Widget build(BuildContext context) { return ListView.separated( itemBuilder: (context, index) { if (index == 0) { //用戶頭像 return Container( color: Color(AppColors.APP_THEME), height: 150.0, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ GestureDetector( child: Container( width: 60.0, height: 60.0, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: Colors.white, width: 2.0, ), image: DecorationImage( image: AssetImage( 'assets/images/ic_avatar_default.png'), fit: BoxFit.cover, )), ), onTap: () { //TODO:執行登錄 }, ), SizedBox( //加間距 height: 10.0, ), Text( '點擊頭像登錄', style: TextStyle(color: Colors.white), ) ], ), ), ); } index -= 1; return ListTile( leading: Icon(menuIcons[index]), //左圖標 title: Text(menuTitles[index]), //中間標題 trailing: Icon(Icons.arrow_forward_ios), ); }, separatorBuilder: (context, index) { return Divider(); }, //分隔線 itemCount: menuTitles.length + 1); }
先寫個TODO吧,由於跳轉到登錄時需要使用到了開源中國的API,所以,接下來將焦點轉移一下,先來對開源中國的API有一個理性上的認識。
開源中國API流程了解:
先上官網https://www.oschina.net/openapi來了解一下,打開之后,先來讀一下整體說明:
嗯,等於是開放了開源中國移動APP的所有接口,其中第一台說到了采用了OAuth2認證,關於它的原理可以參考https://www.cnblogs.com/webor2006/p/10362197.html,然后往下讀,則是比較重要的一個OAuth2的認證流程圖,貼一下:
創建應用:
可以看到最終訪問正式的API時是需要每個接口都攜帶AccessToken滴,而在它之前則需要經過若干步驟才能得到,流程圖左邊可以看到得先創建一個應用,所以下面先來創建一下:
HTTP請求封裝:
接下來咱們則來對HTTP請求進行一個封裝, 根據流程圖步驟可以看到:
在進行OAuth2授權時需要用到我們創建應用時的三個信息:
那咱們先將這些數據配置到常量里面:
然后在登錄時需要用到兩個接口:
所以咱們也將這倆要用到的URL也封裝到常量中:
好,接下來則對HTTP進行一個封裝,由於是個異步處理,所以會用到async..await,關於這塊可以參考:https://www.cnblogs.com/webor2006/p/11994645.html, 對於Android我們平常會使用OkHttp來進行網絡請求,而在Flutter也是借助三方庫來進行,如下:
這里其實有個小技巧,此時同步一下就可以使用它了,其實它的版本號在這里會自己獲取:
所以要想加版本號,則直接將它拷出來再聲明上既可,如下:
然后咱們再來使用一下它:
import 'package:http/http.dart' as http; class NetUtils { static Future<String> get(String url, Map<String, dynamic> params) async { if (url != null && params != null && params.isNotEmpty) { //拼裝參數 StringBuffer sb = StringBuffer('?'); params.forEach((key, value) { sb.write('$key=$value&'); }); //去掉最后一個& String paramStr = sb.toString().substring(0, sb.length - 1); url += paramStr; } print('NetUtils : $url'); http.Response response = await http.get(url); return response.body; } static Future<String> post(String url, Map<String, dynamic> params) async { http.Response response = await http.post(url, body: params); return response.body; } }
其中Map中用到了一個dynamic類型,還記得它么,可在參考:https://www.cnblogs.com/webor2006/p/11975291.html,
網絡請求工具類封裝好之后,接下來咱們就可以來實現登錄邏輯了。
登錄處理:
登錄跳轉到WebView:
先來看一下官方的授權流程,其實可以看到需要用瀏覽器或WebView來請求:
所以先得准備一個WebView頁面,點擊登錄時先來跳轉到這個頁面,而在Flutter中當然也有WebView組件嘍,這里先來集成一下:
同樣的技巧,先不用輸入版本號,然后加載之后,在這可以查看到具體是用哪個版本號:
接下來新建一個頁面:
import 'package:flutter/material.dart'; import 'package:flutter_osc_client/constants/constants.dart' show AppColors, AppInfos, AppUrls; import 'package:flutter_webview_plugin/flutter_webview_plugin.dart'; class LoginWebPage extends StatefulWidget { @override _LoginWebPageState createState() => _LoginWebPageState(); } class _LoginWebPageState extends State<LoginWebPage> { FlutterWebviewPlugin _flutterWebviewPlugin = FlutterWebviewPlugin(); @override void initState() { super.initState(); //監聽url變化 _flutterWebviewPlugin.onUrlChanged.listen((url) { //https://www.oschina.net/action/oauth2/authorize?response_type=code&client_id=6i4Yu6IUqXnR64em0rsJ&redirect_uri=https://www.xxx.com/ print('LoginWebPage onUrlChanged: $url'); // https://www.xxxx.com/?code=6hHYoH&state= if (url != null && url.length > 0 && url.contains('?code=')) { //登錄成功了 } }); } @override void dispose() { super.dispose(); _flutterWebviewPlugin.close(); } @override Widget build(BuildContext context) { //authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz&redirect_uri=https List<Widget> _appBarTitle = [ Text( '登錄開源中國', style: TextStyle( color: Color(AppColors.APPBAR), ), ), ]; return WebviewScaffold( url: AppUrls.OAUTH2_AUTHORIZE + '?response_type=code&client_id=' + AppInfos.CLIENT_ID + '&redirect_uri=' + AppInfos.REDIRECT_URI, appBar: AppBar( title: Row( children: _appBarTitle, ), ), withJavascript: true, //允許執行js withLocalStorage: true, //允許本地存儲 withZoom: true, //允許網頁縮放 ); } }
具體代碼也比較好理解,就不多解釋了,標紅的寫法可以熟悉一下,也就是可以對里面的多個類進行show,接下來則鏈到點擊頭像的事件上面:
關於頁面路由的跳轉細節可以參考https://www.cnblogs.com/webor2006/p/12545701.html,下面運行看一下效果:
嗯,確實正常加載了,不過加載過程中木有loading框顯示,所以接下來先來處理這個小細節:
import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_osc_client/constants/constants.dart' show AppColors, AppInfos, AppUrls; import 'package:flutter_webview_plugin/flutter_webview_plugin.dart'; class LoginWebPage extends StatefulWidget { @override _LoginWebPageState createState() => _LoginWebPageState(); } class _LoginWebPageState extends State<LoginWebPage> { FlutterWebviewPlugin _flutterWebviewPlugin = FlutterWebviewPlugin(); bool isLoading = true; @override void initState() { super.initState(); //監聽url變化 _flutterWebviewPlugin.onUrlChanged.listen((url) { //https://www.oschina.net/action/oauth2/authorize?response_type=code&client_id=6i4Yu6IUqXnR64em0rsJ&redirect_uri=https://www.xxx.com/ print('LoginWebPage onUrlChanged: $url'); // https://www.xxxx.com/?code=6hHYoH&state= if (mounted) { setState(() { isLoading = false; //網頁加載完取消loading框 }); } if (url != null && url.length > 0 && url.contains('?code=')) { //登錄成功了 } }); } @override void dispose() { super.dispose(); _flutterWebviewPlugin.close(); } @override Widget build(BuildContext context) { //authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz&redirect_uri=https List<Widget> _appBarTitle = [ Text( '登錄開源中國', style: TextStyle( color: Color(AppColors.APPBAR), ), ), ]; if (isLoading) { //在標題欄上增加一個loading _appBarTitle.add(SizedBox( width: 10.0, )); _appBarTitle.add(CupertinoActivityIndicator()); } return WebviewScaffold( url: AppUrls.OAUTH2_AUTHORIZE + '?response_type=code&client_id=' + AppInfos.CLIENT_ID + '&redirect_uri=' + AppInfos.REDIRECT_URI, appBar: AppBar( title: Row( children: _appBarTitle, ), ), withJavascript: true, //允許執行js withLocalStorage: true, //允許本地存儲 withZoom: true, //允許網頁縮放 ); } }
其中有個代碼可能比較懵逼:
看一下它的說明就知道了:
運行看一下效果:
接下來咱們登錄連接一下:
看到我的博客主頁了~~因為我們設置的回調地址就是我自己的個人博客:
然后此時看一下我們打印的地址:
很明顯可以看到,在登錄成功之后,給咱們的回調地址后面加上了一個code屬性,還記得code是干嘛用的呢?回到授權流程圖中:
也就是接下來則需要解析這個code授權碼再去請求一個新接口最終獲取到關鍵的AccessToken,所以先來解析一下code值:
獲取AccessToken:
接下來則需要拿這個code授權碼請求這個接口來獲取最終關鍵的AccessToken了:
而它需要的參數為:
所以接下來直接請求,看能否成功:
運行看一下:
2020-06-12 07:42:57.543 16751-16831/com.example.flutter_osc_client I/flutter: LoginWebPage onUrlChanged: https://www.cnblogs.com/webor2006/?code=naJpb7&state= 2020-06-12 07:42:57.551 16751-16831/com.example.flutter_osc_client I/flutter: NetUtils : https://www.oschina.net/action/openapi/token?client_id=HfFS7FhSuffq5My6w3Lk&client_secret=710zTcDJ9NRxnLtsZvjFKgIHqaSD0JDg&grant_type=authorization_code&redirect_uri=https://www.cnblogs.com/webor2006/&code=naJpb7&dataType=json 2020-06-12 07:42:58.413 16751-16831/com.example.flutter_osc_client I/flutter: oauth2_response:{"access_token":"d9736548-242d-42ce-82c4-e8421723017f","refresh_token":"f8ec95b2-4612-4633-bb96-fffc88a972d2","uid":1861912,"token_type":"bearer","expires_in":604799}
保存用戶信息:
先對Json進行解析,那在Flutter當中Json解析需要像Android那樣用三方的么?不需要了,因為已經內置到dart庫中了,如下:
import 'dart:convert'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_osc_client/constants/constants.dart' show AppColors, AppInfos, AppUrls; import 'package:flutter_osc_client/utils/net_utils.dart'; import 'package:flutter_webview_plugin/flutter_webview_plugin.dart'; class LoginWebPage extends StatefulWidget { @override _LoginWebPageState createState() => _LoginWebPageState(); } class _LoginWebPageState extends State<LoginWebPage> { FlutterWebviewPlugin _flutterWebviewPlugin = FlutterWebviewPlugin(); bool isLoading = true; @override void initState() { super.initState(); //監聽url變化 _flutterWebviewPlugin.onUrlChanged.listen((url) { //https://www.oschina.net/action/oauth2/authorize?response_type=code&client_id=6i4Yu6IUqXnR64em0rsJ&redirect_uri=https://www.xxx.com/ print('LoginWebPage onUrlChanged: $url'); // https://www.xxxx.com/?code=6hHYoH&state= if (mounted) { setState(() { isLoading = false; //網頁加載完取消loading框 }); } if (url != null && url.length > 0 && url.contains('?code=')) { //登錄成功了,提取授權碼code String code = url.split('?')[1].split('&')[0].split('=')[1]; Map<String, dynamic> params = Map<String, dynamic>(); params['client_id'] = AppInfos.CLIENT_ID; params['client_secret'] = AppInfos.CLIENT_SECRET; params['grant_type'] = 'authorization_code'; params['redirect_uri'] = AppInfos.REDIRECT_URI; params['code'] = '$code'; params['dataType'] = 'json'; NetUtils.get(AppUrls.OAUTH2_TOKEN, params).then((data) { print('oauth2_response:$data'); if (data != null) { Map<String, dynamic> map = json.decode(data); if (map != null && map.isNotEmpty) { //TODO:保存token等信息 } } }); } }); }
而保存這里也是用SharedPreferences,那Android有它木有疑問,難道其它平台也有么?肯定是有類似的,Flutter是中間語言最終肯定會編譯成相關平台的保存策略的,這里就不用過多操心了,這個就需要導一下庫了,沒有內置,如下:
同樣的技巧,先不寫版本號,然后再查看一下版本號:
然后咱們使用一下,先來封裝一個數據保存的工具類:
import 'package:shared_preferences/shared_preferences.dart'; class DataUtils { static const String SP_ACCESS_TOKEN = 'access_token'; static const String SP_REFRESH_TOKEN = 'refresh_token'; static const String SP_UID = 'uid'; static const String SP_TOKEN_TYPE = 'token_type'; static const String SP_EXPIRES_IN = 'expires_in'; static const String SP_IS_LOGIN = 'is_login'; static Future<void> saveLoginInfo(Map<String, dynamic> map) async { if (map != null && map.isNotEmpty) { SharedPreferences sp = await SharedPreferences.getInstance(); sp ..setString(SP_ACCESS_TOKEN, map[SP_ACCESS_TOKEN]) ..setString(SP_REFRESH_TOKEN, map[SP_REFRESH_TOKEN]) ..setInt(SP_UID, map[SP_UID]) ..setString(SP_TOKEN_TYPE, map[SP_TOKEN_TYPE]) ..setInt(SP_EXPIRES_IN, map[SP_EXPIRES_IN]) ..setBool(SP_IS_LOGIN, true); } } static Future<void> clearLoginInfo() async { SharedPreferences sp = await SharedPreferences.getInstance(); sp ..setString(SP_ACCESS_TOKEN, '') ..setString(SP_REFRESH_TOKEN, '') ..setInt(SP_UID, -1) ..setString(SP_TOKEN_TYPE, '') ..setInt(SP_EXPIRES_IN, -1) ..setBool(SP_IS_LOGIN, false); } //是否登錄 static Future<bool> isLogin() async { SharedPreferences sp = await SharedPreferences.getInstance(); bool isLogin = sp.getBool(SP_IS_LOGIN); return isLogin != null && isLogin; } //獲取token static Future<String> getAccessToken() async { SharedPreferences sp = await SharedPreferences.getInstance(); return sp.getString(SP_ACCESS_TOKEN); } }
代碼比較簡單,其使用跟Android的sp差不多,只是這里又用到了async,await異步處理代碼,需要多熟悉:
好,接下來調用一下它:
通知刷新用戶界面:
在保存了token信息之后,則需要通知主界面根據access_token來獲取用戶的信息,也就是這個接口:
所以先來處理通知我的界面進行刷新的邏輯,具體怎么通知呢?這里可以先使用路由返回一條消息:
然后我們在路由跳轉到登錄界面的那個地方就可以來接收路由的返回信息了:
但這種寫法其實是有些問題的。。很顯然目前這個方法是一個同步方法,而等待路由結果應該是一個異步等待,所以又得async await一下:
好,接下來收到路由刷新通知之后,這里再用EventBus來通知請求用戶信息接口,是的,Flutter中也有EventBus,當然先集成一下它嘍:
老套路,看一下版本號:
接下來使用一下,看它跟在Android中有啥區別,先來定義一下事件:登入和登出:
import 'package:event_bus/event_bus.dart'; EventBus eventBus = EventBus(); class LoginEvent {} class LogoutEvent {}
然后使用一下:
接下來先看一下能否收到登錄成功的EventBus消息:
嗯,確實是跳回到了用戶界面,看一下日志輸出:
嗯,妥妥的,這次暫時先學到這,下次繼續。