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

上一篇中我記錄了基於Flutter的開源中國客戶端裏網絡請求和數據存儲的部分,本篇記錄的是app中插件的使用,因爲不少功能並無內置到Flutter中,因此咱們須要引入一些插件來幫助咱們完成某些功能,好比app內網頁的加載,圖庫選擇照片等。php

索引 文章
1 從0開始寫一個基於Flutter的開源中國客戶端(1)
Flutter簡介及開發環境搭建 | 掘金技術徵文
2 從0開始寫一個基於Flutter的開源中國客戶端(2)
Dart語法基礎
3 從0開始寫一個基於Flutter的開源中國客戶端(3)
初識Flutter & 經常使用的Widgets
4 從0開始寫一個基於Flutter的開源中國客戶端(4)
Flutter佈局基礎
5 從0開始寫一個基於Flutter的開源中國客戶端(5)
App總體佈局框架搭建
6 從0開始寫一個基於Flutter的開源中國客戶端(6)
各個靜態頁面的實現
7 從0開始寫一個基於Flutter的開源中國客戶端(7)
App網絡請求和數據存儲
👉8 從0開始寫一個基於Flutter的開源中國客戶端(8)
插件的使用

搜索插件包

要使用插件,必須知道插件叫什麼名字,目前是什麼版本,Flutter提供了一個插件倉庫,能夠去上面搜索相關的插件,倉庫地址爲:pub.dartlang.org/,可是這個網站在國內可能訪問不了,國內能夠用Flutter專門爲中國開發者提供的網站:pub.flutter-io.cn/。該網站打開後直接在輸入框中搜索關鍵字便可,以下圖所示:前端

好比咱們須要在app中用WebView加載網頁,能夠直接搜索'web view',再或者咱們須要調用圖庫選擇圖片的功能,能夠搜索'image picker',搜索結果可能有一大堆,怎麼選擇合適的插件呢?git

因爲咱們是開發Flutter應用,因此要在搜索結果中過濾出供Flutter使用的插件,以下圖所示:github

過濾是第一步,過濾以後,還要查看插件包的更新日期,更新日期不能是好久前,由於很早以前發佈的插件包,可能並不適合如今的Flutter版本,另外就是看這個插件後面的數字,數字越大表示插件匹配程度越高,以下圖所示:web

上面兩步過濾以後,選擇你以爲合適的插件,點進去看看詳情,裏面有相關的插件說明,示例用法,肯定能夠完成你所須要的功能,就能夠愉快的在項目中添加插件依賴了。json

基本上每一個插件的主頁都會有說明如何在項目中添加該插件的依賴,好比在咱們這個基於Flutter的開源中國客戶端中,用到了flutter_webview_plugin這個插件,在該插件的主頁裏,就有怎麼引入依賴的說明:api

使用flutter_webview_plugin插件

在基於Flutter的開源中國客戶端項目中,用戶登陸和資訊詳情等頁面都使用了WebView加載網頁,使用的是flutter_webview_plugin這個插件。該插件主要功能是能夠在Flutter頁面中加載一個WebView,而且能夠監聽WebView的各類狀態好比加載中,加載完成等,並且還能讀取WebView中的cookies,或者經過dart代碼調用WebView中的js方法。瀏覽器

開源中國提供的基於oauth的認證流程大體以下:bash

  1. 在開源中國後臺添加應用,完善應用的信息,最主要的是回調地址,該地址將會在後面用到;
  2. 使用瀏覽器或者WebView加載三方認證頁面,在該頁面中輸入開源中國的用戶名和密碼(輸入密碼的頁面爲開源中國提供的頁面,第三方是沒法獲取密碼信息的);
  3. 輸入用戶名和密碼後點擊頁面上的登陸按鈕,若登陸成功,將會跳轉到第一步咱們在後臺配置的回調地址上,並給該頁面傳入一個code參數(code參數直接拼接在URL上);
  4. 在該頁面中接收code參數,並根據開源中國後臺提供的client_id client_secret等參數換取token信息(這一步就是一個get請求,只不過放在我本身的服務端進行了);
  5. 上面的請求成功後,開源中國的openapi會返回token等信息,在咱們的回調頁面將這個信息經過js的一個get()方法暴露出來,讓dart代碼去調用。

具體的oauth認證流程能夠查看開源中國的文檔:文檔地址cookie

構造登陸頁面

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是一個發佈/訂閱模式的框架,用於在某個頁面訂閱某個事件,而後在另外的地方觸發這個事件,訂閱這個事件的方法就會被執行。

該框架在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都行),感謝大家的支持!

個人開源項目

  1. 基於Google Flutter的開源中國客戶端,但願你們給個Star支持一下,源碼:
  1. 基於Flutter的俄羅斯方塊小遊戲,但願你們給個Star支持一下,源碼:
上一篇
從0開始寫一個基於Flutter的開源中國客戶端(7)——App網絡請求和數據存儲
相關文章
相關標籤/搜索