Flutter for OpenHarmony游戏集合App实战之游戏列表GridView
本文介绍了如何使用Flutter的GridView.builder实现游戏合集App的网格布局。主要内容包括: 选择GridView.builder的原因:按需创建子Widget,内存占用更优 页面结构设计:使用StatelessWidget、Scaffold和SafeArea搭建基础框架 视觉优化:设置背景渐变和适当的内边距 网格核心配置:固定3列布局,设置主轴和交叉轴间距 性能考虑:使用con
通过网盘分享的文件:game_flutter_openharmony.zip
链接: https://pan.baidu.com/s/1ryUS1A0zcvXGrDaStu530w 提取码: tqip
前言
做一个游戏合集App,第一个要解决的问题就是怎么把十几个游戏展示出来。用列表一个个往下排?太单调了,而且用户要滑很久才能看完。
我选择用网格布局,每行放3个游戏,用户一眼就能看到大部分游戏,想玩哪个点哪个。这篇就来聊聊这个游戏网格怎么实现。
网格布局在移动端App里非常常见,比如手机桌面的应用图标、电商App的商品列表、相册的图片墙,都是网格布局。掌握了GridView,这些场景都能轻松应对。
为什么选GridView.builder
Flutter里做网格布局有好几种方式:
GridView()默认构造函数GridView.count()指定列数GridView.extent()指定格子最大宽度GridView.builder()按需构建GridView.custom()完全自定义
我用的是GridView.builder。为啥不用其他的?
普通的GridView(包括count和extent)需要一开始就把所有子Widget都创建好。游戏少还行,要是有几十上百个,一次性创建那么多Widget,内存扛不住。
举个例子,假设你有100个游戏,每个游戏卡片占用10KB内存,那就是1MB。听起来不多?但这只是Widget对象本身,还没算渲染需要的资源。而且用户可能只看前面几个就选好了,后面90多个根本不会滚动到,白白浪费内存。
GridView.builder就聪明多了,它是按需创建的——只有格子滚动到屏幕可见区域时,才会调用builder函数创建对应的Widget。滚出屏幕的Widget会被回收,内存占用始终可控。
虽然我们现在只有16个游戏,用哪种差别不大,但养成好习惯总没错。以后项目里遇到长列表,直接用builder准没错。
页面整体结构
先看HomeScreen的类定义:
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
用StatelessWidget就够了,因为游戏列表是固定的,不需要动态更新状态。
💡 什么时候用StatelessWidget? 如果Widget创建后,它的内容不会因为用户操作或数据变化而改变,就用StatelessWidget。反之用StatefulWidget。游戏列表是写死的,不会变,所以用StatelessWidget。
构造函数里的{super.key}是Dart 3的新语法,等价于以前写的:
const HomeScreen({Key? key}) : super(key: key);
新语法简洁多了,推荐使用。key参数是Flutter用来识别Widget身份的,在列表中特别重要,能帮助Flutter高效地更新UI。
构造函数前面的const关键字表示这是一个编译时常量构造函数。这意味着如果你用const HomeScreen()创建实例,Flutter会在编译时就创建好这个对象,运行时直接复用,不用每次都new一个新的。对性能有好处。
Scaffold页面脚手架
build方法返回的是Scaffold:
Widget build(BuildContext context) {
// ... 游戏数据定义
return Scaffold(
appBar: AppBar(
title: const Text('经典游戏合集'),
centerTitle: true,
elevation: 2,
),
Scaffold是Material Design的页面脚手架,提供了标准的页面结构。它就像一个房子的框架,帮你把顶部导航栏、页面主体、底部导航栏、侧边抽屉、悬浮按钮这些常见元素安排得明明白白。
我们这里只用到了appBar属性,设置顶部导航栏。
AppBar的几个参数:
- title: 导航栏标题,这里显示"经典游戏合集"。用
const Text()是因为文字内容是固定的,加const能优化性能 - centerTitle: 设为true让标题居中显示。不设的话,Android默认靠左对齐,iOS默认居中,设成true可以统一两个平台的表现
- elevation: 导航栏底部阴影的高度。0是没有阴影,数字越大阴影越明显。2是个比较柔和的值,看起来有层次感但不会太突兀
页面背景渐变
body部分我没有直接放GridView,而是先包了一层Container来做背景装饰:
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
Container是Flutter里最常用的布局组件之一,可以设置大小、边距、背景、边框等各种样式。这里我用它的decoration属性来设置背景渐变。
BoxDecoration是装饰器,可以给Container添加背景色、渐变、图片、边框、圆角、阴影等效果。
LinearGradient是线性渐变,颜色沿着一条直线变化。begin和end指定渐变的起点和终点:
Alignment.topCenter是顶部中间Alignment.bottomCenter是底部中间
所以这个渐变是从上往下的方向。
渐变的颜色设置:
colors: [
Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
Theme.of(context).colorScheme.surface,
],
),
),
colors数组定义了渐变经过的颜色,至少要有两个。这里第一个颜色是主题色的浅色容器色(primaryContainer),透明度设为30%;第二个是页面表面色(surface)。
效果就是从上到下,颜色从淡淡的主题色过渡到纯背景色,比纯色背景有层次感,但又不会太花哨。
💡 为什么用Theme.of(context)获取颜色? 这样做的好处是颜色会跟随主题变化。用户切换深色模式时,primaryContainer和surface都会自动变成深色模式对应的颜色,不用我们手动处理。如果写死成
Colors.blue,深色模式下就会很刺眼。
withOpacity(0.3)是给颜色加透明度,0是完全透明,1是完全不透明,0.3就是30%的不透明度。
SafeArea安全区域
Container里面还包了一层SafeArea:
child: SafeArea(
child: GridView.builder(
SafeArea的作用是避开系统UI占用的区域。
现在的手机屏幕五花八门,各种异形屏:
- iPhone有刘海屏(顶部那个黑色凹槽)和底部的Home指示条
- Android手机有状态栏(显示时间、电量的那条)和底部导航栏(返回、主页、多任务按钮)
- 有些手机还有挖孔屏(摄像头在屏幕上打个洞)
- 折叠屏还有铰链区域
如果不做处理,你的内容可能会被这些东西遮挡住。比如一个按钮正好在刘海下面,用户根本点不到。
SafeArea会自动检测当前设备的这些"危险区域",然后给子Widget加上相应的padding,保证内容显示在安全区域内。
有人可能会问:AppBar不是已经处理了顶部状态栏吗?
确实,Scaffold的AppBar会自动避开状态栏。但SafeArea还能处理底部和两侧的安全区域。比如iPhone X及以后的机型,底部有一个Home指示条,如果你的内容延伸到最底部,就会被它挡住。加上SafeArea就不用担心这个问题。
而且SafeArea很智能,如果某个方向不需要避让(比如顶部已经有AppBar了),它不会重复加padding。
GridView核心配置
终于到重点了,GridView.builder的配置:
child: GridView.builder(
padding: const EdgeInsets.all(12),
padding是网格内部四周的留白,用EdgeInsets.all(12)表示上下左右都是12像素。
这样内容不会紧贴屏幕边缘,看着舒服点。如果不设padding,第一行的卡片会直接顶到SafeArea的边界,显得很拥挤。
💡 EdgeInsets的几种用法:
EdgeInsets.all(12)四边都是12EdgeInsets.symmetric(horizontal: 12, vertical: 8)水平12,垂直8EdgeInsets.only(left: 12, top: 8)只设置某几边EdgeInsets.fromLTRB(12, 8, 12, 8)分别设置左、上、右、下
接下来是gridDelegate,这个参数决定了网格的排列方式:
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
SliverGridDelegateWithFixedCrossAxisCount,名字很长,但意思很简单:固定列数的网格代理。
crossAxisCount: 3表示每行显示3列。
💡 为什么叫crossAxisCount不叫columnCount? 因为GridView可以水平滚动也可以垂直滚动。默认是垂直滚动,此时主轴(main axis)是垂直方向,交叉轴(cross axis)是水平方向,所以列数叫crossAxisCount。如果设置成水平滚动,主轴变成水平方向,crossAxisCount就变成行数了。这种命名方式更通用。
这个3是我试出来的。2列的话每个卡片太大,显得空旷,一屏只能看到4-6个游戏;4列的话每个卡片太小,游戏名都显示不全;3列在大多数手机上刚刚好,一屏能看到9-12个游戏,卡片大小也合适。
当然,如果你想做响应式布局,可以根据屏幕宽度动态计算列数。比如平板上显示4列或5列。但对于这个小项目,固定3列就够了。
间距设置
mainAxisSpacing: 10,
crossAxisSpacing: 10,
这两个参数控制格子之间的间距:
- mainAxisSpacing: 主轴方向的间距。垂直滚动时,主轴是垂直方向,所以这是行间距,就是上下两行格子之间的距离
- crossAxisSpacing: 交叉轴方向的间距。垂直滚动时,交叉轴是水平方向,所以这是列间距,就是左右两列格子之间的距离
都设成10像素,格子之间不会挤在一起,也不会离得太远,看起来比较均匀。
这个间距要和padding配合着看。padding是网格和屏幕边缘的距离,spacing是格子和格子之间的距离。我设置padding=12,spacing=10,边缘稍微宽一点,视觉上更平衡。
宽高比
childAspectRatio: 0.85,
),
childAspectRatio控制每个格子的宽高比,计算公式是宽度 / 高度。
- 1.0 表示正方形
- 大于1 表示扁的(宽大于高)
- 小于1 表示竖的(高大于宽)
0.85意味着宽度是高度的0.85倍,换算一下就是高度是宽度的1.18倍,格子是竖着的长方形,比正方形稍微高一点点。
为啥设成0.85?因为每个游戏卡片里有三行内容:图标、标题、描述。正方形(1.0)放不下,内容会挤在一起;太高(比如0.6)又会显得空荡荡。0.85是我反复调试出来的,刚好能放下三行内容,又不会太空。
这个值没有标准答案,取决于你卡片里放什么内容。如果只有图标和标题两行,0.9或1.0可能更合适。
itemCount和itemBuilder
itemCount: games.length,
itemBuilder: (context, index) => _GameCard(game: games[index]),
),
- itemCount: 告诉GridView一共有多少个格子。这里就是games数组的长度,有多少个游戏就有多少个格子
- itemBuilder: 构建每个格子的回调函数
itemBuilder是GridView.builder的核心。每当需要显示某个格子时(比如用户滚动到那个位置),GridView就会调用这个函数来创建对应的Widget。
函数签名是Widget Function(BuildContext context, int index):
context是构建上下文,有时候需要用它来获取主题、导航等信息index是当前格子的序号,从0开始。第一个格子是0,第二个是1,以此类推
函数返回一个Widget,就是这个格子要显示的内容。
我们的实现很简单:根据index从games数组取出对应的游戏数据,然后创建一个_GameCard组件。_GameCard是自定义的游戏卡片组件,负责渲染单个卡片的样式。
游戏数据结构
在build方法开头,我定义了游戏数据数组:
Widget build(BuildContext context) {
final games = [
_GameInfo('扫雷', Icons.grid_view, Colors.blue, '经典Windows扫雷', const MinesweeperScreen()),
_GameInfo('五子棋', Icons.circle_outlined, Colors.brown, '双人对战五子棋', const GomokuScreen()),
_GameInfo('蜘蛛纸牌', Icons.style, Colors.green, '经典纸牌游戏', const SpiderSolitaireScreen()),
_GameInfo('炸金花', Icons.casino, Colors.red, '刺激扑克游戏', const ZhajinhuaScreen()),
每个游戏用_GameInfo对象表示,包含5个信息:
- title: 游戏名称,显示在卡片上
- icon: 图标,用Material Icons库里的图标
- color: 主题色,每个游戏用不同颜色,方便区分
- description: 简短描述,告诉用户这是什么游戏
- screen: 点击后跳转到的游戏页面Widget
这样设计的好处是数据和UI分离。游戏列表的数据集中管理,GridView只负责渲染,不关心具体有哪些游戏。
想加新游戏?在数组里加一行就行,GridView的代码完全不用动。想改某个游戏的图标或颜色?找到对应那行改一下就行。这种设计让代码更容易维护。
_GameInfo数据类
class _GameInfo {
final String title;
final IconData icon;
final Color color;
final String description;
final Widget screen;
const _GameInfo(this.title, this.icon, this.color, this.description, this.screen);
}
这是一个简单的数据类,用来存储游戏信息。
所有字段都是final的,意味着对象创建后这些值不能修改。这是不可变对象(Immutable Object)的设计模式。不可变对象有很多好处:线程安全、容易推理、不会被意外修改导致bug。
构造函数用的是Dart的简写语法。this.title表示把参数直接赋值给同名字段,等价于:
_GameInfo(String title, ...) : this.title = title, ...;
简写语法省了很多重复代码。
类名前面的下划线_表示这是个私有类,只能在当前文件(home_screen.dart)里使用,其他文件import这个文件后看不到这个类。
为什么要设成私有?因为_GameInfo只是给HomeScreen内部用的,是个实现细节,没必要暴露给外部。如果其他地方也需要用,可以把下划线去掉变成公开类,或者单独建一个文件。
图标的选择
_GameInfo('扫雷', Icons.grid_view, Colors.blue, ...),
_GameInfo('五子棋', Icons.circle_outlined, Colors.brown, ...),
_GameInfo('蜘蛛纸牌', Icons.style, Colors.green, ...),
_GameInfo('炸金花', Icons.casino, Colors.red, ...),
_GameInfo('2048', Icons.apps, Colors.orange, ...),
图标的选择尽量和游戏内容相关,让用户一眼就能认出是什么游戏:
- 扫雷用
grid_view(网格图标),因为扫雷就是在格子里点来点去 - 五子棋用
circle_outlined(空心圆),像棋子的形状 - 蜘蛛纸牌用
style(卡片样式图标),代表扑克牌 - 炸金花用
casino(赌场图标),扑克游戏嘛 - 2048用
apps(应用网格图标),因为2048也是在格子里玩的
Material Icons库非常丰富,有上千个图标,基本能找到合适的。可以在 https://fonts.google.com/icons 搜索浏览。
如果实在找不到合适的图标,也可以用自定义图片,把Icon换成Image就行。
颜色的搭配
Colors.blue, // 扫雷
Colors.brown, // 五子棋
Colors.green, // 蜘蛛纸牌
Colors.red, // 炸金花
Colors.orange, // 2048
Colors.indigo, // 数独
Colors.teal, // 华容道
Colors.amber, // 推箱子
颜色我尽量让相邻的游戏差异大一些。蓝色旁边放棕色,绿色旁边放红色,这样视觉上更容易区分。
如果两个相邻的游戏都是蓝色系,用户快速扫一眼可能会看混。颜色差异大,辨识度就高,用户能更快找到想玩的游戏。
另外,颜色的选择也可以和游戏内容呼应。比如贪吃蛇用lightGreen(浅绿色),让人联想到蛇;推箱子用amber(琥珀色),有种木箱子的感觉。
关于性能的思考
有人可能会问:games数组是在build方法里创建的,每次Widget重建都会创建新数组,会不会有性能问题?
答案是基本没影响。
首先,HomeScreen是StatelessWidget,它的build方法只有在以下情况才会被调用:
- 第一次创建时
- 父Widget重建导致它也重建时
对于我们这个App,HomeScreen是根页面,父Widget(MaterialApp)基本不会重建,所以HomeScreen的build方法调用频率很低。
其次,就算build被调用了,创建一个16个元素的数组开销也可以忽略不计。Dart的对象创建非常快,这点开销连1毫秒都不到,用户完全感知不到。
如果你实在介意,可以把games提取成类的静态常量:
class HomeScreen extends StatelessWidget {
static final games = [
_GameInfo(...),
...
];
但说实话,这属于过早优化。在没有性能问题的时候就去优化,往往是浪费时间。等真的遇到性能瓶颈了再优化也不迟。
小结
这篇讲了游戏列表的GridView实现,核心知识点回顾:
- GridView.builder按需创建子Widget,适合长列表,内存占用可控
- SliverGridDelegateWithFixedCrossAxisCount是固定列数的网格代理
- crossAxisCount设置列数,根据屏幕大小和内容选择合适的值
- mainAxisSpacing和crossAxisSpacing分别设置行间距和列间距
- childAspectRatio控制格子宽高比,根据卡片内容调整
- SafeArea避开系统UI占用的区域,适配各种异形屏
- 数据和UI分离,用数据类管理游戏信息,便于维护
GridView是Flutter里很常用的组件,掌握了它,以后做商品列表、图片墙、应用抽屉这些都是一个套路。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)