上一篇 Flutter 项目中咱们没有运用任何状况管理框架,直接 setState 和 StatefulWidget 一把梭完结了整个项目。本项目呢咱们也来学习 Flutter 的状况管理以及 Flutter 项目的分层。

 这儿首要要感谢两位大佬:用 SwiftUI 完结一个开源的 App Store 和 season_zhu 和 seasonZhu/GetXStudy。

 本项目我是以 iAppStore-SwiftUI 为原型然后也直接运用里边的 Apple 的接口,然后参阅着 GetXStudy 项目运用 GetX 为状况管理完结的。我大约给它起了一个:iAppStore-Flutter 的姓名:chm994483868/iAppStore_Flutter。比起前一个项目的 setState 和 StatefulWidget 一把梭,本项目我全部自己手打完结,内部简直写满了注释,整个开发进程下来,对我而言也加深了很多对 Flutter 的了解,特别是状况管理以及 Widget 的 rebuild。这样一路下来自己的 Flutter 开发技能也算是大约上了一个台阶,持续进步,加油!

Flutter 学习过程中不容错过的项目进阶实践
Flutter 学习过程中不容错过的项目进阶实践
Flutter 学习过程中不容错过的项目进阶实践
Flutter 学习过程中不容错过的项目进阶实践
Flutter 学习过程中不容错过的项目进阶实践
Flutter 学习过程中不容错过的项目进阶实践
Flutter 学习过程中不容错过的项目进阶实践
Flutter 学习过程中不容错过的项目进阶实践

FlutterJsonBeanFactory(Json 模型转化模块)

 Json 转 Model 运用了: FlutterJsonBeanFactory​(Only Null Safety)​,它是 Android Studio 的一个插件,对,除了这儿,其他我是全程运用了 Visual Studio Code 开发。

 在本项目中首要运用到了两个模型,一个是表明 App 概况信息的模型:app_detail_m_entity.dart,一个是表明 App 列表的数据模型:app_rank_m_entity.dart(app_rank_m_entity.dart 内部模型嵌套过多,看起来目不暇接,所以可以从 app_detail_m_entity.dart 内部看起,它内部比较精简),首要他们的内部都很规律,先是模型自己自界说的字段名,然后是它们所需求的 fromJson 和 toJson 函数分别指向了对应的 app_detail_m_entity.g.dart/app_rank_m_entity.g.dart 文件中自动生成的 fromJson 和 toJson 函数。

 FlutterJsonBeanFactory 插件会自动生成的对应的 app_detail_m_entity.g.dart 和 app_rank_m_entity.g.dart 文件,它们内部是自动生成的一切模型 class 与 json 互转时运用到的 fromJson 和 toJson 函数。

 看 app_detail_m_entity.g.dart 文件时咱们留意到内部运用到了一个 JsonConvert 类,它也是 FlutterJsonBeanFactory 插件自动生成的,它统筹起了整个 Json 转模型的工作,首要它界说了一个 static final Map<String, JsonConvertFunction> _convertFuncMap = {...};,把项目中一切需求 Json 转化的模型类的 FromJson 函数收集起来便利后续直接读取运用,然后是 T? asT<T extends Object?>(dynamic value) {...} 函数完结一切基础类型直接转化以及假如是咱们的自界说模型类型则从 _convertFuncMap 中读取对应的 fromJson 函数进行转化。

 这简直便是 Json 转 model 的全部内容了,FlutterJsonBeanFactory 直接协助咱们省掉了单调的手写 fromJson 和 toJson 函数的全部进程,效率拉满!

 看完 FlutterJsonBeanFactory 相关的 Json 模型转化进程后,咱们便可以看懂 IEntity 抽象类的作用了:界说抽象泛型类 IEntity 作为 BaseEntity/BaseEntityiAppStore 的基类,为它们供给一个 generateOBJ 函数,完结 Json 数据到 T 的模型转化。

/// 界说抽象泛型类 IEntity 作为 BaseEntity/BaseEntityiAppStore 的基类,为它们供给一个 generateOBJ 函数,完结 Json 数据到 T 的模型转化。
abstract class IEntity<T> {
  T? generateOBJ<O>(Object? json) {
    if (json == null) {
      return null;
    }
    if (typeName(T) == 'String') {
      return json.toString() as T;
    } else if (typeName(T) == 'Map<dynamic, dynamic>') {
      return json as T;
    } else {
      /// List 类型数据由 fromJsonAsT 判断处理
      return JsonConvert.fromJsonAsT<T>(json);
    }
  }
}

IEntity 为其子类 BaseEntityiAppStore 供给了一个 generateOBJ 函数,用于当网络恳求数据回来后,把回来的 Json 数据转化为对应的 T 模型,而 generateOBJ 函数内部调用的便是 JsonConvert 的 fromJsonAsT 函数,而 fromJsonAsT 内部便是调用的 jsonConvert.asT<M>(json) 函数。

 到这儿看懂了 Json 转模型的全进程,咱们就可以去看网络恳求模块了,此刻便能看懂网络恳求数据回来后,对数据的处理和转化进程了。

修正模型中自界说字段名

 FlutterJsonBeanFactory 运用起来超级便利,其间修正服务器回来的 json 字段名也很简略。例如:app_rank_m_entity.dart 文件中的 AppRankMFeedEntryIdAttributes 类,它的原始字段名服务器回来时会在其间加 “:” 号,这儿运用 @JSONField(name: "xxx") 标注,修正之…,例如下面的示例,服务器回来了一个 im:id 的字段名,咱们把它修正为 imid,便利咱们运用。

@JsonSerializable()
class AppRankMFeedEntryIdAttributes {
  @JSONField(name: "im:id")
  String? imid;
  @JSONField(name: "im:bundleId")
  String? imbundleId;
  ...
}

修正模型中字段类型

 然后还有 AppRankMFeed 类中的 entry 字段,其时运用 json 数据转 model 时我直接全选了 json 文本,没留意到其间的 entry 字段不是 List 类型,而仅仅 AppRankMFeedEntry? 类型,后来在实践开发中恳求接谈锋发现,这儿 entry 的类型跟咱们恳求数据时传递的 limit 参数有关,假如 limit 参数的值大于 1 则 entry 回来 List 数据,假如等于 1 则仅回来一个 AppRankMFeedEntry 数据。

 通常情况下咱们恳求数据时 limit 参数必定大于 1,所以此刻咱们直接把 entry 字段修正为 List<AppRankMFeedEntry>? 类型。

 到这儿今后咱们就要留意一下了,刚刚咱们对生成的模型修正了两处,一个是修正现已生成的字段的姓名,一个是修正现已生成的字段的类型。那么咱们直接改了模型,那么模型对应的 g.dart 文件中的的 fromJson 函数就失效了,这儿咱们也不用忧虑,FlutterJsonBeanFactory 为咱们供给了快捷的操作,当咱们发现已生成的 model 需求修正时,咱们不需求去仿制修正原始的 json 文本重新生成 model,咱们只需求修正咱们已生成的 model 然后按下 option + J 快捷键,那么 model 对应的 generated/json 文件夹中的 xxx.g.dart 文件就会同步更新其间的 FromJson 函数,确保 json 数据转化模型的正常进行。

dio(网络恳求模块)

 看完了上面数据转模型的进程,然后便是和数据最紧密相关的网络恳求模块了。

BaseEntityiAppStore 泛型类承载网络恳求成果

 首要是 BaseEntityiAppStore 泛型类中:

  • T? data;:保存回来的数据
  • int? errorCode:网络恳求呼应的 code
  • String? errorMsg:网络恳求过错信息描绘

 在本项目中 Apple 的数据接口只回来数据,不回来 code 之类的,所以这儿的 errorCodeerrorMsg 是咱们自己增加的字段,在本项目中它们仅表明两种状况:

  • 当网络恳求成功时 errorCode 的值是 0,errorMsg 值为空,data 是恳求回来的数据转化为 T 类型。
  • 当网络恳求以任何原因恳求失利时 errorCode 的值是 -1,errorMsg 值为过错原因,data 为 null。

 大约还有第三种:

  • 当网络恳求成功时 errorCode 的值是 0,errorMsg 值为空,data 因为回来的的数据为空或许异常导致模型转化失利,data 值为 null。

 然后是 BaseEntityiAppStore.fromJson(Map<String, dynamic> json) {...} 函数,是咱们自己手动编写的,首要取出 json 数据中的 errorCodeerrorMsg,然后假如有 data 数据的话,调用 IEntity 中的 T? generateOBJ<O>(Object? json){...} 函数,完结数据到模型 T 的转化。

/// 承继自 IEntity 的泛型类
class BaseEntityiAppStore<T> extends IEntity<T> {
  T? data;
  int? errorCode;
  String? errorMsg;
  // 构造函数
  BaseEntityiAppStore(this.errorCode, this.errorMsg, this.data);
  // Map<String, dynamic> 转化为 BaseEntity
  BaseEntityiAppStore.fromJson(Map<String, dynamic> json) {
    errorCode = json[Constant.errorCode] as int?;
    errorMsg = json[Constant.errorMsg] as String?;
    if (json.containsKey(Constant.data)) {
      // generateOBJ 函数来自父类 IEntity
      data = generateOBJ<T>(json[Constant.data] as Object?);
    }
  }
  // 是否恳求成功的的 get
  bool get isSuccess => errorCode == 0;
  // 恳求呼应状况的 get
  ResponseStatus get responseStatus => _responseStatus;
  // 恳求呼应状况的私有 get
  ResponseStatus get _responseStatus {
    if (errorCode == null) {
      // 正在恳求中
      return ResponseStatus.loading;
    } else if (errorCode == 0) {
      // 其他情况的话,假如 data 不是 null 便是呼应成功并且有数据,否则便是呼应成功并且没数据
      if (data != null) {
        return ResponseStatus.successHasContent;
      } else {
        return ResponseStatus.successNoData;
      }
    } else {
      // 恳求失利
      return ResponseStatus.fail;
    }
  }
}

 看完 base_entity_iappstore.darti_entity.dart 文件的内容后,咱们对网络恳求回来的数据向 T 模型转化的进程有必定的了解了。那么咱们还有两个方向需求学习:怎么运用它们呢?和网络数据怎么恳求呢?

 下面咱们看:网络数据怎么恳求呢?本项目中网络数据的恳求运用了 dio package,并对它进行了一个简略的封装。

HttpUtils 类封装 dio 网络恳求

http_util.dart 文件是对 dio 的简略封装,其间首要封装了 get/post 恳求。

abstract class HttpUtils {
  // 超时时刻 1 min,dio 中是以毫秒计算的
  static const timeout = 60000000;
  /// 初始化办法私有化
  HttpUtils._();
  static final _dio = Dio(
    BaseOptions(
      baseUrl: Api.baseUrl,
      connectTimeout: timeout,
      receiveTimeout: timeout,
      headers: {},
    ),
  ).addPrettyPrint;
  static Options getCookieHeaderOptions() {
    // iAppStore 暂时没有 header
    String value = "";
    Options options = Options(headers: {HttpHeaders.cookieHeader: value});
    return options;
  }
  // get 恳求
  static Future<Map<String, dynamic>> get({
    required String api,
    Map<String, dynamic> params = const {},
    Map<String, dynamic> headers = const {},
  }) async {
    Options options = getCookieHeaderOptions();
    options.headers?.addAll(headers);
    try {
      Response response = await _dio.get(api, queryParameters: params, options: options);
      if (response.data != null) {
        // ❌❌❌ 留意:itunes.apple.com 回来的数据是 String
        Map<String, dynamic> json;
        if (response.data.runtimeType == String) {
          json = convert.jsonDecode(response.data);
        } else {
          json = response.data;
        }
        return {
          Constant.errorCode: 0,
          Constant.errorMsg: "",
          Constant.data: json,
        };
      } else {
        // response.data 数据为 null,阐明恳求成功了,可是没有回来数据,那么这是什么情况呢?
        return {
          Constant.errorCode: 0,
          Constant.errorMsg: "",
          Constant.data: Null,
        };
      }
    } on DioError catch (e) {
      debugPrint("❌❌❌ post 恳求产生过错: $e");
      return {
        Constant.errorCode: -1,
        Constant.errorMsg: e.toString(),
        Constant.data: Null,
      };
    }
  }
  // post 恳求
  static Future<Map<String, dynamic>> post({
    required String api,
    Map<String, dynamic> params = const {},
    Map<String, dynamic> headers = const {},
  }) async {
    debugPrint(" URL: $api");
    Options options = getCookieHeaderOptions();
    options.headers?.addAll(headers);
    try {
      Response response = await _dio.post(api, queryParameters: params, options: options);
      if (response.data != null) {
        // ❌❌❌ 留意:itunes.apple.com 回来的数据是 String
        Map<String, dynamic> json;
        if (response.data.runtimeType == String) {
          json = convert.jsonDecode(response.data);
        } else {
          json = response.data;
        }
        return {
          Constant.errorCode: 0,
          Constant.errorMsg: "",
          Constant.data: json,
        };
      } else {
        // response.data 数据为 null,阐明恳求成功了,可是没有回来数据,那么这是什么情况呢?
        return {
          Constant.errorCode: 0,
          Constant.errorMsg: "",
          Constant.data: Null,
        };
      }
    } on DioError catch (e) {
      debugPrint("❌❌❌ post 恳求产生过错: $e");
      return {
        Constant.errorCode: -1,
        Constant.errorMsg: e.toString(),
        Constant.data: Null,
      };
    }
  }
  // request
  Future<Response<T>> request<T>(
    String api, {
    required HTTPMethod method,
    dynamic data,
    Map<String, dynamic>? queryParameters,
    Map<String, dynamic> headers = const {},
  }) async {
    Response response = await _dio.request(api,
        data: data,
        queryParameters: queryParameters,
        options: Options(headers: headers, method: method.string));
    return response.data;
  }
}
/// 延展 Dio,给它增加一个名为 addPrettyPrint 的 get,自界说 Dio log 输出
extension AddPrettyPrint on Dio {
  Dio get addPrettyPrint {
    interceptors.add(PrettyDioLogger(
      requestHeader: false,
      requestBody: true,
      responseBody: true,
      responseHeader: false,
      compact: false,
    ));
    return this;
  }
}
/// 界说 HTTP 恳求方式的枚举
enum HTTPMethod {
  get("GET"),
  post("POST"),
  delete("DELETE"),
  put("PUT"),
  patch("PATCH"),
  head("HEAD");
  final String string;
  const HTTPMethod(this.string);
}
/// 延展 Response 给它增加一个名为 status 的 get,依据呼应的 code,从 HttpStatus.mappingTable map 中取一个对应的枚举值
extension EnumStatus on Response {
  season.HttpStatus get status =>
      season.HttpStatus.mappingTable[statusCode] ?? season.HttpStatus.connectionError;
}

 其间的恳求回来今后,不论成功与失利都回来:

        return {
          Constant.errorCode: 0,
          Constant.errorMsg: "",
          Constant.data: json,
        };

 形式的 Map,看着有点 low。看完 HttpUtils 类的内容也没有什么东西,很简略,便是 dio 的最基础用法。然后咱们看下 request.dart 文件中对 HttpUtils 类的一个名为 Request 的扩展,外界一切的 get/pos 网络恳求都是走的这儿的 getiAppStore/postiAppStore 函数。

HttpUtils 的延展:Request 的运用

/// 延展 HttpUtils 增加 get<T> 和 post<T> 函数
extension Request on HttpUtils {
  /// for iAppStore,iAppStore 和 GetXStudy 的接口数据结构彻底不同,这儿针对 iAppStore 单独再进行封装
  /// Get
  static Future<BaseEntityiAppStore<T>> getiAppStore<T>({required String api, Map<String, dynamic> params = const {}}) async {
    final data = await HttpUtils.get(api: api, params: params);
    final model = BaseEntityiAppStore<T>.fromJson(data);
    return model;
  }
  /// Post
  static Future<BaseEntityiAppStore<T>> postiAppStore<T>({required String api, Map<String, dynamic> params = const {}}) async {
    final data = await HttpUtils.post(api: api, params: params);
    final model = BaseEntityiAppStore<T>.fromJson(data);
    return model;
  }
}

 看到这儿的网络恳求,咱们便可以和咱们上面看的 BaseEntityiAppStore 泛型类联系起来了:final model = BaseEntityiAppStore<T>.fromJson(data); 直接把恳求回来的 data 数据转化为 BaseEntityiAppStore<T>。这儿咱们先看一个运用实例,例如在 App 概况页面,咱们需求依据 AppID 和当时 App 所处的区域 ID 恳求 App 的详细信息,这儿要建议一个网络恳求,此刻咱们便可以这样:

import 'package:iappstore_flutter/base/interface.dart';
import 'package:iappstore_flutter/entity/app_detail_m_entity.dart';
import 'package:iappstore_flutter/entity/base_entity_iappstore.dart';
import 'package:iappstore_flutter/http_util/request.dart' as http;
class DetailRepository extends IRepository {
  Future<BaseEntityiAppStore<AppDetailMEntity>> appDetailData({required String appID, required String regionID}) => http.Request.postiAppStore(api: "$regionID/lookup?id=$appID");
}

 关于 DetailRepository 这个类名,等后边咱们讲项目分层的时分再来细看。这儿咱们首要把目光聚焦在 appDetailData 函数上。appDetailData 函数带着 appIDregionID 两个参数建议一个 post 恳求(留意这两个参数只需求拼接在 url 里边就可以了,例如一个完整的恳求接口是:https://itunes.apple.com/us/lookup?id=544007664),然后恳求成功后回来一个 BaseEntityiAppStore<AppDetailMEntity> 类的实例目标,其间 T 类型是 AppDetailMEntity 是咱们之前老早就界说好的 App 概况信息的 model。

 至此,本项目中的网络恳求、json 数据转模型咱们就看完了。下面咱们把目光扩大,聚在整个项目中,看下项目的架构

GetX 架构分层

 首要是整个项目每个页面(模块)对应四个文件(夹):(这儿咱们以 rank_home 模块为例来剖析)

  • binding:承继自 abstract class Bindings {...} 的子类,重载其 void dependencies(); 函数,在其间增加的 Get.lazyPut(...);,例如在 RankHomeBinding 中,把 RankHomeRepositoryRankHomeController 实例进行 lazyPut,确保在需求运用的当地可以直接 Get.find 找到。
class RankHomeBinding extends Bindings {
  @override
  void dependencies() {
    Get.lazyPut(() => RankHomeRepository());
    Get.lazyPut(() => RankHomeController());
    Get.lazyPut(tag: RankHomeController.className, () => RefreshController(initialRefresh: true));
  }
}
  • controller:承继自 abstract class GetxController extends DisposableInterface with ListenableMixin, ListNotifierMixin {...} 的子类,这儿首要放置页面所需求运用到的数据,以及各种操作逻辑。作用有点类似 MVVM 中的 VM。例如在 RankHomeController 中,一切的数据变量都放在其间,以及网络恳求、下拉改写、过错重试等逻辑。
class RankHomeController extends BaseRefreshControlleriAppStore<RankHomeRepository, AppRankMEntity> implements IClassName {
  // 完结 IClassName 抽象类中界说的 className
  static String? get className => (RankHomeController).toString();
  // 取得恳求得到的 App 排行榜数据列表
  List<AppRankMFeedEntry> get dataSource => response?.data?.feed?.entry ?? [];
  // 导航栏中的标题,因为要动态更新,所以这儿运用 RxString 类型
  final rankTitle = "排行榜".obs;
  // 导航栏底部的更新时刻,因为要动态更新,同样运用 RxString 类型
  final updateTimeString = DateTime.now().toLocal().toString().obs;
  // 挑选页面默认三个挑选项:都用数据源中第一个挑选项:抢手免费榜-一切 App-我国
  String rankName = Constant.rankingTypeLists.first;
  String categoryName = Constant.categoryTypeLists.first;
  String regionName = Constant.regionTypeLists.first;
  @override
  void onInit() {
    super.onInit();
    // 在 init 中直接 find 到 RankHomeBinding 中 dependencies 函数中增加的:Get.lazyPut(tag: RankHomeController.className, () => RefreshController(initialRefresh: true));
    refreshController = Get.find(tag: RankHomeController.className);
  }
  // 改写函数直接调用 fetchRankData,传 false 不显示加载 loading
  @override
  Future<void> onRefresh() async {
    fetchRankData(false);
  }
  // rank_home 不需求加载更多,这儿直接 loadComplete 完结,并 update
  @override
  Future<void> onLoadMore() async {
    refreshController.loadComplete();
    update();
  }
  // 依据当时的挑选类型查找排行榜的数据
  void fetchRankData(bool isShowLoading) async {
    // 依据排行榜的姓名,找到对应的排行榜的 ID
    final rankID = Constant.rankingTypeListIds[rankName] ?? "topFreeApplications";
    // 依据类型的姓名,找到对应的类型的 ID
    final categoryID = Constant.categoryTypeListIds[categoryName] ?? "0";
    // 依据区域的姓名,找到对应的区域的 ID
    final regionID = Constant.regionTypeListIds[regionName] ?? "cn";
    // 依据排行榜的 ID 找到对应的枚举类型
    final rankingType = RankingType.convert(rankID);
    // 依据入参判断是否需求展现 loading 动画
    if (isShowLoading == true) {
      status = ResponseStatus.loading;
      update();
    }
    // 恳求排行榜的 App 数据列表
    response = await request.applications(url: rankingType.url(categoryID, regionID, 200));
    // 恳求呼应今后依据呼应的状况更新 status 的值,此值决定了 rank_home 页面的显示内容:loading 页面、空页面、App 列表页面、恳求失利的重试页面
    status = response?.responseStatus ?? ResponseStatus.successHasContent;
    // 假如呼应成功后回来的 App 列表为空表明,数据为空
    if ((response?.data?.feed?.entry?.length ?? 0) <= 0) {
      status = ResponseStatus.successNoData;
    }
    // 依据呼应的数据更新导航栏的标题
    rankTitle.value = (response?.data?.feed?.title?.label ?? "").split(":").last;
    // 更新更新的时刻
    updateTimeString.value = DateTime.now().toLocal().toString();
    // 假如是下拉改写的话,完毕改写动画
    refreshController.refreshCompleted();
    // 更新 RankHome 中 RefreshStatusView 中 contentBuilder 中的内容
    update();
  }
  // 重写 onRetry 函数,当网络恳求失利时,点击重试按钮,重新恳求数据
  @override
  void onRetry() {
    super.onRetry();
    debugPrint(" ⛑⛑⛑ 重试被点击");
    fetchRankData(true);
  }
}
  • repository:寄存页面需求运用到的各个网络恳求。例如在 RankHomeRepository 中,把恳求 App 排行榜的网络恳求放在里边:
class RankHomeRepository extends IRepository {
  Future<BaseEntityiAppStore<AppRankMEntity>> applications({required String url}) => http.Request.postiAppStore(api: url);
}
  • view:承继自 abstract class GetView<T> extends StatelessWidget {...} 的子类。GetView 首要增加了 tagcontrolelr 两个字段,controller 作为 GetView 的一个 get 函数:T get controller => GetInstance().find<T>(tag: tag)!;,可以在 GetView 的任何位置找到并运用 controlelr,例如在 class RankHomePage extends GetView<RankHomeController> {...} 中,RankHomePage 的 T 正是 RankHomeController,在上面的 RankHomeController 示例代码中咱们现已看到其内部一切逻辑,并且在 RankHomeBindingGet.lazyPut(() => RankHomeController()); 这样也确保了 GetInstance().find<RankHomeController> 必定能找到现已 putRankHomeController 运用。

 同时在 RankHomePage 中,咱们也把 RefreshStatusViewcontentBuilder 控制在了包括范围最小,这样也确保了在 RankHomeController 中调用 update 函数进行 Widget 重建的功能消耗最小。

 这儿可以认真研习一下 RefreshStatusView 的封装,因为时刻原因,这儿就不展开细说了。

 GetX 假如持续展开的话,还有很多内容要学习,因为时刻原因,这儿就不展开了,作为我下一个阶段的学习目标。当时仅涉及到 GetX 的根本运用。

 当时剖析就先到这儿把,一切的代码和注释都在:chm994483868/iAppStore_Flutter,欢迎大家阅览并提出名贵的修正意见。

参阅链接

参阅链接:

  • get 4.6.5
  • FlutterJsonBeanFactory​(Only Null Safety)​
  • 用 SwiftUI 完结一个开源的 App Store
  • season_zhu
  • seasonZhu/GetXStudy
  • FlutterJsonBeanFactory​(Only Null Safety)​