Flutter 基础体系 · 第 1/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布

Flutter 学习不能简化为“学会几个 Widget,再把页面打包”。一个可维护的 Flutter 应用至少包含四层问题:

  1. Dart 语言层:类型、空安全、异步、模式、类、Mixin 和扩展决定代码能否被安全组合。
  2. Flutter UI 层:Widget 描述配置,Element 保存位置和生命周期,RenderObject 负责布局与绘制。
  3. 应用架构层:状态、数据流、并发、平台能力和错误恢复决定应用在规模扩大后是否仍然可验证。
  4. 交付运维层:签名、Flavor、商店、Web、桌面、灰度和回滚决定代码能否可靠到达用户。

这四层存在依赖关系:不会空安全和异步错误处理,网络层就不可靠;不了解约束和 Widget 生命周期,状态管理容易掩盖布局与资源问题;不了解平台差异,架构会把移动端假设泄漏到 Web 和桌面;不了解签名与版本规则,发布流程就无法恢复。


一、先建立 Flutter 的运行模型

Flutter 应用通常从 main 函数开始:

import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '学习路线示例',
      home: Scaffold(
        appBar: AppBar(title: const Text('首页')),
        body: const Center(child: Text('Hello Flutter')),
      ),
    );
  }
}

runApp 接收一个 Widget,并把它挂接到 Flutter 的渲染管线。MaterialApp 提供 Material 风格的主题、路由、本地化等应用级能力;Scaffold 提供常见页面骨架;CenterText 是具体的布局与绘制描述。

一次典型的界面更新可以抽象为:

Dart 状态变化
    ↓
调用 setState 或通知状态管理对象
    ↓
相关 Element 标记为 dirty
    ↓
下一帧重新执行部分 build
    ↓
Widget 配置与旧配置比较
    ↓
RenderObject 重新布局和/或绘制
    ↓
合成并提交到平台窗口

Widget 本身通常是不可变对象。它不是“屏幕上的控件实例”,而是对当前界面配置的描述。真正跨帧保存位置和生命周期的是 Element;真正参与布局、绘制和命中测试的是 RenderObject。因此,下面两句话必须区分:

  • “Widget 被重新创建”不等于“所有底层对象都被销毁”。
  • “调用了 build”不等于“屏幕上的每个像素都重新绘制”。

Flutter 的更新匹配通常依赖 Widget 的类型和 Key。同一父节点下,如果新旧 Widget 类型及 Key 能匹配,框架倾向于复用原有 Element;否则会移除旧 Element 并创建新的 Element。GlobalKey 可以跨父节点识别同一个 Element,但它会扩大匹配范围,使用不当会增加重建和生命周期复杂度。


二、第一阶段:掌握 Dart 3 的类型和空安全

2.1 类型系统先解决“值是否可能不存在”

Dart 的类型注解既用于静态检查,也帮助 IDE 和编译器推断程序意图。空安全中,String 表示非空字符串,String? 表示可能为 null

String formatUserName(String? name) {
  final value = name?.trim();

  if (value == null || value.isEmpty) {
    return '匿名用户';
  }
  return value;
}

这里的关键不是语法,而是控制流推断:

  1. name 的类型是 String?
  2. name?.trim() 的结果仍可能为 null,所以 valueString?
  3. if (value == null || value.isEmpty) 中,短路逻辑保证当执行 value.isEmpty 时,value == null 已经为假。
  4. return value 位于判定之后,Dart 可以把它提升为非空的 String

常用操作的语义不同:

final a = nullable ?? '默认值'; // null 时提供替代值
final b = nullable?.length;     // null 时整个表达式为 null
final c = nullable!;             // 断言运行时一定非空

! 不是类型转换,而是运行时承诺。若实际值为 null,程序会抛出异常。因此,下面的写法虽然能通过静态检查,却把错误推迟到了运行时:

String getName(Map<String, dynamic> json) {
  return json['name']! as String;
}

当后端返回缺失字段或非字符串字段时,可能出现 Null check operator used on a null value 或类型转换异常。更可靠的边界解析应显式验证:

String parseName(Map<String, Object?> json) {
  final raw = json['name'];
  if (raw is String && raw.trim().isNotEmpty) {
    return raw.trim();
  }
  return '匿名用户';
}

2.2 集合、泛型和不可变边界

泛型描述集合中元素的类型:

final names = <String>['Ada', 'Linus'];
final scores = <String, int>{'math': 95};

List<String> 不表示列表本身不可变。若要构造不可修改的公开结果,可以返回:

List<String> readOnlyNames(List<String> input) {
  return List.unmodifiable(input);
}

这会阻止调用方通过返回值修改列表,但列表元素如果本身是可变对象,仍然可能被修改。不可变性必须按对象图理解,而不是只看外层集合。

2.3 函数、闭包和异步

Dart 的函数也是对象,可以作为参数传递:

int apply(int value, int Function(int) transform) {
  return transform(value);
}

void main() {
  print(apply(3, (x) => x * 2)); // 6
}

异步函数返回 Future<T>,表示未来完成一次并得到 T 或失败;Stream<T> 表示可能产生多次事件:

Future<String> loadUser() async {
  await Future<void>.delayed(const Duration(milliseconds: 100));
  return 'Ada';
}

Stream<int> ticks() async* {
  for (var i = 0; i < 3; i++) {
    await Future<void>.delayed(const Duration(milliseconds: 10));
    yield i;
  }
}

await 不会阻塞整个 Dart isolate,而是暂停当前异步函数,把控制权交还事件循环。错误必须在正确的异步边界处理:

Future<void> save() async {
  try {
    final user = await loadUser();
    print(user);
  } on FormatException catch (error, stackTrace) {
    print('数据格式错误:$error');
    print(stackTrace);
  } catch (error, stackTrace) {
    print('未知错误:$error');
    print(stackTrace);
  }
}

如果不 await 一个可能失败的 Future,try/catch 可能捕获不到异步错误:

void wrong() {
  try {
    save(); // 没有 await,错误可能在当前 try 退出后才发生
  } catch (_) {
    // 不可靠
  }
}

应改为:

Future<void> correct() async {
  try {
    await save();
  } catch (error) {
    print(error);
  }
}

2.4 Dart 3 的模式、记录和密封类

记录(record)适合表达临时的多值结果,不必为每种返回值创建类:

(String, int) userSummary() => ('Ada', 95);

void main() {
  final (name, score) = userSummary();
  print('$name: $score');
}

模式(pattern)可以同时进行解构和匹配:

String describe(Object? value) {
  return switch (value) {
    int n when n >= 0 => '非负整数 $n',
    int _ => '负整数',
    String text => '字符串 ${text.length} 字符',
    null => '空值',
    _ => '其他类型',
  };
}

switch 表达式必须产生一个值。sealed class 可限制直接子类范围,适合表示状态集合:

sealed class LoadState<T> {
  const LoadState();
}

final class Loading<T> extends LoadState<T> {
  const Loading();
}

final class Success<T> extends LoadState<T> {
  const Success(this.data);
  final T data;
}

final class Failure<T> extends LoadState<T> {
  const Failure(this.error);
  final Object error;
}

String renderState(LoadState<String> state) {
  return switch (state) {
    Loading() => '加载中',
    Success(data: final data) => '内容:$data',
    Failure(error: final error) => '失败:$error',
  };
}

这里的推导过程是:

  1. LoadState 定义了状态的共同抽象。
  2. LoadingSuccessFailure 构成有限状态集合。
  3. 模式匹配按运行时类型选择分支。
  4. 新增一个子状态时,编译器可以帮助发现未覆盖的 switch

这比用多个布尔值表示状态更不容易产生矛盾。例如:

isLoading = true;
hasError = true;
hasData = true;

三个变量可以同时为真,但“加载中、失败、已有数据”是否允许,必须额外约定。密封状态把可表达的状态集中起来。

2.5 类、Mixin 和扩展

类封装数据与行为:

class User {
  const User({required this.id, required this.name});

  final int id;
  final String name;

  User rename(String newName) => User(id: id, name: newName);
}

Mixin 用于复用实现,而不是表达“是某个类型”:

mixin Logging {
  void log(String message) => print('[LOG] $message');
}

class UserRepository with Logging {
  Future<User> fetch() async {
    log('fetch user');
    return const User(id: 1, name: 'Ada');
  }
}

扩展(extension)向已有类型添加调用语法,但不能增加实例字段,也不能覆盖原有成员:

extension StringParsing on String {
  int? toIntOrNull() => int.tryParse(trim());
}

void main() {
  print('42'.toIntOrNull()); // 42
}

应把领域相关、无状态的转换放在扩展中;需要持有依赖、生命周期或可变状态时,应使用类或服务对象。


三、第二阶段:理解 Widget、布局约束和渲染树

3.1 StatelessWidget 与 StatefulWidget

StatelessWidget 适合其界面只由输入参数和外部状态决定的组件:

class Greeting extends StatelessWidget {
  const Greeting({super.key, required this.name});

  final String name;

  @override
  Widget build(BuildContext context) {
    return Text('你好,$name');
  }
}

StatefulWidget 本身仍然是不可变配置;可变状态保存在配套的 State 中:

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

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('$count'),
        ElevatedButton(
          onPressed: () => setState(() => count++),
          child: const Text('增加'),
        ),
      ],
    );
  }
}

setState 的作用不是直接绘制,而是标记当前 State 对应的 Element 需要在下一帧重新构建。生命周期通常是:

createState
  → initState
  → didChangeDependencies
  → build
  → didUpdateWidget(父级用新配置更新时)
  → deactivate
  → dispose

initState 中创建的订阅、控制器、动画对象,通常必须在 dispose 中释放:

class SearchBox extends StatefulWidget {
  const SearchBox({super.key});
  @override
  State<SearchBox> createState() => _SearchBoxState();
}

class _SearchBoxState extends State<SearchBox> {
  late final TextEditingController controller;

  @override
  void initState() {
    super.initState();
    controller = TextEditingController();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(controller: controller);
  }
}

常见错误是异步任务完成后,页面已经销毁仍调用 setState

Future<void> load() async {
  final result = await repository.fetch();
  if (!mounted) return;
  setState(() => data = result);
}

mounted 只能避免对已销毁 State 更新 UI,不能取消网络请求本身。需要取消能力时,应使用可取消的客户端、订阅取消或在数据层设置请求生命周期。

3.2 约束决定尺寸:从父到子,再从子到父

Flutter 布局可以概括为:

Constraints go down,sizes go up,parent sets position.

父节点向子节点传递约束,子节点在约束范围内选择尺寸,父节点再决定子节点的位置。

一个约束可以表示为:

C=(wmin,wmax,hmin,hmax)C = (w_{\min}, w_{\max}, h_{\min}, h_{\max})

子节点选择尺寸:

wminwwmax,hminhhmaxw_{\min} \leq w \leq w_{\max}, \qquad h_{\min} \leq h \leq h_{\max}

例如,屏幕给 Scaffold 一个约为 390 × 844 的紧约束;Scaffold 向 body 传递可用区域;Center 把可用区域交给子节点;Text 根据文字内容计算自身需要的尺寸;Center 再把文字放在中间。

Container(width: 1000) 并不保证最终宽度为 1000。如果父级最大宽度只有 390,子节点不能违反 w <= 390 的约束。开发中看到的“宽度设置不生效”,通常不是属性失效,而是父级约束覆盖了子级意图。

3.3 Flex 的计算过程

RowColumn 使用 Flex 布局。以竖直方向为例,假设父级高度为 600:

Column(
  children: [
    const SizedBox(height: 100),
    Expanded(child: Container(color: Colors.blue)),
    const SizedBox(height: 50),
  ],
)

计算步骤是:

  1. 非 Flex 子项先占用 100 + 50 = 150
  2. 剩余空间为 600 - 150 = 450
  3. 唯一的 Expanded 获得 450 高度。
  4. 如果有两个 Expanded(flex: 1),剩余空间会按 flex 比例分成 225 和 225。

Expanded 要求主轴上存在可分配的有限剩余空间。把它放入无界高度的 Column,例如某些 SingleChildScrollView 内部,可能出现:

RenderFlex children have non-zero flex but incoming height constraints are unbounded

原因是滚动视口在滚动方向通常允许内容无限延伸,此时“剩余空间”没有有限上界,Flex 无法计算 Expanded 应该占多少。

修复方法取决于意图:

  • 内容应自然增长:去掉 Expanded
  • 页面需要填满视口:使用 LayoutBuilder 获取有限高度,再设置约束。
  • 大量列表内容:使用 ListView 或 Sliver,而不是在滚动视图中嵌套多个可滚动组件。

3.4 Sliver 适合大规模滚动内容

ListView.builder 本质上使用了按需构建的 Sliver。直接组合多个滚动区域时,应该理解 CustomScrollView

CustomScrollView(
  slivers: [
    const SliverAppBar(
      pinned: true,
      title: Text('商品'),
    ),
    SliverToBoxAdapter(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Text('推荐商品'),
      ),
    ),
    SliverList.builder(
      itemCount: 1000,
      itemBuilder: (context, index) {
        return ListTile(title: Text('商品 $index'));
      },
    ),
  ],
)

Sliver 不是普通 Widget 的同义词。它接收滚动视口提供的滚动约束,并根据当前滚动位置决定哪些子项需要布局。列表项必须稳定识别时,应使用合适的 Key,否则插入或删除项目后,输入框、动画等状态可能与错误的数据行对应。

3.5 渲染树、重建和绘制边界

Widget 树是配置树,Element 树是生命周期树,RenderObject 树是布局和绘制树。一次 build 可能只生成新的配置对象,框架会尽量复用 Element 和 RenderObject。

性能诊断应区分三类成本:

  • build 成本:重新执行 Dart 构建逻辑。
  • layout 成本:重新计算尺寸和位置。
  • paint/raster 成本:重新绘制并由引擎栅格化。

例如,修改一个文本通常需要局部 build、layout 和 paint;复杂阴影、透明叠加或大面积重绘可能主要消耗 raster。应在 DevTools 的 Performance 页面、Widget Inspector 和重建统计中验证,而不是仅凭 Widget 数量猜测性能。


四、第三阶段:从页面状态走向应用架构

4.1 状态必须先分类

状态是会影响未来输出的事实。可以按生命周期分为:

  • 临时 UI 状态:密码是否可见、当前 Tab、动画进度,通常靠近页面保存。
  • 页面状态:分页数据、表单输入、加载状态,通常由页面控制器或 ViewModel 管理。
  • 业务状态:登录用户、购物车、权限,可能被多个页面共享。
  • 持久化状态:令牌、设置、离线缓存,必须考虑存储失败和版本迁移。
  • 服务状态:网络连接、推送、系统权限,需要与平台或外部服务同步。

状态管理库可以改变通知和依赖注入方式,但不能替代状态建模。无论使用 ChangeNotifier、InheritedWidget、Riverpod、Bloc 或其他方案,都应先定义:

输入事件 → 状态转换 → 新状态 → UI 输出 / 副作用

副作用包括网络请求、写文件、导航、显示 SnackBar。它们不应混入纯粹的状态计算中,否则测试和重复执行会变得困难。

4.2 一个可验证的异步状态例子

sealed class LoginState {
  const LoginState();
}

final class LoginIdle extends LoginState {
  const LoginIdle();
}

final class LoginSubmitting extends LoginState {
  const LoginSubmitting();
}

final class LoginSucceeded extends LoginState {
  const LoginSucceeded(this.userId);
  final String userId;
}

final class LoginFailed extends LoginState {
  const LoginFailed(this.message);
  final String message;
}

class LoginController extends ChangeNotifier {
  LoginController(this._login);

  final Future<String> Function(String, String) _login;
  LoginState state = const LoginIdle();

  Future<void> submit(String account, String password) async {
    if (state is LoginSubmitting) return;

    state = const LoginSubmitting();
    notifyListeners();

    try {
      final userId = await _login(account, password);
      state = LoginSucceeded(userId);
    } catch (error) {
      state = LoginFailed(error.toString());
    }
    notifyListeners();
  }
}

它解决了几个具体问题:

  1. 重复提交被 LoginSubmitting 拦截。
  2. 成功和失败不会同时存在于状态中。
  3. 网络异常被转换为界面可消费的失败状态。
  4. LoginController 依赖函数注入,测试时可以传入假实现。

但它仍需补充生产语义:错误信息不应直接展示内部异常;请求是否可取消;页面销毁后控制器是否仍被使用;登录成功后的令牌如何安全存储。这些不能由 ChangeNotifier 自动解决。

4.3 并发与竞态

搜索框是典型并发场景。用户依次输入 aab,请求 A 可能晚于请求 B 返回。如果无控制,旧结果会覆盖新结果。

一种简单的“最新请求获胜”策略是版本号:

class SearchController {
  SearchController(this.search);

  final Future<List<String>> Function(String) search;
  int _requestId = 0;
  List<String> results = const [];

  Future<void> query(String text) async {
    final id = ++_requestId;
    final data = await search(text);

    if (id != _requestId) {
      return; // 不是最新请求,丢弃结果
    }
    results = data;
  }
}

中间状态变化是:

_requestId = 0
输入 a  → id = 1
输入 ab → id = 2
请求 2 返回 → 2 == 当前值,采用
请求 1 返回 → 1 != 当前值,丢弃

这能解决结果覆盖,但不能节省旧请求的网络资源。若 HTTP 客户端支持取消,应同时取消旧请求。另一个必要条件是对空输入、去抖、超时、网络异常和页面销毁分别处理。

4.4 Isolate 与“并发”边界

Flutter UI 通常运行在主 isolate。async/await 适合等待 I/O,不会自动把 CPU 密集计算移到后台。解析超大 JSON、图片处理、加密或复杂算法可能阻塞 UI,这时才考虑 isolate,例如使用 Isolate.run

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

Future<int> countItems(String jsonText) {
  return Isolate.run(() {
    final value = jsonDecode(jsonText);
    if (value is! List) throw const FormatException('需要数组');
    return value.length;
  });
}

isolate 之间不共享普通可变内存,数据通过消息传递。启动 isolate 有成本,因此小计算不应机械地移入后台。Web 平台的 isolate 能力和实现限制还取决于浏览器与 Flutter Web 编译方式,不能直接把移动端线程假设搬过去。


五、第四阶段:多端架构不是简单的条件判断

5.1 先区分可移植代码和平台代码

可复用层通常包括:

领域模型
业务规则
请求协议
状态转换
序列化
大部分 Widget

平台适配层通常包括:

文件系统
安全存储
相机、蓝牙、推送
窗口大小与菜单
键盘快捷键
浏览器 URL、刷新、存储和权限

不要在业务代码中到处写:

if (Platform.isAndroid) { ... }

dart:io 在 Web 上不可用。需要判断平台时,优先使用 Flutter 提供的 kIsWeb,或通过条件导入隔离不兼容库。架构上可定义端口:

abstract interface class SecureStore {
  Future<void> write(String key, String value);
  Future<String?> read(String key);
}

移动端、桌面端和 Web 分别提供实现,业务层只依赖 SecureStore。这不是为了消灭所有差异,而是把差异集中在可测试的边界。

5.2 各平台的真实差异

Android 需要处理 Manifest 权限、Gradle 配置、签名、不同 ABI 和系统返回行为。应用被系统回收后,不能假设内存中的状态仍然存在。

iOS 需要 Xcode 工程、Bundle Identifier、证书、Provisioning Profile、Entitlements 和 App Store Connect 配置。某些能力必须同时满足代码调用、工程权限和系统运行时授权。

桌面端 更接近窗口应用:窗口大小、菜单、快捷键、文件路径、多窗口和鼠标 hover 可能成为一等需求。移动端底部导航直接照搬到桌面通常会浪费空间。

Web 没有 dart:io 的文件和 socket 模型,刷新会重建内存状态,浏览器存储有安全域和容量限制;路由还必须考虑 URL、浏览器后退、直接访问深层路径和服务器 fallback。Web 的 CanvasKit、WebAssembly 或其他渲染实现的可用性与性能应以当前 Flutter 版本文档和目标浏览器验证,不能假设所有浏览器行为一致。

5.3 响应式布局的因果关系

响应式布局不是按设备名称硬编码,而是根据可用空间选择结构:

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

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth < 600) {
          return const MobileHome();
        }
        return const WideHome();
      },
    );
  }
}

这里的 600 是产品设计阈值,不是 Flutter 的规范常量。真正的依据是内容在给定宽度下是否仍可读、操作是否方便。桌面窗口可以任意缩放,因此必须测试窄窗口,而不是只测试“桌面默认宽度”。


六、第五阶段:网络、持久化和错误路径

一个端到端页面至少应明确以下状态:

初始
  ├─ 请求成功 → 有数据
  ├─ 请求失败 → 可重试错误
  └─ 请求中再次进入 → 去重、取消或并发合并

数据层不应把所有错误都转换成字符串。可以区分:

  • 网络不可达:可能允许重试。
  • HTTP 401:需要刷新令牌或重新登录。
  • HTTP 403:权限不足,不应无限重试。
  • HTTP 404:资源不存在。
  • HTTP 5xx:服务端暂时失败。
  • JSON 格式错误:通常是协议或版本问题,需记录原始响应并报警。

持久化也有平台差异。普通设置可以使用键值存储;敏感令牌应使用平台安全存储,而不是明文文件或任意 Web Storage。缓存必须定义过期时间、版本号和损坏恢复策略:

读取缓存
  ├─ 版本匹配且未过期 → 立即展示
  ├─ 版本过期 → 删除或迁移,再请求
  └─ 解析失败 → 记录错误,清除损坏数据,请求网络

离线优先并不等于“永远相信缓存”。界面应明确数据时间和同步状态,否则用户会把过期数据误认为最新数据。


七、第六阶段:测试、调试与性能验证

7.1 测试层次

Dart 纯业务规则适合单元测试:

import 'package:test/test.dart';

void main() {
  test('空名称显示匿名用户', () {
    expect(formatUserName(null), '匿名用户');
    expect(formatUserName('  '), '匿名用户');
  });
}

Widget 测试验证组件在给定输入下的结构和交互:

testWidgets('点击按钮后计数增加', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: Counter()));

  expect(find.text('0'), findsOneWidget);
  await tester.tap(find.text('增加'));
  await tester.pump();

  expect(find.text('1'), findsOneWidget);
});

端到端测试验证真实平台上的启动、登录、权限、文件和发布构建。三者不能相互替代:Widget 测试不能证明真实推送权限可用,端到端测试也不适合覆盖所有状态转换。

7.2 常见故障的诊断路径

遇到布局异常时,先看约束,而不是盲目增加 SizedBox

  1. 打开 Widget Inspector。
  2. 检查异常节点的 constraintssize 和父节点类型。
  3. 判断是否存在无界约束、错误的 Flex 方向或嵌套滚动。
  4. 再决定使用 ExpandedFlexibleConstrainedBox 或 Sliver。

遇到“状态错位”时,检查:

  1. 列表项是否增删或重排。
  2. 是否缺少稳定 Key。
  3. 是否错误使用 index 作为长期身份。
  4. 控制器是否在 dispose 中释放。

遇到“页面卡顿”时,先确认卡顿发生在 build、layout 还是 raster,再用 DevTools 记录帧时间和调用栈。不要把 const 当作万能优化:它能减少不必要的对象重建和比较工作,但无法修复无限列表错误、过度绘制或主 isolate 上的重计算。


八、第七阶段:从 Debug 到发布构建

发布前先确认工具链:

flutter --version
flutter doctor -v
flutter devices

这些命令分别用于确认 Flutter/Dart 版本、平台依赖问题和可用设备。flutter doctor 报告的问题不一定全部阻塞目标平台,但 Android SDK、Xcode、签名工具等关键错误必须在对应平台验证。

常见构建命令包括:

flutter build apk --release
flutter build appbundle --release
flutter build ios --release
flutter build web --release
flutter build windows --release
flutter build macos --release
flutter build linux --release

实际可用目标以 flutter devices、当前稳定 SDK 和项目已启用的平台为准。iOS 构建需要 macOS 和 Xcode;macOS 构建同样受 Apple 工具链限制。Android 应用商店通常更适合上传 AAB,而不是直接上传通用 APK。

8.1 版本号和构建号

Flutter 的版本通常来自 pubspec.yaml

version: 1.4.0+27

其中 1.4.0 是用户可见版本,27 是构建号。实际映射由平台工程和 Flutter 工具链处理,但商店通常要求新上传包的构建号递增。版本号递增不能替代数据库迁移、缓存兼容和 API 兼容测试。

8.2 Android 签名

Android 发布包必须使用发布密钥签名。典型流程包括:

  1. 创建并安全保存上传密钥。
  2. 在 Gradle 配置中读取 keystore,而不是把密码硬编码进仓库。
  3. 配置 release signing。
  4. 构建 AAB。
  5. 使用 apksigner 或商店流程验证签名。
  6. 保存密钥备份和恢复说明。

签名密钥丢失可能导致无法以同一应用身份发布后续版本。不要把 .jks、密码和签名配置提交到公开仓库。Google Play 的应用签名方案还可能区分上传密钥与最终应用签名密钥,具体以 Play Console 当前配置为准。

8.3 iOS 签名

iOS 发布依赖:

Bundle Identifier
→ Apple Developer App ID
→ 证书
→ Provisioning Profile
→ Entitlements
→ Xcode Archive
→ App Store Connect 上传

其中任何一项不匹配,都可能在构建或安装时失败。例如,代码调用推送能力还不够,工程的 Push Notifications 能力、签名权限、服务器端 APNs 配置也必须匹配。

应把证书和 profile 的有效期、团队身份、Bundle ID、环境区分记录下来。发布构建必须在干净环境或 CI 中重现,避免只在某台开发机上因本地钥匙串状态而成功。


九、Flavor、环境和配置安全

Flavor 是同一代码库的不同构建变体,例如:

dev     → 开发 API、调试日志
staging → 测试 API、测试服务
prod    → 生产 API、正式标识

Flutter 层可以通过 --dart-define 传入非敏感配置:

flutter build apk --release \
  --dart-define=APP_ENV=prod \
  --dart-define=API_BASE_URL=https://api.example.com

代码中读取:

const environment =
    String.fromEnvironment('APP_ENV', defaultValue: 'dev');

const apiBaseUrl =
    String.fromEnvironment('API_BASE_URL');

--dart-define 会把值编译进应用,不能用于保护密钥。客户端中的 API Key、签名材料和管理员凭据都可能被提取;真正的秘密应保存在服务端或受控的构建系统中。

Android 通常还需要在 Gradle 中定义 product flavors;iOS 需要 schemes/configurations。三套配置必须同时覆盖:

  • 应用名称和图标。
  • Bundle ID 或 applicationId。
  • API 地址。
  • 推送环境。
  • OAuth 回调地址。
  • 日志与崩溃上报项目。
  • 数据库或缓存命名空间。

如果只切换 API 地址,却复用了生产 Bundle ID 或推送配置,测试包可能覆盖正式安装、向真实用户发送测试推送,造成严重事故。


十、Web 与桌面发布的特殊问题

Web 发布通常是:

flutter build web --release

产物位于构建目录,部署时需要静态服务器或 CDN。若启用路径路由,直接访问 /orders/123 时,服务器必须把未知路径回退到应用入口,否则刷新深层 URL 会得到 404。还要配置:

  • HTTPS。
  • 静态资源缓存策略。
  • 压缩和 CDN。
  • index.html 的缓存失效。
  • 浏览器后退与前进。
  • Web manifest 和图标。
  • 第三方登录的合法回调域名。

Flutter Web 的路由方案、渲染器和浏览器能力随 Flutter 版本变化,应以当前稳定文档和实际目标浏览器测试结果为准。

桌面发布需要额外考虑安装包格式、自动更新、文件关联、系统菜单、窗口尺寸和代码签名。桌面应用可访问更多本地资源,但这不意味着可以忽略权限、路径差异和卸载清理。Linux 发行版差异尤其明显,不能只在单一发行版上验证。


十一、灰度、监控和回滚

发布不是“构建成功”就结束。一个可恢复的发布流程应包含:

flowchart LR
    A[提交代码] --> B[静态检查与单元测试]
    B --> C[构建 dev/staging]
    C --> D[集成测试与人工验收]
    D --> E[签名生产包]
    E --> F[小比例灰度]
    F --> G{错误率与关键指标正常?}
    G -- 是 --> H[扩大覆盖]
    G -- 否 --> I[停止发布]
    I --> J[回滚服务配置或发布上一版本]
    J --> K[定位与修复]

灰度观察的不是单一崩溃率,还包括:

  • 启动失败和首屏失败。
  • 登录、支付、核心接口成功率。
  • 崩溃和非致命异常。
  • ANR 或卡顿。
  • 不同系统版本、机型和地区的差异。
  • Web 浏览器与桌面发行版差异。

回滚分为两类:

  1. 服务端回滚:恢复 API、远程配置或 Feature Flag,通常最快。
  2. 客户端版本回滚:商店一般不能让用户安装任意旧包,需要停止当前发布、提高修复版本号并重新提交;因此客户端必须尽量支持向后兼容。

数据库迁移要尤其谨慎。若新客户端先发布、旧客户端仍在线,服务端和数据库必须在过渡期同时兼容两种协议。破坏性迁移应采用“新增字段 → 双写或兼容读取 → 客户端迁移 → 清理旧字段”的阶段过程,而不是一次删除旧字段。


十二、推荐的完整学习顺序

阶段一:Dart 基础

掌握变量、表达式、集合、泛型、函数、异常、类和包管理,然后重点练习:

  • 非空类型与可空类型。
  • FutureStream 和事件循环。
  • async 错误传播。
  • records、patterns、sealed class。
  • Mixin、extension 和接口抽象。

验收标准是:能写一个带输入校验、异步请求、错误状态和单元测试的纯 Dart 模块。

阶段二:Widget 与布局

掌握:

  • Widget、Element、RenderObject 的分工。
  • StatelessWidgetStatefulWidget 生命周期。
  • Key 的匹配语义。
  • 约束、尺寸和位置。
  • RowColumnFlexExpanded
  • ListViewCustomScrollView 和 Sliver。
  • Material、Cupertino 和自定义绘制的边界。

验收标准是:能解释一个布局异常的约束来源,并能让列表增删后输入状态保持在正确行。

阶段三:交互和状态

继续学习:

  • 表单、焦点、手势和动画。
  • 页面内状态与跨页面业务状态的边界。
  • 状态机建模。
  • 请求去重、取消、超时和竞态。
  • isolate 与主 isolate 的职责。

验收标准是:网络慢、失败、重复点击、页面返回和应用切后台时,界面状态仍然可解释。

阶段四:架构和多端

建立领域层、数据层、平台适配层和 UI 层的依赖方向,使用抽象接口隔离:

  • 网络客户端。
  • 本地缓存。
  • 安全存储。
  • 权限和系统能力。
  • Web、移动端、桌面端特有功能。

验收标准是:核心业务规则可以在不启动 Flutter Engine 的情况下测试;更换平台实现不会修改业务状态转换。

阶段五:测试和性能

掌握单元测试、Widget 测试、集成测试、DevTools、布局检查、帧性能和内存泄漏诊断。验收标准是:能从失败表现定位到 build、layout、paint、异步竞态或平台配置,而不是只尝试重启应用。

阶段六:发布和恢复

最后学习:

  • Release 构建。
  • Android 和 iOS 签名。
  • Flavor 与环境变量。
  • Web 静态部署和深层路由。
  • 桌面安装包与代码签名。
  • 商店版本规则。
  • 灰度、指标、停止发布和恢复。

验收标准不是“成功上传一次”,而是能在签名失效、配置错误、灰度异常和服务端故障时,说明影响范围、验证步骤和恢复路径。

Flutter 的核心能力因此可以归纳为一条因果链:Dart 类型系统保证数据和状态转换的边界,Widget/Element/RenderObject 模型保证界面更新可理解,约束与 Sliver 保证布局和滚动可推导,分层架构隔离平台差异,测试与诊断保证故障可定位,签名和发布流程则把这些能力安全地交付给不同平台的用户。

完整学习目录

一、Dart 与 Flutter 核心

  1. Dart 3 语言基础:类型、空安全、模式、类、Mixin 与扩展
  2. Dart 异步与并发:Future、Stream、Event Loop、Isolate 和取消
  3. Flutter 项目工具链:SDK、Pub、Flavor、代码生成和环境配置

二、界面与交互

  1. Flutter Widget 与布局:约束、尺寸、Flex、Sliver 和渲染树
  2. Flutter 状态与生命周期:StatefulWidget、BuildContext、Key 和更新边界
  3. Flutter 导航与路由:Navigator、Router、Deep Link 和返回栈
  4. Flutter 表单与输入:Controller、Focus、校验、键盘和无障碍
  5. Flutter 动画体系:Implicit、Controller、Hero、CustomPainter 和性能
  6. Flutter 主题与设计系统:Material、ColorScheme、Token 和组件规范

三、数据与平台

  1. Flutter 状态管理:InheritedWidget、Provider、Riverpod、BLoC 和边界
  2. Flutter 网络与数据层:HTTP、序列化、取消、缓存、分页和离线
  3. Flutter 本地存储:Preferences、文件、SQLite、加密和迁移
  4. Flutter 平台集成:Plugin、Platform Channel、原生生命周期和权限
  5. Flutter 文件与媒体:选择、上传、图片、视频、相机和生命周期

四、质量与交付

  1. Flutter 测试体系:Unit、Widget、Golden、Integration 和 Mock 边界
  2. Flutter 性能优化:帧流水线、重建、栅格、内存和 DevTools
  3. Flutter 可访问性与国际化:Semantics、焦点、Locale 和文本适配
  4. Flutter 应用安全:Secret、网络、存储、WebView、证书和供应链
  5. Flutter 错误处理与可观测性:Zone、日志、Crash、性能和隐私
  6. Flutter 大型应用架构:分层、Feature、依赖注入和多端边界
  7. Flutter 应用发布:签名、Flavor、商店、Web/桌面、灰度和回滚

一、Dart 与 Flutter 核心

  1. Dart 函数与闭包:参数、类型、捕获、Callable 和 API 设计
  2. Dart 集合:List、Set、Map、Iterable、扩展和复杂度
  3. Dart 泛型:类型参数、边界、协变、运行时类型和 API 设计
  4. Dart 错误处理:Exception、Error、StackTrace、Zone 和契约
  5. Dart Record 与模式匹配:解构、switch、封闭建模和返回值
  6. Dart Future:Event Loop、async/await、错误、超时和并发组合
  7. Dart Stream:单订阅、广播、转换、背压边界和取消
  8. Dart Isolate:消息复制、TransferableTypedData、Worker 和成本
  9. Dart Pub 与包管理:约束、锁文件、Workspace、发布和供应链
  10. Flutter 代码生成:build_runner、序列化、不可变模型和冲突治理

二、界面与交互

  1. Flutter Widget 生命周期:Element、State、BuildContext 和更新顺序
  2. Flutter 约束布局:Constraints、Size、Flex、溢出和调试
  3. Flutter 渲染流水线:Widget、Element、RenderObject、Layer 和帧
  4. Flutter Key 完整指南:ValueKey、ObjectKey、GlobalKey 和状态保留
  5. Flutter BuildContext:树位置、Inherited 依赖、异步间隙和查找
  6. Flutter InheritedWidget:依赖注册、更新通知和状态框架基础
  7. Flutter 手势系统:Hit Test、Arena、Recognizer 和冲突处理
  8. Flutter 滚动与 Sliver:Viewport、懒构建、吸顶和自定义布局
  9. Flutter 响应式与自适应:约束、断点、平台和窗口尺寸
  10. Flutter 文本与排版:TextSpan、字体、缩放、溢出和国际文本
  11. Flutter 资源与图片:Asset、网络缓存、解码、分辨率和内存
  12. Flutter CustomPainter:Canvas、坐标、重绘、命中和性能
  13. Flutter 隐式动画:Tween、曲线、状态切换和适用边界
  14. Flutter 显式动画:Controller、Ticker、组合、清理和测试
  15. Flutter Hero 与页面转场:匹配、飞行、路由和视觉连续性
  16. Flutter go_router:声明式路由、重定向、Shell、Deep Link 和恢复
  17. Flutter Deep Link 与 Universal Link:配置、解析、登录和安全
  18. Flutter 表单校验:Form、Controller、异步规则、错误和提交
  19. Flutter 焦点与键盘:FocusNode、快捷键、遍历和输入法

三、数据与平台

  1. Flutter Provider:ChangeNotifier、依赖范围、重建和测试
  2. Flutter Riverpod:Provider、Notifier、异步状态、生命周期和测试
  3. Flutter BLoC:Event、State、转换、并发、持久化和测试
  4. Flutter 依赖注入:构造器、GetIt、作用域、生命周期和测试
  5. Flutter Dio 网络层:拦截器、取消、重试、上传和错误模型
  6. Flutter JSON 模型:手写、json_serializable、Freezed 和版本演进
  7. Flutter 离线缓存:Cache-Aside、同步、冲突、过期和用户隔离
  8. Flutter SQLite 与 Drift:Schema、查询、事务、迁移和响应式数据
  9. Flutter 安全存储:Keychain、Keystore、密钥、备份和设备迁移
  10. Flutter 文件上传:选择、分片、进度、取消、后台和断点续传
  11. Flutter 相机与媒体:权限、生命周期、编码、预览和资源释放
  12. Flutter 推送通知:Token、前后台、点击路由、权限和送达率
  13. Flutter 后台任务:平台限制、调度、网络、续传和省电
  14. Flutter Platform Channel 深入:Codec、线程、错误和性能
  15. Flutter 插件开发:多平台接口、Federated Plugin、测试和发布
  16. Flutter Web 与桌面:渲染器、窗口、文件、输入和平台差异

四、质量与交付

  1. Flutter Golden 测试:基线、字体、像素差异、主题和审阅
  2. Flutter 集成测试:设备、权限、网络、性能和失败证据
  3. Flutter 卡顿诊断:UI/Raster 线程、Shader、图片和 Timeline
  4. Flutter 内存泄漏:Controller、订阅、图片缓存、闭包和 DevTools
  5. Flutter 包体积优化:AOT、资源、字体、符号、拆分和分析
  6. Flutter Crash 诊断:错误捕获、符号化、版本、Breadcrumb 和隐私
  7. Flutter CI/CD:分析、测试、签名、构建、商店上传和回滚
  8. Flutter Android 发布:Gradle、签名、Flavor、AAB、权限和混淆
  9. Flutter iOS 发布:证书、Provisioning、Capability、归档和审核
  10. Flutter 桌面发布:Windows、macOS、Linux 打包、签名和更新
  11. Flutter Web 发布:Renderer、缓存、路由、CDN、PWA 和回滚
  12. Flutter Firebase 工程:初始化、认证、消息、Crash 和环境隔离
  13. Flutter 地图与定位:权限、坐标、后台定位、隐私和耗电

系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。