Flutter-一行註解直接編譯生成資源配置檔案

XuYanjun 發表於 2019-10-15

背景

在使用Flutter開發的時候,有時候會存在很多資源圖片問題,按照規定,使用的圖片資源需要在 pubspec.yaml 檔案中配置路徑才可以正常使用,如果存在很多50個以上或者更多圖片資源,難道需要一個一個配置?顯然是不可能的!

其實,Flutter是支援直接在 pubspec.yaml 中配置圖片資原始檔夾路徑即可,沒必要每個圖片資源路徑都詳細配置的,但是不管怎麼樣,在實際呼叫的時候,還是得老老實實寫完整的圖片路徑,顯然是很不方便的,那該如何解決?

不同的開發有不同的思路,這裡我採取的是使用Dart註解的方式,只需要執行一行註解即可完成資原始檔配置。

先看效果: 比如我們有以下資原始檔:

圖片資源

我們只需要配置一行註釋:

@ImagePathSet('assets/', 'ImagePathTest')
void main() => runApp(MyApp());
複製程式碼

然後執行一行命令:

flutter packages pub run build_runner build
複製程式碼

執行完畢即可生成一個 .dart 檔案內容如下:

class ImagePathTest {
  ImagePathTest._();

  static const BANNER = 'assets/image/banner.png';
  static const PLAY_STOP = 'assets/image/play_stop.png';
  static const SAVE_BUTTON = 'assets/image/save_button.png';
  static const MINE_HEADER_IMAGE = 'assets/image/test/mine_header_image.png';
  static const VERIFY = 'assets/image/verify.png';
  static const VERIFY_ERROR = 'assets/image/verify_error.png';
}
複製程式碼

同時在 pubspec.yaml 資料夾下自動配置好我們的資原始檔:

assets:
- assets/image/banner.png
- assets/image/play_stop.png
- assets/image/save_button.png
- assets/image/test/mine_header_image.png
- assets/image/verify.png
- assets/image/verify_error.png
複製程式碼

使用時即可直接以程式碼形式直接引用圖片資源:

Image.asset(ImagePathTest.xxx)
複製程式碼

即可以防止手誤出差,又可以提高效率~ 下面重點介紹我們的開發思路。


實戰利用一行註解生成資源配置檔案

關於Dart註解使用,可以參考這篇文章,寫的比較細:Flutter 註解處理及程式碼生成,也可以參考官方說明:source_gen

這裡簡單說明,在 source_gen官方文件說明裡有這麼一句話:

source_gen is based on the build package and exposes options for using your Generator in a Builder.
省略部分文件內容 ......
In order to get the Builder used with build_runner it must be configured in a build.yaml file.

翻譯成中文即:

source_gen 是基於 build 包的,同時提供暴露了一些選項以方便在一個 Builder 中使用你自己的生成器 Generator
...
為了能夠使 Builderbuild_runner 一塊使用,必須要配置一個 build.yaml 檔案。

因此想要使用Dart註解,我們需要做這幾件事:

  • 依賴註解庫 source_gen
  • 依賴構建執行庫 build_runner
  • 建立註解程式碼生成器 Generator
  • 建立 Build
  • 建立 build.yaml 檔案
  • 在需要使用的地方引入相關注解
  • 執行編譯命令進行構建

下面一個一個說。

依賴註解庫 source_gen

這個沒什麼好說的,只要你是要Dart註解就必須依賴該庫:

dependencies:
  source_gen: ^0.9.4+5
複製程式碼

具體版本可到這裡檢視source_gen

依賴構建執行庫 build_runner

同上,直接依賴就是,一般依賴在 dev_dependencies 節點下:

dev_dependencies:
  build_runner: ^1.7.1
複製程式碼

具體版本可到這檢視build_runner

該庫中內建了編譯執行的命令:pub run build_runner <command>,主要為下面四種編譯型別:

  • build
  • watch
  • serve
  • test

其中在flutter中一般只需要第一種構建方式,同上以上四個命令都可以附加一些命令,例如:--delete-conflicting-outputs。詳細說明可參考這裡:build_runner相關說明

建立註解程式碼生成器 Generator

從字面意思理解為 生成器,官方說明為:

A tool to generate Dart code based on a Dart library source.

一種基於Dart庫原始碼生成Dart程式碼的工具。類圖如下:

Generator

兩個類都是抽象類,通常建立一個類繼承自 GeneratorForAnnoration 並實現抽象方法,在該抽象方法中完整我們需要的邏輯功能開發;這裡我們需要搞一個圖片資原始檔配置功能,該功能具有以下兩個要點:

  • 需要使用者指定資原始檔夾路徑
  • 需要使用者指定生成的資源配置類名稱

因此我們先建立一個例項類包含這兩個資訊:

class ImagePathSet{
  /// 資原始檔夾路徑
  final String pathName;
  
  /// 需要生成的資源配置類名
  final String newClassName;

   const ImagePathSet(this.pathName, this.newClassName);
}
複製程式碼

最終我們在使用的第一引用就需要這樣引用:

@ImagePathSet('assets/', 'ImagePathTest')
複製程式碼

此處需要注意的是,這個類的構造方法必須是 const 的。建立好了最終需要使用的註解類之後,我們建立生成器:

class ImagePathGenerator extends GeneratorForAnnotation<ImagePathSet> {
  @override
  generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep) {
    
    return null;
  }
}
複製程式碼

generateForAnnotatedElement() 方法中,我們即可完成我們的邏輯部分開發了,這裡涉及到三個引數:

  • element:這個是被註解的類/方法等的詳細資訊,比如被修飾的部分程式碼這樣:

    /// path_test.dart
    
    @ImagePathSet('assets/', 'ImagePathTest')
    class PathTest{}
    複製程式碼

    則一些相關的element資訊如下:

    element.name                /// PathTest
    element.displayName         /// PathTest
    element.toString()          /// class PathTest
    element.enclosingElement    /// /example/lib/path_test.dart
    element.kind                /// CLASS
    element.metadata            /// [@ImagePathSet ImagePathSet(String pathName, String newClassName)]
    複製程式碼
  • annotation:註解的詳細資訊
    其中最常用的兩個方法分別是:

    • read(String field)
    • peek(String field)

    兩個都是讀取給定的註解引數資訊,前者如果沒讀取到或丟擲 FormatException 異常,後者則會返回 null
    需要注意的是,這兩個方法返回的結果是 ConstantReader 型別,如果需要獲取到具體註解元素的值,需要呼叫對應的 xxxValue方法,xxx表示具體型別,比如上面的註解,我們需要獲取 pathName資訊,可以寫成這樣:

    String pathName= annotation.peek('pathName').stringValue
    複製程式碼

    當然,我們假如我們不知道註解引數的型別,可以根據 isXxx 來判斷是否是對應的型別,比如:

    annotation.peek('pathName').isString    ///true
    annotation.peek('pathName').isInt       ///false
    複製程式碼
  • buildStep:構建的輸入輸出資訊
    本想著修改這個類來修改生成檔名稱資訊,無奈Dart不支援反射,未找到相關的修改方法,這裡最主要的一個資訊為:

    • inputId:包含了構建時候輸入的相關資訊

完整的根據根據生成資原始檔的生成器程式碼如下:

import 'dart:io';

import 'package:analyzer/dart/element/element.dart';

import 'package:image_path_helper/image_path_set.dart';
import 'package:source_gen/source_gen.dart';
import 'package:build/build.dart';


class ImagePathGenerator extends GeneratorForAnnotation<ImagePathSet> {
  String _codeContent = '';
  String _pubspecContent = '';

  @override
  generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep) {
    final explanation = '// **************************************************************************\n'
        '// 如果存在新檔案需要更新,建議先執行清除命令:\n'
        '// flutter packages pub run build_runner clean \n'
        '// \n'
        '// 然後執行下列命令重新生成相應檔案:\n'
        '// flutter packages pub run build_runner build --delete-conflicting-outputs \n'
        '// **************************************************************************';

    var pubspecFile = File('pubspec.yaml');

    for (String imageName in pubspecFile.readAsLinesSync()) {
      if (imageName.trim() == 'assets:') continue;
      if (imageName.trim().toUpperCase().endsWith('.PNG')) continue;
      if (imageName.trim().toUpperCase().endsWith('.JPEG')) continue;
      if (imageName.trim().toUpperCase().endsWith('.SVG')) continue;
      if (imageName.trim().toUpperCase().endsWith('.JPG')) continue;
      _pubspecContent = "$_pubspecContent\n$imageName";
    }
    _pubspecContent = '${_pubspecContent.trim()}\n\n  assets:';

    /// 圖片檔案路徑
    var imagePath = annotation.peek('pathName').stringValue;
    if (!imagePath.endsWith('/')) {
      imagePath = '$imagePath/';
    }

    /// 生成新的Dart檔名稱
    var newClassName = annotation.peek('newClassName').stringValue;

    /// 遍歷處理圖片資源路徑
    handleFile(imagePath);

    /// 新增圖片路徑到pubspec.yaml檔案中
    pubspecFile.writeAsString(_pubspecContent);

    /// 返回生成的程式碼檔案
    return '$explanation\n\n'
        'class $newClassName{\n'
        '    $newClassName._();\n'
        '    $_codeContent\n'
        '}';
  }

  void handleFile(String path) {
    var directory = Directory(path);
    if (directory == null) {
      throw '$path is not a directory.';
    }

    for (var file in directory.listSync()) {
      var type = file.statSync().type;
      if (type == FileSystemEntityType.directory) {
        handleFile('${file.path}/');
      } else if (type == FileSystemEntityType.file) {
        var filePath = file.path;
        var keyName = filePath.trim().toUpperCase();

        if (!keyName.endsWith('.PNG') &&
            !keyName.endsWith('.JPEG') &&
            !keyName.endsWith('.SVG') &&
            !keyName.endsWith('.JPG')) continue;
        var key = keyName
            .replaceAll(RegExp(path.toUpperCase()), '')
            .replaceAll(RegExp('.PNG'), '')
            .replaceAll(RegExp('.JPEG'), '')
            .replaceAll(RegExp('.SVG'), '')
            .replaceAll(RegExp('.JPG'), '');

        _codeContent = '$_codeContent\n\t\t\t\tstatic const $key = \'$filePath\';';

        /// 此處用 \t 符號代替空格在讀取的時候會報錯,不知道什麼情況。。。
        _pubspecContent = '$_pubspecContent\n    - $filePath';
      }
    }
  }
}

複製程式碼

建立Build

Build 的作用主要是讓生成器執行起來,我們這裡建立的 Build 如下:

Builder imagePathBuilder(BuilderOptions options) =>
    LibraryBuilder(ImagePathGenerator());
複製程式碼

主要引用的包為:

import 'package:build/build.dart';
複製程式碼

建立 build.yaml 檔案

在這裡我們的 build.yaml 檔案配置如下:

builders:
  image_builder:
    import: 'package:image_path_helper/builder.dart'
    builder_factories: ['imagePathBuilder']
    build_extensions: { '.dart': ['.g.dart'] }
    auto_apply: root_package
    build_to: source
複製程式碼

build.yaml 配置的資訊,最終都會被 build_config.dart 中的 BuildConfig 類讀取到。
關於引數說明,這裡推薦官方說明build_config

一個完整的 build.yaml 結構圖如下:

build.yaml檔案結構圖
一個 build.yaml 檔案最終被一個 BuildConfig 物件所描述,也就是說 build.yaml 檔案最終被 BuildConfig 所解析。而 BuildConfig 包含了四個關鍵的資訊:

key value default
targets Map<String, BuildTarget> 單個的target應該所對應的package名一致
builders Map<String, BuilderDefinition> /
post_process_builders Map<String, PostProcessBuilderDefinition> /
global_options Map<String, GlobalBuilderOptions> /

四個關鍵資訊正是對應了 build.yaml 檔案中的四個根節點,其中又以 builders 節點最為常用。

builders說明

builders配置的是你的包中的所有 Builder 的配置資訊,每個資訊格式是 Map<String, BuilderDefinition> 的,比如我們存在一個這樣的 Builder

/// builder.dart
Builder imagePathBuilder(BuilderOptions options) =>
    LibraryBuilder(ImagePathGenerator());
複製程式碼

我們就可以在 build.yaml 檔案中配置成這樣:

builders:
  image_builder:
    import: 'package:image_path_helper/builder.dart'
    builder_factories: ['imagePathBuilder']
    build_extensions: { '.dart': ['.g.dart'] }
    auto_apply: root_package
    build_to: source
複製程式碼

image_builder 對應的就是 Map<String, BuilderDefinition> 中的 String 部分,: 後面的即 BuilderDefinition 資訊,對應上面的結構圖。下面我們細說 BuilderDefinition 中的每個引數的資訊:

引數 引數型別 說明
builder_factories List<String> 必填引數,返回的 Builder 的方法名稱的列表,例如上面的 Builder 方法名為 imagePathBuilder,則寫成 ['imagePathBuilder']
import String 必填引數,匯入Builder所在的包路徑,格式為 package:uri 的字串
build_extensions Map<String, List<String>> 必填引數,從輸入副檔名到輸出副檔名的對映。舉個例子:比如註解使用的位置的檔案對應的格式為 .dart,指定輸出的檔案格式可由 .dart 轉換成 .g.dart.zip 等等其他格式
auto_apply String 可選引數,預設值為 none,對應原始碼中的 AutoApply 列舉類,有四種可選配置:
  • none:除非手動配置了此生成器,否則請不要應用它
  • dependents:將此Builder應用於包,直接依賴於暴露構建器的包
  • all_packages:將此構建器應用於傳遞依賴關係圖中的所有包
  • root_package:僅將此生成器應用於頂級軟體包

  • 是不是感覺一臉懵逼?沒關係,後面單獨解釋~~~
    required_inputs List 可選引數,用於調整構建順序的,指定一個或一系列副檔名,表示在任何可能產生該型別輸出的Builder之後執行
    runs_before List<BuilderKey> 可選引數,用於調整構建循序的,更上面的剛好相反,表示在指定的Builder之前執行
  • BuilderKey:表示一個 target 的身份標誌,主要由對應 Builder 的包名和方法名構成,例如這樣 image_path_helper|imagePathBuilder
  • applies_builders List<BuilderKey> 可選引數,Builder 鍵列表,也就是身份標誌,跟 builder_factories 引數配置應該是一一對應的
    is_optional bool 可選引數,預設值 false,指定是否可以延遲執行 Builder ,通常不需要配置
    build_to String 可選引數,預設值為 cache,主要為 BuildTo 列舉類的兩個引數:
  • cache:輸出將進入隱藏的構建快取,並且不會發布
  • source:輸出進入其主要輸入旁邊的源樹
    直白點就是如果你需要編譯後生成相應的可在自己編寫的原始碼中看到見的檔案,就將這個引數設定成 source,如果指定的生成器返回的是 null 不需要生成檔案,則可以設定為 cache
  • defaults TargetBuilderConfigDefaults 可選引數:使用者未在其builders【此處指的是 targets 節點下的builders,別搞混淆了!】部分中指定相應鍵時應用的預設值

    關於 auto_apply 引數的詳細說明:

    auto_apply

    如上圖所示,一個應用Package依賴了三個子包,此時我們有一個 註解package 包含了一些註解功能:

    • 當我們將auto_apply設定成 dependents時:
      • 如果 註解package 是直接依賴在 sub_package02 上的,那麼只能在 sub_package02 上正常使用註解,雖然 Package 包依賴了 sub_package02,但是依然無法正常使用該註解
    • 當我們將auto_apply設定成 all_packages時:
      • 如果 註解package 是直接依賴在 sub_package02 上的,那麼在 sub_package02Package上都能正常使用註解
    • 當我們將auto_apply設定成 root_package 時:
      • 如果 註解package 是直接依賴在 sub_package02 上的,那麼只能在 Package 上正常使用註解,雖然是 sub_package02 上做的依賴,但是就是不給用
    • 因此,假如 註解package 是直接依賴在 Package 上的時候,不管 auto_apply 設定的是 dependentsall_packages 或者是 root_package 時,其實都是能正常使用的!

    至於 build.yaml 其他的三個節點引數,說實話,因為目前用到的不多,只瞭解一部分,有很多細節尚未理清,只能在這裡略過了。

    在需要使用的地方引入相關注解 & 執行編譯命令進行構建

    上面的工作完成之後,我們就需要引用註解了,比如我們在 main() 方法上引用:

    @ImagePathSet('assets/', 'ImagePathTest')
    void main() => runApp(MyApp());
    複製程式碼

    引用完註解之後,然後我們在Terminal命令列中執行下面這個命令完成編譯:

    flutter packages pub run build_runner build
    複製程式碼

    編譯完成之後會生成對應的檔案,比如我們上面配置的是在 main.dart 檔案中的 main 方法上配置的,最終生成的檔案為 main.g.dart ,關於檔案是如何生成的,你可以參考 run_builder.dart 下的 runBuilder 方法和 expected_outputs.dart 下的 expectedOutputs 方法。

    注意:如果需要重新構建建議先進行清除操作:

    flutter packages pub run build_runner clean
    複製程式碼

    除此之外,建議在構建的時候執行下面的這個命令進行構建:

    flutter packages pub run build_runner build --delete-conflicting-outputs
    複製程式碼

    至此,一個完整的利用註解一行程式碼+一行命令完成圖片檔案配置的功能就做完啦~~~


    總結時刻

    在Flutter中想要註解,只需要遵循一定的步驟加上自己的邏輯即可輕鬆完成相關功能開發,主要的流程步驟總結如下:

    • 依賴 source_genbuild_runner
    • 註解類建立以及建立生成器 Generator
    • 建立 Builder
    • 建立並配置 build.yaml 檔案
    • 引用建立好的註解並執行相關命令完成相關操作

    Tips:本文原始碼位置:image_path_helper