基于原生Android开发基础,在初次学习Flutter的时候就开始查找关于MVVM框架的开源,但苦于Flutter正在成长中,没有一个很完整的整体框架,所以借鉴了网上部分思路最终开源了 fast_mvvm 做开发大家或多或少都听说过MVC,而 MVP和MVVM都是基于MVC进化而来的产物,为了更好的拆分业务与界面,提高整体项目结构,便于后期维护与单元测试。关于具体的区别水平有限这些就不细讲了。 Entity 实体类主要负责将JSON数据进行格式转换。 Model 层主要负责将后台提供API实现。 假设现在有一个用户模块UserModel继承BaseModel负责用户的所有接口。 response接口未格式化数据。 ViewModel 主要负责调用Model层获取数据,将数据和方法提供给View层来调用,因暂时没有找到获取View层控件的方法该功能暂没有实现。 数据刷新基于官方的provider来实现。 源代码可以看到 这里继承了ChangeNotifier,并且需要范型BaseModel和BaseEntity。 页面状态管理枚举类ViewModelState 提供 idle 闲置 busy 加载中 empty 空数据 error 错误 unAuthorized 账号未授权 初始化ViewModel 自动填充BaseModel 初始化 EventBus 事件通知 批量端口注册 fast_event_bus fast_event_bus 源码: 初始化 dispose注册在页面销毁的时候销毁内存占用 源码: 接下来看关于状态的设置: 下面讲解页面数据请求 本地数据装载 http接口请求 在请求时调用model提供的API来获取数据。案例: 页面销毁 在BaseViewModel上进一步封装为长列表页面提供下拉刷新和上拉加载,额外需要一个List中Item类 可以是dynamic 初始化参数 提供一个List对外方法: 案例: 下拉刷新: 上拉加载: 对数据判断提供的新的方法,并提供空数组子类的自己实现。 数组拼接: 案例: View 主要负责页面的呈现展示。 ViewConfig 页面配置类主要用于配置页面属性。 提供了三种构造方法。 value构造 已经被创建,有监听存在,ViewModel可以为空。 noRoot 构造 对根布局刷新进行优化,控制当前页面的根布局刷新不会因为ViewModel调用了notifyListeners()方法而全局刷新。 通过ViewConfig的配置,来进行根布局刷新管理, 这一步东西较多,主要负责页面的状态和后期跨页面刷新的逻辑。 获取状态配置,验证是否已经刷新,更新页面 BaseView 是基于StatelessWidget实现,需要继承BaseViewModel范型。 vmBuild 详解 案例: 针对某些页面需要保持当前滑动状态而必须用到StatefulWidget,对State进行封装。 拿退货退款举例,因为页面涉及到列表所以需要保持页面滑动状态所以必须使用StatefulWidget 首先创建项目模块所需要用的Model,按大模块区分。 在APP首页启动的时候初始化框架 文章设计: ArticleVM 文章的ViewModel设计: ArticlePage 文章View设计 这是本人第一个认真整理并开源的框架,希望可以帮助到大家,让大家在日后做项目的时候能专心于业务的实现,啰啰嗦嗦写了一大堆,在思路上、代码水平等方面会有些不足,望大家提醒我会吸取教训并加以改进。 有问题可以加我 QQ 275916180,欢迎来讨论。 项目在 GitHub上开源了,望大家谢谢。Flutter fast_mvvm 使用帮助
fast_mvvm 创作思路
MVVM介绍
Entity 实体类
业务实现时继承 BaseEntity 暂时没有处理,后期开发迭代会用到。class ArticleEntity extends BaseEntity { List<ArticleItem> list; ArticleEntity(this.list); } class ArticleItem { String id; String title; String content; String time; ArticleItem(this.id, this.title, this.content, this.time); }
Model API层
源码介绍:/// 基类的API 声明API mixin BaseRepo {} /// 基类Model 具体实现API class BaseModel with BaseRepo {}
具体业务实现:class UserModel extends BaseModel { /// 登录 Future<bool> login(String account, String psd) async { await Future.delayed(Duration(seconds: 3)); return true; } /// 资讯列表 Future<DataResponse<ArticleEntity>> getArticleList() async { await Future.delayed(Duration(seconds: 2)); var entity = ArticleEntity( [ArticleItem("1", "好的", "内容内容内容内容内容", DateTime.now().toString())]); DataResponse dataResponse = DataResponse<ArticleEntity>(entity: entity, totalPageNum: 3); return dataResponse; } }
接口数据处理封装
class DataResponse<T> { T entity; bool result; Response response; int totalPageNum; get data => response.data; DataResponse({ @required this.entity, this.result = false, this.response, this.totalPageNum = 1, }); }
ViewModel 数据与页面的绑定调用层
BaseViewModel
/// 基类 VM abstract class BaseViewModel<M extends BaseModel, E extends BaseEntity> extends ChangeNotifier
/// ViewModel的状态 控制页面基础显示 enum ViewModelState { idle, busy, empty, error, unAuthorized }
defaultOfParams 在请求数据用的参数下面会讲到。
viewState 指定状态,默认为闲置。 /// 根据状态构造 /// 子类可以在构造函数指定需要的页面状态 /// FooModel():super(viewState:ViewState.busy); BaseViewModel({ViewModelState viewState, this.defaultOfParams}) : _viewState = viewState ?? ViewModelState.idle { init(false); Future.delayed(Duration(seconds: 1), () => init(true)); } /// model M model; M getModel() => null; @mustCallSuper void init(bool await) { if (!await) { model = getModel() ?? getModelGlobal<M>(); // if (isSaveVM()) _addVM(this); } else { _eventButAddInit(portMap); _disposeInit(); } }
子类实现 portMap
案例: @override Map<String, EventListen> get portMap => { Constant.invoice_select: invoiceSelect, Constant.address_select_send: addressSelect, };
/// 端口 key 跟 回调监听 Map<String, EventListen> get portMap => Map<String, EventListen>(); /// 绑定端口跟回调 void _eventButBindListen(String key, EventListen listen) { EventBus.getDefault().register(key, listen); } /// 绑定初始化 大量绑定 void _eventButAddInit(Map<String, EventListen> portMap) { portMap?.forEach((key, callback) { _eventButBindListen(key, callback); }); } /// 端口删除 void eventButDelete(String key) { EventBus.getDefault().unregister(key); } /// 端口添加 @mustCallSuper bool eventButAdd(String key, EventListen listen) { portMap.update(key, (l) => listen, ifAbsent: () => listen); return EventBus.getDefault().register(key, listen); }
子类实现方法 waitDispose()
案例: TextEditingController name = TextEditingController(); TextEditingController phone = TextEditingController(); TextEditingController area = TextEditingController(); TextEditingController tecAddress = TextEditingController(); @override List waitDispose() => [name, phone, area, tecAddress];
List _disposeWait = []; void _disposeInit() { for (var item in waitDispose()) _disposeAdd(item); } void _disposeAdd(item) { if (item.dispose != null) _disposeWait.add(item); } /// 清理内存占用 void _disposeList() { for (var item in _disposeWait) if (item != null) { try { if (item is StreamSubscription) { item.cancel(); } else { item.dispose(); } } catch (e, s) { handleCatch(e, s); } finally { item = null; } } } @override void dispose() { _disposed = true; for (var key in portMap.keys) { eventButDelete(key); } _disposeList(); super.dispose(); }
/// 当前的页面状态,默认为busy,可在viewModel的构造方法中指定; ViewModelState _viewState; ViewModelState get viewState => _viewState; /// 出错时的message String _errorMessage; String get errorMessage => _errorMessage; /// 以下变量是为了代码书写方便,加入的变量.严格意义上讲,并不严谨 bool get busy => viewState == ViewModelState.busy; bool get idle => viewState == ViewModelState.idle; bool get empty => viewState == ViewModelState.empty; bool get error => viewState == ViewModelState.error; bool get unAuthorized => viewState == ViewModelState.unAuthorized; void setBusy(bool value) { _errorMessage = null; viewState = value ? ViewModelState.busy : ViewModelState.idle; } void setEmpty() { _errorMessage = null; viewState = ViewModelState.empty; } void setError(String message) { _errorMessage = message; viewState = ViewModelState.error; } void setUnAuthorized() { _errorMessage = null; viewState = ViewModelState.unAuthorized; } /// 最终修改状态并通知页面刷新 set viewState(ViewModelState viewState) { _viewState = viewState; notifyListeners(); }
是否是http请求,还是本地数据装载。
在获取到数据后判断空值。
然后执行initResultData()对数据进行下一步自定义处理。bool isHttp() => true; /// 进入页面isInit loading Future<void> viewRefresh({ bool showLoad = false, dynamic params, bool notifier = true, bool busy = true, }) async { if (busy && !showLoad) setBusy(true); bool result = false; result = await _request(param: params); _notifyIntercept = !notifier; // LogUtil.printLog("notifier : $notifier _notifyIntercept:$_notifyIntercept"); if (!result) { setEmpty(); } else { ///改变页面状态为非加载中 setBusy(false); } } /// 请求数据 Future<bool> _request({param}) async { try { var data = await _httpOrData(false, BaseListViewModel.pageFirst, param); if (data == null || data.entity == null) { return false; } else { entity = data.entity; initResultData(); return true; } } catch (e, s) { handleCatch(e, s); return false; } } /// 判断http或者data Future<DataResponse<E>> _httpOrData(bool isLoad, int page, param) async { return isHttp() ? await requestHttp( isLoad: isLoad, page: page, params: param ?? defaultOfParams) : await requestData(isLoad, page); }
异步方法 并返回成功和失败 /// 非http请求 Future<DataResponse<E>> requestData(bool isLoad, int page) async => null;
源码: /// http请求 Future<DataResponse<E>> requestHttp( {@required bool isLoad, int page, params}) async => null;
@override Future<DataResponse<ArticleEntity>> request({bool isLoad, int page, params}) { return model.getArticleList(); }
销毁初始化注册的事件端口和监听Steam等对象。防止内存泄露 @override void dispose() { _disposed = true; for (var key in portMap.keys) { eventButDelete(key); } _disposeList(); super.dispose(); }
BaseListViewModel
/// 基类 ListVM abstract class BaseListViewModel<M extends BaseModel, E extends BaseEntity, I> extends BaseViewModel<M, E>
页面配置显示当前页码 BaseListViewModel({params}) : super(defaultOfParams: params); /// 分页第一页页码 static int pageNumFirst = 1; /// 当前页码 int _currentPageNum = pageNumFirst; static int _totalPageNum = 1; /// 跟EasyRefresh 相关配置 EasyRefreshController _refreshController = EasyRefreshController(); EasyRefreshController get refreshController => _refreshController;
@protected List<I> get list;
EasyRefresh( controller: vm.refreshController, onLoad: vm.loadMore, onRefresh: vm.viewRefresh, child: ListView.builder( itemCount: vm.list.length, itemBuilder: (ctx, index) => _item(vm.list[index])), )
/// 下拉刷新 Future<bool> httpRequest({param}) async { try { _currentPageNum = pageNumFirst; DataResponse<E> data = await request( isLoad: false, page: pageNumFirst, params: param ?? defaultOfParams); refreshController.finishRefresh(); refreshController.resetLoadState(); if (_checkData(false, data)) { return false; } else { initResultData(); _totalPageNum = data.totalPageNum ?? 1; refreshController.finishLoad(success: true); return true; } } catch (e, s) { refreshController.finishRefresh(success: false); refreshController.resetLoadState(); handleCatch(e, s); return false; } }
/// 上拉加载更多 Future<void> loadMore() async { // print('------> current: $_currentPageNum total: $_totalPageNum'); if (_currentPageNum >= _totalPageNum) { refreshController.finishLoad(success: true, noMore: true); } else { var cPage = ++_currentPageNum; //debugPrint('ViewStateRefreshListViewModel.loadMore page: $currentPage'); try { var data = await request(isLoad: true, page: cPage, params: defaultOfParams); if (_checkData(true, data)) { _currentPageNum--; refreshController.finishLoad(success: true, noMore: true); } else { if (_currentPageNum >= _totalPageNum) { refreshController.finishLoad(success: true, noMore: true); } else { refreshController.finishLoad(success: true, noMore: false); refreshController.resetLoadState(); } notifyListeners(); } } catch (e, s) { _currentPageNum--; refreshController.finishLoad(success: false); refreshController.resetLoadState(); debugPrint('error--->n' + e.toString()); debugPrint('stack--->n' + s.toString()); } } }
/// 验证数据是否为空 bool _checkData(bool isLoad, DataResponse<E> data) { if (data == null || data.entity == null) return true; if (isLoad) { jointList(data.entity); } else { entity = data.entity; } return judgeNull(data); } /// 判断数组是否为空 @protected bool judgeNull(DataResponse<E> data) => list == null || list.isEmpty;
void jointList(E newModel);
当获取数据后对数组重装,因数据List的不确定性,这里由子类实现。 @override void jointList(ArticleEntity newEntity) => entity.list.addAll(newEntity.list);
View 页面层
Flutter 主要有StatelessWidget和StatefulWidget两种控件用来展示页面,BaseVIew的设计思路采用的扩展,对StatelessWidget和StatefulWidget进行扩展使用,不破坏控件完整性。ViewConfig
根布局刷新方式,空数据验证,是否初始加载,页面状态页自定义等。/// view层 配置用类 class ViewConfig<VM extends BaseViewModel> { ViewConfig({ @required this.vm, this.child, this.color, this.load = true, this.checkEmpty = true, this.state, this.value = false, this.busy, this.empty, this.error, this.unAuthorized, }) : this.root = true, this._firstLoad = true; ViewConfig.value({ @required this.vm, this.child, this.color, this.load = false, this.checkEmpty = true, this.state, this.value = true, this.busy, this.empty, this.error, this.unAuthorized, }) : this.root = true, this._firstLoad = true; ViewConfig.noRoot({ @required this.vm, this.child, this.color, this.load = true, this.checkEmpty = true, this.state, this.value = false, this.busy, this.empty, this.error, this.unAuthorized, }) : this.root = false, this._firstLoad = true; /// VM VM vm; Widget child; VSBuilder<VM> busy; VSBuilder<VM> empty; VSBuilder<VM> error; VSBuilder<VM> unAuthorized; /// 背景颜色 Color color; /// 加载 bool load; /// 是否根布局刷新 采用 [Selector] bool root; /// 首次加载 bool _firstLoad; /// [ChangeNotifierProvider.value] 或者[ChangeNotifierProvider] bool value; /// 是否验证空数据 bool checkEmpty; /// 页面变化控制 int state; }
默认构造必须传递ViewModel并且不能为空,首次创建,不能被监听。ChangeNotifierProvider<T>( create: (_) => changeNotifier.vm, child: child)
可以用Providr.of(context)获取到ViewModel对象。 changeNotifier.vm = Provider.of<T>(context); return ChangeNotifierProvider<T>.value( value: changeNotifier.vm, child: child)
页面根布局处理
默认情况根布局会响应每次的刷新 if (config.root) return true;
否则就只响应初始数据加载时的变化,后期页面刷新都会忽略进而优化页面渲染。
源码:/// root 根节点加工 根节点是否需要刷新,不刷新就执行一次刷新 更新第一次状态变化 Widget _root<VM extends BaseViewModel>( BuildContext context, ViewConfig config, VMBuilder builder) { /// 是否根节点需要刷新 return _availableCNP<VM>( context, config, child: Selector<VM, dynamic>( child: config.child, selector: (ctx, vm) => vm.entity, shouldRebuild: (_, __) { if (config.root) return true; if (!config._firstLoad) return false; config._firstLoad = false; return true; }, builder: (ctx, value, child) => _viewState(config, (state) => builder(ctx, config.vm, child, state)), ), ); }
页面状态管理
首先判断页面状态ViewConfig配置配置为空则采用默认状态页。
然后就页面背景处理。
最后判断是否需要跨页面刷新 是否配置ViewConfig.state 提前埋点。
源码:/// 页面状态展示 空 正常 错误 忙碌 Widget _viewState<VM extends BaseViewModel>( ViewConfig data, Widget Function(Widget state) builder) { VM viewModel = data.vm; var bgColor = data.color; var checkEmpty = data.checkEmpty; var state = data.state; var empty = data.empty == null ? null : data.empty(viewModel); var busy = data.busy == null ? null : data.busy(viewModel); var error = data.error == null ? null : data.error(viewModel); var un = data.unAuthorized == null ? null : data.unAuthorized(viewModel); Widget stateView; if (viewModel == null || checkEmpty && viewModel.empty) { stateView = empty ?? Container( color: bgColor, child: ViewStateEmptyWidget(onTap: () => viewModel.viewRefresh()), ); } else if (viewModel.busy) { stateView = busy ?? ViewStateBusyWidget(backgroundColor: bgColor); } else if (viewModel.error) { stateView = error ?? ViewStateWidget(onTap: () => viewModel.viewRefresh()); } else if (viewModel.unAuthorized) { stateView = un ?? ViewStateUnAuthWidget(onTap: () => viewModel.viewRefresh()); } Widget view = builder(stateView); if (bgColor != null) { view = Container(child: view, color: bgColor); } if (state == null) { return view; } else { /// view状态变化提醒 var changer = ValueListenableBuilder( valueListenable: changerStateGet(state).vn, builder: (_, changer, __) { // LogUtil.printLog("state : ${state.toString()} value: $changer"); try { var vsChanger = changerStateCheck(state); if (vsChanger.changer) { // LogUtil.printLog("state : ${state.toString()} value: $changer" // "notifier: ${vsChanger.notifier}"); viewModel.viewRefresh(notifier: vsChanger.notifier, busy: false); } } catch (e) { print(e); } return SizedBox(); }, ); return Stack(children: <Widget>[changer, Positioned.fill(child: view)]); } }
跨页面刷新
这里就是上面讲到了状态的埋点,然后其他页面处理时需要刷新则调用changerStateUpdate
举例:商城购物车,在商品详情点击添加购物车后,需要刷新购物车列表。
源码:class _ViewStateNotifier { ValueNotifier<bool> vn; bool notifier; _ViewStateNotifier(this.vn, {this.notifier = true}); } /// 状态通知 跨页面通知数据需要变动 class ViewStateNotifier { bool changer; bool notifier; ViewStateNotifier(this.changer, this.notifier); } /// 全局状态变动存储 Map<int, _ViewStateNotifier> _changerState = {}; /// 获取状态配置 _ViewStateNotifier changerStateGet(int state) { if (!_changerState.containsKey(state)) { _changerState[state] = _ViewStateNotifier(ValueNotifier(false)); } return _changerState[state]; } /// 更新页面状态 void changerStateUpdate(int state, {bool notifier = true}) { if (_changerState.containsKey(state)) { _changerState[state].vn.value = true; _changerState[state].notifier = notifier; } } /// 验证是否需要变化 ViewStateNotifier changerStateCheck(int state) { var result = _changerState[state].vn.value; _changerState[state].vn.value = false; return ViewStateNotifier(result, _changerState[state].notifier); }
BaseView
子类必须实现initConfig和vmBuild,并且删除build的实现。
initConfig 提供页面的初始化配置。
vmBuild 页面最终呈现调用的方法。
源码介绍:/// 基类 view 扩展[StatelessWidget] mixin BaseView<VM extends BaseViewModel> on StatelessWidget { /// 初始化配置 @protected ViewConfig<VM> initConfig(BuildContext context); /// VM 相关 @protected Widget vmBuild(BuildContext context, VM vm, Widget child, Widget state); /// 初始化操作 加载等 _init(BuildContext context, ViewConfig<VM> config) async { config.vm.context ??= context; if (config.load) await config.vm.viewRefresh(); } /// 不要使用 推荐使用 [vmBuild] @override @deprecated Widget build(BuildContext ctx) { // LogUtil.printLog("build:----" + this.runtimeType.toString()); var config = initConfig(ctx); if (config == null) throw "initConfig 方法 返回空值"; /// 是否需要加载 if (!config.load) return _root<VM>(ctx, config, vmBuild); return FutureBuilder( future: _init(ctx, config), builder: (ctx, __) => _root<VM>(ctx, config, vmBuild)); } }
子类必须实现的方法,VM就是当前页面所需要的BaseViewModel,child 就是当前页面不会有状态变化的控件,state就是当前页面状态切换提供给子类展示和判断,只有当页面加载顺利完成才会为空。
源码: /// VM 相关 @protected Widget vmBuild(BuildContext context, VM vm, Widget child, Widget state);
文章页面可以看到,我返回了Scaffold,body里面我判断了state是否为空,当状态为空时,代表数据加载全部完成然后返回一个列表来展示数据。 @override Widget vmBuild( BuildContext context, ArticleVM vm, Widget child, Widget state) { return Scaffold( backgroundColor: Colors.white, appBar: AppBar(title: Text("文章")), body: state ?? EasyRefresh( controller: vm.refreshController, onLoad: vm.loadMore, onRefresh: vm.viewRefresh, child: ListView.builder( itemCount: vm.list.length, itemBuilder: (ctx, index) => _item(vm.list[index])), ), ); }
BaseViewOfState
基本使用跟BaseView类似。/// 基类 state 扩展[StatefulWidget] 的 [State] mixin BaseViewOfState<T extends StatefulWidget, VM extends BaseViewModel> on State<T> {
案例:/// 退货或者退款 class _ReturnedRefund extends StatefulWidget { const _ReturnedRefund( this.isRefund, { Key key, }) : super(key: key); final bool isRefund; @override _ReturnedRefundState createState() => _ReturnedRefundState(); } class _ReturnedRefundState extends State<_ReturnedRefund> with AutomaticKeepAliveClientMixin, BaseViewOfState<_ReturnedRefund, ReturnedRefundListVM> { @override bool get wantKeepAlive => true; @override ViewConfig<ReturnedRefundListVM> initConfig(BuildContext context) => ViewConfig(vm: ReturnedRefundListVM(widget.isRefund)); @override Widget vmBuild(BuildContext context, vm, Widget child, state) { return PSDisplay( primary: () => state, secondary: () => EasyRefresh( controller: vm.refreshController, onRefresh: vm.viewRefresh, onLoad: vm.loadMore, child: ListIntervalView( space: 32, itemCount: vm.list.length, itemBuilder: (_, index) => _OrderItem(vm, index), ), ), ); } }
Demo 讲解
这里创建UserModel。class UserModel extends BaseModel { /// 登录 Future<bool> login(String account, String psd) async { await Future.delayed(Duration(seconds: 3)); return true; } /// 资讯列表 Future<DataResponse<ArticleEntity>> getArticleList() async { await Future.delayed(Duration(seconds: 2)); var entity = ArticleEntity( [ArticleItem("1", "好的", "内容内容内容内容内容", DateTime.now().toString())]); DataResponse dataResponse = DataResponse<ArticleEntity>(entity: entity, totalPageNum: 3); return dataResponse; } }
调用initMVVM(); class App extends StatefulWidget { @override _AppState createState() => _AppState(); } class _AppState extends State<App> { @override void initState() { initMVVM([UserModel()]); super.initState(); } @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, home: ArticlePage(), ); } }
模拟接口返回的数据实体类:class ArticleEntity extends BaseEntity { List<ArticleItem> list; ArticleEntity(this.list); } class ArticleItem { String id; String title; String content; String time; ArticleItem(this.id, this.title, this.content, this.time); }
UserModel 继承 BaseModel,ArticleEntity继承BaseEntity,ArticleItem 暂没要求。class ArticleVM extends BaseListViewModel<UserModel, ArticleEntity, ArticleItem> { /// 下拉加载 装载数据 @override void jointList(ArticleEntity newEntity) => entity.list.addAll(newEntity.list); /// 返回数据 @override List<ArticleItem> get list => entity.list; /// 网络请求 @override Future<DataResponse<ArticleEntity>> request({bool isLoad, int page, params}) { return model.getArticleList(); } }
class ArticlePage extends StatelessWidget with BaseView<ArticleVM> { @override ViewConfig<ArticleVM> initConfig(BuildContext context) => ViewConfig(vm: ArticleVM()); @override Widget vmBuild( BuildContext context, ArticleVM vm, Widget child, Widget state) { return Scaffold( backgroundColor: Colors.white, appBar: AppBar(title: Text("文章")), body: state ?? EasyRefresh( controller: vm.refreshController, onLoad: vm.loadMore, onRefresh: vm.viewRefresh, child: ListView.builder( itemCount: vm.list.length, itemBuilder: (ctx, index) => _item(vm.list[index])), ), ); } Widget _item(ArticleItem item) { return Container( color: Colors.lightGreen, margin: EdgeInsets.all(8), padding: EdgeInsets.all(4), child: Column( children: <Widget>[ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: <Widget>[ Text(item.title), Text(item.time), ], ), Padding( padding: const EdgeInsets.all(8.0), child: Text(item.content), ), ], ), ); } }
写在最后
本网页所有视频内容由 imoviebox边看边下-网页视频下载, iurlBox网页地址收藏管理器 下载并得到。
ImovieBox网页视频下载器 下载地址: ImovieBox网页视频下载器-最新版本下载
本文章由: imapbox邮箱云存储,邮箱网盘,ImageBox 图片批量下载器,网页图片批量下载专家,网页图片批量下载器,获取到文章图片,imoviebox网页视频批量下载器,下载视频内容,为您提供.
阅读和此文章类似的: 全球云计算