Flutter 完整项目wanandroid客户端

418 阅读2分钟

前言

wanandroid 是一个学习android的站点 , 特别感谢 wanandroid提供的api 。

项目截图

项目地址 : flutter_wanandroid

部分依赖插件如下

状态管理 : riverpod

网络请求 : dio

事件总线 : event_bus (android中已经不推荐使用 ,flutter没找到其他暂时先用这个)

数据库 : sqflite

data 类生成 : freezed

动画 : lottie

json处理 : FlutterJsonBeanFactory + json_serializable

完整的依赖可以去项目中查看 , 这里为了减少文章篇幅就不贴出来了

相关插件的使用

1 ,freezed

使用过compose 应该知道 , kotlin 中有个data class , copyWith 方法和state 配合天下无敌 , 但是dart 语言又没有data class , 但 freezed 可以帮我们实现类似功能

集成之后 , 创建一个freezed注解标记的类 , 如下

part 'collect_state.freezed.dart';

@freezed
class CollectPageState with _$CollectPageState{
factory CollectPageState({ @Default([]) List<CollectDatas> datas  , CollectEntity? collectEntity}) = _CollectPageState;

}

然后命令行输入 flutter pub run build_runner build

就会生成另外一个类 , 实现了copyWith 方法

调用copyWith 界面就会刷新

2 ,FlutterJsonBeanFactory

这个插件主要是生成实体类 , idea 安装插件

右键点击FlutterJsonBeanFactory , 然后把json输入会生成实体类

FlutterJsonBeanFactory 插件自动生成代码分析

先分析下插件生成的代码 , 接下来封装网络请求用得上

使用插件生成的实体类会存在map中, key为实体类class name , value 为实体类实例对象

JsonConvert 的 convert 和 convertList 方法 , 非list类型的解析用convert 这个方法 , list的解析用convertList 方法

  T? convert<T>(dynamic value) {
    return asT<T>(value);
  }

  List<T?>? convertList<T>(List<dynamic>? value) {
      return value.map((dynamic e) => asT<T>(e)).toList();
  }

但最终都是调用 asT方法

 T? asT<T extends Object?>(dynamic value) {
    try {
      final String valueS = value.toString();
      if (type == "String") {
        return valueS as T;
      } else if (type == "int") {
          //省略大部分类似代码
      } else if (type == "double") {
       else {
        if (_convertFuncMap.containsKey(type)) {
          return _convertFuncMap[type]!(value) as T;
        } 
      }
    } 
  }

总结一下asT方法 , 如果是基本类型直接as 返回 , 不是基本类型就从map中查找

MVVM架构设计

在android 中mvvm用的比较多 , 层次也比较清晰 , android 中mvvm架构如下

flutter 中 widget 相当于上图的(Activity/Fragmeent) , 即mvvm的view层 , 负责承载ui的显示 , 其他的都类似

model: 负责提供界面显示的数据 , 数据可以从 database , sp , 服务器中获取

view(Widget): 负责显示ui

ViewModel : 负责连接view 和 model ,存放各种state

处理 model 层

model 层要封装的主要有 http , sp ,database

我把这几个都放在data包下

http封装

1 . json 对应的实体类BaseEntity封装

wanandroid 服务器返回的数据定义为如下

{
    "data": ...,
    "errorCode": 0,
    "errorMsg": ""
}

用FlutterJsonBeanFactory 插件生成一个BaseEntity , 经过处理后的关键代码如下

class BaseEntity<T> {
  T? data;
  int? errorCode;
  String? errorMsg;

  BaseEntity();

  factory BaseEntity.fromJson(Map<String, dynamic> json) =>
      _$BaseEntityFromJson(json);

  Map<String, dynamic> toJson() => _$BaseEntityToJson(this);

  @override
  String toString() {
    return jsonEncode(this);
  }
}

BaseEntity<T> _$BaseEntityFromJson<T>(Map<String, dynamic> json) {
  final BaseEntity<T> baseEntity = BaseEntity();
  T? data;
  if(json['data'] !=null){
    data = JsonConvert.fromJsonAsT<T>(json['data']);
  }
  if (data != null) {
    baseEntity.data = data;
  }
  final int? errorCode = jsonConvert.convert<int>(json['errorCode']);
  if (errorCode != null) {
    baseEntity.errorCode = errorCode;
  }
  final String? errorMsg = jsonConvert.convert<String>(json['errorMsg']);
  if (errorMsg != null) {
    baseEntity.errorMsg = errorMsg;
  }
  return baseEntity;
}

Map<String, dynamic> _$BaseEntityToJson(BaseEntity entity) {
  final Map<String, dynamic> data = <String, dynamic>{};
  data['data'] = entity.data?.toJson();
  data['errorCode'] = entity.errorCode;
  data['errorMsg'] = entity.errorMsg;
  return data;
}

泛型T 可以为 List类型 , 去掉@JsonSerializable() 注解 ,避免代码生成器改变我们处理之后的代码

2.网络请求dio封装

封装一下dio , 用于处理请求的返回的结果请求状态(加载中 , 失败 , 成功) , 下面以封装get请求为例

const successCode = 0;

class DioHolder {

  late final Dio _dio = Dio();

  static get() => DioHolder()._dio;

  DioHolder._() {
    _dio.interceptors.add(CookieManager.instance);
  }

  static DioHolder? _dioHolder;

  factory DioHolder() {
    return _dioHolder ??= DioHolder._();
  }
}

Future<T?> get<T>(String path,
      {Map<String, dynamic>? queryParameters,
      Options? options,
      CancelToken? cancelToken,
      ProgressCallback? onReceiveProgress}) async {
    state.setHttpRequestState(HttpRequestState.Loading);
    try {
      var response = await dio.get(
        path,
        queryParameters: queryParameters,
        options: options,
        cancelToken: cancelToken,
        onReceiveProgress: onReceiveProgress,
      );
      if (response.statusCode == 200) {
        return processResponse<T>(response, path)!;
      } else {
        state.setHttpRequestState(HttpRequestState.Fail);
        return null;
      }
    } catch (e) {
      state.setHttpRequestState(HttpRequestState.Fail);
      return null;
    }
  }
  //处理结果
 T? processResponse<T>(Response<dynamic> rep, String path) {
    if (rep.statusCode != 200) {
      state.setHttpRequestState(HttpRequestState.Fail);
      return null;
    }
    var entity = BaseEntity<T>.fromJson(rep.data);
    if (entity.errorCode == successCode) {
      state.setHttpRequestState(HttpRequestState.Suc);
        //把请求的结果缓存在sp中 
      WanRepository.sp.put(path, entity.data.toString());
    } else {
      state.setHttpRequestState(HttpRequestState.Fail,
          message: entity.errorMsg!);
    }
    try {
      return entity.data;
    } catch (e) {
      return null;
    }
  }

dio封装好之后就开始定义请求接口 , 贴出部分代码

 Future<List<NaviEntity>?> getNavi() async {
    return  _dioProxy.get<List<NaviEntity>>(WanUrls.NAVI);
  }

  Future<ArticleListEntity?> getArticleList({page}) async {
    return  _dioProxy
        .get<ArticleListEntity>("${WanUrls.ARTICLE_LIST}$page/json");
  }

cookie 处理

登录成功后会在cookie中返回账号密码,做好cookie持久化存储即可自动登录验证

添加一个dio的拦截器 , 用来处理cookie

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    if (response.statusCode == 200) {
      if (response.realUri.toString().contains(WanUrls.LOGIN)) {
        if (response.headers.map['set-cookie'] != null) {
          _persistCookie(response.headers.map['set-cookie']!);
        }
      }
      if (response.realUri.toString() == WanUrls.LOGOUT) {
        _clearCookie();
      }
    }
    super.onResponse(response, handler);
  }

  @override
  void onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) {
    options.headers[keyCookie] = _cookie;
    return super.onRequest(options, handler);
  }

在登录成功后保存cookie , 退出登录后清空cookie , 如果cookie 不为空且有效 , 请求时把cookie带上便可自动登录 .

database 数据库处理

wanandroid 站点没有提供文章阅读记录 , 只能在本地处理了

数据库打开然后创建文章表

openDatabase(databaseName, version: 1,
        onCreate: (Database _db, int version) async {
      await _db.execute('''
create table $tableArticle ( 
  $columnId integer primary key autoincrement, 
  $columnArticleId integer not null,
  $columnTitle text not null,
  $columnChapterName text not null,
  $columnShareUser text not null,
  $columnNiceDate text not null,
  $columnLink text not null,
  $columnCollect integer not null)
''');

文章表的 增删改查 , db 有个batch 方法 , 用于批处理 , 效率非常高

insert 的时候先把原来的文章删了在插入到末尾 , 获取全部文章然后逆序返回

  Future<Article> insert(Article article) async {
    var batch= _db.batch();
    batch.delete(tableArticle, where: '$columnArticleId = ?', whereArgs: [article.id]);
    batch.insert(tableArticle, article.toMap() ,conflictAlgorithm:ConflictAlgorithm.replace);
    await batch.commit(continueOnError: true);
   return article;
  }

  Future<List<Article>?> getAll() async {
    List<Map<String, dynamic>> maps = await _db.query(tableArticle);
    if (maps.isNotEmpty) {
      var list =<Article>[];
      for (var element in maps) {
       var en= Article.fromMap(element);
       print("getAll ${en.id}");
        list.add(en);
      }
        //逆序返回
      return list.reversed.toList();
    }
    return null;
  }

处理 ViewModel

viewmodel 用于处理逻辑 , 连接界面显示和数据获取

state的处理 , 状态管理用到了 riverpod

riverpod 中有个 StateNotifier , 当对象地址改变后界面也会跟着刷新 , 但使用很麻烦 , 需要封装一下, 就放在viewmodel中 , 以 HomeVm 为例

class HomeVm extends BaseViewModel {
  late var refreshController = RefreshController();

  late final homePageNotifier = newNotifier(HomePageState());

  HomePageState get _homeState => homePageNotifier.state;

  set _homeState(HomePageState state) {
    homePageNotifier.state = state;
  }

  _request() async {
    var value = await service.getArticleList(page:_getNextPage());
    if (value != null) {
      var data = <ArticleListDatas>[];
      data.addAll(_homeState.datas);
      if (value.curPage == 1) {
        data.clear();
      }
      data.addAll(value.datas ?? []);
        //会刷新界面
      _homeState = _homeState.copyWith(datas: data, articleListEntity: value);
    }
  }
}
   

class _HomeArListPageState extends State<HomeArListPage>
    with AutomaticKeepAliveClientMixin {
  late var vm = HomeVm();
  
  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Consumer(builder: (context, ref, _) {
      var data = vm.homePageNotifier.watch(ref).datas;
      var httpState = vm.watchHttpState(ref);
      return refreshListStatePage(
          child: RefreshList(
              controller: vm.refreshController,
              onRefresh: () async {
                await vm.refresh();
              },
              onLoading: () async {
                await vm.loadMore();
              },
              content: ListView.builder(
                itemBuilder: (c, index) {
                  final entity = data[index];
                  return commonListItem(
                    id: entity.id!,
                      title: entity.title,
                      shareUser: entity.shareUser,
                      chapterName: entity.superChapterName,
                      niceDate: entity.niceDate,
                      link: entity.link,
                      itemClick: () {
                        navToPage(Browser(entity.link!, entity.title!));
                      });
                },
                itemCount: data.length,
              )),
          fail:  vm.showFailedPage(),
          retry: () {
            vm.retry();
          });
    });
  }
}

HomePageState 这个类是使用 freezed 生成的 , 用来实现和kotlin data class类似功能 , 当调用_homeState = _homeState.copyWith 界面就会刷新 .

踩过的坑

jsonToDart 插件

这个插件用于生成json 对应的实体类 , 但这个插件真不推荐使用 , 在这个插件上折腾了好几天时间

后续

这个项目是自用的 , 以后还会完善更新 , 欢迎大家学习

最后再贴下项目地址 flutter_wanandroid