多城市天气首页这个需求,做过的同学应该都有体会:城市一多,GridView把每个格子都排成一样高,北京晴天、广州雷雨、拉萨多云全挤在同等大小的方块里,视觉上又呆又难突出重点。后来我在鸿蒙版Flutter项目里试着引入flutter_staggered_grid_view这个三方库,用交错网格重新排布城市卡片,整个页面一下子舒服多了。这篇文章就把我从选型、集成到在鸿蒙环境里踩坑的完整过程写下来,适合正在做鸿蒙Flutter应用、想让首页卡片有层次感的开发者,也适合刚接触Flutter三方库集成、担心鸿蒙兼容性的同学参考。
1. 为什么是Staggered Grid View:多城市卡片形态的选型思考
1.1 多城市天气首页的卡片形态痛点
天气应用的多城市首页,信息天然是不对等的。默认城市要展示当前温度、天气描述、空气质量、未来一周预报,信息量很大;其他收藏城市只需要给出城市名、温度和一两个关键指标就够了。如果所有城市都用同一种卡片,默认城市的信息放不下,次要城市又显得空旷。
我之前第一版用的是Flutter自带的GridView.count,把每个格子做成固定高度,默认城市在卡片内硬塞更多内容,结果小屏手机上文字都快挤到一起了。其实这个问题的本质,是网格单元尺寸不够灵活。GridView从设计上就要求每个cell横纵尺寸一致,想做成“有的占两列、有的占一列、有的高度跟着内容走”的形态,就得手工去拼Row、Column、Stack,还要处理不同屏幕宽度下的跨列计算,代码写起来非常啰嗦,维护成本也高。
对比一下更能看清:普通GridView像超市里的标准货架,每格一样大,好处是整齐,坏处是没法根据商品大小调整陈列;而交错网格(Staggered Grid)更像设计展的展示台,大件占两格、小件占一格,高矮错落,排出来的页面有节奏感。对天气首页来说,这就是最合适的形态。
1.2 交错网格到底解决了什么问题
flutter_staggered_grid_view这个库做的事情很简单:保持网格布局的底层结构,但允许每个tile独立设置跨列数和跨行数,也允许tile高度自适应内容。你在同一个StaggeredGrid.count里,把默认城市配置成2列宽的大卡,次要城市配置成1列宽的小卡,行高可以自动伸缩,视觉上就形成了天然的优先级。
它还提供了两种使用层级。一种是普通组件StaggeredGrid,适合页面内容固定、不涉及懒加载的场景;另一种是SliverStaggeredGrid,适合嵌进CustomScrollView,配合下拉刷新、加载更多等交互。
我当时选这个库的核心原因有三个:
- 它是纯Dart实现,不依赖Android、iOS原生View或原生服务;
- API设计简洁,声明式地描述每张卡片的尺寸,不写布局计算代码;
- 支持Sliver版本,列表项多的时候也能懒加载,滚动性能有保障。
1.3 为什么纯Dart三方库在鸿蒙上可以直接用
这里要先说清楚鸿蒙Flutter的运行机制。HarmonyOS NEXT上跑Flutter应用,Dart代码仍然由FlutterEngine执行,UI界面由Flutter自绘引擎渲染,与Android/iOS上的Flutter在Dart层完全一致。区别只在于平台通道(Platform Channel)要调用鸿蒙系统能力时,需要一套鸿蒙的原生插件适配。
flutter_staggered_grid_view只使用了Dart层面已有的绘制和布局能力,不调用任何平台通道,所以它天然兼容鸿蒙Flutter,不需要额外写鸿蒙原生插件。这也是选型时的一个重要判断依据:引入一个三方库之前,先看它是不是纯Dart包,是否依赖dart:io、dart:ffi或者flutter/services里的平台通道。如果依赖了原生能力,就得去找有没有鸿蒙适配版,或者自己用HarmonyOS的ArkTS接口封装。
判断方法很简单:到pub.dev打开该包的源码目录,如果只有lib/目录,没有android/、ios/目录,大概率是纯Dart实现;再看它引用的依赖里有没有flutter/services,没有的话基本可以放心。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 鸿蒙Flutter工程准备:从环境配置到第一个可运行Demo
2.1 版本匹配与安装
鸿蒙Flutter开发和标准Flutter开发有几个前置差异,最容易栽跟头的是SDK版本。目前鸿蒙Flutter主要使用开源社区维护的支持OpenHarmony的Flutter SDK,通常称为ohos分支。推荐组合是Flutter 3.7以上版本加DevEco Studio 4.0以上版本,目标API可以选HarmonyOS NEXT对应的API 9以上。
安装好后,用flutter doctor -v检查环境,重点看Flutter和DevEco是否识别到鸿蒙工具链。如果有hdc命令,说明鸿蒙开发工具链已经就绪。真机连接后执行:
bash复制hdc list targets
能看到设备序列号就说明连接正常。这里插一句,很多人以为Flutter官方SDK直接支持鸿蒙,实际上需要切换到ohos分支的SDK,否则用flutter create建工程不会生成ohos目录。
2.2 创建支持鸿蒙的Flutter工程
SDK确认无误后,创建一个新工程:
bash复制flutter create --platforms ohos my_weather_app
cd my_weather_app
flutter pub get
执行完检查工程目录,如果看到ohos目录,说明模板创建成功。没有这个目录的话,多半是Flutter SDK不是ohos分支,需要重新配置环境变量切换SDK路径。
2.3 集成flutter_staggered_grid_view依赖
在pubspec.yaml的dependencies节点下添加:
yaml复制dependencies:
flutter:
sdk: flutter
flutter_staggered_grid_view: ^0.7.0
然后执行:
bash复制flutter pub get
0.7.0是目前常用稳定版,如果你手里的Flutter版本偏老,可以降级到0.6.2,API基本一致。集成完成后,先写一个最简单的页面验证它能跑起来,再开始做正式布局。
2.4 模拟器与真机的坑:arm64架构限制
鸿蒙模拟器有一个很现实的问题:目前模拟器镜像只能在arm64架构的设备上跑。如果你用的是x86_64架构的电脑,开模拟器很可能直接报“运行设备不兼容”,或者创建模拟器时根本没有可选镜像。
我的建议是:
- 如果是Apple Silicon Mac,可以直接用鸿蒙模拟器调试;
- 如果是x86_64电脑,优先用真机调试,连接成本不高;
- 云真机也是一个备选方案。
真机调试前在DevEco Studio里打开开发者模式,确认hdc能连上设备。这一步提前处理好,后面调试交错网格效果时能省很多时间。
3. flutter_staggered_grid_view核心API拆解:你只需要掌握这几种用法
3.1 StaggeredGrid.count:固定列数的最常用形态
实际开发里最常用的是StaggeredGrid.count,它和GridView很像,先固定列数,再让每个tile自由跨列跨行:
dart复制StaggeredGrid.count(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
children: [
StaggeredTile.count(2, 1),
StaggeredTile.count(1, 1),
StaggeredTile.count(1, 1),
],
)
crossAxisCount表示横向分成几列,这里用2列,意味着第一张卡可以占满整行,后两张各占一半。mainAxisSpacing和crossAxisSpacing控制卡片间距。整个布局的声明式写法非常直观,不需要关心像素级计算。
3.2 StaggeredGrid.extent:按最大宽度自适应列数
如果希望在不同屏幕宽度下自动调整列数,可以用StaggeredGrid.extent,它按maxCrossAxisExtent限制每列最大宽度,屏幕宽就多出几列,屏幕窄就少几列:
dart复制StaggeredGrid.extent(
maxCrossAxisExtent: 200,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
children: [
StaggeredTile.fit(1),
StaggeredTile.fit(1),
StaggeredTile.fit(2),
],
)
天气首页一般固定2列就够,所以这个API在工具类页面或设置页里更常用。知道它存在就好,需要响应式布局时能想到。
3.3 StaggeredTile三种尺寸配置
StaggeredTile是控制尺寸的关键类,我在项目里主要用了三种:
StaggeredTile.count(crossAxisCellCount, mainAxisCellCount):按单元格数指定宽高。比如StaggeredTile.count(2, 1)表示跨2列、占1个单元格高度;StaggeredTile.fit(crossAxisCellCount):宽度按列数,高度自适应子组件内容,适合信息量不固定的卡片;StaggeredTile.extent(crossAxisCellCount, mainAxisExtent):宽度按列数,高度固定为像素值,适合有明确高度要求的场景。
需要注意,count里的数字是单元格数量,不是像素值。想要大卡占满两列,就用StaggeredTile.count(2, 1),小卡占一列就用StaggeredTile.count(1, 1)。如果想让卡片高度完全根据天气预报内容伸缩,使用StaggeredTile.fit(1)更合适。
3.4 动态生成tiles的数据驱动思路
多城市天气数据是动态的,今天用户可能加了三个城市,明天又删了一个,所以布局不能写死在children里,要从数据动态生成tiles列表:
dart复制List<StaggeredTile> buildTiles(List<CityWeather> cities) {
return List.generate(cities.length, (index) {
if (index == 0) {
return const StaggeredTile.count(2, 1);
}
if (cities[index].aqi > 200) {
return const StaggeredTile.count(2, 1);
}
return const StaggeredTile.count(1, 1);
});
}
这段代码的逻辑是:默认城市占大卡,空气质量指数超过200的重污染城市也放大卡,其他城市用小卡。数据变化时布局自动跟着变,这就是数据驱动布局的核心思路,也是后面实战部分的基础。
4. 实战:多城市天气卡片的完整实现
4.1 天气数据模型设计
先定义数据模型,我习惯用不可变类和fromJson构造方法,方便后续接入接口数据:
dart复制class CityWeather {
final String cityName;
final double temperature;
final String weatherCode;
final String weatherDesc;
final int aqi;
final bool isPrimary;
final List<DailyForecast> forecast;
const CityWeather({
required this.cityName,
required this.temperature,
required this.weatherCode,
required this.weatherDesc,
required this.aqi,
required this.isPrimary,
required this.forecast,
});
factory CityWeather.fromJson(Map<String, dynamic> json) {
return CityWeather(
cityName: json['cityName'] as String,
temperature: (json['temperature'] as num).toDouble(),
weatherCode: json['weatherCode'] as String,
weatherDesc: json['weatherDesc'] as String,
aqi: json['aqi'] as int,
isPrimary: json['isPrimary'] as bool,
forecast: (json['forecast'] as List)
.map((e) => DailyForecast.fromJson(e))
.toList(),
);
}
}
class DailyForecast {
final String date;
final double high;
final double low;
final String weatherCode;
const DailyForecast({
required this.date,
required this.high,
required this.low,
required this.weatherCode,
});
factory DailyForecast.fromJson(Map<String, dynamic> json) {
return DailyForecast(
date: json['date'] as String,
high: (json['high'] as num).toDouble(),
low: (json['low'] as num).toDouble(),
weatherCode: json['weatherCode'] as String,
);
}
}
4.2 卡片组件拆分:小卡与大卡
卡片组件按信息量拆成两个:
CityWeatherCard:小卡,展示城市名、当前温度、天气描述,底部一行小字显示AQI;CityWeatherWideCard:大卡,在天气信息基础上,增加近三天的最高最低温预览。
这样拆的好处是,在itemBuilder里可以根据返回的卡片尺寸,返回对应的组件类型,布局和内容解耦。小卡用一个简单的Container包起来,圆角背景加渐变,内部用Column排列信息:
dart复制class CityWeatherCard extends StatelessWidget {
final CityWeather city;
const CityWeatherCard({super.key, required this.city});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: WeatherVisual.gradientFor(city.weatherCode),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(city.cityName),
const Spacer(),
Text('${city.temperature.toStringAsFixed(0)}°C'),
Text(city.weatherDesc),
Text('AQI ${city.aqi}'),
],
),
);
}
}
4.3 用SliverStaggeredGrid组合出高低错落的首页
首页用CustomScrollView加SliverStaggeredGrid.count,这样既支持滚动,也能在SliverPadding里统一设置页面边距:
dart复制class WeatherHomePage extends StatelessWidget {
final List<CityWeather> cities;
const WeatherHomePage({super.key, required this.cities});
@override
Widget build(BuildContext context) {
return Scaffold(
body: RefreshIndicator(
onRefresh: _refreshWeather,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.all(12),
sliver: SliverStaggeredGrid.count(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
staggeredTileBuilder: (index) {
if (index == 0 || cities[index].isPrimary) {
return const StaggeredTile.count(2, 1);
}
return const StaggeredTile.count(1, 1);
},
itemBuilder: (context, index) {
final city = cities[index];
if (index == 0 || city.isPrimary) {
return CityWeatherWideCard(city: city);
}
return CityWeatherCard(city: city);
},
),
),
],
),
),
);
}
}
这段代码的核心是staggeredTileBuilder和itemBuilder的index一一对应:先决定第index个卡片占多大格子,再决定它渲染什么内容。两张列表长度一致,布局就和内容严格匹配。
4.4 下拉刷新与城市切换交互
天气应用少不了下拉刷新。RefreshIndicator需要直接包住一个可滚动组件,而SliverStaggeredGrid本身不是Scrollable,所以外面套CustomScrollView就对了,别忘了加AlwaysScrollableScrollPhysics,否则列表内容不满一屏时手势会失效。
城市切换的逻辑有两种做法。简单做法是:城市列表顺序就是展示顺序,切城市时把新城市排到最前面,并把它的isPrimary设为true,这样第一张大卡自然展示的是当前选中城市。另一个做法是点击某个城市后跳转到详情页,首页保持总览。我建议用前者,因为这个首页本身要承担“总览+快捷切换”双重职责。
4.5 图标与渐变背景的动态映射
天气代码映射为图标和背景色,单独抽一个工具类,避免在build方法里写又长又丑的switch:
dart复制class WeatherVisual {
static IconData iconFor(String code) {
switch (code) {
case '100':
return Icons.wb_sunny_outlined;
case '101':
return Icons.cloud_outlined;
case '104':
return Icons.cloud_queue;
case '300':
return Icons.thunderstorm_outlined;
default:
return Icons.help_outline;
}
}
static List<Color> gradientFor(String code) {
switch (code) {
case '100':
return [Color(0xFFFDEB71), Color(0xFFF8D800)];
case '101':
return [Color(0xFFB2D8F7), Color(0xFF7BA8D4)];
case '104':
return [Color(0xFFB0BEC5), Color(0xFF78909C)];
case '300':
return [Color(0xFF5A6B7C), Color(0xFF37474F)];
default:
return [Color(0xFFE0E0E0), Color(0xFFBDBDBD)];
}
}
}
这样卡片组件里只需要一行WeatherVisual.gradientFor(city.weatherCode)就能拿到渐变背景,代码干净很多。
5. 鸿蒙适配踩坑实录:三方库不是装完就完事
5.1 编译期报错:pub源与依赖版本冲突
鸿蒙Flutter工程在拉取依赖时,最容易碰到的是pub源访问慢或者失败。这时候把pub源切到国内镜像就好,配置环境变量:
bash复制export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
Windows系统用对应的set命令配置。注意这个只是国内镜像提速,不同于任何代理工具,是合规的公开配置。
另一个常见的编译期问题是依赖版本冲突。flutter_staggered_grid_view本身依赖collection、meta这些Dart基础包,如果你的Flutter SDK版本偏老,pub get可能会报类似这样的错:
text复制Because flutter_staggered_grid_view 0.7.0 depends on collection ^1.17.0
处理方式有三种:升级Flutter SDK到标准版本;把package版本降到与当前SDK兼容的旧版;删除pubspec.lock后重新flutter pub get。我试过最省事的其实是升级SDK,因为老版本SDK在鸿蒙上的坑相对更多。
5.2 运行期崩溃:har包与so库的封装问题
staggered_grid本身不会引发so库崩溃,但你在鸿蒙Flutter工程里一旦接入了其他需要原生能力的SDK,就可能碰到“libxxx.so not found”这类崩溃。鸿蒙的har包类似于Android的aar,可以封装so库和ArkTS接口。
如果真遇到so找不到,先检查har包里的libs目录是否包含arm64-v8a对应的so文件,再确认工程的ohos模块是否引用了这个har包,最后看设备架构是否匹配。多数情况都是库放错目录或者模块没引用导致。
5.3 渲染性能:卡片数量多时的优化
首屏只有几个城市时,普通StaggeredGrid没有任何问题。但如果用户收藏了三四十个城市,而且每个大卡都渲染一周预报,就必须用Sliver版本。
SliverStaggeredGrid和ListView的懒加载机制类似,滚到哪一屏才构建哪一屏的item。普通StaggeredGrid会一次性构建所有children,城市一多就会卡顿。这就是我在实战部分坚持用Sliver的原因。
另外一个性能优化点是卡片里的渐变背景。如果每帧都动态创建LinearGradient对象,会有额外开销。可以把常用背景定义成静态常量,或者在数据层做好缓存。还有卡片组件本身尽量让其为const构造,减少父组件刷新时的重建范围。
5.4 生命周期管理:回到前台再刷新数据
温度数据是强时效数据,放到后台几分钟再回来,显示的还是旧温度就很尴尬。在Flutter里可以用WidgetsBindingObserver监听App生命周期,回到前台时判断缓存时间是否超过10分钟,超过就自动刷新:
dart复制class WeatherHomePageState extends State<WeatherHomePage>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_refreshWeather();
}
}
}
这个逻辑在鸿蒙Flutter上的表现和Android上基本一致,但保险起见,我还是建议在真机上测试一遍从后台恢复的场景。
6. 代码级优化与后续扩展建议
6.1 用Sliver和KeepAlive保护滚动状态
虽然SliverStaggeredGrid已经做了懒加载,但如果你在卡片内部放了TabView或者带有滚动区域的组件,滚出屏幕再滚回来时,子组件的滚动位置可能被重置。这种情况可以给卡片StatefulWidget混入AutomaticKeepAliveClientMixin,并在wantKeepAlive返回true,确保页面状态被保活。
注意,KeepAlive会增加内存占用,不建议所有卡片都启用,只给那些确实有内部状态的卡片打开。
6.2 天气数据的本地缓存与网络降级
为了提升首页加载速度,我通常做两级数据策略:启动时先读本地缓存,页面秒开;网络请求返回后,再用新数据刷新界面并更新缓存。天气缓存数据结构就是城市列表加每个城市的JSON。
dart复制Future<void> _refreshWeather() async {
try {
final data = await WeatherApi.fetchAll();
await _saveLocalCache(data);
setState(() {
_cities = data;
});
} catch (e) {
final cached = await _loadLocalCache();
if (cached != null) {
setState(() {
_cities = cached;
});
}
}
}
这个策略在弱网环境下特别有用,至少保证用户打开App时永远有数据可看。
6.3 从天气卡片到通用信息流卡片的迁移思路
这次做完多城市天气卡片后,我最大的体会是:交错网格这套思路完全可以迁移到其他内容型页面。资讯App的首页,头条文章用大卡,普通短文用小卡;电商App的类目入口,活动位占两列,普通入口占一列;社区App的用户帖子,带长图的卡片自动跨两行。
迁移时只需要做好一件事:定义统一的ItemData和ItemType,在staggeredTileBuilder里根据类型返回尺寸,在itemBuilder里根据类型返回组件,剩下的网格逻辑完全不用动。这样天气卡片、资讯卡片、商品卡片都能共用一个网格容器。
最后再分享一个小技巧:把flutter_staggered_grid_view集成进鸿蒙Flutter工程时,我习惯先在普通Flutter工程里完成卡片样式和交互调试,确认视觉效果没问题后再用鸿蒙SDK编译验证。这样能明显减少鸿蒙构建耗时带来的反馈延迟,排查问题时思路也清晰很多。
