从模块流可以看出,这个NodeWatchFileSystem模块非常深,这里暂时不会深入到chokidar模块,有点太偏离本系列文章了,从WatcherManager开始讲解。

  流程如图:

  源码非常简单,包括一个工厂函数与两个原型方法,整理如下:

var path = require("path");

class WatcherManager {
constructor() {
// 监视容器
this.directoryWatchers = {};
};
getDirectoryWatcher(directory, options) {
var DirectoryWatcher = require("./DirectoryWatcher");
options = options || {};
// 目录路径拼接参数 这个有够厉害的
// 假设directory为lib options不传 拼接后为'lib {}'
var key = directory + " " + JSON.stringify(options);
if (!this.directoryWatchers[key]) {
// 根据监视路径生成一个DirectoryWatcher实例
this.directoryWatchers[key] = new DirectoryWatcher(directory, options);
// 监听监视关闭事件
this.directoryWatchers[key].on("closed", function() {
delete this.directoryWatchers[key];
}.bind(this));
}
// 返回对应的实体类
return this.directoryWatchers[key];
};
// 路径 参数 开始事件
watchFile(p, options, startTime) {
// 返回目录名作为根目录
// lib/newFile.js => lib
var directory = path.dirname(p);
// 生成实例并调用watch方法
// 由于上面返回的是实体类 这里可以进行链式调用
return this.getDirectoryWatcher(directory, options).watch(p, startTime);
};
watchDirectory(directory, options, startTime) {
return this.getDirectoryWatcher(directory, options).watch(directory, startTime);
};
} module.exports = new WatcherManager();

  包含一个容器类和三个实例方法,每一次调用watchFile或watchDirectory方法时会在容器中添加一个目录监视信息,在关闭监视事会删除对应的信息。

  主流方法还是引用的DirectoryWatcher模块,从构造函数开始详细看源码:

function DirectoryWatcher(directoryPath, options) {
// 继承EventEmitter
EventEmitter.call(this);
// 获取配置
this.options = options;
// 根目录
this.path = directoryPath;
// 根目录下的文件信息
this.files = Object.create(null);
// 根目录下的文件夹信息
this.directories = Object.create(null);
// 目录下的文件所有监听器容器
this.watchers = Object.create(null);
// 初始化监视器 跳过
this.watcher = chokidar.watch(directoryPath, { /*options*/ });
// 事件监听
this.watcher.on("add", this.onFileAdded.bind(this));
this.watcher.on("addDir", this.onDirectoryAdded.bind(this));
this.watcher.on("change", this.onChange.bind(this));
this.watcher.on("unlink", this.onFileUnlinked.bind(this));
this.watcher.on("unlinkDir", this.onDirectoryUnlinked.bind(this));
this.watcher.on("error", this.onWatcherError.bind(this));
// 初次扫描标记
this.initialScan = true;
// 对整个文件夹进行监视 仅在传入监视路径为文件夹时置true
this.nestedWatching = false;
this.initialScanRemoved = [];
// 初始化扫描
this.doInitialScan();
// 记录watchers中监听器数量
this.refs = 0;
}

  这里可以分为几块内容:

1、继承nodejs的事件模块

2、获取传进来的路径与配置参数

3、根据参数初始化一个watcher对象,并对文件操作做事件监听

4、初始化扫描

  watcher对象的生成过程暂时不考虑,太深入会偏离主线任务。

初始化扫描

  在构造函数中会对传进来的路径进行扫描,源码如下:

DirectoryWatcher.prototype.doInitialScan = function doInitialScan() {
// 读取根目录
fs.readdir(this.path, function(err, items) {
// 即使报错仍然置initialScan标记为false
if (err) {
this.initialScan = false;
return;
}
// items为到根目录下所有文件的文件名组成的数组
// 同时包含文件与文件夹
async.forEach(items, function(item, callback) {
// 将路径与文件名进行拼接获取完整路径
var itemPath = path.join(this.path, item);
// 获取文件信息
fs.stat(itemPath, function(err2, stat) {
// 该方法仅支持初次扫描
if (!this.initialScan) return;
if (err2) {
callback();
return;
}
// 处理文件
if (stat.isFile()) {
if (!this.files[itemPath])
this.setFileTime(itemPath, +stat.mtime, true);
}
// 处理文件夹
else if (stat.isDirectory()) {
if (!this.directories[itemPath])
this.setDirectory(itemPath, true, true);
}
callback();
}.bind(this));
}.bind(this), function() {
// 回调函数中处理标记initialScan标记
this.initialScan = false;
this.initialScanRemoved = null;
}.bind(this));
}.bind(this));
};

  代码十分易懂,基本上都是fs模块的方法,主要分为以下几步:

1、读取指定根目录下所有文件

2、将文件名与当前路径进行拼接获取完整路径,然后尝试获取文件信息

3、分别处理文件与文件夹

  这里的流程可以用一个案例测试,首先目录如图:

  a.js是执行JS文件,lib是用来测试的文件夹,包含几个js文件和一个空文件夹。

  测试代码如下:

// a.js
const fs = require('fs');
const async = require('async');
const path = require('path'); // 读取文件夹
fs.readdir('./lib', (err, items) => {
// 这里没有传路径 所以用process.cwd()模拟
// 这里需要拼接一下路径
const absPath = path.join(process.cwd(), 'lib');
// items => ['DirectoryWatcher.js','fileDirectory',...,'watchpack.js']
async.forEach(items, (item, callback) => {
// 第一个元素拼接后为d:\workspace\doc\lib\DirectoryWatcher.js
const itemPath = path.join(absPath, item);
fs.stat(itemPath, (err2, stat) => {
// 处理文件
if (stat.isFile()) {
console.log('Find file,the name is: ' + item);
}
// 处理文件夹
else if (stat.isDirectory()) {
console.log('Find directory,the name is: ' + item);
}
});
});
});

  执行JS文件后输出如图:

  可以看到,通过该方法可以区别开文件与文件夹,然后分类处理。

  下面看两种处理方法。

setFileTime

// this.setFileTime(itemPath, +stat.mtime, true);
// itemPath => 路径
// +stat.mtime => 修改时间
// 是否初始化 => true
DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
// 获取当前时间
var now = Date.now();
var old = this.files[filePath];
// 初始化取文件修改时间与当前时间的较小值
// 否则files = {path:[now,mtime]}
// 键为文件路径 值为数组 包含当前时间与上一次修改时间
this.files[filePath] = [initial ? Math.min(now, mtime) : now, mtime];
// 这里的FS_ACCURACY是假设操作可能的运行时间
// 尝试通过加一点点来更精确修改时间
if (mtime)
mtime = mtime + FS_ACCURACY;
if (!old) {
if (mtime) {
if (this.watchers[withoutCase(filePath)]) { /**/ }
}
} else if (!initial && mtime && type !== "add") {
/**/
} else if (!initial && !mtime) { /**/ }
// 初始化不会有watchers
if (this.watchers[withoutCase(this.path)]) { /**/ }
};

  从名字也能看出这个方法的作用就是设置时间,在初始化的情况下,会在files容器中注册,键为文件路径,值为当前时间与修改时间。

  由于watchers对象此时为null,所以后面的代码并不会进入,后面再讨论。

setDirectory

// this.setDirectory(itemPath, true, true);
DirectoryWatcher.prototype.setDirectory = function setDirectory(directoryPath, exist, initial, type) {
if (directoryPath === this.path) {
if (!initial && this.watchers[withoutCase(this.path)]) { /**/ }
} else {
var old = this.directories[directoryPath];
// 初次扫描
if (!old) {
if (exist) {
// 默认为false
if (this.nestedWatching) {
this.createNestedWatcher(directoryPath);
} else {
// 根目录在监听器容器中的值默认设置为true
this.directories[directoryPath] = true;
}
if (!initial && this.watchers[withoutCase(this.path)]) { /**/ }
}
} else { /**/ }
}
};

  在初始化的扫描中,根目录下所有的文件夹也会在对应的容器中注册一个键,值为true。

  其余代码在初始化并不会执行,后面再讲。

  在经过doInitialScan初始化之后,files、directories容器会被填充进对应的键值对,存储文件与文件夹的路径信息。

watch

  无论是watchFile还是watchDirectory都在初始化后直接调用了watch方法对具体文件进行了监视,这里分析该处源码:

DirectoryWatcher.prototype.watch = function watch(filePath, startTime) {
// 将路径小写
// 第一次监视指定路径会初始化一个空数组
this.watchers[withoutCase(filePath)] = this.watchers[withoutCase(filePath)] || [];
// 记数
this.refs++;
// 生成一个内部辅助类
var watcher = new Watcher(this, filePath, startTime);
// 监听closed事件
watcher.on("closed", function() {
// 删除对应的watcher
var idx = this.watchers[withoutCase(filePath)].indexOf(watcher);
this.watchers[withoutCase(filePath)].splice(idx, 1);
// 当对应watcher数组为空时直接删除该键
if (this.watchers[withoutCase(filePath)].length === 0) {
delete this.watchers[withoutCase(filePath)];
// 如果触发了文件夹的closed事件 关闭文件夹的监视
if (this.path === filePath)
this.setNestedWatching(false);
}
// 当watchers为空时调用类的close方法
if (--this.refs <= 0)
this.close();
}.bind(this));
// 加进去
this.watchers[withoutCase(filePath)].push(watcher);
var data;
// 当监视文件路径为一个文件夹时
// 文件夹的修改时间应该为内部文件中修改时间最新的
if (filePath === this.path) {
this.setNestedWatching(true);
data = false;
// 取出所有文件的时间信息中最新的
Object.keys(this.files).forEach(function(file) {
var d = this.files[file];
if (!data)
data = d;
else
data = [Math.max(data[0], d[0]), Math.max(data[1], d[1])];
}, this);
}
// 取对应文件信息
else {
data = this.files[filePath];
}
// node中的异步函数
process.nextTick(function() {
if (data) {
// 相等说明是初始化阶段 修正时间
var ts = data[0] === data[1] ? data[0] + FS_ACCURACY : data[0];
if (ts >= startTime)
watcher.emit("change", data[1]);
}
// 监视的文件路径之前被移除过
else if (this.initialScan && this.initialScanRemoved.indexOf(filePath) >= 0) {
watcher.emit("remove");
}
}.bind(this));
return watcher;
}; class Watcher {
constructor() {
EventEmitter.call(this);
this.directoryWatcher = directoryWatcher;
this.path = filePath;
this.startTime = startTime && +startTime;
this.data = 0;
};
// 也不知道检测啥的
checkStartTime(mtime, initial) {
if (typeof this.startTime !== "number") return !initial;
var startTime = this.startTime;
return startTime <= mtime;
};
// 此方法触发closed事件
close() {
this.emit("closed");
};
}

  内部的Watcher对象负责对应路径文件的操作事件响应。

  watch有两种情形,一种是普通的文件监视,一种是对文件夹的监视。

  如果是普通的文件监视,直接生成一个Watcher监听器,然后将该监听器加入已有目录监视容器对应的watchers容器中。

  如果是传入的是文件夹,会对根目录下所有的文件夹再次调用watchDirectory收集目录信息,代码如下:

DirectoryWatcher.prototype.setNestedWatching = function(flag) {
if (this.nestedWatching !== !!flag) {
this.nestedWatching = !!flag;
if (this.nestedWatching) {
Object.keys(this.directories).forEach(function(directory) {
// 对根目录下所有文件夹路径调用该方法
this.createNestedWatcher(directory);
}, this);
} else {
// 关闭文件夹监视
Object.keys(this.directories).forEach(function(directory) {
this.directories[directory].close();
this.directories[directory] = true;
}, this);
}
}
}; DirectoryWatcher.prototype.createNestedWatcher = function(directoryPath) {
// 文件夹信息容器的值设为一个DirectoryWatcher实例
// startTime设为1
this.directories[directoryPath] = watcherManager.watchDirectory(directoryPath, this.options, 1);
// 实例监听change事件
this.directories[directoryPath].on("change", function(filePath, mtime, type) {
// 文件夹改变时触发对应的监听器
if (this.watchers[withoutCase(this.path)]) {
this.watchers[withoutCase(this.path)].forEach(function(w) {
if (w.checkStartTime(mtime, false)) {
w.emit("change", filePath, mtime, type);
}
});
}
}.bind(this));
};

fs.watch

  下面开始讲解文件操时作触发的事件处理,其中包括文件与文件夹的操作。

  先简要介绍下nodejs原生的watch系统,官方文档:https://nodejs.org/dist/latest-v8.x/docs/api/fs.html#fs_fs_watch_filename_options_listener。

  通过引入nodejs中的fs模块,通过调用fs.watch方法可以对文件进行监视,具体的api如下:

const fs = reqire('fs');
fs.watch(filename /*文件名*/ , options /*配置参数 可忽略*/ , listener /*监听器*/ )

  这里的filename可以是文件,也可以是一个目录。

  options有三个可选参数:

persistent:文件如果在被监视,进程是否应该继续进行,默认为true

recursive:是否监视所有子目录,默认为false

encoding:指定传给监听器文件名的字符编码,默认为'uft-8'

  监听器则是一个函数,有两个参数,分别为事件类型与对应的文件名。

  这里用了小案例来进行演示,代码如下:

const fs = require('fs');
fs.watch('./lib', ((event, filename) => {
console.log('event type is: ' + event);
console.log('the relative filename is: ' + filename);
}));

  目录结构可参考上图,执行node指令后终端会被挂起,等待变化。

  此时新建一个文件,如图:

  在新建成功的时候,会发现监听器被触发,打印信息如图:

  修改文件内容,打印信息如图:

  根据官方文档,事件只有rename与change两种,无论是添加、删除、重命名都会触发rename事件,而修改文件内容会触发change事件。

  所以很明显,框架内部对事件类型进行了细粒度更大的划分,将rename分解为增加文件/文件夹,删除文件/文件夹四种情况。

  实现的原理根据上面的代码也很容易想到,可以根据文件名与files、directories容器中的键做比对,区分文件与文件夹,根据修改时间,区分是新建还是删除。

  下面可以看构造函数中对特殊文件操作的监听器。

add

// 增加文件时触发的事件
this.watcher.on("add", this.onFileAdded.bind(this)); DirectoryWatcher.prototype.onFileAdded = function onFileAdded(filePath, stat) {
// filePath => 文件路径
// stat => fs.stat(...)
// 检测文件是否在监视目录中
if (filePath.indexOf(this.path) !== 0) return;
if (/[\\\/]/.test(filePath.substr(this.path.length + 1))) return;
// 设置文件修改时间信息
this.setFileTime(filePath, +stat.mtime, false, "add");
};

  可以看出,进行简单的文件合法性检测后,还是进入了setFileTime函数,不过这一次的init标记为false,并且有对应的eventType。

  这一次setFileTime的流程如下:

DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
var now = Date.now();
// 初始化的值会被获取
var old = this.files[filePath];
// initial是false 所以值为[now,mtime]
this.files[filePath] = [initial ? Math.min(now, mtime) : now, mtime]; // ...
};

  一句话概括就是,add情况下,只会在files容器中注册该文件的信息。

addDir => 在directories容器中注册该文件夹

change

DirectoryWatcher.prototype.onChange = function onChange(filePath, stat) {
// ...
// 会根据mtime值修改FS_ACCURACY
ensureFsAccuracy(mtime);
// 仍然进入此函数
this.setFileTime(filePath, mtime, false, "change");
}; function ensureFsAccuracy(mtime) {
if (!mtime) return;
// 当mtime为小数时才会跳过
if (FS_ACCURACY > 1 && mtime % 1 !== 0)
FS_ACCURACY = 1;
// 0-9或非10的倍数
else if (FS_ACCURACY > 10 && mtime % 10 !== 0)
FS_ACCURACY = 10;
// 0-99或非100倍数
else if (FS_ACCURACY > 100 && mtime % 100 !== 0)
FS_ACCURACY = 100;
else if (FS_ACCURACY > 1000 && mtime % 1000 !== 0)
FS_ACCURACY = 1000;
else if (FS_ACCURACY > 2000 && mtime % 2000 !== 0)
FS_ACCURACY = 2000;
} DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
// ...
if (!old) { /*...*/ }
// change
else if (!initial && mtime && type !== "add") {
if (this.watchers[withoutCase(filePath)]) {
this.watchers[withoutCase(filePath)].forEach(function(w) {
w.emit("change", mtime, type);
});
}
}
// remove
else if (!initial && !mtime) { /*...*/ }
// 如果监视了根目录
if (this.watchers[withoutCase(this.path)]) {
this.watchers[withoutCase(this.path)].forEach(function(w) {
// 根目录触发change事件
if (!initial || w.checkStartTime(mtime, initial)) {
w.emit("change", filePath, mtime, initial ? "initial" : type);
}
});
}
};

  这里有一个ensureFsAccuracy函数,这里默认的FS_ACCURACY为10000,而mtime一般都是很大的整数,所以这个函数的作用有待研究。

  可以看到change事件除了设置文件的时间信息,同时也对watchers中每个监听器触发了change事件。

  最后,如果根目录设置了监视,由于监视文件在根目录中,所以根目录必定也发生了改变,所以根目录的所有监视器也会同时触发change事件。

unlink

DirectoryWatcher.prototype.onFileUnlinked = function onFileUnlinked(filePath) {
// ...
// 注意第二个参数mtime为null
this.setFileTime(filePath, null, false, "unlink");
// 记录被删除的文件路径
if (this.initialScan) {
this.initialScanRemoved.push(filePath);
}
}; DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
// ...
if (!old) { /**/ }
// 触发remove事件
else if (!initial && mtime && type !== "add") {
if (this.watchers[withoutCase(filePath)]) {
this.watchers[withoutCase(filePath)].forEach(function(w) {
w.emit("change", mtime, type);
});
}
} else if (!initial && !mtime) { /**/ }
if (this.watchers[withoutCase(this.path)]) { /**/ }
};

  当删除文件时,传入的mtime会置null,所以会对所有的watcher触发remove。

  另外,这里被删除的文件路径会被记录到initialScan中。

unlinkDir

DirectoryWatcher.prototype.onDirectoryUnlinked = function onDirectoryUnlinked(directoryPath) {
// ...
// 这里调用文件夹的删除
this.setDirectory(directoryPath, false, false, "unlink");
if (this.initialScan) {
this.initialScanRemoved.push(directoryPath);
}
}; DirectoryWatcher.prototype.setDirectory = function setDirectory(directoryPath, exist, initial, type) {
if (directoryPath === this.path) { /**/ }
// 删除文件夹
else {
var old = this.directories[directoryPath];
if (!old) { /**/ }
else {
if (!exist) {
if (this.nestedWatching)
this.directories[directoryPath].close();
// 删除容器中的文件夹信息
delete this.directories[directoryPath];
if (!initial && this.watchers[withoutCase(this.path)]) { /*...*/ }
}
}
}
};

  在nestedWatching参数为false的情况下,这里是直接从文件夹信息容器中删除对应信息,否则会调用watcher对应的close方法。

error

DirectoryWatcher.prototype.onWatcherError = function onWatcherError( /* err */ ) {};

  源码中,这个事件监听器并没有任何内容,需要自定义。

  由于这节内容比较多,这里做一个简单的内容总结,也帮助自己复习:

  watcherManager模块

1、有一个directoryWatchers容器保存已监视目录信息

2、getDirectoryWatcher方法会根据监视路径与options参数生成容器键,如果存在对应的值直接返回,否则新建一个DirectoryWatcher实体类注册到容器中,并监听closed事件,触发时自动删除该键

3、WatchFile、WatchDirectory分别处理文件、文件夹的监视,会同时调用getDirectoryWatcher方法与返回实体类的watch方法

4、在WatchFile中,监视文件所在的文件夹会作为根目录传入实例化的参数中,且只会监视根目录的该文件

5、若传入文件夹,则该文件夹目录下所有文件夹会被嵌套调用watchDirectory并将数据传入directories容器中,键为路径,值为一个新的DirectoryWatcher对象

  DirectoryWatcher模块

1、包含多个容器,分别为:

files:保存根目录下所有文件信息

directories:保存根目录下所有文件夹信息

initialScanRemoved:记录已被删除的文件或文件夹路径

watchers:指定目录下监听器容器,其中键为监视文件的路径,值为监听器

2、内部细分了原生nodejs的rename、change事件,分别为add、addDir、change、unlink、unlinkDir

3、触发了对应路径文件的事件,会依次触发watchers中对应路径数组中所有监听器

  完结!  

.13-浅析webpack源码之WatcherManager模块的更多相关文章

  1. .6-浅析webpack源码之validateSchema模块

    validateSchema模块 首先来看错误检测: const webpackOptionsValidationErrors = validateSchema(webpackOptionsSchem ...

  2. .14-浅析webpack源码之Watchpack模块

    解决掉了最头疼的DirectoryWatcher内部实现,这一节可以结束NodeWatchFileSystem模块. 关于watch的应用场景,仔细思考了下,这不就是热重载的核心嘛. 首先是监视文件, ...

  3. .12-浅析webpack源码之NodeWatchFileSystem模块总览

    剩下一个watch模块,这个模块比较深,先大概过一下整体涉及内容再分部讲解. 流程图如下: NodeWatchFileSystem const Watchpack = require("wa ...

  4. .4-浅析webpack源码之convert-argv模块

    上一节看了一眼预编译的总体代码,这一节分析convert-argv模块. 这个模块主要是对命令参数的解析,也是yargs框架的核心用处. 生成默认配置文件名数组 module.exports = fu ...

  5. .9-浅析webpack源码之NodeEnvironmentPlugin模块总览

    介绍Compiler的构造比较无趣,不如先过后面的,在用到compiler的时候再做讲解. 这一节主要讲这行代码: // 不管这里 compiler = new Compiler(); compile ...

  6. .15-浅析webpack源码之WebpackOptionsApply模块-plugin事件流总览

    总体过了一下后面的流程,发现Compiler模块确实不适合单独讲解,这里继续讲解后面的代码: compiler.options = new WebpackOptionsApply().process( ...

  7. .11-浅析webpack源码之Storage模块

    至此已完成NodeJsInputFileSysten模块的讲解,下一步就是实际实用的模块: compiler.inputFileSystem = new CachedInputFileSystem(n ...

  8. .10-浅析webpack源码之graceful-fs模块

    在cachedInput.output.watch三大文件系统中,output非常简单,没有必要讲,其余两个模块依赖于input模块,而input主要是引用了graceful-fs的部分API,所以这 ...

  9. .7-浅析webpack源码之WebpackOptionsDefaulter模块

    WebpackOptionsDefaulter模块 通过参数检测后,会根据单/多配置进行处理,本文基于单配置,所以会进行到如下代码: if (Array.isArray(options)) { com ...

随机推荐

  1. javaScrpit 开端

    JavaScript 代码可以直接嵌在网页的任何地方,不过我们通常把JavaScrpit放到<head>中: <html> <head> <script> ...

  2. Java:foreach实现原理

    第一部分: For-each Loop Purpose The basic for loop was extended in Java5 to make iteration over arrays a ...

  3. 地址栏的路由输入不匹配时候,设置默认跳转页面(redirect)

    如果输入正确的路由,就会显示正确的页面. 如果输入错误的路由 ,则可以配置跳转到指定的页面. { redirect:"/', path:"*" ; }

  4. h5移动网页唤起App

    最近这个困惑了很久,不断的有一些坑,目前还有疑问关于iOS唤起无效时会出现弹框的问题,这个最后再说 1.首先可能需要判断当前浏览器的来源(目前开发的App还没有上架,所以针对腾讯出品的大家广为人知的微 ...

  5. shp文件和地理数据库文件的区别

    存储文件结构不同.所能进行的计算也不同. https://blog.csdn.net/lucahan/article/details/51761610 对数据库操作更快更方便,如何证明?尤其是数据量比 ...

  6. js实现粒子特效,particles.js的使用

    今天偶然看到了一个比较炫酷的js网页.是粒子特效的,就试着用了用.一下是步骤,方便以后查看使用. 1.在网站下载源码https://github.com/VincentGarreau/particle ...

  7. java实现随机产生6位数的方法总结

    package com.yin.test; import java.util.Random; import org.junit.Test; /** * @author v_yinyl * @date ...

  8. Navicat for MYSQL 断网时本地连接无法打开,2005错误

    Navicat for MYSQL 断网时本地连接无法打开,2005错误 NO1 提示下图: NO2 解决方法: (1)选中本地连接,右键 连接属性 (2) 将 主机名或IP地址 这一栏改为 127. ...

  9. 团队作业第五周(HCL盐酸队)

    一.Alpha版本测试报告 1.测试计划 测试项目 上下移动   左右移动   发射子弹   与敌方坦克进行攻击 2.测试过程 测试截图 错误记录(提交issues到码云团队项目) 3.测试找出的bu ...

  10. 背水一战 Windows 10 (81) - 全球化

    [源码下载] 背水一战 Windows 10 (81) - 全球化 作者:webabcd 介绍背水一战 Windows 10 之 全球化 Demo 格式化数字 示例1.演示全球化的基本应用Locali ...