1. 程式人生 > >Flutter(二) 建立第一個Flutter App

Flutter(二) 建立第一個Flutter App

這一章主要是建立一個Flutter App。如果你熟悉面向物件程式設計,有基本的程式設計概念(變數,迴圈,條件判斷等),那麼你不必要具備原有的Dart和移動開發經驗,,就可以輕鬆地理解完成這章內容。

構造什麼

你將為一家初創公司實現一個簡單的移動app,主要功能是為這家公司推薦名字。使用者可以選擇或者取消名字,儲存最好的名字。程式會一次性產生10個名字。在使用者滾動時,新名字同時會產生出來。使用者可以點選導航欄(appbar)的列表圖示進入到一個新的列表頁檢視喜歡的名字。

最終的結果最後執行結果中可以看到。

你將會學到:
- Flutter app基本結構
- 查詢並使用包來擴充套件功能
- 使用熱載入加快開發週期
- 如何實現一個有狀態元件(Stateful widget)
- 如何建立一個無限,懶載入列表
- 如何建立並路由到第二個螢幕
- 如何使用app主題修改外觀

你將會使用:
需要安裝:
- Flutter SDK
Flutter SDK包含了Flutter引擎,framework,元件,工具,和Dart SDK。這份程式碼實驗需要v0.1.4或者更高版本。

  • Android Studio
    這次程式碼實驗需要Android Studio。也可以在命令列。

  • 需要IDE外掛
    IDE上必須分別安裝Flutter和Dart外掛。

    檢視Flutter安裝學習如何建立起你的Flutter環境

Step 1:建立Flutter App

建立第一個簡單的,IDE提供模板的Flutter App,可以

按照引導建立Flutter工程。我將工程名命名為flutter_app,這個按照個人習慣吧,只要名稱合法就行。

在這節程式實驗中,最多編輯的會是lib/main.dart,其中就是Dart程式碼。

Tips:當你貼上程式碼到IDE中的時候,可能發生縮排不對齊的情況。你可以使用Flutter工具來解決這種問題:
1. Android Studio/IntelliJ IDEA:右單擊Dart程式碼,選擇Refactor code with dartfmt
2. VS Code:右單擊選擇 Format Document
3. Terminal:執行命令 flutter format filename
  1. 替換模板的lib/main.dart
    移除原有工程中的模板程式碼lib/main.dart。輸入如下程式碼,可以檢視到UI中間顯示的“Hello World”。
        import 'package:flutter/material.dart';

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

        class MyApp extends StatelessWidget {
          @override
          Widget build(BuildContext context) {
            return new MaterialApp(
              title: 'Welcome to Flutter',
              home: new Scaffold(
                appBar: new AppBar(
                  title: new Text('Welcome to Flutter'),
                ),
                body: new Center(
                  child: new Text('Hello World'),
                ),
              ),
            );
          }
        }
  1. 執行app,可以看到如下執行效果
    Hello World

結果

  • 這個例子建立了一個Material app。Material是在移動端和web端上的設計標準。Flutter提供了豐富的Material元件。
  • main()方法聲明瞭胖箭頭(=>)符號表示法,這種寫法表示了man()方法是一個單行函式,即函式體只有一行程式碼構成。
  • App繼承StatelessWidget,這也使得App本身成為了一個元件。在Flutter中,幾乎所有物件都被認為是一個元件,包含對齊方式,內邊距和佈局。
  • Material元件庫中的Scaffold提供了App預設需要的appbar,title,和body屬性,body屬性包含了home screen的元件樹結構。元件的子元件結構可以相當複雜。
  • 元件的主要工作就是提供build()方法,描述如何展示自己及其他元件。
  • 這個例子中元件結構有一個Center元件中包含一個Text子元件組成。Center元件將其內的元件結構置於螢幕中央。

Step 2:使用外部包

這一步中,你將使用開源包english_words。這個包中包含了幾千個最常用的英文單詞和一些實用方法。

  1. 檔案pubspec.yaml管理者Flutter App的資源。在pubspec.yaml中,新增english_words(3.1.0或者更高)到依賴列表。如下程式碼中:
...
dependencies:  
    flutter: sdk: flutter  

    # The following adds the Cupertino Icons font to your application.  
    # Use with the CupertinoIcons class for iOS style icons. 
    cupertino_icons: ^0.1.0  

    english_words: ^3.1.0
  ...

原有模板檔案中程式碼太多(包含註釋),不全部展示出來。這裡在depenencies下新增english_words包依賴。

  1. 在Android Studio編輯器中檢視pubspec.yaml檔案時,可以看到編輯器右上方有命令操作欄,
    AS檢視pubspec檔案的命令操作欄
    點選Package get。這就是獲取english_words包操作。你會在控制檯命令列看到:
    獲取english_words包控制檯輸出

  2. 在lib/main.dart中,新增對english_words包的匯入,如下顯示的匯入語句:

import 'package:flutter/material.dart';  
import 'package:english_words/english_words.dart';

在你輸入後,AS會因為你寫的匯入語句給出建議。表現在你輸入的匯入語句會變成灰色,這就是提示你匯入的庫還沒有使用過。

  1. 使用english_words包來生成文字,而不再顯示“Hello World”
Tips:“Pascal case”(大駝峰規則)意思是在一個字串中的每個單詞,包括第一個單詞,都是以大些字母開頭。因此,“uppercamelcase”就是”UpperCamelCase”。

針對原有程式碼做出修改

import 'package:flutter/material.dart';  
import 'package:english_words/english_words.dart';  

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

class MyApp extends StatelessWidget {  
  @override  
  Widget build(BuildContext context) {  
    final wordPair = new WordPair.random();  
      return new MaterialApp(  
        title: 'Welcome to Flutter',  
        home: new Scaffold(  
                appBar: new AppBar(  
                  title: new Text('Welcome to Flutter'),  
                ),  
        body: new Center(  
                  child: new Text(wordPair.asPascalCase),  
              ),  
        ),  
     );  
  }  
}
  1. 如果App正在執行,使用熱載入按鈕更新app。每次點選熱載入按鈕,或者進行儲存時,你應該都能在執行的app上看到隨機選取的不同的單詞對。這是因為單詞對是在build()方法中產生,build()方法每次在MaterialApp需要渲染或者在Flutter Inspector中開啟Platform時被執行。
    PascalCase執行效果

Step 3:新增有狀態元件

無狀態元件(Stateless widget)是不可變的,意味著他們的屬性無法改變——所有值是final的。

有狀態元件(Stateful widget)維持一個狀態值,此狀態值會根據元件生命週期而有所改變。實現一個有Stateful widget需要至少兩個類:
1. StatefulWidget類用以建立例項;
2. State類。

StatefulWidget元件本身是不可變的,但是State類在整個元件宣告週期過程中是始終存在的。

在這步中,你將會新增一個stateful widget,RandomWords以及建立對應的狀態State類,RandomWordsState。State類實際上為元件保留著喜歡的單詞對。

  1. 在main.dart檔案中新增RandomWords元件。這個類可以定義在檔案內任意位置,但是我將其定義在檔案末尾。
class RandomWords extends StatefulWidget {  

  @override  
  createState() => new RandomWordsState();  
}
  1. 新增RandomWordsState類。大部分的app功能程式碼都會在這個類中。這個類同時儲存使用者滾動列表過程中產生的所有單詞對,以及使用者新增喜歡或者移除的單詞對。
    下面新增對基本的了定義,來保證類檔案編譯通過
class RandomWordsState extends State<RandomWords> {
}
  1. 新增State類之後,IDE會提示缺少一個build()方法。下一步,需要將產生單詞對的程式碼移至這個build()方法中。

在RandomWordsState的build()方法中新增程式碼,整個檔案看起來像這樣

import 'package:flutter/material.dart';
import 'package:english_words/english_words.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {

    return new MaterialApp(
      title: 'Welcome to Flutter',
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text('Welcome to Flutter'),
        ),
        body: new Center(
          child: new RandomWords(),
        ),
      ),
    );
  }
}

class RandomWords extends StatefulWidget {
  @override
  createState() => new RandomWordsState();
}

class RandomWordsState extends State<RandomWords> {
  @override
  Widget build(BuildContext context) {
    final wordPair = new WordPair.random();
    return new Text(wordPair.asPascalCase);
  }
}
  1. 重啟App執行。
    目前修改的程式碼在執行起來之後,效果與之前的一樣,只是將無狀態元件換成了有狀態元件。

Step 4:建立無限滾動列表

在這步中,你將擴充套件RandomWordsState類來展示一個列表。列表隨著使用者的滾動無限增粘。ListView的builder工廠方法可以按需要進行懶載入。

  1. 在RandomWordsState中新增 _suggestions 列表變數儲存生成的候選單詞對。變數以 (_) 開頭 ——在Dart中,下劃線開頭的變數強調私有

同時新增比變數 biggerFont 改變字型大小。

class RandomWordsState extends State<RandomWords> {
  final _suggestions = <WordPair>[];

  final _biggerFont = const TextStyle(fontSize: 18.0);
  ...
}
  1. 在RandomWordsState類中新增 _buildSuggestions()方法。這個類主要功能就是產生需要展示的單詞對列表。

Listview提供了builder屬性itemBuilder 用以產生item及匿名函式的回撥。BuildContext和行的迭代器索引 i ,這兩個引數被傳到ListView的buil()方法。迭代器索引從0開始增長,每次方法呼叫產生一個單詞對的時候就會增長。這就使得列表在使用者滾動時無限增長。
增加程式碼後,整個類看起來就是

class RandomWordsState extends State<RandomWords> {
  final _suggestions = <WordPair>[];

  final _biggerFont = const TextStyle(fontSize: 18.0);

  final _saved = new Set<WordPair>();

  .....

  Widget _buildSuggestions() {
    return new ListView.builder(
        padding: const EdgeInsets.all(16.0),
        // The itemBuilder callback is called once per suggested word pairing,
        // and places each suggestion into a ListTile row.
        // For even rows, the function adds a ListTile row for the word pairing.
        // For odd rows, the function adds a Divider widget to visually
        // separate the entries. Note that the divider may be difficult
        // to see on smaller devices.
        itemBuilder: (context, i) {
          // Add a one-pixel-high divider widget before each row in theListView.
          if (i.isOdd) return new Divider();

          // The syntax "i ~/ 2" divides i by 2 and returns an integer result.
          // For example: 1, 2, 3, 4, 5 becomes 0, 1, 1, 2, 2.
          // This calculates the actual number of word pairings in the ListView,
          // minus the divider widgets.
          final index = i ~/ 2;
          // If you've reached the end of the available word pairings...
          if (index >= _suggestions.length) {
            // ...then generate 10 more and add them to the suggestions list.
            _suggestions.addAll(generateWordPairs().take(10));
          }
          return _buildRow(_suggestions[index]);
        }
    );
  }
}
  1. 在 _buildSuggestions()方法中呼叫了 _buildRow()方法。這個函式作用是在ListTile元件中顯示新的單詞對。ListTile元件可以讓你的每行看起來更加具有渲染力。

在RandomWordsState中新增 _buildRow()方法

class RandomWordsState extends State<RandomWords> {
  ...
  Widget _buildRow(WordPair pair) {
    return new ListTile(
      title: new Text(
        pair.asPascalCase,
        style: _biggerFont,
      ),
    );
  }
}
  1. 最後來更新RandomWordsState入口函式build()。
    整體檔案最終程式碼:
import 'package:flutter/material.dart';
import 'package:english_words/english_words.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {

    return new MaterialApp(
      title: 'Welcome to Flutter',
      home: new RandomWords(),
    );
  }
}

class RandomWords extends StatefulWidget {

  @override
  createState() => new RandomWordsState();
}

class RandomWordsState extends State<RandomWords> {
  final _suggestions = <WordPair>[];

  final _biggerFont = const TextStyle(fontSize: 18.0);

  @override
  Widget build(BuildContext context) {
    return new Scaffold (
      appBar: new AppBar(
        title: new Text('Startup Name Generator'),
      ),
      body: _buildSuggestions(),
    );
  }

  Widget _buildSuggestions() {
    return new ListView.builder(
        padding: const EdgeInsets.all(16.0),
        // The itemBuilder callback is called once per suggested word pairing,
        // and places each suggestion into a ListTile row.
        // For even rows, the function adds a ListTile row for the word pairing.
        // For odd rows, the function adds a Divider widget to visually
        // separate the entries. Note that the divider may be difficult
        // to see on smaller devices.
        itemBuilder: (context, i) {
          // Add a one-pixel-high divider widget before each row in theListView.
          if (i.isOdd) return new Divider();

          // The syntax "i ~/ 2" divides i by 2 and returns an integer result.
          // For example: 1, 2, 3, 4, 5 becomes 0, 1, 1, 2, 2.
          // This calculates the actual number of word pairings in the ListView,
          // minus the divider widgets.
          final index = i ~/ 2;
          // If you've reached the end of the available word pairings...
          if (index >= _suggestions.length) {
            // ...then generate 10 more and add them to the suggestions list.
            _suggestions.addAll(generateWordPairs().take(10));
          }
          return _buildRow(_suggestions[index]);
        }
    );
  }

  Widget _buildRow(WordPair pair) {
    return new ListTile(
      title: new Text(
        pair.asPascalCase,
        style: _biggerFont,
      ),
    );
  }
}
  1. 最終重新啟動App執行。

列表滾動列表

Step 5:新增互動

這步中,你講為每行item新增一個可點選的心形圖示,在使用者點選item時,對應的單詞對(word pair)會被新增到收藏或者被移除。

  1. 在RandomWordsState類中新增一個 _saved 集合(Set)變數。這個集合儲存了使用者喜歡並收藏的單詞對。之所以使用集合是因為集合可以保證其中沒有重複的單詞對。
class RandomWordsState extends State<RandomWords> {
  final _suggestions = <WordPair>[];

  final _biggerFont = const TextStyle(fontSize: 18.0);

  final _saved = new Set<WordPair>();
  ...
}
  1. 在函式_buildRow()中,新增變數alreadySaved來檢查使用者點選的wordPair是否已經儲存。
  Widget _buildRow(WordPair pair) {
    final alreadySaved = _saved.contains(pair);
    ...
  }
  1. 同樣還需要在函式_buildRow()中,需要在ListTiles中新增心形圖示來表示收藏狀態。後邊,你將會為次新增收藏取消功能的互動。
  Widget _buildRow(WordPair pair) {
    final alreadySaved = _saved.contains(pair);
    return new ListTile(
      title: new Text(
        pair.asPascalCase,
        style: _biggerFont,
      ),
      trailing: new Icon(
        alreadySaved ? Icons.favorite : Icons.favorite_border,
        color: alreadySaved ? Colors.red : null,
      ),
    );
  }
  1. 重啟App。就將看到列表中每行右側添加了一個心形圖示。
    新增心形圖示

  2. 為每行新增可點選功能。即若被點選的item對應的單詞對已經被收藏了,那麼就會被取消收藏,反之就新增到收藏。當一個tile被點選,函式呼叫setState()通知framework狀態發生改變。
    新增的程式碼如下,在_buildRow()方法中進行新增

  Widget _buildRow(WordPair pair) {
    final alreadySaved = _saved.contains(pair);
    return new ListTile(
      title: new Text(
        pair.asPascalCase,
        style: _biggerFont,
      ),
      trailing: new Icon(
        alreadySaved ? Icons.favorite : Icons.favorite_border,
        color: alreadySaved ? Colors.red : null,
      ),
      onTap: () {
        setState(() {
          if (alreadySaved) {
            _saved.remove(pair);
          } else {
            _saved.add(pair);
          }
        });
      },
    );
  }
Tips:在Flutter的響應框架中,呼叫setState()方法會出發呼叫State類的bulid()方法,這就導致了更新UI操作。

重執行App。你應該可以通過點選來新增或者取消收藏。注意的一點是,在點選的時候可以看到一個放射性的點選效果,這是Material風格所致。如果有Android開發經驗的程式設計師就會知道。
可點選狀態收藏狀態改變

Step 6:導向新的一屏

在這步中,你將新增一個新螢幕(在Flutter叫做route)來展示你的收藏。你將學習如何在home route和新route之間進行互動。

在Flutter中,導航器(Navigator)管理著所有app route的一個棧。向棧內push一個route就表示這將展示新的一屏。pop出棧表示向前顯示一屏。

  1. 在RandomWordsState類的build方法中,在AppBar上新增一個列表圖示。當用戶點選列表icon,包含收藏資料的新的route就會被推送的棧中,並且展示新的一屏。
Tips:某些元件屬性只包含一個子元件(child),而某些屬性(像action)擁有一組子元件(children),這種方式通過方括號表示([])。

在方法中新增icon和對應的action:

....
class RandomWordsState extends State<RandomWords> {
  final _suggestions = <WordPair>[];

  final _biggerFont = const TextStyle(fontSize: 18.0);

  final _saved = new Set<WordPair>();

  @override
  Widget build(BuildContext context) {
    return new Scaffold (
      appBar: new AppBar(
        title: new Text('Startup Name Generator'),
        actions: <Widget>[
          new IconButton(icon: new Icon(Icons.list), onPressed: _pushSaved),
        ],
      ),
      body: _buildSuggestions(),
    );
  }
  ....
}
  1. 上邊的程式碼中同時定義了press回到,因此同時需要定義方法
class RandomWordsState extends State<RandomWords> {
  ...
  void _pushSaved() {
  }
}
  ...

此處方法中並未新增任何程式碼。

  1. 在使用者點選appbar上的列表icon。系統構建一個route並且push到Navigator的棧中。這種操作改變了螢幕的顯示,顯示了新的route。
    這個新頁面的內容在MaterialPageRoute的builder的匿名函式中構建。

新增呼叫Navigator.push,將route推送的Navigator棧中。

...
class  RandomWordsState  extends  State<RandomWords>  {
  ...
  void  _pushSaved()  {
    Navigator.of(context).push(
    );
  }  
}
...
  1. 新增MaterialPageRoute和對應的builder。現在,可以在push方法中新增對應的程式碼,展示收藏的單詞對列表。使用toList()方法轉換將最終的資料賦值給divided 變數,使其擁有最終資料。
void _pushSaved() {  
  Navigator.of(context).push(new MaterialPageRoute(  
    builder: (context) {  
      final tiles = _saved.map(  
            (pair) {     
          return new ListTile(  
            title: new Text(  
              pair.asPascalCase,  
              style: _biggerFont,  
            ),  
          );  
         },  
      );  
      final divided = ListTile  
            .divideTiles(  
              context: context,  
              tiles: tiles,  
             )  
            .toList();  
      },  
    ),);  
}
  1. builder屬性返回一個Scaffold(包含了新route的appbar)元件命名“Saved Suggestions”。新的route由ListTile元件組成的列表組成,ListTiles之間有分隔符分割。

這樣整體的程式碼如下

void _pushSaved() {
  Navigator.of(context).push(
    new MaterialPageRoute(
      builder: (context) {
        final tiles = _saved.map(
          (pair) {
            return new ListTile(
              title: new Text(
                pair.asPascalCase,
                style: _biggerFont,
              ),
            );
          },
        );
        final divided = ListTile
          .divideTiles(
            context: context,
            tiles: tiles,
          )
          .toList();

        return new Scaffold(
          appBar: new AppBar(
            title: new Text('Saved Suggestions'),
          ),
          body: new ListView(children: divided),
        );
      },
    ),
  );
}
  1. 重執行App。收藏幾個單詞對,然後點選appbar上的列表icon,將會出現新的一屏來展示收藏的單詞對。注意新出現的一頁上預設會又給返回按鈕,這個Navigator預設新增的。這樣你不用特意為返回另外寫程式來執行Navigator.pop來返回。點選返回按鈕就可以返回到之前的頁面。
    整體程式碼如下
import 'package:flutter/material.dart';
import 'package:english_words/english_words.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Welcome to Flutter',
      home: new RandomWords(),
    );
  }
}

class RandomWords extends StatefulWidget {
  @override
  createState() => new RandomWordsState();
}

class RandomWordsState extends State<RandomWords> {
  final _suggestions = <WordPair>[];

  final _biggerFont = const TextStyle(fontSize: 18.0);

  final _saved = new Set<WordPair>();

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: new Text('Startup Name Generator'),
        actions: <Widget>[
          new IconButton(icon: new Icon(Icons.list), onPressed: _pushSaved),
        ],
      ),
      body: _buildSuggestions(),
    );
  }

  void _pushSaved() {
    Navigator.of(context).push(
      new MaterialPageRoute(
        builder: (context) {
          final tiles = _saved.map(
            (pair) {
              return new ListTile(
                title: new Text(
                  pair.asPascalCase,
                  style: _biggerFont,
                ),
              );
            },
          );
          final divided = ListTile
              .divideTiles(
                context: context,
                tiles: tiles,
              )
              .toList();

          return new Scaffold(
            appBar: new AppBar(
              title: new Text('Saved Suggestions'),
            ),
            body: new ListView(children: divided),
          );
        },
      ),
    );
  }

  Widget _buildSuggestions() {
    return new ListView.builder(
        padding: const EdgeInsets.all(16.0),
        // The itemBuilder callback is called once per suggested word pairing,
        // and places each suggestion into a ListTile row.
        // For even rows, the function adds a ListTile row for the word pairing.
        // For odd rows, the function adds a Divider widget to visually
        // separate the entries. Note that the divider may be difficult
        // to see on smaller devices.
        itemBuilder: (context, i) {
          // Add a one-pixel-high divider widget before each row in theListView.
          if (i.isOdd) return new Divider();

          // The syntax "i ~/ 2" divides i by 2 and returns an integer result.
          // For example: 1, 2, 3, 4, 5 becomes 0, 1, 1, 2, 2.
          // This calculates the actual number of word pairings in the ListView,
          // minus the divider widgets.
          final index = i ~/ 2;
          // If you've reached the end of the available word pairings...
          if (index >= _suggestions.length) {
            // ...then generate 10 more and add them to the suggestions list.
            _suggestions.addAll(generateWordPairs().take(10));
          }
          return _buildRow(_suggestions[index]);
        });
  }

  Widget _buildRow(WordPair pair) {
    final alreadySaved = _saved.contains(pair);
    return new ListTile(
      title: new Text(
        pair.asPascalCase,
        style: _biggerFont,
      ),
      trailing: new Icon(
        alreadySaved ? Icons.favorite : Icons.favorite_border,
        color: alreadySaved ? Colors.red : null,
      ),
      onTap: () {
        setState(() {
          if (alreadySaved) {
            _saved.remove(pair);
          } else {
            _saved.add(pair);
          }
        });
      },
    );
  }
}

新route展示

Step 7:使用主題修改UI

這是最後一步,你將使用theme。主題(theme)主要控制app看起來外表如何。可以使用預設的主題,從文章開始到目前使用的一直是預設的主題。主題不依賴與物理裝置或者模擬器。你也可以自定義自己的主題來突顯你自己的品牌。

  1. 通過ThemeData類你可以很簡單的修改app的主題。
    像下邊一樣修改下程式碼,就可以改變頁面的主標題主題
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Startup Name Generator',
      theme: new ThemeData(
        primaryColor: Colors.white,
      ),
      home: new RandomWords(),
    );
  }
}
  1. 重執行App看看效果。需要注意的是整個的頁面背景是白色的,甚至appbar也是白色的。
    主題

你可以通過修改引用來改變主題樣式,自己試試吧。

好了,這章的內容就是這些了。