Flutter 面试宝典【Part1】
一、Dart 核心基础
Q1:const 和 final 区别?
final:运行时常量,只能赋值一次,值可以是运行时计算出来的
const:编译期常量,必须在编译时就确定值,全局唯一、不可变
详解
1. 赋值时机
final:
- 可以在声明时或构造函数初始化列表赋值
- 值可以是运行时计算出来的
final time = DateTime.now(); // 合法,运行时确定
const:
- 必须声明时直接赋值
- 值必须是编译期可确定的字面量
const time = DateTime.now(); // 报错!DateTime.now() 是运行时方法
2. 不可变性
final:变量引用不可变,但对象本身可以可变
final list = [1, 2];
list.add(3); // 允许
const:深度不可变,对象本身也不能修改
const list = [1, 2];
list.add(3); // 报错
3. 内存与实例
final:每次创建都是【新对象】
const:相同内容的 const 对象在【内存中只有一份】(常量 canonicalization)
4. 使用场景(Flutter 重点)
final:
组件里不变的配置(title、id)
运行时才能确定的值
const:
写 Flutter 组件时极力推荐:
提升性能,避免重复构建
const Text("Hello");const SizedBox();
极简背诵版
final 运行时常量,const 编译期常量
final 可运行时计算,const 必须编译期确定
final 引用不可变,对象可改;const 深度不可变
const 对象内存复用,final 每次新建
Flutter 里组件尽量用 const 提升性能
Q2:Dart 单线程模型原理?EventLoop 执行顺序?
Dart 为单线程事件循环模型,无多线程竞争;
执行优先级:同步代码 > Microtask微任务队列 > Event事件队列。
Future 默认进入 Event 队列,scheduleMicrotask 进入微任务队列。
面试背诵版(精简)
- Dart 是【单线程 + 事件循环】,异步不阻塞,靠 EventLoop(事件循环) 调度。
- 真正并发靠 Isolate,内存隔离,通过Port消息通信。
- 任务分两级:MicroTask微任务队列 优先级 > Event事件队列。
- 执行顺序: 同步代码 → 清空微任务 → 执行一个事件 → 再清空微任务 → 循环。
- Future then 是微任务,Future 构造体、Timer、IO 是事件。
详解
1. Dart 单线程模型原理
Dart 是单线程 + 事件循环(EventLoop)模型,没有多线程抢占式调度。
核心要点
单线程
只有一个主线程(UI Thread)执行所有 Dart 代码。
不存在多线程并发,所以不需要锁、不会出现竞态条件。
异步靠 EventLoop,不靠多线程
IO、网络、计时器、Future/Stream 都不是阻塞等待,而是交给系统底层执行。
等结果回来后,把回调丢到事件队列,由 EventLoop 统一调度。
隔离区(Isolate)
Dart 真正的“并发”是 Isolate。
每个 Isolate 有独立的内存、独立的 EventLoop,互不共享内存。
Isolate 之间只能通过 Port 消息通信。
2. 任务队列分类(重中之重)
Dart 事件循环有两级队列:
优先级:MicroTask(微任务队列) > Event(事件队列)
3. EventLoop 完整执行顺序
执行 main() 函数,同步代码立即执行。
当同步代码执行完,开始一轮循环:
优先清空所有 MicroTask,直到微任务队列为空。
再从 Event 队列取出一个事件执行。
执行完一个 Event 后,再次检查并清空 MicroTask。
循环往复,直到程序退出。
4. 哪些任务进哪个队列?
MicroTask 微任务
Future.microtask(() {})
scheduleMicrotask()
Future 的 then() / catchError() 内部(默认进微任务)
Event 事件队列
真正的 I/O、网络请求
计时器 Timer
点击、手势等 UI 事件
Future(() {}) 构造函数内部的函数体
Stream 事件
5. 经典面试题执行顺序示例
main() {
print('1');
Future(() => print('2'));
Future.microtask(() => print('3'));
print('4');
}
1 → 4 → 3 → 2
原因:
- 同步代码先执行:1、4
- 微任务 3
- 事件任务 2
Q3:extends、implements、mixin 区别?
- extends继承:单继承,继承父类所有属性和方法,可重写;
- implements实现:纯接口约定,必须全部实现,不复用代码
- mixin/with混入:多继承能力,复用多个类的方法,不构造父子关系
详解
extends(继承)
规则
-只能继承一个类(Dart 是单继承)
-子类自动拥有父类所有非私有属性、方法
-可以重写父类方法
必须调用父类构造方法(显式或隐式)
场景
-典型is-a关系:Dog extends Animal
-Flutter 中:class MyWidgetextendsStatelessWidget
implements(实现接口)
规则
可以同时实现多个类 / 接口
实现类必须重写所有方法和属性(不管父类有没有实现)
不复用任何代码,只是契约约束
场景
只需要接口规范,不需要继承实现
例如:class A ``implements ``Walk, Run
mixin(混入)
关键字:with
规则
可以混入多个 mixin,实现 “多继承”
mixin 类不能有构造函数
直接复用 mixin 中的方法、属性
同名方法:后混入的覆盖先混入的
关键字限制
mixin 定义了一组可复用的“能力”,with 用来“消费”这个能力,而 on 则限制了“谁能消费这个能力”。
当一个 mixin使用了 on 关键字,它就只能被混入(with)到那些继承了 on 后面指定类的类中
场景
// 定义一个基类
class BaseClass {
void baseMethod() {
print('Base method');
}
}
// 定义一个 mixin,并使用 on 关键字
// 这意味着:只有 BaseClass 的子类才能使用这个 mixin
mixin MyMixin on BaseClass {
void mixinMethod() {
// 因为 on 的限制,这里可以安全地调用 BaseClass 的方法
baseMethod();
print('Mixin method');
}
}
// 正确用法:子类先继承 BaseClass,再混入 MyMixin
class MyClass extends BaseClass with MyMixin {
// ...
}
// 错误用法:没有继承 BaseClass 的类无法混入 MyMixin
// class AnotherClass with MyMixin {} // 这行代码会导致编译错误
Q4:Dart 空安全机制核心要点?late?/!/required 作用?
Dart 空安全:默认不可空,可空必须加?,编译期检查防 NPE(空指针异常)。
?:声明可空类型。
!:强制非空断言,强制认为非空,相信一定不为 null,为 null 则崩溃。
late:延迟初始化,编译不校验赋值,运行时使用前必须赋值;
required:命名参数必传,不可省略不可空。
详解
一、Dart 空安全核心要点
默认不可空 Dart 中所有类型默认不能为空,变量必须有值,不能为 null。
可空类型必须显式声明 想要变量能为 null,必须加 ?,如 String?、int?。
编译期检查 空安全是编译时检查,不是运行时,提前避免空指针异常(NPE)。
非空判断机制 访问可空变量前,必须:
判空 if (str != null)
或使用 ! 强制断言
或使用 late 延迟初始化
空安全带来好处
彻底消灭 NullPointerException
代码更健壮、可读性更高
AOT 优化更好,Flutter 运行更快
二、? / ! /late/required 作用
? :声明可空类型
作用:告诉编译器这个变量可以为 null
不判空直接访问会报错。
String? name; // 可空
name = null; // 合法
! : 非空断言(强制解包)
作用:“我保证它一定不为 null”,让编译器跳过检查
风险:如果实际为 null,会直接崩溃
String? a;print(a!); // 若 a 为 null 则报错
late : 延迟初始化
① late 变量
声明时不赋值,但之后一定会赋值
编译器不再要求立即初始化
late String name;
void init() {
name = "flutter";
}
② late final
只能赋值一次
常用于 State 里的控制器
late final TextEditingController _controller;
③ late 懒加载(late + 赋值)
late String info = fetchInfo(); // 第一次使用时才执行
required : 必传参数
用在命名参数前,表示调用时必须传,不能省略,不能为 null
常和空安全一起使用
void show({required String title}) {}
show(); // 报错
show(title: "hi"); // 正确
Q5:Isolate 和线程的区别?
Dart Isolate 是独立内存隔离单元,不共享内存,无锁竞争;
操作系统线程共享内存、需要锁机制。
Isolate 通信只能通过 Port 消息传递,适合耗时计算、大数据解析,避免UI卡顿。
详解:
六个维度对比:
关键机制详解
消息传递是“拷贝”而非“引用”在 Isolate 之间发送数据时,默认进行深度拷贝(通过 SendPort 发送)。这意味着数据在发送端修改,不会影响接收端。
> 优化点:对于大型文件(如 Uint8List 图片数据),Dart 允许通过 TransferableTypedData 进行“转移”(Transfer),此时数据所有权移交,发送端将无法再使用该内存,避免了拷贝开销。
Isolate 内部仍然有“线程”
需要特别注意的是:
Isolate 本身并不是轻量级线程。
实际上,Dart VM 会为每个活动的 Isolate 分配一个独立的原生操作系统线程(或绑定到线程池中)。
因此,如果你开启了 10 个 Isolate,操作系统层面可能就有 10 个线程在运行。
Isolate 解决的是内存安全问题,而不是“超轻量”问题。
在 Flutter 开发中怎么选?
UI 主线程(Main Isolate):处理界面渲染和用户交互。绝对不要在这里执行耗时计算(如解析超大 JSON、图片压缩)。
使用 Isolate(推荐 compute 函数):当遇到CPU 密集型任务时,必须使用 Isolate 来计算,防止 UI 卡顿。
代码示例(在 Flutter 中开启新 Isolate):
import 'package:flutter/foundation.dart';
// 耗时的计算任务(必须在顶级函数或静态函数中)
int heavyComputation(int value) {
int result = 0;
for (int i = 0; i < 1000000000; i++) {
result += i;
}
return result + value;
}
// 调用方式
void doWork() async {
// compute 是 Flutter 提供的便捷方法,内部自动创建 Isolate 并传回结果
final result = await compute(heavyComputation, 10);
print(result);
}
总结一句话
Isolate 是唯一的并发方案,它通过牺牲部分通信性能来彻底根除数据竞争和死锁问题,这是 Dart 设计上为了保障 UI 稳定性做出的核心取舍。
在普通业务开发中,大部分异步需求(网络请求、数据库读写)靠 async/await 事件循环即可解决,只有遇到“计算爆炸”时才需要动用 Isolate。
Q6:Future 和 Stream 区别?
Future 单次异步结果(一次成功/失败);Stream 多次异步数据流(持续推送数据),支持单订阅/广播订阅,适合长连接、实时数据场景。
详解:
在 Dart(Flutter)中,Future 和 Stream 都是用于处理异步操作的核心对象。它们最本质的区别在于:Future 只返回一次数据(现在或将来),而 Stream 可以返回多次数据(现在和将来)。
我们可以用下面的例子来形象地理解:
-
Future:像是在饿了么下了一个订单。你等待,最终只会收到一个结果:要么是外卖送达(成功数据),要么是订单取消(错误信息)。整个过程只结束一次。 -
Stream:像是打开了抖音直播。主播(数据源)会源源不断地产生内容(数据包),你作为观众(监听者)会持续收到多个数据,直到主播下播(流关闭)。
核心差异对比表
| 对比维度 | Future |
Stream |
|---|---|---|
| 返回次数 | 单次。只能产生一个数据或一个错误,然后结束。 | 零到多次。可以持续不断地产生多个数据或错误,直到生命周期结束。 |
| 监听方式 | 通过 then() 回调,或使用 await 关键字同步等待(挂起函数)。 |
通过 listen() 方法添加监听器(Listener),或有 await for 循环遍历。 |
| 执行时机 | 创建后立即开始执行,并交由事件循环处理。 | 惰性(Lazy)。创建后不会立即执行,只有当**被监听(listen)**时才会开始产生事件。 |
| 常用场景 | HTTP 网络请求、读取本地文件、数据库单次查询、延迟定时器。 | 用户点击事件流(onTap)、WebSocket 长连接、文件流读取、传感器数据(GPS)、UI 动画帧。 |
代码实战与用法
1. Future:一次性结果
无论是成功还是失败,Future 只会触发一次 then 或 catchError。
// 模拟网络请求
Future<String> fetchUserData() async {
await Future.delayed(Duration(seconds: 2));
return "用户信息";
}
void main() {
fetchUserData().then((data) {
print(data); // 只打印一次:用户信息
});
// 或者使用 await(必须在 async 函数中)
// String data = await fetchUserData();
}
2. Stream:持续事件流
Stream 需要持续订阅,每次有数据推送到流中,listen 里的回调都会被执行。
// 模拟每隔 1 秒产生一次数字
Stream<int> getNumberStream() async* {
for (int i = 1; i <= 5; i++) {
await Future.delayed(Duration(seconds: 1));
yield i; // 使用 yield 而不是 return 来产生多个值
}
}
void main() {
getNumberStream().listen((number) {
print('收到: $number'); // 会打印 5 次(1, 2, 3, 4, 5)
}, onDone: () {
print('流已结束'); // 全部数字发送完后触发
});
}
两种不同的生成语法(重要)
在 Dart 中,生成 Future 和 Stream 的关键词不同:
-
生成
Future:使用async+return。 -
生成
Stream:使用async*(异步生成器) +yield(产出单个值)或yield*(产出另一个流的所有值)。
特殊场景:单订阅 vs 广播流
当你在 Flutter 中处理 Stream 时,需要特别注意它的类型,
否则会遇到“Stream has already been listened to”的报错。
-
单订阅流(Single-subscription):默认类型。只能被一个监听器监听,类似于“私聊”。一旦开始监听,数据会从头部开始流式发送。适合读取文件或一次性的 HTTP 响应体。
-
广播流(Broadcast):可以被多个监听器同时监听,类似于“群聊”或“电视台”。新加入的监听器只能听到加入之后发生的事件,听不到历史事件。适合处理 UI 点击事件(多个按钮监听同一个点击源)或全局状态变化。
如何转换:
// 将普通流变为广播流,允许多个监听者
Stream<int> broadcastStream = getNumberStream().asBroadcastStream();
实战中的选择建议(Flutter 开发场景)
-
当你只需要一个结果时,必须用
Future。例如:调用 RESTful API 获取用户 Token、使用shared_preferences读取一次配置。如果用Stream去做单次请求,会平白增加代码复杂度和内存泄漏风险。 -
当你需要持续响应交互或更新时,必须用
Stream。例如:-
监听
TextField的输入变化(onChanged)。 -
使用 Firebase Realtime Database 或 WebSocket 接收服务端推送。
-
监听动画每一帧的进度(
Ticker)。
-
总结一句话
Future 是“一次性的承诺”,你等着拿结果就好;
Stream是“持续的管道”,你需要一直守着,来一个处理一个。在日常开发中,90% 的业务网络请求用 Future,只有涉及到实时数据刷新或 UI 事件监听时,才请出 Stream(配合 Bloc 或 GetX 等状态管理工具使用效果更佳)。