Flutter之英雄联盟

要说我最喜欢的游戏,那必须是英雄联盟。太多太多的回忆!今天我们一起使用Flutter来开发一款英雄资料卡。上图是APP的部分截图,APP的整体设计看上去还是很清爽的。首页使用Tab展示英雄的六大分类,点击英雄的条目会跳转到英雄的详情页面。

目录结构

1
2
3
4
5
6
- lib
- models
- utils
- views
- widgets
- main.dart

我们先从项目的目录结构讲起吧,对APP来个整体上的把握。本APP我们采用的目录结构是很常见的,不仅仅是Flutter开发,现在的前端开发模式也基本相似:

  • models来定义数据模型
  • utils里放一些公用的函数、接口、路由、常量等
  • views里放的是页面级别的组件
  • widgets里放的是页面中需要使用的小组件
  • main.dart 是APP的启动文件

开始之处

APP必定从main.dart开始,一些模板化的代码就不提了,有一点需要注意的是,APP状态栏的背景是透明的,这个配置在main()函数中:

1
2
3
4
5
6
7
8
9
10
11
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
runApp(MyApp());
if (Platform.isAndroid) {
SystemUiOverlayStyle systemUiOverlayStyle =
SystemUiOverlayStyle(statusBarColor: Colors.transparent);
SystemChrome.setSystemUIOverlayStyle(systemUiOverlayStyle);
}
}

首页

APP进入首页后开始拉取后端接口的数据,进而展示英雄列表。TabBar组件来定义页面上部的Tab切换,TabBarView来展示页面下部的列表。本来打算使用拳头开放的接口数据,但是没有提供中文翻译。就去腾讯找了下,腾讯更加封闭,居然没有开发者接口。无赖之举,自己用node写了个接口来提供实时的英雄数据,数据100%来自官网哦。另外本人服务器配置不是很高也不稳定,所以接口只供学习使用哦

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import 'package:flutter/material.dart';
import 'package:lol/views/homeList.dart';
import 'package:lol/utils/api.dart' as api;
import 'package:lol/utils/constant.dart';
import 'package:lol/utils/utils.dart';

class HomeView extends StatefulWidget {
HomeView({Key key}) : super(key: key);

_HomeViewState createState() => _HomeViewState();
}

class _HomeViewState extends State<HomeView> with SingleTickerProviderStateMixin {
TabController _tabController;
List<dynamic> heroList = [];

@override
void initState() {
super.initState();
_tabController = TabController(vsync: this, initialIndex: 0, length: 6);
init();
}

init() async {
Map res = await api.getHeroList();
setState(() {
heroList = res.values.toList();
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: TabBar(
controller: _tabController,
tabs: <Widget>[
Tab(text: '战士'),
Tab(text: '坦克'),
Tab(text: '法师'),
Tab(text: '刺客'),
Tab(text: '辅助'),
Tab(text: '射手'),
],
),
),
body: TabBarView(
controller: _tabController,
children: <Widget>[
HomeList(data: Utils.filterHeroByTag(heroList, Tags.Fighter)),
HomeList(data: Utils.filterHeroByTag(heroList, Tags.Tank)),
HomeList(data: Utils.filterHeroByTag(heroList, Tags.Mage)),
HomeList(data: Utils.filterHeroByTag(heroList, Tags.Assassin)),
HomeList(data: Utils.filterHeroByTag(heroList, Tags.Support)),
HomeList(data: Utils.filterHeroByTag(heroList, Tags.Marksman)),
],
),
);
}
}

首页列表

首页的六个列表都是一样的,只是数据不同,所以公用一个组件homeList.dart即可,切换Tab的时候为了不销毁之前的页面需要让组件继承AutomaticKeepAliveClientMixin类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import 'package:flutter/material.dart';
import 'package:lol/widgets/home/heroItem.dart';
import 'package:lol/models/heroSimple.dart';

class HomeList extends StatefulWidget {
final List data;
HomeList({Key key, this.data}) : super(key: key);

_HomeListState createState() => _HomeListState();
}

class _HomeListState extends State<HomeList>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;

@override
Widget build(BuildContext context) {
super.build(context);
return Container(
padding: EdgeInsets.symmetric(vertical: 5),
child: ListView.builder(
itemCount: widget.data.length,
itemBuilder: (BuildContext context, int index) {
return HeroItem(data: HeroSimple.fromJson(widget.data[index]));
},
),
);
}
}

英雄详情

点击英雄条目,路由跳转到详情页面heroDetail.dart,这个页面中包含了很多小组件,其中的皮肤预览功能使用的是第三方的图片查看库extended_image,这个库很强大,而且还是位中国开发者,必须支持。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import 'package:flutter/material.dart';
import 'package:lol/utils/api.dart' as api;
import 'package:lol/models/heroSimple.dart';
import 'package:lol/models/heroDetail.dart';
import 'package:lol/utils/utils.dart';
import 'package:lol/widgets/detail/detailItem.dart';
import 'package:lol/widgets/detail/skin.dart';
import 'package:lol/widgets/detail/info.dart';

class HeroDetail extends StatefulWidget {
final HeroSimple heroSimple;
HeroDetail({Key key, this.heroSimple}) : super(key: key);

_HeroDetailState createState() => _HeroDetailState();
}

class _HeroDetailState extends State<HeroDetail> {
HeroDetailModel _heroData; // hero数据
bool _loading = false; // 加载状态
String _version = ''; // 国服版本
String _updated = ''; // 文档更新时间

@override
void initState() {
super.initState();
init();
}

init() async {
setState(() {
_loading = true;
});
Map res = await api.getHeroDetail(widget.heroSimple.id);
var data = res['data'];
String version = res['version'];
String updated = res['updated'];
print(version);
setState(() {
_heroData = HeroDetailModel.fromJson(data);
_version = version;
_updated = updated;
_loading = false;
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.heroSimple.name), elevation: 0),
body: _loading
? Center(child: CircularProgressIndicator())
: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
DetailItem(
title: '皮肤',
child: Skins(imgList: _heroData.skins),
),
DetailItem(
title: '类型',
child: Row(
children: _heroData.tags
.map((tag) => Container(
margin: EdgeInsets.only(right: 10),
child: CircleAvatar(
child: Text(
Utils.heroTagsMap(tag),
style: TextStyle(color: Colors.white),
),
),
))
.toList()),
),
DetailItem(
title: '属性',
child: HeroInfo(data: _heroData.info),
),
DetailItem(
title: '使用技巧',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _heroData.allytips
.map((tip) => Column(
children: <Widget>[
Text(tip),
SizedBox(height: 5)
],
))
.toList(),
),
),
DetailItem(
title: '对抗技巧',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _heroData.enemytips
.map((tip) => Column(
children: <Widget>[
Text(tip),
SizedBox(height: 5)
],
))
.toList(),
),
),
DetailItem(
title: '背景故事',
child: Text(_heroData.lore),
),
DetailItem(
title: '国服版本',
child: Text(_version),
),
DetailItem(
title: '更新时间',
child: Text(_updated),
)
],
),
),
);
}
}

打包APK

打包APK通常需要三个步骤:
Step1: 生成签名
Step2: 对项目进行签名配置
Step3: 打包

Step1: 生成签名

在打包APK之前需要生成一个签名文件,签名文件是APP的唯一标识:

1
keytool -genkey -v -keystore c:/Users/15897/Desktop/key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias key

  • c:/Users/15897/Desktop/key.jks表示文件的生成位置,我直接设置的桌面
  • -validity 10000设置的签名的有效时间
  • -alias key为签名文件起个别名,我直接设置成key

执行这条命令行后,会有一个交互式的问答:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
输入密钥库口令:
再次输入新口令:
您的名字与姓氏是什么?
[Unknown]: hua
您的组织单位名称是什么?
[Unknown]: xxx
您的组织名称是什么?
[Unknown]: xxx
您所在的城市或区域名称是什么?
[Unknown]: xxx
您所在的省/市/自治区名称是什么?
[Unknown]: xxx
该单位的双字母国家/地区代码是什么?
[Unknown]: xxx
CN=hua, OU=xxx, O=xxx, L=xxx, ST=xxx, C=xxx是否正确?
[否]: y

正在为以下对象生成 2,048 位RSA密钥对和自签名证书 (SHA256withRSA) (有效期为 10,000 天):
CN=hua, OU=xxx, O=xxx, L=xxx, ST=xxx, C=xxx
输入 <key> 的密钥口令
(如果和密钥库口令相同, 按回车):
[正在存储c:/Users/15897/Desktop/key.jks]

Step2: 对项目进行签名配置

在项目中新建文件<app dir>/android/key.properties,文件中定义了四个变量,留着给<app dir>/android/app/build.gradle调用。

前三个都是上一步用到的几个字段,第四个storeFile是签名文件的位置,文件位置是相对于<app dir>/android/app/build.gradle来说,所以需要将上一步生成了的key.jks复制到<app dir>/android/app/下。

警告:文件涉及到密码啥的,所以最好不要上传到版本管理。

1
2
3
4
5
#  Note: Keep the key.properties file private; do not check it into public source control.
storePassword=123456
keyPassword=123456
keyAlias=key
storeFile=key.jks

再修改<app dir>/android/app/build.gradle这里才是正在的配置):

1
2
3
4
5
6
7
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}

android {

1
2
3
4
5
6
7
8
9
10
11
12
13
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}

Step3: 打包

执行打包命令:

1
flutter build apk

1
2
3
4
5
6
7
8
9
10
11
12
13
14
You are building a fat APK that includes binaries for android-arm, android-arm64.
If you are deploying the app to the Play Store, it's recommended to use app bundles or split the APK to reduce the APK size.
To generate an app bundle, run:
flutter build appbundle --target-platform android-arm,android-arm64
Learn more on: https://developer.android.com/guide/app-bundle
To split the APKs per ABI, run:
flutter build apk --target-platform android-arm,android-arm64 --split-per-abi
Learn more on: https://developer.android.com/studio/build/configure-apk-splits#configure-abi-split
Initializing gradle... 3.6s
Resolving dependencies... 26.8s
Calling mockable JAR artifact transform to create file: C:\Users\15897\.gradle\caches\transforms-1\files-1.1\android.jar\e122fbb402658e4e43e8b85a067823c3\android.jar with input C:\Users\15897\AppData\Local\Android\Sdk\platforms\android-28\android.jar
Running Gradle task 'assembleRelease'...
Running Gradle task 'assembleRelease'... Done 84.7s
Built build\app\outputs\apk\release\app-release.apk (11.2MB).

打包完成后,apk文件就在这里build\app\outputs\apk\release\app-release.apk

打包方面相关链接

更多链接