做这个项目的起因其实很朴素:去年旅游的时候,每到一个城市都想找当地的一级博物馆,网上的信息东一块西一块,门票政策、开放时间、镇馆之宝这些关键信息散落在不同的App里,越查越乱。后来我把全国备案博物馆的公开名录整理了一遍,发现数量接近7000家,数据量完全够一个独立App玩起来。跨平台展示这类"信息查询+列表筛选"需求,Flutter本身就是最顺手的工具,但真正麻烦的地方在于鸿蒙——它已经是一个独立生态,不是改改AGP配置就能跑的。这篇文章就是我用Flutter把"全国博物馆查询"从零做到鸿蒙设备上并可上架的完整记录,包括数据怎么建模、查询怎么做、hap包怎么打出来,以及文档里基本不会写的坑。
1. 项目立项时的三个关键判断
1.1 为什么是Flutter而不是原生鸿蒙
鸿蒙原生开发本身并不难,ArkTS加上声明式UI写起来和SwiftUI很像,如果一个应用只在鸿蒙手机、平板、折叠屏上运行,那用原生开发完全没问题。但"全国博物馆查询"从一开始就需要覆盖 Android、iOS、鸿蒙三大平台,甚至后续可能要跑在Windows和macOS的桌面端做数据维护界390面。
Flutter在这类场景下的优势非常直接:一份Dart代码同时编译出各平台的产物,UI层面用Skia/Impeller自绘引擎渲染,不依赖系统组件,因此三端视觉差异可以压到非常小。实际开发中我发现,列表、详情页、搜索交互、设置页这几块写完,Android和iOS端几乎零改动,鸿蒙端也只是做了引擎和插件的适配,业务代码大部分复用。
另一个关键判断是团队成本。如果一个四人小组各维护一套原生代码,数据模型和接口逻辑要去三个仓库同步,这个成本对小工具型App来说是致命的。Flutter把状态管理、网络层、数据缓存、页面路由都收敛在一个工程里,后期维护只需要改一份代码。
1.2 数据是"重"资产,必须一开始就设计好
博物馆查询类App的核心资产不是代码,而是数据。国内博物馆数量多、分布广,不同省份的博物馆命名方式、行政归属、开放情况差异很大。比如"故宫博物院"是部级直属,"某省博物馆"是省文旅厅下属,到县级还有各种专题馆和纪念馆,数据源格式五花八门。
我在立项时定了一条原则:数据层必须和UI层完全解耦。UI永远不直接操作数据源,所有查询都经过仓库层(Repository)转发。这样将来数据源从本地JSON换成云端API,或者从SQLite换成Drift,UI代码都不需要大改。
数据字段设计上,除了常规的馆名、省份、城市、地址、简介之外,我还加了几个容易被忽略的字段:provinceCode用于省市级联筛选,level表示博物馆等级,lat和lng用于地图跳转,ticketPolicy用于存门票政策文本。这些字段在后续开发中帮了大忙。
1.3 App形态:查询工具,不是内容平台
市面上已经有一些文博类App,但很多做成了社区或内容平台,强调UGC、购买门票、预约活动,反而让"查个博物馆信息"这个高频需求被埋没。这个项目的定位非常克制:纯查询工具。打开App第一眼就是全国六大地区分区的博物馆列表,点进详情页能看基本信息,收藏喜欢的馆,其他功能一概不做。
克制的好处是,开发周期可控,审核风险小,产品边界清晰。我把所有额外功能放到后期扩展计划里,先保证核心链路跑通,这也符合小团队做应用的现实路径。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 鸿蒙化Flutter开发环境搭建:比官方文档多走的三步
2.1 基础工具链与国内镜像配置
Flutter本身的环境配置网上教程很多,但做鸿蒙化开发时,有几个环节是和普通Flutter工程不一样的。先列一下我最终使用的工具链:
| 工具 | 版本 | 说明 |
|---|---|---|
| Flutter SDK | 3.22.x(鸿蒙化分支) | 不能直接用官方主线的Flutter SDK |
| OpenHarmony SDK | 5.x | 从DevEco Studio里安装 |
| DevEco Studio | 5.x | 鸿蒙IDE,用于编译hap、签名、跑模拟器 |
| Java | 17 | 鸿蒙工具链依赖 |
| 代码编辑器 | VS Code + Flutter插件 | 写Dart业务代码 |
普通Flutter环境只需要配置PUB_HOSTED_URL和FLUTTER_STORAGE_BASE_URL两个镜像变量,鸿蒙化之后还需要确保SDK路径正确,并且不能用最新版本强行编译,版本要锁定在鸿蒙适配过的分支上。我最初用Flutter 3.22官方版试过一次,跑鸿蒙构建时直接提示找不到鸿蒙工具链,这个问题换到OpenHarmony SIG维护的flutter_flutter仓库后解决。
2.2 鸿蒙Flutter SDK接入与版本锁定
这里有一个很容易踩的坑:Flutter官方SDK不直接支持鸿蒙打包,需要使用OpenHarmony SIG维护的flutter_flutter仓库。我实际操作时是手动克隆flutter_flutter仓库到本地,然后把它作为Flutter SDK使用,而不是通过官方渠道下载。
环境变量方面,我配置了以下内容:
bash复制export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
export OHOS_SDK_HOME=/path/to/DevEcoStudio/sdk
export DEVECO_SDK_HOME=/path/to/DevEcoStudio/sdk
这里必须强调的是,OHOS_SDK_HOME和DEVECO_SDK_HOME不能配错,鸿蒙构建工具会同时读这两个变量。如果只配了其中一个,编译时会报找不到oh-uni-package.json或者hvigor相关错误。版本锁定也很重要,flutter_flutter仓库的不同分支对应不同的OpenHarmony SDK版本,我用的3.22.x分支对应OpenHarmony 5.x,这个对应关系一定要查清楚再动手。
2.3 检验环境通不通:先跑个小Demo
环境配置有没有成功,直接跑大项目很难定位问题。我习惯先创建一个最小Flutter工程,不加任何第三方依赖,然后执行:
bash复制flutter create demo_app
cd demo_app
flutter build hap --debug
如果能顺利在build/hap/outputs目录下看到hap包,说明基础工具链是通的。这一步我花了差不多半天才完全跑通——第一次卡在SDK路径,第二次卡在hvigor版本不匹配,第三次是签名信息没配。前两个问题靠检查环境变量解决,签名问题则需要在DevEco Studio里先配置好自动签名,然后才能用命令行构建。
提示:命令行构建hap前,一定先在DevEco Studio里登录并完成自动签名配置,否则
flutter build hap会一直在签名环节报错。这是命令行构建最容易忽略的一步。
3. 博物馆数据层的建模与离线索引
3.1 数据结构设计:一个字段一个坑
博物馆数据整理阶段,我踩过的坑比写代码阶段多得多。看似简单的"一个博物馆一条记录",实际整理时涉及的字段很复杂。最终我确定的数据结构如下:
dart复制class MuseumModel {
final String id; // 唯一ID,用拼音缩写+序号生成
final String name; // 馆名
final String province; // 省/直辖市/自治区
final String city; // 地级市
final String district; // 区/县
final String address; // 详细地址
final String level; // 一级/二级/三级/未定级
final String openTime; // 开放时间描述
final String ticketPolicy; // 门票政策
final String phone; // 联系电话
final String intro; // 简介
final double lat; // 纬度
final double lng; // 经度
final String imageUrl; // 代表图片URL
}
province这个字段没有直接存省份名称,而是存了省份代码加名称的映射。为什么?因为省份名称存在异写问题,比如"内蒙古自治区"和"内蒙古"不是同一个字符串,直接做筛选时会出现分组丢失。用省级代码做关联字段后,UI展示层再做名称映射,数据层就稳定了。
level字段也需要注意,博物馆等级是动态的,每轮评估都可能变化,所以它只能作为描述性字段存储在单条记录里,不能作为主索引。
3.2 内置JSON还是内置SQLite:6800条记录的选择
数据量级是决定存储方案的关键。我整理完的博物馆记录大约6700条,每条记录平均1.2KB,总体积约8MB。这个规模用JSON直接打包进App完全可行,但缺点是无法做高效的模糊搜索和复杂筛选。
我最终的方案是:安装时第一次启动把JSON灌入SQLite,运行期用SQLite做查询。这样既保证了App首包体积可控,又能在运行时获得接近瞬时的筛选响应。实现上我用的是sqflite插件,配合一个简单的数据库仓库层:
dart复制class MuseumDatabase {
static const _dbName = 'museums.db';
static const _dbVersion = 1;
static Future<Database> _open() async {
final dir = await getDatabasesPath();
final path = '$dir/$_dbName';
return openDatabase(path, version: _dbVersion,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE museums(
id TEXT PRIMARY KEY,
name TEXT,
province TEXT,
city TEXT,
district TEXT,
address TEXT,
level TEXT,
open_time TEXT,
ticket_policy TEXT,
phone TEXT,
intro TEXT,
lat REAL,
lng REAL,
image_url TEXT
)
''');
});
}
}
首次启动导入时,我在单条SQLite插入前做了batch操作,用db.batch()可以大幅减少事务开销,6700条记录导入时间控制在2秒以内。
3.3 数据服务层要预留远程更新通道
本地SQLite解决了"开箱即用"的问题,但博物馆信息是不断变化的——新馆开张、老馆闭馆装修、开放时间调整。我在仓库层设计了一个更新协议:App启动后请求一个远程版本号,如果远程数据版本号高于本地,则下载增量JSON包并更新数据库。
dart复制class MuseumRepository {
Future<void> syncDataIfNeeded() async {
final remoteVersion = await _api.fetchDataVersion();
final localVersion = await _local.getDataVersion();
if (remoteVersion > localVersion) {
final delta = await _api.fetchDeltaData(localVersion);
await _local.applyDelta(delta);
await _local.saveDataVersion(remoteVersion);
}
}
}
这个设计让后续的数据纠错、字段补全都有了出口,不用每次改数据都发新版App。实际操作中,远程文件放在对象存储上,只用一个data_version.json做版本判断,逻辑非常简单。
4. 查询功能的核心实现:从"能查"到"好用"
4.1 大区分组与省-市联动筛选
查询界面是整个App的门面,我采用了两级结构:第一级是地区选择面板,按华东、华北、华中、华南、西南、西北、东北七个大区组织,每个大区下面列出对应省份;第二级是省份进入后的城市筛选栏,再加上一个综合列表。
省级数据的组织在SQL中就完成,避免在Dart侧频繁遍历:
sql复制SELECT province, COUNT(*) as cnt FROM museums GROUP BY province
拿到各省博物馆数量后,UI上可以直接显示"浙江 425家"这样的信息,用户对这个数字是有感知的。市级的筛选用了类似的方式,先查该省的城市列表,再根据城市过滤博物馆。
这里有一个交互细节:筛选条件变化时,列表不需要重新查询整个数据表,而是维护一个WHERE条件的List<String>,最后拼成一个查询语句。实测下来这种动态条件拼接的方式在几千条数据量级上性能完全够用,秒开级别。
4.2 关键字搜索与博物馆级别筛选
搜索功能要兼顾准确性和响应速度。我用的是SQLite的LIKE查询,同时匹配馆名、所在城市、简介三个字段:
dart复制Future<List<MuseumModel>> searchMuseums(String keyword, {String? level}) async {
final db = await MuseumDatabase._open();
final conditions = <String>[];
final args = <Object?>[];
if (keyword.isNotEmpty) {
conditions.add('(name LIKE ? OR city LIKE ? OR intro LIKE ?)');
final likeKeyword = '%$keyword%';
args.addAll([likeKeyword, likeKeyword, likeKeyword]);
}
if (level != null && level.isNotEmpty) {
conditions.add('level = ?');
args.add(level);
}
final where = conditions.isEmpty ? '' : 'WHERE ${conditions.join(' AND ')}';
final result = await db.query('museums', where: where, args: args);
return result.map(MuseumModel.fromMap).toList();
}
用户体验层面,真正的关键操作是"防抖"。用户输入"青铜"两个字,如果每敲一个字母就触发一次SQL查询,那搜索栏会一直闪结果。我用Timer做了300毫秒防抖,只有用户停止输入后才会发起查询,这个细节让搜索体验有了质的提升。
4.3 详情页的信息组织与地图跳转
博物馆详情页的信息组织遵循"上中下"三段式:顶部是馆名+等级+图片,中部是地址、开放时间、门票政策等结构化信息,底部是简介和镇馆之宝介绍。
结构化信息部分我用了一个统一的InfoItem组件:
dart复制class InfoItem extends StatelessWidget {
final IconData icon;
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary),
SizedBox(width: 8),
Expanded(
child: Text('$label:$value', style: const TextStyle(fontSize: 15)),
),
],
);
}
}
地图跳转没有引入重量级的地图SDK,而是用url_launcher直接调起系统地图或高德地图的URL Scheme。这个方案的好处是包体积不受影响,也不需要申请定位权限,用户点击"查看定位"后自动跳到第三方地图App完成导航。
dart复制Future<void> openMap(MuseumModel museum) async {
final url = Uri.encodeComponent('${museum.lat},${museum.lng}');
final amapUrl = 'amapuri://route/plan/?dlat=${museum.lat}&dlon=${museum.lng}&dev=0&t=0';
final webUrl = 'https://uri.amap.com/marker?position=${museum.lng},${museum.lat}&name=$url';
try {
final canLaunchInApp = await canLaunch(amapUrl);
if (canLaunchInApp) {
await launch(amapUrl);
} else {
await launch(webUrl);
}
} catch (_) {
await launch(webUrl);
}
}
这个方案在Android和鸿蒙上都测过,鸿蒙端系统浏览器能正常打开高德网页版定位页。iOS上由于URL Scheme的限制,我直接降级为网页打开,不影响核心功能。
5. 鸿蒙端打包上架的实战记录
5.1 从flutter build apk到flutter build hap
鸿蒙化开发的最后一道坎是打包。正常情况下Flutter工程执行flutter build apk产出Android安装包,鸿蒙化之后执行的是flutter build hap,产出的是鸿蒙应用包hap文件。
第一次执行flutter build hap --release时,我遇到了两个问题。第一个是签名问题,正如前面所说,命令行构建前必须在DevEco Studio中配置好签名;第二个是hvigor打包过程中的依赖下载,网络环境不稳定时容易超时,我最后通过在DevEco Studio中先执行一次构建,让Gradle和hvigor依赖都缓存到本地,再从命令行构建就顺畅多了。
构建成功的hap包位置在:
text复制build/hap/outputs/<module_name>/<module_name>-release-r0.hap
需要注意,release包和debug包的生产路径不同,而且release包在真机安装时要求签名证书与设备信任状态匹配。为了测试方便,我可以先用debug包做功能验证,上架前再统一构建release包。
5.2 鸿蒙运行的坑:第三方插件与权限声明
鸿蒙化最大的坑不在Flutter本身,而在第三方插件的兼容性。由于鸿蒙的API体系和Android不同,很多Flutter插件没有鸿蒙原生实现,运行时直接报MissingPluginException。
我实际遇到并解决的插件问题可以整理成一张表:
| 插件 | 问题 | 解决方案 |
|---|---|---|
| shared_preferences | 官方插件早期版本不支持鸿蒙 | 切换到社区维护的鸿蒙兼容版本 |
| path_provider | 部分API在鸿蒙沙箱路径语义不同 | 改用ohos版插件,封装修复类 |
| url_launcher | 鸿蒙端canLaunch结果不稳定 | 捕获异常并降级为web方式打开 |
| sqflite | 需要确认是否支持鸿蒙 | 在鸿蒙上改用sqflite_ohos或adjust到hive |
权限声明方面,鸿蒙应用需要在module.json5中声明网络权限,否则HTTP请求会被系统拦截。我在开发时发现,App可以联网但请求全部失败,排查半天才发现是权限没声明:
json复制{
"module": {
"name": "entry",
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
}
]
}
}
这个权限声明不写在Flutter的AndroidManifest里,而在鸿蒙工程的entry/src/main/module.json5里,容易漏掉。
5.3 上架前要准备的材料和审查要点
鸿蒙应用市场上架和传统应用商店类似,但有一些自己特有的要求。根据我的实际操作经验,下面这些材料是必须提前准备的:
- 软著证书或版权证明:计算机软件著作权登记证书是必备项,没有软著基本无法过审
- 应用备案号:根据主管部门要求,在国内应用市场分发必须完成ICP备案
- 隐私政策页面:需要能通过App内链接直接访问,且内容要完整
- 应用截图:建议提供鸿蒙真机截图,不同分辨率的设备截图是加分项
- 权限说明:如果有网络定位、存储读写等权限,必须逐项说明用途
审查中比较容易出问题的是"种子用户"和"评论引导"类设计,因为这类行为容易被判定为违规合集,影响过审。我在开发中刻意没有做任何引导评论的视觉元素,避免踩线。
审查阶段还有一点需要注意:应用内不能出现引导下载其它App的入口,不能有跳转未知来源安装包的链接。我的博物馆详情页里其实想加一个"下载官方App购票"的跳转,后来考虑到审核风险,直接把外链入口改成了"复制链接到浏览器打开",稳妥了许多。
6. 性能优化与维护期的反思
6.1 列表卡顿排查全过程
App开发完成后,我在一台老款鸿蒙手机上测试,发现列表快速滑动时掉帧明显。排查过程是这样的:
第一步,怀疑是图片加载问题。博物馆详情页的缩略图来自网络,快速滑动时ListVIew.builder会频繁创建Image组件,导致图片解码压力大。我使用了cached_network_image配合CacheWidth限定图片解码宽度,把加载的位图内存压下来了:
dart复制CachedNetworkImage(
imageUrl: museum.imageUrl,
width: 96,
height: 96,
cacheWidth: 192,
fit: BoxFit.cover,
placeholder: (_, __) => Container(color: Colors.grey[200]),
errorWidget: (_, __, ___) => const Icon(Icons.museum),
)
cacheWidth这个参数特别有效,它从源头减少了解码后的位图内存占用,列表滑动立刻流畅了很多。
第二步,检查列表项的重建频率。给每项加上key和const关键字后,Flutter的Element复利用率提升,掉帧问题明显改善。第三步,用ListView.separated替代了在itemBuilder里手动加分割线的做法,减少不必要的组件层级。
6.2 后续扩展方向:收藏、导览、预约提醒
性能问题解决后,我开始规划这个项目的下一步。收藏功能是最自然的扩展,我已经用shared_preferences存了收藏列表,只存博物馆的ID数组,详情页再根据ID去SQLite查完整数据,不会产生冗余存储。
导览功能方面,我在调研geolocator插件在鸿蒙端的兼容情况,如果定位权限在鸿蒙上可以顺畅申请,就能实现"附近博物馆"功能,按距离排序展示周边的馆。这个功能对旅行场景非常实用。
预约提醒整合了日历接口,可以在用户收藏某家博物馆后,基于其开放时间设置"开馆提醒"。不过这一块需要处理时区、节假日、闭馆日等复杂逻辑,短期内我不会贸然上线,先把基础查询工具打磨好。
维护期我还做了一件事,就是把全部博物馆的位置数据转成GeoJSON格式,放在远程服务器上。将来如果要做博物馆分布地图,直接加载GeoJSON就能渲染出来,不需要二次编码。这个数据格式转换是免费的、一劳永逸的,推荐同类型项目提前做。
我在实际维护中最大的体会是:工具型应用真正考验人的不是功能开发,而是数据持续更新和各类机型适配。博物馆的开馆时间、预约政策、联络电话都可能在一次闭馆整修后全部变化,所以我的数据更新接口特意做成了按字段更新的方式——某一条记录的一个字段变了,只需要传一小段JSON就能打补丁,不用整条替换。这个设计让远程更新对用户的流量消耗几乎可以忽略。
如果你也想做一个类似的跨平台查询工具,Flutter加鸿蒙化这条技术路线是可行的,但一定要在项目早期就把鸿蒙端的构建链路调试通,别把鸿蒙适配拖到开发末尾。等到全部页面写完再回头适配,光插件替换和权限整理就够你忙两周。先让一个最小Demo在鸿蒙真机上跑起来,后面的大工程才有底气推进。
