Contents

給 Android 開發者的 Flutter 指南

Contents

這篇文件旨在幫助 Android 開發者利用既有的 Android 知識來透過 Flutter 開發移動應用。如果你瞭解 Android 框架的基本知識,你就可以使用這篇文件作為 Flutter 開發的快速入門。

你的 Android 知識和技能對於 Flutter 開發是非常有用的,因為 Flutter 依賴於 Android 作業系統的多種功能和配置。 Flutter 是一種全新的建構移動介面的方式,但是它有一套和 Android(以及 iOS)進行非 UI 任務通訊的外掛系統。如果你是一名 Android 專家,你就不必重新學習所有知識才能使用 Flutter。

這篇文件可以用作隨時查閱以及答疑解惑的專題手冊。

檢視 (Views)

檢視 (View) 在 Flutter 中對應什麼概念?

Android 中的 View 是顯示在螢幕上的一切的基礎。按鈕、工具欄、輸入框以及一切內容都是 View。而 Flutter 中 View 的大致對應著 Widget。 Widget 並非完全對應於 Android 中的 View,但是在你熟悉 Flutter 的工作原理的過程中可以把它們看做「宣告和建構 UI 的方式」。

然而,widget 和 View 還是有一些差異。首先,widget 有著不一樣的生命週期:它們是不可變的,一旦需要變化則生命週期終止。任何時候 widget 或它們的狀態變化時, Flutter 框架都會建立一個新的 widget 樹的例項。對比來看,一個 Android View 只會繪製一次,除非呼叫 invalidate 才會重繪。

Flutter 的 widget 很輕量,部分原因在於它們的不可變性。因為它們本身既非檢視,也不會直接繪製任何內容,而是 UI 及其底層建立真正檢視物件的語義的描述。

Flutter 支援 Material Components 庫。它提供實現了 Material Design 設計規範 的 widgets。 Material Design 是一套 為所有平台最佳化 (包括 iOS)的靈活的設計系統。

Flutter 非常靈活、有表達能力,它可以實現任何設計語言。例如,在 iOS 平臺上,你可以使用 Cupertino widgets 建立 Apple 的 iOS 設計語言 風格的介面。

如何更新 widgets?

在 Android 中,你可以直接操作更新 View。然而在 Flutter 中,Widget 是不可變的,無法被直接更新,你需要操作 Widget 的狀態。

這就是有狀態 (Stateful) 和無狀態 (Stateless) Widget 概念的來源。 StatelessWidget 如其字面意思—沒有狀態資訊的 Widget。

StatelessWidget 用於你描述的使用者介面的一部分不依賴於除了物件中的配置資訊以外的任何東西的場景。

例如在 Android 中,這就像顯示一個展示圖示的 ImageView。這個圖示在執行過程中不會改變,所以在 Flutter 中就使用 StatelessWidget

如果你想要根據 HTTP 請求返回的資料或者使用者的互動來動態地更新介面,那麼你就必須使用 StatefulWidget,並告訴 Flutter 框架 Widget 的 狀態 (State) 更新了,以便 Flutter 可以更新這個 Widget。

這裡需要著重注意的是,無狀態和有狀態的 Widget 本質上是行為一致的。它們每一幀都會重建,不同之處在於 StatefulWidget 有一個跨幀儲存和恢復狀態資料的 State 物件。

如果你有疑問,那麼記住這條規則:如果一個 Widget 會變化(例如由於使用者互動),它是有狀態的。然而,如果一個 Widget 響應變化,它的父 Widget 只要本身不響應變化,就依然是無狀態的。

下面的例子展示瞭如何使用 StatelessWidgetText Widget 是一個普通的 StatelessWidget。如果你檢視 Text Widget 的實現,你會發現它繼承自 StatelessWidget

Text(
  'I like Flutter!',
  style: TextStyle(fontWeight: FontWeight.bold),
);

如上所示,這個 Text Widget 沒有相關聯的狀態資訊,它只渲染傳入構造器的資訊,僅此而已。

但是,假如你想要動態地改變 “I Like Flutter”,例如當你點選一個 FloatingActionButton 的時候,該怎麼辦呢?

為了實現這個效果,將 Text Widget 嵌入一個 StatefulWidget 中,並在使用者點選按鈕的時候更新它。

例如:

import 'package:flutter/material.dart';

void main() {
  runApp(const SampleApp());
}

class SampleApp extends StatelessWidget {
  const SampleApp({super.key});
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  const SampleAppPage({super.key});

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  // Default placeholder text.
  String textToShow = 'I Like Flutter';

  void _updateText() {
    setState(() {
      // Update the text.
      textToShow = 'Flutter is Awesome!';
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample App'),
      ),
      body: Center(child: Text(textToShow)),
      floatingActionButton: FloatingActionButton(
        onPressed: _updateText,
        tooltip: 'Update Text',
        child: const Icon(Icons.update),
      ),
    );
  }
}

如何佈局 Widget?我的 XML 佈局檔案在哪裡?

在 Android 中,你透過 XML 檔案定義佈局,但是在 Flutter 中,你要透過一個 widget 樹來定義佈局的。

以下範例展示瞭如何顯示一個帶有間距的簡單 widget:

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('Sample App'),
    ),
    body: Center(
      child: ElevatedButton(
        style: ElevatedButton.styleFrom(
          padding: const EdgeInsets.only(left: 20.0, right: 30.0),
        ),
        onPressed: () {},
        child: const Text('Hello'),
      ),
    ),
  );
}

你可以在 widget 目錄 中檢視 Flutter 提供的佈局。

如何在佈局中新增或刪除一個元件?

在 Android 中,你透過呼叫父 View 的 addChild()removeChild() 方法動態地新增或者刪除子 View。在 Flutter 中,由於 Widget 是不可變的,所以沒有 addChild() 的直接對應的方法。不過,你可以給返回一個 Widget 的父 Widget 傳入一個方法,並透過布林標記值控制子 Widget 的建立。

例如,下面就是你可以如何在點選一個 FloatingActionButton 時在兩個 widget 之間切換。

import 'package:flutter/material.dart';

void main() {
  runApp(const SampleApp());
}

class SampleApp extends StatelessWidget {
  const SampleApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  const SampleAppPage({super.key});

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  // Default value for toggle.
  bool toggle = true;
  void _toggle() {
    setState(() {
      toggle = !toggle;
    });
  }

  Widget _getToggleChild() {
    if (toggle) {
      return const Text('Toggle One');
    } else {
      return ElevatedButton(
        onPressed: () {},
        child: const Text('Toggle Two'),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample App'),
      ),
      body: Center(
        child: _getToggleChild(),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _toggle,
        tooltip: 'Update Text',
        child: const Icon(Icons.update),
      ),
    );
  }
}

Widget 如何實現動畫?

在 Android 中,你既可以透過 XML 檔案定義動畫,也可以呼叫 View 物件的 animate() 方法。在 Flutter 裡,則使用動畫函式庫,透過將 Widget 嵌入一個動畫 Widget 的方式實現 Widget 的動畫效果。

Flutter 透過 Animation<double> 的子類別 AnimationController 來暫停、播放、停止以及逆向播放動畫。它需要一個 Ticker 在垂直同步 (vsync) 的時候發出訊號,並且在執行的時候建立一個介於 0 和 1 之間的線性插值。然後你就可以建立一個或多個 Animation,並將它們繫結到控制器上。

例如,你可以使用 CurvedAnimation 來實現一個曲線插值的動畫。在這種情況下,控制器決定了動畫進度, CurvedAnimation 計算用於替換控制器預設線性動畫的曲線值。與 Widget 一樣,Flutter 中的動畫效果也可以組合使用。

在建構 Widget 樹的時候,你需要將 Animation 物件賦值給某個 Widget 的動畫屬性,例如FadeTransition 的不透明度屬性,並讓控制器開始動畫。

下面的例子展示瞭如何實現一個點選 FloatingActionButton 時將一個 Widget 漸變為一個圖示的 FadeTransition

import 'package:flutter/material.dart';

void main() {
  runApp(const FadeAppTest());
}

class FadeAppTest extends StatelessWidget {
  const FadeAppTest({super.key});
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Fade Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyFadeTest(title: 'Fade Demo'),
    );
  }
}

class MyFadeTest extends StatefulWidget {
  const MyFadeTest({super.key, required this.title});

  final String title;
  @override
  State<MyFadeTest> createState() => _MyFadeTest();
}

class _MyFadeTest extends State<MyFadeTest> with TickerProviderStateMixin {
  late AnimationController controller;
  late CurvedAnimation curve;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(milliseconds: 2000),
      vsync: this,
    );
    curve = CurvedAnimation(
      parent: controller,
      curve: Curves.easeIn,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: FadeTransition(
          opacity: curve,
          child: const FlutterLogo(
            size: 100.0,
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        tooltip: 'Fade',
        onPressed: () {
          controller.forward();
        },
        child: const Icon(Icons.brush),
      ),
    );
  }
}

獲取更多內容,請檢視 動畫 Widget動畫指南 以及 動畫概覽

如何使用 Canvas 進行繪製?

在 Android 中,你可以使用 CanvasDrawable 將圖片和形狀繪製到螢幕上。 Flutter 也有一個類似於 Canvas 的 API,因為它基於相同的底層渲染引擎 Skia。因此,在 Flutter 中用畫布 (canvas) 進行繪製對於 Android 開發者來說是一件非常熟悉的工作。

Flutter 有兩個幫助你用畫布 (canvas) 進行繪製的類: CustomPaintCustomPainter,後者可以實現自訂的繪製演算法。

如果想學習在 Flutter 中如何實現一個簽名功能,可以檢視 Collin 在 Custom Paint 上的回答。

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: DemoApp()));

class DemoApp extends StatelessWidget {
  const DemoApp({super.key});

  @override
  Widget build(BuildContext context) => const Scaffold(body: Signature());
}

class Signature extends StatefulWidget {
  const Signature({super.key});

  @override
  SignatureState createState() => SignatureState();
}

class SignatureState extends State<Signature> {
  List<Offset?> _points = <Offset>[];
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onPanUpdate: (details) {
        setState(() {
          RenderBox? referenceBox = context.findRenderObject() as RenderBox;
          Offset localPosition =
              referenceBox.globalToLocal(details.globalPosition);
          _points = List.from(_points)..add(localPosition);
        });
      },
      onPanEnd: (details) => _points.add(null),
      child: CustomPaint(
        painter: SignaturePainter(_points),
        size: Size.infinite,
      ),
    );
  }
}

class SignaturePainter extends CustomPainter {
  SignaturePainter(this.points);
  final List<Offset?> points;
  @override
  void paint(Canvas canvas, Size size) {
    var paint = Paint()
      ..color = Colors.black
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 5.0;
    for (int i = 0; i < points.length - 1; i++) {
      if (points[i] != null && points[i + 1] != null) {
        canvas.drawLine(points[i]!, points[i + 1]!, paint);
      }
    }
  }

  @override
  bool shouldRepaint(SignaturePainter oldDelegate) =>
      oldDelegate.points != points;
}

如何建立自訂 Widget?

在 Android 中,一般透過繼承 View 類,或者使用已有的檢視類,再重載或實現以達到特定效果的方法。

在 Flutter 中,透過 組合 更小的 Widget 來建立自訂 Widget(而不是繼承它們)。這和 Android 中實現一個自訂的 ViewGroup 有些類似,所有的建構 UI 的模組程式碼都已存在,不過由你提供不同的行為—例如,自訂佈局 (layout) 邏輯。

舉例來說,你該如何建立一個在構造器接收標籤引數的 CustomButton?你要組合 ElevatedButton 和一個標籤來建立自訂按鈕,而不是繼承 ElevatedButton

class CustomButton extends StatelessWidget {
  final String label;

  const CustomButton(this.label, {super.key});

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {},
      child: Text(label),
    );
  }
}

然後就像使用其它 Flutter Widget 一樣使用 CustomButton

@override
Widget build(BuildContext context) {
  return const Center(
    child: CustomButton('Hello'),
  );
}

意圖 (Intents)

Intent 在 Flutter 中的對應概念是什麼?

在 Android 中,Intent 主要有兩個使用場景:在 Activity 之前進行導航,以及元件間通訊。 Flutter 卻沒有 intent 這樣的概念,但是你依然可以透過原生整合 (外掛) 來啟動 intent。

Flutter 實際上並沒有 Activity 和 Fragment 的對應概念。在 Flutter 中你需要使用 NavigatorRoute 在同一個 Activity 內的不同介面間進行跳轉。

Route 是應用內螢幕和頁面的抽象,Navigator 是管理路徑 route 的工具。一個 route 物件大致對應於一個 Activity,但是它的含義是不一樣的。 Navigator 可以透過對 route 進行壓棧和彈棧操作實現頁面的跳轉。 Navigator 的工作原理和棧相似,你可以將想要跳轉到的 route 壓棧 (push()),想要返回的時候將 route 出棧 (pop())。

在 Android 中,在應用的 AndroidManifest.xml 檔案中宣告 Activity。

在 Flutter 中,你有多種不同的方式在頁面間導航:

  • 定義一個 route 名字的 Map。(MaterialApp)

  • 直接導航到一個 route。(WidgetApp)

下面的例子建立了一個 Map。

void main() {
  runApp(MaterialApp(
    home: const MyAppHome(), // Becomes the route named '/'.
    routes: <String, WidgetBuilder>{
      '/a': (context) => const MyPage(title: 'page A'),
      '/b': (context) => const MyPage(title: 'page B'),
      '/c': (context) => const MyPage(title: 'page C'),
    },
  ));
}

透過路由名 壓棧 (push) 到 Navigator 中來跳轉到這個 route。

Navigator.of(context).pushNamed('/b');

Intent 的另一種常見的使用場景是呼叫外部的元件,例如相機或檔案選擇器。對於這種情況,你需要建立一個原生平台外掛(或者使用 已有的外掛)。

想要學習如何建立一個原生平台整合,請檢視 開發套件和外掛

在 Flutter 中應該如何處理從外部應用接收到的 intent?

Flutter 可以透過直接和 Android 層通訊並請求分享的資料來處理接收到的 Android intent。

下面的例子中,執行 Flutter 程式碼的原生 Activity 註冊了一個文字分享的 intent 過濾器,這樣其它應用就可以和 Flutter 應用分享文字了。

從以上流程可以得知,我們首先在 Android 原生層面(在我們的 Activity 中)處理分享的文字資料,然後 Flutter 再透過使用 MethodChannel 獲取這個資料。

首先,在 AndroidManifest.xml 中註冊 intent 過濾器:

<activity
  android:name=".MainActivity"
  android:launchMode="singleTop"
  android:theme="@style/LaunchTheme"
  android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection"
  android:hardwareAccelerated="true"
  android:windowSoftInputMode="adjustResize">
  <!-- ... -->
  <intent-filter>
    <action android:name="android.intent.action.SEND" />
    <category android:name="android.intent.category.DEFAULT" />
    <data android:mimeType="text/plain" />
  </intent-filter>
</activity>

接著在 MainActivity 中處理 intent,提取出其它 intent 分享的文字並儲存。當 Flutter 準備好處理的時候,它會使用一個平台通道請求資料,資料便會從原生端傳送過來:

package com.example.shared;

import android.content.Intent;
import android.os.Bundle;

import androidx.annotation.NonNull;

import io.flutter.plugin.common.MethodChannel;
import io.flutter.embedding.android.FlutterActivity;
import io.flutter.embedding.engine.FlutterEngine;
import io.flutter.plugins.GeneratedPluginRegistrant;

public class MainActivity extends FlutterActivity {

  private String sharedText;
  private static final String CHANNEL = "app.channel.shared.data";

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    Intent intent = getIntent();
    String action = intent.getAction();
    String type = intent.getType();

    if (Intent.ACTION_SEND.equals(action) && type != null) {
      if ("text/plain".equals(type)) {
        handleSendText(intent); // Handle text being sent
      }
    }
  }

  @Override
  public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
      GeneratedPluginRegistrant.registerWith(flutterEngine);

      new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), CHANNEL)
              .setMethodCallHandler(
                      (call, result) -> {
                          if (call.method.contentEquals("getSharedText")) {
                              result.success(sharedText);
                              sharedText = null;
                          }
                      }
              );
  }

  void handleSendText(Intent intent) {
    sharedText = intent.getStringExtra(Intent.EXTRA_TEXT);
  }
}

最後,當 widget 渲染的時候,從 Flutter 這端請求資料:

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

void main() {
  runApp(const SampleApp());
}

class SampleApp extends StatelessWidget {
  const SampleApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample Shared App Handler',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  const SampleAppPage({super.key});

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  static const platform = MethodChannel('app.channel.shared.data');
  String dataShared = 'No data';

  @override
  void initState() {
    super.initState();
    getSharedText();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(body: Center(child: Text(dataShared)));
  }

  Future<void> getSharedText() async {
    var sharedData = await platform.invokeMethod('getSharedText');
    if (sharedData != null) {
      setState(() {
        dataShared = sharedData;
      });
    }
  }
}

startActivityForResult() 的對應方法是什麼?

Navigator 類負責 Flutter 的導航,並用來接收被壓棧的 route 的返回值。這是透過在 push() 後返回的 Futureawait 來實現的。

例如,要開啟一個讓使用者選擇位置的路由,你可以這樣做:

Object? coordinates = await Navigator.of(context).pushNamed('/location');

然後,在你的位置的路由內,一旦使用者選擇了位置,你就可以出棧 (pop) 並返回結果:

Navigator.of(context).pop({'lat': 43.821757, 'long': -79.226392});

非同步 UI

runOnUiThread() 在 Flutter 中的對應方法是什麼?

Dart 有一個單執行緒執行的模型,同時也支援 Isolate (在另一個執行緒執行 Dart 程式碼的方法),它是一個事件迴圈和非同步程式設計方式。除非你建立一個 Isolate,否則你的 Dart 程式碼會執行在主 UI 執行緒,並被一個事件迴圈所驅動。Flutter 的事件迴圈對應於 Android 裡的主 Looper——也即繫結到主執行緒上的 Looper

Dart 的單執行緒模型並不意味著你需要以會導致 UI 凍結的阻塞操作的方式來執行所有程式碼。不同於 Android 中需要你時刻保持主執行緒空閒,在 Flutter 中,可以使用 Dart 語言提供的非同步工具,例如 async/await 來執行非同步任務。如果你使用過 C# 或者 Javascript 中的 async/await 正規化,或者 Kotlin 中的協程,你應該對它比較熟悉。

例如,你可以透過使用 async/await 來執行網路程式碼而且不會導致 UI 掛起,同時讓 Dart 來處理背後的繁重細節:

Future<void> loadData() async {
  var dataURL = Uri.parse('https://jsonplaceholder.typicode.com/posts');
  http.Response response = await http.get(dataURL);
  setState(() {
    widgets = jsonDecode(response.body);
  });
}

一旦用 await 修飾的網路操作完成,再呼叫 setState() 更新 UI,這會觸發 widget 子樹的重建並更新資料。

下面的例子展示了非同步載入資料並將之展示在 ListView 內:

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

void main() {
  runApp(const SampleApp());
}

class SampleApp extends StatelessWidget {
  const SampleApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  const SampleAppPage({super.key});

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List widgets = [];

  @override
  void initState() {
    super.initState();
    loadData();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample App'),
      ),
      body: ListView.builder(
        itemCount: widgets.length,
        itemBuilder: (context, position) {
          return getRow(position);
        },
      ),
    );
  }

  Widget getRow(int i) {
    return Padding(
      padding: const EdgeInsets.all(10.0),
      child: Text("Row ${widgets[i]["title"]}"),
    );
  }

  Future<void> loadData() async {
    var dataURL = Uri.parse('https://jsonplaceholder.typicode.com/posts');
    http.Response response = await http.get(dataURL);
    setState(() {
      widgets = jsonDecode(response.body);
    });
  }
}

參考下一節內容獲取更多關於後臺任務以及 Flutter 與 Android 的差異的資訊。

如何將任務轉移到後臺執行緒?

在 Android 中,當你想要存取一個網路資源卻又不想阻塞主執行緒並避免 ANR 的時候,你一般會將任務放到一個後臺執行緒中執行。例如,你可以使用 AsyncTaskLiveDataIntentServiceJobScheduler 任務或者透過 RxJava 的管道用排程器將任務切換到後臺執行緒中。

由於 Flutter 是單執行緒並且執行一個事件迴圈(類似 Node.js),你無須擔心執行緒的管理以及後臺執行緒的建立。如果你在執行和 I/O 繫結的任務,例如儲存存取或者網路請求,那麼你可以安全地使用 async/await,並無後顧之憂。再例如,你需要執行消耗 CPU 的計算密集型工作,那麼你可以將其轉移到一個 Isolate 上以避免阻塞事件迴圈,就像你在 Android 中會將任何任務放到主執行緒之外一樣。

對於和 I/O 繫結的任務,將方法宣告為 async 方法,並在方法內 await 一個長時間執行的任務:

Future<void> loadData() async {
  var dataURL = Uri.parse('https://jsonplaceholder.typicode.com/posts');
  http.Response response = await http.get(dataURL);
  setState(() {
    widgets = jsonDecode(response.body);
  });
}

這就是你一般應該如何執行網路和資料庫操作,它們都屬於 I/O 操作。

在 Android 中,當你繼承 AsyncTask 的時候,你一般會覆寫三個方法: onPreExecute()doInBackground()onPostExecute()。 Flutter 中沒有對應的 API,你只需要 await 一個耗時方法呼叫, Dart 的事件迴圈就會幫你處理剩下的事情。

然而,有時候你可能需要處理大量的資料並掛起你的 UI。在 Flutter 中,可以透過使用 Isolate 來利用多核處理器的優勢執行耗時或計算密集的任務。

Isolate 是獨立執行的執行緒,不會和主執行記憶體堆分享記憶體。這意味著你無法存取主執行緒的變數,或者呼叫 setState() 更新 UI。與 Android 中執行緒的概念不同,isolate 如其名所示,它們無法分享記憶體(例如透過靜態變數的形式)。

下面的例子展示了一個簡單的 isolate 是如何將資料分享給主執行緒來更新 UI 的。

Future<void> loadData() async {
  ReceivePort receivePort = ReceivePort();
  await Isolate.spawn(dataLoader, receivePort.sendPort);

  // The 'echo' isolate sends its SendPort as the first message.
  SendPort sendPort = await receivePort.first;

  List msg = await sendReceive(
    sendPort,
    'https://jsonplaceholder.typicode.com/posts',
  );

  setState(() {
    widgets = msg;
  });
}

// The entry point for the isolate.
static Future<void> dataLoader(SendPort sendPort) async {
  // Open the ReceivePort for incoming messages.
  ReceivePort port = ReceivePort();

  // Notify any other isolates what port this isolate listens to.
  sendPort.send(port.sendPort);

  await for (var msg in port) {
    String data = msg[0];
    SendPort replyTo = msg[1];

    String dataURL = data;
    http.Response response = await http.get(Uri.parse(dataURL));
    // Lots of JSON to parse
    replyTo.send(jsonDecode(response.body));
  }
}

Future sendReceive(SendPort port, msg) {
  ReceivePort response = ReceivePort();
  port.send([msg, response.sendPort]);
  return response.first;
}

這裡的 dataLoader() 就是執行在自己獨立執行執行緒內的 Isolate。在 Isolate 中你可以執行更多的 CPU 密集型操作(例如解析一個大的 JSON 資料),或者執行計算密集型的數學運算,例如加密或訊號處理。

你可以執行下面這個完整的例子:

import 'dart:async';
import 'dart:convert';
import 'dart:isolate';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

void main() {
  runApp(const SampleApp());
}

class SampleApp extends StatelessWidget {
  const SampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  const SampleAppPage({super.key});

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List widgets = [];

  @override
  void initState() {
    super.initState();
    loadData();
  }

  Widget getBody() {
    bool showLoadingDialog = widgets.isEmpty;
    if (showLoadingDialog) {
      return getProgressDialog();
    } else {
      return getListView();
    }
  }

  Widget getProgressDialog() {
    return const Center(child: CircularProgressIndicator());
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample App'),
      ),
      body: getBody(),
    );
  }

  ListView getListView() {
    return ListView.builder(
      itemCount: widgets.length,
      itemBuilder: (context, position) {
        return getRow(position);
      },
    );
  }

  Widget getRow(int i) {
    return Padding(
      padding: const EdgeInsets.all(10.0),
      child: Text("Row ${widgets[i]["title"]}"),
    );
  }

  Future<void> loadData() async {
    ReceivePort receivePort = ReceivePort();
    await Isolate.spawn(dataLoader, receivePort.sendPort);

    // The 'echo' isolate sends its SendPort as the first message.
    SendPort sendPort = await receivePort.first;

    List msg = await sendReceive(
      sendPort,
      'https://jsonplaceholder.typicode.com/posts',
    );

    setState(() {
      widgets = msg;
    });
  }

  // The entry point for the isolate.
  static Future<void> dataLoader(SendPort sendPort) async {
    // Open the ReceivePort for incoming messages.
    ReceivePort port = ReceivePort();

    // Notify any other isolates what port this isolate listens to.
    sendPort.send(port.sendPort);

    await for (var msg in port) {
      String data = msg[0];
      SendPort replyTo = msg[1];

      String dataURL = data;
      http.Response response = await http.get(Uri.parse(dataURL));
      // Lots of JSON to parse
      replyTo.send(jsonDecode(response.body));
    }
  }

  Future sendReceive(SendPort port, msg) {
    ReceivePort response = ReceivePort();
    port.send([msg, response.sendPort]);
    return response.first;
  }
}

OkHttp 在 Flutter 中的對應物是什麼?

Flutter 中使用流行的 http package 進行網路請求是很簡單的。

雖然 http 包沒有 OkHttp 中的所有功能,但是它抽象了很多通常你會自己實現的網路功能,這使其本身在執行網路請求時簡單易用。

要使用 http,請在 pubspec.yaml 檔案中新增依賴:

dependencies:
  ...
  http: ^0.11.3+16

如果要發起一個網路請求,在非同步 (async) 方法 http.get() 上呼叫 await 即可:

import 'dart:developer' as developer;
import 'package:http/http.dart' as http;

Future<void> loadData() async {
  var dataURL = Uri.parse('https://jsonplaceholder.typicode.com/posts');
  http.Response response = await http.get(dataURL);
  developer.log(response.body);
}

如何為耗時任務顯示進度?

在 Android 中你通常會在後台執行一個耗時任務的時候顯示一個 ProgressBar 在介面上。

在 Flutter 中,我們使用 ProgressIndicator widget。透過程式碼邏輯使用一個布林標記值控制進度條的渲染。

在下面的例子中,build 方法被拆分成三個不同的方法。如果 showLoadingDialog() 返回 true(當 widgets.length == 0),渲染 ProgressIndicator。否則,在 ListView 裡渲染網路請求返回的資料。

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

void main() {
  runApp(const SampleApp());
}

class SampleApp extends StatelessWidget {
  const SampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  const SampleAppPage({super.key});

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List widgets = [];

  @override
  void initState() {
    super.initState();
    loadData();
  }

  Widget getBody() {
    bool showLoadingDialog = widgets.isEmpty;
    if (showLoadingDialog) {
      return getProgressDialog();
    } else {
      return getListView();
    }
  }

  Widget getProgressDialog() {
    return const Center(child: CircularProgressIndicator());
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample App'),
      ),
      body: getBody(),
    );
  }

  ListView getListView() {
    return ListView.builder(
      itemCount: widgets.length,
      itemBuilder: (context, position) {
        return getRow(position);
      },
    );
  }

  Widget getRow(int i) {
    return Padding(
      padding: const EdgeInsets.all(10.0),
      child: Text("Row ${widgets[i]["title"]}"),
    );
  }

  Future<void> loadData() async {
    var dataURL = Uri.parse('https://jsonplaceholder.typicode.com/posts');
    http.Response response = await http.get(dataURL);
    setState(() {
      widgets = jsonDecode(response.body);
    });
  }
}

工程結構和資原始檔

在哪裡放置解析度相關的圖片檔案?

雖然 Android 區分對待資原始檔 (resources) 和資產檔案 (assets),但是 Flutter 應用只有資產檔案 (assets)。所有原本在 Android 中應該放在 res/drawable-* 資料夾中的資原始檔,在 Flutter 中都放在一個 assets 資料夾中。

Flutter 遵循一個簡單的類似 iOS 的密度相關的格式。檔案可以是一倍 (1x)、兩倍 (2x)、三倍 (3x) 或其它的任意倍數。 Flutter 沒有 dp 單位,但是有邏輯畫素尺寸,基本和裝置無關的畫素尺寸是一樣的。名稱為 devicePixelRatio 的尺寸表示在單一邏輯畫素標準下裝置物理畫素的比例。

與 Android 的密度分類別的對照表如下:

Android 密度修飾符 Flutter 畫素比例
ldpi 0.75x
mdpi 1.0x
hdpi 1.5x
xhdpi 2.0x
xxhdpi 3.0x
xxxhdpi 4.0x

檔案放置於任意資料夾中——Flutter 沒有預先定義好的資料夾結構。你在 pubspec.yaml 檔案中定義檔案(包括位置資訊),Flutter 負責找到它們。

需要注意的是,在 Flutter 1.0 beta 2 之前,在 Flutter 中定義的檔案不能被原生端存取,反之亦然。原生端定義的資產檔案 (assets) 和資原始檔 (resources) 也無法被 Flutter 存取,因為它們是放置於不同的資料夾中的。

至於 Flutter beta 2,檔案是放置於原生端的 asset 資料夾中,所以可以被原生端的 AssetManager 存取:

val flutterAssetStream = assetManager.open("flutter_assets/assets/my_flutter_asset.png")

然而對於 Flutter beta 2,Flutter 依然無法存取原生資原始檔 (resources),也無法存取原生資產檔案 (assets)。

如果你要向 Flutter 專案中新增一個新的叫 my_icon.png 的圖片資源,並且將其放入我們隨便起名的叫做 images 的資料夾中,你需要將基礎圖片(1.0x)放在 images 資料夾中,並將其它倍數的圖片放入以特定倍數作為名稱的子資料夾中:

images/my_icon.png       // Base: 1.0x image
images/2.0x/my_icon.png  // 2.0x image
images/3.0x/my_icon.png  // 3.0x image

接下來,你需要在 pubspec.yaml 檔案中定義這些圖片:

assets:
 - images/my_icon.jpeg

然後你就可以使用 AssetImage 存取你的圖片了:

AssetImage('images/my_icon.jpeg')

或者透過 Image widget 直接存取:

@override
Widget build(BuildContext context) {
  return Image.asset('images/my_image.png');
}

字串儲存在哪裡?如何處理本地化?

Flutter 當下並沒有一個特定的管理字串的資源管理系統。目前來講,最好的辦法是將字串作為靜態域存放在類中,並透過類存取它們。例如:

class Strings {
  static String welcomeMessage = 'Welcome To Flutter';
}

接著在你們的程式碼中,你可以這樣存取你的字串:

Text(Strings.welcomeMessage);

Flutter 在 Android 上提供無障礙的基本支援,但是這個功能當下仍在開發。

我們鼓勵 Flutter 開發者使用 intl 包 進行國際化和本地化。

Gradle 檔案的對應物是什麼?我該如何新增依賴?

在 Android 中,你在 Gradle 建構指令碼中新增依賴。Flutter 使用 Dart 自己的建構系統以及 Pub 包管理器。建構工具會將原生 Android 和 iOS 殼應用的建構代理給對應的建構系統。

雖然在你的 Flutter 專案的 android 資料夾下有 Gradle 檔案,但是它們只用於給對應平台的整合新增原生依賴。一般來說,在 pubspec.yaml 檔案中定義在 Flutter 裡使用的外部依賴。 pub.dev 是查詢 Flutter packages 的好地方。

Activity 和 Fragment

Activity 和 Fragment 在 Flutter 中的對應概念是什麼?

在 Android 中,一個 Activity 代表使用者可以完成的一件獨立任務。一個 Fragment 代表一個行為或者使用者介面的一部分。 Fragment 用於模組化你的程式碼,為大屏組合複雜的使用者介面,並適配應用的介面。在 Flutter 中,這兩個概念都對應於 Widget

如果要學習更多的關於 Activity 和 Fragment 建立介面的內容,請閱讀社群貢獻的 Medium 文章, 給 Android 開發者的 Flutter 指南:如何在 Flutter 中設計一個 Activity 介面

就如在 Intents 部分所提到的,Flutter 中的介面都是以 Widget 表示的,因為 Flutter 中一切皆為 Widget。你使用 Navigator 在表示不同螢幕或頁面,或者僅僅是相同資料的不同狀態和渲染的各個 Route 之間進行導航。

如何監聽 Android Activity 的生命週期事件?

在 Android 中,你可以覆寫 Activity 的生命週期方法來監聽其生命週期,也可以在 Application 上註冊 ActivityLifecycleCallbacks。在 Flutter 中,這兩種方法都沒有,但是你可以透過繫結 WidgetsBinding 觀察者並監聽 didChangeAppLifecycleState() 的變化事件來監聽生命週期。

可以被觀察的生命週期事件有:

  • inactive — 應用處於非活躍狀態並且不接收使用者輸入。

  • detached — 應用依然保留 flutter engine,但是全部宿主 view 均已脫離。

  • paused — 應用當前對使用者不可見,無法響應使用者輸入,並執行在後台。這個事件對應於 Android 中的 onPause()

  • resumed — 應用對使用者可見並且可以響應使用者的輸入。這個事件對應於 Android 中的 onPostResume()

    suspending — 應用暫時被掛起。這個事件對應於 Android 中的 onStop; iOS 上由於沒有對應的事件,因此不會觸發此事件。

想要了解這些狀態含義的更多細節,請檢視 AppLifecycleStatus 文件

你可能已經注意到,只有一小部分的 Activity 生命週期事件是可用的,雖然 FlutterActivity 在內部捕獲了幾乎所有的 Activity 生命週期事件並將它們傳送給 Flutter 引擎,但是它們大部分都向你遮蔽了。 Flutter 為你管理引擎的啟動和停止,在大部分情況下幾乎沒有理由要在 Flutter 一端監聽 Activity 的生命週期。如果你需要透過監聽生命週期來獲取或釋放原生的資源,無論如何都應該在原生端做這件事。

下面的例子展示瞭如何監聽容器 Activity 的生命週期狀態:

import 'package:flutter/widgets.dart';

class LifecycleWatcher extends StatefulWidget {
  const LifecycleWatcher({super.key});

  @override
  State<LifecycleWatcher> createState() => _LifecycleWatcherState();
}

class _LifecycleWatcherState extends State<LifecycleWatcher>
    with WidgetsBindingObserver {
  AppLifecycleState? _lastLifecycleState;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    setState(() {
      _lastLifecycleState = state;
    });
  }

  @override
  Widget build(BuildContext context) {
    if (_lastLifecycleState == null) {
      return const Text(
        'This widget has not observed any lifecycle changes.',
        textDirection: TextDirection.ltr,
      );
    }

    return Text(
      'The most recent lifecycle state this widget observed was: $_lastLifecycleState.',
      textDirection: TextDirection.ltr,
    );
  }
}

void main() {
  runApp(const Center(child: LifecycleWatcher()));
}

佈局

LinearLayout 的對應概念是什麼?

在 Android 中,LinearLayout 用於線性佈局 widget 的——水平或者垂直。在 Flutter 中,使用 Row 或者 Column Widget 來實現相同的效果。

如果你注意看的話,會發現下面的兩段程式碼除了 RowColumn widget 以外是一模一樣的。它們的子級是一樣的,而這個特性可以被充分利用來開發包含有相同的子級,但是會隨時間改變的複雜佈局。

@override
Widget build(BuildContext context) {
  return Row(
    mainAxisAlignment: MainAxisAlignment.center,
    children: const <Widget>[
      Text('Row One'),
      Text('Row Two'),
      Text('Row Three'),
      Text('Row Four'),
    ],
  );
}
@override
Widget build(BuildContext context) {
  return Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: const <Widget>[
      Text('Column One'),
      Text('Column Two'),
      Text('Column Three'),
      Text('Column Four'),
    ],
  );
}

如果想學習更多的建構線性佈局的內容,請閱讀社群貢獻的 Medium 文章 給 Android 開發者的 Flutter 指南:如何在 Flutter 中設計線性佈局

RelativeLayout 的對應概念是什麼?

RelativeLayout 透過 Widget 的相互位置對它們進行佈局。在 Flutter 中,有幾種實現相同效果的方法。

你可以透過組合使用 Column、Row 和 Stack Widget 實現 RelativeLayout 的效果。你還可以在 Widget 構造器內宣告孩子相對父親的佈局規則。

Collin 在 StackOverflow 上的回答是一個在 Flutter 中建構相對佈局的好例子。

ScrollView 的對應概念是什麼?

在 Android 中,使用 ScrollView 佈局 widget—— 如果使用者的裝置螢幕比應用的內容區域小,使用者可以滑動內容。

在 Flutter 中,實現這個功能的最簡單的方法是使用 ListView widget。從 Android 的角度看,這樣做可能是殺雞用牛刀了,但是 Flutter 中 ListView widget 既是一個 ScrollView,也是一個 Android 中的 ListView。

@override
Widget build(BuildContext context) {
  return ListView(
    children: const <Widget>[
      Text('Row One'),
      Text('Row Two'),
      Text('Row Three'),
      Text('Row Four'),
    ],
  );
}

在 Flutter 中如何處理螢幕旋轉?

FlutterView 會處理配置的變化,前提條件是在 AndroidManifest.xml 檔案中聲明瞭:

android:configChanges="orientation|screenSize"

手勢監聽和觸控事件處理

Flutter 中如何為一個 Widget 新增點選監聽器?

在 Android 中,你可以透過呼叫 setOnClickListener 方法在按鈕這樣的 View 上新增點選監聽器。

在 Flutter 中有兩種新增觸控監聽器的方法:

  1. 如果 Widget 支援事件監聽,那麼向它傳入一個方法並在方法中處理事件。例如,RaisedButton 有一個 onPressed 引數:

@override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        developer.log('click');
      },
      child: const Text('Button'),
    );
  }
  1. 如果 Widget 不支援事件監聽,將 Widget 包裝進一個 GestureDetector 中並向 onTap 引數傳入一個方法。

class SampleTapApp extends StatelessWidget {
    const SampleTapApp({super.key});

    @override
    Widget build(BuildContext context) {
      return Scaffold(
        body: Center(
          child: GestureDetector(
            onTap: () {
              developer.log('tap');
            },
            child: const FlutterLogo(
              size: 200.0,
            ),
          ),
        ),
      );
    }
  }

如何處理 Widget 上的其它手勢?

使用 GestureDetector 可以監聽非常多的手勢,例如:

  • Tap

    • onTapDown - 一個可能產生點選事件的指標觸控到螢幕的特定位置。

    • onTapUp - 一個產生了點選事件的指標停止觸控式螢幕幕的特定位置。

    • onTap - 一個已經發生的點選事件。

    • onTapCancel - 之前觸發了 onTapDown 事件的指標不會產生點選事件。

  • Double tap

    • onDoubleTap - 使用者在螢幕同一位置連續快速地點選兩次。

  • Long press

    • onLongPress - 指標在螢幕的同一位置保持了一段較長時間的觸控狀態。

  • Vertical drag

    • onVerticalDragStart - 指標已經觸控式螢幕幕並可能開始垂直移動。

    • onVerticalDragUpdate - 觸控式螢幕幕的指標在垂直方向移動了更多的距離。

    • onVerticalDragEnd - 之前和螢幕接觸並垂直移動的指標不再繼續和螢幕接觸,並且在和螢幕停止接觸的時候以一定的速度移動。

  • Horizontal drag

    • onHorizontalDragStart - 指標已經觸控式螢幕幕並可能開始水平移動。

    • onHorizontalDragUpdate - 觸控式螢幕幕的指標在水平方向移動了更多的距離。

    • onHorizontalDragEnd - 之前和螢幕接觸並水平移動的指標不再繼續和螢幕接觸,並且在和螢幕停止接觸的時候以一定的速度移動。

下面的例子展示了一個實現了雙擊旋轉 Flutter 標誌的 GestureDetector

class SampleApp extends StatefulWidget {
  const SampleApp({super.key});

  @override
  State<SampleApp> createState() => _SampleAppState();
}

class _SampleAppState extends State<SampleApp>
    with SingleTickerProviderStateMixin {
  late AnimationController controller;
  late CurvedAnimation curve;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 2000),
    );
    curve = CurvedAnimation(
      parent: controller,
      curve: Curves.easeIn,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: GestureDetector(
          onDoubleTap: () {
            if (controller.isCompleted) {
              controller.reverse();
            } else {
              controller.forward();
            }
          },
          child: RotationTransition(
            turns: curve,
            child: const FlutterLogo(
              size: 200.0,
            ),
          ),
        ),
      ),
    );
  }
}

列表檢視和介面卡

ListView 在 Flutter 中的對應概念是什麼?

Flutter 中 ListView 的對應概念仍然是…ListView!

使用 Android 的 ListView 時,建立一個 adapter 並將其傳給 ListView, ListView 渲染 adapter 返回的每一行內容。然後,你需要確保回收了每一行檢視,否則,你會遇到各種奇怪的介面和記憶體問題。

因為 Flutter widget 不可變的特點,你需要向 ListView 傳入一組 widget, Flutter 會保證滑動的快速順暢。

import 'package:flutter/material.dart';

void main() {
  runApp(const SampleApp());
}

class SampleApp extends StatelessWidget {
  const SampleApp({super.key});
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  const SampleAppPage({super.key});

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample App'),
      ),
      body: ListView(children: _getListData()),
    );
  }

  List<Widget> _getListData() {
    List<Widget> widgets = [];
    for (int i = 0; i < 100; i++) {
      widgets.add(Padding(
        padding: const EdgeInsets.all(10.0),
        child: Text('Row $i'),
      ));
    }
    return widgets;
  }
}

如何知道點選了哪個列表項?

在 Android 中,ListView 有一個可以幫助你定位哪個列表項被點選了的方法 onItemClickListener。在 Flutter 中,則使用傳入 widget 的觸控監聽。

import 'dart:developer' as developer;

import 'package:flutter/material.dart';

void main() {
  runApp(const SampleApp());
}

class SampleApp extends StatelessWidget {
  const SampleApp({super.key});
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  const SampleAppPage({super.key});

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample App'),
      ),
      body: ListView(children: _getListData()),
    );
  }

  List<Widget> _getListData() {
    List<Widget> widgets = [];
    for (int i = 0; i < 100; i++) {
      widgets.add(
        GestureDetector(
          onTap: () {
            developer.log('row tapped');
          },
          child: Padding(
            padding: const EdgeInsets.all(10.0),
            child: Text('Row $i'),
          ),
        ),
      );
    }
    return widgets;
  }
}

如何動態更新 ListView?

在 Android 中,你需要更新 adapter 並呼叫 notifyDataSetChanged

在 Flutter 中,如果你準備在 setState() 裡更新一組 widget,你很快會發現你的資料並沒有更新到介面上。這是因為當 setState() 被呼叫的時候, Flutter 渲染引擎會檢視 Widget 樹是否有任何更改。當引擎檢查到 ListView,他會執行 == 檢查,並判斷兩個 ListView 是一樣的。沒有任何更改,所以也就不需要更新。

更新 ListView 的一個簡單方法是,在 setState() 裡建立一個新的 List,並將資料從舊列表複製到新列表。雖然這個方法很簡單,就如下面例子所示,但是並不推薦在大資料集的時候使用。

import 'dart:developer' as developer;

import 'package:flutter/material.dart';

void main() {
  runApp(const SampleApp());
}

class SampleApp extends StatelessWidget {
  const SampleApp({super.key});
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  const SampleAppPage({super.key});

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List<Widget> widgets = [];

  @override
  void initState() {
    super.initState();
    for (int i = 0; i < 100; i++) {
      widgets.add(getRow(i));
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample App'),
      ),
      body: ListView(children: widgets),
    );
  }

  Widget getRow(int i) {
    return GestureDetector(
      onTap: () {
        setState(() {
          widgets = List.from(widgets);
          widgets.add(getRow(widgets.length));
          developer.log('row $i');
        });
      },
      child: Padding(
        padding: const EdgeInsets.all(10.0),
        child: Text('Row $i'),
      ),
    );
  }
}

推薦的高效且有效的建立一個列表的方法是使用 ListView.Builder。這個方法非常適用於動態列表或者擁有大量資料的列表。這基本上就是 Android 裡的 RecyclerView,會為你自動回收列表項:

import 'dart:developer' as developer;

import 'package:flutter/material.dart';

void main() {
  runApp(const SampleApp());
}

class SampleApp extends StatelessWidget {
  const SampleApp({super.key});
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  const SampleAppPage({super.key});

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List<Widget> widgets = [];

  @override
  void initState() {
    super.initState();
    for (int i = 0; i < 100; i++) {
      widgets.add(getRow(i));
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample App'),
      ),
      body: ListView.builder(
        itemCount: widgets.length,
        itemBuilder: (context, position) {
          return getRow(position);
        },
      ),
    );
  }

  Widget getRow(int i) {
    return GestureDetector(
      onTap: () {
        setState(() {
          widgets.add(getRow(widgets.length));
          developer.log('row $i');
        });
      },
      child: Padding(
        padding: const EdgeInsets.all(10.0),
        child: Text('Row $i'),
      ),
    );
  }
}

不用建立一個 “ListView”,而是建立接收兩個引數的 ListView.Builder,兩個引數分別是列表的初始長度和一個 ItemBuilder 方法。

ItemBuilder 方法和 Android adapter 裡的 getView 方法類似;它透過位置返回你期望在這個位置渲染的列表項。

最後也是最重要的一條,需要注意 onTap() 方法不再重建列表項,但是會執行 .add 操作。

文字處理

如何為 Text Widget 設定自訂字型?

在 Android SDK 中(從 Android O 開始),你可以建立一個字型資原始檔並將其傳給 TextView 的 FontFamily 引數。

在 Flutter 中,將字型檔案放入一個資料夾,並在 pubspec.yaml 檔案中參考它,和匯入圖片一樣。

fonts:
   - family: MyCustomFont
     fonts:
       - asset: fonts/MyCustomFont.ttf
       - style: italic

然後將字型賦值給你的 Text Widget:

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('Sample App'),
    ),
    body: const Center(
      child: Text(
        'This is a custom font text',
        style: TextStyle(fontFamily: 'MyCustomFont'),
      ),
    ),
  );
}

如何更改 Text Widget 的樣式?

除了字型,你還可以自訂 Text Widget 的其它樣式元素。 Text Widget 的樣式引數接收一個 TextStyle 物件,你可以在這個物件裡自訂很多引數,例如:

  • color
  • decoration
  • decorationColor
  • decorationStyle
  • fontFamily
  • fontSize
  • fontStyle
  • fontWeight
  • hashCode
  • height
  • inherit
  • letterSpacing
  • textBaseline
  • wordSpacing

表單輸入

如果需要更多使用表單的資訊,請檢視 Flutter Cookbook 中的 檢索一個文字欄位的值

Input 的「提示」 (hint) 的對應概念是什麼?

在 Flutter 中,你可以簡單地透過向 Text Widget 構造器的 decoration 引數傳入一個 InputDecoration 物件來為輸入框展示一個「提示」或佔位文字。

Center(
  child: TextField(
    decoration: InputDecoration(hintText: 'This is a hint'),
  ),
)

如何顯示驗證錯誤的資訊?

就像上面實現「提示」功能一樣,像 Text Widget 構造方法的 decoration 引數傳入一個 InputDecoration 物件。

然而,你並不想一開始就顯示錯誤資訊。相反,當用戶輸入了無效的資訊後,更新狀態並傳入一個新的 InputDecoration 物件。

import 'package:flutter/material.dart';

void main() {
  runApp(const SampleApp());
}

class SampleApp extends StatelessWidget {
  const SampleApp({super.key});
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  const SampleAppPage({super.key});

  @override
  State<SampleAppPage> createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  String? _errorText;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample App'),
      ),
      body: Center(
        child: TextField(
          onSubmitted: (text) {
            setState(() {
              if (!isEmail(text)) {
                _errorText = 'Error: This is not an email';
              } else {
                _errorText = null;
              }
            });
          },
          decoration: InputDecoration(
            hintText: 'This is a hint',
            errorText: _getErrorText(),
          ),
        ),
      ),
    );
  }

  String? _getErrorText() {
    return _errorText;
  }

  bool isEmail(String em) {
    String emailRegexp =
        r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|'
        r'(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|'
        r'(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$';

    RegExp regExp = RegExp(emailRegexp);

    return regExp.hasMatch(em);
  }
}

Flutter 外掛

如何使用 GPS 感測器?

使用 geolocator 社群外掛。

如何使用相機?

image_picker 外掛被常用於相機功能的使用。

如何使用 Facebook 登入?

使用 flutter_facebook_login 社群外掛實現 Facebook 登入功能。

如何使用 Firebase 的功能?

官方外掛 提供了 Firebase 的大多數功能。這些外掛都是由 Flutter 團隊維護的官方整合外掛:

你可以在 Pub 網站上查詢一些官方外掛沒有直接支援的功能的第三方 Firebase 外掛。

如何建立自己的自訂原生整合外掛?

如果有 Flutter 官方或社群第三方外掛沒有涵蓋的平台特定的功能,你可以參考 開發套件和外掛 文件建立自己的外掛。

Flutter 的外掛架構,簡而言之,和 Android 中的事件匯流排的使用非常相似:你傳送一個訊息,並讓接受者處理並返回一個結果給你。在這種情況下,接受者是執行在 Android 或 iOS 原生端的程式碼。

如何在 Flutter 應用中使用 NDK?

如果你在現有的 Android 應用中使用 NDK,並且希望你的 Flutter 應用可以利用你的 native 庫,你可以透過建立一個自訂外掛實現。

你的自訂外掛首先和你的 Android 應用通訊,Android 應用會透過 JNI 呼叫 native 方法。一旦有返回值,就可以向 Flutter 傳送回一個訊息並渲染結果。

暫時還不支援從 Flutter 中直接呼叫 native 程式碼。

主題(Themes)

如何對應用使用主題?

Flutter 提供開箱即用的優美的 Material Design 實現,可以滿足你通常需要的各種樣式和主題的需求。不同於 Android 中你在 XML 檔案中定義主題並在 AndroidManifest.xml 中將其賦值給你的應用, Flutter 中是在最上層 Widget 上宣告主題。

為了在應用中利用好 Material 元件,你可以在應用中宣告一個最上層 Widget MaterialApp 作為入口。 MaterialApp 是一個包裝了一系列 Widget 的為你給予便利的 Widget,而這些 Widget 通常是實現 Material Design 的應用所必須的。它基於 WidgetsApp 並添加了 Material 相關的功能。

你也可以使用 WidgetApp 作為應用的 Widget,它會提供一些相同的功能,但是不如 MaterialApp 提供的功能豐富。

如果要自訂任意子元件的顏色或者樣式,給 MaterialApp Widget 傳入一個 ThemeData 物件即可。例如,在下面的程式碼中,主色調設定為藍色,文字選中顏色設定為紅色。

import 'package:flutter/material.dart';

class SampleApp extends StatelessWidget {
  const SampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        textSelectionTheme:
            const TextSelectionThemeData(selectionColor: Colors.red),
      ),
      home: const SampleAppPage(),
    );
  }
}

資料庫和本地儲存

如何使用 Shared Preferences?

在 Android 中,你可以使用 SharedPreferences API 來儲存少量的鍵值對。

在 Flutter 中,使用 Shared_Preferences 外掛 實現此功能。這個外掛同時包裝了 Shared Preferences 和 NSUserDefaults(iOS 平台對應 API)的功能。

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

import 'package:shared_preferences/shared_preferences.dart';

void main() {
  runApp(
    const MaterialApp(
      home: Scaffold(
        body: Center(
          child: ElevatedButton(
            onPressed: _incrementCounter,
            child: Text('Increment Counter'),
          ),
        ),
      ),
    ),
  );
}

Future<void> _incrementCounter() async {
  SharedPreferences prefs = await SharedPreferences.getInstance();
  int counter = (prefs.getInt('counter') ?? 0) + 1;
  await prefs.setInt('counter', counter);
}

在 Flutter 中如何使用 SQLite?

在 Android 中,你會使用 SQLite 來儲存可以透過 SQL 進行查詢的結構化資料。

在 Flutter 中,使用 SQFlite 外掛實現此功能。

除錯

我可以使用什麼工具除錯我的 Flutter 應用?

請使用 開發者工具 除錯你的 Flutter 和 Dart 應用。

開發者工具套件含了效能工具、檢查堆疊、檢視 widget 樹、診斷資訊記錄、除錯、執行程式碼行觀察、除錯記憶體洩漏和記憶體碎片等。有關更多資訊,請參閱 開發者工具 文件。

通知

如何設定推送通知?

在 Android 中,你可以使用 Firebase Cloud Messaging 來為應用設定推送通知。

在 Flutter 中,則使用 Firebase Messaging 外掛實現此功能。想要獲得更多關於使用 Firebase Cloud Messaging API 的資訊,請查閱 firebase_messaging 外掛文件。