| 🔥前端脚手架辣么多,那我也要写一个玩玩 Posted: 12 Apr 2022 03:38 AM PDT 前言 2022年已经过了四分之一还多了,之前说好的每个月一片文章好像也没有让自己兑现。最近公司在做一些前端工程化相关的东西,虽然准备做组件库的事情被领导给毙了,不过在这之前写了一个脚手架的工具,毕竟现在这个环境下,脚手架工具泛滥,所以当然也要写一写玩玩。 最终效果
支持功能- 自主选择web端或移动端;
- 自主选择项目框架react或vue;
- 自主选择是否设置远程git地址;
- 支持在项目模板中自定义变量替换;
- 自主选择是否自动安装依赖,可选择npm、cnpm、yarn;
- 支持使用update命令在线更新;
- 自主选择已存在文件目录是否覆盖;
开发初始化项目 那么接下来就开始开发了,首先我们来新建一个项目文件夹就叫new-cli吧,在项目文件夹中新建package.json文件,设置常用的字段,设置完成后如下: { "name": "new-cli", "version": "1.0.0", "description": "a react project cli, help you create a react project quickly", "bin": { "new-cli": "bin/www.js" }, "dependencies": { "boxen": "^5.1.2", "chalk": "^4.1.2", "commander": "^9.1.0", "consolidate": "^0.16.0", "cross-spawn": "^7.0.3", "download-git-repo": "^3.0.2", "ejs": "^3.1.6", "fs-extra": "^10.0.1", "inquirer": "^8.2.1", "metalsmith": "^2.4.2", "ora": "^5.4.1", "figlet": "^1.5.2", "semver": "^7.3.5", "shelljs": "^0.8.5" }, "repository": { "type": "git", "url": "https://github.com/BoWang816/new-cli.git" }, "keywords": [ "cli", "react" ], "author": "恪晨", "publishConfig": { "registry": "私有仓库地址" }, "engines": { "node":"^12.20.0 || >=14" } }
通过以上设置以后,我们的脚手架名字就叫new-cli,也就是说到时候安装的时候就是通过npm install -g new-cli进行安装。bin下面设置的名称就是为了设置脚手架执行的命令,并且是从bin/www.js文件作为了入口文件;dependencies中为我们需要的项目依赖,值得注意的是像boxen、chalk、figlet这一类的依赖包在最新版本中已经不支持requier方式引入了所以这里我们需要安装低版本的包;publishConfig中可以设置到时候需要发布的npm地址,如果你搭建了npm私服则通过设置registry就可以发布到你的私服了。 设置项目入口 建好package.json以后我们就开始建入口文件,也就是bin下面的www.js,事实上你的入口文件放置在根目录也是可以的,可以根据自己的喜好,当然如果放置在了根目录,则bin下面就要改为new-cli: './www.js'。www.js中主要是引入commander、inquirer等工具包,进行脚手架工具的初始化。因为www.js将要作为一个node脚本来运行,因此需要在最上方声明环境:#! /usr/bin/env node,我写的这个脚手架中涉及到了init、update、help这三个命令,并且help是commander本身就支持的,这里只是做了一点定制化。 初始化init命令、update命令、help命令 首先需要引入commander,使用它的program,const {program} = require("commander");,脚手架工具的主体就是它了,我们初始化相关的命令: #! /usr/bin/env node // 引入commander const {program} = require("commander"); // 初始化init命令, project-name就是你的项目名称与项目文件夹名称 program.command("init <project-name>") // init命令描述 .description("create a new project name is <project-name>") // init命令参数项,因为后续会设置支持覆盖文件夹,所以这里提供一个-f参数 .option("-f, --force", "overwrite target directory if it exists") // init命名执行后做的事情 .action(() => { console.log('doSomething'); }); program.command("update") .description("update the cli to latest version") // update命令执行后做的事情,自动检测更新 .action(async () => { // await checkUpdate(); console.log('update'); }); program.on("--help", () => { // 监听--help命令,输出一个提示 console.log(figlet.textSync("new-cli", { font: "Standard", horizontalLayout: 'full', verticalLayout: 'fitted', width: 120, whitespaceBreak: true })); }); // 这个一定不能忘,且必须在最后!!! program.parse(process.argv);
通过设置以上内容,其实我们就可以使用基本的命令了。本地调试的方式有两种,一种是通过npm link命令将我们写的脚手架工具直接链接到本地的全局npm中,一种则是直接通过node bin/www.js直接执行这个js文件,这里我们使用后者就可以了。
 const chalk = require("chalk"); const path = require("path"); const fs = require('fs-extra'); const figlet = require('figlet'); const create = require('../utils/create'); program .command("init <project-name>") .description("create a new project name is <project-name>") .option("-f, --force", "overwrite target directory if it exists") .action(async (projectName, options) => { const cwd = process.cwd(); // 拼接到目标文件夹 const targetDirectory = path.join(cwd, projectName); // 如果目标文件夹已存在 if (fs.existsSync(targetDirectory)) { if (!options.force) { // 如果没有设置-f则提示,并退出 console.error(chalk.red(`Project already exist! Please change your project name or use ${chalk.greenBright(`new-cli create ${projectName} -f`)} to create`)) return; } // 如果设置了-f则二次询问是否覆盖原文件夹 const {isOverWrite} = await inquirer.prompt([{ name: "isOverWrite", type: "confirm", message: "Target directory already exists, Would you like to overwrite it?", choices: [ {name: "Yes", value: true}, {name: "No", value: false} ] }]); // 如需覆盖则开始执行删除原文件夹的操作 if (isOverWrite) { const spinner = ora(chalk.blackBright('The project is Deleting, wait a moment...')); spinner.start(); await fs.removeSync(targetDirectory); spinner.succeed(); console.info(chalk.green("✨ Deleted Successfully, start init project...")); console.log(); // 删除成功后,开始初始化项目 // await create(projectName); console.log('init project overwrite'); return; } console.error(chalk.green("You cancel to create project")); return; } // 如果当前路径中不存在同名文件夹,则直接初始化项目 // await create(projectName); console.log('init project'); });
我们再来查看现在的效果:
 创建create方法 在上一步操作中,我们覆盖同名文件后,使用了await create(projectName)方法开始初始化项目,接下来我们开始开发create方法。在根目录新建一个文件夹叫utils,当然你可以随意叫lib或者✨点赞都行,在utils下面新建一个文件叫create.js,在这个文件中,我们将设置下载初始化项目中一些问题询问的执行。内容主要有以下: const inquirer = require("inquirer"); const chalk = require("chalk"); const path = require("path"); const fs = require("fs"); const boxen = require("boxen"); const renderTemplate = require("./renderTemplate"); const downloadTemplate = require('./download'); const install = require('./install'); const setRegistry = require('./setRegistry'); const {baseUrl, promptList} = require('./constants'); const go = (downloadPath, projectRoot) => { return downloadTemplate(downloadPath, projectRoot).then(target => { //下载模版 return { downloadTemp: target } }) } module.exports = async function create(projectName) { // 校验项目名称合法性,项目名称仅支持字符串、数字,因为后续这个名称会用到项目中的package.json以及其他很多地方,所以不能存在特殊字符 const pattern = /^[a-zA-Z0-9]*$/; if (!pattern.test(projectName.trim())) { console.log(`\n${chalk.redBright('You need to provide a projectName, and projectName type must be string or number!\n')}`); return; } // 询问 inquirer.prompt(promptList).then(async answers => { // 目标文件夹 const destDir = path.join(process.cwd(), projectName); // 下载地址 const downloadPath = `direct:${baseUrl}/${answers.type}-${answers.frame}-template.git#master` // 创建文件夹 fs.mkdir(destDir, {recursive: true}, (err) => { if (err) throw err; }); console.log(`\nYou select project template url is ${downloadPath} \n`); // 开始下载 const data = await go(downloadPath, destDir); // 开始渲染 await renderTemplate(data.downloadTemp, projectName); // 是否需要自动安装依赖,默认否 const {isInstall, installTool} = await inquirer.prompt([ { name: "isInstall", type: "confirm", default: "No", message: "Would you like to help you install dependencies?", choices: [ {name: "Yes", value: true}, {name: "No", value: false} ] }, // 选择了安装依赖,则使用哪一个包管理工具 { name: "installTool", type: "list", default: "npm", message: 'Which package manager you want to use for the project?', choices: ["npm", "cnpm", "yarn"], when: function (answers) { return answers.isInstall; } } ]); // 开始安装依赖 if (isInstall) { await install({projectName, installTool}); } // 是否设置了仓库地址 if (answers.setRegistry) { setRegistry(projectName, answers.gitRemote); } // 项目下载成功 downloadSuccessfully(projectName); }); }
在create.js文件中,我们首先判断了初始化的项目名称是否包含特殊字符,如果包含则给出错误提示,并终止项目初始化。如果项目名称合法,则开始询问用户需要的项目模板:
 我们将这些询问的list抽离为常量,同时也将模板的地址抽离为常量,因此需要在utils文件夹下建立一个constants.js的文件,里面的内容如下: /** * constants.js * @author kechen * @since 2022/3/25 */ const { version } = require('../package.json'); const baseUrl = 'https://github.com/BoWangBlog'; const promptList = [ { name: 'type', message: 'Which build tool to use for the project?', type: 'list', default: 'webpack', choices: ['webpack', 'vite'], }, { name: 'frame', message: 'Which framework to use for the project?', type: 'list', default: 'react', choices: ['react', 'vue'], }, { name: 'setRegistry', message: "Would you like to help you set registry remote?", type: 'confirm', default: false, choices: [ {name: "Yes", value: true}, {name: "No", value: false} ] }, { name: 'gitRemote', message: 'Input git registry for the project: ', type: 'input', when: (answers) => { return answers.setRegistry; }, validate: function (input) { const done = this.async(); setTimeout(function () { // 校验是否为空,是否是字符串 if (!input.trim()) { done('You should provide a git remote url'); return; } const pattern = /^(http(s)?:\/\/([^\/]+?\/){2}|git@[^:]+:[^\/]+?\/).*?.git$/; if (!pattern.test(input.trim())) { done( 'The git remote url is validate', ); return; } done(null, true); }, 500); }, } ]; module.exports = { version, baseUrl, promptList }
其中version为我们的脚手架版本号,baseUrl为项目模板下载的基础地址,promptList为询问用户的问题列表,promptList的具体写法是根据inquirer.prompt()方法来写的,具体的怎么写后面我都会将官方文档地址附上,大家可以自己发挥。 - 通过
inquirer.prompt()获取到用户反馈的结果以后,我们会拿到相关的字段值,然后去拼接出下载的项目模板地址,接下来就是开始下载项目模板了。这里我们写了go函数和renderTemplate俩个函数,一个用于下载项目模板一个用于渲染项目模板(因为涉及到变量的替换)。go函数中其实是使用了从外部引入的downloadTemplate方法,因此我们需要去关注downloadTemplate与renderTemplate方法,也就是接下来要讲的重点了。
创建download方法 在utils文件夹下,新建一个名称为download.js的文件,文件内容如下: /** * 下载 * download.js * @author kechen * @since 2022/3/25 */ const download = require('download-git-repo') const path = require("path") const ora = require('ora') const chalk = require("chalk"); const fs = require("fs-extra"); module.exports = function (downloadPath, target) { target = path.join(target); return new Promise(function (resolve, reject) { const spinner = ora(chalk.greenBright('Downloading template, wait a moment...\r\n')); spinner.start(); download(downloadPath, target, {clone: true}, async function (err) { if (err) { spinner.fail(); reject(err); console.error(chalk.red(`${err}download template failed, please check your network connection and try again`)); await fs.removeSync(target); process.exit(1); } else { spinner.succeed(chalk.greenBright('✨ Download template successfully, start to config it: \n')); resolve(target); } }) }) }
该文件中,我们使用了download-git-repo这个第三方的工具库,用于下载项目模板,因为download-git-repo的返回结果是下载成功或者失败,我们在使用异步的方式的时候如果直接使用会存在问题,因此这里封装为promise,当err的时候给用户抛出异常提示,成功则将目标文件夹路径返回用于后续使用。在create.js中我们使用了go函数,在go函数执行成功后会返回一个data,里面拿到了项目要下载到具体的文件夹的路径,其实主要是为了获取在download中的promise的resolve结果,拿到目标文件夹的路径后,其实项目模板已经下载到了该文件夹中,就可以开始renderTemplate了。 - 创建renderTemplate方法
在utils文件夹下,新建一个文件叫renderTemplate.js,该函数的主要目的是为了将初始化的项目中设置的变量进行替换,主要使用了metalSmith和consolidate这两个第三方的包,通过遍历初始化项目中的文件,将其转换为ejs模板,并替换相关的变量。这个方法是参考了vww-cli的方式,通过读取项目模板中的ask.ts文件,获取项目模板中自定义的询问列表,然后再进行文件模板引擎渲染替换相关设置好的变量,主要内容如下: /** * 渲染模板 * renderTemplate.js * @author kechen * @since 2022/3/24 */ const MetalSmith = require('metalsmith'); const {render} = require('consolidate').ejs; const {promisify} = require('util'); const path = require("path"); const inquirer = require('inquirer'); const renderPro = promisify(render); const fs = require('fs-extra'); module.exports = async function renderTemplate(result, projectName) { if (!result) { return Promise.reject(new Error(`无效的目录:${result}`)) } await new Promise((resolve, reject) => { MetalSmith(__dirname) .clean(false) .source(result) .destination(path.resolve(projectName)) .use(async (files, metal, done) => { const a = require(path.join(result, 'ask.ts')); // 读取ask.ts文件中设置好的询问列表 let r = await inquirer.prompt(a); Object.keys(r).forEach(key => { // 将输入内容前后空格清除,不然安装依赖时package.json读取会报错 r[key] = r[key]?.trim() || ''; }) const m = metal.metadata(); const tmp = { ...r, // 将使用到的name全部转换为小写字母 name: projectName.trim().toLocaleLowerCase() } Object.assign(m, tmp); // 完成后删除模板中的文件 if (files['ask.ts']) { delete files['ask.ts']; await fs.removeSync(result); } done() }) .use((files, metal, done) => { const meta = metal.metadata(); // 需要替换的文件的后缀名集合 const fileTypeList = ['.ts', '.json', '.conf', '.xml', 'Dockerfile', '.json']; Object.keys(files).forEach(async (file) => { let c = files[file].contents.toString(); // 找到项目模板中设置好的变量进行替换 for (const type of fileTypeList) { if (file.includes(type) && c.includes('<%')) { c = await renderPro(c, meta); files[file].contents = Buffer.from(c); } } }); done() }) .build((err) => { err ? reject(err) : resolve({resolve, projectName}); }) }); };
通过renderTemplate方法,我们基本就完成我们脚手架的主要功能了。我们就可以实现使用init命令创建项目了。这里我遇到一个问题,就是在删除ask.ts文件的时候,如果后面不加await fs.removeSync(result);这个文件就无法删除,但是加上按理说又不合理,具体原因没有找到,有知道的朋友可以留言解释一下,十分感谢。至此,我们初始化项目的功能已经完成,接下来就是一些扩展了。 创建setRegistry方法 在utils文件夹下,新建一个文件叫setRegistry.js,主要是为了帮助用户初始化项目的git地址,在用户创建是选择是否需要自动设置项目仓库地址,如果设置了项目地址,则这里会自动初始化git,并设置项目地址,具体内容如下: /** * 设置仓库地址 * setRegistry.js * @author kechen * @since 2022/3/28 */ const shell = require("shelljs"); const chalk = require("chalk"); module.exports = function setRegistry(projectName, gitRemote) { shell.cd(projectName); if (shell.exec('git init').code === 0) { if (shell.exec(`git remote add origin ${gitRemote}`).code === 0) { console.log(chalk.green(`✨ \n Set registry Successfully, now your local gitRemote is ${gitRemote} \n`)); return; } console.log(chalk.red('Failed to set.')); shell.exit(1); } };
创建install方法 在utils文件夹下,新建一个文件叫install.js,主要是为了帮助用户自动安装依赖,主要内容如下: /** * 安装依赖 * install.js * @author kechen * @since 2022/3/22 */ const spawn = require("cross-spawn"); module.exports = function install(options) { const cwd = options.projectName || process.cwd(); return new Promise((resolve, reject) => { const command = options.installTool; const args = ["install", "--save", "--save-exact", "--loglevel", "error"]; const child = spawn(command, args, {cwd, stdio: ["pipe", process.stdout, process.stderr]}); child.once("close", code => { if (code !== 0) { reject({ command: `${command} ${args.join(" ")}` }); return; } resolve(); }); child.once("error", reject); }); };
创建checkUpdate方法 在utils文件夹下,新建一个文件叫checkUpdate.js,主要是为了帮助用户自动检测并进行脚手架更新,主要内容如下: /** * 检查更新 * checkUpdate.js * @author kechen * @since 2022/3/23 */ const pkg = require('../package.json'); const shell = require('shelljs'); const semver = require('semver'); const chalk = require('chalk'); const inquirer = require("inquirer"); const ora = require("ora"); const updateNewVersion = (remoteVersionStr) => { const spinner = ora(chalk.blackBright('The cli is updating, wait a moment...')); spinner.start(); const shellScript = shell.exec("npm -g install new-cli"); if (!shellScript.code) { spinner.succeed(chalk.green(`Update Successfully, now your local version is latestVersion: ${remoteVersionStr}`)); return; } spinner.stop(); console.log(chalk.red('\n\r Failed to install the cli latest version, Please check your network or vpn')); }; module.exports = async function checkUpdate() { const localVersion = pkg.version; const pkgName = pkg.name; const remoteVersionStr = shell.exec( `npm info ${pkgName}@latest version`, { silent: true, } ).stdout; if (!remoteVersionStr) { console.log(chalk.red('Failed to get the cli version, Please check your network')); process.exit(1); } const remoteVersion = semver.clean(remoteVersionStr, null); if (remoteVersion !== localVersion) { // 检测本地安装版本是否是最新版本,如果不是则询问是否自动更新 console.log(`Latest version is ${chalk.greenBright(remoteVersion)}, Local version is ${chalk.blackBright(localVersion)} \n\r`) const {isUpdate} = await inquirer.prompt([ { name: "isUpdate", type: "confirm", message: "Would you like to update it?", choices: [ {name: "Yes", value: true}, {name: "No", value: false} ] } ]); if (isUpdate) { updateNewVersion(remoteVersionStr); } else { console.log(); console.log(`Ok, you can run ${chalk.greenBright('wb-cli update')} command to update latest version in the feature`); } return; } console.info(chalk.green("Great! Your local version is latest!")); };
这里需要注意的是,因为脚手架是全局安装的,涉及到权限的问题,因此在mac下需要使用sudo new-cli update进行更新,而在windows中需要以管理员身份打开命令行工具执行new-cli update进行更新。到这里,我们的脚手架基本就完成啦。
其他花里胡哨的东东 主要功能基本就是上面这些啦,另外我们需要加一个项目创建成功之后的提示,在上文的create.js中最后面有一个downloadSuccessfully的方法,其实就是创建成功后的提示,主要内容如下: const downloadSuccessfully = (projectName) => { const END_MSG = `${chalk.blue("🎉 created project " + chalk.greenBright(projectName) + " Successfully")}\n\n 🙏 Thanks for using wb-cli !`; const BOXEN_CONFIG = { padding: 1, margin: {top: 1, bottom: 1}, borderColor: 'cyan', align: 'center', borderStyle: 'double', title: '🚀 Congratulations', titleAlignment: 'center' } const showEndMessage = () => process.stdout.write(boxen(END_MSG, BOXEN_CONFIG)) showEndMessage(); console.log('👉 Get started with the following commands:'); console.log(`\n\r\r cd ${chalk.cyan(projectName)}`); console.log("\r\r npm install"); console.log("\r\r npm run start \r\n"); }
具体的实现效果就是这样的,这里我是截了之前做好的图。
 项目模板 我们需要创建一个项目模板,里面需要在根目录下包含一个ask.ts文件,其他的就和正常项目一样就好了,aks.ts的文件内容示例如下, /** * demo * aks.ts * @author kechen * @since 2022/3/24 */ module.exports = [ { name: 'description', message: 'Please enter project description:', }, { name: 'author', message: 'Please enter project author:', }, { name: 'apiPrefix', message: 'Please enter project apiPrefix:', default: 'api/1.0', // @ts-ignore validate: function (input) { const done = this.async(); setTimeout(function () { // 校验是否为空,是否是字符串 if (!input.trim()) { done( 'You can provide a apiPrefix, or not it will be default【api/1.0】', ); return; } const pattern = /[a-zA-Z0-9]$/; if (!pattern.test(input.trim())) { done( 'The apiPrefix is must end with letter or number, like default 【api/1.0】', ); return; } done(null, true); }, 300); }, }, { name: 'proxy', message: 'Please enter project proxy:', default: 'https://www.test.com', // @ts-ignore validate: function (input) { const done = this.async(); setTimeout(function () { // 校验是否为空,是否是字符串 if (!input.trim()) { done( 'You can provide a proxy, or not it will be default【https://www.test.com】', ); return; } const pattern = /(http|https):\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-.,@?^=%&:/~+#]*[\w\-@?^=%&/~+#])?/; if (!pattern.test(input.trim())) { done( 'The proxy is must end with letter or number, like default 【https://www.test.com】', ); return; } done(null, true); }, 300); }, }, ];
这里我设置了四个变量分别是description、author、apiPrefix、proxy,在使用时只需要通过<%= var %>这种方式就可以了,var可以是你在ask.ts中设置的任何变量,具体使用demo如下,当然要替换的文件类型必须是在上面我们提到的renderTemplate函数中设置了后缀名的文件才可以。使用这种方式,你就可以在项目模板中自由添加变量,且不需要更新脚手架工具。 { "name": "xasrd-fe-mobile", "description": "<%= description %>", "private": true, "author": "<%= author %>" }
至此,我们的脚手架就全部开发完成啦,接下来就是怎么发布到npm或者npm私服了。 发布 在上面我们讲过,如果需要发布的npm私服,则需要在package.json中配置publishConfig并指向npm私服的地址,发布的时候则需要通过以下命令进行发布: 私服npm发布 - 登陆私服
npm login --registry=http://xxxxx xxxxx为你的私服地址 - 发布
npm publish
官方npm发布 - 直接
npm login,再npm publish - 前提是你的npm源指向的官方npm
通过github action自动触发npm发布
当然需要注意的是,发布的时候,package.json中的version版本号不能重复哈!!! 总结 到这里,我们就完整的开发了一个比较简单前端脚手架工具,并可以发布使用了。其实具体的做法并不是很难,有很多第三方的工具包可以用,当然因为这个工具的交互相对来说比较简单,各位也可以自己奇思妙想,做一些更加花里胡哨的功能进行扩展。示例的demo就不放啦,基本所有的内容都在上面提到了,大家可以自由发挥。当然基于这套我自己也写了一个地址是https://www.npmjs.com/package/wb-fe-cli,不过因为最近实在没时间,所以项目模板还没有,暂时还不能完整的跑起来,后续会慢慢更新的。 参考结语 最后希望看完本篇文章后,对你有所帮助,勤快的话可以自己动手手写一写啦。另外希望大家能够关注一下我的Github,哈哈哈哈,带你们看贪吃蛇! 也可以关注一下GridManager这个好用的表格插件,支持React、Vue,非常好用哦! 下期,我将会给大家带来一些我常用的Mac软件的介绍,能够帮助你在日常开发与工作中大大提升工作效率!!!可以先预览一下 恪晨的Mac软件推荐。 感谢大家的阅读!! |
| XTask与RxJava的使用比较 Posted: 10 Apr 2022 12:44 AM PDT 简介RxJavaRxJava是一个在Java VM上使用可观测的序列来组成异步的、基于事件的程序的库。RxJava本质上是一个实现异步操作的库。 项目地址: https://github.com/ReactiveX/RxJava XTaskXTask是一个拓展性极强的Android任务执行框架。通过它,你可以自由定义和组合任务来实现你想要的功能,尤其适用于处理复杂的业务流程,可灵活添加前置任务或者调整执行顺序。 项目的地址: https://github.com/xuexiangjys/XTask 背景XTask是我基于RxJava的设计思想,并结合实际项目中使用的经验所创造出来的一个开源项目,其目的就是要代替RxJava在Android中的部分使用场景,提升开发的体验和可维护性。 相信使用过RxJava的人都知道RxJava有很多硬伤,下面我哦简单列举几个: - RxJava最初并不是最先在Android中使用的,所以它一开始就设计的相当的复杂且笨重,一个库常常能达到3M左右,相对于移动端而已,这还是非常占应用体积的。
- 远远超过百种的操作符也常常让使用者摸不着头脑,稀里糊涂的使用很容易带来一些致命性的问题,例如内存泄漏等。
- 由于RxJava是一个基于事件的程序库,缺少一些关键执行任务的日志信息,这就导致出了问题后会很难排查出来。
而XTask就是为了能够解决上述问题而被我开源出来的。 使用对比首先,RxJava作为一个优秀的开源框架这点是毋庸置疑的,XTask并不是用来代替RxJava的,我没有这种能力,同样google也没有。 但是在某些小且常用的场景下,我们是完全可以替换掉RxJava的使用的。例如如下两种场景: 下面我就通过两个小例子来给大家呈现它们的不同。 复杂串行任务相信我们在平时的开发过程中一定会遇到很多复杂的业务流程,而这些流程很多都是一环套着一环,需要一步一步走下去才行,中间有任何错误都将停止执行。 下面我就以 [高仿网红产品] 的案例流程为例,简单讲解如何通过RxJava和XTask去实现这一流程。 案例分析高仿网红产品的流程1.获取产品信息 -> 2.查询可生产的工厂 -> 3.联系工厂生产产品 -> 4.送去市场部门评估售价 -> 5.产品上市 实体类设计这里主要涉及3个实体类: Product、ProductInfo和ProductFactory。 /** * 产品 */ public class Product { /** * 产品信息 */ private ProductInfo info; /** * 产品生产地址 */ private String address; /** * 产品价格 */ private String price; /** * 产品发布时间 */ private String publicTime; } /** * 产品信息 */ public class ProductInfo { /** * 编号 */ private String id; /** * 品牌 */ private String brand; /** * 质量 */ private String quality; } /** * 产品工厂 */ public class ProductFactory { /** * 工厂id */ private String id; /** * 工厂地址 */ private String address; }
案例实现业务流程处理上述共有5个业务流程,我们将其简化分为以下4个处理器进行处理。 - 1.获取产品信息: GetProductInfoProcessor (productId -> ProductInfo)
- 2.查找相关的工厂: SearchFactoryProcessor (ProductInfo -> ProductFactory)
- 3.评估产品,给出价格: GivePriceProcessor (Product -> Product)
- 4.产品发布: PublicProductProcessor (Product -> Product)
业务流程串联普通写法我们直接使用接口回调的方式, 一层层执行。 AppExecutors.get().singleIO().execute(() -> { // 1.获取产品信息 new GetProductInfoProcessor(logger, productId).setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<ProductInfo>() { @Override public void onSuccess(final ProductInfo productInfo) { // 2.查询可生产的工厂 new SearchFactoryProcessor(logger, productInfo).setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<ProductFactory>() { @Override public void onSuccess(final ProductFactory factory) { // 3.联系工厂生产产品 log("开始生产产品..."); Product product = factory.produce(productInfo); // 4.送去市场部门评估售价 new GivePriceProcessor(logger, product).setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<Product>() { @Override public void onSuccess(Product product) { // 5.产品上市 PublicProductProcessor publicProductProcessor = new PublicProductProcessor(logger, product); publicProductProcessor.setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<Product>() { @Override public void onSuccess(Product product) { log("总共耗时:" + (System.currentTimeMillis() - startTime) + "ms"); log("仿冒生产网红产品完成, " + product); } }).process(); } }).process(); } }).process(); } }).process(); });
RxJava中执行串行任务,一般使用map或者flatMap,这里由于是一对一,所以使用map执行即可。 disposable = Observable.just(productId) // 1.获取产品信息 .map(id -> new GetProductInfoProcessor(logger, id).process()) // 2.查询可生产的工厂 .map(productInfo -> new Pair<>(new SearchFactoryProcessor(logger, productInfo).process(), productInfo)) .map(productPair -> { // 3.联系工厂生产产品 log("开始生产产品..."); Product product = productPair.first.produce(productPair.second); // 4.送去市场部门评估售价 return new GivePriceProcessor(logger, product).process(); }) // 5.产品上市 .map(product -> new PublicProductProcessor(logger, product).process()) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(product -> { log("总共耗时:" + (System.currentTimeMillis() - startTime) + "ms"); log("仿冒生产网红产品完成, " + product); });
与普通写法和RxJava写法不同的是,XTask是把所有的业务处理器都封装在了一个一个的Task中,然后按任务的执行顺序依次添加对应的Task即可完成。 XTask.getTaskChain() .setTaskParam(TaskParam.get(ProductTaskConstants.KEY_PRODUCT_ID, productId)) // 1.获取产品信息 .addTask(new GetProductInfoTask(logger)) // 2.查询可生产的工厂, 3.联系工厂生产产品 .addTask(new SearchFactoryTask(logger)) // 4.送去市场部门评估售价 .addTask(new GivePriceTask(logger)) // 5.产品上市 .addTask(new PublicProductTask(logger)) .setTaskChainCallback(new TaskChainCallbackAdapter() { @Override public void onTaskChainCompleted(@NonNull ITaskChainEngine engine, @NonNull ITaskResult result) { log("总共耗时:" + (System.currentTimeMillis() - startTime) + "ms"); Product product = result.getDataStore().getObject(ProductTaskConstants.KEY_PRODUCT, Product.class); log("仿冒生产网红产品完成, " + product); } }).start();
案例执行结果

复杂并行任务除了上面我们讨论到的常见串行任务,我们在平时的开发过程中也会遇到一些复杂的并行流程。这些流程往往是单独可执行的,虽说前后关联不大,但是又是同时为了某个目标去执行的流程。 下面我就以常见的 [展示商品详细信息] 的案例流程为例,简单讲解如何通过RxJava和XTask去实现这一流程。 案例分析展示商品详细信息的流程- 1.根据商品的唯一号ID获取商品简要信息
2.获取商品的详细信息: - 2.1 获取商品的生产信息
- 2.2 获取商品的价格信息
- 2.3 获取商品的促销信息
- 2.4 获取商品的富文本信息
- 3.进行商品信息的展示
其中步骤2中的4个子步骤是可以同时进行,互不影响的并发流程。 实体类设计这里主要涉及6个实体类: BriefInfo、Product、FactoryInfo、PriceInfo、PromotionInfo 和 RichInfo。 /** * 产品简要信息 */ public class BriefInfo { private String id; protected String name; private String factoryId; private String priceId; private String promotionId; private String richId; } /** * 产品 */ public class Product extends BriefInfo { /** * 生产信息 */ private FactoryInfo factory; /** * 价格信息 */ private PriceInfo price; /** * 促销信息 */ private PromotionInfo promotion; /** * 富文本信息 */ private RichInfo rich; } /** * 工厂生产信息 */ public class FactoryInfo { private String id; /** * 生产地址 */ private String address; /** * 生产日期 */ private String productDate; /** * 过期日期 */ private String expirationDate; } /** * 价格信息 */ public class PriceInfo { private String id; /** * 出厂价 */ private float factoryPrice; /** * 批发价 */ private float wholesalePrice; /** * 零售价 */ private float retailPrice; } /** * 产品促销信息 */ public class PromotionInfo { private String id; /** * 促销类型 */ private int type; /** * 促销内容 */ private String content; /** * 生效日期 */ private String effectiveDate; /** * 失效日期 */ private String expirationDate; } /** * 富文本信息 */ public class RichInfo { private String id; /** * 描述信息 */ private String description; /** * 图片链接 */ private String imgUrl; /** * 视频链接 */ private String videoUrl; }
案例实现业务流程处理上述共有3个大业务流程,4个子业务流程,我们将其简化分为以下5个处理器进行处理。 - 1.获取商品简要信息: GetBriefInfoProcessor (productId -> BriefInfo)
- 2.获取商品的生产信息: GetFactoryInfoProcessor (factoryId -> FactoryInfo)
- 3.获取商品的价格信息: GetPriceInfoProcessor (priceId -> PriceInfo)
- 4.获取商品的促销信息: GetPromotionInfoProcessor (promotionId -> PromotionInfo)
- 5.获取商品的富文本信息: GetRichInfoProcessor (richId -> RichInfo)
业务流程串联普通写法我们需要通过接口回调+同步锁的方式, 实现任务的并发和协同。 AppExecutors.get().singleIO().execute(() -> { new GetBriefInfoProcessor(logger, productId).setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<BriefInfo>() { @Override public void onSuccess(BriefInfo briefInfo) { final Product product = new Product(briefInfo); CountDownLatch latch = new CountDownLatch(4); // 2.1 获取商品的生产信息 AppExecutors.get().networkIO().execute(() -> { new GetFactoryInfoProcessor(logger, product.getFactoryId()).setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<FactoryInfo>() { @Override public void onSuccess(FactoryInfo result) { product.setFactory(result); latch.countDown(); } }).process(); }); // 2.2 获取商品的价格信息 AppExecutors.get().networkIO().execute(() -> { new GetPriceInfoProcessor(logger, product.getPriceId()).setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<PriceInfo>() { @Override public void onSuccess(PriceInfo result) { product.setPrice(result); latch.countDown(); } }).process(); }); // 2.3 获取商品的促销信息 AppExecutors.get().networkIO().execute(() -> { new GetPromotionInfoProcessor(logger, product.getPromotionId()).setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<PromotionInfo>() { @Override public void onSuccess(PromotionInfo result) { product.setPromotion(result); latch.countDown(); } }).process(); }); // 2.4 获取商品的富文本信息 AppExecutors.get().networkIO().execute(() -> { new GetRichInfoProcessor(logger, product.getRichId()).setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<RichInfo>() { @Override public void onSuccess(RichInfo result) { product.setRich(result); latch.countDown(); } }).process(); }); try { latch.await(); log("总共耗时:" + (System.currentTimeMillis() - startTime) + "ms"); log("查询商品信息完成, " + product); } catch (InterruptedException e) { e.printStackTrace(); } } }).process(); });
RxJava中执行并行任务,一般使用merge或者zip,这里由于需要协同,所以使用zip对任务流进行合并。 disposable = Observable.just(productId) // 1.获取商品简要信息 .map(id -> new GetBriefInfoProcessor(logger, id).process()) .map(Product::new) .flatMap(product -> Observable.zip( // 2.1 获取商品的生产信息 Observable.fromCallable(() -> new GetFactoryInfoProcessor(logger, product.getFactoryId()).process()).subscribeOn(Schedulers.io()), // 2.2 获取商品的价格信息 Observable.fromCallable(() -> new GetPriceInfoProcessor(logger, product.getPriceId()).process()).subscribeOn(Schedulers.io()), // 2.3 获取商品的促销信息 Observable.fromCallable(() -> new GetPromotionInfoProcessor(logger, product.getPromotionId()).process()).subscribeOn(Schedulers.io()), // 2.4 获取商品的富文本信息 Observable.fromCallable(() -> new GetRichInfoProcessor(logger, product.getRichId()).process()).subscribeOn(Schedulers.io()), (factoryInfo, priceInfo, promotionInfo, richInfo) -> product.setFactory(factoryInfo) .setPrice(priceInfo) .setPromotion(promotionInfo) .setRich(richInfo) ) ) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(product -> { log("总共耗时:" + (System.currentTimeMillis() - startTime) + "ms"); log("查询商品信息完成, " + product); });
XTask是把所有的业务处理器都封装在了一个一个的Task中,然后并行的任务需要通过一个ConcurrentGroupTask(同步组任务)进行包裹,其他按正常执行顺序添加Task即可。 XTask.getTaskChain() .setTaskParam(TaskParam.get(ProductTaskConstants.KEY_PRODUCT_ID, productId)) // 1.获取商品简要信息 .addTask(new GetBriefInfoTask(logger)) .addTask(XTask.getConcurrentGroupTask(ThreadType.SYNC) // 2.1 获取商品的生产信息 .addTask(new GetFactoryInfoTask(logger)) // 2.2 获取商品的价格信息 .addTask(new GetPriceInfoTask(logger)) // 2.3 获取商品的促销信息 .addTask(new GetPromotionInfoTask(logger)) // 2.4 获取商品的富文本信息 .addTask(new GetRichInfoTask(logger))) .setTaskChainCallback(new TaskChainCallbackAdapter() { @Override public void onTaskChainCompleted(@NonNull ITaskChainEngine engine, @NonNull ITaskResult result) { log("总共耗时:" + (System.currentTimeMillis() - startTime) + "ms"); Product product = result.getDataStore().getObject(ProductTaskConstants.KEY_PRODUCT, Product.class); log("查询商品信息完成, " + product); } }).start();
案例执行结果

使用对比总结从上面的使用对比来看,我们可以简单归纳总结以下几点: 编程方式1.RxJava遵循的是函数响应式编程的原则,处理过程都是基于数据流的处理。这样的好处就是,我们可以最直观有效的感受到数据的变化过程,当然缺点就是太过于细化和具体,不符合面向对象的设计模式原则,增加了日后的代码维护成本。当然如果数据的结构相对稳定的话,这样的编程方式还可以接受,但如果数据或者业务频繁发生变动的话,这样的编程方式简直就是地狱。 2.XTask遵循的是面向对象的编程原则,每个处理过程都对应了一个具体或者抽象的Task。这样的好处就是,减少了业务和数据结构之间的耦合,同时也减少了各个业务之间的耦合。这样即使你的数据结构或者业务流程出现大的变动,功能实现的主体也不会产生大的改动,更多的只是每个子业务Task内部的改动和调整,真正实现了高复用低耦合。 总结: 两种不同的编程方式,遵循两种不同的编程原则,无法进行对比。 上手难度如果你是一名RxJava的开发老鸟的话,这样就没什么可比性了,这里我只是从初学者的角度来说。 1.RxJava拥有庞大复杂的操作符,上百种操作符一定会让初学者摸不着头脑,如果在不熟悉的情况下强行使用,很容易导致误用而产生很多意想不到的问题(比如内存泄漏或者OOM等)。 2.XTask作为专为Android设计的任务执行框架,功能相对单一。没有复杂的操作符,有的只是"任务链、任务、组任务、任务参数和执行结果"这五个组成要素,使用起来相对简单容易上手。 总结: 整体比较下来,XTask要优于RxJava。 开发效率1.RxJava的开发效率主要取决于开发者对RxJava操作符使用的熟练程度。越是能够熟练使用操作符,开发效率就越高,出问题的概率也越小。 2.XTask相对而言就平滑了许多,开发效率和使用的熟练程度关系不大(主要还是上手难度不高)。但是由于每个业务子步骤都需要写一个Task类,对于那些使用RxJava比较熟练的人而言,效率是明显会低一些。 总结: 整体比较下来,从长期而言,RxJava要优于XTask。 可维护性1.RxJava遵循的是函数响应式编程的原则,本质上还是面向过程式的编程。所有的业务流程都和数据有着比较强的耦合,当数据结构或者业务流程发生变动的时候,必然会影响到主干代码的变动。而且对于初入项目的开发人员接手项目的时候,能看到的往往是局部业务数据流的变动,无法从全局的视角去理解项目主体业务,很容易产生局部修改影响全局的结果。 2.XTask遵循的是面向对象的编程原则,设计之初就严格遵循面向对象的设计模式原则。充分减少业务与业务、业务与数据流之间的耦合,这样即使你的数据结构或者业务流程出现重大的变化,主干代码也不会有很大的变动。而且XTask拥有较强的日志记录系统,能够非常清晰的记录你当前任务链的执行过程和所在线程的信息(自动的),当任务执行出现问题的时候,便能很快地定位出问题产生的位置。而对于初入项目的开发人员来说,也能快速从任务执行过程的日志中去理解项目的主体业务。待主体业务流程有了清楚的认知后再去仔细看子业务,这样才能全方位理解项目的业务,也更利于项目的维护。 总结: 整体比较下来,XTask完胜RxJava。 性能在性能上,XTask为了实现业务与数据之间的隔离,设计了共享数据的结构,相比较RxJava而言,多了数据拷贝以及数据存储的过程,所以无论是在时间还是空间上而言,RxJava都是较优于XTask的。 最后综合以上的论述,XTask和RxJava各有各的优势。正如我文章开头所说: XTask并不是用来代替RxJava的。XTask只是作为RxJava在Android任务执行流程上的一种补充,喜欢的朋友可以关注XTask的项目主页: https://github.com/xuexiangjys/XTask。 我是xuexiangjys,一枚热爱学习,爱好编程,致力于Android架构研究以及开源项目经验分享的技术up主。获取更多资讯,欢迎微信搜索公众号:【我的Android开源之旅】 |
No comments:
Post a Comment