在 flutter 中利用 source_gen 实现条件编译(下)
在前篇 在 flutter 中利用 source_gen 实现条件编译(中) 中,我们利用 source_gen 实现了一套基础的条件编译流程。
但是目前这套方案还有几个实用性问题:
- 条件的表达力太弱,缺乏平台类型的组合和取反操作,一旦需要处理的平台类型超过两个就会很难处理
1
2
3
4
5
6
7
8
9
10
11/// 例如,如果有三个平台类型[android、ios、desktop],那么代码需要写成下面的样子:
(platformType: PlatformType.desktop)
String platform = 'Desktop';
(platformType: PlatformType.android)
String platform = 'Mobile';
/// 即使 ios 和 android 的代码完全相同,也必须再写一遍
(platformType: PlatformType.ios)
String platform = 'Mobile'; - 生成的代码存在格式丢失情况,尤其是尾随逗号(trailing commas)丢失导致的格式化效果变差(参考:代码格式化:末尾处添加逗号),以及在个别情况下会出现代码替换出错的问题
- 缺少一个统一的入口和路径,可以用于为指定平台生成代码时执行特定的操作
- 当修改已有工程或新增代码时,错误地引用了原始的源文件而不是生成的
.p.dart
文件时,缺少判断和警告信息,从而会导致难以发现的隐蔽bug
针对这些问题,我们进一步对前面的方案进行一些修改和增强。
增强条件的表达力
这个问题是因为,在原本注解的函数签名
1 | const PlatformSpec({ |
中,只有一个枚举值用于控制该代码块的“所属”,所以只能表达它“是什么”的语义。
那么为了增加表达能力,常规能想到的修改方式有如下几种:
使用表达式
这种方式是将传参的类型改为字符串,传入支持的表达式,这种方式我在 Flutter 工程条件编译打包脚本 - FlutterX 中使用过。
但是这种方式虽然可以实现非常复杂且灵活的条件语句表达,但缺点是条件语句编辑时缺乏IDE提示的支持,会有一定出错的概率。而且由于dart不支持eval,替代方案则是使用 Isolate.spawnUri 代替或者使用类似 dart_eval、expressions 这样的库,但是它们使用起来也有各自的问题和成本。使用数组允许传入多个平台类型
这种方式是将传参的类型改为数组,即List<PlatformType>
,这样书写时就可以一次性指定多个平台类型。
这里有个库就是用了这种办法:super_annotations。
但是个人觉得这样不是很优雅,因为在只需要指定一个平台类型的时候也必须写成@PlatformSpec(platformType: [PlatformType.desktop])
,显得不够简洁。使用静态方法作为参数传递
先定义类型为typedef TypeBuilder = List<PlatformType> Function();
的静态方法,然后通过注解的参数指定,运行 build_runner 时通过反射执行函数,从而可以得到该注解匹配的平台类型的列表。这种方法的好处是注解本身看上去比较整洁,定义出的静态方法可以复用,而且由于可以写逻辑,所以可以比较方便地实现“除了xxx以外的所有平台类型”的效果。缺点还是过于繁琐,方法的定义和注解的使用分离时可能不是那么直观。
最终,参考借鉴了一段位运算代码的理解记录 - Android 中常见的用法 这种思路,用标记位的方式传入int类型的参数来实现上述需求,具体实现如下:
1. 增加 PlatformType 的方法
在platform_code_options.yaml
中增加类型:
- 基本平台类型
在platform_types
节点下以数组形式声明所有基本平台类型,形如:1
2
3
4
5platform_types:
- android
- ios
- desktop
- web - 组合平台类型
在union_types
节点下以字典形式声明组合平台类型,形如:1
2
3union_types:
mobile: [android, ios]
native: [mobile, desktop] # 注意,由于上面先定义了mobile类型,所以这里才可以使用
2. 生成平台类型的定义文件
运行 dart run build_runner build
,将根据上面的配置生成 platform_code_builder/lib/platform_type.dart
。以上面的配置为例,生成的代码如下:
1 | class PlatformType { |
这段代码中,android
作为第一个平台类型,其值为1
,实际上是1<<0
的结果;以此类推,ios
和desktop
的值实际上是1<<1
和1<<2
,所以他们的二进制形式分别是0001
、0010
和0100
。mobile
类型是android
和ios
的组合,所以它的二进制是0011
,十进制为3
。
P.S. 需要注意的是,每次修改
platform_code_options.yaml
后执行dart run build_runner build
,会重新生成platform_code_builder/lib/platform_type.dart
文件,再次执行dart run build_runner build
才会为项目生成新的*.p.dart
平台代码。如果是使用dart run build_runner watch
的方式实时监听项目的变化生成代码,则需要退出并重新watch,否则修改的平台类型不会生效。
另外,由于使用的是一个
int
类型的值存储标记位,所以最多只允许64种不同的基本平台类型,因为build_runner运行的native环境中,可以假定int
类型就是64位的(参考Dart 中的数字)。这在绝大多数情况下应该都是够用的,如果确实存在需要更大范围的场景,则需要相关的逻辑。
3. 使用注解
在生成 platform_code_builder/lib/platform_type.dart
后,可以用如下方式使用注解标记代码块:
1 | /// 直接使用定义好的组合平台类型 |
而对于除了...以外的所有平台
这样的场景,我在注解的签名中加入了一个not
的命名参数(借鉴了rust的cfg: cfg - 通过例子学 Rust 中文版),其值默认为false
。当将其值设为true
时即表示标记的代码将在除了...以外的所有平台
保留。
所以现在的注解签名为:
1 | const PlatformSpec({ |
4. 为指定的平台类型生成项目代码
方法一
修改
platform_code_options.yaml
,修改最后一行current_platform:
的值:1
current_platform: android
运行代码生成:
1
dart run build_runner build
方法二
直接在代码生成命令中加入options覆盖参数:
ios:
1
dart run build_runner build --define "platform_code_builder:platform_builder=platform=ios"
web:
1
dart run build_runner build --define "platform_code_builder:platform_builder=platform=web"
注意:代码生成时选择的 platform 必须是
platform_code_options.yaml
中platform_types
定义的“基本平台类型”
解决生成的代码格式丢失问题
造成这个问题的原因是原本的代码替换逻辑是(flutter_platform_code_demo/lib/builder/platform_generator.dart):
1 |
|
在这个过程中,parseString
方法在解析过程中就会移除一些对语法没有影响的内容,包括所有的尾随逗号和换行符。而且由于遍历AST时是用每个ASTNode
的toString()
方法拿到对应源码,也是移除了尾随逗号和换行符的文本,所以后续替换时如果拿未经处理的源码内容来替换,就会出现内容匹配不上导致替换失败的问题。
在尝试了各种方法无果后,偶然看到这个技巧:https://github.com/dart-lang/sdk/issues/34539#issuecomment-423589192
原来可以用需要修改的节点或者元素的token里的offset和end属性,得到该ASTNode
在源码中的开始和结束位置,然后倒序排序后循环对源码做replaceRange 这样就不会出问题了,具体实现的代码是:https://github.com/debuggerx01/platform_code_builder_starter/blob/15011c009d1a7afbea48d260ac70a10d79264890/platform_code_builder/lib/src/platform_generator.dart#L71-L92
增加统一的入口用于为指定平台生成代码时执行特定的操作
除了针对代码的替换,在实际项目中针对不同平台,往往还需要做一些其他修改,比如:
- 针对不同平台,选择使用不同的assets资源
- 根据不同的渠道或配置,修改原生项目的包名
- 根据不同平台,执行特定脚本或下载特定内容到项目中
这里我指定了入口文件为项目根目录下的bin/handle_platform.dart
文件,基础代码如下:
1 | import 'package:platform_code_builder/platform_type.dart'; |
可以参考 platform_code_builder_starter/bin/handle_platform.dart 这个例子,执行的操作是为不同平台设置对应的 logo 图片资源。
错误引用了原始的源文件而不是生成的[*.p.dart]文件时给出错误提示
在使用过程中,尤其是在对已有项目进行改造时,很有可能出现已经将某个源文件用注解改造完成,其他源文件引用的却还是原始的文件而不是生成的*.p.dart
文件,从而导致难以发现的隐蔽bug。
这里我使用了 lakos 这个非常 WonderFull 的库,它可以分析Flutter/Dart项目中源码的依赖关系:
将项目的lib/
目录传入lakos包
的buildModel
方法后,即可得到项目源码之间关系的有向图模型,其中的edges属性就是导入/导出依赖关系表示为有向图形式的所有的边。所以当source_gen
执行到注解标记的源码文件,该文件出现在了edges的任意一条边中时,即代表该源码出现的错误的引用问题,此时通过sdterr
向控制台输出错误提醒信息:
总结
经过了上面的这些改进,最终的结果就得到了platform_code_builder_starter这个项目。
DEMO使用方法
clone本仓库
下载依赖
dart pub get
运行代码生成:
1
dart run build_runner build
查看
lib
目录下生成的*.p.dart
代码,或直接运行项目查看效果tips:可以用
flutter create ./
命令创建支持各个平台运行的模板代码
向项目中集成的步骤
- clone本仓库
- 复制
platform_code_builder
目录至目标项目的根目录 - 编辑目标项目的
pubspec.yaml
,添加如下内容1
2
3
4
5
6
7
8dependencies:
……
platform_code_builder:
path: platform_code_builder
dev_dependencies:
……
build_runner: ^<latest_version> - 在项目根目录创建
platform_code_options.yaml
,根据项目需要定义所有平台类型 - 定义完成后,在项目根目录依次如下命令: 完成后请检查生成的
1
2dart pub get
dart run build_runner buildplatform_code_builder/lib/platform_type.dart
文件内容无误 - 在项目源码中使用注解标记不同平台下的代码,参考注解使用说明
- (可选),创建
bin/handle_platform.dart
,用于为指定平台执行特殊操作,基础代码如下:1
2
3
4
5
6
7import 'package:platform_code_builder/platform_type.dart';
main(List<String> args) {
var platformMaskCode = PlatformType.fromName(args.first);
/// 在这里判断platformMaskCode执行所需操作
} - 运行
run build_runner build
或dart run build_runner watch
,并将项目中相关的import源码路径更改为生成的*.p.dart
- 运行Flutter/Dart项目,检查结果是否符合预期
本方案可能还存在一些BUG,以及改进的空间,欢迎提issue讨论或者pr,谢谢~