《Flutter TolyUI 框架》系列前言:
TolyUI 是 张风捷特烈 打造的 Fluter 全平台应用开发 UI 框架。具备 全平台、组件化、源码开放、响应式 四大特点。可以帮助开发者迅速构建具有响应式全平台应用软件:
开源地址: github.com/TolyFx/toly…
该系列将详细介绍 TolyUI 框架使用方式、框架开发过程中的技术知识、设计理念、难题解决等。
TolyUI 中每个组件有若干个介绍的案例,随着组件的增加,介绍的节点的维护成了一个非常繁杂的事。如下所示,一个介绍节点包括 标题
、介绍
、代码
、内容
四个部分:
之前案例展示的信息内容通过 Map 对象进行维护,如下红框中是 BreadcrumbDemo1
的介绍信息。这种维护方式,任何部分的变更都需要及时同步,比如视图代码变化了,展示的代码信息如果没及时更新。使用者就会产生困惑:
靠手动来维护这些数据,正在变得越来越复杂。为了 TolyUI 更好的发展,我需要寻找一条可以自动解析和管理介绍文本信息的方式。也是文想要向大家分享的内容。
面对当前的维护困境,我给出的方案是: 解析文件 与 自动生成代码。首先需要明确,当前解析的目标以及想要生成的内容。解析的目标自然是对当前案例代码的介绍信息。自动生成的内容有三个部分:
[1]
. 之前手动维护的 displayNodes 将是生成代码的核心内容。放在 node.g.dart
文件中。[2]
. 案例的展示代码属于大文本,并没有必要全部放入映射中占据内存。所以会将其抓取到 assets 资源文件之下,点击时按需加载。[3]
. 案例最终想要以组件的形式展示在界面上,节点数据以字符串作为标识,通过 widget_display_map.g.dart
来维护标识与具体组件间的映射关系。巧妇难为无米之炊
,现在最重要的问题是:在哪里,如何向案例提供描述信息? 由于 Dart 即将支持宏编程,所以我决定采用 注解 的方式来维护某个案例的介绍数据。如下所示: @DisplayNode
是我自定义的注解类,包含标题和描述两个字段:
到这里,解析生成的路线基本就确定了:
[1]
. 遍历案例文件夹,当文件内容有 @DisplayNode
注解时,通过正则解析文件,收录数据。[2]
. 通过解析收录的数据,操作文件生成对应代码。[3]
. 解析过程中提取案例代码到资源文件。NodeMeta
是解析过程中承载数据的核心对象,每个案例文件将解析成一个 NodeMeta 对象。其中变化代码字符串、案例文件路径、案例名称、展示信息四个数据内容:
class NodeMeta {
final String code;
final String name;
final String filePath;
final DisplayNode display;
const NodeMeta({
required this.filePath,
required this.code,
required this.name,
required this.display,
});
拿上面的 CardDemo1 为例,该文件中已经包含了 NodeMeta
对象的所有信息数据。现在关键在于如何解析文本内容,生成 NodeMeta
对象。
想要匹配文本中的关键信息,很容易想到可以使用 正则表达式 。比如想要抓取注解的字符串内容,可以通过 @DisplayNode(.|\s)*?\)
进行匹配:
抓取到 DisplayNode 配置的字符串之后,可以继续通过正则表达式来匹配对应字段的数据。如下所示,匹配其中 title 对应的字符串信息:
通过 class (?<name>\w+)(.|\s)*
匹配第一个以 class 开头的文字及其之后的所有字符串。并且 class 后的类名通过 name
组名获取:
一个园林中有很多树,想着为所有树木修葺是一件很复杂的事。但修葺一棵树就比较简单,而且修葺的任务是类似的工作,一棵修的好,那么其他的都只是时间问题。解析也是一样,一开始应该着眼于个体,做好第一个任务。
如下,定义 DisplayFileParser
类负责解析 path
对应的文件内容,通过 parser 方法异步解析,生成 NodeMeta
对象:
class DisplayFileParser {
final String path;
static final RegExp _codeRegex = RegExp(r'class (?<name>\w+)(.|\s)*');
static final RegExp _displayRegex = RegExp(r'@DisplayNode(.|\s)*?\)');
const DisplayFileParser(this.path);
Future<NodeMeta?> parser() async {...}
在解析前,可以先基于一些既定规则,过滤一下不必要的文件读取和解析。比如这里所有的案例文件名都会包含 _demo
字符串。另外,读取内容中不包含 @DisplayNode(
的文件表示不需要解析。最终通过 _parserContent
方法处理具体的解析逻辑:
Future<NodeMeta?> parser() async {
if (!path.contains('_demo')) return null;
File file = File(path);
String content = await file.readAsString();
bool hasDisplay = content.contains('@DisplayNode(');
if (!hasDisplay) return null;
return _parserContent(path, content);
}
_parserContent
方法中基于两个正则表达式匹配得到数据,从而创建 NodeMeta
对象。其中 DisplayNode.fromString 构造方法是基于字符串,来匹配创建 DisplayNode
对象:
NodeMeta _parserContent(String filePath, String content) {
RegExpMatch? codeMatch = _codeRegex.firstMatch(content);
String? code = codeMatch?.group(0);
String? name = codeMatch?.namedGroup('name');
String? display = _displayRegex.firstMatch(content)?.group(0);
return NodeMeta(
filePath: filePath,
name: name ?? '',
code: code ?? '',
display: DisplayNode.fromString(display ?? ''),
);
}
如下所示,这样就可以解析某个案例文件,通过正则匹配得到关键的数据内容:
所有的案例代码都放在了项目中的 widgets
文件夹下,接下来需要遍历文件夹,来逐一解析内容。得到每个案例文件对应的 NodeMeta
数据集:
下面代码中,通过 parserDir
方法遍历一个文件夹中的文件,处理解析逻辑。并将解析的结果放入 displayMap
中。由于一个组件有若干个案例,所以这里通过 Map<String, List<NodeMeta>>
记录一个组件对应的节点列表信息。解析完后,数据如下所示:
void main() async {
String widgetPath = path.join(Directory.current.path, 'lib', 'view', 'widgets');
Directory widgetDir = Directory(widgetPath);
Map<String, List<NodeMeta>> displayMap = {};
await parserDir(widgetDir, displayMap);
}
Future<void> parserDir(Directory dir, Map<String, List<NodeMeta>> displayMap) async {
List<NodeMeta> displays = [];
List<FileSystemEntity> entity = dir.listSync();
for (FileSystemEntity e in entity) {
if (e is File) {
NodeMeta? ret = await DisplayFileParser(e.path).parser();
if (ret != null) {
displays.add(ret);
ret.saveCode();
}
} else if (e is Directory) {
await parserDir(e, displayMap);
}
}
if (displays.isNotEmpty) {
displayMap[path.basename(dir.path)] = displays;
}
}
上面已经完成了对案例代码的解析,得到了所有期望获取的数据。接下来就是基于这些数据,创建并写入代码文件,完成案例代码的自动维护。
代码生成的核心是 node.g.dart
,其中 queryDisplayNodes
方法可以通过组件名称得到对应的案例列表数据。注意这里使用的是 switch 进行匹配,并不是将所有的数据通过 Map 全部加入到内存中。这种运行时的取用,可以降低内存的使用,特别是对于案例介绍这样的大量数据。
另外,这里将每个组件对应的案例列表数据拆散成 独立文件。通过 part
和 part of
关键字建立文件间的关系。将独立文件在逻辑上视为 node.g.dart
的一部分。单独分离文件的目的在于:让代码逻辑结构更加清晰,另外,单个大文件在多人协作时更容易产生冲突。
在案例介绍的信息中,记录着 String 类型的案例组件名,但在展示时需要将组件名映射为具体的组件。由于解析过程中,所有案例的组件名都可以收集到,因此可以自动生成 widgetDisplayMap 的映射关系,将字符串映射为对应的组件:
在视图层的使用中,通过组件标识调用 queryDisplayNodes
查找案例信息列表。然后遍历列表,根据案例组件的字符串名称,基于 widgetDisplayMap 得到对应的组件:
代码本质上也是字符串,基于解析得到的 displayMap 数据,我们可以通过字符串拼接得到代码字符串,然后写入到指定的文件中。这样就完成了用代码写代码的目的:通过 FileGen
类来维护代码生成的逻辑,其中依赖解析后的数据对象 displayMap
,通过构造函数传入:
class FileGen {
final Map<String, List<NodeMeta>> displayMap;
FileGen(this.displayMap);
代码的生成听起来好像挺高大上的,但本质上也只是一个基于模版的填词游戏。如下是 node.g.dart
的文件模版,需要结合 displayMap 中的数据,将 part
和 content
的两处内容添到指定位置:
String nodeTemplate(String content, String part) {
return """
/// ===================================================
/// Power By 张风捷特烈 --- Generated file. Do not edit.
/// github: https://github.com/toly1994328
/// ===================================================
$part
Map<String, dynamic> queryDisplayNodes(String name){
return switch(name){
$content _ => {},
};
}
""";
}
其中的内容,只需要遍历 displayMap
映射元素,拼接呈成目标字符串即可。如下代码在 nodeParts
和 nodeContents
分别表示 node.g.dart
头部引入的部分和中间的具体内容字符串列表。生成代码字符串之后,写入对应文件中,将完成代码的生成任务:
Future<void> genNode(String outPath) async {
List<String> nodeParts = [];
List<String> nodeContents = [];
File file = File(outPath);
displayMap.forEach((k, v) {
Map<String, dynamic> items = {};
nodeParts.add("part '$k.g.dart';\n");
nodeContents.add(' "$k" => _${k}Data,\n');
///...
});
String content = nodeTemplate(nodeContents.join(), nodeParts.join());
await file.writeAsString(content);
}
单个组件对应的节点列表文件也是类似,定义模版之后,遍历映射关系,向其中插入期望的字符串,得到代码:
String singleNodeTemplate(String content, String name) {
content = content.replaceAll(r'$', r'\$');
return """/// ===================================================
/// Power By 张风捷特烈 --- Generated file. Do not edit.
/// github: https://github.com/toly1994328
/// ===================================================
part of 'node.g.dart';
Map<String, dynamic> get _${name}Data => $content;""";
}
到这里,已经完成了解析和代码生成的逻辑,以后任何的代码或描述信息的改动,或者新增组件案例介绍。只要运行一下工具就可以自动生成代码,同步所有的更新内容。从而大大简化了书写和维护案例介绍的 劳动成本。
虽然现在已经挺好用了,但是作为 dart 文件来执行会比较麻烦,还需要手动点击运行。期间的编译、运行会耗个十几秒,也不是非常优雅。
之前在 《Flutter 知识集锦 | Dart 开发命令行工具》 一文中介绍过,Dart 文件可以作为打包为命令行工具,进行使用。所以为了更好地使用工具来生成代码,我将这个代码解析生成器集成到 toly 命令行工具中:
也就是说,当案例信息有任何变化,我只需要在命令行输入 toly ui ,就可以在 100ms
内完成代码生成来更新所有的案例信息。
工具可以让人从枯燥的繁杂任务中解脱出来,特别是重复性的有明确规则的任务。联合收割机、卡车、电饭锅,优秀的工具能更精准、迅速且正确地完成特定任务,从而可以大大提升生产的效率。希望 tolyui 中对于案例的解析管理,能让你对工具的使用有所启发。那本就到这里,谢谢观看 ~
到这里 TolyUI 就完成了一个可以灵活定制的下拉菜单 TolyDropMenu。目前为止,TolyUI 已经完成了响应式布局和反馈模块的核心功能。导航模块也完成了三个非常重要的组件,下一步会继续对导航模块进行开发,敬请期待 ~
感谢你关注 tolyui 的成长,如果喜欢,也希望你能在 github 中点赞支持~
github 开源地址: github.com/TolyFx/toly… TolyUI 官方案例演示网站:toly1994.com/ui