1. 程式人生 > >從0開始寫一個基於Flutter的開源中國客戶端(8)——外掛的使用

從0開始寫一個基於Flutter的開源中國客戶端(8)——外掛的使用

上一篇中我記錄了基於Flutter的開源中國客戶端裡網路請求和資料儲存的部分,本篇記錄的是app中外掛的使用,由於很多功能並沒有內建到Flutter中,所以我們需要引入一些外掛來幫助我們完成某些功能,比如app內網頁的載入,相簿選擇照片等。

搜尋外掛包

要使用外掛,必須知道外掛叫什麼名字,目前是什麼版本,Flutter提供了一個外掛倉庫,可以去上面搜尋相關的外掛,倉庫地址為:https://pub.dartlang.org/,但是這個網站在國內可能訪問不了,國內可以用Flutter專門為中國開發者提供的網站:https://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的認證流程大致如下:
1. 在開源中國後臺新增應用,完善應用的資訊,最主要的是回撥地址,該地址將會在後面用到;
2. 使用瀏覽器或者WebView載入三方認證頁面,在該頁面中輸入開源中國的使用者名稱和密碼(輸入密碼的頁面為開源中國提供的頁面,第三方是無法獲取密碼資訊的);
3. 輸入使用者名稱和密碼後點擊頁面上的登入按鈕,若登入成功,將會跳轉到第一步我們在後臺配置的回撥地址上,並給該頁面傳入一個code引數(code引數直接拼接在URL上);
4. 在該頁面中接收code引數,並根據開源中國後臺提供的client_id client_secret等引數換取token資訊(這一步就是一個get請求,只不過放在我自己的服務端進行了);
5. 上面的請求成功後,開源中國的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());
    }
  }

上面的程式碼應該很明瞭了吧,Navigatorpush方法返回的是一個Future物件,所以我們可以在then裡面處理登入頁返回的資訊,登入頁pop時傳入的’refresh’字串,將會在這裡被接收,接收到就可以重新整理“我的”頁面了(重新整理使用者暱稱和頭像)。

使用event_bus外掛

上面最後的_login()方法的程式碼中,我們收到了”refresh”引數後,獲取並重新整理了頁面的使用者資訊,然後還呼叫了一行程式碼用於重新整理動彈頁面:

Constants.eventBus.fire(new LoginEvent());

這行程式碼就用到了另外一個框架:event_bus

如果做過Android開發或者前端開發,應該對這個框架不陌生。EventBus是一個釋出/訂閱模式的框架,用於在某個頁面訂閱某個事件,然後在另外的地方觸發這個事件,訂閱這個事件的方法就會被執行。

該外掛的用法很簡單,首先是匯入包:

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外掛

在傳送動彈的頁面,有選擇圖片的功能,如下圖所示:

匯入外掛的程式碼如下:

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) {})

關於傳送動彈的詳細程式碼,可以參考文末的原始碼連結,這裡不再說明。

原始碼

後記

  • 本篇主要記錄的是基於Flutter的開源中國客戶端app中的各種外掛的使用。

  • 二維碼掃描的外掛使用在本篇中沒有做記錄,各位小夥伴可自行上pub倉庫搜尋外掛用法。

  • 本系列部落格並未將所有功能的實現方法都記錄下來,只是有選擇性的記錄了一部分功能的實現。

  • 本專案中還有很多功能暫未實現,比如動彈大圖預覽、個人資訊頁的展示等。大部分的功能都是以WebView的形式載入的,所以整體來看app的實現並不複雜,程式碼量也並不多,開源出來希望給學習Flutter的小夥伴們一點幫助。(如果對你有幫助,請在github給個start支援一下��)

  • 本專案中還有一些已知和未知的bug,已知的bug是token過期後沒有做自動重新整理處理(開源中國給的token是有有效期的,過期後需要使用refresh_token去重新整理access_token),未知的一些bug可能會導致app在執行過程中ANR,由於沒有對各個機型做測試,所以暫時不知道ANR是什麼原因導致的,但是在開發過程中會偶現外掛的報錯,希望各位發現bug可以及時與我聯絡(文末留言或者github提issue都行),感謝你們的支援!

我的開源專案

  1. 基於Google Flutter的開源中國客戶端,希望大家給個Star支援一下,原始碼:

  1. 基於Flutter的俄羅斯方塊小遊戲,希望大家給個Star支援一下,原始碼: