0%

MVI+GetX打造Flutter开发架构


  • 今年的互联网寒冬真是冰冷刺骨,高温+限电+地震+疫情让所有人都感觉到2022年活下来就好,既然疫情下的经济衰退无法避免,那就只能多准备点保暖的装备过冬了,作为菜鸟的我焦虑是无法避免的而学习可以平复心中的不安,大环境无法改变只能抓紧时间充电了,期待破局的那一天。
  • 移动端开发趋近于饱和已经很久了,传统的开发方式需要ios和Android的两端的人力,为了达到双端一致的效果少不了沟通和UI交互校对的成本,如果项目以UI交互为主利用跨平台开发是有优势的,一套代码打造各端UI一致的体验,性能接近原生,提升人效受各大企业青睐,可能这是客户端最终的归宿吧,前有facebook的React后有google的flutter,连compose都有跨平台发展的迹象,随着flutter3.0的发布已经稳定支持所有终端,作为Android开发者面对这种强大的开发框架必须要赶紧学习起来,保持新技术的学习热情,顺应互联网时代的快速发展。
  • 公司的项目中已经用上了flutter,对于二级页面采用混合式的开发方式,集成flutter到原生的项目当中,目前性能和稳定性表现都是非常不错的,由于hot reload开发效率也提升了不少,开发了一段时间后,我利用Android开发中的MVI架构思想和GetX状态管理封装了flutter的开发架构,目前集成到项目中在线上稳定运行,欢迎交流和讨论~

架构图

效果图

  • 先放上一张整体的架构图~
  • 借鉴Android的MVI架构思想,整理一下大概思路:
  1. ViewModel 会存储并公开界面要使用的状态。界面状态是经过 ViewModel 转换的应用数据
  2. 界面会向 ViewModel 发送用户事件通知
  3. ViewModel 会处理用户操作并更新状态
  4. 更新后的状态将反馈给界面呈现
  5. ViewModel的数据源是两种,本地数据源和服务端数据源,本地数据源来自于文件和数据库,服务端数据源是web服务端接口提供的数据

Repository

LocalDataSource本地存储

引入shared_preferences到pubspec.yaml

工具类封装

对shared_preferences进行封装,调用shared_preferences

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Future<bool> saveString2Sp(String key, String value) async {
final prefs = await SharedPreferences.getInstance();
return prefs.setString(key, value);
}

Future<String?> getStringFromSp(String key) async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(key);
}

Future<bool> saveBool2Sp(String key, bool value) async {
final prefs = await SharedPreferences.getInstance();
return prefs.setBool(key, value);
}

Future<bool?> getBoolFromSp(String key) async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(key);
}

Future<bool> removeSp(String key) async {
final prefs = await SharedPreferences.getInstance();
return prefs.remove(key);
}

本地存储基类

调用封装的工具类实现本地数据的操作

  • 本地数据缓存
  • 本地数据获取
  • 删除本地数据
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    abstract 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 API
    1
    2
    3
    abstract class BaseRemoteDataSource {
    final DioManager dioManager = DioManager.getInstance()!;
    }

    Repository基类

    暴露本地数据操作接口
  • 本地数据缓存
  • 本地数据获取
  • 删除本地数据
    1
    2
    3
    4
    5
    abstract 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
    24
    abstract class BaseRepositoryImp<Local extends BaseLocalDataSource,
    Remote extends BaseRemoteDataSource> implements BaseRepository {
    final Local? localDataSource;
    final Remote remoteDataSource;

    BaseRepositoryImp({this.localDataSource, required this.remoteDataSource});

    @override
    Future<bool> saveCache(String cacheData, String key) async {
    var isSuccess = await localDataSource?.saveCache(cacheData, key);
    return isSuccess ?? false;
    }

    @override
    Future<String?> readCache(String key) async {
    return localDataSource?.readCache(key);
    }

    @override
    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
    7
    abstract class BaseView<T extends BaseViewModel> extends StatelessWidget {
    const BaseView({Key? key}) : super(key: key);
    T get viewModel => GetInstance().find<T>();

    @override
    Widget build(BuildContext context);
    }

    封装页面基类ViewModel

  1. 引入GetX到pubspec.yaml
  2. FullLifeCycleController的生命周期
  • onInit Controller初始化
  • onReady 处理异步事件,网络请求
  • onResumed 应用可见且页面回到前台
  • onInactive 应用在前台但页面不可见
  • onPaused 应用不可见且页面到后台
  • onDetach 页面视图销毁
  • onClose 关闭流对象,动画,释放内存,数据持久化
  1. 继承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
    59
    abstract class BaseViewModel<Repository extends BaseRepository?>
    extends FullLifeCycleController with FullLifeCycleMixin {
    final Repository? repository;

    BaseViewModel({this.repository});

    TrackPageViewHelper? trackPageViewHelper;

    String pageId() => '';

    @override
    @mustCallSuper
    void onInit() {
    if (pageId().isNotEmpty) {
    trackPageViewHelper = TrackPageViewHelper(pageId());
    trackPageViewHelper?.onPageShow();
    }

    /// viewModel的初始化工作,例如一些成员属性的初始化
    super.onInit();
    }

    @override
    void onReady() {
    /// 处理异步事件,比如网络请求
    super.onReady();
    }

    @override
    @mustCallSuper
    void onClose() {
    if (pageId().isNotEmpty) {
    trackPageViewHelper?.onPageHide();
    }
    super.onClose();
    }

    @override
    @mustCallSuper
    void onResumed() {
    if (pageId().isNotEmpty) {
    trackPageViewHelper?.onPageShow();
    }
    }

    @override
    @mustCallSuper
    void onInactive() {
    if (pageId().isNotEmpty) {
    trackPageViewHelper?.onPageHide();
    }
    }

    @override
    void onPaused() {}

    @override
    void onDetached() {}
    }

    通用页面基类

    根据当前封装的页面基类和ViewModel基类封装普通/列表/下拉刷新列表页,分别封装它们对应的UIState/Page/ViewModel

    普通页基类

    封装UIState

    UIState只定义与UI刷新相关的变量,结合GetX的Obx机制刷新使用obs变量,普通页的UIState封装内容View的显示状态,根据当前的几个状态加载中/空页面/错误页来展示页面当前View
    1
    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
    15
    abstract 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
    128
    abstract 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;

    @override
    Widget build(BuildContext context) {
    return Scaffold(
    backgroundColor: backgroundColor,
    resizeToAvoidBottomInset: false,
    appBar: appbar(context),
    body: body(context),
    );
    }

    @protected
    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(),
    );
    }

    /// 返回按钮
    @protected
    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);
    },
    );
    }

    /// 标题字符串
    @protected
    String titleString() => '';

    /// 标题
    @protected
    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
    @protected
    Widget content(BuildContext context);

    @protected
    Widget loading() {
    return const PageLoading();
    }

    @protected
    Widget empty() {
    return const Padding(
    padding: EdgeInsetsDirectional.only(
    top: 152,
    ),
    child: PageEmpty(),
    );
    }

    @protected
    Widget networkError() {
    return PageNetworkError(
    onRetry: onRetry,
    );
    }

    /// 点击重试时回调
    @protected
    void onRetry() {}

    @protected
    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
    15
    abstract class BaseListPageViewModel<UIState extends BaseListPageUIState,
    Repository extends BaseRepository>
    extends BasePageViewModel<UIState, Repository> {
    BaseListPageViewModel(
    {required Repository repository, required UIState uiState})
    : super(repository: repository, uiState: uiState);

    @override
    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
    35
    abstract class BaseListPage<T extends BaseListPageViewModel>
    extends BasePage<T> {
    const BaseListPage({Key? key}) : super(key: key);

    @override
    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值,提升列表滚动性能
    @protected
    double? itemExtent() => null;

    @protected
    int? itemCount() => viewModel.uiState.dataList.length;

    Widget item(BuildContext context, int index);
    }

    下拉刷新列表页基类

    封装UIState

    继承列表UIState增加isEmptyList变量用于控制是否展示空列表视图
    1
    2
    3
    class 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
    33
    abstract class BaseLoadMoreListViewModel<
    UIState extends BaseLoadMoreListPageUIState,
    Repository extends BaseRepository>
    extends BaseListPageViewModel<UIState, Repository> {
    late final EasyRefreshController controller;
    @protected
    int currentPage = 1;
    @protected
    bool hasNext = false;

    BaseLoadMoreListViewModel(
    {required Repository repository, required UIState uiState})
    : super(repository: repository, uiState: uiState);

    @override
    void onInit() {
    controller = EasyRefreshController();
    super.onInit();
    }

    // 加载下一页数据时,后端某些分页api采用偏移量计算下一页数据;某些是currentPage自增;
    Future<void> loadNextPageData();

    @override
    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
    57
    abstract class BaseLoadMoreListPage<T extends BaseLoadMoreListViewModel>
    extends BaseListPage<T> {
    const BaseLoadMoreListPage({Key? key}) : super(key: key);

    @override
    Widget content(BuildContext context) {
    return GetBuilder<T>(
    id: 'listView',
    builder: (viewModel) {
    return createContentWidget(viewModel);
    },
    );
    }

    @protected
    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);
    },
    ),
    );
    }

    @override
    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
    41
    class PageEmpty extends StatelessWidget {
    const PageEmpty({Key? key}) : super(key: key);

    @override
    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
    43
    class PageFooter extends Footer {
    final LinkFooterNotifier linkNotifier = LinkFooterNotifier();
    PageFooter({bool safeArea = true}) : super(safeArea: safeArea);

    @override
    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,传入文本并展示加载图标
    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
    class LoadingView extends StatefulWidget {
    final String tips;
    final Size size;

    const LoadingView({
    Key? key,
    this.tips = '',
    this.size = const Size.square(32),
    }) : super(key: key);

    @override
    State<LoadingView> createState() => _LoadingViewState();
    }

    class _LoadingViewState extends State<LoadingView> {
    @override
    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,
    ),
    ),
    ],
    ),
    );
    }
    }
    通用的加载视图,中间展示LoadingView
    1
    2
    3
    4
    5
    6
    7
    8
    class PageLoading extends StatelessWidget {
    const PageLoading({Key? key}) : super(key: key);

    @override
    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
    34
    class PageNetworkError extends StatelessWidget {
    final OnRetry? onRetry;

    const PageNetworkError({
    Key? key,
    this.onRetry,
    }) : super(key: key);

    @override
    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

    返回登录和注册的url
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    class 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
    69
    class 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
    24
    class LoginLogic extends BasePageViewModel<LoginUIState, LoginRepository> {
    LoginLogic({required super.uiState, required super.repository});

    @override
    String pageId() => LoginTrack.pageIdLogin;

    @override
    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
    10
    class LoginLogicBinding extends BaseBinding {
    @override
    void dependencies() {
    Get.lazyPut<LoginLogic>(() =>
    LoginLogic(repository: provideRepository(), uiState: LoginUIState()));
    }

    @override
    LoginRepository provideRepository() => LoginRepositoryImpl.getInstance(LoginRemoteDataSource.getInstance());
    }

    定义UIState实现类LoginUIState

    定义UI相关的变量,是否展示输入密码交互,当前是否可以登录
    1
    2
    3
    4
    class 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
    23
    class 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
    3
    abstract 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
    17
    class 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!;
    }

    @override
    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
    99
    class LoginPage extends BasePage<LoginLogic> {
    LoginLogic? loginLogic;
    String? username;
    String? password;

    LoginPage({Key? key}) : super(key: key) {
    loginLogic = GetInstance().find<LoginLogic>();
    }

    @override
    String titleString() {
    return "登录";
    }

    @override
    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(),
    );
    }

    @override
    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');
    },
    ),
    ];
    }

    @override
    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
    23
    class 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);

    @override
    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
    48
    class LoginEffect extends StatefulWidget {
    final bool protect;

    const LoginEffect({Key? key, required this.protect}) : super(key: key);

    @override
    State<LoginEffect> createState() => _LoginEffectState();
    }

    class _LoginEffectState extends State<LoginEffect> {
    @override
    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
    87
    class 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);

    @override
    State<LoginInput> createState() => _LoginInputState();
    }

    class _LoginInputState extends State<LoginInput> {
    final focusNode = FocusNode();

    @override
    void initState() {
    super.initState();
    focusNode.addListener(() {
    print('Has focus: ${focusNode.hasFocus}');
    if (widget.focusChanged != null) {
    widget.focusChanged!(focusNode.hasFocus);
    }
    });
    }

    @override
    void dispose() {
    focusNode.dispose();
    super.dispose();
    }

    @override
    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

    返回获取文章列表的url
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class 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
    11
    class HomeBinding extends BaseBinding{
    @override
    void dependencies() {
    Get.lazyPut<HomeLogic>(() =>
    HomeLogic(repository: provideRepository(), uiState: HomeUIState()));
    }

    @override
    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
    21
    class HomeLogic extends BaseLoadMoreListViewModel<HomeUIState, HomeRepository> {
    HomeLogic({required super.repository, required super.uiState});

    @override
    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
    22
    class 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
    3
    abstract 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
    18
    class 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!;
    }

    @override
    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
    224
    class 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
    21
    class HomePage extends BaseLoadMoreListPage<HomeLogic> {
    HomeLogic? homeLogic;

    HomePage({Key? key}) : super(key: key) {
    homeLogic = GetInstance().find<HomeLogic>();
    }

    @override
    String titleString() {
    return '首页';
    }

    @override
    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
    120
    class ArticleItemView extends StatefulWidget {
    final ArticleBean articleBean;
    final OnClickCollect onClickCollect;
    RxBool collect = false.obs;

    ArticleItemView({
    Key? key,
    required this.articleBean,
    required this.onClickCollect,
    });

    @override
    State<ArticleItemView> createState() => _ArticleItemViewState();
    }

    class _ArticleItemViewState extends State<ArticleItemView> {
    void itemClick(ArticleBean articleBean) {}

    void itemCollect(ArticleBean articleBean) {}

    @override
    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);