在前篇 在 flutter 中利用 source_gen 实现条件编译(中) 中,我们利用 source_gen 实现了一套基础的条件编译流程。

但是目前这套方案还有几个实用性问题:

  1. 条件的表达力太弱,缺乏平台类型的组合和取反操作,一旦需要处理的平台类型超过两个就会很难处理
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /// 例如,如果有三个平台类型[android、ios、desktop],那么代码需要写成下面的样子:

    @PlatformSpec(platformType: PlatformType.desktop)
    String platform = 'Desktop';

    @PlatformSpec(platformType: PlatformType.android)
    String platform = 'Mobile';

    /// 即使 ios 和 android 的代码完全相同,也必须再写一遍
    @PlatformSpec(platformType: PlatformType.ios)
    String platform = 'Mobile';
  2. 生成的代码存在格式丢失情况,尤其是尾随逗号(trailing commas)丢失导致的格式化效果变差(参考:代码格式化:末尾处添加逗号),以及在个别情况下会出现代码替换出错的问题
  3. 缺少一个统一的入口和路径,可以用于为指定平台生成代码时执行特定的操作
  4. 当修改已有工程或新增代码时,错误地引用了原始的源文件而不是生成的.p.dart文件时,缺少判断和警告信息,从而会导致难以发现的隐蔽bug

针对这些问题,我们进一步对前面的方案进行一些修改和增强。

增强条件的表达力

这个问题是因为,在原本注解的函数签名

1
2
3
4
const PlatformSpec({
required this.platformType,
this.renameTo,
});

中,只有一个枚举值用于控制该代码块的“所属”,所以只能表达它“是什么”的语义。
那么为了增加表达能力,常规能想到的修改方式有如下几种:

  1. 使用表达式
    这种方式是将传参的类型改为字符串,传入支持的表达式,这种方式我在 Flutter 工程条件编译打包脚本 - FlutterX 中使用过。
    但是这种方式虽然可以实现非常复杂且灵活的条件语句表达,但缺点是条件语句编辑时缺乏IDE提示的支持,会有一定出错的概率。而且由于dart不支持eval,替代方案则是使用 Isolate.spawnUri 代替或者使用类似 dart_evalexpressions 这样的库,但是它们使用起来也有各自的问题和成本。

  2. 使用数组允许传入多个平台类型
    这种方式是将传参的类型改为数组,即List<PlatformType>,这样书写时就可以一次性指定多个平台类型。
    这里有个库就是用了这种办法:super_annotations
    但是个人觉得这样不是很优雅,因为在只需要指定一个平台类型的时候也必须写成 @PlatformSpec(platformType: [PlatformType.desktop]),显得不够简洁。

  3. 使用静态方法作为参数传递
    先定义类型为 typedef TypeBuilder = List<PlatformType> Function();的静态方法,然后通过注解的参数指定,运行 build_runner 时通过反射执行函数,从而可以得到该注解匹配的平台类型的列表。这种方法的好处是注解本身看上去比较整洁,定义出的静态方法可以复用,而且由于可以写逻辑,所以可以比较方便地实现“除了xxx以外的所有平台类型”的效果。缺点还是过于繁琐,方法的定义和注解的使用分离时可能不是那么直观。

最终,参考借鉴了一段位运算代码的理解记录 - Android 中常见的用法 这种思路,用标记位的方式传入int类型的参数来实现上述需求,具体实现如下:

1. 增加 PlatformType 的方法

platform_code_options.yaml中增加类型:

  • 基本平台类型
    platform_types节点下以数组形式声明所有基本平台类型,形如:
    1
    2
    3
    4
    5
    platform_types:
    - android
    - ios
    - desktop
    - web
  • 组合平台类型
    union_types节点下以字典形式声明组合平台类型,形如:
    1
    2
    3
    union_types:
    mobile: [android, ios]
    native: [mobile, desktop] # 注意,由于上面先定义了mobile类型,所以这里才可以使用

2. 生成平台类型的定义文件

运行 dart run build_runner build,将根据上面的配置生成 platform_code_builder/lib/platform_type.dart。以上面的配置为例,生成的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class PlatformType {
static const android = 1;
static const ios = 2;
static const desktop = 4;
static const web = 8;
static const mobile = 3;
static const native = 7;

static int fromName(String name) => {
'android': android,
'ios': ios,
'desktop': desktop,
'web': web,
'mobile': mobile,
'native': native
}[name]!;
}

这段代码中,android作为第一个平台类型,其值为1,实际上是1<<0的结果;以此类推,iosdesktop的值实际上是1<<11<<2,所以他们的二进制形式分别是000100100100mobile类型是androidios的组合,所以它的二进制是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,否则修改的平台类型不会生效。
warn1

另外,由于使用的是一个int类型的值存储标记位,所以最多只允许64种不同的基本平台类型,因为build_runner运行的native环境中,可以假定int类型就是64位的(参考Dart 中的数字)。这在绝大多数情况下应该都是够用的,如果确实存在需要更大范围的场景,则需要相关的逻辑。

3. 使用注解

在生成 platform_code_builder/lib/platform_type.dart后,可以用如下方式使用注解标记代码块:

1
2
3
4
5
6
7
/// 直接使用定义好的组合平台类型
@PlatformSpec(platformType: PlatformType.mobile)
const someConstant = 1.0;

/// 或者在代码中直接使用 '|' 组合已经定义的类型
@PlatformSpec(platformType: PlatformType.android | PlatformType.ios)
const someConstant = 2.0;

而对于除了...以外的所有平台这样的场景,我在注解的签名中加入了一个not的命名参数(借鉴了rust的cfg: cfg - 通过例子学 Rust 中文版),其值默认为false。当将其值设为true时即表示标记的代码将在除了...以外的所有平台保留。
所以现在的注解签名为:

1
2
3
4
5
const PlatformSpec({
int platformType,
bool not = false,
String? renameTo,
});

4. 为指定的平台类型生成项目代码

方法一

  1. 修改platform_code_options.yaml,修改最后一行current_platform: 的值:

    1
    current_platform: android
  2. 运行代码生成:

    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.yamlplatform_types 定义的“基本平台类型”

解决生成的代码格式丢失问题

造成这个问题的原因是原本的代码替换逻辑是(flutter_platform_code_demo/lib/builder/platform_generator.dart):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@override
generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep) {
/// 利用 [package:analyzer/dart/analysis/utilities.dart] 里的parseString方法解析源码
var compilationUnit = parseString(content: (element.source as FileSource).file.readAsStringSync()).unit;

/// 自定义 Visitor 遍历解析后的 AST
var _visitor = _Visitor(platformType);
compilationUnit.visitChildren(_visitor);

/// 从解析后的 AST 反向得到源码,再循环遍历替换得到新的源码
var res = compilationUnit.toSource();
for (var ele in _visitor._removes) {
res = res.replaceFirst(ele, '');
}
_visitor._renames.forEach((from, to) {
res = res.replaceFirst(from, to);
});
return res;
}

在这个过程中,parseString方法在解析过程中就会移除一些对语法没有影响的内容,包括所有的尾随逗号和换行符。而且由于遍历AST时是用每个ASTNodetoString()方法拿到对应源码,也是移除了尾随逗号和换行符的文本,所以后续替换时如果拿未经处理的源码内容来替换,就会出现内容匹配不上导致替换失败的问题。

在尝试了各种方法无果后,偶然看到这个技巧: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
2
3
4
5
6
import 'package:platform_code_builder/platform_type.dart';

main(List<String> args) {
var platformMaskCode = PlatformType.fromName(args.first);
/// 在这里判断platformMaskCode执行所需操作
}

可以参考 platform_code_builder_starter/bin/handle_platform.dart 这个例子,执行的操作是为不同平台设置对应的 logo 图片资源。

错误引用了原始的源文件而不是生成的[*.p.dart]文件时给出错误提示

在使用过程中,尤其是在对已有项目进行改造时,很有可能出现已经将某个源文件用注解改造完成,其他源文件引用的却还是原始的文件而不是生成的*.p.dart文件,从而导致难以发现的隐蔽bug。

这里我使用了 lakos 这个非常 WonderFull 的库,它可以分析Flutter/Dart项目中源码的依赖关系:
lakos

将项目的lib/目录传入lakos包buildModel方法后,即可得到项目源码之间关系的有向图模型,其中的edges属性就是导入/导出依赖关系表示为有向图形式的所有的边。所以当source_gen执行到注解标记的源码文件,该文件出现在了edges的任意一条边中时,即代表该源码出现的错误的引用问题,此时通过sdterr向控制台输出错误提醒信息:
warn2

总结

经过了上面的这些改进,最终的结果就得到了platform_code_builder_starter这个项目。

DEMO使用方法

  1. clone本仓库

  2. 下载依赖dart pub get

  3. 运行代码生成:

    1
    dart run build_runner build
  4. 查看lib目录下生成的*.p.dart代码,或直接运行项目查看效果

    tips:可以用 flutter create ./ 命令创建支持各个平台运行的模板代码

向项目中集成的步骤

  1. clone本仓库
  2. 复制platform_code_builder目录至目标项目的根目录
  3. 编辑目标项目的pubspec.yaml,添加如下内容
    1
    2
    3
    4
    5
    6
    7
    8
    dependencies:
    ……
    platform_code_builder:
    path: platform_code_builder

    dev_dependencies:
    ……
    build_runner: ^<latest_version>
  4. 在项目根目录创建platform_code_options.yaml,根据项目需要定义所有平台类型
  5. 定义完成后,在项目根目录依次如下命令:
    1
    2
    dart pub get
    dart run build_runner build
    完成后请检查生成的platform_code_builder/lib/platform_type.dart文件内容无误
  6. 在项目源码中使用注解标记不同平台下的代码,参考注解使用说明
  7. (可选),创建bin/handle_platform.dart,用于为指定平台执行特殊操作,基础代码如下:
    1
    2
    3
    4
    5
    6
    7
    import 'package:platform_code_builder/platform_type.dart';

    main(List<String> args) {
    var platformMaskCode = PlatformType.fromName(args.first);
    /// 在这里判断platformMaskCode执行所需操作
    }

  8. 运行 run build_runner builddart run build_runner watch,并将项目中相关的import源码路径更改为生成的*.p.dart
  9. 运行Flutter/Dart项目,检查结果是否符合预期

本方案可能还存在一些BUG,以及改进的空间,欢迎提issue讨论或者pr,谢谢~