跳转至正文

给 Jetpack Compose 开发者的 Flutter 指南

学习在构建 Flutter 应用时运用 Jetpack Compose 开发经验。

Flutter 是使用 Dart 编程语言构建跨平台应用的框架。

你的 Jetpack Compose 知识与经验在构建 Flutter 时非常宝贵。

本文档可跳转查阅,找到最符合你需求的问题。本指南嵌入示例代码;通过悬停或聚焦时出现的「Open in DartPad」按钮,可在 DartPad 中打开并运行部分示例。

概览

#

Flutter 与 Jetpack Compose 代码描述 UI 的外观与行为,开发者称此类代码为 declarative framework(声明式框架)

两者尤其在与传统 Android 代码交互方面存在关键差异,但框架之间也有许多共同点。

Composable 与 Widget

#

Jetpack Compose 将 UI 组件表示为 composable functions(composable 函数),本文档中简称 composables。可通过 Modifier 对象修改或装饰 Composable。

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

Flutter 将 UI 组件表示为 widget

Composable 与 widget 仅在需要变更前存在,这种特性称为 immutability(不可变性)

Jetpack Compose 通过由 Modifier 对象支持的 modifier 属性修改 UI 组件属性; Flutter widget 则通过构造函数参数直接配置属性。

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 嵌套 Composable,Flutter 嵌套 Widget

布局过程

#

Jetpack Compose 与 Flutter 布局方式相似:单次传递布局 UI,父元素向子元素提供布局约束。更具体地说:

  1. 父级递归测量自身与子级,将来自父级的约束传给子级。

  2. 子级尝试按上述方法确定尺寸,并向自己的子级提供约束及可能来自祖先节点的约束。

  3. 遇到叶节点(无子节点)时,根据约束确定尺寸与属性并放置到 UI。

  4. 所有子级确定尺寸并放置后,根节点可确定自身的测量、尺寸与位置。

在 Jetpack Compose 与 Flutter 中,父组件可覆盖或约束子组件期望的尺寸; widget 不能任意尺寸,也 通常 无法知晓或决定屏幕位置,由父组件决定。

要强制子 widget 以特定尺寸渲染,父级须设置 tight constraints(紧约束);当最小尺寸等于最大尺寸时约束为紧约束。

要了解 Flutter 约束机制,请参阅 理解布局约束

设计系统

#

Flutter 面向多平台,应用不必遵循特定设计系统。本指南使用 Material widget,但 Flutter 应用可采用多种设计系统:

  • 自定义 Material widget

  • 社区构建的 widget

  • 你自己的自定义 widget

若要参考采用自定义设计系统的优秀应用,请参阅 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 Views 的若干默认行为:除非另有说明,多数组件按内容「包裹」尺寸,即仅占用渲染所需空间。Flutter 并非总是如此。

要居中文本,请用 Center widget 包裹。要了解不同 widget 及其默认行为,请参阅 Widget 目录

添加按钮

#

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 则为垂直轴。

显示列表视图

#

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 有 builder 方法,类似 Compose LazyList 内的 item 闭包。

  • ListViewitemCount 参数设置显示项数。

  • itemBuilder 的 index 参数介于 0 与 itemCount 减 1 之间。

上一示例为每项返回 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 delegate 决定网格布局组件的多种参数,包括每行项数的 crossAxisCount

Jetpack Compose 的 LazyHorizontalGridLazyVerticalGrid 与 Flutter 的 GridView 有些相似。 GridView 用 delegate 决定布局; LazyHorizontalGrid/LazyVerticalGridrowscolumns 等属性作用相同。

创建滚动视图

#

Jetpack ComposeLazyColumnLazyRow 内置滚动支持。

FlutterSingleChildScrollView 创建滚动视图。以下示例中 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 中创建相对视图有两种方式:

了解更多请参阅 创建响应式与自适应应用

管理状态

#

Composeremember 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("+")
            }
      }
   }
)

FlutterStatefulWidget 管理本地状态,需以下两个类实现:

  • StatefulWidget 的子类

  • State 的子类

State 对象存储 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 作为 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 中,可类似地设置子项样式或修改按钮自身属性。

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>/ 下要求特定目录结构不同, 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 有类似字体用法,下面一并说明。

使用打包字体

#

以下 Compose 与 Flutter 代码大致等价,用于使用上文所列 /res/fonts 目录中的字体文件。

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 中,实例化方式与引用本地文件大致相同。

实例化引用字体服务特殊字符串的 provider 后,使用相同 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 中,图像通常放在 /res/drawable,用 Image composable 显示,通过 R.drawable.<文件名>(无扩展名)引用。

Flutter 中,资源位置列在 pubspec.yaml 中,如下片段所示。

yaml
    flutter:
      assets:
        - images/Blueberries.jpg

添加图像后,可用 Image widget 的 .asset() 构造函数显示。该构造函数:

完整示例请参阅 Image 文档。