ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter开发必知:Set集合的底层原理与工程实践

Flutter开发必知:Set集合的底层原理与工程实践

Day 3,我打算把前两天的学习节奏稍微放慢一点。前两篇笔记都在讲 Flutter 里最常用的数据容器:List 和 Map,一个管“有序的一串”,一个管“键值对应”,基本覆盖了大部分 CRUD 场景。但到了第三天,我越发觉得有个类型被很多人忽略,却在真实的鸿蒙跨端业务里频频救场,那就是 Set。这个名字直译是“集合”,它背后站着的,其实是整个数学集合论:元素互异、无序、可做交集并集差集。如果你也正在为鸿蒙应用踩 Flutter 的坑,或者刚从 Java、Kotlin 转过来,这篇笔记适合你先搞清楚 Set 的底层逻辑,再写代码。

很多人一开始会把 Set 当成“自动去重的 List”,这个说法不能说错,但会严重低估它的价值。去重只是集合性质在业务层的一个投影,真正值钱的是“唯一性”这件事本身带来的数学计算能力。我花了一整天把 Set 在 Dart 里的默认实现、哈希行为、集合运算、以及几个隐藏陷阱全部过了一遍,正好用这篇笔记把 Day 3 的内容沉淀下来。

1. 前两天都在处理“顺序”和“映射”,今天该正视“唯一性”了

1.1 唯一性不是可选优化,而是业务的基本约束

先回想一下,我们在 Flutter 里做列表页、表单页、状态管理时,List 和 Map 确实是最顺手的工具。List 可以保留元素的先后顺序,做分页、做索引、做动画队列都很自然;Map 可以拿着一个稳定的 key 去取 value,做字典、做配置项、做路由表也很方便。但这两个类型对“重复”这件事的态度都很宽松:List 允许一模一样的内容出现多次,Map 允许不同的 key 指向相同的 value,它们本身不关心“世界上是否已经存在一个等价的对象”。

日常业务里,“重复”往往就意味着数据有误或逻辑有漏洞。比如鸿蒙跨端应用里获取设备列表,同一个蓝牙设备可能在一次扫描回调里出现两次;比如从服务器拉取消息通知,服务端重试机制可能导致同一条消息重复下发;再比如用户给文章打标签,一个标签被多选了一次,最终前端展示就会看到两个相同的 Tag。这些场景如果都靠手动去重,你会写出一堆contains加remove的样板代码,而且每一处都得小心性能问题——List 的contains是线性扫描,数据量大了以后会越来越卡。

Set 的介入时机就在这里。它从数学上保证“一个集合中不会出现两个相等的元素”,不是通过事后过滤,而是在插入那一刻就通过哈希快速判断。换句话说,唯一性不是一个可选项,而是集合这个抽象概念本身的定义。这也是为什么我推荐大家在设计数据模型时,把“是否需要唯一”作为选型的第一问,而不是最后补丁。

1.2 数学里的集合公理,映射到代码里就是一套规则

如果你高中还留着点印象,集合论里有一条外延公理:两个集合相等,当且仅当它们包含的元素完全相同;集合里的元素不能重复。编程里的 Set 基本复刻了这套规则。Dart 的Set<E>接口里没有下标访问,也没有像 List 那样的operator[],因为集合这玩意儿压根儿不在乎谁排第一谁排第二,你在一个数学集合里不会问“第 3 个元素是谁”。

但这里有个值得注意的区别:数学集合通常是静态的,而 Dart 的 Set 默认是可变的,你可以往里面add任意多次相同内容,最终结果还是只有一个。打个比方,数学集合是一张已经写好的成员名单,编程 Set 则是一道安检门:每个新来的人都要验证身份,已有身份的就直接请回去。

所以当我听到“Set 就是去重 List”这种简化时,其实更愿意理解为:List 是“流水账”,Map 是“通讯录”,Set 是“会员名册”。会员名册的规则天然排斥重复,而且支持很多基于名单整体关系的运算,比如交集、并集、差集,这些直接对应数学里集合的基础运算。进入第三天的第一个认知转变,就是不要再把 Set 仅仅当成一种数据结构,而应该把它看成一种约束规则与运算能力的结合体。

2. Set 在 Dart 里的底层真面目:哈希表与“唯一”的代价

2.1 默认的 LinkedHashSet,既给了唯一性又给了插入序

很多从 Java 转过来的开发者,第一次在 Dart 里看到var s = <String>{'a', 'b'};时会以为这是某种语法糖。其实 Dart 的 Set 字面量背后默认实现是LinkedHashSet,它用哈希表做主存储结构,同时用一条链表额外记录元素的插入顺序。所以你会看到,大多数情况下打印一个 Set,元素的顺序看起来“有点像插入顺序”,这跟纯数学集合的“无序”并不冲突,只是工程实现上为了可预测性和调试便利做的设计。

默认LinkedHashSet意味着什么?首先,add、remove、contains的时间复杂度都是均摊 O(1),因为它们直接通过哈希定位;其次,如果你不刻意强调顺序,这个默认选择足够满足日常去重。但你也可以在HashSet和SplayTreeSet之间做选择:HashSet完全不保证顺序,换来的是更纯粹的哈希访问;SplayTreeSet基于二叉搜索树,元素按键排序,但查找复杂度变成 O(log n)。在我做的鸿蒙跨端 demo 里,绝大多数场景直接使用默认 Set 就够了,只有当集合很大且需要稳定排序遍历时,我才会考虑SplayTreeSet。

这里有一个底层原理值得展开:Set 判断“两个元素是否相等”,会先比较它们的hashCode,如果哈希值不同,直接认为两个对象不相等;如果哈希值相同,再进一步调用==做精确判断。换句话说,hashCode是快速的粗筛,==是最终裁决。这在处理自定义对象时非常重要,否则你可能明明往 Set 里塞了两个内容相同的对象,结果它们愣是被当成两个不同的成员。

2.2 一个自定义对象的去重示例,顺便了解 hashCode 的写法

看一个实际例子。假设鸿蒙跨端应用里有一个图书收藏功能,我用一个Book对象来表示书,书的唯一标识是 isbn。如果不重写==和hashCode,两个 isbn 相同的Book对象会被 Set 认为是不同元素。

class Book { final String isbn; final String title; Book(this.isbn, this.title); @override bool operator ==(Object other) => identical(this, other) || other is Book && runtimeType == other.runtimeType && isbn == other.isbn; @override int get hashCode => isbn.hashCode; } void main() { final books = <Book>{ Book('978-1-111-2222-0', '鸿蒙应用开发指南'), Book('978-1-333-4444-1', 'Flutter 跨端实战'), Book('978-1-111-2222-0', '鸿蒙应用开发指南(重复版)'), }; print(books.length); // 输出 2,因为第三本与第一本 isbn 相同,被判定为同一本书 }

重写规则里有个细节:hashCode应该尽量用到所有参与==判断的字段。如果==里比较了 isbn 和 title,而hashCode只算 isbn,那么 isbn 相同但 title 不同的两个对象会先被哈希判定为同一桶,然后==发现不等,Set 里会同时存在它们,这并不违反契约,但会让哈希效率下降。反过来,如果hashCode用了一个可变字段,那问题就大了,后面第四节我会专门讲这个坑。

2.3 哈希冲突并不可怕,但要明白比较链的存在

哈希表不可能完全避开哈希冲突。当两个不同对象的hashCode恰好相等时,Dart 的 Set 并不会直接认为它们相同,它会把它们放进同一个哈希桶,然后逐一调用==来判断。我见过不少初学者一看到“哈希冲突”就紧张,其实冲突在工程上只是意味着一个桶里挂了多个元素,查询时在这个桶里退化成线性比较。只要hashCode分布足够分散,绝大多数桶只有一个元素,整体复杂度仍然接近 O(1)。

这个机制也提醒我们:Set 的唯一性,本质上是由“元素是否相等”定义的,而不是由“是否是同一个内存引用”定义的。identical只能判断是不是同一个对象实例,但业务上的“同一本书”“同一个联系人”往往需要自定义相等规则。所以每次写自定义类之前,先问一句:如果两个对象字段值完全一样,它们在业务上算同一个吗?如果算,就必须重写==和hashCode。

3. 集合运算:把数学课本里的交集并集差集直接写进业务代码

3.1 标签系统里的三行代码,解决我原来看起来很复杂的筛选

Set 最让我觉得“值回票价”的能力,是它原生支持集合间的数学运算。Dart 的Set提供了union、intersection、difference三个方法,分别对应并集、交集、差集,而且都返回一个新的 Set,不会修改原集合。这个特性可以直接拿来处理所有“多个集合之间关系”的场景。

举个例子。我最近在做一个跨端的兴趣标签推荐功能,用户 A 关注了一系列技术标签,用户 B 关注了另一系列技术标签,我想知道他们共同关注了什么,以及各自独有的标签。用两层 for 循环也可以做,但代码又长又容易错,用 Set 就是三行:

final tagsOfA = {'Dart', 'Flutter', 'HarmonyOS', 'ArkUI'}; final tagsOfB = {'Flutter', 'Java', 'HarmonyOS', 'Kotlin'}; final commonTags = tagsOfA.intersection(tagsOfB); // {Flutter, HarmonyOS} final allTags = tagsOfA.union(tagsOfB); // {Dart, Flutter, HarmonyOS, ArkUI, Java, Kotlin} final onlyA = tagsOfA.difference(tagsOfB); // {Dart, ArkUI}

这三行背后对应的是数学里的集合运算定义:交集是同时属于两个集合的元素,并集是至少属于一个集合的元素,差集是从第一个集合中去掉第二个集合里也有的元素。在真实业务里,这可以直接用于联系人黑白名单、好友共同关注、权限列表合并、商品筛选条件叠加等。你不需要自己维护“当前结果集 + 是否已包含”这种状态,集合运算天然地把这些逻辑封装好了。

3.2 containsAll 与 subset:判断覆盖关系时别再用循环

除了三大基本运算,Set 还提供了集合关系的判断。containsAll可以判断一个集合是否包含另一个集合的全部元素,这在实际开发中非常有用。比如权限系统里,一个角色可能需要同时具备多个权限点才能进入某个页面,如果你用 List,大概率要这样写:

final userPermissions = {'read', 'write', 'delete'}; final requiredPermissions = {'read', 'write'}; bool hasPermission = userPermissions.containsAll(requiredPermissions);

再配合difference,还能知道具体缺哪些权限:

final missing = requiredPermissions.difference(userPermissions); if (missing.isNotEmpty) { print('缺少权限:$missing'); }

这种写法对数学逻辑的映射非常直接:判断集合 A 是否是集合 B 的子集,就是B.containsAll(A);计算 A 比 B 多了什么,就是A.difference(B)。我看过太多人在代码里写嵌套循环去判断“一个列表的每一项是否在另一个列表里”,一旦数据量到几千,性能立刻出现问题。换成 Set 之后,均摊 O(1) 的contains加上集合级操作,简洁性和性能同时拿到。

3.3 把布尔组合改成集合运算,筛选逻辑会变得非常清晰

还有一个我常用的技巧:当 UI 上的筛选条件由多个可选项组成时,与其用一长串 if 判断,不如把条件转成 Set 运算。比如列表页有一个筛选器,用户选了“鸿蒙开发”“Flutter”“Kotlin”三个分类,但需求是“展示同时属于这三个分类的文章”,那你就可以对每篇文章的分类 Set 与所选分类 Set 做containsAll判断。如果需求是“展示属于任一选中分类的文章”,那就把每篇文章的分类集合与选中集合做交集,交集非空即可。

这个思考方式的好处在于:你把“逻辑关系”变成了“集合关系”。数学里的 Venn 图变成了代码里的表达式,阅读代码的人不需要反复在循环里追踪状态,看到intersection、union、difference这些方法名,就能立刻理解业务意图。

4. 可变对象的“幽灵成员”:我调试一晚上才看懂的 Set 陷阱

4.1 插入之后修改对象字段,Set 里就会留下“看得见摸不着”的幽灵

接下来要说的这个坑,是我在写一个图片缓存选择器时真实踩过的。当时我用一个 Set 保存用户当前选中的图片对象,这些图片对象有一个lastModified字段。用户编辑图片时,我直接修改了图片对象的lastModified,然后顺手更新了 Set。诡异的事情发生了:Set 的length明明增加了,但contains判断那张图是否被选中时竟然返回 false。我一度怀疑是 Flutter 的 UI 刷新问题,查了半天,最后才发现罪魁祸首是hashCode字段被改了。

复现这个问题的代码非常简单:

class MutablePoint { int x; int y; MutablePoint(this.x, this.y); @override bool operator ==(Object other) => other is MutablePoint && x == other.x && y == other.y; @override int get hashCode => Object.hash(x, y); } void main() { final pointSet = <MutablePoint>{}; final p = MutablePoint(1, 2); pointSet.add(p); print(pointSet.contains(p)); // true p.x = 99; // 修改了参与 hashCode 的字段 print(pointSet.contains(p)); // false,但 pointSet.length 仍然是 1 }

表面上看,Set 里确实还“存着”这个对象,因为length是 1,但当你拿修改后的对象去查找时,哈希表先根据新的hashCode计算位置,发现那个桶里没有匹配的对象,于是返回 false。这个对象就像幽灵一样留在 Set 里,既无法通过contains找到它,也无法用普通的remove(p)把它删掉,因为remove同样依赖当前的hashCode去找桶。唯一的办法是遍历整个 Set 并清空重建。

这个坑的本质是:可变对象作为 Set 成员后,它的哈希身份已经变了,但它在哈希表中的位置还留在旧地址。数学集合的“元素身份”应该是稳定的,而工程实现依赖哈希值做定位,所以可变对象天然不适合长期充当 Set 的成员。

4.2 规避方案:不可变字段 + copyWith 替换,而不是原地改

从那以后,我给自己定了一条规则:放进 Set 的自定义对象,参与==和hashCode的字段必须是final。如果业务上确实需要修改,就用copyWith生成一个新对象,然后从 Set 中移除旧对象、加入新对象。或者更简单一点,Set 里不要放整个对象,只放对象的稳定唯一标识,比如 id 字符串,需要完整对象时再通过 Map 去拿。

class ImmutablePoint { final int x; final int y; const ImmutablePoint(this.x, this.y); ImmutablePoint copyWith({int? x, int? y}) { return ImmutablePoint(x ?? this.x, y ?? this.y); } @override bool operator ==(Object other) => other is ImmutablePoint && x == other.x && y == other.y; @override int get hashCode => Object.hash(x, y); } void main() { final pointSet = <ImmutablePoint>{}; final p = ImmutablePoint(1, 2); pointSet.add(p); final newP = p.copyWith(x: 99); pointSet.remove(p); pointSet.add(newP); print(pointSet.length); // 1 print(pointSet.contains(newP)); // true }

这种“先移除、后添加”的模式虽然多写了两行代码,但保证了 Set 内部哈希表的索引永远是有效的。用不可变对象作为集合成员,其实也回到了集合论的本质:一个元素一旦进入集合,它的身份就不该变化。想让元素变化,相当于制造了一个新的元素,那就应该以新成员的身份重新进入集合。

4.3 遍历 Set 时也不能直接修改自身

与幽灵成员相关的另一个坑是遍历时修改。Dart 的 Set 在被迭代的过程中,如果你直接添加或者删除元素,会触发并发修改错误,导致运行时异常。我有一次在鸿蒙设备的联系人同步逻辑里,想要把重复联系人从 Set 中剔除,直接在forEach里调用remove,结果应用闪退。

安全做法是先收集要删除的元素,遍历完成后再做删除;或者直接用removeWhere,这个方法专门用于按条件批量删除:

final ids = <String>{'contact_1', 'contact_2', 'contact_3'}; // 错误示范:遍历时直接 remove // ids.forEach((id) { // if (id == 'contact_2') ids.remove(id); // }); // 安全做法 ids.removeWhere((id) => id == 'contact_2');

removeWhere内部处理了迭代与修改的冲突,这也是我建议优先使用它的原因。别小看这个细节,在真实跨端项目里,数据同步往往有上千条记录,一旦闪退,用户看到的就是一次糟糕的体验。

5. 在鸿蒙跨端项目里,Set 该怎么用,又该在哪儿保持克制

5.1 JSON 序列化时先 toList,别让数据在字节流里丢形状

鸿蒙跨端应用免不了与原生侧通信、与服务端交互,而数据的序列化几乎都会走 JSON。Dart 的jsonEncode对List、Map支持得很好,但对Set并不会直接序列化成一个数组。如果你试图把一个 Set 直接塞进jsonEncode,运行时会对集合类型不支持而报错,或者得到的结果不符合预期。稳妥的做法是在序列化前显式地调用.toList()。

import 'dart:convert'; void main() { final tagSet = <String>{'Flutter', 'HarmonyOS', 'Dart'}; final payload = { 'tags': tagSet.toList(), }; print(jsonEncode(payload)); // {"tags":["Flutter","HarmonyOS","Dart"]} }

反过来,从 JSON 解出来的数组默认是 List,如果你希望后续操作享受 Set 的去重和集合运算能力,就再用Set.of或者字面量展开转回来。这个转换非常轻量,但能避免很多因为类型不匹配引起的隐性 bug。我在做配置文件解析时,就见过有人拿 List 去调用 Set 特有的intersection,结果 IDE 直接标红,因为接口根本不存在。

5.2 状态管理里的 Set:更新要产生新集合,而不是原地 add

Flutter 的界面刷新机制依赖“状态变化可检测”。如果在一份状态里保存Set<int>,然后直接对原来的 Set 调用add,哪怕setState被触发,框架也可能因为新旧状态引用相同而无法正确重建依赖它的组件。这是因为默认的Set是可变对象,引用没有变,依赖它的控件无法感知内容变化。

所以我在 StatefulWidget 里保存 Set 时,会刻意使用“不可变更新”的写法:

class SelectionState extends State<SelectionWidget> { Set<String> _selectedIds = <String>{}; void _toggleSelect(String id) { setState(() { if (_selectedIds.contains(id)) { _selectedIds = {..._selectedIds}..remove(id); } else { _selectedIds = {..._selectedIds, id}; } }); } }

这里的{..._selectedIds, id}会创建一个新的 Set,于是新老状态的引用不同,框架就能准确感知变化,触发关联组件刷新。如果用的是状态管理框架,思路也一样:把 Set 的更新看作“生成新的不可变快照”,而不是修改旧的集合。这个习惯对我来说是绕过了很多“页面不刷新”的玄学问题。

5.3 别把 Set 当万能药:有顺序和索引需求时还是回到 List

虽然 Set 很强大,但它毕竟牺牲了顺序和位置访问。如果你需要一个“可去重但必须保持元素原始出现顺序”的场景,Dart 的默认LinkedHashSet其实已经能应付,因为它会维护插入顺序。但如果还要按下标随机访问、频繁取第一个或最后一个元素、做分页切片,那 Set 就不合适了。一个更务实的方案是:底层数据用 List 保存,展示前去重时借助 Set 的中间量,最后再转回 List。

我在鸿蒙跨端应用里处理过图片选择列表,有一些逻辑是按最近添加顺序展示,但同一张图片不能重复添加。这种场景我会先用 List 接收用户操作,等到需要计算“哪些图片是新增的”时,再临时用 Set 做差集,而不是把所有数据都改造成 Set。这个克制很关键,因为集合运算虽然优雅,但过度使用会让代码在某些局部看起来更像数学题,反而增加了其他人阅读时的认知负担。

5.4 小数据量别太纠结,大数据量一定要想清楚哈希分布

最后说一个经常被问到的点:Set 一定比 List 快吗?如果集合里只有几十个元素,List 的contains线性扫描完全可以接受,换成 Set 带来的性能提升感知不强。但如果元素数量上千、上万,或者去重操作发生在高频回调里,Set 的 O(1) 优势就会非常明显。我建议在做性能判断时,先衡量数据规模和调用频率,再来决定是否引入 Set。

另外,自定义对象的hashCode质量直接影响 Set 的哈希分布。如果所有对象的hashCode都返回同一个常数,那整个 Set 会退化成一个链表,所有插入、查找都变成 O(n)。Dart 里用Object.hash组合多个字段,比手动x * 31 + y更不容易出现明显分布问题。这条经验我是在大量日志分析后得出的:哈希函数写不好,Set 再优秀也救不回来。

一点后续想法

第三天的学习让我对 Set 有了新的认识。它不只是一个“去重工具”,而是一种思考数据关系的方式:当业务逻辑涉及“两个集合之间有哪些相同、哪些不同、哪些覆盖关系”时,Set 能直接给出可读性好、性能稳定的解决方案。后续几天我会继续整理 Flutter 在鸿蒙跨端开发中的其他关键容器与状态管理细节,尤其是集合对象在异步和并发场景下的表现。如果你正处在学习 Flutter 的早期阶段,希望这篇笔记能帮你少走几个小时弯路,至少别再被幽灵成员和序列化问题绊住脚。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进