尧图精选

Flutter实现智能家居设备搜索筛选与分组:架构设计与性能优化

🕒 发布时间:2026/9/14 6:03:57 📁 来源:尧图网络
1. 把找设备和管设备拆成两条链路Day6的边界划分前面五天App 里已经能把局域网里的智能设备发现出来、渲染成列表、点进去看详情。到了第六天产品同事提了两个需求一是设备多了以后得能搜、能筛二是用户想把客厅的灯、卧室的灯分成一组统一控制。听起来是两件事但如果真按直觉去做——在设备列表组件里加个搜索框、加几个筛选按钮、再给设备加个groupName字段——大概率两天后就会陷入改一处崩三处的泥潭。所以 Day6 我做的第一件事不是写代码而是先把边界划清楚。设备搜索筛选指的是在已有的设备集合上通过关键词和多个维度的条件快速把用户想要的那一小撮设备挑出来。分组全流程指的是用户能新建分组、往里塞设备、重命名、删除、把设备在组之间搬来搬去并且这个分组结果要能持久化、重启 App 还在。这两件事在用户体验上是连着的用户搜到设备后往往想归个类但在代码结构上必须是两条独立的链路否则后面会非常难维护。这一篇会把搜索、筛选、分组这三个模块从数据结构、状态管理、匹配算法、性能优化到和 OpenHarmony 侧设备发现链路的对接完整走一遍。适合已经能用 Flutter 写出基础列表页、正准备给智能家居 App 加检索和组织能力的同学如果你正在被几百台设备的列表卡顿折磨第 5 节应该能直接抄作业。1.1 搜索、筛选、分组共用一套状态会出什么问题我先说说最典型的错误做法。很多人在DeviceListPage里搞一个ListDevice devices然后搜索的时候直接devices devices.where(...).toList()筛选的时候再在这个已经过滤过的列表上继续where分组的时候又在这个列表上打标签。这套写法在 Demo 阶段特别爽几乎不用设计但问题会在三个地方同时爆出来。第一是原始数据被覆盖。用户输入客厅列表被过滤成 3 台这时候他把关键词清空你拿什么还原如果没有保留原始集合你会发现列表只剩那 3 台了或者你不得不重新发起一次设备发现请求——而设备发现是有延迟和耗时的用户会看到列表闪一下白。第二是筛选和搜索互相污染。用户先勾了照明分类再搜插座两个条件叠在一起结果为空用户以为是没搜到其实是筛选条件没清。第三是分组视图和查询结果集打架。分组页面需要展示全部设备而设备页此时正处于搜索状态两边共用一个devices变量就会互相覆盖。我最后的做法是把状态拆成三层原始设备集_allDevices只增删改不做任何过滤、查询条件_query一个不可变对象包含关键词和所有筛选维度、查询结果集_visibleDevices由前两者通过一个纯函数计算得出。分层之后任何一次交互都变成改条件 → 重算结果而不是改数据 → 覆盖数据。1.2 原始设备集、查询结果集、分组视图的职责边界把这三层的职责写清楚后面写代码基本不会跑偏。层级数据类型谁改它何时改是否持久化原始设备集MapString, Device设备发现回调、设备属性更新设备上下线、状态上报否可由平台侧重建查询条件DeviceQuery不可变搜索框、筛选栏、分组标签用户交互否会话级查询结果集ListDeviceView纯函数计算原始集或查询条件变化时否派生数据分组数据MapString, DeviceGroup分组操作用户操作后立即持久化是这里有两个关键决策值得展开说。第一原始设备集用 Map 而不是 List。设备 ID 是唯一键Map 的containsKey、[]是 O(1)设备状态上报时按 ID 直接定位不用遍历而 List 在设备数量到几百之后每次属性更新都要firstWhere扫一遍非常浪费。第二查询结果集是派生数据绝不持久化。它只是当前视角任何时刻都能从原始集重算出来持久化它反而会带来一致性问题。分组数据是唯一需要持久化的因为它承载的是用户的组织意图App 重启、设备离线都不应该丢。这一点和第 2 节讲的搜索状态形成鲜明对比搜索词不需要存用户回来看到搜索框是空的才符合预期。1.3 三条链路的数据流与刷新时机数据流理顺之后刷新时机就非常清晰了我把它们列成一张对照表实现时照着对就行。设备发现回调新设备加入→ 更新_allDevices→ 触发一次结果重算。设备状态上报在线变离线、开关状态变化→ 更新_allDevices中对应项 → 如果当前筛选包含在线状态维度触发结果重算否则只做局部刷新。用户输入关键词 → 更新DeviceQuery.keyword带防抖→ 触发结果重算。用户点击筛选 chip → 更新DeviceQuery对应维度 → 触发结果重算。用户新建/删除分组、往组里加设备 → 更新分组数据 → 如果当前视图按分组过滤触发结果重算否则不影响设备列表。提示设备状态上报是高频事件有些设备每秒上报一次如果每次上报都触发全量重算列表会明显抖动。我的处理是给结果重算加一个 16ms 的合并窗口同一帧内的多次更新只算一次。这套边界划分让后面所有的调试都变得可控列表显示不对我只需要问自己三个问题——原始集对不对查询条件对不对纯函数算得对不对三选一几分钟就能定位。相比之下之前那种到处where的写法出错之后只能靠打日志慢慢猜。2. 搜索匹配引擎多字段加权命中与拼音首字母检索搜索框看着简单其实是一个很容易被低估的模块。用户输入的可能是设备名客厅落地灯也可能是拼音首字母ktldd还可能是房间名客厅、设备类型灯、甚至设备编号后四位。如果只做name.contains(keyword)用户在输入拼音时就会觉得这搜索怎么没反应。这一节讲的就是怎么把这几种输入都接住并且让最可能是用户想要的那台排在前面。2.1 输入防抖的参数选型与请求节流先说防抖。这里的防抖分两个层面UI 层的输入防抖和数据层的计算节流。如果设备集合完全在本地内存里计算是同步的那么防抖的意义主要是不让每一帧都重算——中文输入法在组词过程中会触发多次onChanged一次客厅可能触发三到四次每次都跑一遍几百台设备的匹配加排序帧率直接掉。我的选型是250ms的输入防抖。为什么不是常见的 300ms 或 150ms实测下来150ms 对人手输入停顿的容错不够用户打客然后停顿一下打厅两次都可能触发300ms 以上用户会感觉我打完了它才动有轻微延迟感。250ms 是我在一加、小米、华为几台机器上反复试下来手感最稳的值。当然这个值最好做成常量方便根据机型调整。Timer? _debounce; void onKeywordChanged(String value) { _debounce?.cancel(); _debounce Timer(const Duration(milliseconds: 250), () { if (!mounted) return; setState(() { _query _query.copyWith(keyword: value.trim()); }); _recomputeVisibleDevices(); }); }注意setState之前一定要判mounted。搜索框在用户快速切换页面时很容易在 Timer 回调触发前就被销毁不判会直接抛异常而且这个异常在 release 模式下偶尔才复现非常难查。如果设备列表的数据是从平台侧边查边加载的比如设备数量超过内存能舒服承载的量那还需要一层请求节流给每次查询打一个自增序号异步结果回来时只接受序号最新的那一次否则会出现用户搜 a、搜 ab结果 ab 的结果先回来、a 的结果后回来把列表覆盖了的经典乱序问题。2.2 从contains到评分排序命中位置与字段权重单纯contains只能判断有无判断不了哪个更相关。用户搜灯可能匹配二十台设备如果不排序顺序完全取决于设备的插入顺序用户会觉得这个搜索不聪明。我的方案是给每条结果打一个分数按分数降序。int scoreDevice(Device d, String kw) { if (kw.isEmpty) return 0; final k kw.toLowerCase(); var score 0; final name d.name.toLowerCase(); if (name k) { score 1000; // 完全相等最强信号 } else if (name.startsWith(k)) { score 600; // 前缀命中通常就是用户想找的 } else if (name.contains(k)) { score 300 - name.indexOf(k).clamp(0, 20); // 越靠前越相关 } if (d.roomName.toLowerCase().contains(k)) score 120; if (d.categoryName.toLowerCase().contains(k)) score 80; if (d.deviceId.toLowerCase().endsWith(k)) score 60; // 在线设备略微加权避免离线设备长期霸占榜首 if (d.online) score 20; return score; }这里的权重不是拍脑袋的是我根据用户实际点击行为调过的。完全相等给 1000 是因为用户能完整打出设备名说明目标非常明确前缀命中给 600这个档位覆盖了绝大多数打了一半的场景包含命中给 300 并减去位置偏移是为了让客厅台灯排在我在客厅的第三个灯前面。房间名和分类名的权重明显低于设备名因为这种命中往往是顺便匹配上的一旦权重过高用户搜客厅会把整个客厅的设备全顶到最前面反而淹没了真正叫客厅的设备。2.3 拼音首字母与中文模糊匹配的轻量实现拼音匹配是智能家居 App 里性价比极高的一个功能。用户给设备命名主卧床头灯每次打主卧床头灯五个字太累打zwctd显然更爽。我不建议一上来就引入大而全的拼音库很多场景下只需要首字母就够代码量极小。// 轻量首字母表只处理常用汉字的首字母按 Unicode 区间分段 const Mapint, String _boundaries { 0x4E00: a, // 简化处理实际项目里用完整的边界表 // ... 省略中间分段 }; String toPinyinInitials(String source) { final buf StringBuffer(); for (final rune in source.runes) { final ch String.fromCharCode(rune); if (RegExp(r[a-zA-Z0-9]).hasMatch(ch)) { buf.write(ch.toLowerCase()); } else { final initial _lookupInitial(rune); if (initial ! null) buf.write(initial); } } return buf.toString(); }关键点是这个转换结果要缓存。设备的名称基本不会变如果每次搜索都重新算一遍拼音几百台设备乘以每台五六个字一秒钟几十次输入CPU 会很累。我的做法是给每台设备加一个_pinyinCache字段在设备加入原始集时算一次之后复用。实测这一步能省掉 60% 以上的匹配耗时。匹配时把关键词也转成首字母然后和设备的首字母做前缀或包含判断if (d.pinyinInitials.startsWith(k)) score 500; else if (d.pinyinInitials.contains(k)) score 200;权重给到 500 是有讲究的它要低于完整设备名的前缀命中600高于普通包含命中300。因为用户打拼音首字母时目标通常很明确但拼音本身有歧义kt可能是客厅也可能是空调所以不能顶到最高。2.4 命中高亮的数据结构回传列表项上把命中的字符高亮出来是提升搜索体验的一个细节。实现上不要在图层面做文章而是在数据层回传命中区间。我给搜索结果包一层class DeviceView { final Device device; final int score; final ListTextRange highlightsOnName; // 命中的字符区间 final ListTextRange highlightsOnRoom; }TextRange是 Flutter 自带的配合RichText的TextSpan就能实现高亮完全不用第三方库。这里有个坑我踩过首字母命中和原文命中不能同时高亮因为位置对不上。用户打kt原文里根本没有kt这两个字符。我的处理是首字母命中就整段加粗、原文命中才做区间高亮不要混着来。3. 筛选条件的组合模型五个维度交叉过滤不打架筛选比搜索更容易做乱因为它有状态而且是多维度的。用户可能同时勾了照明分类、客厅房间、在线状态还选了某个分组。这四个条件怎么组合、怎么求交、用户怎么知道当前生效了哪些条件、怎么一键清空都是要在设计阶段想清楚的事。3.1 筛选状态的数据结构选型先说数据结构。每个筛选维度的特点不一样不能一刀切。设备类型多选用户可能同时想看灯和开关。用SetString。房间多选同理。用SetString。开关状态在线/离线这两者互斥单选或者用 nullable bool。我最后用了bool?null 表示不筛。分组用户一次只看一个分组用String?。收藏布尔开关用bool。为什么在线状态不用Set因为在线和离线在语义上是互补的同时选上等于没选用集合反而让状态多了一种无意义组合。用bool?三个值在线/离线/全部语义清晰UI 上也能直接映射成三个 chip。immutable class DeviceQuery { final String keyword; final SetString categories; final SetString rooms; final bool? online; final String? groupId; final bool onlyFavorite; const DeviceQuery({ this.keyword , this.categories const {}, this.rooms const {}, this.online, this.groupId, this.onlyFavorite false, }); DeviceQuery copyWith({ /* ... */ }) /* ... */; bool get hasFilter categories.isNotEmpty || rooms.isNotEmpty || online ! null || groupId ! null || onlyFavorite; int get activeFilterCount (categories.isNotEmpty ? 1 : 0) (rooms.isNotEmpty ? 1 : 0) (online ! null ? 1 : 0) (groupId ! null ? 1 : 0) (onlyFavorite ? 1 : 0); }把整个查询条件做成不可变对象是我这次做下来最满意的决策。不可变意味着任何一次修改都是copyWith生成新对象配合和hashCode我可以用一句判断跳过无意义的重算if (newQuery _query) return;这个判断在筛选栏快速点击时能省掉大量重复计算实测下来列表页的 CPU 占用大概降了两成。3.2 多维度求交的顺序与短路优化条件组合的顺序直接影响性能。假设有 500 台设备每个筛选维度分别能过滤掉不同比例最应该先执行的是过滤力度最大、代价最小的那个。测试数据是分类通常能砍掉一半房间能砍掉七成在线状态砍掉大概一成分组砍掉的幅度取决于分组大小小分组可能只剩 5 台。所以我的求交顺序是分组 → 房间 → 分类 → 在线 → 收藏 → 关键词匹配。先把结果集缩到最小后面的关键词匹配就只在小集合上跑评分排序的代价也同步降低。ListDeviceView computeVisible(DeviceQuery q, IterableDevice all) { IterableDevice base all; if (q.groupId ! null) { final ids _groups[q.groupId]?.deviceIds ?? const String{}; base base.where((d) ids.contains(d.id)); } if (q.rooms.isNotEmpty) { base base.where((d) q.rooms.contains(d.roomId)); } if (q.categories.isNotEmpty) { base base.where((d) q.categories.contains(d.category)); } if (q.online ! null) { base base.where((d) d.online q.online); } if (q.onlyFavorite) { base base.where((d) d.favorite); } if (q.keyword.isEmpty) { final list base.toList()..sort(_byDefaultOrder); return list.map((d) DeviceView(d, 0, const [], const [])).toList(); } final scored DeviceView[]; for (final d in base) { final v matchAndScore(d, q.keyword); if (v ! null) scored.add(v); } scored.sort((a, b) b.score.compareTo(a.score)); return scored; }这里有个细节没有关键词时不要走评分逻辑。用户只筛选不搜索的时候默认排序应该按房间、分类、名称来这样相邻设备视觉上聚在一起而不是按一个恒为 0 的分数排序否则列表顺序会显得莫名其妙。提示where是惰性的链式调用不会产生中间列表直到toList()才真正执行。所以上面的写法在求交顺序优化之后其实只遍历了原始集合一次。不要手痒到处.toList()那会把惰性优势全废掉。3.3 筛选与搜索的叠加顺序为什么不能反很多人会把关键词匹配放在最前面觉得搜索是最主要的入口。从性能角度看这是错的关键词匹配每台设备都要跑一遍评分包含拼音、多个字段、多次字符串操作代价远高于简单的集合判断。把它放在最后让前面几层把候选集砍到几十台评分只跑这几十台整体耗时能差一个数量级。从语义角度看更要注意筛选和搜索的叠加必须是与关系而且筛选条件对搜索是可见的。什么意思用户搜灯同时勾了客厅那结果应该是客厅里名字含灯的设备而不是所有含灯的设备然后其中在客厅的排在前面。我见过有的实现把筛选做成了排序而不是过滤结果用户勾了客厅列表里还是能看到卧室的灯只是排在后面用户直接懵了。另一个反面例子是筛选条件在搜索时被临时忽略。有些 App 觉得用户一搜索就说明他要跨筛选找东西于是自动清空筛选。这个行为在当时可能解决了某类需求但它让用户失去了对筛选状态的掌控感用户会怀疑我勾的筛选到底生效没有。我坚持筛选条件始终生效但要有一个非常显眼的当前筛选客厅 ×标签让用户随时能取消。3.4 筛选栏UI与状态的同步与条件回显筛选栏的 UI 同步有两个方向用户操作 → 状态以及状态 → UI 回显。第二个方向特别容易被忽略但它是取消筛选能正确工作的前提。举个例子用户在筛选面板里勾了客厅关闭面板列表变了左上角出现客厅 ×标签。用户点这个 ×筛选被清空此时筛选面板里的客厅 chip 必须同步取消勾选否则用户再打开面板会看到它还亮着以为自己没点成功。要做到这一点筛选面板就不能自己维护一份选中状态而必须完全由DeviceQuery驱动。我在写面板时的原则是面板只负责把用户操作翻译成copyWith不存自己的状态。FilterChip( label: Text(客厅), selected: query.rooms.contains(living_room), onSelected: (sel) { final next SetString.from(query.rooms); sel ? next.add(living_room) : next.remove(living_room); onQueryChanged(query.copyWith(rooms: next)); }, )这段代码里没有任何本地变量selected完全来自query回调只负责算下一份query。这样无论用户是从面板里取消、还是从顶部标签里取消UI 都是同一个来源永远不会不一致。另外那个当前筛选标签区我建议做成横向可滑动的Wrap或者SingleChildScrollView每个生效的条件一个 chip带 ×。这个区域的存在感要强因为它是用户理解为什么列表里没有我想要的东西的唯一线索。4. 设备分组的完整实现模型、增删改与持久化分组是这个 Day 的重头戏也是最容易被低估的部分。设备搜索是瞬时的用户搜完就完了分组是有状态、要持久化、要支持各种编辑操作、还要和筛选联动的它更接近一个完整的领域模型。4.1 分组模型为什么存设备ID集合而不是嵌套对象最直觉的模型是让分组直接持有设备对象列表// 不推荐的写法 class DeviceGroup { final String id; final String name; final ListDevice devices; }这个写法在设备状态变化时会有大麻烦。设备在线状态变了你要去遍历所有分组、找到对应设备、更新它的副本设备被删除了你要去每个分组里删分组数据持久化时你把整台设备的信息包括状态、信号强度这些易变的字段也存进去了重启之后拿到的是过期数据。正确的做法是分组只存 ID 集合设备对象始终只有一份存在原始设备集里immutable class DeviceGroup { final String id; // 分组的唯一标识 final String name; // 分组名如客厅灯光 final int iconCode; // 图标编码不存图标对象 final SetString deviceIds; final int sortOrder; // 手动排序用 final DateTime createdAt; const DeviceGroup({ required this.id, required this.name, this.iconCode 0, this.deviceIds const {}, this.sortOrder 0, required this.createdAt, }); DeviceGroup copyWith({ String? name, int? iconCode, SetString? deviceIds, int? sortOrder, }) DeviceGroup( id: id, name: name ?? this.name, iconCode: iconCode ?? this.iconCode, deviceIds: deviceIds ?? this.deviceIds, sortOrder: sortOrder ?? this.sortOrder, createdAt: createdAt, ); }改成 ID 集合之后设备状态变化完全不影响分组分组持久化只存几十个字符串体积非常小分组内的设备信息要展示时从原始设备集里按 ID 查一下就行。唯一的代价是显示分组内设备列表时需要一次 ID 到设备的映射但这个映射是 O(n) 的而且可以缓存。注意ID 集合一定要用不可变的Set。我在早期版本里直接传了Set的引用结果某处add之后分组的判断失效了UI 没刷新排查了半小时才发现是可变集合导致的哈希不一致。4.2 新建、重命名、删除与设备迁移分组的编辑操作用户感知上是简单的但每个操作都有边界情况。新建分组最容易忽略的是重名。用户建了两个卧室界面上分不清。我的处理是不禁止重名用户可能确实想要卧室灯和卧室插座但要在 UI 上给出提示同时在内部始终用 ID 作为唯一键名称只用于显示。重命名要考虑输入校验空字符串要拒绝或者回退到原名超长要截断我限制在 16 个字符超过后界面上会挤压特殊字符要过滤。这里有个体验细节重命名时不要把光标默认放到末尾应该全选或者放开头因为用户改名往往是从头改。删除分组必须明确一个策略组内的设备要不要一起删答案显然是否定的设备归零是绝对不能接受的。所以删除分组只是删掉分组本身设备原封不动地回到全部设备里。但这里要提醒用户——如果一个设备只属于这一个分组删掉分组后它会不会找不到我的处理是设备列表始终展示全部设备分组只是视图所以不存在找不到的问题。这个设计在用户第一次看到时可能有点疑惑所以我加了一句话说清楚。设备迁移指把一个设备从组 A 移到组 B或者同时属于多个组。设备是否能属于多个分组这是个产品决策。我调研了几款主流智能家居 App结论是允许多归属更符合直觉——客厅的落地灯既是客厅组的成员也可以属于照明组。所以我的数据结构里一个设备 ID 可以出现在多个DeviceGroup.deviceIds里设备详情页的所属分组是一个列表。迁移的代码逻辑就是两次集合操作void moveDevice(String deviceId, String? fromGroupId, String? toGroupId) { final next MapString, DeviceGroup.from(_groups); if (fromGroupId ! null) { final g next[fromGroupId]; if (g ! null) { next[fromGroupId] g.copyWith( deviceIds: {...g.deviceIds}..remove(deviceId), ); } } if (toGroupId ! null) { final g next[toGroupId]; if (g ! null) { next[toGroupId] g.copyWith( deviceIds: {...g.deviceIds}..add(deviceId), ); } } _groups next; _persistGroups(); }注意{...g.deviceIds}..remove(...)这个写法先拷贝再修改保证了不可变性。迁移完成后立即持久化不要等用户退出页面否则 App 被系统回收就丢了。4.3 持久化选型JSON文件、键值库还是关系型存储分组的持久化我对比了三种方案最后选了 JSON 文件但这个选择是有前提的换个场景结论可能不同。方案优点缺点适用场景JSON 文件无依赖、结构直观、易调试、跨平台一致全量读写、数据量大时慢分组数量 200、总数据 几百 KB键值库如 Hive读写快、支持部分更新引入依赖、迁移稍复杂数据经常增量更新关系型存储支持查询、事务、约束结构重、开发成本高分组与设备有复杂关联查询分组数据的规模很小——一个用户最多建几十个分组每个分组几十个 ID整个序列化之后通常不到 100KB。这个量级下JSON 全量读写的耗时在毫秒级完全可以接受。而且 JSON 文件有个巨大优势出问题时可以直接把文件拉出来看比翻数据库快多了。Futurevoid _persistGroups() async { final dir await getApplicationDocumentsDirectory(); final file File(${dir.path}/device_groups.json); final payload { version: 1, groups: _groups.values.map((g) g.toJson()).toList(), }; // 先写临时文件再 rename避免写一半被中断导致文件损坏 final tmp File(${file.path}.tmp); await tmp.writeAsString(jsonEncode(payload)); await tmp.rename(file.path); }这里的version字段和先写临时文件再重命名是两个必须做的防御措施。前者让未来的数据结构升级有路可走读到 version 0 的数据就走迁移逻辑后者保证了写入的原子性——智能家居 App 常常在后台被系统回收写到一半崩掉的概率不低直接覆盖原文件可能把用户的分组全毁了。如果需要更稳的方案可以把分组同步一份到 OpenHarmony 侧的关系型存储里Flutter 侧的内存态作为缓存磁盘上以平台侧为准。这样即使 Flutter 侧的沙箱出问题数据也在。第 6 节会展开讲。4.4 分组作为一级筛选维度的联动逻辑分组做出来之后很自然地要把它做成设备页的一个筛选维度。用户点客厅灯光这个分组列表就只显示组内设备。这个联动有两个要做到位的细节。第一是空分组的处理。用户新建一个分组还没往里加设备点进去应该是空列表加一个去添加设备的引导而不是显示一个错误的无匹配设备。这两种空状态文案不一样用户感受也不一样。第二是分组内设备的排序。分组内设备我建议默认按加入顺序或手动排序而不是按名称因为用户分组往往有意图比如前三个是最常用的灯。如果按名称排序用户的排序意图就丢了。我在DeviceGroup里留了sortOrder字段就是为这个准备的支持长按拖拽调整顺序。还有一个体验上的联动用户在分组视图里搜索时搜索范围应该是分组内还是全部设备我试过两种最后选了分组内因为用户的预期是我在这个组里找东西。但同时我在搜索框旁边保留了一个不起眼的提示在客厅灯光中搜索让用户明确知道当前范围。如果搜不到再给一个在全部设备中搜索的跳转入口这样就两头兼顾了。5. 三百台设备下的性能实战从掉帧到顺滑前面说的都是功能正确性这一节讲性能。智能家居 App 的设备数量差异极大小户型可能十几台别墅或者办公场景动辄几百台。我在测试机上灌了 300 台模拟设备第一版实现直接把列表滑出了明显的掉帧卡顿感在低端机上非常刺眼。这部分记录了我从掉帧到顺滑的完整排查过程。5.1 一次性重建全部卡片的代价实测第一版的问题很典型列表用ListView(children: devices.map(...).toList())每次查询条件变化就把所有卡片全部重建。300 台设备意味着一次要构建 300 个 widget其中大部分根本不在屏幕内。我用 Flutter 的性能面板测了一下一次全量重建的耗时在 60ms 上下——远超一帧 16ms 的预算所以必然掉帧。改法是把ListView换成ListView.builder只构建可见区域的卡片。这一处改动之后单次滚动和条件变化的耗时降到了 8ms 以内。ListView.builder( itemCount: visible.length, itemExtent: 88, // 卡片高度固定直接给出可跳过高度测量 itemBuilder: (ctx, i) { final v visible[i]; return DeviceCard( key: ValueKey(v.device.id), // 用设备 ID 做 key避免复用错位 view: v, ); }, )itemExtent这个参数很多人不填但它的收益很实在给了固定高度Flutter 就不需要为了估算滚动范围而逐个测量 item滚动条长度正确、滑动更跟手。代价是卡片高度必须固定这在设备卡片这种规整布局下不是问题。key也不能省。设备卡片里有开关状态的动画、有高亮的AnimatedContainer如果复用错了对象会出现点 A 灯亮了 B 灯的诡异现象。用ValueKey(device.id)是最稳妥的。5.2 索引缓存与增量更新第二个性能问题是每次查询都要重新遍历原始集。我在 Device 上加了几个派生缓存的字段拼音首字母、小写的名称、小写的房间名算一次存下来查询时直接读。这一步把查询耗时从 25ms 左右压到了 12ms 左右。再进一步是给高频筛选维度建反向索引。分类和房间是低基数的十几个分类、十几个房间可以预先算好MapString, SetString分类 → 设备 ID 集合。这样筛选时直接做集合求交不用遍历设备对象。// 预先维护的反向索引 final MapString, SetString _byRoom {}; final MapString, SetString _byCategory {}; // 筛选时直接求交 SetString? _intersect(SetString? a, SetString? b) { if (a null) return b; if (b null) return a; return a.length b.length ? a.intersection(b) : b.intersection(a); }intersection的实现会遍历较小的那个集合所以我显式判断了长度先遍历小的。这个优化在集合大小差异大时比如房间有 300 台设备、分类只有 5 台效果非常明显。增量更新是另一个关键点。设备状态上报时不要重新算整个结果集只在结果集里找到对应项做局部替换。因为结果集的顺序和内容大多没变重建整个 List 会让所有可见卡片都重新 build 一次。void onDeviceStateUpdated(String id, Device next) { _allDevices[id] next; final idx _visible.indexWhere((v) v.device.id id); if (idx 0) { // 只有状态字段变化就地在结果集里替换 final updated _visible.toList(); updated[idx] updated[idx].withDevice(next); _visible updated; _notify(idx); // 只通知这一项重绘 return; } // 不在结果集里说明状态变化可能影响了筛选结果才走全量重算 if (_query.online ! null) _recomputeVisibleDevices(); }5.3 列表项复用的收敛技巧即使有ListView.builder和key卡片内部如果太重滚动还是会卡。我做了几件事来给卡片减重。第一避免在卡片里做布局计算。卡片内原本用Wrap包了几个标签Wrap的布局成本很高。我改成固定数量的Row加Expanded虽然灵活性差一点但布局快得多。第二图标资源要预解码。设备图标如果是网络图滚动时会不停触发图片解码。我改成启动时把常用图标预解码成ui.Image缓存起来卡片里直接画。第三动画范围要收窄。卡片上有个开关状态的过渡动画第一版用了AnimatedSwitcher包了整个卡片导致状态一变整张卡重绘。改成只包开关那个小图标之后重绘区域小了几十倍。5.4 内存与CPU的观测手段性能优化不能靠感觉我用两个手段来观测。CPU 侧用 Flutter 自带的 DevTools 看帧渲染时间重点关注有没有超过 16ms 的帧。内存侧我在设备集变化的关键节点打印ProcessInfo.currentRss看大列表滚动之后内存有没有持续上涨。实测下来300 台设备加上图标缓存常驻内存大概在 80MB 到 110MB 之间滚动时会有 10MB 左右的波动属于正常范围。如果看到滚动之后内存一路上涨不回落那基本可以确定有对象泄漏重点查Timer有没有取消、StreamSubscription有没有 dispose、AnimationController有没有释放。注意调试时不要用 debug 模式的性能数据做结论。debug 模式下 Dart 是逐行解释执行的帧率天然比 release 低很多。判断卡不卡一定要跑flutter run --release或者出正式包再测我在 debug 模式下优化了半天结果 release 模式下本来就不卡。6. 和OpenHarmony侧设备发现链路对接的细节智能家居 App 的设备来源通常不是本地数据库而是平台侧的发现能力。在这一套里设备的上线、下线、属性变化都是从 OpenHarmony 侧传过来的。搜索筛选分组做在 Flutter 层但如果和底层链路的约定没对齐会出现设备明明在线列表里显示离线这类难查的问题。6.1 上线/下线事件怎么传到Flutter侧设备发现一般有两条通道拉和推。拉是你主动发一次发现请求平台返回当前设备列表推是设备上线或下线时平台主动通知。启动时用拉之后靠推。从 Flutter 到 OpenHarmony 侧的通路我在项目里用的是平台通道MethodChannel和EventChannel。MethodChannel负责命令式的调用发起发现、获取列表、下发控制指令EventChannel负责流式的设备事件。选择EventChannel而不是让侧边反复回调MethodChannel是有理由的设备事件是持续的、无界的用流的方式能让 Dart 侧的StreamBuilder或listen自然处理也能在页面销毁时统一取消订阅。class DeviceDiscoveryChannel { static const _events EventChannel(smarthome/device_events); static const _methods MethodChannel(smarthome/device_methods); StreamDeviceEvent get events _events .receiveBroadcastStream() .map((e) DeviceEvent.fromMap(MapString, dynamic.from(e))); FutureListDevice fetchAll() async { final list await _methods.invokeMethodList(fetchAllDevices); return (list ?? const []) .map((e) Device.fromMap(MapString, dynamic.from(e))) .toList(); } }这里有个容易翻车的点receiveBroadcastStream是广播流多个页面订阅会各自触发底层监听。如果设备页和分组页都订阅底层可能收到两份相同的事件导致设备被处理两次。我的处理是在 App 启动时建立唯一一条订阅把事件写进原始设备集各页面共享这份状态不再各自订阅。6.2 离线设备在搜索与筛选中的处理策略设备离线是常态处理策略直接影响用户对 App 准不准的判断。我的原则是离线设备不删除但状态要如实反映。具体做法有三条。第一设备下线事件来了之后不把它从原始集里删掉只把online改成 false并记录下线时间。第二如果用户当前筛选条件包含在线那这台设备会从结果集里消失这是符合预期的但如果用户没筛在线状态它应该还留在列表里只是显示成灰色的离线态。第三搜索时离线设备依然可以被搜到——用户搜卧室灯是因为他要找这台设备不是因为他只想看在线的把离线设备从搜索结果里藏起来反而让用户以为设备丢了。有个更细的问题离线的设备要不要在排序里降权我在评分里给了在线设备加 20 分就是为了在搜索结果里让在线设备略微靠前。但权重不能太大否则用户搜一个明确的名字结果那台唯一匹配的离线设备被顶到了第二页体验更差。提示设备下线之后如果长时间不再上线可以考虑从原始集里清理掉比如超过 7 天避免原始集无限膨胀。但这个清理策略一定要谨慎最好只在用户长时间不使用 App 后触发不要误删了用户已经分好组但暂时离线的设备。6.3 分组数据放在哪一侧存储更合适分组是纯用户数据和平台侧的设备发现没有直接关系理论上放在 Flutter 侧的沙箱里就够了。我在实现时确实是以 Flutter 侧为主但加了一层同步到平台侧的动作主要是为了两件事。第一是防止 Flutter 侧沙箱被清。用户卸载重装、清理缓存都可能影响 Flutter 侧的数据把分组同步一份到平台侧的关系型存储能在重装后恢复。第二是未来可能的跨端同步。如果以后有多个端手机、平板、车机分组数据放在平台侧更有利于统一。具体做法是把DeviceGroup序列化成 JSON 字符串通过方法调用存到平台侧的一个表里Flutter 侧启动时先读本地本地为空再读平台侧。这个同步是异步的不阻塞 UI失败了也不弹错只记日志。因为它本质上是一个备份性质的功能不应该影响用户当下的使用。7. 踩坑记录几个真实发生过的翻车点这一节把我在 Day6 里真实踩过的坑集中说一下。有些坑在文档里根本找不到只有自己撞上才会知道。7.1 筛选条件残留导致设备凭空消失这是测试同学报的第一个 bug用户搜完灯清空了搜索框结果列表里只剩几台灯其他设备不见了。看日志发现是清空搜索时没有清空筛选条件。用户之前点过照明这个分类筛选清空搜索只是把关键词设成空筛选还在所以列表依然只显示照明设备。这个 bug 的根源是**清空搜索和清空全部条件是两回事但 UI 上没有区分**。我的修复方案是在搜索框右侧加一个清除按钮点击时只清关键词另外在筛选标签区加一个全部清除清掉所有筛选维度。两个操作各有各的入口用户就不会混淆了。同时搜索框的提示文案从搜索设备改成了搜索设备当前筛选照明让用户时刻知道筛选在生效。7.2 防抖写错引发的查询风暴第二个坑是我自己写出来的。防抖的Timer我在TextEditingController的addListener里创建但忘记在dispose里cancel。结果用户在搜索页和详情页之间来回切换几次之后后台积累了十几个Timer每个都在触发查询列表开始疯狂闪烁日志刷得看不清。修复很简单但教训值得记任何Timer都要成对出现创建的地方就要想好在哪里取消。我后来养成了一个习惯在 State 里维护一个ListTimerdispose时统一cancel这样即使漏了某处也能兜住。还有一个更隐蔽的版本防抖的Timer每次都会取消上一次看起来没问题但如果查询本身是异步的比如设备列表从平台侧拉取前一次查询的结果可能在防抖触发之后才回来把最新结果覆盖掉。这个就是 2.1 节提到的乱序问题需要用请求序号来解决光靠防抖是不够的。7.3 设备被删除后分组里的脏数据第三个坑和分组有关。设备从原始集里移除比如用户解绑了一台设备之后分组里的deviceIds还留着这个 ID。用户在分组视图里看到的设备数量是 5点进去只显示了 4 台第 5 台因为查不到设备对象被跳过了数量对不上。处理脏数据有两种思路写入时清理和读取时过滤。我两个都做了。读取时分组视图展示设备前先按原始集过滤一遍保证展示数量正确写入时设备移除的那一刻遍历所有分组把对应 ID 删掉并持久化。读取时过滤保证了即使有历史脏数据也不会出错写入时清理保证了数据不会越积越多。ListDevice resolveGroupDevices(DeviceGroup g, MapString, Device all) { final result Device[]; for (final id in g.deviceIds) { final d all[id]; if (d ! null) result.add(d); // 查不到的静默跳过 } // 按分组的排序意图排 result.sort((a, b) g.sortOrderOf(a.id).compareTo(g.sortOrderOf(b.id))); return result; }7.4 几个能省事的收尾技巧最后分享几个让这块代码更好维护的小技巧。第一给查询条件加一个调试用的 toString。DeviceQuery的toString输出成kw灯, cat[照明], room[客厅], onlinetrue这种紧凑格式出问题时打一行日志就能看清所有条件比逐字段打印快多了。第二把设备匹配逻辑抽成独立的纯函数。matchAndScore(Device, String)不依赖任何 State可以直接写单元测试。我给它写了二十多个用例覆盖中文、拼音、数字、空串、超长串后面改评分权重的时候心里很有底。第三空状态的文案要区分场景。列表为空可能是还没有设备、当前筛选无结果、搜索无结果三种情况文案和引导按钮都不一样。第一种引导添加设备第二种引导清除筛选第三种引导换个关键词。这个细节做不做用户对 App 的懂事程度感知差别很大。第四分组图标不要存图片对象。存一个编码值UI 层做映射。这样持久化的 JSON 里就是一个小整数非常干净将来换一套图标资源也不用迁移数据。这套东西做完之后我最深的体会是搜索、筛选、分组看起来是三个 UI 功能本质上是三个数据模型问题。把原始集、查询条件、结果集、分组数据这四份状态的关系理清楚代码量其实不大而且每加一个筛选维度、每加一种匹配规则都只是往既有框架里填内容不会动到骨架。反过来如果一开始就把它们混在一起后面每加一个需求都要重新想一遍数据从哪来、到哪去那种累才是真的累。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →