Github客户端示例
本章新建一个Flutter工程,实现一个简单的Github客户端。这个实例的主要目标有两个:
- 带领读者了解如何使用Flutter来开发一个完整APP,了解Flutter应用开发流程及工程结构等。
- 对前面章节所学内容的一个应用及总结。
需要注意的是,由于Github本身功能非常多,我们的焦点并不是去实现Github的所有业务功能。因此,我们只需要实现一个APP的骨架,能达到上面这两点即可。下面对我们要实现的功能如下:
- 实现Github账号登录、退出登录功能
- 登录后可以查看自己的项目主页
- 支持换肤
- 支持多语言
- 登录状态可以持久化;
要实现上面这些功能会涉及到如下技术点:
- 网络请求;需要请求Github API。
- Json转Dart Model类;
- 全局状态管理;语言、主题、登录态等都需要全局共享。
- 持久化存储;保存登录信息,用户信息等。
- 支持国际化、Intl包的使用
现在,目标已经确定,在接下来章节中,我们将分模块一步一步实现上述功能。
Flutter APP代码结构
我们先来创建一个全新的Flutter工程,命名为”github_client_app”;创建新工程的步骤视读者使用的编辑器而定,都比较简单,在此不再赘述。创建完成后,工程结构如下:
github_client_app├── android├── ios├── lib└── test
由于我们需要使用外部图片和Icon资源,所以我们在项目根目录下分别创建“imgs”和“fonts”文件夹,前者用于保存图片,后者用于保存Icon文件。关于图片和Icon,读者可以参考第三章中相应的内容。
由于在网络数据传输和持久化时,我们需要通过Json来传输、保存数据;但是在应用开发时我们又需要将Json转成Dart Model类,现在我们使用在第十一章中“Json转Model”小节中介绍的方案,所以,我们需要在根目录下再创建一个用于保存Json文件的“jsons”文件夹。
多语言支持我们使用第十三章“国际化”中介绍的方案,所以还需要在根目录下创建一个“l10n”文件夹,用于保存各国语言对应的arb文件。
现在工程目录变为:
github_client_app├── android├── fonts├── l10n-arb├── imgs├── ios├── jsons├── lib└── test
由于我们的Dart代码都在“lib”文件夹下,笔者根据技术选型和经验在lib文件下创建了如下目录:
lib├── common├── l10n├── models├── states├── routes└── widgets
| 文件夹 | 作用 |
|---|---|
| common | 一些工具类,如通用方法类、网络接口类、保存全局变量的静态类等 |
| l10n | 国际化相关的类都在此目录下 |
| models | Json文件对应的Dart Model类会在此目录下 |
| states | 保存APP中需要跨组件共享的状态类 |
| routes | 存放所有路由页面类 |
| widgets | APP内封装的一些Widget组件都在该目录下 |
注意,使用不同的框架或技术选型会对代码有不同的组织方式,因此,本节介绍的代码组织结构并不是固定或者“最佳”的,在实战中,读者可以自己根据情况调整源码结构。但是无论采取何种源码组织结构,清晰和解耦都是一个通用原则,我们应该让自己的代码结构清晰,以便交流和维护。
赏
Model类定义
本节我们先梳理一下APP中将用到的数据,然后生成相应的Dart Model类。Json文件转Dart Model的方案采用前面介绍过的 json_model 包方案
Github账号信息
登录Github后,我们需要获取当前登录者的Github账号信息,Github API接口返回Json结构如下:
{"login": "octocat", //用户登录名"avatar_url": "https://github.com/images/error/octocat_happy.gif", //用户头像地址"type": "User", //用户类型,可能是组织"name": "monalisa octocat", //用户名字"company": "GitHub", //公司"blog": "https://github.com/blog", //博客地址"location": "San Francisco", // 用户所处地理位置"email": "octocat@github.com", // 邮箱"hireable": false,"bio": "There once was...", // 用户简介"public_repos": 2, // 公开项目数"followers": 20, //关注该用户的人数"following": 0, // 该用户关注的人数"created_at": "2008-01-14T04:33:35Z", // 账号创建时间"updated_at": "2008-01-14T04:33:35Z", // 账号信息更新时间"total_private_repos": 100, //该用户总的私有项目数(包括参与的其它组织的私有项目)"owned_private_repos": 100 //该用户自己的私有项目数... //省略其它字段}
我们在“jsons”目录下创建一个“user.json”文件保存上述信息。
API缓存策略信息
由于Github服务器在国内访问速度较慢,我们对Github API应用一些简单的缓存策略。我们在“jsons”目录下创建一个“cacheConfig.json”文件缓存策略信息,定义如下:
{"enable":true, // 是否启用缓存"maxAge":1000, // 缓存的最长时间,单位(秒)"maxCount":100 // 最大缓存数}
用户信息
用户信息(Profile)应包括如下信息:
- Github账号信息;由于我们的APP可以切换账号登录,且登录后再次打开则不需要登录,所以我们需要对用户账号信息和登录状态进行持久化。
- 应用使用配置信息;每一个用户都应有自己的APP配置信息,如主题、语言、以及数据缓存策略等。
- 用户注销登录后,为了便于用户在退出APP前再次登录,我们需要记住上次登录的用户名。
需要注意的是,目前Github有三种登录方式,分别是账号密码登录、oauth授权登录、二次认证登录;这三种登录方式的安全性依次加强,但是在本示例中,为了简单起见,我们使用账号密码登录,因此我们需要保存用户的密码。
注意:在这里需要提醒读者,在登录场景中,保护用户账号安全是一个非常重要且永恒的话题,在实际开发中应严格杜绝直接明文存储用户账密的行为。
我们在“jsons”目录下创建一个“profile.json”文件,结构如下:
{"user":"$user", //Github账号信息,结构见"user.json""token":"", // 登录用户的token(oauth)或密码"theme":5678, //主题色值"cache":"$cacheConfig", // 缓存策略信息,结构见"cacheConfig.json""lastLogin":"", //最近一次的注销登录的用户名"locale":"" // APP语言信息}
项目信息
由于APP主页要显示其所有项目信息,我们在“jsons”目录下创建一个“repo.json”文件保存项目信息。通过参考Github 获取项目信息的API文档,定义出最终的“repo.json”文件结构,如下:
{"id": 1296269,"name": "Hello-World", //项目名称"full_name": "octocat/Hello-World", //项目完整名称"owner": "$user", // 项目拥有者,结构见"user.json""parent":"$repo", // 如果是fork的项目,则此字段表示fork的父项目信息"private": false, // 是否私有项目"description": "This your first repo!", //项目描述"fork": false, // 该项目是否为fork的项目"language": "JavaScript",//该项目的主要编程语言"forks_count": 9, // fork了该项目的数量"stargazers_count": 80, //该项目的star数量"size": 108, // 项目占用的存储大小"default_branch": "master", //项目的默认分支"open_issues_count": 2, //该项目当前打开的issue数量"pushed_at": "2011-01-26T19:06:43Z","created_at": "2011-01-26T19:01:12Z","updated_at": "2011-01-26T19:14:43Z","subscribers_count": 42, //订阅(关注)该项目的人数"license": { // 该项目的开源许可证"key": "mit","name": "MIT License","spdx_id": "MIT","url": "https://api.github.com/licenses/mit","node_id": "MDc6TGljZW5zZW1pdA=="}...//省略其它字段}
生成Dart Model类
现在,我们需要的Json数据已经定义完毕,现在只需要运行json_model package提供的命令来通过json文件生成相应的Dart类:
flutter packages pub run json_model
命令执行成功后,可以看到lib/models文件夹下会生成相应的Dart Model类:
├── models│ ├── cacheConfig.dart│ ├── cacheConfig.g.dart│ ├── index.dart│ ├── profile.dart│ ├── profile.g.dart│ ├── repo.dart│ ├── repo.g.dart│ ├── user.dart│ └── user.g.dart
数据持久化
我们使用shared_preferences包来对登录用户的Profile信息进行持久化。shared_preferences是一个Flutter插件,它通过Android和iOS平台提供的机制来实现数据持久化。由于shared_preferences的使用非常简单,读者可以自行查看其文档,在此不再赘述。
全局变量及共享状态
应用程序中通常会包含一些贯穿APP生命周期的变量信息,这些信息在APP大多数地方可能都会被用到,比如当前用户信息、Local信息等。在Flutter中我们把需要全局共享的信息分为两类:全局变量和共享状态。全局变量就是单纯指会贯穿整个APP生命周期的变量,用于单纯的保存一些信息,或者封装一些全局工具和方法的对象。而共享状态则是指哪些需要跨组件或跨路由共享的信息,这些信息通常也是全局变量,而共享状态和全局变量的不同在于前者发生改变时需要通知所有使用该状态的组件,而后者不需要。为此,我们将全局变量和共享状态分开单独管理。
15.4.1 全局变量-Global类
我们在“lib/common”目录下创建一个Global类,它主要管理APP的全局变量,定义如下:
// 提供五套可选主题色const _themes = <MaterialColor>[Colors.blue,Colors.cyan,Colors.teal,Colors.green,Colors.red,];class Global {static SharedPreferences _prefs;static Profile profile = Profile();// 网络缓存对象static NetCache netCache = NetCache();// 可选的主题列表static List<MaterialColor> get themes => _themes;// 是否为release版static bool get isRelease => bool.fromEnvironment("dart.vm.product");//初始化全局信息,会在APP启动时执行static Future init() async {_prefs = await SharedPreferences.getInstance();var _profile = _prefs.getString("profile");if (_profile != null) {try {profile = Profile.fromJson(jsonDecode(_profile));} catch (e) {print(e);}}// 如果没有缓存策略,设置默认缓存策略profile.cache = profile.cache ?? CacheConfig()..enable = true..maxAge = 3600..maxCount = 100;//初始化网络请求相关配置Git.init();}// 持久化Profile信息static saveProfile() =>_prefs.setString("profile", jsonEncode(profile.toJson()));}
Global类的各个字段的意义都有注释,在此不再赘述,需要注意的是init()需要在App启动时就要执行,所以应用的main方法如下:
void main() => Global.init().then((e) => runApp(MyApp()));
在此,一定要确保Global.init()方法不能抛出异常,否则 runApp(MyApp())根本执行不到。
15.4.2 共享状态
有了全局变量,我们还需要考虑如何跨组件共享状态。当然,如果我们将要共享的状态全部用全局变量替代也是可以的,但是这在Flutter开发中并不是一个好主意,因为组件的状态是和UI相关,而在状态改变时我们会期望依赖该状态的UI组件会自动更新,如果使用全局变量,那么我们必须得去手动处理状态变动通知、接收机制以及变量和组件依赖关系。因此,本实例中,我们使用前面介绍过的Provider包来实现跨组件状态共享,因此我们需要定义相关的Provider。在本实例中,需要共享的状态有登录用户信息、APP主题信息、APP语言信息。由于这些信息改变后都要立即通知其它依赖的该信息的Widget更新,所以我们应该使用ChangeNotifierProvider,另外,这些信息改变后都是需要更新Profile信息并进行持久化的。综上所述,我们可以定义一个ProfileChangeNotifier基类,然后让需要共享的Model继承自该类即可,ProfileChangeNotifier定义如下:
class ProfileChangeNotifier extends ChangeNotifier {Profile get _profile => Global.profile;@overridevoid notifyListeners() {Global.saveProfile(); //保存Profile变更super.notifyListeners(); //通知依赖的Widget更新}}
用户状态
用户状态在登录状态发生变化时更新、通知其依赖项,我们定义如下:
class UserModel extends ProfileChangeNotifier {User get user => _profile.user;// APP是否登录(如果有用户信息,则证明登录过)bool get isLogin => user != null;//用户信息发生变化,更新用户信息并通知依赖它的子孙Widgets更新set user(User user) {if (user?.login != _profile.user?.login) {_profile.lastLogin = _profile.user?.login;_profile.user = user;notifyListeners();}}}
APP主题状态
主题状态在用户更换APP主题时更新、通知其依赖项,定义如下:
class ThemeModel extends ProfileChangeNotifier {// 获取当前主题,如果为设置主题,则默认使用蓝色主题ColorSwatch get theme => Global.themes.firstWhere((e) => e.value == _profile.theme, orElse: () => Colors.blue);// 主题改变后,通知其依赖项,新主题会立即生效set theme(ColorSwatch color) {if (color != theme) {_profile.theme = color[500].value;notifyListeners();}}}
APP语言状态
当APP语言选为跟随系统(Auto)时,在系通语言改变时,APP语言会更新;当用户在APP中选定了具体语言时(美国英语或中文简体),则APP便会一直使用用户选定的语言,不会再随系统语言而变。语言状态类定义如下:
class LocaleModel extends ProfileChangeNotifier {// 获取当前用户的APP语言配置Locale类,如果为null,则语言跟随系统语言Locale getLocale() {if (_profile.locale == null) return null;var t = _profile.locale.split("_");return Locale(t[0], t[1]);}// 获取当前Locale的字符串表示String get locale => _profile.locale;// 用户改变APP语言后,通知依赖项更新,新语言会立即生效set locale(String locale) {if (locale != _profile.locale) {_profile.locale = locale;notifyListeners();}}}
网络请求封装
本节我们会基于前面介绍过的dio网络库封装APP中用到的网络请求接口,并同时应用一个简单的缓存策略。下面我们先介绍一下网络接口缓存原理,然后再封装APP的业务请求接口。
15.5.1 网络接口缓存
由于在国内访问Github服务器速度较慢,所以我们应用一些简单的缓存策略:将请求的url作为key,对请求的返回值在一个指定时间段类进行缓存,另外设置一个最大缓存数,当超过最大缓存数后移除最早的一条缓存。但是也得提供一种针对特定接口或请求决定是否启用缓存的机制,这种机制可以指定哪些接口或那次请求不应用缓存,这种机制是很有必要的,比如登录接口就不应该缓存,又比如用户在下拉刷新时就不应该再应用缓存。在实现缓存之前我们先定义保存缓存信息的CacheObject类:
class CacheObject {CacheObject(this.response): timeStamp = DateTime.now().millisecondsSinceEpoch;Response response;int timeStamp; // 缓存创建时间@overridebool operator ==(other) {return response.hashCode == other.hashCode;}//将请求uri作为缓存的key@overrideint get hashCode => response.realUri.hashCode;}
接下来我们需要实现具体的缓存策略,由于我们使用的是dio package,所以我们可以直接通过拦截器来实现缓存策略:
import 'dart:collection';import 'package:dio/dio.dart';import '../index.dart';class CacheObject {CacheObject(this.response): timeStamp = DateTime.now().millisecondsSinceEpoch;Response response;int timeStamp;@overridebool operator ==(other) {return response.hashCode == other.hashCode;}@overrideint get hashCode => response.realUri.hashCode;}class NetCache extends Interceptor {// 为确保迭代器顺序和对象插入时间一致顺序一致,我们使用LinkedHashMapvar cache = LinkedHashMap<String, CacheObject>();@overrideonRequest(RequestOptions options) async {if (!Global.profile.cache.enable) return options;// refresh标记是否是"下拉刷新"bool refresh = options.extra["refresh"] == true;//如果是下拉刷新,先删除相关缓存if (refresh) {if (options.extra["list"] == true) {//若是列表,则只要url中包含当前path的缓存全部删除(简单实现,并不精准)cache.removeWhere((key, v) => key.contains(options.path));} else {// 如果不是列表,则只删除uri相同的缓存delete(options.uri.toString());}return options;}if (options.extra["noCache"] != true &&options.method.toLowerCase() == 'get') {String key = options.extra["cacheKey"] ?? options.uri.toString();var ob = cache[key];if (ob != null) {//若缓存未过期,则返回缓存内容if ((DateTime.now().millisecondsSinceEpoch - ob.timeStamp) / 1000 <Global.profile.cache.maxAge) {return cache[key].response;} else {//若已过期则删除缓存,继续向服务器请求cache.remove(key);}}}}@overrideonError(DioError err) async {// 错误状态不缓存}@overrideonResponse(Response response) async {// 如果启用缓存,将返回结果保存到缓存if (Global.profile.cache.enable) {_saveCache(response);}}_saveCache(Response object) {RequestOptions options = object.request;if (options.extra["noCache"] != true &&options.method.toLowerCase() == "get") {// 如果缓存数量超过最大数量限制,则先移除最早的一条记录if (cache.length == Global.profile.cache.maxCount) {cache.remove(cache[cache.keys.first]);}String key = options.extra["cacheKey"] ?? options.uri.toString();cache[key] = CacheObject(object);}}void delete(String key) {cache.remove(key);}}
关于代码的解释都在注释中了,在此需要说明的是dio包的option.extra是专门用于扩展请求参数的,我们通过定义了“refresh”和“noCache”两个参数实现了“针对特定接口或请求决定是否启用缓存的机制”,这两个参数含义如下:
| 参数名 | 类型 | 解释 |
|---|---|---|
| refresh | bool | 如果为true,则本次请求不使用缓存,但新的请求结果依然会被缓存 |
| noCache | bool | 本次请求禁用缓存,请求结果也不会被缓存。 |
15.5.2 封装网络请求
一个完整的APP,可能会涉及很多网络请求,为了便于管理、收敛请求入口,工程上最好的作法就是将所有网络请求放到同一个源码文件中。由于我们的接口都是请求的Github 开发平台提供的API,所以我们定义一个Git类,专门用于Github API接口调用。另外,在调试过程中,我们通常需要一些工具来查看网络请求、响应报文,使用网络代理工具来调试网络数据问题是主流方式。配置代理需要在应用中指定代理服务器的地址和端口,另外Github API是HTTPS协议,所以在配置完代理后还应该禁用证书校验,这些配置我们在Git类初始化时执行(init()方法)。下面是Git类的源码:
import 'dart:async';import 'dart:convert';import 'dart:io';import 'package:dio/dio.dart';import 'package:dio/adapter.dart';import 'package:flutter/material.dart';import '../index.dart';class Git {// 在网络请求过程中可能会需要使用当前的context信息,比如在请求失败时// 打开一个新路由,而打开新路由需要context信息。Git([this.context]) {_options = Options(extra: {"context": context});}BuildContext context;Options _options;static Dio dio = new Dio(BaseOptions(baseUrl: 'https://api.github.com/',headers: {HttpHeaders.acceptHeader: "application/vnd.github.squirrel-girl-preview,""application/vnd.github.symmetra-preview+json",},));static void init() {// 添加缓存插件dio.interceptors.add(Global.netCache);// 设置用户token(可能为null,代表未登录)dio.options.headers[HttpHeaders.authorizationHeader] = Global.profile.token;// 在调试模式下需要抓包调试,所以我们使用代理,并禁用HTTPS证书校验if (!Global.isRelease) {(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate =(client) {client.findProxy = (uri) {return "PROXY 10.1.10.250:8888";};//代理工具会提供一个抓包的自签名证书,会通不过证书校验,所以我们禁用证书校验client.badCertificateCallback =(X509Certificate cert, String host, int port) => true;};}}// 登录接口,登录成功后返回用户信息Future<User> login(String login, String pwd) async {String basic = 'Basic ' + base64.encode(utf8.encode('$login:$pwd'));var r = await dio.get("/users/$login",options: _options.merge(headers: {HttpHeaders.authorizationHeader: basic}, extra: {"noCache": true, //本接口禁用缓存}),);//登录成功后更新公共头(authorization),此后的所有请求都会带上用户身份信息dio.options.headers[HttpHeaders.authorizationHeader] = basic;//清空所有缓存Global.netCache.cache.clear();//更新profile中的token信息Global.profile.token = basic;return User.fromJson(r.data);}//获取用户项目列表Future<List<Repo>> getRepos({Map<String, dynamic> queryParameters, //query参数,用于接收分页信息refresh = false}) async {if (refresh) {// 列表下拉刷新,需要删除缓存(拦截器中会读取这些信息)_options.extra.addAll({"refresh": true, "list": true});}var r = await dio.get<List>("user/repos",queryParameters: queryParameters,options: _options,);return r.data.map((e) => Repo.fromJson(e)).toList();}}
可以看到我们在init()方法中,我们判断了是否是调试环境,然后做了一些针对调试环境的网络配置(设置代理和禁用证书校验)。而Git.init()方法是应用启动时被调用的(Global.init()方法中会调用Git.init())。
另外需要注意,我们所有的网络请求是通过同一个dio实例(静态变量)发出的,在创建该dio实例时我们将Github API的基地址和API支持的Header进行了全局配置,这样所有通过该dio实例发出的请求都会默认使用者些配置。
在本实例中,我们只用到了登录接口和获取用户项目的接口,所以在Git类中只定义了login(…)和getRepos(…)方法,如果读者要在本实例的基础上扩充功能,读者可以将其它的接口请求方法添加到Git类中,这样便实现了网络请求接口在代码层面的集中管理和维护。
APP入口及主页
15.6.1 APP入口
main函数为APP入口函数,实现如下:
void main() => Global.init().then((e) => runApp(MyApp()));
初始化完成后才会加载UI(MyApp),MyApp 是应用的入口Widget,实现如下:
class MyApp extends StatelessWidget {// This widget is the root of your application.@overrideWidget build(BuildContext context) {return MultiProvider(providers: <SingleChildCloneableWidget>[ChangeNotifierProvider.value(value: ThemeModel()),ChangeNotifierProvider.value(value: UserModel()),ChangeNotifierProvider.value(value: LocaleModel()),],child: Consumer2<ThemeModel, LocaleModel>(builder: (BuildContext context, themeModel, localeModel, Widget child) {return MaterialApp(theme: ThemeData(primarySwatch: themeModel.theme,),onGenerateTitle: (context){return GmLocalizations.of(context).title;},home: HomeRoute(), //应用主页locale: localeModel.getLocale(),//我们只支持美国英语和中文简体supportedLocales: [const Locale('en', 'US'), // 美国英语const Locale('zh', 'CN'), // 中文简体//其它Locales],localizationsDelegates: [// 本地化的代理类GlobalMaterialLocalizations.delegate,GlobalWidgetsLocalizations.delegate,GmLocalizationsDelegate()],localeResolutionCallback:(Locale _locale, Iterable<Locale> supportedLocales) {if (localeModel.getLocale() != null) {//如果已经选定语言,则不跟随系统return localeModel.getLocale();} else {Locale locale;//APP语言跟随系统语言,如果系统语言不是中文简体或美国英语,//则默认使用美国英语if (supportedLocales.contains(_locale)) {locale= _locale;} else {locale= Locale('en', 'US');}return locale;}},// 注册命名路由表routes: <String, WidgetBuilder>{"login": (context) => LoginRoute(),"themes": (context) => ThemeChangeRoute(),"language": (context) => LanguageRoute(),},);},),);}}
在上面的代码中:
- 我们的根widget是
MultiProvider,它将主题、用户、语言三种状态绑定到了应用的根上,如此一来,任何路由中都可以通过Provider.of()来获取这些状态,也就是说这三种状态是全局共享的! HomeRoute是应用的主页。- 在构建
MaterialApp时,我们配置了APP支持的语言列表,以及监听了系统语言改变事件;另外MaterialApp消费(依赖)了ThemeModel和LocaleModel,所以当APP主题或语言改变时MaterialApp会重新构建 - 我们注册了命名路由表,以便在APP中可以直接通过路由名跳转。
- 为了支持多语言(本APP中我们支持美国英语和中文简体两种语言)我们实现了一个
GmLocalizationsDelegate,子Widget中都可以通过GmLocalizations来动态获取APP当前语言对应的文案。关于GmLocalizationsDelegate和GmLocalizations的实现方式读者可以参考“国际化”一章中的介绍,此处不再赘述。15.6.2 主页
为了简单起见,当APP启动后,如果之前已登录了APP,则显示该用户项目列表;如果之前未登录,则显示一个登录按钮,点击后跳转到登录页。另外,我们实现一个抽屉菜单,里面包含当前用户头像及APP的菜单。下面我们先看看要实现的效果,如图15-1、15-2所示:

我们在“lib/routes”下创建一个“home_page.dart”文件,实现如下:
上面代码中,主页的标题(title)我们是通过class HomeRoute extends StatefulWidget {@override_HomeRouteState createState() => _HomeRouteState();}class _HomeRouteState extends State<HomeRoute> {@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: Text(GmLocalizations.of(context).home),),body: _buildBody(), // 构建主页面drawer: MyDrawer(), //抽屉菜单);}...// 省略}
GmLocalizations.of(context).home来获得,GmLocalizations是我们提供的一个Localizations类,用于支持多语言,因此当APP语言改变时,凡是使用GmLocalizations动态获取的文案都会是相应语言的文案,这在前面“国际化”一章中已经介绍过,读者可以前翻查阅。
我们通过_buildBody()方法来构建主页内容,_buildBody()方法实现代码如下:
上面代码注释很清楚:如果用户未登录,显示登录按钮;如果用户已登录,则展示项目列表。这里项目列表使用了Widget _buildBody() {UserModel userModel = Provider.of<UserModel>(context);if (!userModel.isLogin) {//用户未登录,显示登录按钮return Center(child: RaisedButton(child: Text(GmLocalizations.of(context).login),onPressed: () => Navigator.of(context).pushNamed("login"),),);} else {//已登录,则展示项目列表return InfiniteListView<Repo>(onRetrieveData: (int page, List<Repo> items, bool refresh) async {var data = await Git(context).getRepos(refresh: refresh,queryParameters: {'page': page,'page_size': 20,},);//把请求到的新数据添加到items中items.addAll(data);// 如果接口返回的数量等于'page_size',则认为还有数据,反之则认为最后一页return data.length==20;},itemBuilder: (List list, int index, BuildContext ctx) {// 项目信息列表项return RepoItem(list[index]);},);}}}
InfiniteListViewWidget,它是flukit package中提供的。InfiniteListView同时支持了下拉刷新和上拉加载更多两种功能。onRetrieveData为数据获取回调,该回调函数接收三个参数:
| 参数名 | 类型 | 解释 |
|---|---|---|
| page | int | 当前页号 |
| items | List | 保存当前列表数据的List |
| refresh | bool | 是否是下拉刷新触发 |
返回值类型为bool,为true时表示还有数据,为false时则表示后续没有数据了。onRetrieveData 回调中我们调用Git(context).getRepos(...)来获取用户项目列表,同时指定每次请求获取20条。当获取成功时,首先要将新获取的项目数据添加到items中,然后根据本次请求的项目条数是否等于期望的20条来判断还有没有更多的数据。在此需要注意,Git(context).getRepos(…)方法中需要refresh参数来判断是否使用缓存。itemBuilder为列表项的builder,我们需要在该回调中构建每一个列表项Widget。由于列表项构建逻辑较复杂,我们单独封装一个RepoItem Widget 专门用于构建列表项UI。RepoItem 实现如下:
import '../index.dart';class RepoItem extends StatefulWidget {// 将`repo.id`作为RepoItem的默认keyRepoItem(this.repo) : super(key: ValueKey(repo.id));final Repo repo;@override_RepoItemState createState() => _RepoItemState();}class _RepoItemState extends State<RepoItem> {@overrideWidget build(BuildContext context) {var subtitle;return Padding(padding: const EdgeInsets.only(top: 8.0),child: Material(color: Colors.white,shape: BorderDirectional(bottom: BorderSide(color: Theme.of(context).dividerColor,width: .5,),),child: Padding(padding: const EdgeInsets.only(top: 0.0, bottom: 16),child: Column(crossAxisAlignment: CrossAxisAlignment.start,children: <Widget>[ListTile(dense: true,leading: gmAvatar(//项目owner头像widget.repo.owner.avatar_url,width: 24.0,borderRadius: BorderRadius.circular(12),),title: Text(widget.repo.owner.login,textScaleFactor: .9,),subtitle: subtitle,trailing: Text(widget.repo.language ?? ""),),// 构建项目标题和简介Padding(padding: const EdgeInsets.symmetric(horizontal: 16.0),child: Column(crossAxisAlignment: CrossAxisAlignment.start,children: <Widget>[Text(widget.repo.fork? widget.repo.full_name: widget.repo.name,style: TextStyle(fontSize: 15,fontWeight: FontWeight.bold,fontStyle: widget.repo.fork? FontStyle.italic: FontStyle.normal,),),Padding(padding: const EdgeInsets.only(top: 8, bottom: 12),child: widget.repo.description == null? Text(GmLocalizations.of(context).noDescription,style: TextStyle(fontStyle: FontStyle.italic,color: Colors.grey[700]),): Text(widget.repo.description,maxLines: 3,style: TextStyle(height: 1.15,color: Colors.blueGrey[700],fontSize: 13,),),),],),),// 构建卡片底部信息_buildBottom()],),),),);}// 构建卡片底部信息Widget _buildBottom() {const paddingWidth = 10;return IconTheme(data: IconThemeData(color: Colors.grey,size: 15,),child: DefaultTextStyle(style: TextStyle(color: Colors.grey, fontSize: 12),child: Padding(padding: const EdgeInsets.symmetric(horizontal: 16),child: Builder(builder: (context) {var children = <Widget>[Icon(Icons.star),Text(" " +widget.repo.stargazers_count.toString().padRight(paddingWidth)),Icon(Icons.info_outline),Text(" " +widget.repo.open_issues_count.toString().padRight(paddingWidth)),Icon(MyIcons.fork), //我们的自定义图标Text(widget.repo.forks_count.toString().padRight(paddingWidth)),];if (widget.repo.fork) {children.add(Text("Forked".padRight(paddingWidth)));}if (widget.repo.private == true) {children.addAll(<Widget>[Icon(Icons.lock),Text(" private".padRight(paddingWidth))]);}return Row(children: children);}),),),);}}
上面代码有两点需要注意:
在构建项目拥有者头像时调用了
gmAvatar(…)方法,该方法是是一个全局工具函数,专门用于获取头像图片,实现如下:Widget gmAvatar(String url, {double width = 30,double height,BoxFit fit,BorderRadius borderRadius,}) {var placeholder = Image.asset("imgs/avatar-default.png", //头像占位图,加载过程中显示width: width,height: height);return ClipRRect(borderRadius: borderRadius ?? BorderRadius.circular(2),child: CachedNetworkImage(imageUrl: url,width: width,height: height,fit: fit,placeholder: (context, url) =>placeholder,errorWidget: (context, url, error) =>placeholder,),);}
代码中调用了
CachedNetworkImage是cached_network_image包中提供的一个Widget,它不仅可以在图片加载过程中指定一个占位图,而且还可以对网络请求的图片进行缓存,更多详情读者可以自行查阅其文档。由于Flutter 的Material 图标库中没有fork图标,所以我们在iconfont.cn上找了一个fork图标,然后根据“图片和Icon”一节中介绍的使用自定义字体图标的方法集成到了我们的项目中。
15.6.3 抽屉菜单
抽屉菜单分为两部分:顶部头像和底部功能菜单项。当用户未登录,则抽屉菜单顶部会显示一个默认的灰色占位图,若用户已登录,则会显示用户的头像。抽屉菜单底部有“换肤”和“语言”两个固定菜单,若用户已登录,则会多一个“注销”菜单。用户点击“换肤”和“语言”两个菜单项,会进入相应的设置页面。我们的抽屉菜单效果如图15-3、15-4所示:


实现代码如下:class MyDrawer extends StatelessWidget {const MyDrawer({Key key,}) : super(key: key);@overrideWidget build(BuildContext context) {return Drawer(//移除顶部paddingchild: MediaQuery.removePadding(context: context,removeTop: true,child: Column(crossAxisAlignment: CrossAxisAlignment.start,children: <Widget>[_buildHeader(), //构建抽屉菜单头部Expanded(child: _buildMenus()), //构建功能菜单],),),);}Widget _buildHeader() {return Consumer<UserModel>(builder: (BuildContext context, UserModel value, Widget child) {return GestureDetector(child: Container(color: Theme.of(context).primaryColor,padding: EdgeInsets.only(top: 40, bottom: 20),child: Row(children: <Widget>[Padding(padding: const EdgeInsets.symmetric(horizontal: 16.0),child: ClipOval(// 如果已登录,则显示用户头像;若未登录,则显示默认头像child: value.isLogin? gmAvatar(value.user.avatar_url, width: 80): Image.asset("imgs/avatar-default.png",width: 80,),),),Text(value.isLogin? value.user.login: GmLocalizations.of(context).login,style: TextStyle(fontWeight: FontWeight.bold,color: Colors.white,),)],),),onTap: () {if (!value.isLogin) Navigator.of(context).pushNamed("login");},);},);}// 构建菜单项Widget _buildMenus() {return Consumer<UserModel>(builder: (BuildContext context, UserModel userModel, Widget child) {var gm = GmLocalizations.of(context);return ListView(children: <Widget>[ListTile(leading: const Icon(Icons.color_lens),title: Text(gm.theme),onTap: () => Navigator.pushNamed(context, "themes"),),ListTile(leading: const Icon(Icons.language),title: Text(gm.language),onTap: () => Navigator.pushNamed(context, "language"),),if(userModel.isLogin) ListTile(leading: const Icon(Icons.power_settings_new),title: Text(gm.logout),onTap: () {showDialog(context: context,builder: (ctx) {//退出账号前先弹二次确认窗return AlertDialog(content: Text(gm.logoutTip),actions: <Widget>[FlatButton(child: Text(gm.cancel),onPressed: () => Navigator.pop(context),),FlatButton(child: Text(gm.yes),onPressed: () {//该赋值语句会触发MaterialApp rebuilduserModel.user = null;Navigator.pop(context);},),],);},);},),],);},);}}
用户点击“注销”,
userModel.user会被置空,此时所有依赖userModel的组件都会被rebuild,如主页会恢复成未登录的状态。
本小节我们介绍了APP入口MaterialApp的一些配置,然后实现了APP的首页。后面我们将展示登录页、换肤页、语言切换页。登录页
我们说过Github有多种登录方式,为了简单起见,我们只实现通过用户名和密码登录。在实现登录页时有四点需要注意:
可以自动填充上次登录的用户名(如果有)。
- 为了防止密码输入错误,密码框应该有开关可以看明文。
- 用户名或密码字段在调用登录接口前有本地合法性校验(比如不能为空)。
- 登录成功后需更新用户信息。
实现代码如下:
import '../index.dart';class LoginRoute extends StatefulWidget {@override_LoginRouteState createState() => _LoginRouteState();}class _LoginRouteState extends State<LoginRoute> {TextEditingController _unameController = new TextEditingController();TextEditingController _pwdController = new TextEditingController();bool pwdShow = false; //密码是否显示明文GlobalKey _formKey = new GlobalKey<FormState>();bool _nameAutoFocus = true;@overridevoid initState() {// 自动填充上次登录的用户名,填充后将焦点定位到密码输入框_unameController.text = Global.profile.lastLogin;if (_unameController.text != null) {_nameAutoFocus = false;}super.initState();}@overrideWidget build(BuildContext context) {var gm = GmLocalizations.of(context);return Scaffold(appBar: AppBar(title: Text(gm.login)),body: Padding(padding: const EdgeInsets.all(16.0),child: Form(key: _formKey,autovalidate: true,child: Column(children: <Widget>[TextFormField(autofocus: _nameAutoFocus,controller: _unameController,decoration: InputDecoration(labelText: gm.userName,hintText: gm.userNameOrEmail,prefixIcon: Icon(Icons.person),),// 校验用户名(不能为空)validator: (v) {return v.trim().isNotEmpty ? null : gm.userNameRequired;}),TextFormField(controller: _pwdController,autofocus: !_nameAutoFocus,decoration: InputDecoration(labelText: gm.password,hintText: gm.password,prefixIcon: Icon(Icons.lock),suffixIcon: IconButton(icon: Icon(pwdShow ? Icons.visibility_off : Icons.visibility),onPressed: () {setState(() {pwdShow = !pwdShow;});},)),obscureText: !pwdShow,//校验密码(不能为空)validator: (v) {return v.trim().isNotEmpty ? null : gm.passwordRequired;},),Padding(padding: const EdgeInsets.only(top: 25),child: ConstrainedBox(constraints: BoxConstraints.expand(height: 55.0),child: RaisedButton(color: Theme.of(context).primaryColor,onPressed: _onLogin,textColor: Colors.white,child: Text(gm.login),),),),],),),),);}void _onLogin() async {// 提交前,先验证各个表单字段是否合法if ((_formKey.currentState as FormState).validate()) {showLoading(context);User user;try {user = await Git(context).login(_unameController.text, _pwdController.text);// 因为登录页返回后,首页会build,所以我们传false,更新user后不触发更新Provider.of<UserModel>(context, listen: false).user = user;} catch (e) {//登录失败则提示if (e.response?.statusCode == 401) {showToast(GmLocalizations.of(context).userNameOrPasswordWrong);} else {showToast(e.toString());}} finally {// 隐藏loading框Navigator.of(context).pop();}if (user != null) {// 返回Navigator.of(context).pop();}}}}
代码很简单,关键地方都有注释,不再赘述,下面我们看一下运行效果,如图15-5所示。
多语言和多主题
本实例APP中语言和主题都是可以设置的,而两者都是通过ChangeNotifierProvider来实现的:我们在main函数中使用了Consumer2,依赖了ThemeModel和LocaleModel,因此,当我们在语言和主题设置页更该当前的配置后,Consumer2的builder都会重新执行,构建一个新的MaterialApp,所以修改会立即生效。下面看一下语言和主题设置页的实现。
15.8.1 语言选择页
APP语言选择页提供三个选项:中文简体、美国英语、跟随系统。我们将当前APP使用的语言高亮显示,并且在后面添加一个“对号”图标,实现如下:
class LanguageRoute extends StatelessWidget {@overrideWidget build(BuildContext context) {var color = Theme.of(context).primaryColor;var localeModel = Provider.of<LocaleModel>(context);var gm = GmLocalizations.of(context);//构建语言选择项Widget _buildLanguageItem(String lan, value) {return ListTile(title: Text(lan,// 对APP当前语言进行高亮显示style: TextStyle(color: localeModel.locale == value ? color : null),),trailing:localeModel.locale == value ? Icon(Icons.done, color: color) : null,onTap: () {// 更新locale后MaterialApp会重新buildlocaleModel.locale = value;},);}return Scaffold(appBar: AppBar(title: Text(gm.language),),body: ListView(children: <Widget>[_buildLanguageItem("中文简体", "zh_CN"),_buildLanguageItem("English", "en_US"),_buildLanguageItem(gm.auto, null),],),);}}
上面代码逻辑很简单,唯一需要注意的是我们在build(…)方法里面定义了_buildLanguageItem(…)方法,它和在LanguageRoute类中定义该方法的区别就在于:在build(…)内定义的方法可以共享build(...)方法上下文中的变量,本例中是共享了localeModel。当然,如果_buildLanguageItem(…)的实现复杂一些的话不建议这样做,此时最好是将其作为LanguageRoute类的方法。该页面运行效果如图15-6、15-7所示:

切换语言后立即生效。
15.8.2 主题选择页
一个完整的主题Theme包括很多选项,这些选项在ThemeData中定义。本实例为了简单起见,我们只配置主题颜色。我们提供几种默认预定义的主题色供用户选择,用户点击一种色块后则更新主题。主题选择页的实现代码如下:
class ThemeChangeRoute extends StatelessWidget{@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: Text(GmLocalizations.of(context).theme),),body: ListView( //显示主题色块children: Global.themes.map<Widget>((e) {return GestureDetector(child: Padding(padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 16),child: Container(color: e,height: 40,),),onTap: () {//主题更新后,MaterialApp会重新buildProvider.of<ThemeModel>(context).theme = e;},);}).toList(),),);}}
运行效果如图15-8所示:
点击其它主题色块后,APP主题色立马切换生效。
