從0開始寫一個基於Flutter的開源中國客戶端(3)——經常使用的Widgets

上一篇主要介紹了Dart語言的語法基礎,從這一篇開始就要真正涉及到Flutter的開發了,但願本身在寫做的過程當中能溫故知新,同時給Flutter初學者帶來一些幫助。html

建立項目,添加代碼

還記得在上一篇中,咱們使用Android Studio建立了一個Flutter項目嗎?新建立的Flutter項目自動爲咱們生成了一些代碼,代碼在/lib/main.dart文件中,這裏咱們先清空/lib/main.dart文件中的代碼,用下面的代碼代替:android

// main.dart文件內容
import 'package:flutter/material.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text('First App')
        ),
        body: new Center(
          child: new Text('Hello world'),
        ),
      ),
    );
  }
}

啓動模擬器

爲了在手機上跑起咱們的App來,首先咱們得運行一個模擬器(固然你也能夠用真機調試)。若是你的電腦上Flutter開發環境配置得沒有問題,該裝的都裝了(運行flutter doctor命令檢查依賴是否安裝,AndroidStudio的dart和flutter插件也必須安裝),那麼在Android Studio的工具欄上,應該能夠看到以下圖的圖標: <img src="https://user-gold-cdn.xitu.io/2018/7/20/164b70798c50cccc?w=2876&h=1084&f=png&s=397663" />ios

這裏因爲個人電腦上尚未任何運行的Android / iOS模擬器,因此這裏顯示的是<no devices>,點擊該按鈕,選擇Open iOS Simulator便可啓動一個iOS模擬器(確保你的電腦上安裝了Xcode)以下圖所示:git

<img src="https://user-gold-cdn.xitu.io/2018/7/20/164b70da3349d6ec?w=680&h=306&f=png&s=44855" width=300/>github

若是你想建立Android模擬器,必須先確保你有可用的Android模擬器,在AndroidStudio的工具欄上找到AVD Manager圖標,以下圖:markdown

<img src="https://user-gold-cdn.xitu.io/2018/7/20/164b70e9dffb3e07?w=1070&h=206&f=png&s=29892" width=300/>網絡

點擊打開Android模擬器管理對話框,以下圖: 這裏我已經建立了一個API 27的Android模擬器,若是你的列表爲空,點擊圖中Create Virtual Devices...建立模擬器便可。app

運行Dart代碼

第一步中咱們已經寫好了代碼,第二步中咱們的模擬器也啓動了,點擊AndroidStudio工具欄中的Run按鈕便可運行Flutter項目到咱們的模擬器中了,Run按鈕在下圖所示位置: 稍等片刻,模擬器中就會自動安裝並打開咱們的第一個Flutter App了,以下圖所示:框架

<img src="https://user-gold-cdn.xitu.io/2018/7/20/164b714f5afe0cb9?w=704&h=1366&f=png&s=64497" width=300/>less

能夠看到,咱們僅僅用了20多行代碼,就完成了一個精美的Demo App(雖然沒有實現任何功能,可是對比下若是要用Android或iOS原生開發方式,能夠作到這麼簡單實現嗎),這一切都歸功於Flutter爲咱們提供的Widgets,下面的篇幅裏會針對經常使用的Widgets作一些講解。

Flutter項目結構

新建立的Flutter項目的結構以下圖所示:

各個目錄/文件說明以下:

.
├── README.md                   ---markdown項目描述文件
├── android                     ---Android源代碼目錄    
├── build                       ---項目構建後輸出的相關文件目錄
├── flutter_app.iml             ---項目相關的配置文件
├── flutter_app_android.iml     ---Android相關的配置文件
├── ios                         ---iOS源代碼目錄
├── lib                         ---Dart源碼目錄
├── pubspec.lock                ---安裝鎖定文件
├── pubspec.yaml                ---flutter依賴配置文件,相似Android中的build.grale
└── test                        ---測試代碼目錄

咱們開發的代碼主要存放在lib/目錄下,項目的入口文件main.dart也在lib/目錄下。

Flutter App是怎樣的App

關於一個Flutter App,你須要瞭解以下幾個點:

  1. Flutter App的佈局文件都是使用Dart代碼來寫的(業務邏輯代碼和UI代碼都用dart來寫),沒有像Android中的xml佈局文件或者iOS中的xib, storyboard文件等。
  2. Flutter App中的界面都是由Widget組成的,Widget分爲兩種:StatefulWidget和StatelessWidget。StatefulWidget表示一個有狀態的組件,這個組件的狀態發生改變時,組件UI會同步發生改變;StatelessWidget表示一個無狀態的組件,它沒有狀態的改變,UI也不會發生改變。若是你熟悉Reactjs,對Flutter中的這兩種組件就很容易理解了。
  3. Dart是一門單線程語言,這意味着在Flutter開發過程當中你不用去考慮線程的同步異步、鎖、線程切換等問題,網絡請求也好,UI更新也好,都在一個線程中執行,只不過那些比較耗時的操做(網絡IO,文件IO等等)會被放入延遲運算隊列中以避免阻塞了其餘的操做而形成卡頓。
  4. Flutter跟ReactNative或者WEEX這類移動端跨平臺框架最大的區別在於:Flutter經過AOT(Ahead of Time)或者JIT(Just In Time)的方式(Debug模式下采用JIT編譯,Release模式下使用AOT編譯)將Dart代碼直接編譯成對應平臺的代碼用於在移動設備上執行,而ReactNative、WEEX則是有一套本身的jsRender,將js代碼經過渲染引擎渲染成原生的UI,這個過程有js和native的互操做,也就是一個jsBridge,因此ReactNative或者WEEX雖然寫出來的也是原生應用,可是因爲有了jsBridge的存在,致使代碼運行的效率沒有直接編譯成原生代碼的Flutter App的運行效率高。

Flutter經常使用Widgets

在移動開發中,咱們常常會跟按鈕、文本輸入框、圖片等打交道,Flutter中也不例外,使用Flutter開發的App,界面上的每個UI元素都是一個Widget,經過不一樣的Widget組合造成一整個頁面。除了按鈕、輸入框、圖片等Widget外,Flutter還給咱們提供了不少功能強大,界面美觀的Widget,好比在本文最開始的一段代碼:

// main.dart文件內容
import 'package:flutter/material.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text('First App')
        ),
        body: new Center(
          child: new Text('Hello world'),
        ),
      ),
    );
  }
}

在上面的代碼中,MyApp是咱們自定義的一個類,它繼承自StatelessWidget,表明它是一個無狀態的組件,UI不會發生改變。build方法是父類的一個方法,被MyApp類重寫了,繼承自StatelessWidget的類必須實現build方法並返回一個Widget對象。因此在上面的代碼中,MaterialApp也是一個Widget,若是你用AndroidStudio查看源碼,會發現MaterialApp的參數home也是一個Widget對象,因此上面的Scaffold也是一個Widget。

StatefulWidget和StatelessWidget

StatefulWidget和StatelessWidget是Flutter中全部Widget的兩個分類,StatefulWidget的內部保存有狀態,當狀態發生改變時,Widget的界面也會隨之改變(這點跟React相似);StatelessWidget的內部沒有保存狀態,它的界面也不會發生改變。上面的代碼中已經展現了定義一個無狀態Widget的步驟:繼承StatelessWidget並實現build方法便可。若是是定義一個有狀態的Widget,代碼會稍微多一點,以下代碼所示:

import 'package:flutter/material.dart';

void main() => runApp(new MyStatefulWidget());

// 定義一個有狀態的組件
class MyStatefulWidget extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return new MyStatefulWidgetState();
  }
}

// 定義一個有狀態的組件時,必須爲該組件建立一個狀態類,這個類繼承自State類
class MyStatefulWidgetState extends State<MyStatefulWidget> {

  String text = "Click Me!";

  changeText() {
    if (text == "Click Me!") {
      setState(() {
        text = "Hello World!";
      });
    } else {
      setState(() {
        text = "Click Me!";
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: "Test",
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text("Test"),
        ),
        body: new Center(
          // InkWell是Flutter內置的一個Widget,用於給其餘Widget添加點擊事件,而且在點擊時會有水波紋擴散效果
          child: new InkWell(
            child: new Text(text),
            onTap: () {
              this.changeText();
            },
          ),
        ),
      ),
    );
  }
}

上面的代碼運行後,會在頁面中央顯示文本,點擊該文本時,文本內容會在"Click Me!"和"Hello World!"間切換,以下圖所示:

定義一個有狀態的組件的步驟以下:

  1. 建立類繼承自StatefulWidget並實現createState方法,注意,這裏跟StatelessWidget不一樣了,不是實現build方法。createState方法返回的是一個狀態State。
  2. 爲了讓第一步中的createState方法有返回值,還須要建立一個狀態類繼承自State類,State類是個泛型類,你須要將第一步中建立的類傳給State。
  3. 建立完自定義的State類後,實現build方法,並返回你所須要的Widget。
  4. 在自定義的State類中,用變量保存組件的狀態,並在合適的時候改變這個狀態值。好比在上面的代碼中,咱們須要在點擊文本時切換文本,因此用一個text變量保存組件的文本值,當點擊按鈕時,經過調用State組件的setState()方法,從新爲text變量賦值,從而達到改變文本的目的。

若是你瞭解Reactjs,那麼對於Flutter的這種狀態機制確定也不陌生。React中也是經過一個state對象保存Component的狀態,當狀態須要改變時,調用setState()方法修改狀態,組件就會自動刷新。

MaterialApp和Scaffold

MaterialApp和Scaffold是Flutter提供的兩個Widget,其中:

  • MaterialApp是一個方便的Widget,它封裝了應用程序實現Material Design所須要的一些Widget。(參考

  • Scaffold組件是Material Design佈局結構的基本實現。此類提供了用於顯示drawer、snackbar和底部sheet的API。(參考

基於Flutter的開源中國客戶端App中,我也使用到了MaterialApp和Scaffold兩個組件,下面是部分代碼:

@override
  Widget build(BuildContext context) {
    initData();
    return new MaterialApp(
      theme: new ThemeData(
        // 設置主題顏色
        primaryColor: const Color(0xFF63CA6C)
      ),
      home: new Scaffold(
        // 設置App頂部的AppBar
        appBar: new AppBar(
          // AppBar的標題
          title: new Text(appBarTitles[_tabIndex], 
          // 標題文本的顏色
          style: new TextStyle(color: Colors.white)),
          // AppBar上的圖標的顏色
          iconTheme: new IconThemeData(color: Colors.white)
        ),
        body: _body,
        // 頁面底部的導航欄
        bottomNavigationBar: new CupertinoTabBar(
          items: <BottomNavigationBarItem>[
            new BottomNavigationBarItem(
                icon: getTabIcon(0),
                title: getTabTitle(0)),
            new BottomNavigationBarItem(
                icon: getTabIcon(1),
                title: getTabTitle(1)),
            new BottomNavigationBarItem(
                icon: getTabIcon(2),
                title: getTabTitle(2)),
            new BottomNavigationBarItem(
                icon: getTabIcon(3),
                title: getTabTitle(3)),
          ],
          currentIndex: _tabIndex,
          // 底部Tab的點擊事件處理
          onTap: (index) {
            setState((){
              _tabIndex = index;
            });
          },
        ),
        // 側滑菜單,這裏的MyDrawer是自定義的Widget
        drawer: new MyDrawer(),
      ),
    );
  }

Text組件

Text組件是很是經常使用的組件,任何須要顯示文本的地方基本都會用到。經過查看Text類的源碼,能夠發現Text是一個無狀態的組件,下面的代碼演示瞭如何修改Text組件的字號、顏色,給字體加粗、設置下劃線、設置斜體等:

import 'package:flutter/material.dart';

void main() => runApp(new MaterialApp(
  title: "Text Demo",
  home: new Scaffold(
    appBar: new AppBar(
      title: new Text("Text Demo"),
    ),
    body: new Center(
      child: new Text(
        "Hello Flutter",
        style: new TextStyle(
          color: Colors.red, // 或者用這種寫法:const Color(0xFF6699FF) 必須使用AARRGGBB
          fontSize: 20.0, // 字號
          fontWeight: FontWeight.bold, // 字體加粗
          fontStyle: FontStyle.italic, // 斜體
          decoration: new TextDecoration.combine([TextDecoration.underline]) // 文本加下劃線
        ),
      ),
    ),
  ),
));

注意

  1. MaterialApp的title參數是字符串類型,而AppBar的title參數是一個Text組件類型。
  2. 開發基於Flutter的開源中國客戶端時,Flutter仍是beta版本,致使在設置中文文本的某些樣式時不起做用,好比字體加粗,斜體等。目前的Flutter Preview版本,該問題好像已經修復了。

TextField組件

TextFiled組件用於文本的輸入,示例代碼以下:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: "Test",
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text("Test")
        ),
        body: new Padding(
          padding: const EdgeInsets.all(8.0),
          child: new TextField(
            maxLines: 8, // 設置輸入框顯示的最大行數(不是可輸入的最大行數)
            maxLength: 30, // 設置輸入框中最多可輸入的字符數
            decoration: new InputDecoration( // 給輸入框添加樣式
              hintText: "Input something...", // 輸入框中placeholder文本
              border: new OutlineInputBorder( // 輸入框的邊框
                borderRadius: const BorderRadius.all(Radius.circular(1.0))
              )
            ),
          )
        )
      ),
    );
  }
}

在模擬器中運行界面以下圖:

InkWell和GestureDetector

這兩個組件放到一塊兒說,是由於在處理組件的點擊事件時,會常常用到它們。 好比某個列表的item的點擊事件,某個圖標的點擊事件等等。Flutter有專門設計MaterialDesign風格的按鈕,可是更多時候咱們但願自定義按鈕樣式或者爲某個組件添加點擊事件,因此在處理點擊事件時,最多見的作法是,用InkWell或者GestureDetector將某個組件包起來。

InkWell的使用方法以下:

new InkWell(
    child: new Text("Click me!"),
    onTap: () {
      // 單擊
    },
    onDoubleTap: () {
      // 雙擊
    },
    onLongPress: () {
      // 長按
    }
  );

GestureDetector用法與InkWell相似,不過GestureDetector有更多處理手勢的方法,這裏暫時不作介紹(其實我也用得很少)。

按鈕

Flutter提供了幾種類型的按鈕組件:RaisedButton FloatingActionButton FlatButton IconButton PopupMenuButton,下面用一段代碼說明這幾種按鈕的用法:

import 'package:flutter/material.dart';

main() {
  runApp(new MyApp());
}

enum WhyFarther { harder, smarter, selfStarter, tradingCharter }

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Test',
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text('Test')
        ),
        body: new Column(
          children: <Widget>[
            new RaisedButton(
              child: new Text("Raised Button"),
              onPressed: (){},
            ),
            new FloatingActionButton(
              child: new Icon(Icons.add),
              onPressed: (){},
            ),
            new FlatButton(
              onPressed: (){},
              child: new Text("Flat Button")
            ),
            new IconButton(
              icon: new Icon(Icons.list),
              onPressed: (){}
            ),
            new PopupMenuButton<WhyFarther>(
              onSelected: (WhyFarther result) {},
              itemBuilder: (BuildContext context) => <PopupMenuEntry<WhyFarther>>[
                const PopupMenuItem<WhyFarther>(
                  value: WhyFarther.harder,
                  child: const Text('Working a lot harder'),
                ),
                const PopupMenuItem<WhyFarther>(
                  value: WhyFarther.smarter,
                  child: const Text('Being a lot smarter'),
                ),
                const PopupMenuItem<WhyFarther>(
                  value: WhyFarther.selfStarter,
                  child: const Text('Being a self-starter'),
                ),
                const PopupMenuItem<WhyFarther>(
                  value: WhyFarther.tradingCharter,
                  child: const Text('Placed in charge of trading charter'),
                ),
              ],
            )
          ],
        )
      )
    );
  }
}

在模擬器中上面的代碼運行效果以下圖所示:

Dialog組件

Flutter提供了兩種類型的對話框:SimpleDialog和AlertDialog。SimpleDialog是一個能夠顯示附加的提示或操做的簡單對話框,AlertDialog則是一個會中斷用戶操做的對話框,須要用戶確認的對話框,下面用代碼來講明其用法:

import 'package:flutter/material.dart';

main() {
  runApp(new MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Test',
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text('Test')
        ),
//        body: new MyAlertDialogView()
        body: new MySimpleDialogView(),
      ),
    );
  }
}

class MyAlertDialogView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new RaisedButton(
      child: new Text('顯示AlertDialog'),
      onPressed: () {
        showDialog<Null>(
          context: context,
          barrierDismissible: false, // 不能點擊對話框外關閉對話框,必須點擊按鈕關閉
          builder: (BuildContext context) {
            return new AlertDialog(
              title: new Text('提示'),
              content: new Text('微軟重申Windows 7將在2020年1月到達支持終點,公司但願利用這個機會說服用戶在最新更新發布以前升級到Windows 10。'),
              actions: <Widget>[
                new FlatButton(
                  child: new Text('明白了'),
                  onPressed: () {
                    Navigator.of(context).pop();
                  },
                ),
              ],
            );
          },
        );
      },
    );
  }
}

class MySimpleDialogView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new RaisedButton(
      child: new Text('顯示SimpleDialog'),
      onPressed: () {
        showDialog(
          context: context,
          builder: (BuildContext ctx) {
            return new SimpleDialog(
              title: new Text('這是SimpleDialog'),
              children: <Widget>[
                new SimpleDialogOption(
                  onPressed: () { Navigator.pop(context); },
                  child: const Text('肯定'),
                ),
                new SimpleDialogOption(
                  onPressed: () { Navigator.pop(context); },
                  child: const Text('取消'),
                ),
              ],
            );
          }
        );
      },
    );
  }
}

上面的代碼分別展現了SimpleDialog和AlertDialog的基本用法。須要注意的是,這裏並無直接將按鈕和顯示對話框的邏輯寫到MyApp類中,而是分兩個StatelessWidget來寫的,若是你直接將按鈕及顯示對話框的邏輯寫到MyApp的build方法裏,是會報錯的,具體報錯信息爲:

Navigator operation requested with a context that does not include a Navigator.

意思是導航操做須要一個不包含Navigator的上下文對象,而若是咱們將showDialog的邏輯寫到MyApp的build方法中時,使用的是MaterialApp的上下文對象,這個上下文對象是包含Navigator的,因此就會報錯。上面的代碼在模擬器中運行效果以下圖:

Image組件

Image組件用於顯示一張圖片,能夠加載本地(項目中或手機存儲中)或網絡圖片。

加載本地圖片

使用下面的方法加載一張項目中的圖片:

new Image.asset(path, width: 20.0, height: 20.0, fit: BoxFit.cover)

其中path是項目中的圖片目錄。

加載項目中的圖片必定要注意編輯pubspec.yaml文件:

假設當前咱們在跟lib/同級的目錄下建立了images/目錄,在images/目錄下存放了若干圖片供項目使用,那麼必定要記得在項目根目錄下(也是跟images/同級的目錄)編輯pubspec.yaml文件,打開pubspec.yaml文件,默認狀況下assets是被註釋了的,這裏咱們要取消註釋assets並添加images/目錄下的每一個圖片的路徑,以下圖所示:

在上圖中咱們配置了文件路徑images/ic_nav_news_normal.png,因此能夠用下面的代碼來加載圖片了:

new Image.asset('images/ic_nav_news_normal.png', width: 20.0, height: 20.0, fit: BoxFit.cover)

widthheight是圖片長寬,爲double類型,若是你傳整型20則會報錯。 若是要加載手機存儲中的圖片,使用下面的方法:

new Image.file(path, width: 20.0, height: 20.0, fit: BoxFit.cover)

fit屬性指定了圖片顯示的不一樣方式,有以下幾個值:

  • contain:儘量大,同時仍然包含圖片徹底在目標容器內。

  • cover:儘量小,同時仍然覆蓋整個目標容器。

  • fill:經過拉伸圖片的長寬比填充目標容器。

  • fitHeight:確保是否顯示了圖片的完整高度,而不論是否意圖片高度溢出了目標容器。

  • fitWidth:確保是否顯示了圖片的完整寬度,而不論是否圖片高度溢出目標容器。

  • none:對齊目標容器內的圖片(默認狀況下居中)並丟棄位於容器外的圖片的任何部分。圖片原始大小不會被調整。

  • scaleDown:對齊目標容器內的圖片(默認狀況下居中),若是必要的話,對圖片進行縮放,以確保圖片適合容器。這與contain的狀況相同,不然它與沒有同樣。

加載網絡圖片

加載網絡圖片使用下面的方法:

new Image.network(imgUrl, width: 20.0, height: 20.0, fit: BoxFit.cover)

ListView組件

ListView組件用於顯示一個列表,在基於Flutter的開源中國客戶端App中,新聞列表、動彈列表等都須要用到ListView,一個最簡單的ListView能夠用以下代碼實現:

import 'package:flutter/material.dart';

void main() {
  List<Widget> items = new List();
  for (var i = 0; i < 20; i++) {
    items.add(new Text("List Item $i"));
  }
  runApp(new MaterialApp(
    title: "Text Demo",
    home: new Scaffold(
      appBar: new AppBar(
        title: new Text("Text Demo"),
      ),
      body: new Center(
          child: new ListView(children: items)
      ),
    ),
  ));
}

運行上面的代碼,結果以下圖所示:

這樣的ListView顯示不是咱們須要的,太難看,每一個item沒有邊距並且沒有分割線,因此咱們用下面的代碼改造一下:

import 'package:flutter/material.dart';

void main() {
  // 裝有ListView中全部item的集合
  List<Widget> items = new List();
  for (var i = 0; i < 20; i++) {
    var text = new Text("List Item $i");
    // Padding也是一個Widget,是一個有內邊距的容器,能夠裝其餘Widget
    items.add(new Padding(
      // 內邊距設置爲15.0,上下左右四邊都是15.0
      padding: const EdgeInsets.all(15.0),
      // Padding容器中裝的是Text組件
      child: text
    ));
  }
  runApp(new MaterialApp(
    title: "Text Demo",
    home: new Scaffold(
      appBar: new AppBar(
        title: new Text("Text Demo"),
      ),
      body: new Center(
        // build是ListView提供的靜態方法,用於建立ListView
        child: new ListView.builder(
          // itemCount是ListView的item個數,這裏之因此是items.length * 2是由於將分割線也算進去了
          itemCount: items.length * 2,
          itemBuilder: (context, index) {
            // 若是index爲奇數,則返回分割線
            if (index.isOdd) {
              return new Divider(height: 1.0);
            }
            // 這裏index爲偶數,爲了根據下標取items中的元素,須要對index作取整
            index = index ~/ 2;
            return items[index];
          },
        )
      )
    ),
  ));
}

此時再次運行上面的代碼,UI就好看多了:

關於ListView的用法,上面的代碼中已有相關注釋,更詳細的用法會在後面的篇幅中介紹,好比ListView中的item實現不一樣的佈局,下拉刷新,加載更多等等。

小結

關於Flutter經常使用的部分Widget,在上面已有相關示例代碼和說明,你還能夠在Flutter中文網上查看更多組件及其用法。下一篇中我將記錄Flutter中的佈局,任何移動開發,甚至Web開發和桌面端應用開發中都不可避免的須要瞭解佈局的知識。

個人開源項目

  1. 基於Google Flutter的開源中國客戶端,但願你們給個Star支持一下,源碼:

    GitHub

    碼雲

  1. 基於Flutter的俄羅斯方塊小遊戲,但願你們給個Star支持一下,源碼:

    GitHub

    碼雲

相關文章
相關標籤/搜索