Contents

給 Xamarin.Forms 開發者的 Flutter 指南

Contents

本文件旨在幫助 Xamarin.Forms 開發者利用已有的知識去建構 Flutter 移動應用。如果你懂得 Xamarin.Forms 框架的基本原理,那麼你就可以將本文件當作你開始 Flutter 開發的不錯的起點。

你的 Android 和 iOS 知識以及技能組合在建構 Flutter 時都是有價值的,因為 Flutter 依賴的原生系統配置都與你配置 Xamarin.Forms 原生專案時一樣。 Flutter 框架在建立適用於多個平台的單一介面時,與 Xamarin.Forms 是類似的。

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

專案設定

app 是如何執行的?

對於 Xamarin.Forms 裡的每個平台,你可以呼叫 LoadApplication 方法,建立一個新應用並執行你的應用。

LoadApplication(new App());

在 Flutter 中,載入 Flutter app 的預設主入口是 main

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

在 Xamarin.Forms 中,你會分配一個 PageApplication 類中的 MainPage 屬性。

public class App : Application
{
    public App()
    {
        MainPage = new ContentPage
        {
            Content = new Label
            {
                Text = "Hello World",
                HorizontalOptions = LayoutOptions.Center,
                VerticalOptions = LayoutOptions.Center
            }
        };
    }
}

在 Flutter 中,「萬物皆 widget」,甚至連應用本身也是。接下來的範例展示了 MyApp,一個簡單的應用 Widget

class MyApp extends StatelessWidget {
  /// This widget is the root of your application.
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text(
        'Hello World!',
        textDirection: TextDirection.ltr,
      ),
    );
  }
}

如何建立一個頁面?

Xamarin.Forms 擁有一些不同型別的頁面,ContentPage 是最為通用的。在 Flutter 中,指定一個應用程式 widget 來控制你的根頁面。你可以使用一個 MaterialApp widget,它支援 Material Design;你也可以使用 CupertinoApp widget,它能用來建立 iOS 風格的應用;或者你也可以使用更底層的 WidgetsApp,可供你隨心所欲地客製。

接下來的程式碼定義了一個有狀態的主頁 widget。在 Flutter 中,所有 widget 都是不可變的,並且包含以下兩種主要的 widget: 有狀態無狀態 widget。無狀態 widget 的範例都是標題、圖示或圖片。

下面的範例使用了 MaterialApp,它透過 home 屬性中控制根頁面。

class MyApp extends StatelessWidget {
  /// This widget is the root of your application.
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Flutter Demo',
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

在這裡真正的首頁是另一個建立了狀態的 Widget (MyHomePage)。

一個 有狀態的 widget,例如下面的 MyHomePage,包含兩個部分。第一部分,是自身不變的 widget,建立一個狀態物件來管理 widget 的狀態。狀態物件在 widget 的整個生命週期中持續存在。

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

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

State 物件實現了有狀態 widget 中的 build() 方法。

當 widget 樹的狀態發生了改變,將會呼叫 setState() 觸發 widget 當中該部分 UI 的建構。確保只在需要時呼叫 setState(),並且在只有部分 widget 樹發生變化時呼叫,否則會造成糟糕的 UI 表現。

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        // Take the value from the MyHomePage object that was created by
        // the App.build method, and use it to set the appbar title.
        title: Text(widget.title),
      ),
      body: Center(
        // Center is a layout widget. It takes a single child and positions it
        // in the middle of the parent.
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

在 Flutter 中的 UI(也就是 widget 樹)是不可變的,意味著它一旦被建構,你就無法再改變它的狀態。你可以修改 State 類中的欄位,並再次呼叫 setState 來重新建構整個 widget 樹。

這樣產生 UI 的方式不同於 Xamarin.Forms,但是卻帶來了更多好處。

檢視

在 Flutter 中頁面(Page)與元素(Element)的相同的是什麼?

ContentPageTabbedPageFlyoutPage 就是你可以在 Xamarin.Forms 應用程式中使用的全部頁面型別。這些頁面會控制 Element 來顯示各種控制項。在 Xamarin.Forms 中,Entry 或者 Button 就是一個 Element 的範例。

在 Flutter 中,幾乎所有東西都是 widget,在 Flutter 中被稱作 Route 的一個 Page,也是一個 widget。按鈕、進度條、動畫控制器都是 widget。當建構一個路由時,就會建立一棵 widget 樹。

Flutter 包含 Material 元件 庫。這些都是實現了 Material Design 指南 的 widget。 Material Design 是一個靈活的 針對所有平台 的設計系統,包括 iOS。

不過,Flutter 有足夠靈活和自描述性 (expressive) 去實現任何設計語言。舉個例子,在 iOS 上,你可以用 Cupertino widgets 來產生一個看起來像 蘋果 iOS 設計語言 的介面。

如何更新 widget?

在 Xamarin.Forms 中,每一個 Page 或者 Element 都是一個有狀態的類,擁有一些屬性和方法。透過更新一個屬性來更新你的元素,而且這會傳遞到原生控制項。

在 Flutter 中,widget 是不可變的,你不可以直接地透過修改一個屬性來更新它們,而是應該使用 widget 的狀態。

有狀態 widget 和無狀態 widget 的概念就是出自這裡, StatelessWidget 顧名思義,就是一個沒有狀態資訊的 widget。

當你在描繪使用者介面的一個不依賴除物件中的配置資訊之外任何東西的部分時, StatelessWidget 是有用的。

舉個例子,在 Xamarin.Forms 中,可以輕而易舉地用你的 logo 替換一張 Image。這個 logo 將不會在執行過程中修改,所以在 Flutter 會使用 StatelessWidget

如果你想基於進行了 HTTP 呼叫或者使用者互動後接收到的資料來動態地修改 UI,你需要使用 StatefulWidget 並告訴 Flutter 框架這個 widget 的 State 已經被更新了所以它可以更新那個 widget。

這裡要記下的重要內容是有狀態和無狀態 widget 的核心行為都是一樣的。他們重建每個結構,不同的是 StatefulWidget 擁有一個 State 物件來跨結構儲存狀態資料和恢復它。

如果你有疑惑,那麼就記住這個規則:如果一個 widget 改變了(例如是因為使用者互動),它就是有狀態的。相反,如果一個 widget 對修改作出反應,包含它的父 widget 如果本身沒有對修改作出反應,它就是無狀態的。

接下來的範例展示瞭如何使用一個 StatelessWidget。一個常見的 StatelessWidgetText widget。如果你閱讀了 Text widget 的實現,你會發現它是 StatelessWidget 的子類別。

const 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 {
  /// This widget is the root of your application.
  const SampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Sample App',
      home: 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 呢?什麼東西可以等價於一個 XAML 檔案?

在 Xamarin.Forms 中,大部分開發者用 XAML 寫佈局,有時也會用到 C#。在 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'),
      ),
    ),
  );
}

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

如何從佈局中新增或移除一個元素?

在 Xamarin.Forms 中,你需要在程式碼中移除或新增一個 Element。如果是一個列表,這將會涉及設定 Content 屬性或者呼叫 Add() 或者 Remove() 方法。

在 Flutter 中,因為 widget 都是不可變的,所以沒有直接對等的東西。但是你可以將一個建構 widget 的函式傳遞給父級,並用布林值控制它的子 widget 的建立。

下面的範例展示當用戶點選 FloatingActionButton 時,如何在兩個 widget 之間切換。

class SampleApp extends StatelessWidget {
  /// This widget is the root of your application.
  const SampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Sample App',
      home: 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');
    }
    return CupertinoButton(
      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 動起來?

在 Xamarin.Forms 中,你可以利用 FadeToTranslateTo 等檢視擴充方法(ViewExtensions)來建立簡單的動畫。你需要在一個檢視中使用這些方法來執行需要的動畫。

<Image Source="{Binding MyImage}" x:Name="myImage" />

在後面的程式碼或一個動作中,這個會在 1 秒內淡入這張影象。

myImage.FadeTo(0, 1000);

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 {
  /// This widget is the root of your application.
  const FadeAppTest({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Fade Demo',
      home: 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 final AnimationController controller;
  late final 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(
        onPressed: () {
          controller.forward();
        },
        tooltip: 'Fade',
        child: const Icon(Icons.brush),
      ),
    );
  }
}

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

如何在螢幕上繪圖?

Xamarin.Forms 從來沒有任何內建的方法來直接在螢幕上繪圖。如果他們需要一個自訂影象繪製,大多數使用 SkiaSharp。在 Flutter 中,你可以直接存取 Skia 畫布(Skia Canvas)方便地在螢幕上繪圖。

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

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

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?>[];

  void _onPanUpdate(DragUpdateDetails details) {
    setState(() {
      final RenderBox referenceBox = context.findRenderObject() as RenderBox;
      final Offset localPosition = referenceBox.globalToLocal(
        details.globalPosition,
      );
      _points = List.from(_points)..add(localPosition);
    });
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onPanUpdate: _onPanUpdate,
      onPanEnd: (details) => _points.add(null),
      child: CustomPaint(
        painter: SignaturePainter(_points),
        size: Size.infinite,
      ),
    );
  }
}

class SignaturePainter extends CustomPainter {
  const SignaturePainter(this.points);

  final List<Offset?> points;

  @override
  void paint(Canvas canvas, Size size) {
    final Paint 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 的不透明度在哪裡?

Xamarin.Forms 上,所有 VisualElement 都有不透明度的屬性。在 Flutter 中,你需要將 widget 放到一個 不透明度 widget 來實現。

如何建構一個自訂 widget ?

在 Xamarin.Forms 中,通常派生 VisualElement 或使用一個已有的 VisualElement ,來重寫和實現所需行為的方法。

在 Flutter 中,透過 組合 更小的 Widget 來建立自訂 Widget(而不是繼承它們)。這有點類似於基於 Grid 實現自訂控制項,其中添加了大量 VisualElement,同時使用自訂邏輯進行擴充。

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

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

  final String label;

  @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'),
  );
}

如何在頁面之間導航?

在 Xamarin.Forms 中,NavigationPage 類提供了一個階級式的導航方式,讓使用者可以在頁面之間來回進行跳轉。

Flutter 也有類似的實現,使用 NavigatorRouteRoute 是應用程式裡 Page 的抽象,而 Navigator 是用於管理路由的 widget

一個路由大致上對映到一個 PageNavigator 的工作方式類似於 Xamarin.Forms 的 NavigationPage,在裡面可以 push()pop() 路由,取決於你是否想導航到一個檢視,或者從它返回。

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

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

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

接下來建構一個對映的範例。

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');

Navigator 管理應用程式的路由堆疊。把一個路由推入堆疊可以導航到這個路由,而從堆疊彈出一個路由可以返回到前一個路由。這是透過 awaitpush() 返回的 Future 來完成的。

async/await 與 .NET 的實現非常類似,在 Async UI 中有更詳盡的解釋。

舉個例子,想要讓使用者選擇他們的定位的 定位 (location) 路由,你需要以下步驟:

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

然後,在你的「定位」路由裡,使用者選擇他們的定位後,透過 pop() 路由堆疊來返回結果。

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

如何導航到其它應用程式?

在 Xamarin.Forms 中,需要用指定的 URI 協議並使用 Device.OpenUrl("mailto://") 跳轉到其它應用程式。

在 Flutter 裡想要實現這個功能,需要建立原生平台的整合層,或者使用已經存在的 外掛,例如 url_launcher,可與在 [pub.dev 上的許多其他包一起使用。。

非同步 UI

在 Flutter 中有什麼是跟 Device.BeginOnMainThread() 方法是相等的?

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

Dart 的單執行緒模型並不意味著你需要以會導致 UI 凍結的阻塞操作的方式來執行所有程式碼。與 Xamarin.Forms 一樣,UI 執行緒應該儘可能地保持空閒。你將使用 async/wait 來執行任務,其中必須等待響應。

在 Flutter 中,可以使用 Dart 語言提供的非同步工具,例如 async/await 來執行非同步任務。這跟 C# 很像,並且對於 Xamarin.Forms 開發者來說應該是非常容易使用的。

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

Future<void> loadData() async {
  final Uri dataURL = Uri.parse(
    'https://jsonplaceholder.typicode.com/posts',
  );
  final http.Response response = await http.get(dataURL);
  setState(() {
    data = 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 const MaterialApp(
      title: 'Sample App',
      home: SampleAppPage(),
    );
  }
}

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

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

class _SampleAppPageState extends State<SampleAppPage> {
  List<Map<String, dynamic>> data = <Map<String, dynamic>>[];

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

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

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

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

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

如何將工作轉移到後臺執行緒?

因為 Flutter 是單執行緒的,並且持有事件迴圈,所以你不必擔心執行緒管理或產生後臺執行緒。這一點與 Xamarin.Forms 非常相似。如果你正在做 I/O 密集型的工作,比如磁碟存取或網路呼叫,那麼你可以安全地使用 async/await,這樣就一切就緒了。

另一方面,如果你需要執行消耗 CPU 的計算密集型工作,那麼你可以將其轉移到一個 Isolate 上以避免阻塞事件迴圈,就像你會將任何任務放到主執行緒之外一樣。這類似於透過 Xamarin.Forms 中的 Task.Run() 將內容移動到另一個執行緒。

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

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

這是你通常執行網路或資料庫呼叫的方式,它們都屬於 I/O 操作。

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

Isolate 是獨立執行的執行緒,不會和主執行記憶體堆分享記憶體。這是與 Task.Run() 的區別。這意味著你無法存取主執行緒的變數,或者呼叫 setState() 更新 UI。

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

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

  // The 'echo' isolate sends its SendPort as the first message
  final SendPort sendPort = await receivePort.first as SendPort;
  final List<Map<String, dynamic>> msg = await sendReceive(
    sendPort,
    'https://jsonplaceholder.typicode.com/posts',
  );
  setState(() {
    data = msg;
  });
}

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

  // Notify any other isolates what port this isolate listens to.
  sendPort.send(port.sendPort);
  await for (final dynamic msg in port) {
    final String url = msg[0] as String;
    final SendPort replyTo = msg[1] as SendPort;

    final Uri dataURL = Uri.parse(url);
    final http.Response response = await http.get(dataURL);
    // Lots of JSON to parse
    replyTo.send(jsonDecode(response.body) as List<Map<String, dynamic>>);
  }
}

Future<List<Map<String, dynamic>>> sendReceive(SendPort port, String msg) {
  final ReceivePort response = ReceivePort();
  port.send(<dynamic>[msg, response.sendPort]);
  return response.first as Future<List<Map<String, dynamic>>>;
}

這裡的 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 const MaterialApp(
      title: 'Sample App',
      home: SampleAppPage(),
    );
  }
}

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

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

class _SampleAppPageState extends State<SampleAppPage> {
  List<Map<String, dynamic>> data = <Map<String, dynamic>>[];

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

  bool get showLoadingDialog => data.isEmpty;

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

    // The 'echo' isolate sends its SendPort as the first message
    final SendPort sendPort = await receivePort.first as SendPort;
    final List<Map<String, dynamic>> msg = await sendReceive(
      sendPort,
      'https://jsonplaceholder.typicode.com/posts',
    );
    setState(() {
      data = msg;
    });
  }

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

    // Notify any other isolates what port this isolate listens to.
    sendPort.send(port.sendPort);
    await for (final dynamic msg in port) {
      final String url = msg[0] as String;
      final SendPort replyTo = msg[1] as SendPort;

      final Uri dataURL = Uri.parse(url);
      final http.Response response = await http.get(dataURL);
      // Lots of JSON to parse
      replyTo.send(jsonDecode(response.body) as List<Map<String, dynamic>>);
    }
  }

  Future<List<Map<String, dynamic>>> sendReceive(SendPort port, String msg) {
    final ReceivePort response = ReceivePort();
    port.send(<dynamic>[msg, response.sendPort]);
    return response.first as Future<List<Map<String, dynamic>>>;
  }

  Widget getBody() {
    if (showLoadingDialog) {
      return getProgressDialog();
    }
    return getListView();
  }

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

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

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

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

如何傳送一個網路請求?

在 Xamarin.Forms 中,你可以使用 HttpClient。在 Flutter 中,你可以使用流行的 http package package 輕鬆進行網路呼叫。它抽象了很多通常你會自己實現的網路功能,這使其本身在執行網路請求時簡單易用。

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

dependencies:
  http: ^0.13.4

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

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

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

在 Xamarin.Forms 中常會建立一個載入指示器,可以直接在 XAML 中建立,也可以透過第三方外掛建立,比如 AcrDialogs。

在 Flutter 中,我們使用 ProgressIndicator widget。透過程式碼邏輯使用一個布林標記值控制進度條的渲染。告訴 Flutter 在長時間執行的任務開始之前更新狀態,並在結束後將其隱藏。

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

import 'dart:async';
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 const MaterialApp(
      title: 'Sample App',
      home: SampleAppPage(),
    );
  }
}

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

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

class _SampleAppPageState extends State<SampleAppPage> {
  List<Map<String, dynamic>> data = <Map<String, dynamic>>[];

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

  bool get showLoadingDialog => data.isEmpty;

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

  Widget getBody() {
    if (showLoadingDialog) {
      return getProgressDialog();
    }
    return getListView();
  }

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

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

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

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

工程結構和資原始檔

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

Xamarin.Forms 沒有獨立於平台的儲存影象的方法,你必須放置圖片在 iOS 的 xcasset 資料夾, 或 Android 的 drawable 資料夾中。

Android 和 iOS 將資源 (resources) 和資產 (assets) 視為不同的專案,但是 Flutter 應用只有資產檔案 (assets)。所有原本在 Android 中應該放在 res/drawable-* 資料夾中的資原始檔,在 Flutter 中都放在一個 assets 資料夾中。

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

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

Android density qualifier Flutter pixel ratio
ldpi 0.75x
mdpi 1.0x
hdpi 1.5x
xhdpi 2.0x
xxhdpi 3.0x
xxxhdpi 4.0x

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

如果你要向 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

然後你就可以使用 Image.asset 存取你的圖片了:

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

或者透過 AssetImage widget 直接存取:

@override
Widget build(BuildContext context) {
  return const Image(
    image: AssetImage('images/my_image.png'),
  );
}

更多詳盡的資訊可以在 在 Flutter 中新增資產和影象 中找到。

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

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

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

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

Text(Strings.welcomeMessage);

預設情況下,Flutter 只支援美式英語的本地化字串。如果你需要新增其他語言支援,請引入 flutter_localizations 庫。同時你可能還需要新增 intl package 來使用國際化機制,比如日期和時間的格式化等。

dependencies:
  flutter_localizations:
    sdk: flutter
  intl: '^0.17.0'

若你想使用 flutter_localizations package,指定應用的 localizationsDelegatessupportedLocales

import 'package:flutter_localizations/flutter_localizations.dart';

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      localizationsDelegates: <LocalizationsDelegate<dynamic>>[
        // Add app-specific localization delegate[s] here
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
      ],
      supportedLocales: <Locale>[
        Locale('en', 'US'), // English
        Locale('he', 'IL'), // Hebrew
        // ... other locales the app supports
      ],
    );
  }
}

supportedLocales 指定了應用支援的語言,而這些 delegates 則包含了實際的本地化內容。上面的範例使用了一個 MaterialApp,所以它既使用了處理基礎 widget 本地化的 GlobalWidgetsLocalizations,也使用了處理 Material widget 本地化的 MaterialWidgetsLocalizations。如果你在應用中使用的是 WidgetApp,就不需要後者了。注意,這兩個 delegates 雖然都包含了「預設」值,但是如果你想要實現本地化,就必須在本地提供一個或多個 delegates 的實現副本。

當初始化的時候,WidgetsApp(或 MaterialApp)會根據你提供的 delegates 建立一個 Localizations widget。 Localizations widget 可以隨時從當前上下文中中獲取裝置所用的語言,也可以使用 Window.locale

要使用本地化資源,使用 Localizations.of() 方法可以存取提供代理的特定本地化類別。使用 intl_translation 庫解壓翻譯的副本到 arb 檔案,然後在應用中透過 intl 來參考它們。

關於 Flutter 中國際化和本地化的細節內容,請參看 Flutter 應用裡的國際化,裡面包含有使用和不使用 intl 庫的範例程式碼。

我的專案檔案在哪裡?

Xamarin.Forms 中有一個 csproj 檔案。在 Flutter 中最接近的它的是 pubspec.yaml,其中包含套件相依項和各種專案細節。與 .NET Standard 類似,相同目錄中的檔案被認為是專案的一部分。

Nuget 的等價物是什麼?如何新增依賴項?

在 .NET 生態系統中,原生 Xamarin 專案和 Xamarin.Forms 專案都可以存取 Nuget 和內建的包管理系統。 Flutter 應用程式預設包含一個原生 Android 應用程式、原生 iOS 應用程式和 Flutter 應用程式。

在 Android 中,你可以透過向 Gradle 新增建構指令碼來新增依賴項。而在 iOS 中,你可以透過新增到 Podfile 來新增依賴項。

Flutter 使用 Dart 自己的建構系統和 Pub 包管理器。這些工具將原生 Android 和 iOS 封裝應用程式的建構委託給各自的建構系統。

通常你會在 Flutter 中使用 pubspec.yaml 來宣告外部依賴。你可以透過 pub.dev 來查詢一些優秀的 Flutter 第三方套件。

應用程式生命週期

如何偵聽應用程式的生命週期事件?

在 Xamarin.Forms 中,你會有一個包含 OnStartOnResumeOnSleepApplication。在 Flutter 中,你可以透過在 WidgetsBinding 的監聽器 (observer) 中新增監聽,也可以透過監聽 didChangeAppLifecycleState() 事件,來實現相應的功能。

可監聽的生命週期事件有:

inactive
The application is in an inactive state and is not receiving user input. This event is iOS only.
inactive
應用當前處於不活躍狀態,不接收使用者輸入事件。這個事件只在 iOS 上有效。
paused
The application is not currently visible to the user, is not responding to user input, but is running in the background.
paused
應用當前處於使用者不可見狀態,不接收使用者輸入事件,但仍在後台執行。
resumed
The application is visible and responding to user input.
resumed
應用可見,同時響應使用者輸入。
suspending
The application is suspended momentarily. This event is Android only.
suspending
應用被掛起。這個事件只在 Android 上有效。

有關這些狀態的含義的更多細節,可參考 AppLifecycleStatus 文件

佈局

什麼東西與 StackLayout 等效?

在 Xamarin.Forms 中,可以建立一個帶水平或垂直方向 OrientationStackLayout 。 Flutter 也有類似的方法,不過你將使用的是 RowColumn 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'),
    ],
  );

什麼東西與網格(Grid)等價?

Grid 最接近的對等項是 GridView。這比你在 Xamarin.Forms 中習慣使用的功能強大得多。 GridView 在內容超出其可視空間時自動滾動。

@override
Widget build(BuildContext context) {
  return GridView.count(
    // Create a grid with 2 columns. If you change the scrollDirection to
    // horizontal, this would produce 2 rows.
    crossAxisCount: 2,
    // Generate 100 widgets that display their index in the list.
    children: List<Widget>.generate(
      100,
      (index) {
        return Center(
          child: Text(
            'Item $index',
            style: Theme.of(context).textTheme.headlineMedium,
          ),
        );
      },
    ),
  );
}

你可能在 Xamarin.Forms 中使用 Grid 來實現覆蓋其他 widget 的 widget。在 Flutter 中,你可以使用 Stack widget 來完成這一操作。

這個範例建立了兩個相互重疊的圖示。

@override
Widget build(BuildContext context) {
  return Stack(
    children: const <Widget>[
      Icon(
        Icons.add_box,
        size: 24.0,
        color: Colors.black,
      ),
      Positioned(
        left: 10.0,
        child: Icon(
          Icons.add_circle,
          size: 24.0,
          color: Colors.black,
        ),
      ),
    ],
  );
}

有什麼等同於 ScrollView ?

在 Xamarin.Forms 中,ScrollView 封裝了 VisualElement,如果內容大於裝置螢幕,它就會滾動。

在 Flutter 中,最接近的是 SingleChildScrollView widget。你只需用想要可滾動的內容來填充 widget。

@override
Widget build(BuildContext context) {
  return const SingleChildScrollView(
    child: Text('Long Content'),
  );
}

如果你想在捲軸中包含許多項,即使是不同的Widget型別,也可以使用 ListView。這可能看起來有點大材小用,但在 Flutter 中,它比 Xamarin.Forms 的回到平台特定控制項的 ListView 更為最佳化且靈活。

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

在 Flutter 中如何處理橫向過渡 ?

透過在 AndroidManifest.xml 中設定 configChanges 屬性,可以自動處理橫向轉換。

<activity android:configChanges="orientation|screenSize" />

手勢檢測和觸控事件處理

如何在 Flutter 中向 widget 新增手勢識別器?

在 Xamarin.Forms 中,Element 可能包含一個可供附加 (attach) 的單擊事件。許多元素還包含一個與此事件關聯的 Command。你也可以使用 TapGestureRecognizer。而在 Flutter 中有兩種非常相似的方式:

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

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

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

我如何處理 widget 上的其他手勢?

在 Xamarin.Forms 中你可以在 VisualElement 中新增一個 GestureRecognizer。你通常只能使用 TapGestureRecognizerPinchGestureRecognizerPanGestureRecognizer、, SwipeGestureRecognizerDragGestureRecognizerDropGestureRecognizer,除非你建構了自己的實現。

在 Flutter 中,使用手勢檢測器,你可以監聽到各種各樣的手勢,比如:

  • 單擊

onTapDown
A pointer that might cause a tap has contacted the screen at a particular location.
onTapDown
當指尖在特定位置與螢幕接觸產生點選事件。
onTapUp
A pointer that triggers a tap has stopped contacting the screen at a particular location.
onTapUp
當指尖觸發的點選事件已經停止在特定位置與螢幕接觸。
onTap
A tap has occurred.
onTap
一個點選事件已經發生。
onTapCancel
The pointer that previously triggered the onTapDown won’t cause a tap.
onTapCancel
觸發了 onTapDown 事件之後的指尖沒有導致點選事件。
  • 雙擊

onDoubleTap
The user tapped the screen at the same location twice in quick succession.
onDoubleTap
使用者在同一位置連續快速點選螢幕兩次。
  • 長按

onLongPress
A pointer has remained in contact with the screen at the same location for a long period of time.
onLongPress
指尖長時間保持與螢幕在同一位置的接觸。
  • 垂直拖動

onVerticalDragStart
A pointer has contacted the screen and might begin to move vertically.
onVerticalDragStart
指尖與螢幕接觸後,可能開始垂直移動。
onVerticalDragUpdate
A pointer in contact with the screen has moved further in the vertical direction.
onVerticalDragUpdate
指尖與螢幕接觸並在垂直方向上移動得更遠。
onVerticalDragEnd
A pointer that was previously in contact with the screen and moving vertically is no longer in contact with the screen and was moving at a specific velocity when it stopped contacting the screen.
onVerticalDragEnd
指尖在之前與螢幕接觸並垂直移動,當不再與螢幕接觸時觸發這個事件。當它停止與螢幕接觸時,它會以特定的速度移動。
  • 水平拖動

onHorizontalDragStart
A pointer has contacted the screen and might begin to move horizontally.
onHorizontalDragStart
指尖與螢幕接觸,開始水平移動時觸發。
onHorizontalDragUpdate
A pointer in contact with the screen has moved further in the horizontal direction.
onHorizontalDragUpdate
指尖與螢幕接觸並在水平方向上移動得更遠。
onHorizontalDragEnd
A pointer that was previously in contact with the screen and moving horizontally is no longer in contact with the screen and was moving at a specific velocity when it stopped contacting the screen.
onHorizontalDragEnd
指尖在之前與螢幕接觸並水平移動,當不再與螢幕接觸時會觸發這個事件。當它停止與螢幕接觸時,它正在以特定的速度移動。

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

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

  @override
  State<RotatingFlutterDetector> createState() =>
      _RotatingFlutterDetectorState();
}

class _RotatingFlutterDetectorState extends State<RotatingFlutterDetector>
    with SingleTickerProviderStateMixin {
  late final AnimationController controller;
  late final 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(
      body: Center(
        child: GestureDetector(
          onDoubleTap: () {
            if (controller.isCompleted) {
              controller.reverse();
            } else {
              controller.forward();
            }
          },
          child: RotationTransition(
            turns: curve,
            child: const FlutterLogo(size: 200.0),
          ),
        ),
      ),
    );
  }
}

列表檢視和介面卡

在 Flutter 中,與列表檢視等價的是什麼?

在 Flutter 中與 ListView 等價的是……一個 ListView

在一個 Xamarin.Forms 的 ListView 中,你可以建立一個 ViewCell 或者 DataTemplateSelector,並將其傳遞到 ListView 中,該檢視將用你的 DataTemplateSelector 或者 ViewCell 的返回資料渲染每一行。但是,你通常必須確保開啟單元格回收,否則會遇到記憶體問題和會使滾動速度變慢。

由於 Flutter 中 widget 的不可變特性,你需要向 ListView 傳遞一個 widget 列表, Flutter 會確保滾動快速而流暢。

import 'package:flutter/material.dart';

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

class SampleApp extends StatelessWidget {
  /// This widget is the root of your application.
  const SampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Sample App',
      home: SampleAppPage(),
    );
  }
}

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

  List<Widget> _getListData() {
    return List<Widget>.generate(
      100,
      (index) => Padding(
        padding: const EdgeInsets.all(10.0),
        child: Text('Row $index'),
      ),
    );
  }

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

如何確定列表中被點選的元素?

在 Xamarin.Forms 中,ListView 擁有一個 ItemTapped 方法能找出哪個列表項被單擊了。還有其他一些方法,比如檢查 SelectedItemEventToCommand 的行為何時會發生更改。

而在 Flutter 裡,需要透過 widget 傳遞進來的 touch 響應處理來實現。

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

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

class SampleApp extends StatelessWidget {
  // This widget is the root of your application.
  const SampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Sample App',
      home: SampleAppPage(),
    );
  }
}

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

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

class _SampleAppPageState extends State<SampleAppPage> {
  List<Widget> _getListData() {
    return List<Widget>.generate(
      100,
      (index) => GestureDetector(
        onTap: () {
          developer.log('Row $index tapped');
        },
        child: Padding(
          padding: const EdgeInsets.all(10.0),
          child: Text('Row $index'),
        ),
      ),
    );
  }

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

如何動態更新 ListView ?

在 Xamarin.Forms 中,如果將 ItemsSource 屬性繫結到一個 ObservableCollection,就只需要更新檢視模型中的列表。另一種方法是,你可以給屬性 ItemsSource 分配一個新的 `List 。

在 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 {
  /// This widget is the root of your application.
  const SampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Sample App',
      home: SampleAppPage(),
    );
  }
}

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

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

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

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

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

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

一個推薦的、高效且有效的方法就是使用 ListView.Builder 來建構列表。當你的資料量很大,且需要建構動態列表時,這個方法會非常好用。這基本上相當於 Android 上的 RecyclerView,它會自動回收列表元素:

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

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

class SampleApp extends StatelessWidget {
  /// This widget is the root of your application.
  const SampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Sample App',
      home: 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));
    }
  }

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

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

與建立 ListView 不同,建立 ListView.Builder 需要兩個關鍵引數:初始化列表長度和 item 建構函式。

Item 建構函式類似於 Android 介面卡中的 getView 函式;它接受一個位置,並返回你希望的在該位置呈現的行。

最後且最重要的是,要注意 onTap() 函式不再重新建立列表,而是用 .add 新增給它的。

更多資訊,請存取 codelab: 編寫你的第一個 Flutter 應用

文字處理

如何在文字 Text widget 上設定自訂字型?

在 Xamarin.Forms 中,你必須在每個原生專案中新增自訂字型。然後在你的 Element 中,你會使用 filename#fontnameFontFamily 屬性分配字型名,在 iOS 中使用 fontname

在 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'),
      ),
    ),
  );
}

如何更改 · Widget 的樣式?

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

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

表單輸入

如何檢索使用者輸入?

Xamarin.Forms 的 element 允許你直接查詢 element 來確定它的任何屬性的狀態,或者它被繫結到 ViewModel 中的屬性。

在 Flutter 中檢索資訊是由專門的 widget 處理的,這是跟原來的習慣不同的。如果你有一個 TextFieldTextFormField,你可以提供一個 TextEditingController 來檢索使用者輸入:

import 'package:flutter/material.dart';

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

  @override
  State<MyForm> createState() => _MyFormState();
}

class _MyFormState extends State<MyForm> {
  /// Create a text controller and use it to retrieve the current value
  /// of the TextField.
  final TextEditingController myController = TextEditingController();

  @override
  void dispose() {
    // Clean up the controller when disposing of the widget.
    myController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Retrieve Text Input')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: TextField(controller: myController),
      ),
      floatingActionButton: FloatingActionButton(
        // When the user presses the button, show an alert dialog with the
        // text that the user has typed into our text field.
        onPressed: () {
          showDialog(
            context: context,
            builder: (context) {
              return AlertDialog(
                // Retrieve the text that the user has entered using the
                // TextEditingController.
                content: Text(myController.text),
              );
            },
          );
        },
        tooltip: 'Show me the value!',
        child: const Icon(Icons.text_fields),
      ),
    );
  }
}

你可以在 Flutter 實用課程 中的 獲取文字框的輸入值 找到更多的資訊和完整的程式碼清單。

在入口的佔位符 (Placeholder) 與什麼等價?

在 Xamarin.Forms 中,一些 Element 支援設定 Placeholder 屬性。如:

  <Entry Placeholder="This is a hint">

在 Flutter 中,透過在文字 widget 的裝飾器建構函式引數中新增 InputDecoration 物件,可以輕鬆地為輸入顯示「提示」或佔位符文字。

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

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

使用 Xamarin.Forms 時,如果你希望提供驗證錯誤的視覺化提示,則需要建立新屬性和 VisualElement 來包圍具有驗證錯誤的元素。

在 Flutter 中,我們將 InputDecoration 物件傳遞給文字 widget 的裝飾器建構函式。

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

import 'package:flutter/material.dart';

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

class SampleApp extends StatelessWidget {
  /// This widget is the root of your application.
  const SampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Sample App',
      home: SampleAppPage(),
    );
  }
}

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

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

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

  String? _getErrorText() {
    return _errorText;
  }

  bool isEmail(String em) {
    const 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,}))$';
    final RegExp regExp = RegExp(emailRegexp);
    return regExp.hasMatch(em);
  }

  @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(),
          ),
        ),
      ),
    );
  }
}

Flutter 外掛

與硬體、第三方服務和平台互動

應該如何與平台以及平台原生程式碼互動?

Flutter 不直接在底層平臺上執行程式碼。相反,構成一個 Flutter 應用程式的 Dart 程式碼是在裝置上原生執行的,「繞開」了平台提供的 SDK。這意味著當你在 Dart 中執行網路請求時,它將直接執行在 Dart 上下文中。在編寫原生應用程式時,你通常不會使用 Android 或 iOS 的 API。 Flutter 應用程式仍然作為檢視駐留在原生應用程式的 ViewControllerActivity 中,但你不能直接存取這個或原生框架。

這並不意味著 Flutter 應用程式不能與這些原生 API 或你自己的任何原生程式碼互動。 Flutter 提供了 平台通道 用於與託管 Flutter 檢視的 ViewControllerActivity 通訊和交換資料。平台通道本質上是一個非同步訊息傳遞機制,它將 Dart 程式碼與 ViewControllerActivity 宿主以及它所執行的 iOS 或 Android 框架橋接起來。例如,你可以使用平台通道在原生端執行一個方法,或者從裝置的感測器檢索一些資料。

除了直接使用平台通道外,你還可以使用各種預製 外掛,它們封裝了針對特定目標的原生程式碼和 Dart 程式碼。例如,你可以使用外掛直接從 Flutter 存取裝置相機,而無需編寫自己的整合。外掛可以在 pub.dev、Dart 和 Flutter 的開源 package 儲存庫中找到。有些包可能支援 iOS 上的本地整合,有些支援 Android,還有兩者都兼而有之的。

如果在 Pub 上找不到適合你需求的外掛,你可以 編寫自己的外掛在 Pub 上釋出

如何存取 GPS 感測器?

使用 geolocator 社群外掛.

如何存取照相機?

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

如何透過 Facebook 登入?

To log in with Facebook, use the

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

如何使用 Firebase 特性?

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

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

如何建構自訂的原生整合?

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

簡單地說,Flutter 的外掛架構很像在 Android 中使用事件匯流排:你發出一條訊息,讓接收方處理並向你發回一個結果。在這種情況下,接收方是執行在 Android 或 iOS 上的原生程式碼。

主題(樣式)

如何對應用使用主題?

Flutter 附帶了一個內建的漂亮的 Material Design 實現,它處理了許多你通常會做的樣式和主題需求。

Xamarin.Forms 確實有一個全域的 ResourceDictionary,可以為你的應用程式共享樣式。另外,預覽版目前還支援主題。

在 Flutter 中,你可以在最最上層 widget 中宣告主題。

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

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

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

class SampleApp extends StatelessWidget {
  /// This widget is the root of your application.
  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(),
    );
  }
}

資料庫與本地儲存

如何存取共享首選項或使用者預設值?

Xamarin.Forms 開發者可能會熟悉 Xam.Plugins.Settings 外掛。

在 Flutter 中,使用 shared_preferences 外掛 就可以存取相同的功能。這個外掛封裝了 UserDefaults 和 Android 平臺上的 SharedPreferences

在 Flutter 中如何存取 SQLite

在 Xamarin.Forms 中大多數應用會使用 sqlite-net-pcl 外掛來存取 SQLite 資料庫。

在 Flutter 中,使用 SQFlite 外掛來存取這個功能。

除錯

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

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

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

通知

如何設定通知推送?

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

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