Flutter桌面开发-项目工程化框架搭建
通过此篇文章,你将了解到:
- 完整且可投入生产的Flutter项目架构;
- mvvm状态管理库GetX的全家桶式体验;
前言
在本专栏前面的几篇文章中,我们对桌面应用实现了可定制的窗口化
,适配了多种分辨率
的屏幕,并且实现了小组件 “灵动岛”。前面的文章算是一些基础建设的搭建,这篇文章我将基于状态管理库GetX
,搭建一个成熟完善的,可投入生产开发的项目架构。这也是我们后面继续开发桌面应用的基础。
搭建原则
此次项目框架的搭建,完全基于GetX库。虽说之前我也分析过GetX的优势和弊端,但对于我们一个开源的项目,GetX这种“全家桶”库再适合不过啦。同时还会提供在Windows开发过程中一些区别于移动端开发的小技巧。
GetX全家桶的搭建
为啥说GetX是全家桶,因为它不仅可以满足MVVM的状态管理,还能满足:国际化、路由配置、网络请求等等,着实方便,而且亲测可靠!GetX
1. 国际化
GetX提供了应用的顶层入口GetMaterialApp
,这个控件封装了Flutter的MaterialApp
,我们只需要按照GetX给定的规则传入多语言的配置即可。配置也是非常简单的,只需要在类中提供get声明的map对象即可。Map的key由语言的代码和国家地区组成,无需处理系统语言环境变化等事件。
import 'package:get/get.dart';
class Internationalization extends Translations {
@override
Map<String, Map<String, String>> get keys => {
'en_US': {
'appName': 'Flutter Windows',
'hello':'Hello World!'
},
'zh_CN': {
'appName': 'Flutter桌面应用',
'hello':'你好,世界!'
},
'zh_HK': {
'appName': 'Flutter桌面應用',
'hello':'你好,世界!'
},
};
}
2. 路由配置
如果没有使用GetX,路由管理很大情况是使用Fluro,大量的define、setting、handle真的配置的很枯燥。在GetX中,你只需要配置路由名称和对应的Widget即可。
class RouteConfig {
/// home模块
static const String home = "/home/homePage";
/// 我的模块
static const String mine = "/mine/myPage";
static final List<GetPage> getPages = [
GetPage(name: home, page: () => HomePage()),
GetPage(name: mine, page: () => MinePage()),
];
}
至于参数,可以直接像web端的url一样,使用?、&
传递。
同时GetX也提供了路由跳转的方式,相比Flutter Navigator2提供的api,GetX的路由跳转明显更加方便,可以脱离context进行跳转,我们可以在VM层随意处理路由,这点真的很爽。
// 跳转到我的页面
Get.toNamed('${RouteConfig.mine}?userId=123&userName=karl');
// 我的页面接收参数
String? userName = Get.parameters['userName'];
3. GetX状态管理
状态管理才是GetX的重头戏,GetX中实现的Obx机制,能非常轻量级的帮我们定点刷新。Obx是通过创建定向的Stream,来局部setState的。而且作者还提供了ide的插件,我们来创建一个GetX的页面。 通过插件快捷创建之后我们可以得到:logic、state、view的分层结构,通过logic绑定数据和视图,并且实现数据驱动UI刷新。 当然,通过Obx的方式会触发创建较多的Stream,有时使用update()来主动刷新也是可以的。 关于GetX的状态管理,有个细节要提示下:
- 如果listview.build下的item都有自己的状态管理,那么每个item需要向logic传递自己的tag才能产生各自的Obx stream;
Get.put(SwiperItemLogic(), tag: model.key);
GetX相对其他的状态管理,最重点是基于Stream实现了真正的跨组件通信,包括兄弟组件;只需要保证logic层Put一次,其余组件去Find即可直接更新logic的值,实现视图刷新。
4. 网络请求
在网络请求上,GetX的封装其实并没有dio来的好,Get_connect插件集成了REST API请求和GraphQL规范,我们开发过程中其实不会两者都用。虽然GraphQL提高了健壮性,但在定义请求对象的时候,往往会增加一些工作量,特别是对于小项目。
- 我们可以先创建一个基础内容提供,完成通用配置;
/// 网络请求基类,配置公共属性
class BaseProvider extends GetConnect {
@override
void onInit() {
super.onInit();
httpClient.baseUrl = Api.baseUrl;
// 请求拦截
httpClient.addRequestModifier<void>((request) {
request.headers['accept'] = 'application/json';
request.headers['content-type'] = 'application/json';
return request;
});
// 响应拦截;甚至已经把http status都帮我们区分好了
httpClient.addResponseModifier((request, response) {
if (response.isOk) {
return response;
} else if (response.unauthorized) {
// 账户权限失效
}
return response;
});
}
}
- 然后按照模块化去配置请求,提高可维护性。
import 'package:get/get.dart';
import 'base_provider.dart';
/// 按照模块去制定网络请求,数据源模块化
class HomeProvider extends BaseProvider {
// get会带上baseUrl
Future<Response> getHomeSwiper(int id) => get('home/swiper');
}
日志记录
日志我们采用Logger进行记录,桌面端一般使用txt文件格式。以时间命名,天为单位建立日志文件即可。如果有需要,也可以加一些定时清理的逻辑。
我们需要重写下LogOutput
的方法,把颜色和表情都去掉,避免编码错误,然后实现下单例。
Logger? logger;
Logger get appLogger => logger ??= Logger(
filter: CustomerFilter(),
printer: PrettyPrinter(
printEmojis: false,
colors: false,
methodCount: 0,
noBoxingByDefault: true),
output: LogStorage(),
);
class LogStorage extends LogOutput {
// 默认的日志文件过期时间,以小时为单位
static const _logExpiredTime = 72;
/// 日志文件操作对象
File? _file;
/// 日志目录
String? logDir;
/// 日志名称
String? logName;
LogStorage({this.logDir, this.logName});
@override
void destroy() {
deleteExpiredLogs(_logExpiredTime);
}
@override
void init() async {
deleteExpiredLogs(_logExpiredTime);
}
@override
void output(OutputEvent event) async {
_file ??= await createFile(logDir, logName);
String now = CommonUtils.formatDateTime(DateTime.now());
String version = packageInfo.version;
_file!.writeAsStringSync('>>>> $version $now [${event.level.name}]\n',
mode: FileMode.writeOnlyAppend);
for (var line in event.lines) {
_file!.writeAsStringSync('${line.toString()}\n',
mode: FileMode.writeOnlyAppend);
debugPrint(line);
}
}
Future<File> createFile(String? logDir, String? logName) async {
logDir = logDir;
logName = logName;
if (logDir == null) {
Directory documentsDirectory = await getApplicationSupportDirectory();
logDir =
"${documentsDirectory.path}${Platform.pathSeparator}${Constants.logDir}";
}
logName ??=
"${CommonUtils.formatDateTime(DateTime.now(), format: 'yyyy-MM-dd')}.txt";
String path = '$logDir${Platform.pathSeparator}$logName';
debugPrint('>>>>日志存储路径:$path');
File file = File(path);
if (!file.existsSync()) {
file = await File(path).create(recursive: true);
}
return file;
}
吐司提示
吐司用的还是fluttertoast的方式。但是windows的实现比较不一样,在windows上的实现toast提示只能显示在应用窗体内。
static FToast fToast = FToast().init(Get.overlayContext!);
static void showToast(String text, {int? timeInSeconds}) {
// 桌面版必须使用带context的FToast
if (Platform.isWindows || Platform.isMacOS) {
cancelToastForDesktop();
fToast.showToast(
toastDuration: Duration(seconds: timeInSeconds ?? 3),
gravity: ToastGravity.BOTTOM,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(25.0),
color: const Color(0xff323334),
),
child: Text(
text,
style: const TextStyle(
fontSize: 16,
color: Colors.white,
),
),
),
);
} else {
cancelToast();
Fluttertoast.showToast(
msg: text,
gravity: ToastGravity.BOTTOM,
timeInSecForIosWeb: timeInSeconds ?? 3,
backgroundColor: const Color(0xff323334),
textColor: Colors.white,
fontSize: 16,
);
}
}
一些的小技巧
代码注入,更简洁的实现单例和构造引用
在开发过程中,我还会使用get_it
和injectable
来生成自动单例、工厂构造函数等类。好处是让代码更为简洁可靠,便于维护。下面举个萌友上报的例子,初始配置只需要在create中写入即可,然后业务方调用只需要使用GetIt.get<YouMengReport>().report()
上报就行了。这就是一个非常完整的单例,使用维护都很方便。
/// 声明单例,并且自动初始化
@singleton(signalsReady: true)
class YouMengReport {
/// 声明工厂构造函数,自动初始化的时候会自动自行create方法
@factoryMethod
create() {
// 这里可以做一些初始化工作
}
report() {}
}
json生成器
由于不支持反射,导致Flutter的json解析一直为人诟病。因此使用json_serializable
会是一个不错的选择,其原理是通过AOP注解,帮我们生成json编码和解析。通过插件Json2json_serializable可以帮我们自动生成dart文件,如下图:
其他
还有很多应用窗口化、单例、窗口效果交互等的细节,也是属于windows项目框架必须的,提高其可维护性也是很重要的。具体不再赘述,可看本专栏之前文章:Flutter桌面实践。
转载自:https://juejin.cn/post/7159958059006033950