1. 项目定位:Flutter跑在鸿蒙上,到底图什么
1.1 鸿蒙应用开发的三条路线
美妆好物清单这个项目最初的计划里,并没有单独规划鸿蒙版本。手上这个轻量级的工具型应用需要覆盖多个平台,当时最优先考虑的是用Flutter一套代码铺开Android和iOS。后来需求边界变了,鸿蒙用户也要覆盖,于是整个计划就多出来一件事:Flutter能不能顺利跑进鸿蒙生态。
现在要做鸿蒙应用,大致有三条路线。第一条是用ArkTS和ArkUI做原生开发,这条路最稳,但代价也最直接:团队要额外掌握一套语言和框架,业务代码从零另起一份,Android、iOS、鸿蒙三套代码并行维护,成本是实打实的乘法关系。第二条是H5套壳,开发快、上线也快,但交互体验、离线能力和列表滚动流畅度都差一个档次,工具型小应用还能将就,做内容展示类的清单应用,用户大概率不买账。第三条就是我这次走的路线,用跨端框架的鸿蒙适配方案,Flutter层面UI由自绘引擎渲染,平台差异由适配层消化,业务代码基本不用动。
从成本角度看,单独养一条ArkTS开发链路,对大多数中小团队来说负担偏重;Flutter方案等于在已有代码资产上多了一个编译目标,团队还是同一拨人,技能栈没有断裂。这个项目选美妆好物清单,是因为它的体量刚刚好:覆盖首页列表、分类筛选、关键词搜索、详情页、收藏持久化这些典型移动端交互,又不至于一上来就牵扯推送、支付、地图之类重度原生能力,很适合作为探索Flutter鸿蒙适配的练手场景。
1.2 这次做出来的应用是什么
具体功能并不复杂。首页是两列商品卡片流,每张卡片包含商品图、名称、品牌、价格、好评率,右上角有收藏按钮。顶部有一排可横向滚动的分类标签,点“口红”“眼影”“底妆”“护肤”就过滤列表。搜索栏在分类标签上方,输入关键词实时匹配商品名和品牌名。详情页展示大图和完整参数,也带了收藏入口。收藏页集中展示已经收藏的商品,支持取消收藏,空状态时给出引导。
数据来源先用本地JSON模拟,包含几十条商品记录,字段覆盖品牌、分类、价格、评分、图片等。收藏状态写入本地存储,冷启动后能恢复。整体不接后端、不做登录,先把Flutter跨端能力在鸿蒙系统上的表现摸清楚。功能听起来简单,但真正从0跑通,工程结构、权限声明、资源路径、打包签名这些环节每个都有各自的坑。下面按顺序拆开讲,先从环境搭建开始。
2. 鸿蒙适配环境搭建,从零到跑通
2.1 工具链准备与版本对应
想用Flutter做鸿蒙应用,第一步不是写代码,而是确认工具链是齐的。这次环境是三件套:Flutter SDK、鸿蒙开发者IDE、鸿蒙系统SDK。Flutter侧的SDK要选带鸿蒙适配分支的版本,这一点特别容易忽略,以为装上官方稳定版就直接支持鸿蒙设备,结果一看设备列表里根本识别不到。官方版本对鸿蒙的支持一直在演进,但做项目时要用的适配分支得单独选。
版本对应关系是这里最麻烦的事。开发IDE会捆绑特定鸿蒙SDK版本,Flutter适配分支又会依赖某一批原生构建工具链,两边对不上就会出现各种莫名其妙的编译错误。我第一次搭的时候,装了一个稍旧一点的Flutter适配分支,IDE却是新版,结果构建时一直报“找不到HarmonyOS SDK的某个组件”,排查了半天,最后发现就是SDK路径没被Flutter构建脚本正确读取。
我的建议是:装好IDE之后,先记下它自带的鸿蒙SDK版本号,再去找对应日期的Flutter适配分支,宁可都选新的也别混搭。装完后把flutter的bin目录和IDE里的SDK路径都加进系统环境变量,确保命令行工具和IDE能找到同一套依赖。另外,构建链路依赖的Node和Python组件也要提前确认,经常是环境里缺了某个基础组件,编译到一半才报错,非常浪费时间。
2.2 建工程:Flutter工程怎么长出ohos目录
新建项目还是走标准流程,用flutter create生成工程。执行完成之后,项目根目录除了常规的android、ios、web目录,会多出一个ohos目录,这就是鸿蒙侧的原生工程模块,定位类似于android模块在Flutter工程里的角色。
不过这里有个容易踩的细节:标准Flutter工程创建出来后,pubspec.yaml并不会自动带上鸿蒙相关插件依赖,适配层需要的包得手动补。具体补什么,要对照适配分支的文档说明。以这个美妆清单项目为例,实际只加了一个本地存储相关的适配包,其余UI和业务逻辑都是Dart代码,全程没碰原生层。
ohos目录里比较关键的是模块配置文件和入口Ability。初次生成后,包名和应用名都要改一遍,而且是在ohos侧的配置文件里改,不是在AndroidManifest里改。Android和鸿蒙是两套体系,权限、包名、应用图标各管各的,这个认知要尽早建立,否则后面配置权限时会在Android的清单文件里找鸿蒙的权限,找到天荒地老也找不到。
2.3 编译上真机的第一次执行
真机调试的第一步是打开设备的开发者模式,用数据线连接电脑,然后命令行执行flutter devices。如果能列出鸿蒙设备,直接flutter run -d 设备ID就能开始构建。第一次跑的时间会远超预期,原因是构建链路要把Flutter引擎代码和鸿蒙壳工程一起编译,中间还有依赖拉取和缓存构建环节。
我这次跑通第一个Hello World页面,前后花了大概二十分钟,中间遇到两个报错。第一个是SDK版本声明太低,IDE提示需要升级目标SDK版本,在构建配置里把版本号改掉就过了。第二个是签名配置缺失,debug包安装到真机时直接报签名校验失败,这个需要先配置自动签名,否则release包也别想装上。
跑通首屏之后,我建议别急着写业务代码,先在真机上随手划一划,确认滚动、点击这些基础交互的手感没问题。如果连Hello World都卡顿,说明引擎或渲染通道配置有问题,这时候再往上叠加业务页面,问题会被淹没在一堆业务代码里,很难定位。
3. 清单核心功能:数据、列表、筛选与收藏
3.1 商品数据模型与mock数据准备
先定义Product数据类,字段包括id、名称、品牌、分类、价格、好评率、图片标识、是否收藏。Dart语言做JSON序列化非常直接,手写fromJson和toJson两个方法就行,不必为这个规模的轻量项目引入代码生成工具链。
class Product { final String id; final String name; final String brand; final String category; final double price; final double rating; final String imageKey; bool isFavorite; Product({ required this.id, required this.name, required this.brand, required this.category, required this.price, required this.rating, required this.imageKey, this.isFavorite = false, }); factory Product.fromJson(Map<String, dynamic> json) { return Product( id: json['id'] as String, name: json['name'] as String, brand: json['brand'] as String, category: json['category'] as String, price: (json['price'] as num).toDouble(), rating: (json['rating'] as num).toDouble(), imageKey: json['imageKey'] as String, isFavorite: json['isFavorite'] as bool? ?? false, ); } }模拟数据放在assets目录下,用JSON文件维护一批商品记录,运行时通过rootBundle.loadString读取,统一解析成List。真实项目里这一步会换成网络接口,但本地JSON的优势是开发期完全不依赖后端,可以随时改数据验证各种UI状态,比如把价格改成异常值测试显示效果,或者把名称拉长看卡片换行。
图片这块我踩了坑。最初想用网络图,但在鸿蒙模拟器里加载外网图片时页面一直不出图,排查半天确认是网络权限没声明。为了减少开发期的变量,我改成把商品图放在assets里,用本地资源生成卡片封面。后接真实数据源时再切回网络图片,同时把权限补上就行。
3.2 首页卡片流的实现细节
首页主体用CustomScrollView套SliverGrid,两列网格,行距和列距都留了白,卡片是白底圆角加浅阴影。做美妆类清单,两列网格视觉效果比较干净利落,也符合用户对电商类App的审美预期。没有用瀑布流,是因为商品卡片高度差异不大,强行做瀑布流反而显得乱。如果后续商品描述长短差异变大,可以平滑切换到瀑布流容器。
卡片我抽成了一个独立的Widget,避免在build方法里堆大量嵌套布局代码。图片区域固定宽高比,下方放名称、品牌、价格三行,右上角放收藏按钮。这里有一个关键点:收藏按钮被点击后,要回调到上层,由上层去更新数据源,而不是卡片自己改内部状态。否则列表一滚动,卡片被回收重建,状态就丢了,收藏红心会自己跳回去,用户会直接骂人。
Widget buildProductCard(Product product, VoidCallback onFavoriteTap) { return Card( elevation: 2, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ AspectRatio( aspectRatio: 1, child: Image.asset( 'assets/images/${product.imageKey}.png', fit: BoxFit.cover, ), ), Padding( padding: const EdgeInsets.all(8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(product.name, maxLines: 1, overflow: TextOverflow.ellipsis), Text(product.brand, style: TextStyle(color: Colors.grey)), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('¥${product.price}'), IconButton( icon: Icon( product.isFavorite ? Icons.favorite : Icons.favorite_border, ), onPressed: onFavoriteTap, ), ], ), ], ), ), ], ), ); }数据解析不要在build方法里反复做。初始化时解析一次,存到缓存里,列表只是做过滤和排序,这样滑动起来才不卡。这个道理在Android上成立,在鸿蒙上一样成立,Flutter自绘引擎的绘制压力跟重建频率直接挂钩。
3.3 分类筛选与搜索的联动逻辑
筛选和搜索同时存在时,最容易写乱。我的做法是维护两个状态:当前选中的分类、当前搜索关键词。列表实际展示的是先按分类过滤,再按关键词匹配的结果。匹配时除了商品名,品牌字段也一起查,命中率更高,用户搜“兰蔻”能直接找到对应商品。
搜索框用onChanged监听,加一个300毫秒的防抖Timer。如果一敲键盘就重建列表,在低端鸿蒙设备上能明显感到掉帧。TextEditingController配合Timer,逻辑也不复杂:
Timer? _debounce; void onSearchChanged(String query) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 300), () { setState(() { _searchQuery = query.toLowerCase().trim(); }); }); }分类Tab用水平滚动列表实现,选项少的时候不需要引入复杂的状态管理库。整个应用的页面状态其实很浅,setState加一个顶层State管理类就完全够用。这类小工具App不建议一开始就上Provider、Riverpod这类状态管理框架,多一个三方库就多一个鸿蒙适配风险,现阶段尽量用最保守的依赖组合。
3.4 收藏功能与本地持久化
收藏的交互逻辑很简单,但跨页面同步容易出错。我的方案是收藏状态统一存在顶层管理类里,用一个Set保存已收藏的商品id。首页、详情页、收藏页监听同一个状态变更回调,任何页面点了收藏,直接更新Set并通知所有页面刷新。这样不会出现首页收藏了,收藏页没更新的问题。
持久化用shared_preferences在鸿蒙侧的适配实现,把收藏id列表序列化成字符串数组存进去。应用冷启动时先读这个列表,再和商品数据源做一次合并。存储类型是字符串,从数组转Set时要做类型判断,防止老版本缓存数据解崩。
Future<void> toggleFavorite(String productId) async { if (_favoriteIds.contains(productId)) { _favoriteIds.remove(productId); } else { _favoriteIds.add(productId); } await _prefs.setStringList('favorite_ids', _favoriteIds.toList()); notifyListeners(); }收藏页交互也做了一点补充。取消收藏后,列表要实时移除这一项;如果收藏页没有数据,显示一个空状态提示,引导用户去首页逛逛。虽然是小细节,但这类清单应用的用户对收藏的即时反馈非常敏感,宁可多做一步,别让用户觉得按钮失灵。
4. 鸿蒙适配绕不开的硬骨头:安全区、权限和资源路径
4.1 安全区与异形屏适配
Flutter本身对安全区有内置支持,SafeArea组件可以避免内容被刘海屏、状态栏和底部手势条遮挡。但在鸿蒙设备上,我遇到的不只是SafeArea问题:状态栏高度的获取方式、系统导航条的显示与隐藏,不同设备上的表现不完全一致。
我的处理办法是整页最外层包一个SafeArea,把核心内容约束在安全区内;需要沉浸效果的背景,背景色延伸但内容仍然在安全区内。底部操作栏额外留出边距,防止全面屏手势区域误触。这套规则在Android和鸿蒙上可以复用同一套写法,因为Flutter层面的代码拿到的平台视图信息已经由适配层做了一层转换。
有一点要提醒:不要试图用隐藏系统导航栏来换取全屏体验。在鸿蒙设备上,隐藏导航栏容易造成页面底部按钮错位,手势提示条覆盖在列表内容上,反而更难处理。
4.2 权限声明与隐私合规
鸿蒙的权限体系和Android是两套东西,要在ohos侧的模块配置文件里声明权限。这个应用联网加载资源时,需要网络权限;如果要读写相册,还需要存储权限。纯本地Assets应用其实可以做到零权限运行,但开发调试阶段经常会查看缓存文件,所以我把调试用到的权限也一并声明了。
如果应用要上架,隐私政策说明是硬指标。哪怕只是读取本地数据,也要在应用内提供隐私政策入口,说清楚收集了什么信息、为什么收集。之前有应用因为缺少隐私弹窗被驳回,这类问题在审核阶段非常常见。
权限声明这块可以对照这个速查表检查:
| 场景 | 需要配置的内容 |
|---|---|
| 访问网络 | 声明网络权限 |
| 本地缓存与文件读取 | 声明存储读写权限 |
| 隐私合规 | 应用内提供隐私政策入口 |
| 应用信息 | 包名、图标、简介与实际功能保持一致 |
权限申请的最小化原则一定要守住。一个工具型应用,不要上来就申请相机、定位、通讯录,权限越多,审核风险越大,用户安装时的转化率也会掉。
4.3 资源加载与缓存路径差异
资源加载这块有个容易忽略的点:Flutter的Assets在鸿蒙上也是打包进HAP安装包的,代码里用AssetBundle加载的相对路径规则不变。但本地文件系统路径完全不一样,Android的data目录在鸿蒙上根本不存在,硬编码路径一定会崩。
临时缓存、日志文件这类需求,要走鸿蒙侧的沙箱目录。我这次遇到过一次路径问题:想把商品图片缓存到本地,直接写了一个Android逻辑的路径,跑到鸿蒙真机上直接就找不到目录了。Flutter层面拿不到真实沙箱路径时,需要写一个小的平台通道方法,由ohos原生侧返回路径,再回传给Dart层。这也是这个应用里我唯一用到的平台通道,其他UI和业务逻辑全都在Flutter层完成。
路径问题还有一个隐蔽点:不要把界面显示的字符串跟文件路径混用。商品图片这块,我在数据层统一做了映射,界面上只接收最终的图片资源标识,这样以后切换到网络图还是本地图,布局代码都不用动。
5. 打包签名与上架前检查
5.1 从debug到hap包的完整链路
开发阶段一直用flutter run调试,发布阶段就要打HAP包,这是鸿蒙的安装包格式。在IDE里配置自动签名后,执行构建命令,产物生成到build目录下,后缀是.hap。这个包跟Android的APK类似,可以直接安装到真机,也可以提交应用市场审核。
签名环节是坑最密集的地方。自动签名需要登录开发者账号,在后台创建应用、生成证书和Profile,再把证书配置同步到本地工程。如果签名配置和应用包名不一致,安装时会报签名校验失败。这种问题不用改代码,重新走一遍签名配置流程就行。
发布前要把构建模式切到release,确认关闭调试日志和开发态入口。我见过有应用上线后还保留着开发者调试按钮,审核时被直接打回。调试入口这种东西,在开发阶段有多方便,在审核阶段就有多刺眼。
5.2 应用市场审核的常见拒绝点
结合这次实际经历,有几个高频问题值得提前规避。应用名称、图标、简介要和实际功能匹配,素材如果存在知识产权争议会被驳回。隐私政策入口缺失是高频原因,权限申请要最小化,不要一口气把存储、相机、位置全申请了。
空壳应用也过不了审核。如果提交的包首页还是Hello World,明显是测试包,几乎没有通过的可能。哪怕功能简单,也要把核心链路走完整:进入首页、浏览数据、点击详情、收藏再取消收藏,每一步都要顺畅。
版本兼容性说明要如实填写。在应用市场填写适配系统版本范围时,只勾选实际测过的型号和版本。夸大兼容范围,用户下载回来闪退,评分瞬间被拉低,后续再想补救就很被动了。
6. 性能与体验打磨
6.1 首屏速度的取舍
首帧快慢直接决定用户对这个应用的第一印象。为了做首屏优化,我把启动流程拆成了两段:先渲染页面框架和骨架屏,再异步解析JSON数据,用FutureBuilder在数据到位后填充列表。这样首帧渲染不会被数据解析卡住。
不要小看这个改动。在低端鸿蒙设备上,处理几十条JSON也会有几帧明显的卡顿。数据量再大一些,比如上千条商品,解析就有必要放到独立Isolate里做,避免阻塞UI线程。这个美妆清单应用目前规模用不上,但代码结构已经预留了这样的扩展空间。
FutureBuilder<List<Product>>( future: _loadProducts(), builder: (context, snapshot) { if (snapshot.hasData) { return ProductGrid(products: snapshot.data!); } return const Center(child: CircularProgressIndicator()); }, )6.2 图片与内存控制
列表类应用的内存大头基本都在图片上。我在素材阶段把每张商品图压缩到合适尺寸,并且统一调了cacheWidth。设置这个参数后,解码库会按目标宽度做降采样,内存占用能降一大截,但视觉上几乎没有区别。这一招成本极低,收益极高。
列表滚动时的性能,还受卡片复杂度影响。不是非要给每个卡片加复杂阴影和模糊效果,美妆类卡片用了极浅的阴影,保留层次感,又不触发过度绘制。Flutter在鸿蒙上的绘制性能直接跟页面复杂度挂钩,UI简单一点反而更流畅。
最后一轮真机测试,我重点关注了低电量模式、弱网环境下图片占位的表现,确认没有崩溃和明显掉帧,才把它放进发布序列。性能优化这事,永远是在真实设备上测出来的,模拟器上看着流畅,真机上该卡还是卡。
拿Flutter跑鸿蒙,这个方向最值钱的地方不只是省了多少代码量,而是让存量跨平台团队多了一个目标平台,不必从零养一条新的原生开发链路。从我实际操作体会来看,踩坑最多的根本不是Flutter代码本身,而是原生壳工程、签名配置、权限声明、路径差异这些工程化环节。对想尝试类似方向的朋友,我的建议是先拿工具型小项目探路,不用一上来就接推送、登录、支付这些复杂原生能力。把列表、详情、收藏这几个基础场景跑顺,鸿蒙适配的大部分典型问题就都见过了。这个美妆清单应用目前的迭代方向是接真实后端、增加分类管理后台,再往深度链路里加订单流程。等项目再走一轮,我再写一篇复盘,聊聊更复杂的原生插件要怎么在鸿蒙上做适配。