上一篇中我記錄了基於Flutter的開源中國客戶端裡網路請求和資料儲存的部分,本篇記錄的是app中外掛的使用,由於很多功能並沒有內建到Flutter中,所以我們需要引入一些外掛來幫助我們完成某些功能,比如app內網頁的載入,相簿選擇照片等。
搜尋外掛包
要使用外掛,必須知道外掛叫什麼名字,目前是什麼版本,Flutter提供了一個外掛倉庫,可以去上面搜尋相關的外掛,倉庫地址為:pub.dartlang.org/,但是這個網站在國內可能訪問不了,國內可以用Flutter專門為中國開發者提供的網站:pub.flutter-io.cn/。該網站開啟後直接在輸入框中搜尋關鍵字即可,如下圖所示:
比如我們需要在app中用WebView載入網頁,可以直接搜尋'web view',再或者我們需要呼叫相簿選擇圖片的功能,可以搜尋'image picker',搜尋結果可能有一大堆,怎麼選擇合適的外掛呢?
由於我們是開發Flutter應用,所以要在搜尋結果中過濾出供Flutter使用的外掛,如下圖所示:
過濾是第一步,過濾之後,還要檢視外掛包的更新日期,更新日期不能是很久前,因為很早之前釋出的外掛包,可能並不適合現在的Flutter版本,另外就是看這個外掛後面的數字,數字越大表示外掛匹配程度越高,如下圖所示:
上面兩步過濾之後,選擇你覺得合適的外掛,點進去看看詳情,裡面有相關的外掛說明,示例用法,確定可以完成你所需要的功能,就可以愉快的在專案中新增外掛依賴了。
基本上每個外掛的主頁都會有說明如何在專案中新增該外掛的依賴,比如在我們這個基於Flutter的開源中國客戶端中,用到了flutter_webview_plugin
這個外掛,在該外掛的主頁裡,就有怎麼引入依賴的說明:
使用flutter_webview_plugin外掛
在基於Flutter的開源中國客戶端專案中,使用者登入和資訊詳情等頁面都使用了WebView載入網頁,使用的是flutter_webview_plugin
這個外掛。該外掛主要功能是可以在Flutter頁面中載入一個WebView,並且可以監聽WebView的各種狀態比如載入中,載入完成等,而且還能讀取WebView中的cookies,或者通過dart程式碼呼叫WebView中的js方法。
開源中國提供的基於oauth的認證流程大致如下:
- 在開源中國後臺新增應用,完善應用的資訊,最主要的是回撥地址,該地址將會在後面用到;
- 使用瀏覽器或者WebView載入三方認證頁面,在該頁面中輸入開源中國的使用者名稱和密碼(輸入密碼的頁面為開源中國提供的頁面,第三方是無法獲取密碼資訊的);
- 輸入使用者名稱和密碼後點選頁面上的登入按鈕,若登入成功,將會跳轉到第一步我們在後臺配置的回撥地址上,並給該頁面傳入一個code引數(code引數直接拼接在URL上);
- 在該頁面中接收code引數,並根據開源中國後臺提供的
client_id
client_secret
等引數換取token資訊(這一步就是一個get請求,只不過放在我自己的服務端進行了); - 上面的請求成功後,開源中國的openapi會返回token等資訊,在我們的回撥頁面將這個資訊通過js的一個
get()
方法暴露出來,讓dart程式碼去呼叫。
具體的oauth認證流程可以檢視開源中國的文件:文件地址
構造登入頁面
在lib/pages/
目錄下新建LoginPage.dart
檔案,並使用flutter_webview_plugin
外掛提供的WebviewScaffold
元件,該元件會在頁面上渲染一個WebView用於載入某個URL,程式碼如下:
@override
Widget build(BuildContext context) {
List<Widget> titleContent = [];
titleContent.add(new Text(
"登入開源中國",
style: new TextStyle(color: Colors.white),
));
if (loading) {
// 如果還在載入中,就在標題欄上顯示一個圓形進度條
titleContent.add(new CupertinoActivityIndicator());
}
titleContent.add(new Container(width: 50.0));
// WebviewScaffold是外掛提供的元件,用於在頁面上顯示一個WebView並載入URL
return new WebviewScaffold(
key: _scaffoldKey,
url: Constants.LOGIN_URL, // 登入的URL
appBar: new AppBar(
title: new Row(
mainAxisAlignment: MainAxisAlignment.center,
children: titleContent,
),
iconTheme: new IconThemeData(color: Colors.white),
),
withZoom: true, // 允許網頁縮放
withLocalStorage: true, // 允許LocalStorage
withJavascript: true, // 允許執行js程式碼
);
}
複製程式碼
上面的程式碼中,我們給AppBar元件上加了標題,還加了一個圓形的進度條,用於指示WebView載入的狀態,如果在載入中,就顯示進度條,否則就隱藏進度條(所以LoginPage類應該繼承StatefulWidget)。
監聽WebView的載入狀態和URL變化
flutter_webview_plugin
外掛提供的api可以監聽WebView載入的狀態和URL的變化,主要程式碼如下:
// 登入頁面,使用網頁載入的開源中國三方登入頁面
class LoginPage extends StatefulWidget {
@override
State<StatefulWidget> createState() => new LoginPageState();
}
class LoginPageState extends State<LoginPage> {
// 標記是否是載入中
bool loading = true;
// 標記當前頁面是否是我們自定義的回撥頁面
bool isLoadingCallbackPage = false;
GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey();
// URL變化監聽器
StreamSubscription<String> _onUrlChanged;
// WebView載入狀態變化監聽器
StreamSubscription<WebViewStateChanged> _onStateChanged;
// 外掛提供的物件,該物件用於WebView的各種操作
FlutterWebviewPlugin flutterWebViewPlugin = new FlutterWebviewPlugin();
@override
void initState() {
super.initState();
// 監聽WebView的載入事件,該監聽器已不起作用,不回撥
_onStateChanged = flutterWebViewPlugin.onStateChanged.listen((WebViewStateChanged state) {
// state.type是一個列舉型別,取值有:WebViewState.shouldStart, WebViewState.startLoad, WebViewState.finishLoad
switch (state.type) {
case WebViewState.shouldStart:
// 準備載入
setState(() {
loading = true;
});
break;
case WebViewState.startLoad:
// 開始載入
break;
case WebViewState.finishLoad:
// 載入完成
setState(() {
loading = false;
});
if (isLoadingCallbackPage) {
// 當前是回撥頁面,則呼叫js方法獲取資料
parseResult();
}
break;
}
});
_onUrlChanged = flutterWebViewPlugin.onUrlChanged.listen((url) {
// 登入成功會跳轉到自定義的回撥頁面,該頁面地址為http://yubo725.top/osc/osc.php?code=xxx
// 該頁面會接收code,然後根據code換取AccessToken,並將獲取到的token及其他資訊,通過js的get()方法返回
if (url != null && url.length > 0 && url.contains("osc/osc.php?code=")) {
isLoadingCallbackPage = true;
}
});
}
}
複製程式碼
上面程式碼的邏輯是:
- 監聽WebView的載入狀態,控制loading的改變達到改變AppBar上進度條的目的;
- 監聽頁面URL的改變,若頁面URL中包含“osc/osc.php?code=”,代表開源中國的賬號密碼驗證通過,並跳轉到了我們自定義的回撥頁面,這裡給isLoadingCallbackPage賦值為true,代表當前載入的是回撥頁面;
- 在WebView的WebViewState.finishLoad狀態中,判斷如果當前頁是回撥頁,則可以通過
parseResult()
方法呼叫js程式碼獲取token資訊了。
dart呼叫js程式碼獲取token資訊
parseResult()
方法中就是dart呼叫js程式碼的邏輯了,flutter_webview_plugin
外掛提供了API供我們很方便的用dart程式碼呼叫js程式碼,下面是parseResult()
方法的程式碼:
// 解析WebView中的資料
void parseResult() {
flutterWebViewPlugin.evalJavascript("get();").then((result) {
// result json字串,包含token資訊
if (result != null && result.length > 0) {
// 拿到了js中的資料
try {
// what the fuck?? need twice decode??
var map = json.decode(result); // s is String
if (map is String) {
map = json.decode(map); // map is Map
}
if (map != null) {
// 登入成功,取到了token,關閉當前頁面
DataUtils.saveLoginInfo(map);
Navigator.pop(context, "refresh");
}
} catch (e) {
print("parse login result error: $e");
}
}
});
}
複製程式碼
主要方法是flutterWebViewPlugin.evalJavascript()
傳入的引數是一個字串,表示要執行的js程式碼。上面的程式碼意思是執行頁面中的get()
方法,在該方法中返回了token等資訊,然後在then
中解析這些資訊,並呼叫DataUtils.saveLoginInfo(map);
儲存登入資訊,這就到了上一篇中我記錄的資料儲存的部分了。資料儲存後呼叫Navigator.pop(context, "refresh");
方法將當前頁推出棧,後面的"refresh"引數有什麼作用呢?
通知上一個頁面登入成功,讓上一個頁面重新整理
"refresh"的作用就是為了讓上一個頁面重新整理(這裡只是一個字串引數,定義成什麼樣子完全取決於你自己)。如果是做過Android開發的朋友,應該會很熟悉,我們要把當前頁的資料傳遞給上一個頁面,一般會在上一個頁面用startActivityForResult方法啟動當前頁,上一個頁面會在onActivityResult回撥方法中接收引數。Flutter的做法跟這個有點類似,在“我的”頁面中開啟登入頁時,使用下面的方法:
_login() async {
// 開啟登入頁並處理登入成功的回撥
final result = await Navigator
.of(context)
.push(new MaterialPageRoute(builder: (context) {
return new LoginPage();
}));
// result為"refresh"代表登入成功
if (result != null && result == "refresh") {
// 重新整理使用者資訊
getUserInfo();
// 通知動彈頁面重新整理
Constants.eventBus.fire(new LoginEvent());
}
}
複製程式碼
上面的程式碼應該很明瞭了吧,Navigator
的push
方法返回的是一個Future物件,所以我們可以在then裡面處理登入頁返回的資訊,登入頁pop時傳入的'refresh'字串,將會在這裡被接收,接收到就可以重新整理“我的”頁面了(重新整理使用者暱稱和頭像)。
使用event_bus外掛
上面最後的_login()
方法的程式碼中,我們收到了"refresh"引數後,獲取並重新整理了頁面的使用者資訊,然後還呼叫了一行程式碼用於重新整理動彈頁面:
Constants.eventBus.fire(new LoginEvent());
複製程式碼
這行程式碼就用到了另外一個框架:event_bus
如果做過Android開發或者前端開發,應該對這個框架不陌生。EventBus是一個釋出/訂閱模式的框架,用於在某個頁面訂閱某個事件,然後在另外的地方觸發這個事件,訂閱這個事件的方法就會被執行。
該框架在pub倉庫的主頁是:pub.flutter-io.cn/packages/ev…
該外掛的用法很簡單,首先是匯入包:
import 'package:event_bus/event_bus.dart';
複製程式碼
如果要訂閱某個事件,使用下面的程式碼:
new EventBus().on(MyEvent).listen((event) {
// 處理事件
});
複製程式碼
其中MyEvent
是自定義的一個類,表示唯一的一個事件。如果要監聽所有的事件,on
方法中可以不傳引數。
要傳送某個事件,可以用如下程式碼:
new EventBus().fire(new MyEvent());
複製程式碼
使用fire
方法傳送某個事件,引數就是這個自定義的事件物件,可以在這個物件中加入任何你需要的引數。
在基於Flutter的開源中國客戶端專案中,可以只用到一個EventBus物件,沒必要在每次用的時候都new EventBus()
,所以我們在lib/constants/Constants.dart
中定義了一個靜態的eventBus變數,全域性都可以共用這一個物件:
static EventBus eventBus = new EventBus();
複製程式碼
在登入成功後,呼叫如下程式碼來通知動彈列表重新整理:
Constants.eventBus.fire(new LoginEvent());
複製程式碼
LoginEvent是一個空的類,表示登入成功的事件。
在動彈列表頁,還要為登入成功的事件加上監聽:
Constants.eventBus.on(LoginEvent).listen((event) {
setState(() {
this.isUserLogin = true;
});
});
複製程式碼
動彈列表頁根據上面的isUserLogin變數載入不同的頁面,如果該變數為false,表示當前沒有登入,則顯示如下介面:
如果該變數為true,則會呼叫開源中國的api去獲取動彈資訊,顯示如下介面:
關於動彈列表的載入,這裡就不詳細說明了,文末會給出原始碼連結。
使用image_picker外掛
在傳送動彈的頁面,有選擇圖片的功能,如下圖所示:
Flutter並沒有提供相關API供我們操作移動裝置的相簿,所以這裡又用到了image_picker外掛,該外掛的地址在這裡:pub.flutter-io.cn/packages/im…
匯入外掛的程式碼如下:
import 'package:image_picker/image_picker.dart';
複製程式碼
外掛的使用方法也比較簡單,如下程式碼:
// source是一個列舉值,可取值有ImageSource.camera和ImageSource.gallery,分別代表呼叫相機和相簿
_imageFile = ImagePicker.pickImage(source: source);
複製程式碼
顯示底部彈出選單
上圖中的彈出選單在Flutter中已有內建的元件可直接使,當我們點選➕選擇圖片時,呼叫pickImage
方法,程式碼如下:
// 相機拍照或者從相簿選擇圖片
pickImage(ctx) {
// 如果已新增了9張圖片,則提示不允許新增更多
num size = fileList.length;
if (size >= 9) {
Scaffold.of(ctx).showSnackBar(new SnackBar(
content: new Text("最多隻能新增9張圖片!"),
));
return;
}
// Flutter提供的API,用於顯示一個底部彈出的Dialog
showModalBottomSheet<void>(context: context, builder: _bottomSheetBuilder);
}
// 自定義底部選單的佈局
Widget _bottomSheetBuilder(BuildContext context) {
return new Container(
height: 182.0,
child: new Padding(
padding: const EdgeInsets.fromLTRB(0.0, 30.0, 0.0, 30.0),
child: new Column(
children: <Widget>[
_renderBottomMenuItem("相機拍照", ImageSource.camera),
new Divider(height: 2.0,),
_renderBottomMenuItem("相簿選擇照片", ImageSource.gallery)
],
),
)
);
}
// 渲染底部選單的每個item
_renderBottomMenuItem(title, ImageSource source) {
var item = new Container(
height: 60.0,
child: new Center(
child: new Text(title)
),
);
return new InkWell(
child: item,
onTap: () {
// 點選選單item,關閉這個底部彈窗並呼叫相機或者相簿
Navigator.of(context).pop();
setState(() {
_imageFile = ImagePicker.pickImage(source: source);
});
},
);
}
複製程式碼
上面程式碼中的_imageFile
是一個Future<File>
物件,因為選擇圖片的操作是非同步的,那麼在什麼地方接收選擇的圖片呢?不論是拍照還是相簿選擇,最後呼叫ImagePicker.pickImage(source: source)
返回的都是一個檔案物件,在image_picker
主頁給出的示例程式碼中,是以元件的形式返回一個FutureBuilder<File>
物件,在該物件的builder
方法中接收返回的圖片檔案的。
在基於Flutter的開源中國客戶端專案中,接收選擇的圖片是放在build方法中的,PublishTweetPage頁面的build
方法程式碼如下:
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text("釋出動彈", style: new TextStyle(color: Colors.white)),
iconTheme: new IconThemeData(color: Colors.white),
actions: <Widget>[
new Builder(
builder: (ctx) {
return new IconButton(icon: new Icon(Icons.send), onPressed: () {
// 傳送動彈
DataUtils.isLogin().then((isLogin) {
if (isLogin) {
return DataUtils.getAccessToken();
} else {
return null;
}
}).then((token) {
sendTweet(ctx, token);
});
});
},
)
],
),
// 在這裡接收選擇的圖片
body: new FutureBuilder(
future: _imageFile,
builder: (BuildContext context, AsyncSnapshot<File> snapshot) {
if (snapshot.connectionState == ConnectionState.done &&
snapshot.data != null && _imageFile != null) {
// 選擇了圖片(拍照或相簿選擇),新增到List中
fileList.add(snapshot.data);
_imageFile = null;
}
// 返回的widget
return getBody();
},
),
);
}
複製程式碼
在AppBar的右邊新增了一個按鈕,用於傳送動彈資訊。在body部分返回了一個FutureBuilder
物件,在該物件的builder
方法中接收了選中的圖片檔案,並將該檔案加入到圖片列表中,然後呼叫getBody()
方法返回整個頁面,這麼做的原因是因為每次選中一張圖片後,都需要將頁面重新整理,在getBody()
方法中會用到fileList
變數,getBody()
方法程式碼如下:
Widget getBody() {
// 輸入框
var textField = new TextField(
decoration: new InputDecoration(
hintText: "說點什麼吧~",
hintStyle: new TextStyle(
color: const Color(0xFF808080)
),
border: new OutlineInputBorder(
borderRadius: const BorderRadius.all(const Radius.circular(10.0))
)
),
// 最多顯示6行文字(不代表最多隻能輸入6行)
maxLines: 6,
// 最多輸入的文字數
maxLength: 150,
// 通過_controller.text可以獲取輸入框中輸入的文字
controller: _controller,
);
// gridView用來顯示選擇的圖片
var gridView = new Builder(
builder: (ctx) {
return new GridView.count(
// 分4列顯示
crossAxisCount: 4,
children: new List.generate(fileList.length + 1, (index) {
// 這個方法體用於生成GridView中的一個item
var content;
if (index == 0) {
// 新增圖片按鈕
var addCell = new Center(
child: new Image.asset('./images/ic_add_pics.png', width: 80.0, height: 80.0,)
);
content = new GestureDetector(
onTap: () {
// 新增圖片
pickImage(ctx);
},
child: addCell,
);
} else {
// 被選中的圖片
content = new Center(
child: new Image.file(fileList[index - 1], width: 80.0, height: 80.0, fit: BoxFit.cover,)
);
}
return new Container(
margin: const EdgeInsets.all(2.0),
width: 80.0,
height: 80.0,
color: const Color(0xFFECECEC),
child: content,
);
}),
);
},
);
var children = [
new Text("提示:由於OSC的openapi限制,釋出動彈的介面只支援上傳一張圖片,本專案可新增最多9張圖片,但OSC只會接收最後一張圖片。", style: new TextStyle(fontSize: 12.0),),
textField,
new Container(
margin: const EdgeInsets.fromLTRB(0.0, 10.0, 0.0, 0.0),
height: 200.0,
child: gridView
)
];
if (isLoading) { // 上傳圖片可能會比較慢,所以這裡顯示loading
children.add(new Container(
margin: const EdgeInsets.fromLTRB(0.0, 20.0, 0.0, 0.0),
child: new Center(
child: new CircularProgressIndicator(),
),
));
} else { // 上傳成功後顯示msg
children.add(new Container(
margin: const EdgeInsets.fromLTRB(0.0, 20.0, 0.0, 0.0),
child: new Center(
child: new Text(msg),
)
));
}
return new Container(
padding: const EdgeInsets.all(5.0),
child: new Column(
children: children,
),
);
}
複製程式碼
獲取到了選擇的圖片和輸入的動彈內容,下一步是傳送動彈,傳送動彈呼叫的是開源中國的openapi,這裡涉及到使用dart上傳圖片的問題,下面先上程式碼:
sendTweet(ctx, token) async {
// 未登入或者未輸入動彈內容時,使用SnackBar提示使用者
if (token == null) {
Scaffold.of(ctx).showSnackBar(new SnackBar(
content: new Text("未登入!"),
));
return;
}
String content = _controller.text;
if (content == null || content.length == 0 || content.trim().length == 0) {
Scaffold.of(ctx).showSnackBar(new SnackBar(
content: new Text("請輸入動彈內容!"),
));
}
// 下面是呼叫介面釋出動彈的邏輯
try {
Map<String, String> params = new Map();
params['msg'] = content;
params['access_token'] = token;
// 構造一個MultipartRequest物件用於上傳圖片
var request = new MultipartRequest('POST', Uri.parse(Api.PUB_TWEET));
request.fields.addAll(params);
if (fileList != null && fileList.length > 0) {
// 這裡雖然是新增了多個圖片檔案,但是開源中國提供的介面只接收一張圖片
for (File f in fileList) {
// 檔案流
var stream = new http.ByteStream(
DelegatingStream.typed(f.openRead()));
// 檔案長度
var length = await f.length();
// 檔名
var filename = f.path.substring(f.path.lastIndexOf("/") + 1);
// 將檔案加入到請求體中
request.files.add(new http.MultipartFile(
'img', stream, length, filename: filename));
}
}
setState(() {
isLoading = true;
});
// 傳送請求
var response = await request.send();
// 解析請求返回的資料
response.stream.transform(utf8.decoder).listen((value) {
print(value);
if (value != null) {
var obj = json.decode(value);
var error = obj['error'];
setState(() {
if (error != null && error == '200') {
// 成功
setState(() {
isLoading = false;
msg = "釋出成功";
fileList.clear();
});
_controller.clear();
} else {
setState(() {
isLoading = false;
msg = "釋出失敗:$error";
});
}
});
}
});
} catch (exception) {
print(exception);
}
}
複製程式碼
使用dart上傳圖片的程式碼和普通的get/post請求是完全不一樣的,上傳圖片需要構造一個Request物件:
var request = new MultipartRequest('POST', Uri.parse(Api.PUB_TWEET));
複製程式碼
新增普通的引數需要呼叫request.field.addAll方法:
request.fields.addAll(params); // params是引數map
複製程式碼
新增檔案引數時,需要呼叫request.files.add方法:
request.files.add(new http.MultipartFile(
'img', stream, length, filename: filename));
複製程式碼
解析返回的資料時需要使用如下程式碼:
// 傳送請求
var response = await request.send();
// 解析請求返回的資料
response.stream.transform(utf8.decoder).listen((value) {})
複製程式碼
關於傳送動彈的詳細程式碼,可以參考文末的原始碼連結,這裡不再說明。
原始碼
本篇相關的所有原始碼都在GitHub上flutter-osc專案。
後記
-
本篇主要記錄的是基於Flutter的開源中國客戶端app中的各種外掛的使用。
-
二維碼掃描的外掛使用在本篇中沒有做記錄,各位小夥伴可自行上pub倉庫搜尋外掛用法。
-
本系列部落格並未將所有功能的實現方法都記錄下來,只是有選擇性的記錄了一部分功能的實現。
-
本專案中還有很多功能暫未實現,比如動彈大圖預覽、個人資訊頁的展示等。大部分的功能都是以WebView的形式載入的,所以整體來看app的實現並不複雜,程式碼量也並不多,開源出來希望給學習Flutter的小夥伴們一點幫助。(如果對你有幫助,請在github給個start支援一下?)
-
本專案中還有一些已知和未知的bug,已知的bug是token過期後沒有做自動重新整理處理(開源中國給的token是有有效期的,過期後需要使用refresh_token去重新整理access_token),未知的一些bug可能會導致app在執行過程中ANR,由於沒有對各個機型做測試,所以暫時不知道ANR是什麼原因導致的,但是在開發過程中會偶現外掛的報錯,希望各位發現bug可以及時與我聯絡(文末留言或者github提issue都行),感謝你們的支援!
我的開源專案
- 基於Google Flutter的開源中國客戶端,希望大家給個Star支援一下,原始碼:
- 基於Flutter的俄羅斯方塊小遊戲,希望大家給個Star支援一下,原始碼:
上一篇 |
---|
從0開始寫一個基於Flutter的開源中國客戶端(7) ——App網路請求和資料儲存 |