自动生成代码指南


本文介绍一种自动生成脚手架代码的方法,基于Swift代码做示例,方法适用于其它编程语言。

代码分层降低开发速度

项目开发的趋势之一就是进行功能职责清晰的代码分层,譬如卡搭编程iOS端按照MVVM结构进行代码结构划分后,View层和ViewModel可以更好复用,Controller层代码得到精简,针对ViewModel层也能够便捷进行单元测试了。

分层带来以上看得见的好处的同时,也带来一个不可回避的影响,就是开发速度的降低 —— 每增加一个分层,意味着要多写一层的代码,以及与这层代码交互的其它代码。

复用性和开发效率之间也需要一定的平衡。

关于脚手架代码

分层方案、协议、命名和代码规范固定之后,可以发现要多写的代码是存在一定共性的,这些代码除了类名和少数变化外结构非常相似,却又较难进一步抽取(比如实现UITableViewDelegate和UITableViewDataSource的那一堆代码,即便抽取成一些配置方法,每次还是需要大段代码来调用这些配置方法),那为什么不考虑通过工具自动生成这些“脚手架”代码呢?

借助接口管理平台NEI提供的nei-toolkit这个工具,卡搭编程iOS端现在完全通过NEI上录入的网络请求接口和数据模型信息,来生成Swift的网络请求Service层和纯数据Model层代码,受这个工具启发,我编写了个小工具进一步生成Controller、View、ViewModel层里的框架性代码。

自动生成的代码包括

  • 组成模块所需的类、属性、常用方法的声明
  • 常用的协议的默认实现,比如UITableView、UICollectionView、其它自定义组件的Delegate方法
  • 视图、配置相关的storyboard、xib、json等文件

实现功能

代码生成工具通过简单配置和一行命令./generate.js,几秒钟就能生成展示如下一个基于UITableView的界面所需的全部代码:

                                                    

该界面包含功能有:

  • 生成Controller、View、ViewModel层代码,且均实现MVVM相关的协议
  • 进入页面进行loading
  • 下拉刷新
  • 加载更多
  • 网络请求失败页面,点击失败图标重新加载
  • 默认空态页面
  • 实现路由协议

具体使用时,填充如下配置,就能生成下面的文件夹结构和相应代码(共200多行代码):

var configs = {
    "Module": "DownloadManager",
    "author": "Wang Jiawen"
  }

                                                     

原理

自动生成代码一般有两种思路:

  1. 写好模板代码,然后全文替换其中的类名等有变化的元素
  2. 通过字符串拼接方式,输出有一定逻辑的代码文本

第1种方式的优点是基本不用处理代码缩进等问题,效率高,适合处理生成代码较多的情况; 第2种方式则更加灵活,可精细化输出有一定逻辑的代码,但是代码缩进空格的输出较难控制。

如果生成的代码有一定复杂度,则两种方法会同时用到。本文使用Handlebars这一模板引擎来实现方法1的代码替换,使用Node.js处理方法2的字符串拼接、文件操作等逻辑。

Handlebars简介和代码生成方法

Handlebars 是 JavaScript 一个语义模板库,通过对view和data的分离来快速构建Web模板。一般用在前端开发中的页面生成,能够生成网页也就能用于生成其它文本格式的代码。

引入

本工具通过npm进行包管理,执行命令npm install --save handlebars安装包,js文件中通过require('handlebars')进行库引入。

简单用法

expressions标签 是Handlebars中的基本元素,分为两类标签:

简单标签

以两对花括号包裹标签名{{value}}方式表示,Handlebars能自动匹配其中的value为具体数值函数

表达式标签

当需要在模板文件中进行条件判断、循环等逻辑操作时,需要用到以{{#blockName}}开头{{/blockName}}结尾的表达式标签。

其中的blockName可以是:

  • 数组,能够自动遍历数组的元素
  • each、if、else、unless等内置的表达式标签
  • 自定义函数标签

表达式标签在生成复杂代码时非常有用。更多Handlebars简介、基本用法可参考《Handlebars.js 模板引擎》官网

代码生成示例

比如上文提到的configs.js文件中有如下配置:

// configs.js
var configs = {
    "Module": "DownloadManager",
    "author": "Wang Jiawen"
  }
module.exports = configs;

{{Module}}TableViewCell.swift模板文件中有如下内容:

//  {{Module}}TableViewCell.swift
//
//  Created by {{author}} on {{date}}.
//
//  Copyright © 2018年 NetEase. All rights reserved.

import UIKit

class {{Module}}TableViewCell: UITableViewCell {
    @IBOutlet weak var mainImageView: UIImageView!
    @IBOutlet weak var titleLabel: UILabel!
}

extension {{Module}}TableViewCell: TableViewCellProtocol {
    func bindWithViewModel(_ viewModel: TableViewCellViewModelProtocol?) {
        guard let viewModel = viewModel as? {{Module}}CellViewModel else {
            return
        }
        titleLabel.text = viewModel.title
        mainImageView.setWebImageWith(viewModel.imageUrl, placehold: nil)
    }
}

运行命令npm install --save handlebars安装格式化时间的依赖库moment,编写以下生成代码的脚本generate.js

// generate.js
#!/usr/bin/env node
var config = require("./config");
var handlebars = require('handlebars');
var fs = require('fs');
var moment = require("moment")

// 向handlebars中注册{{date}}函数调用标签
handlebars.registerHelper('date', function (items, options) {
  var time = moment().format("YYYY/M/d")
  return new handlebars.SafeString(time)
});

// 读取模板
var source = fs.readFileSync("{{Module}}TableViewCell.swift", "utf8");
// 预编译模板内容
var template = handlebars.compile(source);
// 匹配模板文件和配置文本内容,输出编译后的内容
var result = template(config)
console.log(result)

为generate.js添加执行权限chmod 755 generate.js,控制台直接运行./generate.js后可看到输出的文本为:

//  DownloadManagerTableViewCell.swift
//
//  Created by Wang Jiawen on 2018/7/5.
//
//  Copyright © 2018年 NetEase. All rights reserved.

import UIKit

class DownloadManagerTableViewCell: UITableViewCell {
    @IBOutlet weak var mainImageView: UIImageView!
    @IBOutlet weak var titleLabel: UILabel!
}

extension DownloadManagerTableViewCell: TableViewCellProtocol {
    func bindWithViewModel(_ viewModel: TableViewCellViewModelProtocol?) {
        guard let viewModel = viewModel as? DownloadManagerCellViewModel else {
            return
        }
        titleLabel.text = viewModel.title
        mainImageView.setWebImageWith(viewModel.imageUrl, placehold: nil)
    }
}

上述示例就包含了最基础的标签{{Module}}、{{author}}文本替换,自定义JS函数提供{{date}}标签内容。

代码生成工具开发步骤

1. 创建模板代码

  • 明确工具自动生成代码的对象、类型,比如本例工具要生成的是展示一个界面所需的所有代码;
  • 暂时把这个界面模块命名为Module,利用IDE创建一个空项目,在其中创建Module文件夹,在文件夹内创建UIViewController、View等需要自动生成的文件,并在其中填充需要自动生成的代码,最好保证这几个文件可以编译运行;

2. 代码替换

  • 利用IDE的Replace All功能,将Module替换为标签{{Module}},以此类推替换其它可配置元素;
  • 可能需要手动替换文件夹、文件名中的Module为{{Module}};
  • 将{{Module}}文件夹,拷贝到代码生成工具的文件夹中,此处将工具文件夹命名为ModuleGenerate
  • 执行以下命令安装npm依赖:
npm init -y
npm install handlebars --save
npm install moment --save
npm install shelljs --save

则此时ModuleGenerate结构为:

- ModuleGenerate
    - config.js
    - generate.js
    - package.json
    - node_modules
    - templates
        - {{Module}}
        - {{Module}}.storyboard]
            - Controller
                - {{Module}}ViewController.swift
            - View
                - {{TableCell}}TableViewCell.swift
                - {{TableCell}}TableViewCell.xib
            - ViewModel
                - {{Module}}ViewModel.swift
                - {{TableCell}}CellViewModel.swift

3. 模板文件夹遍历替换

编写generate.js文件:

  • 首先从templates开始遍历文件夹,遍历到普通文件,则当做模板处理,遍历到子文件夹继续遍历,核心代码:
// 遍历处理dir下的文件
function handleFile(dir) {
  fs.readdir(dir, function (err, files) {
    if (err) {
      console.warn(err)
    } else {
      files.forEach(function (fileName) {
        var fileDir = path.join(dir, fileName)
        fs.stat(fileDir, function (err, stats) {
          if (err) {
            console.warn("get stats fail" + err)
          } else {
            if (stats.isFile()) {
              if (!fileName.startsWith(".")) {
                console.log("处理:" + fileDir)
                handleTemplate(fileDir)
              }
            } else if (stats.isDirectory()) {
              handleFile(fileDir)
            }
          }
        })
      })
    }
  })
}
  • 读取模板文件,将模板文件路径中的标签替换为模块名,在results文件夹下生成目标文件,核心代码:
// 生成源码,传入templatefile为模板文件路径
function handleTemplate(templatefile) {
  var source = fs.readFileSync(templatefile, "utf8");
  var template = handlebars.compile(source);
  var result = template(config)

  // 将路径中的标签替换为配置中的变量值作为保存路径
  var saveFilePath = templatefile.replace(new RegExp(templateDir, 'g'), config.Module).replace(/{{Module}}/g, config.Module)
  var filePath = path.join(targetDir, saveFilePath)
  console.log("生成:" + filePath)
  // 文件不存在则创建
  ensureDirExists(filePath)
  fs.writeFile(filePath, result, function (e) {
    if (e) {
      console.log("error: " + s.name + " : " + e);
    }
  });
}

高级用法

以上即是工具编写的流程,此时工具功能还比较简单,只相当于IDE的Replace All和文件重命名功能,借助Handlebars和node.js可实现更多定制化需求。

代码生成配置项

生成的目标代码大而全,大多数情况下并不是每段代码都需要,那可以针对代码块做些配置,以决定是否生成某些部分代码。

例如,config.js中会出现以下配置:

var configs = {
    "needRouter": true   // 是否需要实现路由协议
  }

模板代码中,使用{{#if condition}}{{/if}}标签对,仅当needRouter配置为true时才生成标签对包裹的代码:

{{#if needRouter}}
// MARK: - Routable
extension {{Module}}ViewController: Routable {
    static var routePath: Router.Page {
        return .{{Module}}ViewController
    }

    static func routePageCreate(parameters: [String: Any]?, object: Any?) -> UIViewController? {
        let vc = {{Module}}ViewController.createInstance()
        return vc
    }
}
{{/if}}

多个Cell类生成

上述示例生成的TableView只有一个Cell且命名固定,如果支持多个Cell,则能在代码生成时一次性生成更多Cell相关文件(一个Cell对应3个文件),并在使用到Cell的地方填充相关的测试数据。

config.js中可以如下配置:

var configs = {
    "tableViewCells": [    // 生成UITableViewCell及其ViewModel的名称前缀
      "DownloadManager",
      "DownloadManagerDownloading",
      "EmptyContent"
    ]
  }

模板代码中,使用到cell的地方以数组标签方式输出代码,类似:

{{#tableViewCells}}
        tableView.register(UINib(nibName: {{this}}CellViewModel.identifier, bundle: nil),
                           forCellReuseIdentifier: {{this}}CellViewModel.identifier)
{{/tableViewCells}}

generate.js中需要为Cell模板的遍历读取和生成逻辑进行修改,此时逻辑变为先用sed命令将模板文件中的{{TableCell}}标签替换为tableViewCells中的Cell名,再把替换好后的文本作为模板使用,主要代码:

// 生成Cell相关的代码
config.tableViewCells.forEach(function (cell) {
  console.log(cell)
  cellTeplateFilePaths.forEach(function (filePath) {
    console.log("Path:" + filePath)
    handleCellTemplate(filePath, cell)
  })
})

// 传入模板名和生成的Cell名,生成与Cell相关的几个文件
function handleCellTemplate(templatefile, cellName) {

  var source = shell.exec('sed -e "s/{{TableCell}}/' + cellName + '/g" ' + templatefile).stdout

  var template = handlebars.compile(source);

  var result = template(config)
  var saveFilePath = templatefile.replace(new RegExp(templateDir, 'g'), config.Module)
    .replace(/{{Module}}/g, config.Module)
    .replace(/{{TableCell}}/g, cellName)

  var filePath = path.join(targetDir, saveFilePath)
  console.log("生成:" + filePath)

  ensureDirExists(filePath)
  fs.writeFile(filePath, result, function (e) {
    if (e) {
      console.log("error: " + s.name + " : " + e);
    }
  });
}

除了本文示例,相信该方法可以用到更多能够解放生产力的地方。

参考

本文来自网易实践者社区,经作者王佳文授权发布。