跳到主內容

面向 Jetpack Compose 開發者的 Flutter

學習如何在構建 Flutter 應用時應用 Jetpack Compose 開發經驗。

Flutter 是一個使用 Dart 程式語言構建跨平臺應用程式的框架。

您在 Jetpack Compose 方面的知識和經驗在構建 Flutter 應用時非常有價值。

本文件可用作參考指南,您可以根據需要跳轉檢視最相關的問題。本指南嵌入了示例程式碼。透過懸停或聚焦時顯示的“在 DartPad 中開啟”按鈕,您可以在 DartPad 中檢視並執行部分示例。

概述

#

Flutter 和 Jetpack Compose 的程式碼都描述了 UI 的外觀和工作方式。開發者將這種型別的程式碼稱為宣告式框架

儘管兩者存在主要差異(尤其是在與傳統的 Android 程式碼互動時),但這兩個框架之間有許多共同點。

Composable 函式與 Widget

#

Jetpack Compose 將 UI 元件表示為可組合函式 (composable functions),在本文件中簡稱為 composables。Composables 可以透過使用 Modifier 物件進行更改或修飾。

kotlin
Text("Hello, World!",
   modifier: Modifier.padding(10.dp)
)
Text("Hello, World!",
    modifier = Modifier.padding(10.dp))

Flutter 將 UI 元件表示為 widgets

Composables 和 Widgets 在需要更改之前一直保持存在。這些語言將這種特性稱為不可變性。Jetpack Compose 使用由 Modifier 物件支援的可選 modifier 屬性來修改 UI 元件屬性。相比之下,Flutter widgets 則直接透過建構函式引數來配置其屬性。

dart
Padding(                         // <-- This is a Widget
  padding: EdgeInsets.all(10.0), // <-- a parameter to Padding
  child: Text("Hello, World!"),  // <-- This is also a Widget
);

為了構成佈局,Jetpack Compose 和 Flutter 都會將 UI 元件相互巢狀。Jetpack Compose 巢狀 Composables,而 Flutter 巢狀 Widgets

佈局流程

#

Jetpack Compose 和 Flutter 處理佈局的方式非常相似。兩者都透過單次遍歷來佈局 UI,並且父元素會將佈局約束向下傳遞給子元素。具體來說:

  1. 父級遞迴地測量自身及其子級,並將來自父級的任何約束提供給子級。
  2. 子級嘗試使用上述方法確定自身大小,併為其子級提供它們自己的約束以及可能適用的任何來自祖先節點的約束。
  3. 當遇到葉節點(沒有子節點的節點)時,會根據提供的約束確定大小和屬性,並將該元素放置在 UI 中。
  4. 在所有子級完成測量和放置後,根節點可以確定其測量值、大小和位置。

在 Jetpack Compose 和 Flutter 中,父元件都可以覆蓋或限制子元件的期望大小。Widget 不能隨意設定大小。它也通常無法知曉或決定其在螢幕上的位置,因為該決定由其父級做出。

要強制子 widget 以特定大小呈現,父級必須設定緊湊約束 (tight constraints)。當約束的最小尺寸值等於其最大尺寸值時,該約束即變為緊湊。

要了解 Flutter 中的約束是如何工作的,請訪問理解約束

設計系統

#

由於 Flutter 面向多個平臺,您的應用不需要遵循任何特定的設計系統。雖然本指南以 Material widgets 為特色,但您的 Flutter 應用可以使用許多不同的設計系統。

  • 自定義 Material widgets
  • 社群構建的 widgets
  • 您自己的自定義 widgets

如果您正在尋找一個採用自定義設計系統的優秀參考應用,請檢視 Wonderous

UI 基礎

#

本節涵蓋了 Flutter UI 開發的基礎知識,以及它與 Jetpack Compose 的比較。這包括如何開始開發您的應用、顯示靜態文字、建立按鈕、響應點選事件、顯示列表、網格等。

入門

#

對於 Compose 應用,您的主要入口點將是 Activity 或其子類,通常是 ComponentActivity

kotlin
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            SampleTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Greeting(
                        name = "Android",
                        modifier = Modifier.padding(innerPadding)
                    )
                }
            }
        }
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "Hello $name!",
        modifier = modifier
    )
}

要啟動您的 Flutter 應用,請將應用的例項傳遞給 runApp 函式。

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

App 是一個 widget。它的 build 方法描述了它所代表的使用者介面部分。通常以 WidgetApp 類(例如 MaterialApp)作為應用的起點。

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: HomePage(),
    );
  }
}

HomePage 中使用的 widget 可能以 Scaffold 類開始。Scaffold 為應用實現了基本的佈局結構。

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

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Text(
          'Hello, World!',
        ),
      ),
    );
  }
}

請注意 Flutter 如何使用 Center widget。

Compose 從其 Android View 祖先那裡繼承了許多預設設定。除非另有說明,否則大多陣列件會根據內容“包裹”其大小,這意味著它們在渲染時僅佔用所需的空間。但在 Flutter 中情況並非總是如此。

要使文字居中,請將其包裹在 Center widget 中。要了解有關不同 widget 及其預設行為的資訊,請檢視元件目錄 (Widget catalog)

新增按鈕

#

Compose 中,您可以使用 Button composable 或其變體來建立按鈕。使用 Material 主題時,ButtonFilledTonalButton 的別名。

kotlin
Button(onClick = {}) {
    Text("Do something")
}

要在 Flutter 中實現相同的結果,請使用 FilledButton 類。

dart
FilledButton(
  onPressed: () {
    // This closure is called when your button is tapped.
  },
  const Text('Do something'),
),

Flutter 為您提供了各種具有預定義樣式的按鈕。

水平或垂直排列元件

#

Jetpack Compose 和 Flutter 對水平和垂直項集合的處理方式類似。

以下 Compose 程式碼片段在 RowColumn 容器中添加了一個地球影像和文字,並使項居中。

kotlin
Row(horizontalArrangement = Arrangement.Center) {
   Image(Icons.Default.Public, contentDescription = "")
   Text("Hello, world!")
}

Column(verticalArrangement = Arrangement.Center) {
   Image(Icons.Default.Public, contentDescription = "")
   Text("Hello, world!")
}

Flutter 也使用 RowColumn,但在指定子 widget 和對齊方式方面存在一些細微差別。以下程式碼等效於上述 Compose 示例。

dart
Row(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    Icon(Icons.public),
    Text('Hello, world!'),
  ],
),

Column(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    Icon(MaterialIcons.globe),
    Text('Hello, world!'),
  ],
)

RowColumnchildren 引數中需要一個 List<Widget>mainAxisAlignment 屬性告訴 Flutter 如何在有額外空間時放置子項。MainAxisAlignment.center 將子項放置在主軸中心。對於 Row,主軸是水平軸;相反,對於 Column,主軸是垂直軸。

::: note 雖然 Flutter 的 RowColumn 使用 MainAxisAlignmentCrossAxisAlignment 來控制項的放置方式,但 Jetpack Compose 中控制放置的屬性包括以下垂直和水平屬性中的各一個:verticalArrangementverticalAlignmenthorizontalAlignmenthorizontalArrangement。確定哪一個是 MainAxis 的訣竅是尋找以 arrangement 結尾的屬性。CrossAxis 將是名稱中以 alignment 結尾的屬性。 ::

顯示列表檢視

#

Compose 中,您可以根據需要顯示的列表大小,通過幾種方式建立列表。對於可以一次性全部顯示的少量項,可以在 ColumnRow 內迭代集合。

對於項數很多的列表,LazyList 具有更好的效能。它僅佈局可見的元件,而不是全部元件。

kotlin
data class Person(val name: String)

val people = arrayOf(
   Person(name = "Person 1"),
   Person(name = "Person 2"),
   Person(name = "Person 3")
)

@Composable
fun ListDemo(people: List<Person>) {
   Column {
      people.forEach {
         Text(it.name)
      }
   }
}

@Composable
fun ListDemo2(people: List<Person>) {
   LazyColumn {
      items(people) { person ->
         Text(person.name)
      }
   }
}

要在 Flutter 中延遲構建列表,....

dart
class Person {
  String name;
  Person(this.name);
}

var items = [
  Person('Person 1'),
  Person('Person 2'),
  Person('Person 3'),
];

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView.builder(
        itemCount: items.length,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text(items[index].name),
          );
        },
      ),
    );
  }
}

Flutter 對列表有一些約定。

  • ListView widget 有一個構建器方法。其工作方式類似於 Compose LazyList 中的 item 閉包。

  • ListViewitemCount 引數設定 ListView 顯示多少項。

  • itemBuilder 具有一個索引引數,該索引將介於 0 到 itemCount 減 1 之間。

前面的示例為每個項返回了一個 ListTile widget。ListTile widget 包含諸如 heightfont-size 之類的屬性。這些屬性有助於構建列表。但是,Flutter 允許您返回幾乎任何代表資料的 widget。

顯示網格

#

Compose 中構建網格類似於 LazyList(LazyColumnLazyRow)。您可以使用相同的 items 閉包。每種網格型別都有屬性來指定如何排列項、是否使用自適應或固定佈局等。

kotlin
val widgets = arrayOf(
        "Row 1",
        Icons.Filled.ArrowDownward,
        Icons.Filled.ArrowUpward,
        "Row 2",
        Icons.Filled.ArrowDownward,
        Icons.Filled.ArrowUpward
    )

    LazyVerticalGrid (
        columns = GridCells.Fixed(3),
        contentPadding = PaddingValues(8.dp)
    ) {
        items(widgets) { i ->
            if (i is String) {
                Text(i)
            } else {
                Image(i as ImageVector, "")
            }
        }
    }

要在 Flutter 中顯示網格,請使用 GridView widget。該 widget 具有多種建構函式。每個建構函式的目標相似,但使用不同的輸入引數。以下示例使用 .builder() 初始化程式。

dart
const widgets = [
  Text('Row 1'),
  Icon(Icons.arrow_downward),
  Icon(Icons.arrow_upward),
  Text('Row 2'),
  Icon(Icons.arrow_downward),
  Icon(Icons.arrow_upward),
];

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: GridView.builder(
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3,
          mainAxisExtent: 40,
        ),
        itemCount: widgets.length,
        itemBuilder: (context, index) => widgets[index],
      ),
    );
  }
}

SliverGridDelegateWithFixedCrossAxisCount 委託決定了網格用於佈局其元件的各種引數。這包括決定每行顯示項數的 crossAxisCount

Jetpack Compose 的 LazyHorizontalGridLazyVerticalGrid 與 Flutter 的 GridView 在某種程度上是相似的。GridView 使用委託來決定網格應如何佈局其元件。LazyHorizontalGrid / LazyVerticalGrid 上的 rowscolumns 和其他關聯屬性實現了相同的目的。

建立滾動檢視

#

Jetpack Compose 中的 LazyColumnLazyRow 內建了對滾動的支援。

要建立滾動檢視,Flutter 使用 SingleChildScrollView。在以下示例中,函式 mockPerson 模擬了 Person 類的例項,以建立自定義的 PersonView widget。

dart
SingleChildScrollView(
  child: Column(
    children: mockPersons
        .map(
          (person) => PersonView(
            person: person,
          ),
        )
        .toList(),
  ),
),

響應式和自適應設計

#

Compose 中的自適應設計是一個複雜的話題,有許多可行的解決方案:

  • 使用自定義佈局
  • 僅使用 WindowSizeClass
  • 使用 BoxWithConstraints 根據可用空間控制顯示內容
  • 使用 Material 3 自適應庫,該庫結合使用 WindowSizeClass 和用於常見佈局的專用 composable 佈局

因此,建議您直接檢視 Flutter 的選項,看看什麼符合您的要求,而不是試圖尋找一一對應的轉換。

要在 Flutter 中建立相對檢視,可以使用以下兩種選項之一:

  • LayoutBuilder 類中獲取 BoxConstraints 物件。
  • 在您的構建函式中使用 MediaQuery.of() 來獲取當前應用的大小和方向。

要了解更多資訊,請檢視建立響應式和自適應應用

狀態管理

#

Compose 使用 remember API 和 MutableState 介面的子類來儲存狀態。

kotlin
Scaffold(
   content = { padding ->
      var _counter = remember {  mutableIntStateOf(0) }
      Column(horizontalAlignment = Alignment.CenterHorizontally,
         verticalArrangement = Arrangement.Center,
         modifier = Modifier.fillMaxSize().padding(padding)) {
            Text(_counter.value.toString())
            Spacer(modifier = Modifier.height(16.dp))
            FilledIconButton (onClick = { -> _counter.intValue += 1 }) {
               Text("+")
            }
      }
   }
)

Flutter 使用 StatefulWidget 管理本地狀態。透過以下兩個類實現有狀態的 widget:

  • StatefulWidget 的子類
  • State 的子類

State 物件儲存 widget 的狀態。要更改 widget 的狀態,請從 State 子類呼叫 setState(),通知框架重新繪製該 widget。

以下示例顯示了計數器應用的一部分。

dart
class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('$_counter'),
            TextButton(
              onPressed: () => setState(() {
                _counter++;
              }),
              child: const Text('+'),
            ),
          ],
        ),
      ),
    );
  }
}

要了解更多管理狀態的方法,請檢視狀態管理

在螢幕上繪製

#

Compose 中,您使用 Canvas composable 在螢幕上繪製形狀、影像和文字。

Flutter 擁有一個基於 Canvas 類的 API,並提供了兩個幫助您繪製的類:

  1. CustomPaint,需要一個 painter

    dart
    CustomPaint(
      painter: SignaturePainter(_points),
      size: Size.infinite,
    ),
    
  2. CustomPainter,實現了您繪製到畫布的演算法。

    dart
    class SignaturePainter extends CustomPainter {
      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;
        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;
    }
    

主題、樣式和媒體

#

您可以輕鬆地為 Flutter 應用設定樣式。樣式設定包括在淺色和深色主題之間切換、更改文字和 UI 元件的設計等。本節介紹如何為您的應用設定樣式。

使用深色模式

#

Compose 中,您可以透過將元件包裹在 Theme composable 中,在任意級別控制淺色和深色模式。

Flutter 中,您可以在應用級別控制淺色和深色模式。要控制亮度模式,請使用 App 類的 theme 屬性。

dart
const MaterialApp(
  theme: ThemeData(
    brightness: Brightness.dark,
  ),
  home: HomePage(),
);

文字樣式

#

Compose 中,您可以使用 Text 上的屬性來設定一兩個屬性,或者構造一個 TextStyle 物件來同時設定多個屬性。

kotlin
Text("Hello, world!", color = Color.Green,
        fontWeight = FontWeight.Bold, fontSize = 30.sp)
kotlin
Text("Hello, world!",
   style = TextStyle(
      color = Color.Green,
      fontSize = 30.sp,
      fontWeight = FontWeight.Bold
   ),
)

要在 Flutter 中設定文字樣式,請將 TextStyle widget 新增為 Text widget 的 style 引數的值。

dart
Text(
  'Hello, world!',
  style: TextStyle(
    fontSize: 30,
    fontWeight: FontWeight.bold,
    color: Colors.blue,
  ),
),

設定按鈕樣式

#

Compose 中,您可以使用 colors 屬性修改按鈕的顏色。如果未修改,它們將使用當前主題的預設值。

kotlin
Button(onClick = {},
   colors = ButtonDefaults.buttonColors().copy(
      containerColor = Color.Yellow, contentColor = Color.Blue,
       )) {
    Text("Do something", fontSize = 30.sp, fontWeight = FontWeight.Bold)
}

要為 Flutter 中的按鈕 widget 設定樣式,您同樣可以設定其子級的樣式,或者直接修改按鈕本身的屬性。

dart
FilledButton(
  onPressed: (){},
  style: FilledButton.styleFrom(backgroundColor: Colors.amberAccent),
  child: const Text(
    'Do something',
    style: TextStyle(
      color: Colors.blue,
      fontSize: 30,
      fontWeight: FontWeight.bold,
    )
  )
)

在 Flutter 中繫結資源

#

通常需要繫結資源以供應用程式使用。它們可以是動畫、向量圖形、影像、字型或其他常規檔案。

與原生 Android 應用(期望在 /res/<qualifier>/ 下有特定的目錄結構,其中限定符可能指示檔案型別、特定方向或 Android 版本)不同,Flutter 不需要特定的位置,只要引用的檔案列在 pubspec.yaml 檔案中即可。下面是 pubspec.yaml 的摘錄,其中引用了多個影像和一個字型檔案。

yaml
flutter:
  assets:
    - assets/my_icon.png
    - assets/background.png
  fonts:
    - family: FiraSans
      fonts:
        - asset: fonts/FiraSans-Regular.ttf

使用字型

#

Compose 中,您有兩種在應用中使用字型的選擇。您可以使用執行時服務從 Google Fonts 獲取它們。或者,它們可以繫結在資原始檔中。

Flutter 也有類似的方法來使用字型,我們將在下面討論這兩種方法。

使用繫結字型

#

以下是用於在 /res/fonts 目錄中使用字型檔案的 Compose 和 Flutter 程式碼,基本等效。

kotlin
// Font files bundled with app
val firaSansFamily = FontFamily(
   Font(R.font.firasans_regular, FontWeight.Normal),
   // ...
)

// Usage
Text(text = "Compose", fontFamily = firaSansFamily, fontWeight = FontWeight.Normal)
dart
Text(
  'Flutter',
  style: TextStyle(
    fontSize: 40,
    fontFamily: 'FiraSans',
  ),
),

使用字型提供程式(Google Fonts)

#

一個不同之處在於使用來自像 Google Fonts 這樣的字型提供程式的字型。在 Compose 中,例項化是內聯完成的,使用的程式碼與引用本地檔案的程式碼大致相同。

在例項化引用字型服務特殊字串的提供程式後,您將使用相同的 FontFamily 宣告。

kotlin
// Font files bundled with app
val provider = GoogleFont.Provider(
    providerAuthority = "com.google.android.gms.fonts",
    providerPackage = "com.google.android.gms",
    certificates = R.array.com_google_android_gms_fonts_certs
)

val firaSansFamily = FontFamily(
    Font(
        googleFont = GoogleFont("FiraSans"),
        fontProvider = provider,
    )
)

// Usage
Text(text = "Compose", fontFamily = firaSansFamily, fontWeight = FontWeight.Light)

對於 Flutter,這是透過使用字型名稱的 google_fonts 外掛提供的。

dart
import 'package:google_fonts/google_fonts.dart';
//...
Text(
  'Flutter',
  style: GoogleFonts.firaSans(),
  // or
  //style: GoogleFonts.getFont('FiraSans')
),

使用影像

#

Compose 中,影像檔案通常放在資源目錄的 drawable 資料夾 /res/drawable 中,並使用 Image composable 顯示這些影像。資源透過使用資源定位器(格式為 R.drawable.<file name>,不帶副檔名)來引用。

Flutter 中,資源位置列在 pubspec.yaml 中,如下面的程式碼片段所示。

yaml
    flutter:
      assets:
        - images/Blueberries.jpg

新增影像後,您可以使用 Image widget 的 .asset() 建構函式顯示它。此建構函式

要檢視完整示例,請參閱 Image 文件。