把 Flutter 接到 OpenHarmony 上做应用,这几年一直是个既热门又有点尴尬的话题。说热门,是因为鸿蒙生态的设备越来越多,App 开发者的确需要一个跨端方案来降低重复成本;说尴尬,是因为 Flutter 官方并不直接支持 OpenHarmony,真正能跑通的,是社区维护的那条 Flutter 引擎分支。今天这篇是“垃圾分类指南 App 实战”的第三篇,重点聊聊首页该怎么落地,从前期的信息架构、主题适配,到搜索框、分类卡片、轮播知识卡和最近搜索这几个核心模块的实现,再到 OpenHarmony 真机上跑起来会遇到哪些坑。如果你已经用 Flutter 写过几个页面,但从来没碰过 OpenHarmony 分支,或者正准备把一个现有 Flutter 项目迁移到鸿蒙生态,这篇应该能让你少走不少弯路。
1. 首页设计思路:用户打开App的第一眼看到什么
1.1 垃圾分类场景的首页刚需分析
做首页之前,我先把产品诉求捋了一遍。垃圾分类这个 App 的典型用户,绝大多数时候是“分不清某样东西扔哪个桶”,打开 App 的行为非常短暂且目的明确:查一下,马上关掉。这种情况下首页最重要的不是内容多丰富,而是让用户在一屏之内触达查询入口。
同时还要考虑另一类用户:想系统学习分类知识、或者刚刚开始做分类的新手。这类用户会往分类入口、知识模块点。因此首页核心就是两个动作:查(搜索)和学(浏览分类/知识卡片)。
基于这个判断,我把首页拆成了四块:顶部搜索区、四大分类卡片区、知识轮播区、底部 Tab 导航。四大分类对应国内通用的可回收物、有害垃圾、厨余垃圾、其他垃圾。知识卡片可以放一些冷知识,比如“碎玻璃是什么垃圾”“椰子壳是不是厨余垃圾”这种容易混淆的条目,比单纯放新闻公告有用得多。
提示:首页功能不宜贪多。MVP 阶段先保住搜索和分类入口,识别、语音、积分商城这些功能放到后续版本,否则首页会变成一个功能堆砌场,反而让核心诉求被淹没。
1.2 信息层级与布局决策
我的布局顺序是:搜索框置顶,四大分类卡片紧跟其后,再往下是知识轮播和最近搜索。“搜索置顶”这个决策很多人会纠结,觉得太占地方,但我试过把搜索框放到第三四屏的版本,用户停留时间明显变短,因为真正想查东西的人找不到入口,直接放弃了。
四大分类卡片用两列网格展示,每张卡片显示一个类别的名称、典型物品例子和桶的颜色标记。这比四张一行的小图标更有辨识度,尤其对中年用户来说,大卡片点起来更不容易误触。
知识轮播放在第三屏,用横向滑动卡片实现。这个模块的定位是“有则更好”,不承担核心链路,所以不能写死整屏的高度,我用大约 140 到 160 的逻辑像素高度,塞到一屏内不至于把下面的最近搜索挤出首屏。
底部 Tab 我规划了四个:首页、识别、指南、我的。识别这个 Tab 在当前版本只放一个占位页,预留相机和语音输入的扩展位,指南页放分类知识长文。首页作为默认 Tab,整个 App 打开后先渲染的就是首页。
1.3 配色、圆角与卡片规范
垃圾分类 App 的视觉基调不用花哨,主色直接选绿色系,传达环保感。我用的是Color(0xFF2E7D32)这种带一点深沉的绿色做 seed color,再辅助浅绿色背景Color(0xFFF6F8F6),让页面整体有呼吸感。四大分类需要区分度,可以在卡片上给每个类别一个略微不同的辅助色。
圆角我统一用 16 到 20 的弧度,卡片间距 12,页面左右边距 16。这些数值不是拍脑袋定的,我参照了主流生活服务类 App 的间距标准,既保证卡片之间有明确边界,又不会让小屏设备显得拥挤。
组件规范这块,我把卡片、搜索框、按钮的圆角和边框统一收敛到主题里,而不是每个页面各自写死。这样后续改视觉风格只需要动一个文件,避免了重构时在十几个文件里翻圆角参数的痛苦。
2. 工程骨架与主题适配
2.1 目录结构怎么组织
Flutter 项目一旦页面变多,目录乱不乱直接影响到排障效率。我的习惯是按下述方式组织:
lib/ main.dart app.dart theme/ app_theme.dart models/ category.dart knowledge_item.dart search_record.dart pages/ home/ home_page.dart widgets/ search_input.dart category_grid.dart knowledge_carousel.dart recent_search_list.dart identify/ identify_page.dart guide/ guide_page.dart profile/ profile_page.dart data/ categories.dart knowledge_items.dart search_record_store.dartpages/home下面单独开widgets目录,是因为首页的组件足够多,全塞进一个home_page.dart会导致代码上千行,维护体验很差。不过我不建议用 Dart 的part关键字去拆文件,虽然在 OpenHarmony 的 Dart 编译链路里part能用,但它会引入隐式的共享作用域,调试时跳转逻辑反而绕,单独建文件再 import 是更清晰的做法。
data目录放静态数据源和本地存储封装。垃圾分类的类别和知识条目在 MVP 阶段是写死的,不需要后端接口,所以我把它们做成 Dart 常量文件,后续要接服务端时再替换成网络请求即可。
注意:OpenHarmony 分支的 Flutter 工程结构和标准 Flutter 工程基本一致,都是用
flutter create生成后再改造。但入口文件不能照搬 Android/iOS 那套模板,社区维护的分支一般会附带示例工程,建议直接参考示例里的main.dart和app.dart初始化写法。
2.2 底部Tab框架与页面状态保持
底部 Tab 的实现我用了IndexedStack而不是每次切换都Navigator.push新页面。很多从 Android 转过来的同学会把 Tab 每项都当成一个独立页面来做,但这在 Flutter 里有个坑:用Navigator.push的话,每次切 Tab 都会建新 State,页面里列表的滚动位置、搜索框内容全部重置,体验很差。
IndexedStack的做法是四个页面常驻内存,切换时只是改变索引,State 完全保留。首页之前滚动到了第五屏,切去指南页再切回来,位置还在原地,这就是用户想要的“状态不丢”。
class MainShell extends StatefulWidget { const MainShell({super.key}); @override State<MainShell> createState() => _MainShellState(); } class _MainShellState extends State<MainShell> { int _currentIndex = 0; final List<Widget> _pages = const [ HomePage(), IdentifyPage(), GuidePage(), ProfilePage(), ]; @override Widget build(BuildContext context) { return Scaffold( body: IndexedStack( index: _currentIndex, children: _pages, ), bottomNavigationBar: BottomNavigationBar( currentIndex: _currentIndex, onTap: (index) { setState(() => _currentIndex = index); }, type: BottomNavigationBarType.fixed, items: const [ BottomNavigationBarItem(icon: Icon(Icons.home_outlined), label: '首页'), BottomNavigationBarItem(icon: Icon(Icons.camera_alt_outlined), label: '识别'), BottomNavigationBarItem(icon: Icon(Icons.menu_book_outlined), label: '指南'), BottomNavigationBarItem(icon: Icon(Icons.person_outline), label: '我的'), ], ), ); } }IndexedStack的代价是四个页面从 App 启动时就会一起构建,如果某个 Tab 里有重量级地图或视频流,首帧耗时就会被拖上来。我的处理方案是:占位页和轻量页直接放进来,重页后续再改成懒加载。首页在这个版本信息量不大,常驻完全无压力。
BottomNavigationBar 的切换动画在部分 OpenHarmony 真机上会有轻微掉帧,我试过通过ThemeData的pageTransitionsTheme把过渡动画时长调短,效果有改善。如果你对这个动画就是看不顺眼,也可以不用BottomNavigationBar,自己用Row + Expanded搭一个底部栏,点击时只改颜色和索引,没有任何动画,但这属于体验取舍,我当前版本保留了默认动画,因为绝大多数真机上表现是正常的。
2.3 全局主题与组件复用
主题配置我单独放在theme/app_theme.dart,用ThemeData统一管理颜色、输入框样式、卡片样式。这里有个细节:Flutter 版本更新后,CardTheme的类型在 3.27 左右从CardTheme变成了CardThemeData,你要是照抄旧代码编译报错,先看自己的 Flutter 版本再决定用哪个类型,不要盲目升级依赖。
ThemeData buildAppTheme() { final ColorScheme colorScheme = ColorScheme.fromSeed( seedColor: const Color(0xFF2E7D32), brightness: Brightness.light, ); return ThemeData( useMaterial3: true, colorScheme: colorScheme, scaffoldBackgroundColor: const Color(0xFFF6F8F6), appBarTheme: const AppBarTheme( centerTitle: true, elevation: 0, backgroundColor: Colors.transparent, foregroundColor: Color(0xFF1B5E20), ), inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: Colors.white, hintStyle: const TextStyle(color: Color(0xFF9E9E9E)), border: OutlineInputBorder( borderRadius: BorderRadius.circular(30), borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), cardTheme: CardThemeData( elevation: 0, margin: EdgeInsets.zero, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), color: Colors.white, ), ); }这套主题的好处是,搜索框和卡片在首页以外的地方也能直接继承样式。比如指南页的条目卡片、我的页的个人信息卡,都不用重新定义圆角和背景色,只要在业务页面里用Card组件就能保持视觉统一。
动态字体方面,“我的”页会有字号设置的入口,但首页目前不需要特殊处理,保持系统默认字号即可。如果你要支持大字体,建议所有组件都用textScaleFactor做一次边界测试,尤其是分类卡片文字是否溢出、搜索框 hint 是否被截断,这类问题在鸿蒙设备上出现过,因为我测试机默认字号调大后,卡片标题差点被挤出外层容器。
3. 首页核心模块的完整实现
3.1 搜索框:防抖、清空与键盘处理
搜索框是首页的灵魂,我单独封装成SearchInput组件。除了基础的输入框样式,还需要处理三件事:防抖、一键清空、键盘搜索键触发。
防抖不是先例,大家都懂:用户连续输入“电”“电池”“电池”的过程里,如果每敲一个字都去触发搜索,既浪费资源又会让结果闪来闪去。我设置 500 毫秒的延迟,用户停止输入半秒后才真正发起查询。
class SearchInput extends StatefulWidget { const SearchInput({ super.key, required this.onSearch, required this.onClear, }); final ValueChanged<String> onSearch; final VoidCallback onClear; @override State<SearchInput> createState() => _SearchInputState(); } class _SearchInputState extends State<SearchInput> { final TextEditingController _controller = TextEditingController(); Timer? _debounce; @override void dispose() { _debounce?.cancel(); _controller.dispose(); super.dispose(); } void _handleChanged(String text) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 500), () { if (text.trim().isEmpty) return; widget.onSearch(text.trim()); }); } void _handleSubmitted(String text) { _debounce?.cancel(); widget.onSearch(text.trim()); } @override Widget build(BuildContext context) { return TextField( controller: _controller, onChanged: _handleChanged, textInputAction: TextInputAction.search, onSubmitted: _handleSubmitted, decoration: InputDecoration( hintText: '搜索垃圾名称,比如“电池”', prefixIcon: const Icon(Icons.search), suffixIcon: ValueListenableBuilder<TextEditingValue>( valueListenable: _controller, builder: (context, value, _) { if (value.text.isEmpty) { return const SizedBox.shrink(); } return IconButton( icon: const Icon(Icons.cancel), onPressed: () { _controller.clear(); widget.onClear(); }, ); }, ), ), ); } }清空按钮这里有两个实现方案:简单做法是suffixIcon直接判断_controller.text.isEmpty,但这样点击清空后按钮不会立刻消失,因为TextField没有触发 rebuild。我用ValueListenableBuilder监听TextEditingController的值变化,输入一清空按钮就自动隐藏,交互反馈更即时。
键盘的处理要注意textInputAction必须设置为TextInputAction.search,这样软键盘右下角会变成搜索按钮,用户输完可以直接点键盘搜索键触发查询。在 OpenHarmony 真机上,输入法的弹起速度在不同机型上有差异,我建议在Scaffold上用resizeToAvoidBottomInset: false做一次测试,如果搜索框在键盘弹起后被顶到屏幕中上方,就用默认的 true,否则交互会奇怪。
3.2 分类入口:两列卡片网格
四大分类卡片我用GridView.builder实现,放在页面主体靠上的位置。因为首页整体是纵向滚动,GridView嵌套在SingleChildScrollView或者CustomScrollView里时,必须设置shrinkWrap: true和physics: NeverScrollableScrollPhysics(),让它变成一个不自带滚动的网格,跟着父级列表一起滚。
class CategoryGrid extends StatelessWidget { const CategoryGrid({ super.key, required this.categories, required this.onTap, }); final List<Category> categories; final ValueChanged<Category> onTap; @override Widget build(BuildContext context) { return GridView.builder( padding: const EdgeInsets.symmetric(horizontal: 16), shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: 1.7, ), itemCount: categories.length, itemBuilder: (context, index) { final category = categories[index]; return _CategoryCard( category: category, onTap: () => onTap(category), ); }, ); } }childAspectRatio: 1.7是经过调试的:卡片太扁会显得拥挤,太高又会把知识轮播挤到第二屏之外。1.7 在大多数手机宽度下,卡片高度大约是宽度的 0.59 倍,刚好容纳两行文字加一个小图标。
每张卡片的内容我放了类别名、一句典型物品提示和一个类别图标。比如“可回收垃圾”对应“塑料瓶、纸箱、金属罐”,“有害垃圾”对应“电池、过期药品、废灯管”。这些描述字很小,但比只放一个分类名词有引导性,用户在不确定“杀虫剂”属于哪类时,光看“有害垃圾”卡片上的“过期药品”就能建立联想。
点击卡片的跳转逻辑里,我传了一个完整的Category对象给详情页,而不是只传 id 再让详情页自己拉数据。这个细节是因为分类数据量小,建模简单,传对象能省去详情页的加载等待,也避免索引越界问题。
3.3 知识轮播:PageView与定时器配合
知识轮播展示的是垃圾分类中容易搞错的冷知识,比如“大棒骨不是厨余垃圾而是其他垃圾”“榴莲壳也是其他垃圾”。这类内容有传播性,能让用户产生“原来如此”的感觉,也顺带推动分享。
轮播组件我用PageView实现,每个知识条目是一张卡片,支持用户手动滑动,滑到边缘后不会停住,而是用定时器自动轮播。核心代码是定时器控制页面切换到下一张:
class KnowledgeCarousel extends StatefulWidget { const KnowledgeCarousel({super.key, required this.items}); final List<KnowledgeItem> items; @override State<KnowledgeCarousel> createState() => _KnowledgeCarouselState(); } class _KnowledgeCarouselState extends State<KnowledgeCarousel> { late final PageController _pageController; Timer? _timer; int _currentPage = 0; @override void initState() { super.initState(); _pageController = PageController(viewportFraction: 0.9); _startAutoPlay(); } @override void dispose() { _timer?.cancel(); _pageController.dispose(); super.dispose(); } void _startAutoPlay() { _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 4), (timer) { if (!_pageController.hasClients) return; final next = (_currentPage + 1) % widget.items.length; _pageController.animateToPage( next, duration: const Duration(milliseconds: 400), curve: Curves.easeInOut, ); }); } @override Widget build(BuildContext context) { return SizedBox( height: 140, child: PageView.builder( controller: _pageController, itemCount: widget.items.length, onPageChanged: (index) { setState(() => _currentPage = index); }, itemBuilder: (context, index) { final item = widget.items[index]; return Card( margin: const EdgeInsets.symmetric(horizontal: 6), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(item.title, style: const TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), Expanded( child: Text( item.desc, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Color(0xFF616161)), ), ), ], ), ), ); }, ), ); } }这里有几个容易踩的坑。第一个是Timer.periodic必须在dispose里取消,否则页面销毁后定时器还在跑,触发animateToPage时PageController已经释放,控制台会红屏报错。第二个是viewportFraction: 0.9,让左右两张卡片露出一部分边缘,用户一眼能看出这组卡片是可以左右滑的,这种视觉提示比任何引导文案都直观。第三个是判断hasClients,因为PageController在 widget 还未附加到树上的时候调用会产生异常,加个防御更稳妥。
另一个细节是轮播卡片内容超过两行时做了ellipsis截断,标题加粗、描述用普通灰色。这种设计保证卡片高度统一,轮播切换时不会因为文字长度不同而出现跳动。
3.4 最近搜索:落盘、读取与空态
最近搜索这个模块看起来简单,但直接影响用户的重复查询效率。我实现了一个SearchRecordStore类,内部用shared_preferences存一个最多保留 10 条的最近搜索列表,去重后新的排最前面。因为 OpenHarmony 分支的shared_preferences不是官方直接支持的,需要引入适配版插件,社区维护的 flutter 包仓库里有对应的shared_preferences_ohos,在pubspec.yaml里做依赖替换即可。
class SearchRecordStore { static const String _key = 'recent_search_records'; static const int _maxCount = 10; Future<List<String>> load() async { final prefs = await SharedPreferences.getInstance(); final list = prefs.getStringList(_key) ?? []; return list; } Future<void> add(String keyword) async { final prefs = await SharedPreferences.getInstance(); final list = prefs.getStringList(_key) ?? []; list.remove(keyword); list.insert(0, keyword); if (list.length > _maxCount) { list.removeRange(_maxCount, list.length); } await prefs.setStringList(_key, list); } Future<void> clear() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_key); } }最近搜索列表的 UI 用ListView.builder渲染,每条前面放一个历史图标,右侧放删除单个条目的按钮。当列表为空时,显示“暂无查询记录,去搜索框试试吧”的空态文案,并隐藏删除全部按钮。
这个模块有一个体验细节:搜索成功后,不要立即setState刷新列表,而是等搜索结果的页面切换完成后,再异步从SharedPreferences里拉一遍。原因是当前页面是首页,如果用户在结果页里反复搜索,首页不必每次都同步刷新,这样也能避免过度重建。
4. 数据源选择与状态管理落地
4.1 静态JSON还是轻量数据库
MVP 阶段的首页数据来源有两种选择:Dart 常量文件和 JSON 资源文件。
我最终选择 Dart 常量文件,比如data/categories.dart里直接放一个List<Category>,而不是从assets/json/categories.json里加载。原因是分类和知识条目都是静态的、体量极小,用常量文件可以免去rootBundle.loadString的异步解析过程,代码里也方便直接使用Category对象,类型提示完整。
当然这也有个前提:数据更新频率极低,不需要走热更新。如果后续要做运营后台,分类数据每天变,那就要切到 JSON 资源 + 远端更新方案。我的建议是数据层做一层抽象,CategoryRepository现在返回静态数据,后续换成网络数据,页面代码完全不用动,这是个常规但容易忽视的设计。
4.2 状态管理选型:MVP阶段别过度设计
首页在这个版本里的共享状态其实很少:最近搜索列表变化、搜索框清空状态、轮播页索引。我用ChangeNotifierProvider加provider包来管理,没有上bloc或Riverpod,不是因为哪个框架不好,而是 MVP 阶段的状态流还没复杂到需要额外引入模板代码。
以最近搜索列表为例,我用HomeController extends ChangeNotifier持有recentSearches列表和loading标志位,页面通过context.watch<HomeController>()监听变化。这种做法在 Flutter 里是最接近“够用就好”的状态管理,没有冗余概念,OpenHarmony 分支也完全支持。
如果你是从bloc转过来的兄弟,建议克制一下。首页这种单一页面用bloc会造成事件和状态的大量空转,尤其是搜索防抖和轮播定时器这类纯 UI 逻辑,硬放进bloc里反而难调试。
4.3 Loading、Error、Empty三种状态的兜底
首页虽然大多是静态数据,但SharedPreferences读取是异步的,搜索也是异步的,所以页面仍然需要处理加载中、失败、空数据三种状态。
最近搜索列表加载时,我用一个半透明的占位骨架,而不是转圈。骨架屏在 Flutter 里实现很简单:
if (_controller.loading) { return const SizedBox( height: 140, child: Center(child: CircularProgressIndicator()), ); } if (_controller.recentSearches.isEmpty) { return const SizedBox( height: 140, child: Center( child: Text( '暂无查询记录,去搜索框试试吧', style: TextStyle(color: Colors.grey), ), ), ); }空态文案其实也是产品细节,尽量不用“暂无数据”这种开发语气,改成“去搜索框试试吧”能引导用户行动。数据加载失败的情况在这个版本极少,但我在SearchRecordStore.load外层加了 try-catch,失败时返回空列表而不是抛异常,页面不至于白屏。
在实际开发时,这三种状态往往被忽略到最后一刻,结果真机测试时偶尔看到空白一块,排查半天才发现是异步没到位。建议从第一天就把三态写进组件里,哪怕数据是同步的,也预留好接口。
5. OpenHarmony适配踩坑与真机调试
5.1 跑起来之前的环境检查清单
Flutter 在 OpenHarmony 上的开发流程和标准 Flutter 有一个明显的分叉点:SDK 路径和工具链。我用的是社区维护的 Flutter OpenHarmony 分支,开发工具是 OpenHarmony 官方的 IDE 加命令行 Flutter。第一次跑真机时最容易踩的坑是flutter doctor检测不到 OpenHarmony SDK。
我整理了开发环境检查清单:
- DEVECO_SDK_HOME 环境变量是否指向已安装的 OpenHarmony SDK 目录。
- 命令行里
flutter doctor -v是否能看到 OpenHarmony 相关项通过。 - 真机是否开启开发者模式,以及是否通过 hdc 连接成功。
- 工程里
build.gradle和entry/src/main/ohos/module.json5里的包名是否与签名证书一致。
这四项缺一个都会让flutter run在最后一步失败。我项目刚迁移时跳过第二项,一直以为配置没问题,结果编译时报“找不到 OpenHarmony SDK 平台”,回头看是环境变量没生效,终端里 export 完直接退了 shell,重新打开又变回去。解决办法就是把它写进 shell 配置文件里,一劳永逸。
5.2 真机运行常见问题速查
我在多台 OpenHarmony 真机上跑过首页,整理了一份问题速查表,给同路人参考:
| 现象 | 可能原因 | 修复思路 |
|---|---|---|
flutter run找不到设备 | hdc 服务未启动或 USB 调试授权未通过 | 先执行hdc list targets,确认设备在线后再跑 Flutter |
| 首页白屏 | 入口页面未在main_pages.json注册 | 打开entry/src/main/resources/base/profile/main_pages.json,把首页路由加进去 |
| 图片资源不显示 | 资源路径挂载错误 | 检查pubspec.yaml的 assets 路径和文件名大小写 |
| 搜索键盘无法弹出 | 输入法框架与 App 冲突 | 升级 Flutter OpenHarmony 分支版本,或临时切换默认输入法 |
| 热重载失效 | 修改了原生侧的 OpenHarmony 代码 | 重启应用,部分原生改动必须重新编译 |
| 启动黑屏后恢复 | 引擎首次初始化较慢 | 用真机性能模式测试,确保设备未进入省电模式 |
这里面main_pages.json的白屏问题我印象最深。第一次打包安装后,应用图标一点开就是白屏,日志里没有任何异常,查了半天发现自己把首页路由写成了根节点/,但main_pages.json里没有注册MainAbility对应的pages/Index。这个文件是 OpenHarmony 工程的入口配置,标准 Flutter 里压根没有,所以特别容易漏。
另一个和标准 Flutter 差异明显的地方是日志工具。OpenHarmony 真机调试用hdc hilog而不是adb logcat,很多搜索代码用print打的日志,在 hilog 里按 tag 过滤时看不到,我最后统一用debugPrint加前缀的方式才在日志里找到页面刷新失败的原因。
5.3 首页性能调优的两个方向
首页在 OpenHarmony 设备上的性能问题,我实际遇到的主要集中在两个方面:首帧渲染和列表滑动流畅度。
首帧方面,影响最大的是主页组件树复杂度。IndexedStack四个页面同时构建,首页虽然轻,但指南页和我的页带了一些静态列表,如果这些页面里混入了高分辨率图片,首帧会被拖慢。我的做法是把首页之外的 Tab 页内容换成占位骨架,只保留页面标题和少量说明文字,真正的内容等用户切到对应 Tab 再初始化。骨架屏和真实页面之间切换用淡入动画,用户感知不到好坏差异,但首屏时间能明显缩短。
滑动流畅度方面,我遇到过一个奇怪现象:首页CustomScrollView往下滚动时,到了知识轮播区域会偶尔卡顿一下。排查后发现是轮播的PageView在滚动过程中会和外层滚动冲突,造成重复曝光和布局计算。我用NeverScrollableScrollPhysics限制内层滑动,把PageView的滚动能力关掉一部分,只在用户手动横滑时才激活,纵滑时轮播不参与手势竞争,卡顿就消失了。
渲染引擎上,Flutter 分支里 Impeller 默认启用,但部分 OpenHarmony 设备的 GPU 驱动对 Impeller 支持不完整,会出现轻微闪烁。实测下来,如果遇到闪烁,可以在启动时使用--no-enable-impeller关闭 Impeller 回退到 Skia。代价是渲染性能略降,但稳定优先,后续等社区分支把 Impeller 的 OpenHarmony 适配打磨好再切回去。
另外,如果你后续要把首页的搜索事件通过EventChannel发给 OpenHarmony 原生侧(比如接入系统级语音识别),需要留意 OpenHarmony 的 channel 注册方式和 Android 不一样,不能直接在MainActivity里注册,要在Ability的OnStart里通过FlutterEngine拿到 channel 实例。这个和标准 Flutter 的差异比较隐蔽,我下一篇写识别模块时再展开。
最后补一个我觉得特别值得记住的实践:不要把 OpenHarmony 当成 Android 的平替来写。虽然 Flutter 帮你屏蔽了 90% 的差异,但那 10% 的差异——插件适配、生命周期、开发者工具——才是决定项目能不能落地的部分。首页只是一切开始,等把平台通道、系统相机这些能力真正接进来时,坑只会更多,但也只有蹚过去才能把这套跨端方案的价值吃透。当前版本的首页已经能跑稳了,下一步我打算把识别模块接到系统相机上,做一个拍照识别垃圾类别的功能,那时候平台通道的事情我再来补一篇实战记录。