从上一篇聊完Dart基础语法之后,不少读者都在催更,尤其是最近Flutter适配OpenHarmonyOS(也就是鸿蒙)的讨论越来越热,很多人开始把现有Flutter工程往鸿蒙上迁移。说实话,鸿蒙适配过程中真正卡住人的往往不是平台侧的原生代码,而是对Dart语言特性不够熟悉。你连Stream和Future的差异都说不清楚,连part和import的边界都没搞明白,到了写平台通道、做日志上报、处理分帧数据的时候就会很痛苦。
这篇就是专门补这些短板的。我会把Dart语言里那些“平时写着爽、真出问题就懵”的知识点全部摊开讲清楚,包括异步事件循环、Isolate并发模型、泛型和运算符重载、库与part的组织方式、空安全的坑,以及元数据与反射之间的关系。每一个点我都会结合Flutter开发的实际场景,部分内容还会带上鸿蒙适配时的注意细节。适合已经学过Dart基础、准备深入Flutter或正在进行鸿蒙迁移的开发者阅读,也适合想把自己的代码水平从“能跑”提升到“不出事”的人收藏。
1. 异步编程:Future与Stream,这是Flutter的血脉
1.1 Future不是多线程,它是事件循环里的“待办事项”
很多初学者会有一个错误的直觉:用Future做异步操作,是不是就开启了新线程?真不是。Dart是单线程模型,所有的Dart代码默认跑在同一个“事件循环”里面。事件循环可以理解成一个超级商场里的客服台:一个客服一次只能接待一位顾客,但顾客可以向客服“登记”一件事,客服记下来之后就去处理别的事情,等那件事有结果了再回头通知你。
Future的本质就是这张“登记单”。你调用一个返回Future的函数时,它立刻返回一个Future对象,函数真正的逻辑会在事件循环中被合理安排。Dart把待执行的任务分成了两个队列:微任务队列(microtask queue)和事件队列(event queue)。微任务优先于事件队列执行,而Future.then里的回调默认会进入微任务队列,Timer.run、Stream事件以及IO事件会进入事件队列。
理解了这个模型之后,Flutter里一些诡异的问题就很好解释了。比如说在build方法里直接执行一个高耗时同步操作,页面会掉帧卡顿,因为你把整个事件循环堵住了。哪怕你把耗时的东西包在Future里,只要没有切Isolate,它依然会阻塞UI线程。这也是Dart新手最容易搞混的地方——Future解决的是“等待”,不是“并行”。
1.2 async/await只是语法糖,异常处理才见真功夫
你不会真的以为async函数等于魔法吧?它的本质就是用同步书写的方式表达异步流程。Dart的做法是把async函数体中所有await之后的代码打包成回调链,然后扔进事件循环。所以你在async函数里写的每一行代码,并不仅仅是“从上往下按顺序执行”,每遇到一个await,执行权就交出去了。
用async/await也不代表你可以忽略异常。在这个模型下,异常是按Future链传播的。如果你在build里调了一个会抛异常的函数,却没有用try/catch包住,那么这个异常会被Dart的unhandled exception捕获,在Flutter里大概率会在控制台刷一大片红。更隐蔽的是,如果你在一个没有await的Future上调用.then,然后在里面抛了异常,这个异常很有可能变成“孤儿异常”,不冒泡、不退出,让你排查半天。
正确的做法是给自己定两条规矩。第一,凡是能异步的函数,一律返回Future并主动处理错误,要么用catchError,要么用try/catch包裹整个业务链路。第二,只要你主动丢弃了Future,就要对它负责。Flutter里有个现成的工具叫unawaited_futureslint,可以帮你找出没有await的Future调用;Dart内部也有ignore这个API用来显式表达“我不在乎这个Future的结果”,尽量用它而不是空着不写。
1.3 Stream:单订阅与广播,EventChannel的底层基础
Future是一次性的“应答”,Stream才是持续不断的“信道”。这个区别在Flutter里极其直观:Future适合做网络请求返回值,Stream适合做传感器数据流、页面路由监听、平台通道持续事件回调。如果说Future是快递柜里的一个包裹,Stream就是一条流水线传送带。
Stream有两种类型:单订阅(Single-subscription)和广播(Broadcast)。单订阅Stream就像点对点的电话线,只能有一个监听者,如果你试着在同一个Stream上调用两次listen,第二次调用会直接报错。广播流则像现在的短视频直播间,一个人开播,十万人可以同时看,数据照常往下发,不在乎有多少乘客上车。
EventChannel是Flutter与鸿蒙原生侧通信的基础设施,它本质上用的就是广播流。原生侧不断地把音量变化、网络状态、传感器数据等等推给Dart侧,Dart代码通过EventChannel.receiveBroadcastStream()拿到一个Stream,你只要listen就能收到持续事件。鸿蒙适配的时候有一个点很容易踩:EventChannel的事件发送频率不能太高,首页每一次都得跑一遍Platform通道的序列化和反序列化,如果每毫秒发一条,Dart侧事件的消费速度跟不上,内存就会持续堆积。
1.4 生成器与await for:写数据处理的舒服姿势
除了直接使用已有的Stream,日常开发里你还会需要自己“制造”Stream。Dart用得最多的是async*生成器。一个简单的例子:
Stream<int> countDown(int seconds) async* { for (var i = seconds; i > 0; i--) { yield i; await Future.delayed(Duration(seconds: 1)); } }async*函数里的yield关键字就是“每次吐出一个值”的意思。每次调用yield,值会作为一个事件发送到Stream中,之后函数会暂停,等下一个事件被消费。这里你还能看到async*函数里可以用await,说明生成器内部的执行过程依然是线性的、可等待的。
接收这一侧,除了listen,Dart还提供了await for语法,可以像写同步遍历一样异步地消费数据:
await for (final event in eventChannel.receiveBroadcastStream()) { // 处理事件 }注意await for只能写在async函数里,这不算什么大坑,真正的坑是:你如果同时又调用了listen去监听同一个单订阅Stream,就会出问题。因为await for本身就是一个监听者,单订阅流不让你同时挂两个监听者。所以在使用的时候要想清楚,你到底是用listen的灵活控制,还是用await for的简洁遍历,不要两头都占。
2. 不像“线程”的“线程”:Isolate与并发模型
2.1 为什么叫“隔离区”
前面说过Dart默认单线程,但单线程照样能多核并行,靠的就是Isolate。隔离区这个词已经说得很明白了——每个Isolate有自己独立的内存堆,有自己独立的事件循环,两个Isolate之间完全不共享任何状态。它们之间的通信,唯一途径是端口(Port)加消息(Message),消息本质是拷贝或转移,而不是共享引用。
这一套设计放在移动端,尤其是鸿蒙适配的场景下有非常现实的意义。原生开发里写多线程,最头疼的就是数据竞争和锁。Isolate从根上把这种问题断掉了,因为你们压根不共用一块内存,不存在你改我的对象、我读你垃圾数据的可能。
代价就是性能。消息传递需要拷贝数据,尤其是当你传递一个超大的列表时,Dart内部会做一次深度拷贝。这其实就是Flutter里compute的局限:适合传小数据量的耗时任务。你如果拿它去做大图卷积、搞定几百MB的数据,就会看到卡顿和内存翻倍。
2.2 创建Isolate的三种姿势
最基础的方式是Isolate.spawn。需要给一个入口函数传一个初始消息,入口函数在第一个参数的位置接收。下面是典型写法:
Future<void> heavyTask(int message) async { // 耗时逻辑 } final isolate = await Isolate.spawn(heavyTask, 42);这样创建之后,你还要自己管理通信。更常用的方式是Flutter自己封装的compute函数,它本质上就是用Isolate.spawn跑一个任务、拿结果,然后销毁整个Isolate。它的好处是不需要你自己管端口,适合“发个任务过去,拿结果回来”的场景。
第三种做法是自己维护一个Isolate池或者长期存活的Isolate,一般用在需要反复执行相同类型任务的场景,比如图片处理服务。它可以省去频繁创建和销毁Isolate的开销,但也会引入更复杂的通信管理问题。
我给一个建议:当你的任务计算量小于5毫秒时,老老实实在主Isolate里同步做,别想着开Isolate。因为在移动端,创建Isolate本身也是一次不小的开销,包含线程创建、内存分配、Dart运行时初始化,整个过程可能比你的任务本身还贵。
2.3 鸿蒙平台上的并发注意点
做过鸿蒙Flutter适配的都知道,Flutter引擎跑在ArkTS运行时之上,它底层的线程调度并不完全等同于Android或iOS平台。在HarmonyOS上,Dart的Isolate被映射到底层的原生线程,所以你触发的每个Isolate最终真的会在系统上起一个线程。如果你把Isolate当线程池随便开,性能反而更差。
实际操作中,第一原则是控制并发数量。Flutter的compute每一调用都会临时开一个Isolate,频繁调用等于频繁起线程,在鸿蒙上尤其容易被系统识别成高耗电任务。第二原则是尽量让耗时任务分块执行,打散到事件循环里做,比如计算量大但可以分段的东西,用Future.delayed切分,减少单次阻塞。这两个原则能帮你躲掉大部分线上流量起来以后才出现的性能问题,尤其是那些在开发者机器上根本测不出来、一上真机就疯狂发热的问题。
3. 类与泛型:把复杂逻辑写得更安全和更顺手
3.1 泛型的边界:extends、covariant
Dart泛型用起来很简单,List<int>、Map<String, dynamic>这些随手就来。但你要写一个受约束的泛型方法,往往就需要extends关键字了:
T maxOf<T extends num>(T a, T b) { return (a > b) ? a : b; }这里extends num限制传入的类型必须是num的子类,所以int和double可以传,String不行。泛型的约束不仅是给调用者设边界,也是给自己提供能力。有了extends num,编辑器才会允许你在泛型参数上调用比较运算符,否则Dart编译器根本不知道这个T是什么类型,当然不允许你用大于号。
covariant相比而言更冷门。它解决的是类型收窄的问题。假设你有一个父类Repository和子类UserRepository,父类定义了一个方法,参数类型是Model,你想在子类中覆盖它并让参数类型变成UserModel(更具体的类型),这时Dart的覆盖规则默认不允许这种“超集变子集”的行为,加covariant关键字才能通过。
实际开发中,我用得最多的地方是定义统一的基类接口,为不同业务做定制时把入参类型收窄到各业务自己的Model。如果没有covariant,你只能接受父类Model参数,然后在方法内部做类型判断和强转,丑且不安全。而加了covariant之后,类型系统会替你保证所有的强制转换都发生在调用侧。
3.2 扩展方法:给别人的代码“续写”
Dart 2.7引入的扩展方法,是那种“不起眼但用惯了回不去”的特性。你可以给不归你管的类(甚至是你项目里的某个JSON模型类)动态增加方法,而不用去改原类的定义。比如:
extension StringParsing on String { int toIntOrZero() { return int.tryParse(this) ?? 0; } }定义完之后,所有的String实例都可以直接.toIntOrZero(),编辑器还会有自动补全,跟原生方法没有差别。
扩展方法在Flutter工程里的一个经典用途是给颜色字符串加解析方法,给页面路由封装统一的跳转辅助。鸿蒙适配时还有一个细节值得注意:因为平台差异,你需要给某个对象加查询能力,扩展方法可以让你在不改动原类的情况下,写平台特有的逻辑分支。
极少数踩坑的案例是在扩展方法里写了实例变量。扩展方法只能包含方法,不允许有真正的存储字段,如果你试图在扩展里声明int _value这样的东西,编译器会直接拒绝。这也提醒诸位:扩展方法的本质是静态方法 + 语法糖,它并没有改变原有类的内存布局。
3.3 运算符重载与callable class:让对象的行为更自然
Dart允许你重载一些运算符,比如+、-、*、==,用来给自定义类型建立更自然的语义。最常见的案例就是Money、Duration、Vector这类值类型。你肯定不希望每次比较两个价格的时候都写if (a.amount == b.amount),重载==之后直接if (a == b)即可。
重载==时有一个硬性要求:同时重写hashCode。因为Dart约定相等的对象必须有相同的哈希码,一套Map、Set都依赖这个约定。如果你只重写==不重写hashCode,你会发现往Set里放两个“相等”的对象,结果两个都能进去,排查半天也找不到原因。
说完运算符重载,再讲callable类。这个很巧妙——Dart允许类的实例像函数一样被直接调用,只要这个类定义了call方法:
class TypeMatcher { final String type; const TypeMatcher(this.type); bool call(dynamic obj) => obj.runtimeType.toString() == type; } void main() { final isString = TypeMatcher('String'); print(isString('hello')); // true }callable的用法在很多状态管理库里会出现,比如BLoC中经常见到把某个Action处理器封装成可调用的对象,直接bloc(action)触发。这种风格与“类即函数”的函数式思想结合得很好,代码读起来也有一种行云流水的通畅感。
3.4 不可变数据与copyWith模式
很多人写Flutter状态管理,最头疼的就是数据被意外修改。你不小心把一个对象塞进List,没过多久它的字段全变了,页面闪得没法看。这往往是因为你用了可变对象反复赋值。
Dart并不强制不可变,但提供了很好的工具让你设计不可变。一个不可变类,所有字段都用final修饰,构造函数用const,这样实例一旦创建就无法被改变。与此同时,给类加一个copyWith方法,像这样:
class User { final String name; final int age; const User({required this.name, required this.age}); User copyWith({String? name, int? age}) { return User(name: name ?? this.name, age: age ?? this.age); } }每次修改操作都不改原对象,而是创建一个新实例,这种做法与Flutter的组件树重建机制天然契合。你re-render的时候传新的对象进去,Framework层面会做shouldUpdate比较,减少无谓的重绘。
4. 工程组织的学问:part、import与库的边界
4.1 import的几种姿势
大多数人对import的理解就是“引入别的文件,用里面的类”。话是没说错,但Dart的import远比这灵活。标准写法里有show和hide两个筛选器,用来控制你暴露的东西:
import 'package:flutter/material.dart' show Card, ListView; import 'package:foo/foo.dart' hide InternalLogger;show表示只从那堆公开成员里挑出几个来用,hide表示全都用但把这几个挡在外面。这两个关键字在大型项目里的价值是防止命名冲突。你同时引入两个各有buildConfig方法的库时,冲突就会冒出来,而用show就是把其中一个库的buildConfig隐藏掉的最干净办法。
as关键字用来给库起别名,import 'package:xxx/xxx.dart' as util;,调的时候util.someFunction()。它的主要适用场景是调用命名空间比较通用的工具库,避免类名污染当前作用域。
4.2 part与part of:一把双刃剑
可能你会因为自动生成代码的工具而认识part。Dart的part允许把一个库拆成多个文件,但这些文件共享同一个namespace。
看代码会比文字更直观。假设主文件是parser.dart:
library parser; part 'lexer.dart'; part 'ast.dart';这两个part文件里以part of parser;开头,然后就可以直接使用library parser里定义的所有私有成员,像是同一个文件一样。
这里我要给一句重话:普通业务代码尽量别用part。它最大的问题是破坏了文件间的封装边界,几个文件共享私有成员后,你很难判断某个变量到底是谁在写、谁在读。极其容易被改出隐形耦合,代码review时也看不出来。
那么什么时候适合用part?第一是代码生成场景,比如Freezed、JSON序列化生成器生成的代码需要访问你手写的私有构造器,用part能绕开私有可见性限制。第二是某些巨型解析器或状态机,逻辑不得不拆成多个文件,又需要及时共享大量内部状态,这种极端情况下part能帮你保住这个库不至于变成两三个互相import的大迷宫。
如果只是单纯希望把一个大类拆成几个文件管理,Dart建议用多个带独立前缀的库文件,然后通过export统一暴露给外部。这是一种更符合常规面向对象的组织方式,因为模块间有明确的依赖和边界。
4.3 在Flutter鸿蒙适配中为什么要加倍重视库边界
说真的,凡是做过跨平台适配的,都应该对“库边界”这四个字有膝盖般的敬畏。你适配一个平台,往往需要修改底层依赖的实现。如果你的代码库是import满天飞,谁都直接import内部的某个文件,一旦改了它,不知道哪座庙会塌。
Dart的库边界配合export可以搭建一道隔离墙。对外暴露一个稳定接口,对内自由折腾实现。用上part就意味着对外界暴露了库内部的碎片,你没法做到“内部细节随便改”。所以鸿蒙适配的过程中,遇到平台通道、插件封装的场景,我都会特意要求团队把所有跨平台逻辑封装成一个入口dart文件,向外export必要类型,内部具体实现全部私有。想改鸿蒙原生实现的时候,只动那一个文件就够了,其他业务代码毫发无损。这比什么设计模式都管用。
5. 空安全:Dart既让你舒服也让你难受的设计
5.1 可空与不可空,不是加个问号那么简单
Dart在2.12之后进入全环境空安全,这可以说是Dart语言历史上最正确也最反人类的一次改动。所有类型默认非空,除非你显式加?声明它可以为null。
String name = 'zhang'; // 非空,编译器保证不会null String? nickName; // 可空,需要判断才能使用这套设计的核心是类型系统层面的保证:只要你拿到一个非空类型,直接放心使用编译器不会报错,因为不管谁来传,都不可能传null进来。
但是注意,这种保证只对Dart内部有效。从原生的平台通道返回的值,或者从json解析出来的值,类型统统是dynamic,跑起来才知道是不是null。这时候最大的坑就是你以为拿到了String,其实是String?,又懒得判空,直接trim(),运行期直接崩掉。
真正稳妥的做法是:一切从外部进入Dart的数据,必须第一时间做显式判空和类型转换。可以封装统一的数据解析工具函数,在边界处就把nullable转换成non-null的干净数据,并拒绝无效空值。
5.2 类型提升与闭包捕获的坑
空安全引入了一种叫“类型提升”的行为。在你做判空之后,Dart编译器能聪明地把可空类型提升为不可空类型:
void printName(String? name) { if (name == null) { return; } // 这里直接用name,它已经被提升为String print(name); }代码看起来挺舒服,但一碰到闭包就出幺蛾子。比如:
void test(String? name) { final callback = () { print(name.length); // 编译报错:name可能为null }; }明明外层已经判过空了,为什么闭包里还是不行?因为闭包里面的代码可能在判空之后才运行,Dart编译器无法证明捕获变量在运行那一刻仍然是非空的,你只有两种选择:判空放在闭包内部,或者把它先赋值给一个非空局部变量。
这一个点我看到很多新人的代码里频频出错,报错信息看半天也不知道原因。更新到最新的Flutter Dart SDK之后,这类报错还会更严格,所以请记住,闭包捕获的变量,判空逻辑要写在闭包里面才是安全的。
5.3 延迟初始化late:方便是有代价的
late修饰符允许你声明一个非空类型,但先不赋值,再约定在某个时刻一定会初始化好。它本质上是把“保证非空”的责任从编译器移交到程序员手里。
late最常见的两个场景,一是依赖注入容器的属性,二是Flutter里State对象的引用。老实说,第二种真的让人又爱又恨。你会在很多老教程里看到:
late final TextEditingController _controller;确实方便,构造时不管,initState里再赋值。但late一旦被访问而还没有被赋值,就会抛出一个LateInitializationError异常。这个异常时常在页面build的时候才冒出来,而你在initState里忘了赋值,完全不会立刻报错。鸿蒙适配中如果涉及到平台通道初始化,iniState里初始化一个普通的非空监听器而你忘记了,可能就是一个空指针崩溃,还可能一下子找不到崩溃堆栈层级,会让你排查折腾一下午。
我的建议是除了极少数情况以外,能不用late就不用。最安全的是在构造函数里就完成所有非空字段的初始化,或者使用可空类型加!强制解包并坦然接受它可能为空的运行期后果。late是给那些特别确定逻辑的操作保留的,不是给你写偷懒代码的工具。
5.4 空安全在数据解析中的实战经验
数据解析可以说是空安全锤炼最多的阵地。我见过太多次崩在接口数据返回null上,整个App当场挂掉。所以当我们写一个model的fromJson方法时,有几个原则绝不妥协:
- 每个字段都用对应类型的可空声明,再用空合运算符回填默认值。
json['name'] as String? ?? ''。 - 对数字字段注意,JSON里可能出现字符串形式的数字,我经历过一模一样的。这个时候
as int?直接炸了,得用toString()再转。 - 对嵌套对象,不要急着用
!,写成User.fromJson(json['user'] as Map<String, dynamic>?)再加判空。
这些经验虽然基础,但能帮你省下很多线上版本的心惊胆战。空安全的核心,在于边界。只要你把所有的边界数据都处理好,中间层的数据流就是纯且稳的。
6. 元数据与注解:Flutter工具链的底层机制
6.1 常用注解背后
Flutter里到处都是@override、@immutable、@protected,这些就是Dart的元数据(Metadata)。它们是编译器、静态检查工具和代码生成器识别的标记。
@override只干一件事——告诉编译器这个方法是故意的覆盖,如果父类或接口里找不到同名方法会直接警告。@immutable则是告诉分析器,这个类的所有字段必须是final,不然就报错。这都是开发期保障,不进入运行期。
6.2 自定义注解与代码生成
你可以定义自己的注解,可以带参数,像这样:
class FieldInfo { final String label; final bool visible; const FieldInfo(this.label, {this.visible = true}); } class User { @FieldInfo('用户ID', visible: false) final String id; }如果你的项目会用到json_serializable、Freezed这类包,你会发现它们都大量利用注解收集信息,再通过Dart的build_runner在编译期生成代码。为什么要生成代码而不是用反射?因为Dart明确限制反射能力,dart:mirrors在Flutter里不可用。这带来一个巨大的优势:你所有的方法和字段都能被编译器静态分析出来,tree shaking可以剪掉未使用的代码,打包体积明显降低。缺点则是不灵活,一切需要在编译期确定。
6.3 鸿蒙平台上的注解实践
适配OpenHarmonyOS时,你会发现插件开发中很多自动生成代码的依赖链比Android/iOS平台复杂。注解用来描述通道信息,用build_runner生成的模板代码里包含了平台通道的名称、方法签名,然后在原生侧就可以直接用这些元数据驮着Dart调用落实到鸿蒙API上。
可以说,理解“元数据是给工具用的,不是给本体用的”,就能少走很多弯路。很多新手想给Dart类加“运行期标记”用于反射,结果查了半天发现根本没有像Java那样的反射体系,这才回过头去学build_runner。趁早把这个弯转过来,后期收益很大。
7. 常见问题与排查技巧实录
7.1 页面切换后状态丢失,是Dart的锅吗
我经常在Flutter社区看到有人问:“为什么用Navigator.push切换到新页面,原来页面的状态丢了?”这根本不是Dart的问题,而是Flutter组件树生命周期的问题。不过,Dart的final特性会放大这种错觉。你在State里定义了final controller = TextEditingController(),当State对象被销毁时,所有final资源也跟着销毁,页面再回来时controller已经被重建成新的实例,你感觉“状态丢了”,其实连对象都换了。
处理办法是让状态往上层提升。用Provider或者Riverpod等状态管理框架,把状态从State对象中抽出来,放到跟页面生命周期不绑定的地方。这件事用Dart写起来很顺,因为你在一个普通的类里用final持有一个控制器,App顶层一初始化,它就存在了,页面切换不销毁它,自然状态就不会丢。
7.2 事件订阅了却没反应
EventChannel收不到消息是鸿蒙适配中非常常见的问题。绝大多数情况下不是Dart代码的问题,而是原生侧的Sink没接对。在鸿蒙侧,如果你通过EventChannel往Dart发消息,确保Dart侧listen时的回调能跟原生侧存活的stream关联上。
但我也遇到过一次确实跟Dart有关的情况:流被垃圾回收了。写了eventChannel.receiveBroadcastStream().listen(...),但没有把返回的StreamSubscription保存起来,页面build完后订阅者被回收,之后原生侧发什么都收不到。这是最典型的Dart资源管理失误。正确做法是在State里保存Subscription,并在dispose里cancel,把生命周期管起来。
7.3 空安全迁移时的批量报错
把一个老项目升级到空安全,满屏红的感受绝对让人一度怀疑人生。不过核心逻辑很简单——把代码分成两类:第一类,能确认绝对不为空的,加late或者构造器初始化;第二类,确实可能为空的,加?并在使用处判空。
对于大项目,迁移有一个相对舒服的路线:先用dart migrate工具跑一遍,它会自动加上大部分的类型标注,再逐个文件检查手动修正。注意,Dart编译时报错提示通常很精准,指向具体字段,优先级是先把每个文件的构造器初始化搞定,再做逻辑判空。千万不要全局加!硬来,那等于给自己埋雷。
7.4 高CPU任务卡UI,compute也不灵了怎么办
用compute做耗时计算,结果UI还是卡,这是不少人遇到的怪问题。原因有两个:第一,compute传参和返回值需要拷贝,大列表的拷贝本身就很耗时;第二,任务在Isolate里执行完成后,通知结果回来也需要排队调度,如果主Isolate当前很忙,回调照旧得等。
这种情况我建议换个思路:将数据分片。把大列表拆成小块,每块用Future.delayed(Duration.zero)调度到事件循环的微任务中,这样虽然计算还是主Isolate在做,但UI有喘息机会,不会被一个巨大任务整个堵死。如果数据量实在太大,就必须优化算法了,看看有没有剪枝和降采样的空间,很多时候不必要的计算比计算本身更拖后腿。
说到最后,我自己的体会是,学Dart不能停留在“会用关键字”的层面,你得顺着这套语言的癖好去设计代码结构。它的不可变倾向、单线程事件循环模型、库封装的哲学,每一项都直接影响着你的Flutter项目能不能顺利运行在鸿蒙上。把这些底层机制吃透的人,写的代码不仅跑得稳,改起来也快。真要挨个踩坑再回头补理论,这个学费交得就有点冤枉了。这一篇讲到的每一个点,大家最好都动手写一段小例子验证一下,哪怕只是跑个dart单文件,都比光看文字记得牢靠。