ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter×OpenHarmony实战:家具购买记录App搜索功能全拆解

Flutter×OpenHarmony实战:家具购买记录App搜索功能全拆解 前一百字内带出核心关键词直接以从业者口吻切入。不写主标题从二级标题开始。主体四个H2以上每个下面有H3小节。字数严格超过五千。开头两百字以上。结尾不搞AI总结用个人体会或技巧自然收尾。1. 项目背景与需求拆解家具购买记录App凭什么值得做做这个项目的起因其实很朴素——我自己在装修期间买了大量家具家电从床垫、沙发到灯饰、窗帘十几个订单分散在淘宝、京东、线下门店和闲鱼想查某个东西什么时候买的、保修期到没到、花了多少钱全靠翻聊天记录和订单列表痛苦得想摔手机。跟几个朋友聊了下发现大家都有同样的困扰家居类消费频次低但单价高购买信息往往分散在各种渠道里而且售后周期长家具常见的三包期、保修期动辄一年到五年时间一长根本记不住。所以我才萌生了做一款“家具购买记录App”的念头——核心功能就两个记录和检索。记录端要快三步之内能录入一条购买信息检索端要准输入品牌名、品类、店铺甚至模糊词都能立刻定位到对应的订单和凭证。整个项目我选用了Flutter作为跨平台框架因为购买记录App天然需要多端同步的潜力后续还可能上手表和桌面端而目标系统选择了OpenHarmony一方面是想在这套面向全场景的分布式系统上试试水另一方面OpenHarmony目前的应用生态还处在一个快速找齐边界的阶段提前进入这个赛道对技术积累的价值比做一套纯Android应用高得多。这个App适合谁来参考如果你正好在观望Flutter与OpenHarmony的适配程度或者你正在开发一个以本地数据读写为核心的小工具类应用又或者你只是想知道“搜索”这个看起来平平无奇的功能到底有多少细节可以做砸那这篇实战拆解会比较对你的胃口。我会把搜索功能的前因后果、数据模型、实现逻辑、性能优化和踩坑记录全部过一遍每一步都会讲清楚我当时为什么那样选以及还有哪些备选方案。1.1 需求为什么是“记录搜索”而不是“记账”或“库存管理”一开始我确实想过把产品做成一个轻量级的记账工具归类到“家装支出”这一个分类里。但深入梳理使用场景之后我发现购买记录的诉求跟记账有明显的差异记账关心的是收支平衡和预算而购买记录关心的是“这个东西在哪买的、什么时候买的、保修到什么时候、现在还能不能联系上卖家”。把这两个需求混在一起会让数据模型变得不伦不类——既要维护商户信息又要做类目聚合还得处理售后节点复杂度直接翻倍。所以我最终把范围收敛得很干净每一笔记录就是一次购买行为字段包括商品名称、品牌、品类、购买渠道、购买日期、金额、保修期截止日期、备注以及一到多张凭证照片。搜索功能则围绕这些字段展开核心场景有三个想查某个品牌的所有消费比如输入“慕思”或“顾家”想查某类商品的大概花费比如输入“沙发”或“床垫”想查某段时间买了什么比如输入“2024年6月”或者通过筛选器限定时间区间。搜索看似是输入框加列表展示但当你真正开始做会发现它牵一发而动全身数据表结构要为查询优化、输入事件要做防抖处理、中文匹配要考虑分词和拼音、搜索结果要兼顾排序与高亮、UI列表要避免滚动卡顿。这一整套链路才是这个实战项目的核心价值所在。1.2 数据模型设计搜索功能的地基其实是表结构很多人在做搜索功能时只盯着搜索框和结果页忽略了一个底层问题你的数据模型是否方便被搜索我在这里吃了不小的亏。第一版数据模型我采用了单表设计字段直接铺开class PurchaseRecord { final int id; final String name; // 商品名称如“头层牛皮沙发” final String brand; // 品牌如“顾家家居” final String category; // 品类如“沙发” final String store; // 购买渠道如“天猫顾家旗舰店” final String purchaseDate; final String warrantyDate; final double amount; final String note; final ListString photos; }单表在记录少的时候完全够用搜索时用 SQLite 的LIKE查询就行。但当你录入了一百条以上尤其备注里还有一堆店铺客服的旺旺号、物流单号之后LIKE %关键字%做全表扫描的性能问题就会浮现出来。在真机上实测两百条记录四五万字的文本量级下普通查询还能维持在一两百毫秒内但一旦加上排序、高亮、分组统计就会明显感觉变慢。后来我做了两个关键调整调整一拆分品牌和品类为独立字典表。品牌和品类是高频筛选字段拆出来后可以走索引也方便后续做下拉筛选。搜索“沙发”时先命中品类字典再通过外键关联记录主表性能提升非常明显。调整二引入一个独立的搜索辅助表。这张表专门存放recordId和经过归一化的可搜索文本拼接串把商品名、品牌、品类、店铺、备注拼接成一个长字符串并为它建立 FTS5 全文索引。FTS5 是 SQLite 自带的全文检索扩展在 OpenHarmony 的 Flutter 生态里通过sqflite插件可以正常使用。这样设计的逻辑很简单主表负责完整数据的增删改查辅助表负责“快”两者通过事务保持同步。这个结构性调整解决了搜索功能的“地基”问题。后面所有搜索逻辑都建立在它之上而不是在业务代码里临时拼接查询条件。2. Flutter 与 OpenHarmony 落地前的关键准备把 Flutter 应用跑在 OpenHarmony 上跟跑在 Android 上完全是两码事。先说结论OpenHarmony 目前对 Flutter 的官方支持还在演进中社区方案主要是flutter_flutter这个 OpenHarmony 系的分支仓以及 OpenHarmony 团队维护的 Flutter 适配仓库。我在项目启动阶段最担心的三件事分别是环境能不能顺利搭起来、常用插件能不能用、运行时性能跟 Android 比差多少。一圈实测下来大方向可行但细节处处处是坑。2.1 Flutter 在 OpenHarmony 上的运行机制很多人误以为 Flutter 在 OpenHarmony 上跟 Android 是完全相同的体验其实并不是。Flutter 本身是一个高度自绘的 UI 框架它不依赖系统原生控件所以在 OpenHarmony 上核心渲染引擎可以复用这是 Flutter 跨端最大的优势。但 Flutter 与系统之间的通信渠道也就是 Platform Channel必须针对 OpenHarmony 重新实现一遍。也就是说Flutter 应用的 Dart 代码可以完全不变但凡是牵涉到与原生的交互比如读取相册、访问文件系统、调用系统设置等都必须走 OpenHarmony 侧的 Platform Channel 适配层。这个架构本身是清晰的但问题是 OpenHarmony 生态里的 Flutter 插件还非常少很多在 pub.dev 上随手一搜就能用的插件在 OpenHarmony 上根本没有对应实现。我在这块踩过最典型的坑是path_provider插件。在 Android 上getApplicationDocumentsDirectory()一行代码就能拿到应用私有目录。但在 OpenHarmony 的 Flutter 适配版本中这个插件的 Channel 注册方式不一样直接调用会报MissingPluginException。解决办法有两个一是改用 OpenHarmony 社区适配过的插件版本二是自己实现一个极简的 Platform Channel通过 OpenHarmony 的getFilesDir()能力返回路径。我最终选择了后者代码量不大但完全可控。2.2 环境搭建与工程适配细节环境搭建是第一个劝退点。我的操作环境是 Windows 11OpenHarmony 的 SDK 是通过 DevEco Studio 配套安装的而 Flutter 的 OpenHarmony 版本分支需要从代码仓库拉取并手动编译工具链。整个流程整理下来大约是安装 DevEco Studio 并配置 OpenHarmony SDK拉取flutter的 OpenHarmony 分支代码用这个分支的flutter命令创建项目flutter create --platforms ohos或者手工修改现有 Flutter 工程增加ohos目录配置local.properties指向 OpenHarmony SDK 路径用 DevEco Studio 打开工程并执行构建。如果你是第一次接触建议直接用官方示例工程跑通最快不要在“从零创建工程”这个环节浪费太多时间。我反复创建过三次工程前两次都是因为初始化参数不一致导致中文乱码或依赖拉取失败最后是拷贝了示例工程再改包名反而省事。运行机制上还有一点需要留意OpenHarmony 的 Flutter 应用默认使用其自研的图形栈渲染效果在多数场景下与 Android 一致但部分字体渲染、动画插值器行为会有细微差异。也就是说你在 Android 模拟器上预览完美的东西搬到 OpenHarmony 上可能有轻微的视觉偏移需要以真机为准。2.3 为什么提前规划 Channel 通信在日志级别、文件读写和剪贴板这几类需求上OpenHarmony 的能力跟 Android 并不是一一对应的。我在开发中用到最多的原生能力是“读取系统相册中的图片”因为购买记录需要上传或展示凭证照片。Android 上可以用image_pickerOpenHarmony 上的适配并不好最终我用了自己封装的一个轻量 Channelclass OhosImagePicker { static const _channel MethodChannel(com.record.app/image); static FutureString? pickImage() async { try { return await _channel.invokeMethod(pickImage); } on PlatformException catch (e) { debugPrint(pick image failed: ${e.message}); return null; } } }OpenHarmony 侧则通过继承FlutterPlugin并在OnMethodCall中处理pickImage内部调用其PhotoViewPicker能力。这个 Channel 从写好到跑通前后花了一天半主要耗在排查权限声明上。OpenHarmony 对相册权限的管理比 Android 更细必须在module.json5里配置ohos.permission.READ_IMAGEVIDEO权限少配置一个调用就不会有结果且不报错。注意Channel 名称和两端注册的 handler 必须完全一致。排查这类问题最快的方式是看 DevEco 的日志过滤器关键字MethodChannel能直接定位到有没有收到消息。3. 搜索功能核心实现从输入到结果的全链路搜索功能的主要模块有四块搜索入口与状态管理、检索逻辑、结果列表渲染、空状态与历史记录。每一块看似独立实际耦合在一起。我最终实现的代码路径是输入框文字变化 - 防抖 - 触发 Repository 层查询 - 返回 List - 结果列表渲染并在渲染时高亮命中的关键字。3.1 搜索触发方式防抖搜索与实时搜索怎么平衡搜索框的功能是让用户“快进快出”如果每输入一个字符就立刻跑一次数据库查询体验未必好性能也扛不住。尤其是中文输入法在拼音组合过程中会频繁触发文本变化事件比如输入“shafa”中间会依次冒出“s”“sh”“sha”……这些中间态去查询根本没有意义。我的处理方式是引入 300 毫秒的防抖定时器用户停止输入 300 毫秒后才真正发起搜索。这个值是我实测调出来的太短会频繁触发太长会让人感觉搜索卡。在真机上以拼音输入法连续输入 5 个字符300 毫秒基本能覆盖输入法完成选词的停顿节奏。对这项逻辑的复现可以参考下面的实现class SearchDebouncer { Timer? _timer; void run(VoidCallback action, {int milliseconds 300}) { _timer?.cancel(); _timer Timer(Duration(milliseconds: milliseconds), action); } void dispose() _timer?.cancel(); }另外一种做法是“首次输入立即搜后续输入防抖”听起来更智能但实现复杂度高而且在本地数据库这种低延迟场景下收益不大。除非你后续接后端 API 做云端检索否则统一防抖就够了。3.2 检索实现先用 FTS5 全文索引还是先用 LIKE前文提到我建了一张搜索辅助表这张表的核心字段是search_text里面拼好了所有可搜索字段的归一化文本。搜索时我会判断关键词是否包含多个词比如“顾家 沙发”这种带空格的就拆分成多个词分别匹配再用 AND 组合。FTS5 在 SQLite 中的写法如下SELECT record_id FROM record_fts WHERE record_fts MATCH 顾家 AND 沙发 ORDER BY rank;FTS5 的rank排序非常好用它会把命中词频更高、匹配位置更靠前的记录排在前面。但是FTS5 有一个老问题默认分词器对中文支持不理想。SQLite 默认的unicode61分词器按空格和标点分词中文连续字符串会被当成一个整体搜“沙发”可以命中“真皮沙发”但搜“皮沙”就命不中了。我最终采用了一种折中策略搜索词比较短、比较“明确”的时候走 FTS5但如果搜索词包含 FTS5 分词器处理不了的内容就回退到主表走LIKE模糊匹配。回退条件简单粗暴检测关键词里是否含中文且长度小于等于两个字。因为中文二字词在 FTS5 索引里大概率已经被整个吃进一个 token 里而 LIKE 在记录量不超过两百条的情况下性能完全可接受。LIKE查询的写法上有个性能细节尽量不要写LIKE %关键字%这类写法无法利用索引只能全表扫描。如果条件允许至少把前缀匹配和后缀匹配分开处理。比如搜索“沙发”优先跑LIKE 沙发%匹配前缀再跑LIKE %沙发匹配后缀最后跑LIKE %沙发%匹配中间。这听起来很土但在数据量几千条的量级内前缀匹配能过滤掉大部分数据慢查询概率大幅降低。3.3 搜索结果高亮用户为什么需要看到“哪里命中了”搜索结果的用户体验很大程度上取决于高亮。想象一下你搜“沙发”结果列表里有一条“头层牛皮沙发2024年6月购入”但你的目光扫过整条记录还是不知道系统为什么把它搜出来这种体验非常糟糕。高亮就是要解决这个信息缺口。实现高亮的思路是这样的在搜索结果拿回完整记录后用正则把命中的词条在name、brand、category、note字段中找出来然后用 Flutter 的TextSpan做分段渲染。核心代码如下ListTextSpan buildHighlightSpans(String text, String keyword, TextStyle normalStyle, TextStyle highlightStyle) { if (keyword.isEmpty) { return [TextSpan(text: text, style: normalStyle)]; } final lowerText text.toLowerCase(); final lowerKeyword keyword.toLowerCase(); final spans TextSpan[]; var start 0; while (true) { final index lowerText.indexOf(lowerKeyword, start); if (index -1) { spans.add(TextSpan(text: text.substring(start), style: normalStyle)); break; } if (index start) { spans.add(TextSpan(text: text.substring(start, index), style: normalStyle)); } spans.add(TextSpan( text: text.substring(index, index keyword.length), style: highlightStyle, )); start index keyword.length; } return spans; }高亮的颜色我用了主题的 primaryColor并加了FontWeight.bold这样最醒目。不要用背景色块在滚动列表里带背景色的 TextSpan 很容易出现渲染异常尤其是翻页和复用 Widget 时。另外要提醒的是对note字段做高亮前必须做长度截断。备注可能很长但列表里只需要显示前后各一小段上下文比如命中位置前 10 个字和后 20 个字。不然一个备注上百字的情况下整个列表会被撑得很难看。3.4 中文匹配与归一化为什么“GUJIA”搜不到“顾家”中文搜索场景下最容易被忽略的是“归一化”。家具品牌的输入习惯千奇百怪有人输入“顾家”有人输入“Gujia”有人甚至输入“顾家家居”的拼音首字母“gjjj”。如果系统不做归一化这些用户就会被搜索结果教育得很难受。我的做法是在写入搜索辅助表时把可搜索文本做一次归一化处理英文和数字统一转小写并去掉空格中文保留原样同时生成一份拼音全拼字段和一份拼音首字母字段。也就是说search_text字段拆成了三列text_normalized、text_pinyin、text_initials。搜索时把输入的关键词也做同样的归一化然后三列全部用 FTS5 或 LIKE 尝试匹配。拼音转换我用了lpinyin这个 Dart 包中文转拼音准确率不错多音字处理也基本可用。比如“重庆”会转成“chongqing”搜“cq”也能通过首字母字段命中。这个细节很容易被忽略但做完之后搜索功能的整体可用性会上一个台阶。4. 性能优化与状态管理让搜索不卡的关键操作搜索功能做完并不难难的是在真机上做到不卡、不闪、不崩溃。这一章整理了我做性能优化和状态管理中比较核心的几个决策都是实测得到的经验。4.1 状态管理方案Riverpod、Provider 还是 Bloc搜索功能涉及到的状态其实不多当前关键词、防抖状态、搜索结果列表、是否正在搜索。但如果你后续要加“历史搜索”“筛选条件”“结果排序”状态会越来越多。我从热词里反复看到 Flutter Bloc 和 Cubit说明这套方案确实是大伙儿常用的但我的项目最终选了 Riverpod理由很具体家具购买记录 App 的功能边界清晰Riverpod 的FutureProvider和StateNotifierProvider足够覆盖所有场景Riverpod 的依赖注入方式在写测试时特别舒服我可以直接 mock 一个 Repository 而不需要额外引框架Cubit 其实也不差状态切分直观但样板代码相对多。状态管理这块没有绝对的对错关键是“搜一下”这个操作的每次触发都要保证旧状态被正确清理、新状态能建立起来。以我实现的SearchController为例class SearchController extends StateNotifierSearchState { SearchController(this._repository) : super(const SearchState.initial()); final PurchaseRepository _repository; Futurevoid search(String keyword) async { if (keyword.trim().isEmpty) { state const SearchState.empty(); return; } state const SearchState.loading(); final results await _repository.search(keyword.trim()); state SearchState.success(results); } }状态分的越细UI 越好渲染也越好排查问题。我见过很多项目把 loading、error、empty 全部塞在一个布尔值里管理结果就是各种状态组合导致界面错乱。4.2 搜索结果缓存与异步加载的分寸本地数据库查询虽然快但也不意味着每次都该全量重查。我的做法是同一关键词 5 分钟内的搜索结果直接走内存缓存关键词变化后才重新查询。这个策略主要应对用户切换 App、再回到搜索页的场景避免重复读库。内存缓存的实现很简单用一个MapString, ListPurchaseRecord加时间戳就行。但要防止缓存无限膨胀我设置了最多 20 个 key超过后按时间淘汰最旧的。日常使用中用户最多搜十几个词20 个 key 绰绰有余。异步加载方面我采用“先渲染列表框架再逐条加载图片”的方案。搜索结果是记录列表每条记录都带缩略图如果同步加载全部图片列表会瞬间卡死。我的处理方式是列表项用FutureBuilder包装缩略图图片的加载逻辑放在ImageProvider的缓存层里这样滚动时只有可见的 item 才会真正触发图片解码不可见的会自动取消。4.3 列表渲染与滚动性能差距全在细节里搜索结果的列表项我用了一个自定义 Widget包含商品名、品牌、购买时间和一个缩略图。第一版图省事所有字段用Text嵌套在Column里真机上滑动时肉眼可见有掉帧。后来逐项排查发现真正的问题出在缩略图而不是文本。Image.network或者Image.file如果直接放在列表项里每次重建都会重新解码。正确做法有两个缓存ImageProvider或者使用cached_network_image。本地图片我直接用了FileImage配合自定义 LRU 缓存核心代码如下class ThumbnailCache { static final MapString, ImageProvider _cache {}; static ImageProvider get(String path) { return _cache.putIfAbsent(path, () FileImage(File(path))); } }另一个关键点是列表项的key。搜索列表每次刷新后Widget 会被重建如果没有稳定的keyFlutter 会尝试复用旧 item导致高亮文本、图片错位甚至滚动位置跳跃。我最终为每个列表项指定了记录的唯一 id 作为 ValueKey刷新前后 Widget 复用稳定滑动体验就顺了。5. 常见问题与排查技巧本项目值得记录的几个大坑单列一章写问题排查是因为这个项目的很多问题很有代表性而且网上能找到的 OpenHarmony 相关资料还比较少。以下每个问题都是我实际踩过、并给出解决路径的。5.1 插件 MissingPluginExceptionOpenHarmony 生态最大的痛点做 Flutter 开发的老手都知道MissingPluginException大概是怎么回事Dart 侧调用了一个原生 MethodChannel但原生侧没有对应的实现。在 Android 上这个异常很少出现因为大部分插件都由官方维护。但在 OpenHarmony 上主流插件的适配进度参差不齐这个异常几乎每天都在出现。排查思路很有规律先检查插件有没有 OpenHarmony 适配版本可以在 pub.dev 或 gitee 上搜flutter_packages的相关项目如果插件本身适配了检查工程ohos目录下有没有正确引入插件模块如果插件没适配只能自行实现 Platform Channel。自己实现时注意接口设计要窄不要一上来就做复杂的传参结构先用一个字符串参数跑通链路。我项目中自己封装的 Channel 一共有三个相册图片选择、文件路径获取、震动反馈。每个 Channel 代码量都不大但三个合起来耗费了大约三天时间这在项目规划里是需要提前预留的缓冲期。5.2 中文检索乱码与大小写问题搜索时如果老是搜不到先别急着怀疑 FTS5很可能是大小写和编码问题。我在第一个版本里就出过一次“用大写字母输入品牌名搜不到记录”的搞笑故障。原因也简单辅助表写入时没有统一做小写化查询时又套了一层toLowerCase()两边不对齐。解决办法是确立一个规范写入和查询时都走同一个normalizeKeyword()函数把大写转小写、全角转半角、去除首尾空格。全角转半角这个细节容易被忽略但中文输入法很容易打出全角空格或全角标点不处理就会 “看起来一模一样实际搜不到”。5.3 搜索结果的排序规则 rank 不等于用户直觉FTS5 的rank排序有时会让用户疑惑比如搜“沙发”它可能因为备注里出现了三次“沙发”就排到最前面而一条标题就是“真皮沙发”的记录反而排在后面。用户对排序的直觉非常简单名字里命中的要比备注里命中的更重要。我的最终排序权重如下商品名称精确匹配权重最高品牌精确匹配次之品类匹配再次备注里命中权重最低。实现方式就是给查询结果手动打分不再依赖 FTS5 自带 rank。在数据量不大时手动排比全文检索的排序可控性更好也更符合业务直觉。5.4 真机调试的几个实用技巧OpenHarmony 真机调试跟 Android 类似但有几个细节不太一样。首先日志输出必须用 DevEco Studio 自带的 Log 面板查看print在控制台能看到但debugPrint在某些版本下会丢日志。排查 Channel 通信问题时我在原生侧加了 Log 输出Dart 侧也同步打印两边对比时间戳定位是“没发出去”还是“没接住”效率非常高。其次是断点调试。Flutter 的 Dart 断点在 DevEco Studio 里支持得尚可但如果你同时想在 OpenHarmony 原生侧断点需要在 IDE 里切换调试模式。这有点麻烦所以我大部分原生代码的排查还是靠日志完成。最后是一个很实用的小技巧在开发阶段给搜索框加一个“调试模式开关”开启后会在搜索时把辅助表里的search_text字段打印出来。这让你不用每次去翻数据库就能直观看到系统在拿什么文本做匹配对排查“为什么搜不到”非常有帮助。6. 项目后续可以扩展的方向跟搜索相关的迭代空间其实还有很多我做完了基本功能之后脑子里至少有四个方向没来得及在本期实现写在这里供你评估。方向一搜索历史的个性化推荐。记录用户的搜索关键词高频关键词自动置顶搜索时给出联想建议。这就需要在本地再维护一张搜索历史表并做频次统计。数据结构不复杂但 UI 交互值得花时间打磨。方向二语音搜索。家具购买场景里用户很可能在整理房间或搬运家具时腾不出手来打字。OpenHarmony 的语音识别能力可以通过原生侧接入转发到 Flutter 的 Channel 里。这个功能的关键点在 VAD语音活动检测和结果回传的实时性做成半成品的风险不小。方向三保修到期提醒。搜索功能做完后保修到期提醒是最自然的下一个付费点。它本质上是对warrantyDate字段的定时扫描到了日期就推送通知。在 OpenHarmony 上做本地通知需要适配渠道设置我还没有完整跑通后续如果有进展再单独写一篇。方向四多端同步。如果 App 后续上架 OpenHarmony 手机和手表购买记录和搜索历史需要在设备间同步。一种轻量方案是通过云数据库数据量不大但同步冲突处理要做好另一种方案是用 OpenHarmony 的分布式数据服务这个能力是 OpenHarmony 的差异化卖点也值得一试。我个人在实际操作中的体会是搜索功能往往被低估但它的完成度直接决定了一个记录类 App 的可用性。如果你只是把输入框和结果列表写出来一天就能跑通但你一旦开始抠防抖、抠归一化、抠高亮、抠排序权重、抠列表复用你就会发现每个细节都值得好好打磨。希望这篇基于 Flutter for OpenHarmony 的实战拆解能帮你少走一些弯路。后面有新的踩坑记录我会继续更新。
返回列表