- 今年的互联网寒冬真是冰冷刺骨,高温+限电+地震+疫情让所有人都感觉到2022年活下来就好,既然疫情下的经济衰退无法避免,那就只能多准备点保暖的装备过冬了,作为菜鸟的我焦虑是无法避免的而学习可以平复心中的不安,大环境无法改变只能抓紧时间充电了,期待破局的那一天。
- 移动端开发趋近于饱和已经很久了,传统的开发方式需要ios和Android的两端的人力,为了达到双端一致的效果少不了沟通和UI交互校对的成本,如果项目以UI交互为主利用跨平台开发是有优势的,一套代码打造各端UI一致的体验,性能接近原生,提升人效受各大企业青睐,可能这是客户端最终的归宿吧,前有facebook的React后有google的flutter,连compose都有跨平台发展的迹象,随着flutter3.0的发布已经稳定支持所有终端,作为Android开发者面对这种强大的开发框架必须要赶紧学习起来,保持新技术的学习热情,顺应互联网时代的快速发展。
- 公司的项目中已经用上了flutter,对于二级页面采用混合式的开发方式,集成flutter到原生的项目当中,目前性能和稳定性表现都是非常不错的,由于hot reload开发效率也提升了不少,开发了一段时间后,我利用Android开发中的MVI架构思想和GetX状态管理封装了flutter的开发架构,目前集成到项目中在线上稳定运行,欢迎交流和讨论~
架构图
- 先放上一张整体的架构图~
- 借鉴Android的MVI架构思想,整理一下大概思路:
- ViewModel 会存储并公开界面要使用的状态。界面状态是经过 ViewModel 转换的应用数据
- 界面会向 ViewModel 发送用户事件通知
- ViewModel 会处理用户操作并更新状态
- 更新后的状态将反馈给界面呈现
- ViewModel的数据源是两种,本地数据源和服务端数据源,本地数据源来自于文件和数据库,服务端数据源是web服务端接口提供的数据
Repository
LocalDataSource本地存储
引入shared_preferences到pubspec.yaml
工具类封装
对shared_preferences进行封装,调用shared_preferences
1 | Future<bool> saveString2Sp(String key, String value) async { |
本地存储基类
调用封装的工具类实现本地数据的操作
- 本地数据缓存
- 本地数据获取
- 删除本地数据
1
2
3
4
5
6
7
8
9
10
11
12
13abstract class BaseLocalDataSource {
Future<bool> saveCache(String cacheData, String key) async {
return saveString2Sp(key, cacheData);
}
Future<String?> readCache(String key) async {
return getStringFromSp(key);
}
Future<bool?> deleteCache(String key) async {
return removeSp(key);
}
}网络请求基类
引入dio到pubspec.yaml,初始化网络请求DioManager对象供实现类使用,封装了网络请求的Dio API1
2
3abstract class BaseRemoteDataSource {
final DioManager dioManager = DioManager.getInstance()!;
}Repository基类
暴露本地数据操作接口 - 本地数据缓存
- 本地数据获取
- 删除本地数据
1
2
3
4
5abstract class BaseRepository {
Future<bool> saveCache(String cacheData, String key);
Future<String?> readCache(String key);
Future<bool?> deleteCache(String key);
}Repository基础实现类
- 需外部传入本地数据,网络请求操作对象
- 方便外部自定义BaseLocalDataSource,替换shared_preferences,实现本地数据操作方法
- 方便外部自定义BaseRemoteDataSource, 定义网络请求的相关接口
- 网络请求的相关接口在外部Repository实现类中实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24abstract class BaseRepositoryImp<Local extends BaseLocalDataSource,
Remote extends BaseRemoteDataSource> implements BaseRepository {
final Local? localDataSource;
final Remote remoteDataSource;
BaseRepositoryImp({this.localDataSource, required this.remoteDataSource});
Future<bool> saveCache(String cacheData, String key) async {
var isSuccess = await localDataSource?.saveCache(cacheData, key);
return isSuccess ?? false;
}
Future<String?> readCache(String key) async {
return localDataSource?.readCache(key);
}
Future<bool?> deleteCache(String key) async {
var isSuccess = await localDataSource?.deleteCache(key);
return isSuccess ?? false;
}
}Binding基类
注入repository实现类1
2
3
4// 页面binding封装,注入repository
abstract class BaseBinding<R extends BaseRepository> extends Bindings {
R provideRepository();
}封装页面基类
继承StatelessWidget,暴露viewModel的get接口,重写build方法1
2
3
4
5
6
7abstract class BaseView<T extends BaseViewModel> extends StatelessWidget {
const BaseView({Key? key}) : super(key: key);
T get viewModel => GetInstance().find<T>();
Widget build(BuildContext context);
}封装页面基类ViewModel
- 引入GetX到pubspec.yaml
- FullLifeCycleController的生命周期
- onInit Controller初始化
- onReady 处理异步事件,网络请求
- onResumed 应用可见且页面回到前台
- onInactive 应用在前台但页面不可见
- onPaused 应用不可见且页面到后台
- onDetach 页面视图销毁
- onClose 关闭流对象,动画,释放内存,数据持久化
- 继承FullLifeCycleController混入FullLifeCycleMixin重写生命周期方法,传入Repository的实现类定义Repository对象的具体类型,初始化数据埋点收集对象TrackPageViewHelper,通过桥调用native SDK中的API实现的,传入Repository对象,在生命周期做数据埋点,onInit和onResumed调用数据埋点的onPageShow,onInactive和onClose调用数据埋点的onPageHide
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
59abstract class BaseViewModel<Repository extends BaseRepository?>
extends FullLifeCycleController with FullLifeCycleMixin {
final Repository? repository;
BaseViewModel({this.repository});
TrackPageViewHelper? trackPageViewHelper;
String pageId() => '';
void onInit() {
if (pageId().isNotEmpty) {
trackPageViewHelper = TrackPageViewHelper(pageId());
trackPageViewHelper?.onPageShow();
}
/// viewModel的初始化工作,例如一些成员属性的初始化
super.onInit();
}
void onReady() {
/// 处理异步事件,比如网络请求
super.onReady();
}
void onClose() {
if (pageId().isNotEmpty) {
trackPageViewHelper?.onPageHide();
}
super.onClose();
}
void onResumed() {
if (pageId().isNotEmpty) {
trackPageViewHelper?.onPageShow();
}
}
void onInactive() {
if (pageId().isNotEmpty) {
trackPageViewHelper?.onPageHide();
}
}
void onPaused() {}
void onDetached() {}
}通用页面基类
根据当前封装的页面基类和ViewModel基类封装普通/列表/下拉刷新列表页,分别封装它们对应的UIState/Page/ViewModel普通页基类
封装UIState
UIState只定义与UI刷新相关的变量,结合GetX的Obx机制刷新使用obs变量,普通页的UIState封装内容View的显示状态,根据当前的几个状态加载中/空页面/错误页来展示页面当前View1
2
3
4
5
6
7// 只定义UI显示所需要的state,其他和界面显示无关的state,请放在ViewModel
// 普通页面UIState
abstract class BasePageUIState {
bool isLoading = true;
bool isEmpty = false;
bool isError = false;
}封装ViewModel
传入UIState的实例化对象和Repository的实例化对象,继承自前面封装的基类ViewModel,暴露状态传入的接口并利用GetX的GetBuilder刷新状态视图1
2
3
4
5
6
7
8
9
10
11
12
13
14
15abstract class BasePageViewModel<UIState extends BasePageUIState,
Repository extends BaseRepository?> extends BaseViewModel<Repository?> {
BasePageViewModel({Repository? repository, required this.uiState})
: super(repository: repository);
final UIState uiState;
void activeStatus(
{bool isLoading = false, bool isEmpty = false, bool isError = false}) {
uiState.isLoading = isLoading;
uiState.isEmpty = isEmpty;
uiState.isError = isError;
update(['status']);
}
}封装普通页面
- 定义页面背景色,appBar背景色
- 重写build方法,设置页面背景色和appBar背景色
- 标题居中,设置左侧返回按钮,右侧操作按钮
- 暴露标题设置,左侧/右侧按钮设置,空界面,网络错误界面,重试,点击返回,内容界面绘制接口
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
125
126
127
128abstract class BasePage<T extends BasePageViewModel> extends BaseView<T> {
const BasePage({Key? key}) : super(key: key);
Color get backgroundColor => ColorConfig.background;
Color get appBarBackgroundColor => ColorConfig.background;
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: backgroundColor,
resizeToAvoidBottomInset: false,
appBar: appbar(context),
body: body(context),
);
}
PreferredSizeWidget? appbar(BuildContext buildContext) {
return AppBar(
brightness: Global.isDark! ? Brightness.dark : Brightness.light,
backgroundColor: appBarBackgroundColor,
elevation: 0,
centerTitle: true,
leading: leading(buildContext),
title: title(),
actions: actions(),
);
}
/// 返回按钮
Widget? leading(BuildContext context) {
return IconButton(
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
icon: Image.asset(
'resource/images/icon_back.png',
width: 24,
height: 24,
color: ColorConfig.emphasis,
),
onPressed: () {
onBackClick(context);
},
);
}
/// 标题字符串
String titleString() => '';
/// 标题
Widget? title() {
return Container(
padding: EdgeInsetsDirectional.only(bottom: 5),
child: Text(
titleString(),
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: ColorConfig.emphasis,
fontSize: 18,
),
textAlign: TextAlign.center,
),
);
}
List<Widget>? actions() {
return null;
}
Widget body(BuildContext context) {
return GetBuilder<T>(
id: 'status',
builder: (viewModel) {
if (viewModel.uiState.isLoading) {
return loading();
} else if (viewModel.uiState.isEmpty) {
return empty();
} else if (viewModel.uiState.isError) {
return networkError();
} else {
return content(context);
}
});
}
/// 子类实现该方法,创建自己的UI
Widget content(BuildContext context);
Widget loading() {
return const PageLoading();
}
Widget empty() {
return const Padding(
padding: EdgeInsetsDirectional.only(
top: 152,
),
child: PageEmpty(),
);
}
Widget networkError() {
return PageNetworkError(
onRetry: onRetry,
);
}
/// 点击重试时回调
void onRetry() {}
void onBackClick(BuildContext context) {
if (Navigator.canPop(context)) {
Navigator.pop(context);
} else {
Global.bridge.pop();
}
}
}列表页基类
封装列表UIState
继承普通页的UIState增加dataList变量1
2
3
4
5
6/**
* 普通列表UIState
*/
class BaseListPageUIState<T> extends BasePageUIState {
final List<T> dataList = [];
}封装列表ViewModel
传入列表UIState实现类,列表Repository实现类,重写onReady方法调数据刷新接口1
2
3
4
5
6
7
8
9
10
11
12
13
14
15abstract class BaseListPageViewModel<UIState extends BaseListPageUIState,
Repository extends BaseRepository>
extends BasePageViewModel<UIState, Repository> {
BaseListPageViewModel(
{required Repository repository, required UIState uiState})
: super(repository: repository, uiState: uiState);
void onReady() {
refreshData(isFirstLoading: true);
super.onReady();
}
Future<void> refreshData({bool isFirstLoading = false});
}封装列表页
继承普通页面基类,传入列表ViewModel,重写content方法返回带下拉刷新的listview,暴露itemExtent接口设置固定的高度重写可提升性能,设置itemCount为viewModel内置UIState中的dataList的数据长度,暴露listview的item接口给外部定义视图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
35abstract class BaseListPage<T extends BaseListPageViewModel>
extends BasePage<T> {
const BaseListPage({Key? key}) : super(key: key);
Widget content(BuildContext context) {
return GetBuilder<T>(
id: 'listview',
builder: (viewModel) {
return EasyRefresh(
onRefresh: () async {
await viewModel.refreshData();
},
child: ListView.builder(
scrollDirection: Axis.vertical,
itemExtent: itemExtent(),
itemCount: itemCount(),
shrinkWrap: true,
itemBuilder: (BuildContext context, int index) {
return item(context, index);
},
),
);
});
}
/// 已知item固定高度时设置itemExtent值,提升列表滚动性能
double? itemExtent() => null;
int? itemCount() => viewModel.uiState.dataList.length;
Widget item(BuildContext context, int index);
}下拉刷新列表页基类
封装UIState
继承列表UIState增加isEmptyList变量用于控制是否展示空列表视图1
2
3class BaseLoadMoreListPageUIState<T> extends BaseListPageUIState<T> {
bool isEmptyList = false;
}封装ViewModel
继承列表ViewModel传入列表UIState实现类,列表Repository实现类,定义EasyRefreshController对象并在onInit中初始化,定义当前页数和是否有下一页变量支持分页加载,暴露加载下一页数据和刷新列表的接口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
33abstract class BaseLoadMoreListViewModel<
UIState extends BaseLoadMoreListPageUIState,
Repository extends BaseRepository>
extends BaseListPageViewModel<UIState, Repository> {
late final EasyRefreshController controller;
int currentPage = 1;
bool hasNext = false;
BaseLoadMoreListViewModel(
{required Repository repository, required UIState uiState})
: super(repository: repository, uiState: uiState);
void onInit() {
controller = EasyRefreshController();
super.onInit();
}
// 加载下一页数据时,后端某些分页api采用偏移量计算下一页数据;某些是currentPage自增;
Future<void> loadNextPageData();
void onClose() {
controller.dispose();
super.onClose();
}
void updatePageList() {
update(['listView']);
}
}封装加载更多列表页
继承列表页基类,传入加载更多列表的ViewModel,重写content方法,利用GetX的GetBuilder设置列表的id,设置列表的下拉刷新和加载更多回调,根据UIState的isEmptyList设置通用空白页面的显示,设置列表的通用底部视图,如果列表不为空加载下一页否则不加载数据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
57abstract class BaseLoadMoreListPage<T extends BaseLoadMoreListViewModel>
extends BaseListPage<T> {
const BaseLoadMoreListPage({Key? key}) : super(key: key);
Widget content(BuildContext context) {
return GetBuilder<T>(
id: 'listView',
builder: (viewModel) {
return createContentWidget(viewModel);
},
);
}
Widget createContentWidget(viewModel) {
return EasyRefresh(
enableControlFinishLoad: false,
enableControlFinishRefresh: false,
taskIndependence: true,
topBouncing: false,
behavior: null,
bottomBouncing: false,
footer: PageFooter(),
controller: viewModel.controller,
emptyWidget: viewModel.uiState.isEmptyList ? empty() : null,
onRefresh: () async {
await viewModel.refreshData();
},
onLoad:
viewModel.uiState.isEmptyList || viewModel.uiState.dataList.isEmpty
? null
: () async {
await viewModel.loadNextPageData();
},
child: ListView.builder(
scrollDirection: Axis.vertical,
itemExtent: itemExtent(),
itemCount: itemCount(),
shrinkWrap: true,
itemBuilder: (BuildContext context, int index) {
return item(context, index);
},
),
);
}
Widget empty() {
return const Padding(
padding: EdgeInsetsDirectional.only(
top: 40,
),
child: PageEmpty(),
);
}
}封装通用视图
空界面
通用的空界面视图,中间展示图标和文本,适配黑白模式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
41class PageEmpty extends StatelessWidget {
const PageEmpty({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Container(
alignment: Alignment.topCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Image.asset(
_provideImagePath(),
width: 180,
height: 180,
),
const Padding(
padding: EdgeInsetsDirectional.only(top: 12),
),
Text(
'empty',
style: TextStyle(
fontSize: 14,
color: ColorConfig.emphasis60,
),
),
],
),
);
}
String _provideImagePath() {
bool isDark = Global.isDark ?? false;
if (isDark) {
return "resource/images/ic_no_record_dark.png";
} else {
return "resource/images/ic_no_record.png";
}
}
}列表底部视图
通用的列表底部视图,居中展示文本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
43class PageFooter extends Footer {
final LinkFooterNotifier linkNotifier = LinkFooterNotifier();
PageFooter({bool safeArea = true}) : super(safeArea: safeArea);
Widget contentBuilder(
BuildContext context,
LoadMode loadState,
double pulledExtent,
double loadTriggerPullDistance,
double loadIndicatorExtent,
AxisDirection axisDirection,
bool float,
Duration? completeDuration,
bool enableInfiniteLoad,
bool success,
bool noMore) {
linkNotifier.contentBuilder(
context,
loadState,
pulledExtent,
loadTriggerPullDistance,
loadIndicatorExtent,
axisDirection,
float,
completeDuration,
enableInfiniteLoad,
success,
noMore);
return SafeArea(
child: Container(
height: 32,
child: Center(
child: Text(
noMore ? 'no_more_data' : 'loading',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: ColorConfig.emphasis40),
),
),
),
);
}
}加载视图
封装LoadingView
引入Lottie到pubspec.yaml,传入文本并展示加载图标通用的加载视图,中间展示LoadingView1
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
46class LoadingView extends StatefulWidget {
final String tips;
final Size size;
const LoadingView({
Key? key,
this.tips = '',
this.size = const Size.square(32),
}) : super(key: key);
State<LoadingView> createState() => _LoadingViewState();
}
class _LoadingViewState extends State<LoadingView> {
Widget build(BuildContext context) {
return UnconstrainedBox(
child: Column(
children: [
Container(
alignment: Alignment.center,
width: widget.size.width,
height: widget.size.height,
child: Lottie.asset(
'resource/lottie/loading.json',
animate: true,
),
),
widget.tips.isEmpty
? SizedBox()
: Text(
widget.tips,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
fontFamily: 'URWDIN',
fontWeight: FontWeight.w400,
color: ColorConfig.emphasis,
),
),
],
),
);
}
}1
2
3
4
5
6
7
8class PageLoading extends StatelessWidget {
const PageLoading({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return const Center(child: LoadingView(size: Size.square(50)));
}
}网络错误视图
通用的网络错误,中间展示图标和文字暴露重试接口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
34class PageNetworkError extends StatelessWidget {
final OnRetry? onRetry;
const PageNetworkError({
Key? key,
this.onRetry,
}) : super(key: key);
Widget build(BuildContext context) {
return GestureDetector(
child: Container(
alignment: Alignment.center,
margin: EdgeInsetsDirectional.only(top: 50),
child: Column(
children: <Widget>[
Image.asset(
'resource/images/candy_net_error.png',
width: 250,
height: 100,
),
Text(
'network_error',
style: TextStyle(fontSize: 14, color: ColorConfig.emphasis60),
),
],
),
),
onTap: () => onRetry,
);
}
}
typedef OnRetry = void Function();应用场景
普通页面-登录页面
这里用wanandroid的api来举例登录页面LoginApi
返回登录和注册的url1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18class LoginApi {
static LoginApi? _instance;
LoginApi._internal();
factory LoginApi.getInstance() {
_instance ??= LoginApi._internal();
return _instance!;
}
String getLoginUrl() {
return "/user/login";
}
String getRegisterUrl() {
return "/user/register";
}
}接口返回的bean类
所有字段均可空,实现fromJson方法接收map实例返回实例和toJson方法返回序列化后的map对象,可以用插件JsonToDart也可以用在线转换[0][传送门]再拷贝过来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
69class LoginBean {
LoginBean({
this.admin,
this.chapterTops,
this.coinCount,
this.collectIds,
this.email,
this.icon,
this.id,
this.nickname,
this.password,
this.publicName,
this.token,
this.type,
this.username,
});
factory LoginBean.fromJson(Map<String, dynamic> json) {
return LoginBean(
admin: json['admin'],
chapterTops:
(json['chapterTops'] as List?)?.map((e) => e as String).toList(),
coinCount: json['coinCount'],
collectIds: (json['collectIds'] as List?)?.map((e) => e as int).toList(),
email: json['email'],
icon: json['icon'],
id: json['id'],
nickname: json['nickname'],
password: json['password'],
publicName: json['publicName'],
token: json['token'],
type: json['type'],
username: json['username']);
}
bool? admin;
List<dynamic>? chapterTops;
int? coinCount;
List<dynamic>? collectIds;
String? email;
String? icon;
int? id;
String? nickname;
String? password;
String? publicName;
String? token;
int? type;
String? username;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['admin'] = admin;
if (chapterTops != null) {
map['chapterTops'] = chapterTops?.map((v) => v.toJson()).toList();
}
map['coinCount'] = coinCount;
map['collectIds'] = collectIds?.map((v) => v.toJson()).toList();
map['email'] = email;
map['icon'] = icon;
map['id'] = id;
map['nickname'] = nickname;
map['password'] = password;
map['publicName'] = publicName;
map['token'] = token;
map['type'] = type;
map['username'] = username;
return map;
}
}定义BasePageViewModel实现类LoginLogic
调用LoginRepository的login方法发起登录请求,onReady方法中可执行loadData执行请求任务列表1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24class LoginLogic extends BasePageViewModel<LoginUIState, LoginRepository> {
LoginLogic({required super.uiState, required super.repository});
String pageId() => LoginTrack.pageIdLogin;
void onReady() {
// TODO: implement onReady
super.onReady();
loadData();
}
void loadData() async {
activeStatus(isLoading: true);
await Future.wait<dynamic>([]);
activeStatus(isLoading: false);
}
void login(String username, String password) async {
LoginBean? loginBean = await repository?.login(username, password);
showToast(loginBean != null ? '登录成功' : '登录失败');
}
}定义BaseBinding实现类LoginBinding
初始化LoginRemoteDataSource实例,重写provideRepository方法注入LoginRepositoryImpl的实例和LoginUIState的实例1
2
3
4
5
6
7
8
9
10class LoginLogicBinding extends BaseBinding {
void dependencies() {
Get.lazyPut<LoginLogic>(() =>
LoginLogic(repository: provideRepository(), uiState: LoginUIState()));
}
LoginRepository provideRepository() => LoginRepositoryImpl.getInstance(LoginRemoteDataSource.getInstance());
}定义UIState实现类LoginUIState
定义UI相关的变量,是否展示输入密码交互,当前是否可以登录1
2
3
4class LoginUIState extends BasePageUIState {
RxBool protect = false.obs;
RxBool loginEnable = false.obs;
}定义RemoteDataSource实现类LoginRemoteDataSource
继承BaseRemoteDataSource,实现调用服务端login接口的方法并实现单例,利用封装的DioManager发起post请求1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23class LoginRemoteDataSource extends BaseRemoteDataSource {
static LoginRemoteDataSource? _instance;
LoginRemoteDataSource._internal();
factory LoginRemoteDataSource.getInstance() {
_instance ??= LoginRemoteDataSource._internal();
return _instance!;
}
Future<LoginBean?> login(String username, String password) async {
var requestParams = {
'username': username,
'password': password
};
BaseResult result = await dioManager.postRequest(
LoginApi.getInstance().getLoginUrl(),
params: requestParams,
isForm: true);
logPrint(LoginApi.getInstance().getLoginUrl(), result);
return result.data == null ? null : LoginBean.fromJson(result.data);
}
}定义BaseRepository子类LoginRepository
定义登录接口1
2
3abstract class LoginRepository extends BaseRepository {
Future<LoginBean?> login(String username, String password);
}定义RepositoryImpl子类LoginRepositoryImpl
实现LoginRepository接口的登录方法调用服务端数据处理对象请求接口,传入LoginRemoteDataSource服务端数据处理对象类型1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17class LoginRepositoryImpl
extends BaseRepositoryImp<BaseLocalDataSource, LoginRemoteDataSource>
implements LoginRepository {
static LoginRepositoryImpl? _instance;
LoginRepositoryImpl._internal({required super.remoteDataSource});
factory LoginRepositoryImpl.getInstance(
LoginRemoteDataSource loginRemoteDataSource) {
_instance ??=
LoginRepositoryImpl._internal(remoteDataSource: loginRemoteDataSource);
return _instance!;
}
Future<LoginBean?> login(String username, String password) => remoteDataSource.login(username, password);
}定义BasePage的子类LoginPage
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
99class LoginPage extends BasePage<LoginLogic> {
LoginLogic? loginLogic;
String? username;
String? password;
LoginPage({Key? key}) : super(key: key) {
loginLogic = GetInstance().find<LoginLogic>();
}
String titleString() {
return "登录";
}
PreferredSizeWidget? appbar(BuildContext buildContext) {
// TODO: implement appbar
return AppBar(
brightness: Global.isDark! ? Brightness.dark : Brightness.light,
backgroundColor: appBarBackgroundColor,
elevation: 0,
centerTitle: true,
titleSpacing: 0,
leading: leading(buildContext),
title: title(),
actions: actions(),
);
}
List<Widget>? actions() {
return [
GestureDetector(
child: Container(
height: 44,
margin: EdgeInsetsDirectional.only(start: 12, end: 12),
padding: EdgeInsetsDirectional.only(bottom: 5),
alignment: AlignmentDirectional.center,
child: Image.asset('resource/images/newcomer_home_rule.png',
width: 24, height: 24, color: ColorConfig.emphasis),
),
onTap: () {
print('explainIcon');
},
),
];
}
Widget content(BuildContext context) {
return Container(
child: ListView(
children: [
Obx(() => LoginEffect(protect: viewModel.uiState.protect.value)),
LoginInput('用户名', '请输入用户名', onChanged: (text) {
username = text;
checkInput();
}),
LoginInput(
'密码',
'请输入密码',
obscureText: true,
onChanged: (text) {
password = text;
checkInput();
},
focusChanged: (focus) {
viewModel.uiState.protect.value = focus;
},
),
Obx(() => Padding(
padding: EdgeInsets.only(left: 20, right: 20, top: 10),
child: LoginButton(
'登录',
enable: viewModel.uiState.loginEnable.value,
onPressed: send,
),
))
],
),
);
}
void checkInput() {
bool enable;
if (username?.isNotEmpty == true && password?.isNotEmpty == true) {
enable = true;
} else {
enable = false;
}
viewModel.uiState.loginEnable.value = enable;
}
void send() async {
if (username != null && password != null) {
loginLogic?.login(username!, password!);
}
}
}封装登录按钮
封装编辑框下面的圆角登录按钮1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23class LoginButton extends StatelessWidget {
final String title;
final bool enable;
final VoidCallback? onPressed;
const LoginButton(this.title, {Key? key, this.enable = true, this.onPressed})
: super(key: key);
Widget build(BuildContext context) {
return FractionallySizedBox(
widthFactor: 1,
child: MaterialButton(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
height: 45,
onPressed: enable ? onPressed : null,
disabledColor: ColorConfig.primary60,
color: ColorConfig.primary,
child: Text(title, style: TextStyle(color: Colors.white, fontSize: 16)),
),
);
}
}封装登录页面顶部交互头
传入密码输入时是否展示遮挡交互的标记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
48class LoginEffect extends StatefulWidget {
final bool protect;
const LoginEffect({Key? key, required this.protect}) : super(key: key);
State<LoginEffect> createState() => _LoginEffectState();
}
class _LoginEffectState extends State<LoginEffect> {
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(top: 10),
decoration: BoxDecoration(
color: Colors.grey[100],
border: Border(bottom: BorderSide(color: Colors.grey[300]!)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
image(true),
Image(
height: 90,
width: 90,
image: AssetImage('resource/images/logo.png'),
),
image(false)
],
),
);
}
/**
* 密码保护的交互图片
*/
image(bool left) {
var leftImg = widget.protect
? 'resource/images/head_left_protect.png'
: 'resource/images/head_left.png';
var rightImg = widget.protect
? 'resource/images/head_right_protect.png'
: 'resource/images/head_right.png';
return Image(height: 90, image: AssetImage(left ? leftImg : rightImg));
}
}封装登录用户名密码编辑框
传入标题和提示文本,暴露文字/焦点改变的回调,是否有左边距,传入密码是否模糊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
87class LoginInput extends StatefulWidget {
final String title;
final String hint;
final ValueChanged<String>? onChanged;
final ValueChanged<bool>? focusChanged;
final bool lineStretch;
final bool obscureText;
final TextInputType? keyboardType;
const LoginInput(this.title, this.hint,
{Key? key,
this.onChanged,
this.focusChanged,
this.lineStretch = false,
this.obscureText = false,
this.keyboardType})
: super(key: key);
State<LoginInput> createState() => _LoginInputState();
}
class _LoginInputState extends State<LoginInput> {
final focusNode = FocusNode();
void initState() {
super.initState();
focusNode.addListener(() {
print('Has focus: ${focusNode.hasFocus}');
if (widget.focusChanged != null) {
widget.focusChanged!(focusNode.hasFocus);
}
});
}
void dispose() {
focusNode.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Column(
children: [
Row(
children: [
Container(
padding: EdgeInsets.only(left: 15),
width: 100,
child: Text(
widget.title,
style: TextStyle(fontSize: 16),
),
),
input()
],
),
Padding(
padding: EdgeInsets.only(left: !widget.lineStretch ? 15 : 0),
child: Divider(
height: 1,
thickness: 0.5,
)),
],
);
}
input() {
return Expanded(
child: TextField(
focusNode: focusNode,
onChanged: widget.onChanged,
obscureText: widget.obscureText,
keyboardType: widget.keyboardType,
autofocus: !widget.obscureText,
cursorColor: ColorConfig.primary,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w300),
decoration: InputDecoration(
contentPadding: EdgeInsets.only(left: 20, right: 20),
border: InputBorder.none,
hintText: widget.hint,
hintStyle: TextStyle(fontSize: 15, color: Colors.grey)),
));
}
}加载更多列表页面-WanAndroid
首页HomeApi
返回获取文章列表的url1
2
3
4
5
6
7
8
9
10
11
12class HomeApi {
static HomeApi? _instance;
HomeApi._internal();
factory HomeApi.getInstance() {
_instance ??= HomeApi._internal();
return _instance!;
}
String getArticleList(int currentPage) {
return '/article/list/$currentPage/json';
}
}BaseLoadMoreListPageUIState的子类HomeUIState
暂时没用到与UI显示有关的变量1
class HomeUIState extends BaseLoadMoreListPageUIState<ArticleBean> {}
BaseBinding的实现类首页Binding
初始化HomeRemoteDataSource的实例,重写provideRepository方法,注入HomeRepositoryImpl的实例和HomeUIState的实例1
2
3
4
5
6
7
8
9
10
11class HomeBinding extends BaseBinding{
void dependencies() {
Get.lazyPut<HomeLogic>(() =>
HomeLogic(repository: provideRepository(), uiState: HomeUIState()));
}
HomeRepository provideRepository() => HomeRepositoryImpl.getInstance(HomeRemoteDataSource.getInstance());
}BaseLoadMoreListViewModel的实现类HomeLogic
重写loadData方法调用repository的获取文章列表接口添加到BaseListPageUIState的dataList中,刷新是否有下一页标记,刷新下一次请求的页数1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21class HomeLogic extends BaseLoadMoreListViewModel<HomeUIState, HomeRepository> {
HomeLogic({required super.repository, required super.uiState});
Future<bool> loadData(bool isRefresh) async {
var result = await repository?.getArticleList(currentPage);
if (result == null) {
return false;
}
if (isRefresh) {
uiState.dataList.clear();
}
currentPage = result.curPage ?? 0;
if (result.articles != null) {
var list = result.articles?.map((e) => e as ArticleBean).toList();
uiState.dataList.addAll(list ?? []);
}
hasNext = result.curPage != result.pageCount;
return true;
}
}BaseRemoteDataSource的实现类HomeRemoteDataSource
调用dioManager对象发起get请求获取文章列表1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22class HomeRemoteDataSource extends BaseRemoteDataSource {
static HomeRemoteDataSource? _instance;
HomeRemoteDataSource._internal();
factory HomeRemoteDataSource.getInstance(){
_instance ??= HomeRemoteDataSource._internal();
return _instance!;
}
Future<ArticleList?> getArticleList(int currentPage) async{
String requestUrl = HomeApi.getInstance().getArticleList(currentPage);
BaseResult result = await dioManager.getRequest(requestUrl);
logPrint(requestUrl, result);
return result.data == null ? null : ArticleList.fromJson(result.data);
}
logPrint(String api, BaseResult result){
if (Global.isDebug) {
print("Login >> $api \n ${jsonEncode(result)}");
}
}
}BaseRepository的子类HomeRepository
定义获取文章列表接口1
2
3abstract class HomeRepository extends BaseRepository{
Future<ArticleList?> getArticleList(int currentPage);
}BaseRepositoryImp的子类HomeRepositoryImpl实现HomeRepository接口
重写getArticleList方法调用服务端数据处理对象请求文章列表接口,传入HomeRemoteDataSource服务端数据处理对象类型1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18class HomeRepositoryImpl
extends BaseRepositoryImp<BaseLocalDataSource, HomeRemoteDataSource>
implements HomeRepository {
static HomeRepositoryImpl? _instance;
HomeRepositoryImpl._internal({required super.remoteDataSource});
factory HomeRepositoryImpl.getInstance(
HomeRemoteDataSource homeRemoteDataSource) {
_instance ??=
HomeRepositoryImpl._internal(remoteDataSource: homeRemoteDataSource);
return _instance!;
}
Future<ArticleList?> getArticleList(int currentPage) =>
remoteDataSource.getArticleList(currentPage);
}封装文章列表的Bean对象
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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224class ArticleList {
ArticleList({
this.curPage,
this.articles,
this.offset,
this.over,
this.pageCount,
this.size,
this.total,
});
factory ArticleList.fromJson(Map<String, dynamic> json) {
return ArticleList(
curPage: json['curPage'],
articles: (json['datas'] as List?)?
.map((e) => ArticleBean.fromJson(e))
.toList(),
offset: json['offset'],
over: json['over'],
pageCount: json['pageCount'],
size: json['size'],
total: json['total']);
}
int? curPage;
List<dynamic>? articles;
int? offset;
bool? over;
int? pageCount;
int? size;
int? total;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['curPage'] = curPage;
if (articles != null) {
map['datas'] = articles?.map((v) => v.toJson()).toList();
}
map['offset'] = offset;
map['over'] = over;
map['pageCount'] = pageCount;
map['size'] = size;
map['total'] = total;
return map;
}
}
class ArticleBean {
ArticleBean({
this.adminAdd,
this.apkLink,
this.audit,
this.author,
this.canEdit,
this.chapterId,
this.chapterName,
this.collect,
this.courseId,
this.desc,
this.descMd,
this.envelopePic,
this.fresh,
this.host,
this.id,
this.isAdminAdd,
this.link,
this.niceDate,
this.niceShareDate,
this.origin,
this.prefix,
this.projectLink,
this.publishTime,
this.realSuperChapterId,
this.selfVisible,
this.shareDate,
this.shareUser,
this.superChapterId,
this.superChapterName,
this.tags,
this.title,
this.type,
this.userId,
this.visible,
this.zan,
});
factory ArticleBean.fromJson(Map<String, dynamic> json) {
return ArticleBean(
adminAdd: json['adminAdd'],
apkLink: json['apkLink'],
audit: json['audit'],
author: json['author'],
canEdit: json['canEdit'],
chapterId: json['chapterId'],
chapterName: json['chapterName'],
collect: json['collect'],
courseId: json['courseId'],
desc: json['desc'],
descMd: json['descMd'],
envelopePic: json['envelopePic'],
fresh: json['fresh'],
host: json['host'],
id: json['id'],
isAdminAdd: json['isAdminAdd'],
link: json['link'],
niceDate: json['niceDate'],
niceShareDate: json['niceShareDate'],
origin: json['origin'],
prefix: json['prefix'],
projectLink: json['projectLink'],
publishTime: json['publishTime'],
realSuperChapterId: json['realSuperChapterId'],
selfVisible: json['selfVisible'],
shareDate: json['shareDate'],
shareUser: json['shareUser'],
superChapterId: json['superChapterId'],
superChapterName: json['superChapterName'],
tags: (json['tags'] as List?)?.map((e) => Tag.fromJson(e)).toList(),
title: json['title'],
type: json['type'],
userId: json['userId'],
visible: json['visible'],
zan: json['zan']);
}
bool? adminAdd;
String? apkLink;
int? audit;
String? author;
bool? canEdit;
int? chapterId;
String? chapterName;
bool? collect;
int? courseId;
String? desc;
String? descMd;
String? envelopePic;
bool? fresh;
String? host;
int? id;
bool? isAdminAdd;
String? link;
String? niceDate;
String? niceShareDate;
String? origin;
String? prefix;
String? projectLink;
int? publishTime;
int? realSuperChapterId;
int? selfVisible;
dynamic shareDate;
String? shareUser;
int? superChapterId;
String? superChapterName;
List<dynamic>? tags;
String? title;
int? type;
int? userId;
int? visible;
int? zan;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['adminAdd'] = adminAdd;
map['apkLink'] = apkLink;
map['audit'] = audit;
map['author'] = author;
map['canEdit'] = canEdit;
map['chapterId'] = chapterId;
map['chapterName'] = chapterName;
map['collect'] = collect;
map['courseId'] = courseId;
map['desc'] = desc;
map['descMd'] = descMd;
map['envelopePic'] = envelopePic;
map['fresh'] = fresh;
map['host'] = host;
map['id'] = id;
map['isAdminAdd'] = isAdminAdd;
map['link'] = link;
map['niceDate'] = niceDate;
map['niceShareDate'] = niceShareDate;
map['origin'] = origin;
map['prefix'] = prefix;
map['projectLink'] = projectLink;
map['publishTime'] = publishTime;
map['realSuperChapterId'] = realSuperChapterId;
map['selfVisible'] = selfVisible;
map['shareDate'] = shareDate;
map['shareUser'] = shareUser;
map['superChapterId'] = superChapterId;
map['superChapterName'] = superChapterName;
if (tags != null) {
map['tags'] = tags?.map((v) => v.toJson()).toList();
}
map['title'] = title;
map['type'] = type;
map['userId'] = userId;
map['visible'] = visible;
map['zan'] = zan;
return map;
}
}
class Tag {
Tag({
this.name,
this.url,
});
factory Tag.fromJson(Map<String, dynamic> json) {
return Tag(name: json['name'], url: json['url']);
}
String? name;
String? url;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['name'] = name;
map['url'] = url;
return map;
}
}BasePage的实现类HomePage
重写首页展示标题,重写item方法返回列表项视图1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21class HomePage extends BaseLoadMoreListPage<HomeLogic> {
HomeLogic? homeLogic;
HomePage({Key? key}) : super(key: key) {
homeLogic = GetInstance().find<HomeLogic>();
}
String titleString() {
return '首页';
}
Widget item(BuildContext context, int index) {
return ArticleItemView(
articleBean: viewModel.uiState.dataList[index],
onClickCollect: (collect) {
print('click collect');
});
}
}封装文章列表项
外部Widget传入ViewModel中的UIState中的dataList中的某条ArticleBean数据项,传入回调方法onClickCollect,定义collect通过GetX的Obx机制结合obs变量刷新局部视图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
120class ArticleItemView extends StatefulWidget {
final ArticleBean articleBean;
final OnClickCollect onClickCollect;
RxBool collect = false.obs;
ArticleItemView({
Key? key,
required this.articleBean,
required this.onClickCollect,
});
State<ArticleItemView> createState() => _ArticleItemViewState();
}
class _ArticleItemViewState extends State<ArticleItemView> {
void itemClick(ArticleBean articleBean) {}
void itemCollect(ArticleBean articleBean) {}
Widget build(BuildContext context) {
String authorTitle;
String author;
if (widget.articleBean.author == null ||
widget.articleBean.author!.isEmpty) {
authorTitle = "分享人:";
author = widget.articleBean.shareUser ?? "";
} else {
authorTitle = "作者:";
author = widget.articleBean.author ?? "";
}
Row row = Row(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
Expanded(
child: Text(
"$authorTitle$author",
style: TextStyle(color: ColorConfig.emphasis, fontSize: 13),
)),
Text(
widget.articleBean.niceDate ?? "",
style: TextStyle(color: ColorConfig.emphasis, fontSize: 13),
),
],
);
Row title = Row(
children: <Widget>[
Expanded(
child: Text.rich(
TextSpan(text: widget.articleBean.title ?? ""),
softWrap: true,
style: TextStyle(fontSize: 16.0, color: ColorConfig.emphasis),
textAlign: TextAlign.left,
)),
],
);
Row chapterName = Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: EdgeInsets.only(left: 4, right: 4, top: 1, bottom: 2),
decoration: BoxDecoration(
color: ColorConfig.darkOverLay,
borderRadius: BorderRadius.circular(2)),
child: Text(
widget.articleBean.chapterName ?? "",
softWrap: true,
style: TextStyle(color: ColorConfig.emphasis60, fontSize: 12),
textAlign: TextAlign.left,
),
),
Obx(() => GestureDetector(
child: Icon(
widget.collect.value ? Icons.favorite : Icons.favorite_border,
color: widget.collect.value ? Colors.red : null,
),
onTap: () {
widget.articleBean.collect =
!widget.articleBean.collect.notNull();
widget.collect.value = widget.articleBean.collect.notNull();
widget.onClickCollect
.call(widget.articleBean.collect.notNull());
},
))
],
);
Column column = Column(
children: [
title,
SizedBox(
height: 5,
),
row,
SizedBox(
height: 5,
),
chapterName
],
);
return InkWell(
onTap: () {
itemClick(widget.articleBean);
},
child: Container(
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
margin: EdgeInsets.all(4),
child: column,
));
}
}
typedef OnClickCollect = void Function(bool collect);